diff --git a/.claude/settings.json b/.claude/settings.json index f5ac854fc..00bb06ad6 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "FILE=$(jq -r '.tool_input.file_path') && case \"$FILE\" in *.rs) cargo fmt -- \"$FILE\" ;; esac" + "command": "FILE=$(jq -r '.tool_input.file_path') && case \"$FILE\" in *.rs) cargo +nightly fmt -- \"$FILE\" ;; esac" } ] } diff --git a/.claude/skills/changelog/watermark b/.claude/skills/changelog/watermark index a443028b4..01c80ce3d 100644 --- a/.claude/skills/changelog/watermark +++ b/.claude/skills/changelog/watermark @@ -1 +1 @@ -ed651dcd571699e92162cf657b8eca42d7a6d6b1 +6c53fc29b5f1309bfd026a883a23006db2bb441c diff --git a/.claude/skills/docs/watermark b/.claude/skills/docs/watermark index 54c7dee68..01c80ce3d 100644 --- a/.claude/skills/docs/watermark +++ b/.claude/skills/docs/watermark @@ -1 +1 @@ -1afa8419b53670f95c5d6a041cfd373c4a8c9371 +6c53fc29b5f1309bfd026a883a23006db2bb441c diff --git a/.config/nextest.toml b/.config/nextest.toml index ba1335d78..908d600c1 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -1,11 +1,25 @@ [profile.default] -# Unit tests: flag SLOW after 2s, hard-kill after 4s -slow-timeout = { period = "3s", terminate-after = 2 } +# Default-profile tests: flag SLOW after 1s, hard-kill after 3s +slow-timeout = { period = "1s", terminate-after = 3 } +leak-timeout = "500ms" -[[profile.default.overrides]] -filter = "package(fabro-cli) & kind(test)" -slow-timeout = { period = "5s", terminate-after = 4 } + [[profile.default.overrides]] + filter = "package(fabro-cli) & kind(test)" + slow-timeout = { period = "3s", terminate-after = 4 } + + [[profile.default.overrides]] + filter = "package(fabro-server) & kind(test)" + slow-timeout = { period = "5s", terminate-after = 4 } + + [[profile.default.overrides]] + filter = "package(fabro-workflow) & kind(test)" + slow-timeout = { period = "2s", terminate-after = 3 } + + [[profile.default.overrides]] + filter = "package(twin-openai) & test(debug_page_renders_in_headless_chrome)" + slow-timeout = { period = "30s", terminate-after = 1 } [profile.e2e] # E2E (ignored) tests: flag SLOW after 10s, hard-kill after 30s slow-timeout = { period = "10s", terminate-after = 3 } +leak-timeout = "500ms" diff --git a/fabro.toml b/.fabro/project.toml similarity index 85% rename from fabro.toml rename to .fabro/project.toml index e823e6866..24f0d8384 100644 --- a/fabro.toml +++ b/.fabro/project.toml @@ -1,29 +1,23 @@ -version = 1 +_version = 1 -[fabro] -root = "fabro/" - -[features] -retros = false - -[pull_request] +[run.pull_request] enabled = true draft = false -[sandbox] +[run.sandbox] provider = "daytona" -[sandbox.daytona] +[run.sandbox.daytona] auto_stop_interval = 30 -[sandbox.daytona.labels] +[run.sandbox.daytona.labels] repo = "fabro-sh/fabro" -[sandbox.daytona.snapshot] +[run.sandbox.daytona.snapshot] name = "fabro-v6" cpu = 4 -memory = 8 -disk = 20 +memory = "8GB" +disk = "20GB" dockerfile = """ FROM ubuntu:24.04 @@ -52,9 +46,10 @@ ENV PATH="/root/.bun/bin:${PATH}" WORKDIR /root """ -[[hooks]] +[[run.hooks]] +id = "cargo-fmt" name = "cargo-fmt" event = "post_tool_use" matcher = "write_file|edit_file|apply_patch" -command = "cargo fmt" +script = "cargo fmt" blocking = true diff --git a/fabro/workflows/gh-triage/workflow.fabro b/.fabro/workflows/gh-triage/workflow.fabro similarity index 100% rename from fabro/workflows/gh-triage/workflow.fabro rename to .fabro/workflows/gh-triage/workflow.fabro diff --git a/.fabro/workflows/gh-triage/workflow.toml b/.fabro/workflows/gh-triage/workflow.toml new file mode 100644 index 000000000..8fafbcab1 --- /dev/null +++ b/.fabro/workflows/gh-triage/workflow.toml @@ -0,0 +1,7 @@ +_version = 1 + +[server.integrations.github] + +[server.integrations.github.permissions] +pull_requests = "read" +issues = "read" diff --git a/fabro/workflows/implement-issue/workflow.fabro b/.fabro/workflows/implement-issue/workflow.fabro similarity index 100% rename from fabro/workflows/implement-issue/workflow.fabro rename to .fabro/workflows/implement-issue/workflow.fabro diff --git a/.fabro/workflows/implement-issue/workflow.toml b/.fabro/workflows/implement-issue/workflow.toml new file mode 100644 index 000000000..2a0f45f16 --- /dev/null +++ b/.fabro/workflows/implement-issue/workflow.toml @@ -0,0 +1,7 @@ +_version = 1 + +[server.integrations.github] + +[server.integrations.github.permissions] +issues = "read" +pull_requests = "write" diff --git a/fabro/workflows/implement-plan/workflow.fabro b/.fabro/workflows/implement-plan/workflow.fabro similarity index 100% rename from fabro/workflows/implement-plan/workflow.fabro rename to .fabro/workflows/implement-plan/workflow.fabro diff --git a/.fabro/workflows/implement-plan/workflow.toml b/.fabro/workflows/implement-plan/workflow.toml new file mode 100644 index 000000000..9e79c2378 --- /dev/null +++ b/.fabro/workflows/implement-plan/workflow.toml @@ -0,0 +1 @@ +_version = 1 diff --git a/fabro/workflows/smoke/workflow.fabro b/.fabro/workflows/smoke/workflow.fabro similarity index 81% rename from fabro/workflows/smoke/workflow.fabro rename to .fabro/workflows/smoke/workflow.fabro index ccf76317f..75d9be4a0 100644 --- a/fabro/workflows/smoke/workflow.fabro +++ b/.fabro/workflows/smoke/workflow.fabro @@ -1,5 +1,5 @@ digraph Smoke { - graph [goal="Verify the sandbox can lint and test the project"] + graph [goal="Verify the sandbox can lint and test the project", retry_target=exit] rankdir=LR start [shape=Mdiamond, label="Start"] @@ -8,8 +8,8 @@ digraph Smoke { toolchain [label="Toolchain", shape=parallelogram, script="rustc --version && cargo --version && bun --version 2>&1", goal_gate=true] compile_rust [label="Compile Rust", shape=parallelogram, script="cargo check -q --workspace 2>&1", goal_gate=true] compile_typescript [label="Compile TypeScript", shape=parallelogram, script="cd apps/fabro-web && bun install && bun run typecheck 2>&1", goal_gate=true] - lint_rust [label="Lint Rust", shape=parallelogram, script="cargo fmt --check --all 2>&1 && cargo clippy -q --workspace -- -D warnings 2>&1", goal_gate=true] - test_rust [label="Test Rust", shape=parallelogram, script="cargo nextest run --cargo-quiet --workspace --status-level fail 2>&1", goal_gate=true] + lint_rust [label="Lint Rust", shape=parallelogram, script="cargo +nightly fmt --check --all 2>&1 && cargo clippy -q --workspace -- -D warnings 2>&1", goal_gate=true] + test_rust [label="Test Rust", shape=parallelogram, script="ulimit -n 4096 && cargo nextest run --cargo-quiet --workspace --status-level fail 2>&1", goal_gate=true] test_typescript [label="Test TypeScript", shape=parallelogram, script="cd apps/fabro-web && bun test 2>&1", goal_gate=true] start -> toolchain diff --git a/.fabro/workflows/smoke/workflow.toml b/.fabro/workflows/smoke/workflow.toml new file mode 100644 index 000000000..9e79c2378 --- /dev/null +++ b/.fabro/workflows/smoke/workflow.toml @@ -0,0 +1 @@ +_version = 1 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..a7f2b7f3b --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +lib/crates/fabro-spa/assets/** linguist-generated=true -diff diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 414806c82..486669b52 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -78,3 +78,46 @@ jobs: GH_TOKEN: ${{ github.token }} TAG_NAME: ${{ github.ref_name }} run: gh release create "$TAG_NAME" target/distrib/* --generate-notes + + update-homebrew: + name: Update Homebrew Formula + needs: release + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + path: target/distrib + pattern: artifacts-fabro-* + merge-multiple: true + + - name: Generate and push formula + env: + GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + TAG_NAME: ${{ github.ref_name }} + run: | + VERSION="${TAG_NAME#v}" + + SHA_AARCH64_DARWIN=$(awk '{print $1}' target/distrib/fabro-aarch64-apple-darwin.tar.gz.sha256) + SHA_X86_64_LINUX=$(awk '{print $1}' target/distrib/fabro-x86_64-unknown-linux-gnu.tar.gz.sha256) + SHA_AARCH64_LINUX=$(awk '{print $1}' target/distrib/fabro-aarch64-unknown-linux-gnu.tar.gz.sha256) + + sed \ + -e "s/{{VERSION}}/$VERSION/g" \ + -e "s/{{SHA_AARCH64_DARWIN}}/$SHA_AARCH64_DARWIN/g" \ + -e "s/{{SHA_X86_64_LINUX}}/$SHA_X86_64_LINUX/g" \ + -e "s/{{SHA_AARCH64_LINUX}}/$SHA_AARCH64_LINUX/g" \ + installer/fabro.rb.template > fabro.rb + + git clone https://x-access-token:${GH_TOKEN}@github.com/fabro-sh/homebrew-tap.git tap + cp fabro.rb tap/Formula/fabro.rb + cd tap + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add Formula/fabro.rb + git diff --cached --quiet || (git commit -m "Update fabro to ${TAG_NAME}" && git push) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 0bb72a6b8..14a738134 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -5,18 +5,22 @@ on: branches: [main] paths: - "lib/crates/**" + - "test/**" - "Cargo.toml" - "Cargo.lock" - ".cargo/**" + - ".config/**" - "openapi/**" - ".github/workflows/rust.yml" pull_request: branches: [main] paths: - "lib/crates/**" + - "test/**" - "Cargo.toml" - "Cargo.lock" - ".cargo/**" + - ".config/**" - "openapi/**" - ".github/workflows/rust.yml" workflow_dispatch: @@ -40,7 +44,10 @@ jobs: with: persist-credentials: false - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable - - run: cargo fmt --check --all + with: + toolchain: nightly + components: rustfmt + - run: cargo +nightly fmt --check --all clippy: name: Clippy @@ -53,7 +60,7 @@ jobs: - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 with: cache-on-failure: true - - run: cargo clippy --workspace -- -D warnings + - run: cargo clippy --workspace --all-targets -- -D warnings test: name: Test (Linux) diff --git a/.github/workflows/typescript.yml b/.github/workflows/typescript.yml index 3aff100fe..21becf7ec 100644 --- a/.github/workflows/typescript.yml +++ b/.github/workflows/typescript.yml @@ -5,17 +5,23 @@ on: branches: [main] paths: - "apps/**" + - "lib/crates/fabro-spa/**" - "lib/packages/**" - "package.json" - "bun.lock" + - ".gitattributes" + - "scripts/**" - ".github/workflows/typescript.yml" pull_request: branches: [main] paths: - "apps/**" + - "lib/crates/fabro-spa/**" - "lib/packages/**" - "package.json" - "bun.lock" + - ".gitattributes" + - "scripts/**" - ".github/workflows/typescript.yml" workflow_dispatch: @@ -58,4 +64,9 @@ jobs: persist-credentials: false - uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2 - run: bun install - - run: cd apps/fabro-web && bun run build + - run: scripts/refresh-fabro-spa.sh + - run: git diff --exit-code -- lib/crates/fabro-spa/assets + - run: scripts/check-fabro-spa-budgets.sh + - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable + - run: cargo build -p fabro-cli --release + - run: wc -c < target/release/fabro diff --git a/.gitignore b/.gitignore index 54abbce01..3f95c30da 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ target .env .entire node_modules +apps/fabro-web/dist/ tmp evals/swe-bench/repos/ evals/swe-bench/results/ @@ -12,3 +13,4 @@ __pycache__ .ai/tmp .worktrees/ **/*.pending-snap +.worktrees diff --git a/AGENTS.md b/AGENTS.md index 8b19e8fe4..9412edeef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,11 +11,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - `cargo nextest run -p fabro-workflow -- test_name` — run a single test - `set -a && source .env && set +a && cargo nextest run --workspace --profile e2e --run-ignored only` — run all E2E live tests (requires credentials in `.env`, see `.env.example`) - `set -a && source .env && set +a && cargo nextest run -p fabro-llm --profile e2e --run-ignored only` — run E2E tests for a single crate -- `cargo fmt --check --all` — check formatting +- `cargo +nightly fmt --check --all` — check formatting (nightly required for rustfmt config) +- `cargo +nightly fmt --all` — auto-format - `cargo clippy --workspace -- -D warnings` — lint +macOS note: if `cargo nextest run` fails with `Too many open files (os error 24)` / `EMFILE`, raise the shell's soft FD limit before running tests, for example `ulimit -n 4096 && cargo nextest run --workspace`. Some terminals and inherited agent sessions start with `ulimit -n 256`, which is too low for the shared CLI test daemon under parallel nextest load. + ### TypeScript (fabro-web) -- `cd apps/fabro-web && bun run dev` — start React dev server +- `cd apps/fabro-web && bun run dev` — rebuild web assets on change for the Rust server; refresh the browser manually - `cd apps/fabro-web && bun test` — run tests - `cd apps/fabro-web && bun run typecheck` — type check - `cd apps/fabro-web && bun run build` — production build @@ -27,7 +30,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ### Dev servers 1. `fabro server start` — starts the Rust API server (demo mode is per-request via `X-Fabro-Demo: 1` header) -2. `cd apps/fabro-web && bun run dev` — starts the React dev server +2. `cd apps/fabro-web && bun run dev` — rebuilds web assets on change; refresh the browser manually 3. Mintlify docs dev server (requires Docker — `mintlify dev` needs Node LTS which may not match the host): ``` docker run --rm -d -p 3333:3333 -v $(pwd)/docs:/docs -w /docs --name mintlify-dev node:22-slim \ @@ -40,7 +43,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co The OpenAPI spec at `docs/api-reference/fabro-api.yaml` is the source of truth for the fabro-api HTTP interface. 1. Edit `docs/api-reference/fabro-api.yaml` -2. `cargo build -p fabro-api-types` — build.rs regenerates Rust types via typify +2. `cargo build -p fabro-api` — build.rs regenerates Rust types and client via progenitor 3. Write/update handler in `lib/crates/fabro-server/src/server.rs`, add route to `build_router()` 4. `cargo nextest run -p fabro-server` — conformance test catches spec/router drift 5. `cd lib/packages/fabro-api-client && bun run generate` — regenerates TypeScript Axios client @@ -50,14 +53,13 @@ The OpenAPI spec at `docs/api-reference/fabro-api.yaml` is the source of truth f Fabro is an AI-powered workflow orchestration platform. Workflows are defined as Graphviz graphs, where each node is a stage (agent, prompt, command, conditional, human, parallel, etc.) executed by the workflow engine. ### Rust crates (`lib/crates/`) -- **fabro-cli** — CLI entry point. Commands: `run`, `exec`, `serve`, `validate`, `parse`, `cp`, `model`, `doctor`, `install`, `ps`, `system prune`, `llm` +- **fabro-cli** — CLI entry point. Commands: `run`, `exec`, `serve`, `validate`, `parse`, `cp`, `model`, `doctor`, `install`, `ps`, `system prune` - **fabro-workflow** — Core workflow engine. Parses Graphviz graphs, runs stages, manages checkpoints/resume, hooks, retros, and human-in-the-loop interactions - **fabro-agent** — AI coding agent with tool use (Bash, Read, Write, Edit, Glob, Grep, WebFetch). `Sandbox` trait abstracts execution environments - **fabro-server** — Axum HTTP server. Routes for runs, sessions, models, completions, usage. SSE event streaming. Demo mode via header - **fabro-llm** — Unified LLM client with providers: Anthropic, OpenAI, Gemini, OpenAI-compatible, plus retry/middleware/streaming -- **fabro-api-types** — Auto-generated Rust types from OpenAPI spec (build.rs + typify) +- **fabro-api** — Auto-generated Rust types and reqwest HTTP client from OpenAPI spec (build.rs + progenitor) - **fabro-github** — GitHub App auth (JWT signing, installation tokens, PR creation) -- **fabro-db** — SQLite with WAL mode, schema migrations - **fabro-mcp** — Model Context Protocol client/server - **fabro-slack** — Slack integration (socket mode, blocks API) - **fabro-devcontainer** — Parses `.devcontainer/devcontainer.json` for container setup @@ -72,7 +74,7 @@ Fabro is an AI-powered workflow orchestration platform. Workflows are defined as ### Key design patterns - **Sandbox trait** — Uniform interface for local, Docker, and Daytona execution environments - **Graphviz graph workflows** — Stages and transitions defined as Graphviz graph attributes -- **OpenAPI-first** — `fabro-api.yaml` drives both Rust type generation (typify) and TypeScript client generation (openapi-generator) +- **OpenAPI-first** — `fabro-api.yaml` drives Rust type + client generation (progenitor) and TypeScript client generation (openapi-generator) - **Checkpoint/resume** — Workflows can be paused, checkpointed, and resumed ## Strategy docs @@ -80,7 +82,7 @@ Fabro is an AI-powered workflow orchestration platform. Workflows are defined as When working on Rust crates, read the relevant strategy doc **before** making changes: - **`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 `WorkflowRunEvent` variants, touching `EventEmitter`/`emit()`, changing `progress.jsonl` output, or adding new workflow stage types +- **`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 ## Shell quoting in sandbox code @@ -105,6 +107,8 @@ Never run `cargo insta accept` without first checking what's pending — it acce ## Testing workflows -- `fabro run ` — run a workflow by name (resolves `fabro/workflows//workflow.toml`), e.g. `fabro run repl` +- `fabro run ` — run a workflow by name (resolves `.fabro/workflows//workflow.toml`), e.g. `fabro run repl` - Use `--no-retro` to skip the retro step and finish faster - `#[e2e_test(twin, live("VAR"))]` — dual-mode test that runs against twin-openai or real API. `#[e2e_test(twin)]` for twin-only tests (e.g., scripted failures). `#[e2e_test(live("VAR"))]` for live-only tests requiring secrets. `#[e2e_test()]` for sandbox tests with no API deps. Behavior is controlled by `FABRO_TEST_MODE` (`live`, `strict`; default is `twin`), and `cargo nextest run --profile e2e ...` implies `strict`. Use `fabro_test::e2e_openai!()` in twin/dual-mode tests to get `(base_url, api_key)`. +- Local test HTTP clients must use `.no_proxy()`. Prefer shared helpers like `fabro_test::test_http_client()` or crate-local equivalents instead of `reqwest::Client::new()`, bare `Client::builder().build()`, or `reqwest::get(...)`. +- This is not cosmetic: macOS proxy discovery adds hidden startup overhead to repeated localhost reqwest clients and can surface as misleading nextest timeouts. diff --git a/Cargo.lock b/Cargo.lock index ca64ec5b0..98dda030c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,47 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "adler32" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.8.12" @@ -268,15 +309,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "atoi" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" -dependencies = [ - "num-traits", -] - [[package]] name = "atomic" version = "0.6.1" @@ -386,6 +418,29 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-extra" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96" +dependencies = [ + "axum", + "axum-core", + "bytes", + "cookie", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "serde_core", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "axum-macros" version = "0.5.0" @@ -429,12 +484,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - [[package]] name = "bincode" version = "1.3.3" @@ -464,9 +513,6 @@ name = "bitflags" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" -dependencies = [ - "serde_core", -] [[package]] name = "block-buffer" @@ -565,12 +611,6 @@ version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "bytes" version = "1.11.1" @@ -592,6 +632,12 @@ dependencies = [ "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cfg-if" version = "1.0.4" @@ -618,6 +664,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.5.60" @@ -698,6 +754,16 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -733,12 +799,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - [[package]] name = "convert_case" version = "0.10.0" @@ -748,6 +808,24 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "aes-gcm", + "base64", + "hkdf", + "hmac", + "percent-encoding", + "rand 0.8.5", + "sha2", + "subtle", + "time", + "version_check", +] + [[package]] name = "coolor" version = "1.1.0" @@ -783,6 +861,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" +dependencies = [ + "memchr", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -792,21 +879,6 @@ dependencies = [ "libc", ] -[[package]] -name = "crc" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" -dependencies = [ - "crc-catalog", -] - -[[package]] -name = "crc-catalog" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" - [[package]] name = "crc32fast" version = "1.5.0" @@ -942,9 +1014,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "darling" version = "0.14.4" @@ -1049,6 +1131,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dary_heap" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06d2e3287df1c007e74221c49ca10a95d557349e54b3a75dc2fb14712c751f04" + [[package]] name = "data-encoding" version = "2.10.0" @@ -1060,7 +1148,7 @@ name = "daytona-api-client" version = "0.1.0" source = "git+https://github.com/brynary/daytona-sdk-rust?rev=06033ca#06033caaf5d9e12918ae68396048c5c0a98822c6" dependencies = [ - "reqwest", + "reqwest 0.12.28", "reqwest-middleware", "serde", "serde_json", @@ -1077,7 +1165,7 @@ dependencies = [ "daytona-api-client", "daytona-toolbox-client", "futures-util", - "reqwest", + "reqwest 0.12.28", "reqwest-middleware", "serde", "serde_json", @@ -1093,7 +1181,7 @@ name = "daytona-toolbox-client" version = "0.1.0" source = "git+https://github.com/brynary/daytona-sdk-rust?rev=06033ca#06033caaf5d9e12918ae68396048c5c0a98822c6" dependencies = [ - "reqwest", + "reqwest 0.12.28", "reqwest-middleware", "serde", "serde_json", @@ -1111,17 +1199,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid", - "pem-rfc7468", - "zeroize", -] - [[package]] name = "der-parser" version = "9.0.0" @@ -1204,7 +1281,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", - "const-oid", "crypto-common", "subtle", ] @@ -1302,9 +1378,6 @@ name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" -dependencies = [ - "serde", -] [[package]] name = "email_address" @@ -1367,17 +1440,6 @@ dependencies = [ "libc", ] -[[package]] -name = "etcetera" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" -dependencies = [ - "cfg-if", - "home", - "windows-sys 0.48.0", -] - [[package]] name = "event-listener" version = "5.4.1" @@ -1419,12 +1481,14 @@ dependencies = [ "clap", "dirs", "fabro-config", + "fabro-http", "fabro-llm", "fabro-macros", "fabro-mcp", "fabro-model", "fabro-sandbox", "fabro-test", + "fabro-types", "fabro-util", "futures", "glob", @@ -1432,7 +1496,6 @@ dependencies = [ "jsonschema", "libc", "paste", - "reqwest", "serde", "serde_json", "shell-escape", @@ -1445,17 +1508,20 @@ dependencies = [ ] [[package]] -name = "fabro-api-types" +name = "fabro-api" version = "0.176.2" dependencies = [ "chrono", + "openapiv3", "prettyplease", - "schemars 0.8.22", + "progenitor", + "progenitor-client", + "regress", + "reqwest 0.13.2", "serde", "serde_json", "serde_yaml", "syn 2.0.117", - "typify", "uuid", ] @@ -1482,6 +1548,7 @@ dependencies = [ "async-trait", "axum", "base64", + "bytes", "chrono", "clap", "clap_complete", @@ -1493,19 +1560,21 @@ dependencies = [ "dirs", "dotenvy", "fabro-agent", + "fabro-api", "fabro-checkpoint", "fabro-config", "fabro-devcontainer", "fabro-github", "fabro-graphviz", "fabro-hooks", + "fabro-http", "fabro-interview", "fabro-llm", "fabro-macros", "fabro-mcp", "fabro-model", "fabro-oauth", - "fabro-proctitle", + "fabro-proc", "fabro-retro", "fabro-sandbox", "fabro-server", @@ -1515,6 +1584,7 @@ dependencies = [ "fabro-types", "fabro-util", "fabro-validate", + "fabro-vault", "fabro-workflow", "futures", "git2", @@ -1522,14 +1592,13 @@ dependencies = [ "indicatif", "insta", "jsonwebtoken", - "libc", "object_store", "open", "paste", "predicates", + "progenitor-client", "rand 0.8.5", "regex", - "reqwest", "rustls", "rustls-pemfile", "scopeguard", @@ -1541,6 +1610,7 @@ dependencies = [ "shlex", "tempfile", "tokio", + "tokio-util", "toml 0.8.23", "tracing", "tracing-appender", @@ -1555,6 +1625,7 @@ name = "fabro-config" version = "0.176.2" dependencies = [ "anyhow", + "chrono", "clap", "dirs", "fabro-types", @@ -1563,8 +1634,10 @@ dependencies = [ "serde_json", "strsim 0.11.1", "tempfile", + "thiserror 2.0.18", "toml 0.8.23", "tracing", + "ulid", ] [[package]] @@ -1582,24 +1655,13 @@ dependencies = [ "tracing", ] -[[package]] -name = "fabro-db" -version = "0.176.2" -dependencies = [ - "chrono", - "sqlx", - "thiserror 2.0.18", - "tokio", - "tracing", -] - [[package]] name = "fabro-devcontainer" version = "0.176.2" dependencies = [ + "fabro-http", "fabro-util", "insta", - "reqwest", "serde", "serde_json", "serde_yaml", @@ -1615,10 +1677,10 @@ version = "0.176.2" dependencies = [ "base64", "chrono", + "fabro-http", "fabro-macros", "fabro-test", "jsonwebtoken", - "reqwest", "serde", "serde_json", "tokio", @@ -1644,13 +1706,14 @@ dependencies = [ "async-trait", "fabro-agent", "fabro-config", + "fabro-http", "fabro-llm", "fabro-model", + "fabro-template", "fabro-types", "fabro-util", "httpmock", "regex", - "reqwest", "serde", "serde_json", "tokio", @@ -1659,6 +1722,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "fabro-http" +version = "0.176.2" +dependencies = [ + "http", + "reqwest 0.13.2", + "thiserror 2.0.18", +] + [[package]] name = "fabro-interview" version = "0.176.2" @@ -1681,9 +1753,7 @@ dependencies = [ "async-trait", "base64", "bytes", - "clap", - "cli-table", - "dialoguer", + "fabro-http", "fabro-macros", "fabro-model", "fabro-test", @@ -1691,10 +1761,8 @@ dependencies = [ "futures", "http", "httpmock", - "indicatif", "insta", "rand 0.8.5", - "reqwest", "serde", "serde_json", "thiserror 2.0.18", @@ -1720,9 +1788,9 @@ version = "0.176.2" dependencies = [ "anyhow", "fabro-config", + "fabro-http", "fabro-types", "futures", - "reqwest", "rmcp", "serde", "serde_json", @@ -1745,11 +1813,11 @@ version = "0.176.2" dependencies = [ "axum", "base64", + "fabro-http", "hex", "httpmock", "open", "rand 0.8.5", - "reqwest", "serde", "serde_json", "sha2", @@ -1758,11 +1826,12 @@ dependencies = [ ] [[package]] -name = "fabro-proctitle" +name = "fabro-proc" version = "0.176.2" dependencies = [ "cc", "libc", + "tempfile", ] [[package]] @@ -1796,11 +1865,11 @@ dependencies = [ "daytona-sdk", "fabro-config", "fabro-github", + "fabro-proc", "fabro-types", "futures", "git2", "glob", - "libc", "rand 0.8.5", "serde", "serde_json", @@ -1820,26 +1889,33 @@ version = "0.176.2" dependencies = [ "anyhow", "axum", + "axum-extra", "base64", "bytes", "chrono", "clap", + "cookie", "dirs", "fabro-agent", - "fabro-api-types", + "fabro-api", "fabro-config", - "fabro-db", "fabro-github", "fabro-graphviz", "fabro-hooks", + "fabro-http", "fabro-interview", "fabro-llm", "fabro-model", + "fabro-proc", "fabro-retro", "fabro-sandbox", + "fabro-slack", + "fabro-spa", "fabro-store", "fabro-types", "fabro-util", + "fabro-validate", + "fabro-vault", "fabro-workflow", "futures-util", "hex", @@ -1848,27 +1924,34 @@ dependencies = [ "hyper", "hyper-util", "jsonwebtoken", + "mime_guess", + "multer", "object_store", "openapiv3", - "reqwest", + "rand 0.8.5", + "regex", "rustls", "rustls-pemfile", "rustls-pki-types", + "semver", "serde", "serde_json", "serde_yaml", "sha2", - "sqlx", "tempfile", + "thiserror 2.0.18", "tokio", "tokio-rustls", "tokio-stream", "toml 0.8.23", + "toml_edit", "tower", + "tower-http", "tower-service", "tracing", "ulid", "uuid", + "walkdir", "x509-parser", ] @@ -1876,10 +1959,10 @@ dependencies = [ name = "fabro-slack" version = "0.176.2" dependencies = [ + "fabro-http", "fabro-interview", "fabro-workflow", "futures-util", - "reqwest", "rustls", "serde", "serde_json", @@ -1891,6 +1974,13 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "fabro-spa" +version = "0.176.2" +dependencies = [ + "rust-embed", +] + [[package]] name = "fabro-store" version = "0.176.2" @@ -1901,6 +1991,7 @@ dependencies = [ "fabro-types", "futures", "object_store", + "percent-encoding", "serde", "serde_json", "slatedb", @@ -1909,6 +2000,7 @@ dependencies = [ "tokio", "tokio-stream", "tracing", + "ulid", ] [[package]] @@ -1920,13 +2012,14 @@ dependencies = [ "chrono", "dirs", "exec", + "fabro-http", + "fabro-util", "fork", "git2", "insta", "mac_address", "md5", "regex", - "reqwest", "sentry", "serde", "serde_json", @@ -1935,18 +2028,35 @@ dependencies = [ "uuid", ] +[[package]] +name = "fabro-template" +version = "0.176.2" +dependencies = [ + "anyhow", + "fabro-util", + "minijinja", + "serde", + "thiserror 2.0.18", + "toml 0.8.23", +] + [[package]] name = "fabro-test" version = "0.176.2" dependencies = [ "assert_cmd", "axum", + "fabro-config", + "fabro-http", + "fabro-proc", + "fabro-types", "insta", "regex", - "reqwest", + "serde", "serde_json", "tempfile", "tokio", + "toml 0.8.23", "twin-github", "twin-openai", ] @@ -1957,8 +2067,8 @@ version = "0.176.2" dependencies = [ "async-trait", "fabro-github", + "fabro-http", "httpmock", - "reqwest", "serde_json", "tokio", "tracing", @@ -1970,9 +2080,16 @@ version = "0.176.2" dependencies = [ "chrono", "clap", + "dirs", "fabro-macros", + "fabro-model", + "fabro-util", + "hex", "serde", "serde_json", + "sha2", + "tempfile", + "toml 0.8.23", "ulid", ] @@ -2007,6 +2124,17 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "fabro-vault" +version = "0.176.2" +dependencies = [ + "chrono", + "serde", + "serde_json", + "tempfile", + "ulid", +] + [[package]] name = "fabro-workflow" version = "0.176.2" @@ -2015,6 +2143,7 @@ dependencies = [ "assert_cmd", "async-trait", "base64", + "bytes", "chrono", "dirs", "fabro-agent", @@ -2025,6 +2154,7 @@ dependencies = [ "fabro-github", "fabro-graphviz", "fabro-hooks", + "fabro-http", "fabro-interview", "fabro-llm", "fabro-macros", @@ -2033,6 +2163,7 @@ dependencies = [ "fabro-retro", "fabro-sandbox", "fabro-store", + "fabro-template", "fabro-test", "fabro-types", "fabro-util", @@ -2042,10 +2173,10 @@ dependencies = [ "hex", "md5", "mime_guess", + "object_store", "predicates", "rand 0.8.5", "regex", - "reqwest", "scopeguard", "serde", "serde_json", @@ -2400,17 +2531,6 @@ dependencies = [ "futures-util", ] -[[package]] -name = "futures-intrusive" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" -dependencies = [ - "futures-core", - "lock_api", - "parking_lot", -] - [[package]] name = "futures-io" version = "0.3.32" @@ -2513,6 +2633,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gimli" version = "0.32.3" @@ -2538,6 +2668,19 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + [[package]] name = "gloo-timers" version = "0.3.0" @@ -2606,15 +2749,6 @@ dependencies = [ "foldhash 0.2.0", ] -[[package]] -name = "hashlink" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" -dependencies = [ - "hashbrown 0.15.5", -] - [[package]] name = "headers" version = "0.4.1" @@ -2675,15 +2809,6 @@ dependencies = [ "digest", ] -[[package]] -name = "home" -version = "0.5.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "hostname" version = "0.4.2" @@ -3052,6 +3177,39 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "include-flate" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a05fb00d9abc625268e0573a519506b264a7d6965de09bac13201bfb44e723d" +dependencies = [ + "include-flate-codegen", + "include-flate-compress", +] + +[[package]] +name = "include-flate-codegen" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92c3c319a7527668538a8530c541e74e881e94c4f41e1425622d0a41c16468af" +dependencies = [ + "include-flate-compress", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "include-flate-compress" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed0bd9ea81b94169d61c5a397e9faef02153d3711fc62d3270bcde3ac85380d9" +dependencies = [ + "libflate", + "zstd", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -3094,6 +3252,15 @@ version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "insta" version = "1.46.3" @@ -3163,6 +3330,50 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -3255,9 +3466,6 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -dependencies = [ - "spin", -] [[package]] name = "leb128fmt" @@ -3271,6 +3479,30 @@ version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +[[package]] +name = "libflate" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3248b8d211bd23a104a42d81b4fa8bb8ac4a3b75e7a43d85d2c9ccb6179cd74" +dependencies = [ + "adler32", + "core2", + "crc32fast", + "dary_heap", + "libflate_lz77", +] + +[[package]] +name = "libflate_lz77" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a599cb10a9cd92b1300debcef28da8f70b935ec937f44fcd1b70a7c986a11c5c" +dependencies = [ + "core2", + "hashbrown 0.16.1", + "rle-decode-fast", +] + [[package]] name = "libgit2-sys" version = "0.18.3+1.9.2" @@ -3283,12 +3515,6 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - [[package]] name = "libredox" version = "0.1.12" @@ -3300,17 +3526,6 @@ dependencies = [ "redox_syscall 0.7.3", ] -[[package]] -name = "libsqlite3-sys" -version = "0.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" -dependencies = [ - "cc", - "pkg-config", - "vcpkg", -] - [[package]] name = "libz-sys" version = "1.1.24" @@ -3523,6 +3738,12 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memo-map" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b" + [[package]] name = "memoffset" version = "0.9.1" @@ -3548,6 +3769,16 @@ dependencies = [ "unicase", ] +[[package]] +name = "minijinja" +version = "2.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "805bfd7352166bae857ee569628b52bcd85a1cecf7810861ebceb1686b72b75d" +dependencies = [ + "memo-map", + "serde", +] + [[package]] name = "minimad" version = "0.14.0" @@ -3594,6 +3825,23 @@ dependencies = [ "parking_lot", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "naive-timer" version = "0.2.0" @@ -3727,22 +3975,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-bigint-dig" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" -dependencies = [ - "lazy_static", - "libm", - "num-integer", - "num-iter", - "num-traits", - "rand 0.8.5", - "smallvec", - "zeroize", -] - [[package]] name = "num-cmp" version = "0.1.0" @@ -3802,7 +4034,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", - "libm", ] [[package]] @@ -4005,7 +4236,7 @@ dependencies = [ "percent-encoding", "quick-xml", "rand 0.9.2", - "reqwest", + "reqwest 0.12.28", "ring", "serde", "serde_json", @@ -4040,6 +4271,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "open" version = "5.3.3" @@ -4262,15 +4499,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "pem-rfc7468" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" -dependencies = [ - "base64ct", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -4390,33 +4618,24 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" -[[package]] -name = "pkcs1" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" -dependencies = [ - "der", - "pkcs8", - "spki", -] - -[[package]] -name = "pkcs8" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der", - "spki", -] - [[package]] name = "pkg-config" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -4493,6 +4712,28 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -4529,6 +4770,72 @@ dependencies = [ "windows 0.62.2", ] +[[package]] +name = "progenitor" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d36315275b213c64c68dff684477ea7118a0f630832f737b550796a368f9962c" +dependencies = [ + "progenitor-client", + "progenitor-impl", + "progenitor-macro", +] + +[[package]] +name = "progenitor-client" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3999c302f5f2a42b7ca1cc39ad9e612c74cf2910ef6e58f869e45f3068b9659f" +dependencies = [ + "bytes", + "futures-core", + "percent-encoding", + "reqwest 0.13.2", + "serde", + "serde_json", + "serde_urlencoded", +] + +[[package]] +name = "progenitor-impl" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de362a0477182f45accdbad4d43cd89a95a1db0a518a7c1ddf3e525e6896f0f0" +dependencies = [ + "heck 0.5.0", + "http", + "indexmap 2.13.0", + "openapiv3", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "serde", + "serde_json", + "syn 2.0.117", + "thiserror 2.0.18", + "typify", + "unicode-ident", +] + +[[package]] +name = "progenitor-macro" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c98aeaaab266bf848a602c78e039e7d62c80ba36303ae4092ec65f17e7fd0eaa" +dependencies = [ + "openapiv3", + "proc-macro2", + "progenitor-impl", + "quote", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_tokenstream", + "serde_yaml", + "syn 2.0.117", +] + [[package]] name = "quick-xml" version = "0.38.4" @@ -4565,6 +4872,7 @@ version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ + "aws-lc-rs", "bytes", "getrandom 0.3.4", "lru-slab", @@ -4834,11 +5142,54 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.4.2", "web-sys", "webpki-roots 1.0.6", ] +[[package]] +name = "reqwest" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", + "web-sys", +] + [[package]] name = "reqwest-middleware" version = "0.4.2" @@ -4848,7 +5199,7 @@ dependencies = [ "anyhow", "async-trait", "http", - "reqwest", + "reqwest 0.12.28", "serde", "thiserror 1.0.69", "tower-service", @@ -4869,10 +5220,16 @@ dependencies = [ ] [[package]] -name = "rmcp" -version = "0.15.0" +name = "rle-decode-fast" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bef41ebc9ebed2c1b1d90203e9d1756091e8a00bbc3107676151f39868ca0ee" +checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422" + +[[package]] +name = "rmcp" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2231b2c085b371c01bc90c0e6c1cab8834711b6394533375bdbf870b0166d419" dependencies = [ "async-trait", "chrono", @@ -4881,7 +5238,7 @@ dependencies = [ "pastey", "pin-project-lite", "process-wrap", - "reqwest", + "reqwest 0.13.2", "rmcp-macros", "schemars 1.2.1", "serde", @@ -4896,9 +5253,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "0.15.0" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e88ad84b8b6237a934534a62b379a5be6388915663c0cc598ceb9b3292bbbfe" +checksum = "36ea0e100fadf81be85d7ff70f86cd805c7572601d4ab2946207f36540854b43" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -4908,23 +5265,39 @@ dependencies = [ ] [[package]] -name = "rsa" -version = "0.9.10" +name = "rust-embed" +version = "8.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" dependencies = [ - "const-oid", - "digest", - "num-bigint-dig", - "num-integer", - "num-traits", - "pkcs1", - "pkcs8", - "rand_core 0.6.4", - "signature", - "spki", - "subtle", - "zeroize", + "include-flate", + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.117", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" +dependencies = [ + "globset", + "sha2", + "walkdir", ] [[package]] @@ -5027,6 +5400,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.9" @@ -5075,10 +5475,12 @@ version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" dependencies = [ + "chrono", "dyn-clone", "schemars_derive 0.8.22", "serde", "serde_json", + "uuid", ] [[package]] @@ -5165,6 +5567,10 @@ name = "semver" version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "sentry" @@ -5173,7 +5579,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "016958f51b96861dead7c1e02290f138411d05e94fad175c8636a835dee6e51e" dependencies = [ "httpdate", - "reqwest", + "reqwest 0.12.28", "rustls", "sentry-backtrace", "sentry-contexts", @@ -5356,6 +5762,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_tokenstream" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c49585c52c01f13c5c2ebb333f14f6885d76daa768d8a037d28017ec538c69" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -5498,7 +5916,6 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", "rand_core 0.6.4", ] @@ -5610,9 +6027,6 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -dependencies = [ - "serde", -] [[package]] name = "socket2" @@ -5633,208 +6047,6 @@ dependencies = [ "lock_api", ] -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - -[[package]] -name = "sqlx" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" -dependencies = [ - "sqlx-core", - "sqlx-macros", - "sqlx-mysql", - "sqlx-postgres", - "sqlx-sqlite", -] - -[[package]] -name = "sqlx-core" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" -dependencies = [ - "base64", - "bytes", - "chrono", - "crc", - "crossbeam-queue", - "either", - "event-listener", - "futures-core", - "futures-intrusive", - "futures-io", - "futures-util", - "hashbrown 0.15.5", - "hashlink", - "indexmap 2.13.0", - "log", - "memchr", - "once_cell", - "percent-encoding", - "serde", - "serde_json", - "sha2", - "smallvec", - "thiserror 2.0.18", - "tokio", - "tokio-stream", - "tracing", - "url", -] - -[[package]] -name = "sqlx-macros" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" -dependencies = [ - "proc-macro2", - "quote", - "sqlx-core", - "sqlx-macros-core", - "syn 2.0.117", -] - -[[package]] -name = "sqlx-macros-core" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" -dependencies = [ - "dotenvy", - "either", - "heck 0.5.0", - "hex", - "once_cell", - "proc-macro2", - "quote", - "serde", - "serde_json", - "sha2", - "sqlx-core", - "sqlx-mysql", - "sqlx-postgres", - "sqlx-sqlite", - "syn 2.0.117", - "tokio", - "url", -] - -[[package]] -name = "sqlx-mysql" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" -dependencies = [ - "atoi", - "base64", - "bitflags", - "byteorder", - "bytes", - "chrono", - "crc", - "digest", - "dotenvy", - "either", - "futures-channel", - "futures-core", - "futures-io", - "futures-util", - "generic-array", - "hex", - "hkdf", - "hmac", - "itoa", - "log", - "md-5", - "memchr", - "once_cell", - "percent-encoding", - "rand 0.8.5", - "rsa", - "serde", - "sha1", - "sha2", - "smallvec", - "sqlx-core", - "stringprep", - "thiserror 2.0.18", - "tracing", - "whoami", -] - -[[package]] -name = "sqlx-postgres" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" -dependencies = [ - "atoi", - "base64", - "bitflags", - "byteorder", - "chrono", - "crc", - "dotenvy", - "etcetera", - "futures-channel", - "futures-core", - "futures-util", - "hex", - "hkdf", - "hmac", - "home", - "itoa", - "log", - "md-5", - "memchr", - "once_cell", - "rand 0.8.5", - "serde", - "serde_json", - "sha2", - "smallvec", - "sqlx-core", - "stringprep", - "thiserror 2.0.18", - "tracing", - "whoami", -] - -[[package]] -name = "sqlx-sqlite" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" -dependencies = [ - "atoi", - "chrono", - "flume", - "futures-channel", - "futures-core", - "futures-executor", - "futures-intrusive", - "futures-util", - "libsqlite3-sys", - "log", - "percent-encoding", - "serde", - "serde_urlencoded", - "sqlx-core", - "thiserror 2.0.18", - "tracing", - "url", -] - [[package]] name = "sse-stream" version = "0.2.1" @@ -5897,17 +6109,6 @@ version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b3c8667cd96245cbb600b8dec5680a7319edd719c5aa2b5d23c6bff94f39765" -[[package]] -name = "stringprep" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" -dependencies = [ - "unicode-bidi", - "unicode-normalization", - "unicode-properties", -] - [[package]] name = "strsim" version = "0.10.0" @@ -6386,6 +6587,7 @@ dependencies = [ "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -6506,8 +6708,8 @@ dependencies = [ "axum", "base64", "chrono", + "fabro-http", "jsonwebtoken", - "reqwest", "serde", "serde_json", "tempfile", @@ -6524,9 +6726,9 @@ dependencies = [ "anyhow", "async-stream", "axum", + "fabro-http", "futures-util", "http", - "reqwest", "serde", "serde_json", "tokio", @@ -6557,6 +6759,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b715573a376585888b742ead9be5f4826105e622169180662e2c81bed4a149c3" dependencies = [ "typify-impl", + "typify-macro", ] [[package]] @@ -6579,6 +6782,23 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "typify-macro" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd04bb1207cd4e250941cc1641f4c4815f7eaa2145f45c09dd49cb0a3691710a" +dependencies = [ + "proc-macro2", + "quote", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "serde_tokenstream", + "syn 2.0.117", + "typify-impl", +] + [[package]] name = "ulid" version = "1.2.1" @@ -6614,12 +6834,6 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" -[[package]] -name = "unicode-bidi" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" - [[package]] name = "unicode-general-category" version = "1.1.0" @@ -6632,21 +6846,6 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "unicode-normalization" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" -dependencies = [ - "tinyvec", -] - -[[package]] -name = "unicode-properties" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" - [[package]] name = "unicode-segmentation" version = "1.12.0" @@ -6677,6 +6876,16 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -6839,12 +7048,6 @@ dependencies = [ "wit-bindgen", ] -[[package]] -name = "wasite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" - [[package]] name = "wasm-bindgen" version = "0.2.114" @@ -6939,6 +7142,19 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.244.0" @@ -6983,6 +7199,15 @@ dependencies = [ "string_cache_codegen", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -7001,16 +7226,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "whoami" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" -dependencies = [ - "libredox", - "wasite", -] - [[package]] name = "winapi" version = "0.3.9" @@ -7236,11 +7451,11 @@ dependencies = [ [[package]] name = "windows-sys" -version = "0.48.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" dependencies = [ - "windows-targets 0.48.5", + "windows-targets 0.42.2", ] [[package]] @@ -7281,17 +7496,17 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.48.5" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", ] [[package]] @@ -7347,9 +7562,9 @@ dependencies = [ [[package]] name = "windows_aarch64_gnullvm" -version = "0.48.5" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" [[package]] name = "windows_aarch64_gnullvm" @@ -7365,9 +7580,9 @@ checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" [[package]] name = "windows_aarch64_msvc" -version = "0.48.5" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" [[package]] name = "windows_aarch64_msvc" @@ -7383,9 +7598,9 @@ checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" [[package]] name = "windows_i686_gnu" -version = "0.48.5" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" [[package]] name = "windows_i686_gnu" @@ -7413,9 +7628,9 @@ checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" [[package]] name = "windows_i686_msvc" -version = "0.48.5" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" [[package]] name = "windows_i686_msvc" @@ -7431,9 +7646,9 @@ checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" [[package]] name = "windows_x86_64_gnu" -version = "0.48.5" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" [[package]] name = "windows_x86_64_gnu" @@ -7449,9 +7664,9 @@ checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" [[package]] name = "windows_x86_64_gnullvm" -version = "0.48.5" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" [[package]] name = "windows_x86_64_gnullvm" @@ -7467,9 +7682,9 @@ checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" [[package]] name = "windows_x86_64_msvc" -version = "0.48.5" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" [[package]] name = "windows_x86_64_msvc" diff --git a/Cargo.toml b/Cargo.toml index 116d6883a..192b60d63 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,13 +11,15 @@ license = "MIT" [workspace.dependencies] anyhow = "1" axum = { version = "0.8" } +axum-extra = { version = "0.10", features = ["cookie-private"] } +cookie = { version = "0.18", features = ["percent-encode", "private", "signed", "key-expansion"] } thiserror = "2" serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["preserve_order"] } tokio = { version = "1", features = ["full"] } -reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] } +reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "rustls", "query", "form", "multipart"] } ulid = "1" -uuid = { version = "1", features = ["v4", "v7"] } +uuid = { version = "1", features = ["v4", "v7", "v8"] } rand = "0.8" dotenvy = "0.15" futures = "0.3" @@ -39,12 +41,11 @@ git2 = { version = "0.20", default-features = false } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } tracing-appender = "0.2" -rmcp = { version = "0.15.0", default-features = false } +rmcp = { version = "1.3", default-features = false } walkdir = "2" regex = "1" semver = "1" aho-corasick = "1" -sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "chrono"] } dirs = "6" mac_address = "1" md5 = "0.7" @@ -52,6 +53,7 @@ mime_guess = "2" indicatif = "0.18" termimad = "0.34" toml = "0.8" +toml_edit = "0.22" jsonwebtoken = { version = "10", features = ["aws_lc_rs"] } hmac = "0.12" sha2 = "0.10" @@ -68,10 +70,14 @@ sentry = { version = "0.35", default-features = false, features = ["backtrace", fork = "0.2" exec = "0.3" slatedb = "0.11.2" -object_store = "0.12.5" +object_store = { version = "0.12.5", features = ["aws"] } +rust-embed = "8" +percent-encoding = "2" +minijinja = "2" +fabro-http = { path = "lib/crates/fabro-http" } [workspace.lints.rust] -unsafe_code = "warn" +unsafe_code = "deny" unreachable_pub = "warn" [workspace.lints.clippy] @@ -94,6 +100,7 @@ print_stderr = "warn" dbg_macro = "warn" empty_drop = "warn" empty_structs_with_brackets = "warn" +disallowed_methods = "deny" exit = "warn" get_unwrap = "warn" rc_buffer = "warn" diff --git a/apps/fabro-web/app/api-client.test.ts b/apps/fabro-web/app/api-client.test.ts deleted file mode 100644 index b77950166..000000000 --- a/apps/fabro-web/app/api-client.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, test, expect, beforeEach, mock } from "bun:test"; -import { apiJson } from "./api-client"; - -const originalFetch = globalThis.fetch; - -beforeEach(() => { - globalThis.fetch = originalFetch; -}); - -describe("apiJson", () => { - test("returns parsed JSON on 200", async () => { - globalThis.fetch = mock(() => - Promise.resolve(new Response(JSON.stringify({ id: 1, name: "test" }), { - status: 200, - headers: { "Content-Type": "application/json" }, - })) - ); - - const result = await apiJson<{ id: number; name: string }>("/items/1"); - - expect(result).toEqual({ id: 1, name: "test" }); - }); - - test("throws Response with status 404 and null body on not found", async () => { - globalThis.fetch = mock(() => - Promise.resolve(new Response("Not Found: /items/999", { status: 404 })) - ); - - try { - await apiJson("/items/999"); - expect.unreachable("should have thrown"); - } catch (thrown) { - expect(thrown).toBeInstanceOf(Response); - const res = thrown as Response; - expect(res.status).toBe(404); - expect(res.body).toBeNull(); - } - }); - - test("throws Response with status 500 and null body, stripping sensitive details", async () => { - globalThis.fetch = mock(() => - Promise.resolve( - new Response( - "Internal error: database connection string is postgres://admin:secret@db.internal:5432/prod", - { status: 500 } - ) - ) - ); - - try { - await apiJson("/items/1"); - expect.unreachable("should have thrown"); - } catch (thrown) { - expect(thrown).toBeInstanceOf(Response); - const res = thrown as Response; - expect(res.status).toBe(500); - expect(res.body).toBeNull(); - } - }); -}); diff --git a/apps/fabro-web/app/api-client.ts b/apps/fabro-web/app/api-client.ts deleted file mode 100644 index 912314e79..000000000 --- a/apps/fabro-web/app/api-client.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { importPKCS8, SignJWT } from "jose"; -import { getAppConfig } from "./lib/config.server"; -import { isDemoMode } from "./lib/demo-mode.server"; -import { getUser } from "./lib/session.server"; - -const FABRO_JWT_PRIVATE_KEY = process.env.FABRO_JWT_PRIVATE_KEY; - -function decodePemEnv(value: string): string { - if (value.startsWith("-----")) return value; - return Buffer.from(value, "base64").toString("utf-8"); -} - -let cachedKey: CryptoKey | null = null; - -async function getSigningKey(): Promise { - if (cachedKey) return cachedKey; - if (!FABRO_JWT_PRIVATE_KEY) { - throw new Error("FABRO_JWT_PRIVATE_KEY environment variable is not set"); - } - cachedKey = await importPKCS8(decodePemEnv(FABRO_JWT_PRIVATE_KEY), "EdDSA"); - return cachedKey; -} - -async function signToken(sub?: string): Promise { - const key = await getSigningKey(); - return new SignJWT({ iss: "fabro-web", ...(sub ? { sub } : {}) }) - .setProtectedHeader({ alg: "EdDSA" }) - .setIssuedAt() - .setExpirationTime("30s") - .sign(key); -} - -interface ApiOptions { - init?: RequestInit; - request?: Request; -} - -/** - * Fetch wrapper that signs requests with a JWT for service-to-service auth. - * When a request is provided, the authenticated user's URL is included as - * the JWT `sub` claim. - */ -export async function apiFetch( - path: string, - options?: ApiOptions -): Promise { - const { base_url } = getAppConfig().api; - const { init, request } = options ?? {}; - - let sub: string | undefined; - if (request) { - const user = await getUser(request); - sub = user?.userUrl; - } - - const headers = new Headers(init?.headers); - if (FABRO_JWT_PRIVATE_KEY) { - const token = await signToken(sub); - headers.set("Authorization", `Bearer ${token}`); - } - if (request && isDemoMode(request)) { - headers.set("X-Fabro-Demo", "1"); - } - - const url = `${base_url}${path}`; - try { - return await fetch(url, { ...init, headers }); - } catch (cause) { - throw new Error(`API request to ${url} failed`, { cause }); - } -} - -/** - * Typed JSON fetch helper. Calls apiFetch and parses the JSON response. - */ -export async function apiJson(path: string, options?: ApiOptions): Promise { - const res = await apiFetch(path, options); - if (!res.ok) throw new Response(null, { status: res.status }); - return res.json() as Promise; -} diff --git a/apps/fabro-web/app/api.test.ts b/apps/fabro-web/app/api.test.ts new file mode 100644 index 000000000..d308e3a19 --- /dev/null +++ b/apps/fabro-web/app/api.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from "bun:test"; +import { isNotImplemented } from "./api"; + +describe("isNotImplemented", () => { + test("returns true for 501 status", () => { + expect(isNotImplemented(501)).toBe(true); + }); + + test("returns false for 200 status", () => { + expect(isNotImplemented(200)).toBe(false); + }); + + test("returns false for 404 status", () => { + expect(isNotImplemented(404)).toBe(false); + }); +}); diff --git a/apps/fabro-web/app/api.ts b/apps/fabro-web/app/api.ts new file mode 100644 index 000000000..59baa48fc --- /dev/null +++ b/apps/fabro-web/app/api.ts @@ -0,0 +1,79 @@ +export interface ApiOptions { + init?: RequestInit; + request?: Request; +} + +export async function apiFetch(path: string, options?: ApiOptions): Promise { + const { init } = options ?? {}; + const response = await fetch(`/api/v1${path}`, { + ...init, + credentials: "include", + headers: init?.headers, + }); + + if (response.status === 401) { + window.location.href = "/login"; + throw new Error("Unauthorized"); + } + + return response; +} + +export async function apiJson(path: string, options?: ApiOptions): Promise { + const response = await apiFetch(path, options); + if (!response.ok) { + throw new Response(null, { status: response.status, statusText: response.statusText }); + } + return response.json() as Promise; +} + +export function isNotImplemented(status: number): boolean { + return status === 501; +} + +export async function apiJsonOrNull( + path: string, + options?: ApiOptions, +): Promise { + const response = await apiFetch(path, options); + if (isNotImplemented(response.status)) { + return null; + } + if (!response.ok) { + throw new Response(null, { + status: response.status, + statusText: response.statusText, + }); + } + return response.json() as Promise; +} + +export async function getSetupStatus(): Promise<{ configured: boolean }> { + const response = await fetch("/api/v1/setup/status", { credentials: "include" }); + if (!response.ok) { + throw new Response(null, { status: response.status, statusText: response.statusText }); + } + return response.json(); +} + +export async function getAuthMe(): Promise<{ + user: { + login: string; + name: string; + email: string; + avatarUrl: string; + userUrl: string; + }; + provider: string; + demoMode: boolean; + features: { session_sandboxes: boolean; retros: boolean }; +}> { + const response = await fetch("/api/v1/auth/me", { credentials: "include" }); + if (response.status === 401) { + throw new Response(null, { status: 401, statusText: "Unauthorized" }); + } + if (!response.ok) { + throw new Response(null, { status: response.status, statusText: response.statusText }); + } + return response.json(); +} diff --git a/apps/fabro-web/app/data/retros.ts b/apps/fabro-web/app/data/retros.ts deleted file mode 100644 index cb28ce4ba..000000000 --- a/apps/fabro-web/app/data/retros.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { formatDurationSecs } from "../lib/format"; - -export type SmoothnessRating = "effortless" | "smooth" | "bumpy" | "struggled" | "failed"; - -type LearningCategory = "repo" | "code" | "workflow" | "tool"; - -export interface Learning { - category: LearningCategory; - text: string; -} - -type FrictionKind = "retry" | "timeout" | "wrong_approach" | "tool_failure" | "ambiguity"; - -export interface FrictionPoint { - kind: FrictionKind; - description: string; - stage_id?: string; -} - -type OpenItemKind = "tech_debt" | "follow_up" | "investigation" | "test_gap"; - -export interface OpenItem { - kind: OpenItemKind; - description: string; -} - -export interface StageRetro { - stage_id: string; - stage_label: string; - status: string; - duration_ms: number; - retries: number; - cost?: number; - notes?: string; - failure_reason?: string; - files_touched: string[]; -} - -export interface AggregateStats { - total_duration_ms: number; - total_cost?: number; - total_retries: number; - files_touched: string[]; - stages_completed: number; - stages_failed: number; -} - -export interface Retro { - run_id: string; - workflow_name: string; - goal: string; - timestamp: string; - smoothness?: SmoothnessRating; - stages: StageRetro[]; - stats: AggregateStats; - intent?: string; - outcome?: string; - learnings?: Learning[]; - friction_points?: FrictionPoint[]; - open_items?: OpenItem[]; -} - -export const smoothnessConfig: Record = { - effortless: { label: "Effortless", bg: "bg-emerald-500/15", text: "text-emerald-400", dot: "bg-emerald-400" }, - smooth: { label: "Smooth", bg: "bg-mint/15", text: "text-mint", dot: "bg-mint" }, - bumpy: { label: "Bumpy", bg: "bg-amber/15", text: "text-amber", dot: "bg-amber" }, - struggled: { label: "Struggled", bg: "bg-orange-500/15", text: "text-orange-400", dot: "bg-orange-400" }, - failed: { label: "Failed", bg: "bg-coral/15", text: "text-coral", dot: "bg-coral" }, -}; - -export const learningCategoryConfig: Record = { - repo: { label: "Repo", text: "text-teal-400" }, - code: { label: "Code", text: "text-sky-400" }, - workflow: { label: "Workflow", text: "text-violet-400" }, - tool: { label: "Tool", text: "text-amber" }, -}; - -export const frictionKindConfig: Record = { - retry: { label: "Retry", text: "text-amber" }, - timeout: { label: "Timeout", text: "text-coral" }, - wrong_approach: { label: "Wrong Approach", text: "text-orange-400" }, - tool_failure: { label: "Tool Failure", text: "text-coral" }, - ambiguity: { label: "Ambiguity", text: "text-violet-400" }, -}; - -export const openItemKindConfig: Record = { - tech_debt: { label: "Tech Debt", text: "text-orange-400" }, - follow_up: { label: "Follow-up", text: "text-teal-400" }, - investigation: { label: "Investigation", text: "text-sky-400" }, - test_gap: { label: "Test Gap", text: "text-coral" }, -}; - -function formatDurationMs(ms: number): string { - return formatDurationSecs(Math.floor(ms / 1000)); -} - -export { formatDurationMs }; diff --git a/apps/fabro-web/app/data/runs.test.ts b/apps/fabro-web/app/data/runs.test.ts new file mode 100644 index 000000000..e614114d3 --- /dev/null +++ b/apps/fabro-web/app/data/runs.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test"; +import { mapRunSummaryToRunItem } from "./runs"; + +describe("mapRunSummaryToRunItem", () => { + test("maps store run summary to RunItem", () => { + const summary = { + run_id: "01ABC", + goal: "Fix the build", + workflow_slug: "fix_build", + workflow_name: "Fix Build", + host_repo_path: "/home/user/myrepo", + status: "running", + duration_ms: 65000, + total_usd_micros: 500000, + labels: {}, + start_time: "2026-04-08T12:00:00Z", + status_reason: null, + pending_control: null, + }; + const item = mapRunSummaryToRunItem(summary); + expect(item.id).toBe("01ABC"); + expect(item.title).toBe("Fix the build"); + expect(item.workflow).toBe("fix_build"); + expect(item.repo).toBe("myrepo"); + expect(item.elapsed).toBeDefined(); + }); + + test("handles missing optional fields", () => { + const summary = { + run_id: "01DEF", + goal: null, + workflow_slug: null, + workflow_name: null, + host_repo_path: null, + status: "submitted", + duration_ms: null, + total_usd_micros: null, + labels: {}, + start_time: null, + status_reason: null, + pending_control: null, + }; + const item = mapRunSummaryToRunItem(summary); + expect(item.id).toBe("01DEF"); + expect(item.title).toBe("Untitled run"); + expect(item.workflow).toBe("unknown"); + expect(item.repo).toBe("unknown"); + }); +}); diff --git a/apps/fabro-web/app/data/runs.ts b/apps/fabro-web/app/data/runs.ts index 5b91dca5d..0d936d997 100644 --- a/apps/fabro-web/app/data/runs.ts +++ b/apps/fabro-web/app/data/runs.ts @@ -66,6 +66,36 @@ export function mapRunListItem(item: RunListItem): RunItem { }; } +export interface RunSummaryResponse { + run_id: string; + goal: string | null; + workflow_slug: string | null; + workflow_name: string | null; + host_repo_path: string | null; + status: string | null; + status_reason: string | null; + pending_control: string | null; + duration_ms: number | null; + total_usd_micros: number | null; + labels: Record; + start_time: string | null; +} + +export function mapRunSummaryToRunItem(summary: RunSummaryResponse): RunItem { + const repoPath = summary.host_repo_path ?? ""; + const repoName = repoPath.split("/").pop() || "unknown"; + return { + id: summary.run_id, + repo: repoName, + title: summary.goal ?? "Untitled run", + workflow: summary.workflow_slug ?? "unknown", + elapsed: + summary.duration_ms != null + ? formatElapsedSecs(summary.duration_ms / 1000) + : undefined, + }; +} + export function deriveCiStatus(checks: CheckRun[]): CiStatus { if (checks.some((c) => c.status === "failure")) return "failing"; if (checks.some((c) => c.status === "pending" || c.status === "queued")) return "pending"; diff --git a/apps/fabro-web/app/data/verifications.ts b/apps/fabro-web/app/data/verifications.ts deleted file mode 100644 index 40aeb1bc4..000000000 --- a/apps/fabro-web/app/data/verifications.ts +++ /dev/null @@ -1,96 +0,0 @@ -export type VerificationResult = "pass" | "fail" | "skip" | "na"; - -export type VerificationType = "ai" | "automated" | "analysis" | "ai-analysis"; - -export interface Criterion { - name: string; - description: string; - type: VerificationType | null; - status: VerificationResult; -} - -export interface VerificationCategory { - name: string; - question: string; - status: VerificationResult; - criteria: Criterion[]; -} - -export const statusConfig = { - pass: { - label: "Pass", - color: "text-mint", - bg: "bg-mint/15", - dot: "bg-mint", - border: "border-l-mint/50", - }, - fail: { - label: "Fail", - color: "text-coral", - bg: "bg-coral/15", - dot: "bg-coral", - border: "border-l-coral/50", - }, - skip: { - label: "Skip", - color: "text-fg-muted", - bg: "bg-overlay", - dot: "bg-fg-muted", - border: "border-l-fg-muted/50", - }, - na: { - label: "N/A", - color: "text-fg-muted", - bg: "bg-overlay", - dot: "bg-fg-muted", - border: "border-l-fg-muted/50", - }, -} as const satisfies Record< - VerificationResult, - { label: string; color: string; bg: string; dot: string; border: string } ->; - -export const typeConfig = { - ai: { label: "AI", color: "text-teal-300", bg: "bg-teal-500/10" }, - automated: { label: "Automated", color: "text-mint", bg: "bg-mint/10" }, - analysis: { label: "Analysis", color: "text-amber", bg: "bg-amber/10" }, - "ai-analysis": { label: "AI + Analysis", color: "text-teal-300", bg: "bg-teal-500/10" }, -} as const satisfies Record< - VerificationType, - { label: string; color: string; bg: string } ->; - -export type VerificationMode = "active" | "evaluate" | "disabled"; - -export interface CriterionPerformance { - f1: number | null; - passAt1: number | null; - mode: VerificationMode; - evaluations: VerificationResult[]; -} - -export const modeConfig = { - active: { label: "Active", color: "text-mint", bg: "bg-mint/10" }, - evaluate: { label: "Evaluate", color: "text-amber", bg: "bg-amber/10" }, - disabled: { label: "Disabled", color: "text-fg-muted", bg: "bg-overlay" }, -} as const satisfies Record< - VerificationMode, - { label: string; color: string; bg: string } ->; - -export function getCriteriaSummary(criteria: readonly Criterion[]) { - return { - passing: criteria.filter((c) => c.status === "pass").length, - failing: criteria.filter((c) => c.status === "fail").length, - na: criteria.filter((c) => c.status === "na").length, - total: criteria.length, - }; -} - -export function slugify(name: string): string { - return name - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/(^-|-$)/g, ""); -} - diff --git a/apps/fabro-web/app/entry.tsx b/apps/fabro-web/app/entry.tsx new file mode 100644 index 000000000..542dec68a --- /dev/null +++ b/apps/fabro-web/app/entry.tsx @@ -0,0 +1,17 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { createBrowserRouter, RouterProvider } from "react-router"; +import { routes } from "./router"; + +const router = createBrowserRouter(routes); +const rootElement = document.getElementById("root"); + +if (!rootElement) { + throw new Error("Missing #root element"); +} + +createRoot(rootElement).render( + + + , +); diff --git a/apps/fabro-web/app/layouts/app-shell.test.tsx b/apps/fabro-web/app/layouts/app-shell.test.tsx new file mode 100644 index 000000000..ee75d543f --- /dev/null +++ b/apps/fabro-web/app/layouts/app-shell.test.tsx @@ -0,0 +1,20 @@ +import { describe, expect, test } from "bun:test"; +import { getVisibleNavigation } from "./app-shell"; + +describe("getVisibleNavigation", () => { + test("shows all nav items in demo mode", () => { + const items = getVisibleNavigation(true); + const names = items.map((i) => i.name); + expect(names).toContain("Workflows"); + expect(names).toContain("Runs"); + expect(names).toContain("Insights"); + }); + + test("hides Workflows and Insights in production mode", () => { + const items = getVisibleNavigation(false); + const names = items.map((i) => i.name); + expect(names).not.toContain("Workflows"); + expect(names).not.toContain("Insights"); + expect(names).toContain("Runs"); + }); +}); diff --git a/apps/fabro-web/app/layouts/app-shell.tsx b/apps/fabro-web/app/layouts/app-shell.tsx index f926bcc29..392ff4457 100644 --- a/apps/fabro-web/app/layouts/app-shell.tsx +++ b/apps/fabro-web/app/layouts/app-shell.tsx @@ -11,75 +11,42 @@ import { Bars3Icon, BeakerIcon, ChartBarIcon, - CheckBadgeIcon, - Cog6ToothIcon, - LightBulbIcon, MoonIcon, PlayIcon, RectangleStackIcon, - SparklesIcon, SunIcon, XMarkIcon, } from "@heroicons/react/24/outline"; -import { Form, Link, Outlet, redirect, useLocation, useMatches } from "react-router"; +import { Form, Link, Outlet, redirect, useLocation, useMatches, useRevalidator } from "react-router"; +import { getAuthMe } from "../api"; +import { DemoModeProvider } from "../lib/demo-mode"; import { useTheme } from "../lib/theme"; -import { getAppConfig } from "../lib/config.server"; -import { isDemoMode, demoCookieHeader } from "../lib/demo-mode.server"; -import { isGitHubAppConfigured } from "../lib/github.server"; -import { requireUser } from "../lib/session.server"; -import type { Route } from "./+types/app-shell"; -const DEMO_USER = { - userUrl: "", - login: "demo", - name: "Demo User", - email: "demo@example.com", - avatarUrl: "https://github.githubassets.com/assets/GitHub-Mark-ea2971cee799.png", -}; - -export async function loader({ request }: Route.LoaderArgs) { - const config = getAppConfig(); - const { provider } = config.web.auth; - const demoMode = isDemoMode(request); - if (provider === "insecure_disabled") { - return { user: DEMO_USER, demoMode, features: config.features }; - } - if (provider === "github" && !isGitHubAppConfigured()) { - throw redirect("/setup"); - } - const user = await requireUser(request); - return { user, provider, demoMode, features: config.features }; +export async function loader() { + return getAuthMe(); } -export async function action({ request }: Route.ActionArgs) { - const form = await request.formData(); - if (form.get("intent") === "toggle-demo") { - const enabled = !isDemoMode(request); - const referer = request.headers.get("Referer") ?? "/start"; - const url = new URL(referer); - return redirect(url.pathname + url.search, { - headers: { "Set-Cookie": demoCookieHeader(enabled) }, - }); - } -} - -const navigation = [ - { name: "Workflows", href: "/workflows", icon: RectangleStackIcon }, - { name: "Runs", href: "/runs", icon: PlayIcon }, - { name: "Verification", href: "/verification/criteria", icon: CheckBadgeIcon }, - { name: "Retros", href: "/retros", icon: LightBulbIcon }, - { name: "Insights", href: "/insights", icon: ChartBarIcon }, +const allNavigation = [ + { name: "Workflows", href: "/workflows", icon: RectangleStackIcon, demoOnly: true }, + { name: "Runs", href: "/runs", icon: PlayIcon, demoOnly: false }, + { name: "Insights", href: "/insights", icon: ChartBarIcon, demoOnly: true }, ]; +export function getVisibleNavigation(demoMode: boolean) { + return allNavigation.filter((item) => !item.demoOnly || demoMode); +} + function classNames(...classes: Array) { return classes.filter(Boolean).join(" "); } -export default function AppShell({ loaderData }: Route.ComponentProps) { +export default function AppShell({ loaderData }: any) { const { user, provider, demoMode } = loaderData; const { pathname } = useLocation(); const matches = useMatches(); + const revalidator = useRevalidator(); const { theme, toggle } = useTheme(); + const navigation = getVisibleNavigation(demoMode); const currentNav = navigation.find((item) => pathname.startsWith(item.href)); const title = currentNav?.name ?? ""; const lastMatch = matches[matches.length - 1]; @@ -91,7 +58,18 @@ export default function AppShell({ loaderData }: Route.ComponentProps) { const ThemeIcon = theme === "dark" ? SunIcon : MoonIcon; + async function toggleDemoMode() { + await fetch("/api/v1/demo/toggle", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: !demoMode }), + }); + revalidator.revalidate(); + } + return ( +
@@ -128,20 +106,18 @@ export default function AppShell({ loaderData }: Route.ComponentProps) {
-
- - -
+ - {provider !== "tailscale" && ( - - - - Open user menu - - + + + + Open user menu + + - - -
- -
-
-
-
- )} + + +
+ +
+
+
+
@@ -240,20 +214,18 @@ export default function AppShell({ loaderData }: Route.ComponentProps) {
-
- - -
+
+
); } diff --git a/apps/fabro-web/app/lib/config.server.ts b/apps/fabro-web/app/lib/config.server.ts deleted file mode 100644 index 01142f644..000000000 --- a/apps/fabro-web/app/lib/config.server.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { readFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; -import { parse } from "smol-toml"; - -interface AuthConfig { - provider: "github" | "tailscale" | "insecure_disabled"; - allowed_usernames: string[]; -} - -interface ApiConfig { - base_url: string; - authentication_strategy: "jwt" | "insecure_disabled"; -} - -interface GitConfig { - provider: "github"; - app_id: string | null; - client_id: string | null; - slug: string | null; -} - -interface Features { - session_sandboxes: boolean; - retros: boolean; -} - -interface WebConfig { - url: string; - auth: AuthConfig; -} - -interface AppConfig { - web: WebConfig; - api: ApiConfig; - git: GitConfig; - features: Features; -} - -const AUTH_DEFAULTS: AuthConfig = { - provider: "github", - allowed_usernames: [], -}; - -const WEB_DEFAULTS: WebConfig = { - url: "http://localhost:5173", - auth: AUTH_DEFAULTS, -}; - -const API_DEFAULTS: ApiConfig = { - base_url: "http://localhost:3000/api/v1", - authentication_strategy: "jwt", -}; - -const GIT_DEFAULTS: GitConfig = { - provider: "github", - app_id: null, - client_id: null, - slug: null, -}; - -const FEATURES_DEFAULTS: Features = { - session_sandboxes: false, - retros: false, -}; - -export const FABRO_CONFIG_PATH = join(homedir(), ".fabro", "server.toml"); - -function loadAppConfig(): AppConfig { - const configPath = FABRO_CONFIG_PATH; - - let raw: Record = {}; - try { - raw = parse(readFileSync(configPath, "utf-8")) as Record; - } catch { - // File doesn't exist or is unreadable — use defaults - } - - const rawWeb = (raw.web ?? {}) as Record; - const rawWebAuth = (rawWeb.auth ?? {}) as Partial; - const rawApi = (raw.api ?? {}) as Partial; - const rawGit = (raw.git ?? {}) as Partial; - const rawFeatures = (raw.features ?? {}) as Partial; - - const demo = process.env.FABRO_DEMO === "1"; - - return { - web: { - ...WEB_DEFAULTS, - url: (rawWeb.url as string) ?? WEB_DEFAULTS.url, - auth: demo - ? { provider: "insecure_disabled", allowed_usernames: [] } - : { ...AUTH_DEFAULTS, ...rawWebAuth }, - }, - api: demo - ? { ...API_DEFAULTS, ...rawApi, authentication_strategy: "insecure_disabled" } - : { ...API_DEFAULTS, ...rawApi }, - git: { ...GIT_DEFAULTS, ...rawGit }, - features: { ...FEATURES_DEFAULTS, ...rawFeatures }, - }; -} - -/** Loaded once at module init; call reloadAppConfig() to pick up changes. */ -let appConfig: AppConfig = loadAppConfig(); - -export function getAppConfig(): AppConfig { - return appConfig; -} - -export function reloadAppConfig(): void { - appConfig = loadAppConfig(); -} diff --git a/apps/fabro-web/app/lib/db.server.ts b/apps/fabro-web/app/lib/db.server.ts deleted file mode 100644 index 24cec41cd..000000000 --- a/apps/fabro-web/app/lib/db.server.ts +++ /dev/null @@ -1,29 +0,0 @@ -import Database from "better-sqlite3"; -import path from "node:path"; -import os from "node:os"; -import fs from "node:fs"; - -let db: Database.Database | null = null; - -export function getDatabase(): Database.Database { - if (db) return db; - - const fabroDir = path.join(os.homedir(), ".fabro"); - fs.mkdirSync(fabroDir, { recursive: true }); - - db = new Database(path.join(fabroDir, "fabro-web.db")); - db.pragma("journal_mode = WAL"); - - db.exec(` - CREATE TABLE IF NOT EXISTS web_sessions ( - id TEXT PRIMARY KEY, - user_url TEXT NOT NULL, - data TEXT NOT NULL, - expires_at INTEGER - ); - CREATE INDEX IF NOT EXISTS idx_web_sessions_user_url ON web_sessions (user_url); - CREATE INDEX IF NOT EXISTS idx_web_sessions_expires_at ON web_sessions (expires_at); - `); - - return db; -} diff --git a/apps/fabro-web/app/lib/demo-mode.server.ts b/apps/fabro-web/app/lib/demo-mode.server.ts deleted file mode 100644 index a1792f079..000000000 --- a/apps/fabro-web/app/lib/demo-mode.server.ts +++ /dev/null @@ -1,15 +0,0 @@ -const COOKIE_NAME = "fabro-demo"; - -/** Check whether demo mode is active for this request (cookie, then env var fallback). */ -export function isDemoMode(request: Request): boolean { - const cookies = request.headers.get("Cookie") ?? ""; - const match = cookies.match(/(?:^|;\s*)fabro-demo=([^;]*)/); - if (match) return match[1] === "1"; - return process.env.FABRO_DEMO === "1"; -} - -/** Build a Set-Cookie header value to persist the demo mode preference. */ -export function demoCookieHeader(enabled: boolean): string { - const value = enabled ? "1" : "0"; - return `${COOKIE_NAME}=${value}; Path=/; SameSite=Lax; Max-Age=${60 * 60 * 24 * 365}`; -} diff --git a/apps/fabro-web/app/lib/demo-mode.test.tsx b/apps/fabro-web/app/lib/demo-mode.test.tsx new file mode 100644 index 000000000..60c0f7c4e --- /dev/null +++ b/apps/fabro-web/app/lib/demo-mode.test.tsx @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test"; +import { renderToString } from "react-dom/server"; +import { DemoModeProvider, useDemoMode } from "./demo-mode"; + +function TestConsumer() { + const demoMode = useDemoMode(); + return {demoMode ? "demo" : "prod"}; +} + +describe("DemoModeProvider", () => { + test("provides demo mode value to children", () => { + const html = renderToString( + + + , + ); + expect(html).toContain("demo"); + expect(html).toContain('data-demo="true"'); + }); + + test("defaults to false", () => { + const html = renderToString( + + + , + ); + expect(html).toContain("prod"); + }); +}); diff --git a/apps/fabro-web/app/lib/demo-mode.tsx b/apps/fabro-web/app/lib/demo-mode.tsx new file mode 100644 index 000000000..e7f5f2b38 --- /dev/null +++ b/apps/fabro-web/app/lib/demo-mode.tsx @@ -0,0 +1,21 @@ +import { createContext, useContext } from "react"; + +const DemoModeContext = createContext(false); + +export function DemoModeProvider({ + value, + children, +}: { + value: boolean; + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} + +export function useDemoMode(): boolean { + return useContext(DemoModeContext); +} diff --git a/apps/fabro-web/app/lib/github.server.ts b/apps/fabro-web/app/lib/github.server.ts deleted file mode 100644 index e3a4d7810..000000000 --- a/apps/fabro-web/app/lib/github.server.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { GitHub, generateState } from "arctic"; -import { getAppConfig } from "./config.server"; - -export { generateState }; - -export function getGitHubOAuth() { - const clientId = getAppConfig().git.client_id; - const clientSecret = process.env.GITHUB_APP_CLIENT_SECRET; - if (!clientId || !clientSecret) { - throw new Error("GitHub App is not configured"); - } - return new GitHub(clientId, clientSecret, null); -} - -export function isGitHubAppConfigured(): boolean { - return getAppConfig().git.client_id !== null; -} - diff --git a/apps/fabro-web/app/lib/merge-env.test.ts b/apps/fabro-web/app/lib/merge-env.test.ts deleted file mode 100644 index c77331a83..000000000 --- a/apps/fabro-web/app/lib/merge-env.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { mergeEnv } from "./merge-env"; - -describe("mergeEnv", () => { - test("replaces existing key with new value", () => { - const result = mergeEnv( - "FOO=old\nBAR=keep\n", - new Map([["FOO", "new"]]), - ); - expect(result).toContain("FOO=new"); - expect(result).toContain("BAR=keep"); - }); - - test("preserves comments, blank lines, and unrelated vars", () => { - const existing = "# A comment\n\nFOO=old\n# Another\nBAR=keep\n"; - const result = mergeEnv(existing, new Map([["FOO", "new"]])); - expect(result).toContain("# A comment"); - expect(result).toContain("# Another"); - expect(result).toContain("FOO=new"); - expect(result).toContain("BAR=keep"); - }); - - test("appends keys not already present", () => { - const result = mergeEnv( - "FOO=old\n", - new Map([ - ["FOO", "new"], - ["BAZ", "added"], - ]), - ); - expect(result).toContain("FOO=new"); - expect(result).toContain("BAZ=added"); - }); - - test("handles export prefix", () => { - const result = mergeEnv( - "export FOO=old\nexport BAR=keep\n", - new Map([["FOO", "new"]]), - ); - expect(result).toContain("export FOO=new"); - expect(result).toContain("export BAR=keep"); - }); - - test("idempotent: merging twice produces same result", () => { - const vars = new Map([ - ["FOO", "new"], - ["BAZ", "added"], - ]); - const first = mergeEnv("FOO=old\nBAR=keep\n", vars); - const second = mergeEnv(first, vars); - expect(second).toBe(first); - }); - - test("full scenario matches expected output", () => { - const result = mergeEnv( - "FOO=old\nBAR=keep", - new Map([ - ["FOO", "new"], - ["BAZ", "added"], - ]), - ); - expect(result).toBe("FOO=new\nBAR=keep\nBAZ=added\n"); - }); - - test("empty existing string", () => { - const result = mergeEnv( - "", - new Map([ - ["FOO", "bar"], - ["BAZ", "qux"], - ]), - ); - expect(result).toContain("FOO=bar"); - expect(result).toContain("BAZ=qux"); - }); -}); diff --git a/apps/fabro-web/app/lib/merge-env.ts b/apps/fabro-web/app/lib/merge-env.ts deleted file mode 100644 index 14930bab6..000000000 --- a/apps/fabro-web/app/lib/merge-env.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Merge new key=value pairs into an existing .env file string. - * - Replaces lines whose key matches (handles optional `export ` prefix) - * - Preserves comments, blank lines, and unrelated variables - * - Appends keys not already present - */ -export function mergeEnv( - existing: string, - newVars: Map, -): string { - const handledKeys = new Set(); - const resultLines: string[] = []; - - for (const line of existing.split("\n")) { - const eqPos = line.indexOf("="); - if (eqPos !== -1) { - let rawKey = line.slice(0, eqPos).trim(); - const hasExport = rawKey.startsWith("export "); - if (hasExport) { - rawKey = rawKey.slice("export ".length).trim(); - } - if (rawKey.length > 0 && !rawKey.startsWith("#")) { - const newVal = newVars.get(rawKey); - if (newVal !== undefined) { - const prefix = hasExport ? "export " : ""; - resultLines.push(`${prefix}${rawKey}=${newVal}`); - handledKeys.add(rawKey); - continue; - } - } - } - resultLines.push(line); - } - - for (const [key, val] of newVars) { - if (!handledKeys.has(key)) { - resultLines.push(`${key}=${val}`); - } - } - - let result = resultLines.join("\n"); - if (!result.endsWith("\n")) { - result += "\n"; - } - return result; -} diff --git a/apps/fabro-web/app/lib/session-storage.server.ts b/apps/fabro-web/app/lib/session-storage.server.ts deleted file mode 100644 index 947a944dd..000000000 --- a/apps/fabro-web/app/lib/session-storage.server.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { createSessionStorage } from "react-router"; -import crypto from "node:crypto"; -import { getDatabase } from "./db.server"; - -interface SessionRow { - id: string; - user_url: string; - data: string; - expires_at: number | null; -} - -const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; - -function cleanupExpiredSessions() { - const db = getDatabase(); - db.prepare("DELETE FROM web_sessions WHERE expires_at IS NOT NULL AND expires_at < ?").run( - Date.now(), - ); -} - -export function createSqliteSessionStorage(secret: string) { - return createSessionStorage({ - cookie: { - name: "__fabro_session", - httpOnly: true, - sameSite: "lax" as const, - secure: process.env.NODE_ENV === "production", - secrets: [secret], - path: "/", - maxAge: 30 * 24 * 60 * 60, // 30 days in seconds - }, - async createData(data, expiresAt) { - const db = getDatabase(); - const id = crypto.randomUUID(); - const userUrl = (data.userUrl as string) ?? ""; - const expiresAtMs = expiresAt ? expiresAt.getTime() : Date.now() + THIRTY_DAYS_MS; - - db.prepare( - "INSERT INTO web_sessions (id, user_url, data, expires_at) VALUES (?, ?, ?, ?)", - ).run(id, userUrl, JSON.stringify(data), expiresAtMs); - - // Probabilistic cleanup (~1% of creates) - if (Math.random() < 0.01) { - cleanupExpiredSessions(); - } - - return id; - }, - async readData(id) { - const db = getDatabase(); - const row = db - .prepare("SELECT * FROM web_sessions WHERE id = ?") - .get(id) as SessionRow | undefined; - - if (!row) return null; - - if (row.expires_at && row.expires_at < Date.now()) { - db.prepare("DELETE FROM web_sessions WHERE id = ?").run(id); - return null; - } - - return JSON.parse(row.data); - }, - async updateData(id, data, expiresAt) { - const db = getDatabase(); - const userUrl = (data.userUrl as string) ?? ""; - const expiresAtMs = expiresAt ? expiresAt.getTime() : Date.now() + THIRTY_DAYS_MS; - - db.prepare( - "UPDATE web_sessions SET user_url = ?, data = ?, expires_at = ? WHERE id = ?", - ).run(userUrl, JSON.stringify(data), expiresAtMs, id); - }, - async deleteData(id) { - const db = getDatabase(); - db.prepare("DELETE FROM web_sessions WHERE id = ?").run(id); - }, - }); -} diff --git a/apps/fabro-web/app/lib/session.server.test.ts b/apps/fabro-web/app/lib/session.server.test.ts deleted file mode 100644 index 80d2fce1c..000000000 --- a/apps/fabro-web/app/lib/session.server.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { describe, test, expect, beforeEach, mock } from "bun:test"; - -// --- Mocks (must be set up before importing module under test) --- - -let testAuthConfig = { provider: "github" as string, allowed_usernames: [] as string[] }; - -mock.module("./config.server", () => ({ - getAppConfig: () => ({ web: { auth: testAuthConfig } }), - reloadAppConfig: () => {}, - FABRO_CONFIG_PATH: "/tmp/test.toml", -})); - -let sessionData: Record = {}; - -mock.module("./session-storage.server", () => ({ - createSqliteSessionStorage: () => ({ - getSession: async () => ({ - get: (key: string) => sessionData[key], - }), - commitSession: async () => "", - destroySession: async () => "", - }), -})); - -process.env.SESSION_SECRET = "test-secret"; - -const { getUser } = await import("./session.server"); - -// --- Tests --- - -describe("getUser", () => { - beforeEach(() => { - sessionData = {}; - testAuthConfig = { provider: "github", allowed_usernames: [] }; - }); - - describe("tailscale provider", () => { - beforeEach(() => { - testAuthConfig = { provider: "tailscale", allowed_usernames: ["user@example.com"] }; - }); - - test("returns user from headers when login is in allowed_usernames", async () => { - const request = new Request("http://localhost", { - headers: { - "Tailscale-User-Login": "user@example.com", - "Tailscale-User-Name": "Test User", - "Tailscale-User-Profile-Pic": "https://example.com/pic.jpg", - }, - }); - - const user = await getUser(request); - - expect(user).toEqual({ - userUrl: "tailscale:user@example.com", - login: "user@example.com", - name: "Test User", - email: "user@example.com", - avatarUrl: "https://example.com/pic.jpg", - }); - }); - - test("returns null when Tailscale-User-Login header is missing", async () => { - const request = new Request("http://localhost"); - - const user = await getUser(request); - - expect(user).toBeNull(); - }); - - test("returns null when login is not in allowed_usernames", async () => { - const request = new Request("http://localhost", { - headers: { - "Tailscale-User-Login": "stranger@example.com", - "Tailscale-User-Name": "Stranger", - }, - }); - - const user = await getUser(request); - - expect(user).toBeNull(); - }); - }); - - describe("github provider", () => { - beforeEach(() => { - testAuthConfig = { provider: "github", allowed_usernames: [] }; - }); - - test("returns user from session", async () => { - sessionData = { - userUrl: "https://github.com/octocat", - login: "octocat", - name: "Octocat", - email: "octocat@github.com", - avatarUrl: "https://github.com/octocat.png", - }; - const request = new Request("http://localhost"); - - const user = await getUser(request); - - expect(user).toEqual({ - userUrl: "https://github.com/octocat", - login: "octocat", - name: "Octocat", - email: "octocat@github.com", - avatarUrl: "https://github.com/octocat.png", - }); - }); - - test("returns null when session is empty", async () => { - sessionData = {}; - const request = new Request("http://localhost"); - - const user = await getUser(request); - - expect(user).toBeNull(); - }); - }); -}); diff --git a/apps/fabro-web/app/lib/session.server.ts b/apps/fabro-web/app/lib/session.server.ts deleted file mode 100644 index fbae025c7..000000000 --- a/apps/fabro-web/app/lib/session.server.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { redirect } from "react-router"; -import { getAppConfig } from "./config.server"; -import { createSqliteSessionStorage } from "./session-storage.server"; - -interface SessionData { - userUrl: string; - githubId: number; - githubNodeId: string; - login: string; - name: string; - email: string; - avatarUrl: string; - accessToken: string; -} - -function getSessionStorage() { - const secret = process.env.SESSION_SECRET; - if (!secret) { - throw new Error("SESSION_SECRET is not set"); - } - return createSqliteSessionStorage(secret); -} - -export async function getSession(request: Request) { - const storage = getSessionStorage(); - return storage.getSession(request.headers.get("Cookie")); -} - -export async function commitSession(session: Awaited>) { - const storage = getSessionStorage(); - return storage.commitSession(session); -} - -export async function destroySession(session: Awaited>) { - const storage = getSessionStorage(); - return storage.destroySession(session); -} - -export async function getUser(request: Request) { - const { provider, allowed_usernames } = getAppConfig().web.auth; - - if (provider === "tailscale") { - const login = request.headers.get("Tailscale-User-Login"); - if (!login || !allowed_usernames.includes(login)) return null; - return { - userUrl: `tailscale:${login}`, - login, - name: request.headers.get("Tailscale-User-Name") ?? login, - email: login, - avatarUrl: request.headers.get("Tailscale-User-Profile-Pic") ?? "", - }; - } - - const session = await getSession(request); - const login = session.get("login"); - if (!login) return null; - return { - userUrl: session.get("userUrl") ?? "", - login, - name: session.get("name") ?? login, - email: session.get("email") ?? "", - avatarUrl: session.get("avatarUrl") ?? "", - }; -} - -export async function requireUser(request: Request) { - const user = await getUser(request); - if (!user) throw redirect("/auth/login"); - return user; -} diff --git a/apps/fabro-web/app/lib/theme-selection.test.ts b/apps/fabro-web/app/lib/theme-selection.test.ts new file mode 100644 index 000000000..fad7568cf --- /dev/null +++ b/apps/fabro-web/app/lib/theme-selection.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test"; +import { buildThemeBootScript, resolveTheme } from "./theme-selection"; + +describe("resolveTheme", () => { + test("defaults to dark when no saved theme exists", () => { + expect(resolveTheme(null)).toBe("dark"); + expect(resolveTheme("system")).toBe("dark"); + }); + + test("uses the saved theme when it is valid", () => { + expect(resolveTheme("light")).toBe("light"); + expect(resolveTheme("dark")).toBe("dark"); + }); +}); + +describe("buildThemeBootScript", () => { + test("defaults the boot script to dark without using system preference", () => { + const script = buildThemeBootScript(); + + expect(script).toContain('localStorage.getItem("fabro-theme")'); + expect(script).toContain('t="dark"'); + expect(script).not.toContain("matchMedia"); + }); +}); diff --git a/apps/fabro-web/app/lib/theme-selection.ts b/apps/fabro-web/app/lib/theme-selection.ts new file mode 100644 index 000000000..aa4a06013 --- /dev/null +++ b/apps/fabro-web/app/lib/theme-selection.ts @@ -0,0 +1,16 @@ +type Theme = "light" | "dark"; + +const STORAGE_KEY = "fabro-theme"; + +function resolveTheme(storedTheme: string | null): Theme { + return storedTheme === "light" || storedTheme === "dark" + ? storedTheme + : "dark"; +} + +function buildThemeBootScript(storageKey = STORAGE_KEY) { + return `(function(){try{var t=localStorage.getItem("${storageKey}");if(t!=="light"&&t!=="dark")t="dark";document.documentElement.classList.add(t)}catch(e){document.documentElement.classList.add("dark")}})()`; +} + +export { STORAGE_KEY, buildThemeBootScript, resolveTheme }; +export type { Theme }; diff --git a/apps/fabro-web/app/lib/theme.tsx b/apps/fabro-web/app/lib/theme.tsx index 534cdb6dd..948340fe9 100644 --- a/apps/fabro-web/app/lib/theme.tsx +++ b/apps/fabro-web/app/lib/theme.tsx @@ -1,16 +1,10 @@ import { createContext, useCallback, useContext, useEffect, useState } from "react"; - -type Theme = "light" | "dark"; - -const STORAGE_KEY = "fabro-theme"; +import { STORAGE_KEY, resolveTheme } from "./theme-selection"; +import type { Theme } from "./theme-selection"; function getInitialTheme(): Theme { if (typeof window === "undefined") return "dark"; - const stored = localStorage.getItem(STORAGE_KEY); - if (stored === "light" || stored === "dark") return stored; - return window.matchMedia("(prefers-color-scheme: dark)").matches - ? "dark" - : "light"; + return resolveTheme(localStorage.getItem(STORAGE_KEY)); } function applyThemeClass(theme: Theme) { diff --git a/apps/fabro-web/app/lib/time.ts b/apps/fabro-web/app/lib/time.ts index decaf06fe..1d8a818d4 100644 --- a/apps/fabro-web/app/lib/time.ts +++ b/apps/fabro-web/app/lib/time.ts @@ -22,46 +22,3 @@ export function timeUntil(iso: string): string { return relativeTime(Math.floor((new Date(iso).getTime() - Date.now()) / 1000), false); } -/** - * Return a human-readable date label for grouping (e.g. "Today", "Yesterday", "Previous 7 days"). - */ -function dateLabel(iso: string): string { - const now = new Date(); - const date = new Date(iso); - const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - const startOfYesterday = new Date(startOfToday.getTime() - 86_400_000); - const startOf7DaysAgo = new Date(startOfToday.getTime() - 7 * 86_400_000); - - if (date >= startOfToday) return "Today"; - if (date >= startOfYesterday) return "Yesterday"; - if (date >= startOf7DaysAgo) return "Previous 7 days"; - return "Older"; -} - -interface SessionItem { - id: string; - title: string; - created_at: string; -} - -interface SessionGroup { - label: string; - sessions: SessionItem[]; -} - -/** - * Group a flat list of sessions (already sorted newest-first) into date-labeled groups. - */ -export function groupSessionsByDate(sessions: SessionItem[]): SessionGroup[] { - const groups: SessionGroup[] = []; - let current: SessionGroup | undefined; - for (const s of sessions) { - const label = dateLabel(s.created_at); - if (!current || current.label !== label) { - current = { label, sessions: [] }; - groups.push(current); - } - current.sessions.push(s); - } - return groups; -} diff --git a/apps/fabro-web/app/lib/workflow-api.ts b/apps/fabro-web/app/lib/workflow-api.ts new file mode 100644 index 000000000..e73d79ed0 --- /dev/null +++ b/apps/fabro-web/app/lib/workflow-api.ts @@ -0,0 +1,40 @@ +import type { PaginationMeta } from "@qltysh/fabro-api-client"; + +/** + * Opaque settings payload returned by `/api/v1/runs/:id/settings`. Mirrors the + * v2 `SettingsFile` shape in `lib/crates/fabro-types/src/settings/tree.rs`, + * with secret-bearing subtrees dropped before serialization. Treated as a + * loose JSON object on the web side — consumers only render it. + */ +export type RunSettings = Record; + +export interface WorkflowScheduleSummary { + expression: string; + next_run?: string | null; +} + +export interface WorkflowLastRunSummary { + ran_at?: string | null; +} + +export interface WorkflowListItem { + name: string; + slug: string; + filename: string; + last_run?: WorkflowLastRunSummary | null; + schedule?: WorkflowScheduleSummary | null; +} + +export interface PaginatedWorkflowListResponse { + data: WorkflowListItem[]; + pagination?: PaginationMeta; +} + +export interface WorkflowDetailResponse { + name: string; + slug: string; + description: string; + filename: string; + settings: RunSettings; + graph: string; +} diff --git a/apps/fabro-web/app/root.tsx b/apps/fabro-web/app/root.tsx index 3ee6a1414..6b938eac9 100644 --- a/apps/fabro-web/app/root.tsx +++ b/apps/fabro-web/app/root.tsx @@ -1,62 +1,24 @@ -import { - isRouteErrorResponse, - Links, - Meta, - Outlet, - Scripts, - ScrollRestoration, -} from "react-router"; - -import type { Route } from "./+types/root"; +import { isRouteErrorResponse, Outlet } from "react-router"; import { ThemeProvider } from "./lib/theme"; +import { buildThemeBootScript } from "./lib/theme-selection"; import "./app.css"; -const themeScript = `(function(){try{var t=localStorage.getItem("fabro-theme");if(t!=="light"&&t!=="dark")t=window.matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light";document.documentElement.classList.add(t)}catch(e){document.documentElement.classList.add("dark")}})()`; +const themeScript = buildThemeBootScript(); -export const links: Route.LinksFunction = () => [ - { rel: "icon", href: "/favicon.svg", type: "image/svg+xml" }, - { rel: "icon", href: "/favicon.ico", sizes: "32x32" }, - { rel: "apple-touch-icon", href: "/apple-touch-icon.png" }, - { rel: "preconnect", href: "https://fonts.googleapis.com" }, - { - rel: "preconnect", - href: "https://fonts.gstatic.com", - crossOrigin: "anonymous", - }, - { - rel: "stylesheet", - href: "https://fonts.googleapis.com/css2?family=Geist:wght@100..900&family=JetBrains+Mono:wght@400;500;600&display=swap", - }, -]; - -export function Layout({ children }: { children: React.ReactNode }) { - return ( - - - + {{styles}} + + +
+ {{scripts}} + + diff --git a/apps/fabro-web/package.json b/apps/fabro-web/package.json index ef6259eec..3bc47e5aa 100644 --- a/apps/fabro-web/package.json +++ b/apps/fabro-web/package.json @@ -3,11 +3,10 @@ "private": true, "type": "module", "scripts": { - "build": "react-router build", - "dev": "react-router dev", - "start": "react-router-serve ./build/server/index.js", + "build": "bun run scripts/build.ts", + "dev": "bun run scripts/build.ts --watch", "test": "bun test", - "typecheck": "react-router typegen && tsc" + "typecheck": "tsc" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -17,28 +16,17 @@ "@heroicons/react": "^2.2.0", "@pierre/diffs": "^1.0.11", "@qltysh/fabro-api-client": "workspace:*", - "@react-router/node": "7.12.0", - "@react-router/serve": "7.12.0", "@viz-js/viz": "^3.24.0", - "arctic": "^3.7.0", - "better-sqlite3": "^12.6.2", - "isbot": "^5.1.31", - "jose": "^6.1.3", "react": "^19.2.4", "react-dom": "^19.2.4", - "react-router": "7.12.0", - "smol-toml": "^1.6.0" + "react-router": "7.12.0" }, "devDependencies": { - "@react-router/dev": "7.12.0", - "@tailwindcss/vite": "^4.1.13", - "@types/better-sqlite3": "^7.6.13", + "@tailwindcss/cli": "^4.1.13", "@types/node": "^22", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "tailwindcss": "^4.1.13", - "typescript": "^5.9.2", - "vite": "^7.1.7", - "vite-tsconfig-paths": "^5.1.4" + "typescript": "^5.9.2" } -} \ No newline at end of file +} diff --git a/apps/fabro-web/react-router.config.ts b/apps/fabro-web/react-router.config.ts deleted file mode 100644 index 6ff16f917..000000000 --- a/apps/fabro-web/react-router.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { Config } from "@react-router/dev/config"; - -export default { - // Config options... - // Server-side render by default, to enable SPA mode set this to `false` - ssr: true, -} satisfies Config; diff --git a/apps/fabro-web/scripts/build.test.ts b/apps/fabro-web/scripts/build.test.ts new file mode 100644 index 000000000..446c3a7e4 --- /dev/null +++ b/apps/fabro-web/scripts/build.test.ts @@ -0,0 +1,32 @@ +import { test, expect } from "bun:test"; + +const root = Bun.fileURLToPath(new URL("..", import.meta.url)); + +test("watch mode keeps running until interrupted", async () => { + const process = Bun.spawn([ + "bun", + "run", + "scripts/build.ts", + "--watch", + ], { + cwd: root, + stdout: "pipe", + stderr: "pipe", + }); + + const result = await Promise.race([ + process.exited.then((code) => ({ kind: "exited" as const, code })), + Bun.sleep(1000).then(() => ({ kind: "running" as const })), + ]); + + if (result.kind === "exited") { + const stderr = await new Response(process.stderr).text(); + const stdout = await new Response(process.stdout).text(); + throw new Error( + `watch process exited unexpectedly with code ${result.code}\nstdout:\n${stdout}\nstderr:\n${stderr}`, + ); + } + + process.kill("SIGINT"); + expect([0, 130]).toContain(await process.exited); +}); diff --git a/apps/fabro-web/scripts/build.ts b/apps/fabro-web/scripts/build.ts new file mode 100644 index 000000000..292e78567 --- /dev/null +++ b/apps/fabro-web/scripts/build.ts @@ -0,0 +1,121 @@ +import { watch as fsWatch } from "node:fs"; +import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { join, relative } from "node:path"; + +declare const Bun: any; + +const root = new URL("..", import.meta.url); +const rootPath = Bun.fileURLToPath(root); +const distDir = join(rootPath, "dist"); +const assetsDir = join(distDir, "assets"); +const publicDir = join(rootPath, "public"); +const templatePath = join(rootPath, "index.template.html"); +const watch = Bun.argv.includes("--watch"); + +async function buildOnce() { + await rm(distDir, { recursive: true, force: true }); + await mkdir(assetsDir, { recursive: true }); + + const result = await Bun.build({ + entrypoints: [join(rootPath, "app", "entry.tsx")], + outdir: assetsDir, + naming: "[name]-[hash].[ext]", + minify: true, + splitting: true, + target: "browser", + sourcemap: "external", + }); + + if (!result.success) { + throw new Error(result.logs.map((log: any) => log.message).join("\n")); + } + + const cssResult = await Bun.spawn([ + "bunx", + "@tailwindcss/cli", + "-i", + "app/app.css", + "-o", + "dist/assets/app.css", + "--minify", + ], { + cwd: rootPath, + stdout: "inherit", + stderr: "inherit", + }).exited; + + if (cssResult !== 0) { + throw new Error("Tailwind build failed"); + } + + await cp(publicDir, distDir, { recursive: true }); + await writeIndexHtml(result.outputs.map((output: any) => relative(distDir, output.path))); +} + +async function writeIndexHtml(outputs: string[]) { + const template = await readFile(templatePath, "utf8"); + const scripts = outputs + .filter((path) => path.endsWith(".js")) + .map((path) => ``) + .join("\n "); + const styles = [ + "/assets/app.css", + ...outputs.filter((path) => path.endsWith(".css")).map((path) => `/${path.replaceAll("\\\\", "/")}`), + ] + .filter((value, index, array) => array.indexOf(value) === index) + .map((path) => ``) + .join("\n "); + + const html = template + .replace("{{styles}}", styles) + .replace("{{scripts}}", scripts); + + await writeFile(join(distDir, "index.html"), html, "utf8"); +} + +async function main() { + if (!watch) { + await buildOnce(); + return; + } + + await buildOnce(); + let building = false; + let rebuildQueued = false; + + async function rebuild() { + if (building) { + rebuildQueued = true; + return; + } + + building = true; + do { + rebuildQueued = false; + try { + await buildOnce(); + } catch (error) { + console.error(error); + } + } while (rebuildQueued); + building = false; + } + + const watchers = [ + fsWatch(join(rootPath, "app"), { recursive: true }, rebuild), + fsWatch(publicDir, { recursive: true }, rebuild), + fsWatch(templatePath, rebuild), + ]; + + process.on("SIGINT", () => { + for (const watcher of watchers) { + watcher.close(); + } + process.exit(0); + }); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/apps/fabro-web/tsconfig.json b/apps/fabro-web/tsconfig.json index 03960bfb9..1a122dbf3 100644 --- a/apps/fabro-web/tsconfig.json +++ b/apps/fabro-web/tsconfig.json @@ -1,19 +1,16 @@ { "include": [ - "**/*", - "**/.server/**/*", - "**/.client/**/*", - ".react-router/types/**/*" + "app/**/*", + "scripts/**/*" ], - "exclude": ["**/*.test.ts"], + "exclude": ["**/*.test.ts", "**/*.test.tsx"], "compilerOptions": { "lib": ["DOM", "DOM.Iterable", "ES2022"], - "types": ["node", "vite/client"], + "types": ["node"], "target": "ES2022", "module": "ES2022", "moduleResolution": "bundler", "jsx": "react-jsx", - "rootDirs": [".", "./.react-router/types"], "baseUrl": ".", "paths": { "~/*": ["./app/*"], @@ -24,6 +21,7 @@ "noEmit": true, "resolveJsonModule": true, "skipLibCheck": true, - "strict": true + "strict": false, + "noImplicitAny": false } } diff --git a/apps/fabro-web/vite.config.ts b/apps/fabro-web/vite.config.ts deleted file mode 100644 index 25690b111..000000000 --- a/apps/fabro-web/vite.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { reactRouter } from "@react-router/dev/vite"; -import tailwindcss from "@tailwindcss/vite"; -import { defineConfig } from "vite"; -import tsconfigPaths from "vite-tsconfig-paths"; - -export default defineConfig({ - plugins: [tailwindcss(), reactRouter(), tsconfigPaths()], - ssr: { external: ["better-sqlite3"] }, -}); diff --git a/bun.lock b/bun.lock index 462d3bad2..d21d7146a 100644 --- a/bun.lock +++ b/bun.lock @@ -13,29 +13,18 @@ "@heroicons/react": "^2.2.0", "@pierre/diffs": "^1.0.11", "@qltysh/fabro-api-client": "workspace:*", - "@react-router/node": "7.12.0", - "@react-router/serve": "7.12.0", "@viz-js/viz": "^3.24.0", - "arctic": "^3.7.0", - "better-sqlite3": "^12.6.2", - "isbot": "^5.1.31", - "jose": "^6.1.3", "react": "^19.2.4", "react-dom": "^19.2.4", "react-router": "7.12.0", - "smol-toml": "^1.6.0", }, "devDependencies": { - "@react-router/dev": "7.12.0", - "@tailwindcss/vite": "^4.1.13", - "@types/better-sqlite3": "^7.6.13", + "@tailwindcss/cli": "^4.1.13", "@types/node": "^22", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "tailwindcss": "^4.1.13", "typescript": "^5.9.2", - "vite": "^7.1.7", - "vite-tsconfig-paths": "^5.1.4", }, }, "apps/marketing": { @@ -77,9 +66,6 @@ }, }, }, - "trustedDependencies": [ - "better-sqlite3", - ], "packages": { "@astrojs/compiler": ["@astrojs/compiler@2.13.1", "", {}, "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg=="], @@ -101,28 +87,16 @@ "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], - "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.27.3", "", { "dependencies": { "@babel/types": "^7.27.3" } }, "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg=="], - "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], - "@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/helper-replace-supers": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/traverse": "^7.28.6", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow=="], - "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], - "@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.28.5", "", { "dependencies": { "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5" } }, "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg=="], - "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], - "@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.27.1", "", { "dependencies": { "@babel/types": "^7.27.1" } }, "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw=="], - "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], - "@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.28.6", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg=="], - - "@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg=="], - "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], @@ -131,22 +105,12 @@ "@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="], - "@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], - - "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="], - - "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="], - - "@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.28.6", "", { "dependencies": { "@babel/helper-module-transforms": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA=="], + "@babel/parser": ["@babel/parser@7.24.1", "", { "bin": "./bin/babel-parser.js" }, "sha512-Zo9c7N3xdOIQrNip7Lc9wvRPzlRtovHVE4lkz8WEDr7uYh/GMQhSiIgFxGIArRHYdJE5kxtZjAf8rT0xhdLCzg=="], "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], - "@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-create-class-features-plugin": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw=="], - - "@babel/preset-typescript": ["@babel/preset-typescript@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g=="], - "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], @@ -303,8 +267,6 @@ "@mediabunny/mp3-encoder": ["@mediabunny/mp3-encoder@1.39.2", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-3rrodrGnUpUP8F2d1aRUl8IvjqK3jegkupbOzvOokooSAO5rXk2Lr5jZe7TnPeiVGiXfmnoJ7s9uyUOHlCd8qw=="], - "@mjackson/node-fetch-server": ["@mjackson/node-fetch-server@0.2.0", "", {}, "sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng=="], - "@module-federation/error-codes": ["@module-federation/error-codes@0.22.0", "", {}, "sha512-xF9SjnEy7vTdx+xekjPCV5cIHOGCkdn3pIxo9vU7gEZMIw0SvAEdsy6Uh17xaCpm8V0FWvR0SZoK9Ik6jGOaug=="], "@module-federation/runtime": ["@module-federation/runtime@0.22.0", "", { "dependencies": { "@module-federation/error-codes": "0.22.0", "@module-federation/runtime-core": "0.22.0", "@module-federation/sdk": "0.22.0" } }, "sha512-38g5iPju2tPC3KHMPxRKmy4k4onNp6ypFPS1eKGsNLUkXgHsPMBFqAjDw96iEcjri91BrahG4XcdyKi97xZzlA=="], @@ -319,15 +281,35 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.7", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" } }, "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw=="], - "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], - - "@oslojs/binary": ["@oslojs/binary@1.0.0", "", {}, "sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ=="], - - "@oslojs/crypto": ["@oslojs/crypto@1.0.1", "", { "dependencies": { "@oslojs/asn1": "1.0.0", "@oslojs/binary": "1.0.0" } }, "sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ=="], - "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], - "@oslojs/jwt": ["@oslojs/jwt@0.2.0", "", { "dependencies": { "@oslojs/encoding": "0.4.1" } }, "sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg=="], + "@parcel/watcher": ["@parcel/watcher@2.5.6", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="], + + "@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.6", "", { "os": "android", "cpu": "arm64" }, "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A=="], + + "@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA=="], + + "@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg=="], + + "@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.5.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng=="], + + "@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ=="], + + "@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg=="], + + "@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA=="], + + "@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA=="], + + "@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ=="], + + "@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg=="], + + "@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q=="], + + "@parcel/watcher-win32-ia32": ["@parcel/watcher-win32-ia32@2.5.6", "", { "os": "win32", "cpu": "ia32" }, "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g=="], + + "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="], "@pierre/diffs": ["@pierre/diffs@1.0.11", "", { "dependencies": { "@shikijs/core": "^3.0.0", "@shikijs/engine-javascript": "^3.0.0", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-j6zIEoyImQy1HfcJqbrDwP0O5I7V2VNXAaw53FqQ+SykRfaNwABeZHs9uibXO4supaXPmTx6LEH9Lffr03e1Tw=="], @@ -341,22 +323,12 @@ "@react-aria/utils": ["@react-aria/utils@3.33.0", "", { "dependencies": { "@react-aria/ssr": "^3.9.10", "@react-stately/flags": "^3.1.2", "@react-stately/utils": "^3.11.0", "@react-types/shared": "^3.33.0", "@swc/helpers": "^0.5.0", "clsx": "^2.0.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-yvz7CMH8d2VjwbSa5nGXqjU031tYhD8ddax95VzJsHSPyqHDEGfxul8RkhGV6oO7bVqZxVs6xY66NIgae+FHjw=="], - "@react-router/dev": ["@react-router/dev@7.12.0", "", { "dependencies": { "@babel/core": "^7.27.7", "@babel/generator": "^7.27.5", "@babel/parser": "^7.27.7", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/preset-typescript": "^7.27.1", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@react-router/node": "7.12.0", "@remix-run/node-fetch-server": "^0.9.0", "arg": "^5.0.1", "babel-dead-code-elimination": "^1.0.6", "chokidar": "^4.0.0", "dedent": "^1.5.3", "es-module-lexer": "^1.3.1", "exit-hook": "2.2.1", "isbot": "^5.1.11", "jsesc": "3.0.2", "lodash": "^4.17.21", "p-map": "^7.0.3", "pathe": "^1.1.2", "picocolors": "^1.1.1", "pkg-types": "^2.3.0", "prettier": "^3.6.2", "react-refresh": "^0.14.0", "semver": "^7.3.7", "tinyglobby": "^0.2.14", "valibot": "^1.2.0", "vite-node": "^3.2.2" }, "peerDependencies": { "@react-router/serve": "^7.12.0", "@vitejs/plugin-rsc": "~0.5.7", "react-router": "^7.12.0", "react-server-dom-webpack": "^19.2.3", "typescript": "^5.1.0", "vite": "^5.1.0 || ^6.0.0 || ^7.0.0", "wrangler": "^3.28.2 || ^4.0.0" }, "optionalPeers": ["@react-router/serve", "@vitejs/plugin-rsc", "react-server-dom-webpack", "typescript", "wrangler"], "bin": { "react-router": "bin.js" } }, "sha512-5GpwXgq4pnOVeG7l6ADkCHA1rthJus1q/A3NRYJAIypclUQDYAzg1/fDNjvaKuTSrq+Nr3u6aj2v+oC+47MX6g=="], - - "@react-router/express": ["@react-router/express@7.12.0", "", { "dependencies": { "@react-router/node": "7.12.0" }, "peerDependencies": { "express": "^4.17.1 || ^5", "react-router": "7.12.0", "typescript": "^5.1.0" }, "optionalPeers": ["typescript"] }, "sha512-uAK+zF93M6XauGeXLh/UBh+3HrwiA/9lUS+eChjQ0a5FzjLpsc6ciUqF5oHh3lwWzLU7u7tj4qoeucUn6SInTw=="], - - "@react-router/node": ["@react-router/node@7.12.0", "", { "dependencies": { "@mjackson/node-fetch-server": "^0.2.0" }, "peerDependencies": { "react-router": "7.12.0", "typescript": "^5.1.0" }, "optionalPeers": ["typescript"] }, "sha512-o/t10Cse4LK8kFefqJ8JjC6Ng6YuKD2I87S2AiJs17YAYtXU5W731ZqB73AWyCDd2G14R0dSuqXiASRNK/xLjg=="], - - "@react-router/serve": ["@react-router/serve@7.12.0", "", { "dependencies": { "@mjackson/node-fetch-server": "^0.2.0", "@react-router/express": "7.12.0", "@react-router/node": "7.12.0", "compression": "^1.8.1", "express": "^4.19.2", "get-port": "5.1.1", "morgan": "^1.10.1", "source-map-support": "^0.5.21" }, "peerDependencies": { "react-router": "7.12.0" }, "bin": { "react-router-serve": "bin.js" } }, "sha512-j1ltgU7s3wAwOosZ5oxgHSsmVyK706gY/yIs8qVmC239wQ3zr3eqaXk3TVVLMeRy+eDgPNmgc6oNJv2o328VgA=="], - "@react-stately/flags": ["@react-stately/flags@3.1.2", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg=="], "@react-stately/utils": ["@react-stately/utils@3.11.0", "", { "dependencies": { "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-8LZpYowJ9eZmmYLpudbo/eclIRnbhWIJZ994ncmlKlouNzKohtM8qTC6B1w1pwUbiwGdUoyzLuQbeaIor5Dvcw=="], "@react-types/shared": ["@react-types/shared@3.33.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-xuUpP6MyuPmJtzNOqF5pzFUIHH2YogyOQfUQHag54PRmWB7AbjuGWBUv0l1UDmz6+AbzAYGmDVAzcRDOu2PFpw=="], - "@remix-run/node-fetch-server": ["@remix-run/node-fetch-server@0.9.0", "", {}, "sha512-SoLMv7dbH+njWzXnOY6fI08dFMI5+/dQ+vY3n8RnnbdG7MdJEgiP28Xj/xWlnRnED/aB6SFw56Zop+LbmaaKqA=="], - "@remotion/bundler": ["@remotion/bundler@4.0.437", "", { "dependencies": { "@remotion/media-parser": "4.0.437", "@remotion/studio": "4.0.437", "@remotion/studio-shared": "4.0.437", "@rspack/core": "1.7.6", "@rspack/plugin-react-refresh": "1.6.1", "css-loader": "5.2.7", "esbuild": "0.25.0", "react-refresh": "0.18.0", "remotion": "4.0.437", "source-map": "0.7.3", "style-loader": "4.0.0", "webpack": "5.105.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-9Kp5g6zoJmK/vBWgzJvgEJKxmnauCKGqnzrd6V3PkmIWilJJop61E+Di8RukX7ds20utVjUgNBsAp7+7bAputQ=="], "@remotion/cli": ["@remotion/cli@4.0.437", "", { "dependencies": { "@remotion/bundler": "4.0.437", "@remotion/media-utils": "4.0.437", "@remotion/player": "4.0.437", "@remotion/renderer": "4.0.437", "@remotion/studio": "4.0.437", "@remotion/studio-server": "4.0.437", "@remotion/studio-shared": "4.0.437", "dotenv": "17.3.1", "minimist": "1.2.6", "prompts": "2.4.2", "remotion": "4.0.437" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" }, "bin": { "remotion": "remotion-cli.js", "remotionb": "remotionb-cli.js", "remotiond": "remotiond-cli.js" } }, "sha512-w5FncfLWDPUCgr1+NgUHQ/VDKRcUmt7SL4x/dZT4miXVP7dma2RBYgXS1bfmmgWscSy2h+AeDFwEaqJZU5uoqg=="], @@ -497,33 +469,35 @@ "@swc/helpers": ["@swc/helpers@0.5.19", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA=="], - "@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="], + "@tailwindcss/cli": ["@tailwindcss/cli@4.2.2", "", { "dependencies": { "@parcel/watcher": "^2.5.1", "@tailwindcss/node": "4.2.2", "@tailwindcss/oxide": "4.2.2", "enhanced-resolve": "^5.19.0", "mri": "^1.2.0", "picocolors": "^1.1.1", "tailwindcss": "4.2.2" }, "bin": { "tailwindcss": "dist/index.mjs" } }, "sha512-iJS+8kAFZ8HPqnh0O5DHCLjo4L6dD97DBQEkrhfSO4V96xeefUus2jqsBs1dUMt3OU9Ks4qIkiY0mpL5UW+4LQ=="], - "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.1", "@tailwindcss/oxide-darwin-arm64": "4.2.1", "@tailwindcss/oxide-darwin-x64": "4.2.1", "@tailwindcss/oxide-freebsd-x64": "4.2.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", "@tailwindcss/oxide-linux-x64-musl": "4.2.1", "@tailwindcss/oxide-wasm32-wasi": "4.2.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw=="], + "@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="], - "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg=="], + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="], - "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw=="], + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.2", "", { "os": "android", "cpu": "arm64" }, "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg=="], - "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw=="], + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg=="], - "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA=="], + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw=="], - "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw=="], + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ=="], - "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ=="], + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ=="], - "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ=="], + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw=="], - "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g=="], + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag=="], - "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g=="], + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg=="], - "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.1", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q=="], + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ=="], - "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA=="], + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.2", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q=="], - "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ=="], + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA=="], "@tailwindcss/vite": ["@tailwindcss/vite@4.2.1", "", { "dependencies": { "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "tailwindcss": "4.2.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w=="], @@ -541,8 +515,6 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - "@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="], - "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], "@types/dom-mediacapture-transform": ["@types/dom-mediacapture-transform@0.1.11", "", { "dependencies": { "@types/dom-webcodecs": "*" } }, "sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ=="], @@ -579,7 +551,7 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], - "@viz-js/viz": ["@viz-js/viz@3.24.0", "", {}, "sha512-sTRz2cFN6PwICVC7GVlF2aYNAZ/LwF7Y1mp3B+8LXQ7otyHrzQYSzNBwJ4lctL/aS3x97ppw59z6zIW+wPs6bQ=="], + "@viz-js/viz": ["@viz-js/viz@3.25.0", "", {}, "sha512-dM7zAYMdf7mcRz5Kdb+YJb6+qv5Rjk0rPZ18gROdpMrP/3S7RFOp8uxybeiz5RypHrE1zo1vccA8Twh4mIcLZw=="], "@webassemblyjs/ast": ["@webassemblyjs/ast@1.14.1", "", { "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ=="], @@ -615,8 +587,6 @@ "@xtuc/long": ["@xtuc/long@4.2.2", "", {}, "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="], - "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], - "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "acorn-import-phases": ["acorn-import-phases@1.0.4", "", { "peerDependencies": { "acorn": "^8.14.0" } }, "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ=="], @@ -635,16 +605,10 @@ "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], - "arctic": ["arctic@3.7.0", "", { "dependencies": { "@oslojs/crypto": "1.0.1", "@oslojs/encoding": "1.1.0", "@oslojs/jwt": "0.2.0" } }, "sha512-ZMQ+f6VazDgUJOd+qNV+H7GohNSYal1mVjm5kEaZfE2Ifb7Ss70w+Q7xpJC87qZDkMZIXYf0pTIYZA0OPasSbw=="], - - "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], - "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], - "array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="], - "array-iterate": ["array-iterate@2.0.1", "", {}, "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg=="], "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], @@ -657,48 +621,26 @@ "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], - "babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="], - "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], "base-64": ["base-64@1.0.0", "", {}, "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg=="], - "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="], - "basic-auth": ["basic-auth@2.0.1", "", { "dependencies": { "safe-buffer": "5.1.2" } }, "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg=="], - - "better-sqlite3": ["better-sqlite3@12.6.2", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA=="], - "big.js": ["big.js@5.2.2", "", {}, "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ=="], - "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], - - "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], - - "body-parser": ["body-parser@1.20.4", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.14.0", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA=="], - "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], "boxen": ["boxen@8.0.1", "", { "dependencies": { "ansi-align": "^3.0.1", "camelcase": "^8.0.0", "chalk": "^5.3.0", "cli-boxes": "^3.0.0", "string-width": "^7.2.0", "type-fest": "^4.21.0", "widest-line": "^5.0.0", "wrap-ansi": "^9.0.0" } }, "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw=="], "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], - "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], - "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], - "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], - - "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], - "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], - "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], - "camelcase": ["camelcase@8.0.0", "", {}, "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA=="], "caniuse-lite": ["caniuse-lite@1.0.30001775", "", {}, "sha512-s3Qv7Lht9zbVKE9XoTyRG6wVDCKdtOFIjBGg3+Yhn6JaytuNKPIjBMTMIY1AnOH3seL5mvF+x33oGAyK3hVt3A=="], @@ -713,9 +655,7 @@ "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], - "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], - - "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], + "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], "chrome-trace-event": ["chrome-trace-event@1.0.4", "", {}, "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ=="], @@ -733,24 +673,12 @@ "common-ancestor-path": ["common-ancestor-path@1.0.1", "", {}, "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w=="], - "compressible": ["compressible@2.0.18", "", { "dependencies": { "mime-db": ">= 1.43.0 < 2" } }, "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg=="], - - "compression": ["compression@1.8.1", "", { "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "negotiator": "~0.6.4", "on-headers": "~1.1.0", "safe-buffer": "5.2.1", "vary": "~1.1.2" } }, "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w=="], - - "confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], - - "content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="], - - "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], - "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], "cookie-es": ["cookie-es@1.2.2", "", {}, "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg=="], - "cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="], - "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="], @@ -773,26 +701,16 @@ "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], - "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], - - "dedent": ["dedent@1.7.2", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA=="], - - "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], - "define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="], "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], - "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], - "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], - "destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="], - "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "deterministic-object-hash": ["deterministic-object-hash@2.0.2", "", { "dependencies": { "base-64": "^1.0.0" } }, "sha512-KxektNH63SrbfUyDiwXqRb1rLwKt33AmMv+5Nhsw1kqZ13SJBRTgZHtGbE+hH3a1mVW1cz+4pqSWVPAtLVXTzQ=="], @@ -819,16 +737,12 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "electron-to-chromium": ["electron-to-chromium@1.5.302", "", {}, "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg=="], "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "emojis-list": ["emojis-list@3.0.0", "", {}, "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q=="], - "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], - "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], "enhanced-resolve": ["enhanced-resolve@5.20.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="], @@ -851,8 +765,6 @@ "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], - "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], - "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "eslint-scope": ["eslint-scope@5.1.1", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" } }, "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw=="], @@ -865,22 +777,12 @@ "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], - "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], - "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], "execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], - "exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="], - - "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], - - "express": ["express@4.22.1", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.3", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g=="], - - "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], - "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], @@ -899,10 +801,6 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], - - "finalhandler": ["finalhandler@1.3.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "statuses": "~2.0.2", "unpipe": "~1.0.0" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="], - "flattie": ["flattie@1.1.1", "", {}, "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ=="], "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], @@ -913,12 +811,6 @@ "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], - "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], - - "fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], - - "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], - "fs-monkey": ["fs-monkey@1.0.3", "", {}, "sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -931,20 +823,14 @@ "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], - "get-port": ["get-port@5.1.1", "", {}, "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ=="], - "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], "get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], - "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], - "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], "glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="], - "globrex": ["globrex@0.1.2", "", {}, "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="], - "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], @@ -987,30 +873,22 @@ "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], - "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], - "human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], - "iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], - "icss-utils": ["icss-utils@5.1.0", "", { "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA=="], - "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], - "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], - "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - - "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], - - "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], - "iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="], "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], @@ -1019,16 +897,12 @@ "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], - "isbot": ["isbot@5.1.35", "", {}, "sha512-waFfC72ZNfwLLuJ2iLaoVaqcNo+CAaLR7xCpAn0Y5WfGzkNHv7ZN39Vbi1y+kb+Zs46XHOX3tZNExroFUPX+Kg=="], - "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "jest-worker": ["jest-worker@27.5.1", "", { "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg=="], "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], - "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], - "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], @@ -1043,36 +917,34 @@ "kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], - "lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], - "lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="], + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], - "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="], + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], - "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="], + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], - "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="], + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], - "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="], + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], - "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="], + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], - "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="], + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], - "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="], + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], - "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="], + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], - "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="], + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], - "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="], + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], "loader-runner": ["loader-runner@4.3.1", "", {}, "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q=="], "loader-utils": ["loader-utils@2.0.4", "", { "dependencies": { "big.js": "^5.2.2", "emojis-list": "^3.0.0", "json5": "^2.1.2" } }, "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw=="], - "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], - "lodash.sortby": ["lodash.sortby@4.7.0", "", {}, "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA=="], "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], @@ -1119,18 +991,12 @@ "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], - "media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="], - "mediabunny": ["mediabunny@1.39.2", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-VcrisGRt+OI7tTPrziucJoCIPYIS/DEWY37TqzQVLWSUUHiyvsiRizEypQ3FOlhfIZ4ytAG/Mw4zxfetCTyKUg=="], "memfs": ["memfs@3.4.3", "", { "dependencies": { "fs-monkey": "1.0.3" } }, "sha512-eivjfi7Ahr6eQTn44nvTnR60e4a1Fs1Via2kCR5lHo/kyNoiMWaXCNJ/GpSd0ilXas2JSOl9B5FTIhflXu0hlg=="], - "merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="], - "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], - "methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="], - "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], @@ -1187,21 +1053,15 @@ "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], - "mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], - "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], - "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], - "minimist": ["minimist@1.2.6", "", {}, "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q=="], - "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], - - "morgan": ["morgan@1.10.1", "", { "dependencies": { "basic-auth": "~2.0.1", "debug": "2.6.9", "depd": "~2.0.0", "on-finished": "~2.3.0", "on-headers": "~1.1.0" } }, "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A=="], + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], @@ -1209,17 +1069,13 @@ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], - "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], - - "negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="], - "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], "neotraverse": ["neotraverse@0.6.18", "", {}, "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA=="], "nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="], - "node-abi": ["node-abi@3.87.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ=="], + "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], @@ -1233,16 +1089,10 @@ "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], - "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], - "ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="], "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], - "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], - - "on-headers": ["on-headers@1.1.0", "", {}, "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A=="], - "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], @@ -1255,8 +1105,6 @@ "p-limit": ["p-limit@6.2.0", "", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="], - "p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="], - "p-queue": ["p-queue@8.1.1", "", { "dependencies": { "eventemitter3": "^5.0.1", "p-timeout": "^6.1.2" } }, "sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ=="], "p-timeout": ["p-timeout@6.1.4", "", {}, "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg=="], @@ -1267,14 +1115,8 @@ "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], - "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], - "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - "path-to-regexp": ["path-to-regexp@0.1.12", "", {}, "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="], - - "pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], - "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], "piccolore": ["piccolore@0.1.3", "", {}, "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw=="], @@ -1283,8 +1125,6 @@ "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], - "pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="], - "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], "postcss-modules-extract-imports": ["postcss-modules-extract-imports@3.1.0", "", { "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q=="], @@ -1299,8 +1139,6 @@ "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], - "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], - "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], @@ -1309,35 +1147,23 @@ "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], - "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], - "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - "qs": ["qs@6.14.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q=="], - "radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="], - "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], - - "raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="], - - "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], - "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], - "react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="], + "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], "react-router": ["react-router@7.12.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw=="], - "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], - - "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], @@ -1379,10 +1205,6 @@ "rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="], - "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], - - "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - "sax": ["sax@1.5.0", "", {}, "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], @@ -1391,14 +1213,8 @@ "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - "send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="], - - "serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="], - "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], - "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], - "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -1407,20 +1223,8 @@ "shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="], - "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], - - "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], - - "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], - - "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], - "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], - "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="], - - "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], - "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], "smol-toml": ["smol-toml@1.6.0", "", {}, "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw=="], @@ -1435,20 +1239,14 @@ "stackframe": ["stackframe@1.3.4", "", {}, "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw=="], - "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], - "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], - "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], - "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], "strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], - "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], - "style-loader": ["style-loader@4.0.0", "", { "peerDependencies": { "webpack": "^5.27.0" } }, "sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA=="], "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], @@ -1461,10 +1259,6 @@ "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], - "tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], - - "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], - "terser": ["terser@5.46.1", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ=="], "terser-webpack-plugin": ["terser-webpack-plugin@5.4.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", "terser": "^5.31.1" }, "peerDependencies": { "webpack": "^5.1.0" } }, "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g=="], @@ -1477,8 +1271,6 @@ "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], - "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], - "tr46": ["tr46@1.0.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA=="], "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], @@ -1489,12 +1281,8 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], - "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], - "type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="], - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="], @@ -1527,8 +1315,6 @@ "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], - "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], - "unstorage": ["unstorage@1.17.4", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.5", "lru-cache": "^11.2.0", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-fHK0yNg38tBiJKp/Vgsq4j0JEsCmgqH58HAn707S7zGkArbZsVr/CwINoi+nh3h98BRCwKvx1K3Xg9u3VV83sw=="], "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], @@ -1539,23 +1325,13 @@ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], - "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], - - "valibot": ["valibot@1.2.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg=="], - - "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], - "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], "vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="], "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], - "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], - - "vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="], - - "vite-tsconfig-paths": ["vite-tsconfig-paths@5.1.4", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" }, "optionalPeers": ["vite"] }, "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w=="], + "vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="], "vitefu": ["vitefu@1.1.2", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw=="], @@ -1605,36 +1381,38 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], - "@astrojs/react/vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="], + "@babel/core/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@babel/generator/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@babel/template/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], - "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="], + "@babel/traverse/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], "@remotion/bundler/esbuild": ["esbuild@0.25.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.0", "@esbuild/android-arm": "0.25.0", "@esbuild/android-arm64": "0.25.0", "@esbuild/android-x64": "0.25.0", "@esbuild/darwin-arm64": "0.25.0", "@esbuild/darwin-x64": "0.25.0", "@esbuild/freebsd-arm64": "0.25.0", "@esbuild/freebsd-x64": "0.25.0", "@esbuild/linux-arm": "0.25.0", "@esbuild/linux-arm64": "0.25.0", "@esbuild/linux-ia32": "0.25.0", "@esbuild/linux-loong64": "0.25.0", "@esbuild/linux-mips64el": "0.25.0", "@esbuild/linux-ppc64": "0.25.0", "@esbuild/linux-riscv64": "0.25.0", "@esbuild/linux-s390x": "0.25.0", "@esbuild/linux-x64": "0.25.0", "@esbuild/netbsd-arm64": "0.25.0", "@esbuild/netbsd-x64": "0.25.0", "@esbuild/openbsd-arm64": "0.25.0", "@esbuild/openbsd-x64": "0.25.0", "@esbuild/sunos-x64": "0.25.0", "@esbuild/win32-arm64": "0.25.0", "@esbuild/win32-ia32": "0.25.0", "@esbuild/win32-x64": "0.25.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw=="], - "@remotion/bundler/react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], - "@remotion/renderer/source-map": ["source-map@0.8.0-beta.0", "", { "dependencies": { "whatwg-url": "^7.0.0" } }, "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA=="], "@remotion/studio/semver": ["semver@7.5.3", "", { "dependencies": { "lru-cache": "^6.0.0" }, "bin": { "semver": "bin/semver.js" } }, "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ=="], "@remotion/studio/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], - "@remotion/studio-server/@babel/parser": ["@babel/parser@7.24.1", "", { "bin": "./bin/babel-parser.js" }, "sha512-Zo9c7N3xdOIQrNip7Lc9wvRPzlRtovHVE4lkz8WEDr7uYh/GMQhSiIgFxGIArRHYdJE5kxtZjAf8rT0xhdLCzg=="], - "@remotion/studio-server/semver": ["semver@7.5.3", "", { "dependencies": { "lru-cache": "^6.0.0" }, "bin": { "semver": "bin/semver.js" } }, "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ=="], "@remotion/zod-types/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + "@tailwindcss/cli/tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="], + + "@tailwindcss/node/tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], @@ -1647,9 +1425,15 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@vitejs/plugin-react/react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], + "@tailwindcss/vite/@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="], - "accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], + "@tailwindcss/vite/@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.1", "@tailwindcss/oxide-darwin-arm64": "4.2.1", "@tailwindcss/oxide-darwin-x64": "4.2.1", "@tailwindcss/oxide-freebsd-x64": "4.2.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", "@tailwindcss/oxide-linux-x64-musl": "4.2.1", "@tailwindcss/oxide-wasm32-wasi": "4.2.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw=="], + + "@types/babel__core/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], + + "@types/babel__template/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], + + "@vitejs/plugin-react/react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], "ajv-formats/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], @@ -1657,66 +1441,34 @@ "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - "astro/vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="], - - "basic-auth/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], - - "body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], - - "compressible/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], - - "compression/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], - "csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="], "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "esrecurse/estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], - "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], - - "express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], - "extract-zip/get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], - "finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], - - "marketing/@viz-js/viz": ["@viz-js/viz@3.25.0", "", {}, "sha512-dM7zAYMdf7mcRz5Kdb+YJb6+qv5Rjk0rPZ18gROdpMrP/3S7RFOp8uxybeiz5RypHrE1zo1vccA8Twh4mIcLZw=="], - - "morgan/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], - - "morgan/on-finished": ["on-finished@2.3.0", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww=="], + "magicast/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], "open/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], "open/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], - "pkg-types/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], - - "prebuild-install/minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], - - "rc/minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], - "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], - "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], - "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], "terser-webpack-plugin/schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="], - "unstorage/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], - - "vite-node/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], "webpack/es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="], "webpack/schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="], - "@astrojs/react/vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], - "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "@remotion/bundler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ=="], @@ -1773,144 +1525,136 @@ "@remotion/studio/semver/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + "@tailwindcss/vite/@tailwindcss/node/lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="], + + "@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg=="], + + "@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw=="], + + "@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw=="], + + "@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA=="], + + "@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw=="], + + "@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ=="], + + "@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ=="], + + "@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g=="], + + "@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g=="], + + "@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.1", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q=="], + + "@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA=="], + + "@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ=="], + "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "astro/vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], - - "body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - - "compression/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="], - "express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - - "finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - - "morgan/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - - "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - "terser-webpack-plugin/schema-utils/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], "terser-webpack-plugin/schema-utils/ajv-keywords": ["ajv-keywords@5.1.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3" }, "peerDependencies": { "ajv": "^8.8.2" } }, "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw=="], - "unstorage/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], + "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], "webpack/schema-utils/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], "webpack/schema-utils/ajv-keywords": ["ajv-keywords@5.1.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3" }, "peerDependencies": { "ajv": "^8.8.2" } }, "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw=="], - "@astrojs/react/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + "@tailwindcss/vite/@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="], - "@astrojs/react/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + "@tailwindcss/vite/@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="], - "@astrojs/react/vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + "@tailwindcss/vite/@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="], - "@astrojs/react/vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + "@tailwindcss/vite/@tailwindcss/node/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="], - "@astrojs/react/vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + "@tailwindcss/vite/@tailwindcss/node/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="], - "@astrojs/react/vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + "@tailwindcss/vite/@tailwindcss/node/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="], - "@astrojs/react/vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + "@tailwindcss/vite/@tailwindcss/node/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="], - "@astrojs/react/vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + "@tailwindcss/vite/@tailwindcss/node/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="], - "@astrojs/react/vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + "@tailwindcss/vite/@tailwindcss/node/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="], - "@astrojs/react/vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + "@tailwindcss/vite/@tailwindcss/node/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="], - "@astrojs/react/vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + "@tailwindcss/vite/@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="], - "@astrojs/react/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + "@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], - "@astrojs/react/vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + "@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], - "@astrojs/react/vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + "@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], - "@astrojs/react/vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + "@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], - "@astrojs/react/vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + "@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], - "@astrojs/react/vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], - - "@astrojs/react/vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], - - "@astrojs/react/vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], - - "@astrojs/react/vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], - - "@astrojs/react/vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], - - "@astrojs/react/vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], - - "@astrojs/react/vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], - - "@astrojs/react/vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], - - "@astrojs/react/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], - - "@astrojs/react/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + "@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "astro/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], - - "astro/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], - - "astro/vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], - - "astro/vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], - - "astro/vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], - - "astro/vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], - - "astro/vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], - - "astro/vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], - - "astro/vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], - - "astro/vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], - - "astro/vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], - - "astro/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], - - "astro/vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], - - "astro/vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], - - "astro/vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], - - "astro/vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], - - "astro/vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], - - "astro/vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], - - "astro/vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], - - "astro/vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], - - "astro/vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], - - "astro/vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], - - "astro/vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], - - "astro/vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], - - "astro/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], - - "astro/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], - "terser-webpack-plugin/schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "webpack/schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], diff --git a/clippy.toml b/clippy.toml index ecc515b4f..93173b2be 100644 --- a/clippy.toml +++ b/clippy.toml @@ -1,2 +1,13 @@ absolute-paths-max-segments = 2 absolute-paths-allowed-crates = ["std", "core", "alloc"] +disallowed-methods = [ + { path = "std::thread::sleep", reason = "Prefer tokio::time::sleep on Tokio paths; document intentional blocking sleeps with #[expect(clippy::disallowed_methods, reason = \"...\")]", replacement = "tokio::time::sleep" }, + { path = "std::thread::spawn", reason = "Prefer Tokio task APIs on async paths; document intentional dedicated OS threads with #[expect(clippy::disallowed_methods, reason = \"...\")]" }, + { path = "std::thread::Builder::spawn", reason = "Prefer Tokio task APIs on async paths; document intentional dedicated OS threads with #[expect(clippy::disallowed_methods, reason = \"...\")]" }, + { path = "std::process::Command::new", reason = "Prefer tokio::process::Command on Tokio paths; document intentional synchronous subprocesses with #[expect(clippy::disallowed_methods, reason = \"...\")]" }, + { 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 }, + { path = "reqwest::blocking::Client::builder", reason = "Use fabro_http::BlockingHttpClientBuilder::new()", allow-invalid = true }, + { path = "reqwest::get", reason = "Build a fabro_http client and send the request explicitly", allow-invalid = true }, +] diff --git a/docker/demo-server.toml b/docker/demo-settings.toml similarity index 100% rename from docker/demo-server.toml rename to docker/demo-settings.toml diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 4c55ed074..8ec7f8ca1 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -20,7 +20,7 @@ services: environment: - FABRO_DEMO=1 volumes: - - ./demo-server.toml:/root/.fabro/server.toml:ro + - ./demo-settings.toml:/root/.fabro/settings.toml:ro - ../apps/fabro-web/public:/app/apps/fabro-web/public depends_on: - api diff --git a/docker/entrypoint.ts b/docker/entrypoint.ts index 8300800c5..6824a8ad7 100644 --- a/docker/entrypoint.ts +++ b/docker/entrypoint.ts @@ -1,11 +1,11 @@ const service = process.argv[2]; -type ServiceConfig = { +type ServiceOptions = { command: string[]; cwd?: string; }; -const services: Record = { +const services: Record = { api: { command: ["fabro", "serve", "--host", "0.0.0.0"], }, diff --git a/docs-internal/demo/14-search-imagegen.toml b/docs-internal/demo/14-search-imagegen.toml index dd217f77e..d6eb2b9a4 100644 --- a/docs-internal/demo/14-search-imagegen.toml +++ b/docs-internal/demo/14-search-imagegen.toml @@ -6,14 +6,14 @@ graph = "14-search-imagegen.fabro" provider = "daytona" [sandbox.env] -GEMINI_API_KEY = "${env.GEMINI_API_KEY}" +GEMINI_API_KEY = "{{ env.GEMINI_API_KEY }}" [sandbox.daytona.snapshot] name = "imagegen-tools-v3" cpu = 4 memory = 8 disk = 10 -dockerfile = { path = "../../fabro/workflows/imagegen/Dockerfile.imagegen" } +dockerfile = { path = "../../.fabro/workflows/imagegen/Dockerfile.imagegen" } [assets] include = ["output/**"] diff --git a/docs-internal/event-schema-competitive-analysis.md b/docs-internal/event-schema-competitive-analysis.md new file mode 100644 index 000000000..9763fdf30 --- /dev/null +++ b/docs-internal/event-schema-competitive-analysis.md @@ -0,0 +1,376 @@ +# Event Schema Competitive Analysis + +Date: 2026-04-08 + +This report compares the event schemas used by: + +- Claude Sessions API +- Claude Code +- Goose +- OpenAI Codex +- OpenCode +- pi-mono + +Goal: identify patterns Fabro should copy, avoid, or formalize more clearly. + +## Executive Summary + +Fabro's current event model is already ahead of most of the field on one important point: it has a canonical envelope with stable metadata (`id`, `ts`, `run_id`, `event`, optional `session_id`, `parent_session_id`, `node_id`, `node_label`) and a typed internal-to-external mapping. + +The biggest improvement opportunities are not "more events." They are: + +1. Keep transport concerns separate from domain events, but document them as part of the contract. +2. Make every streamed event part of one explicit public schema. Avoid opaque blobs and server-injected fields that the schema does not admit. +3. Add more first-class correlation fields where the UI or downstream systems need them, especially `turn_id`, `message_id`, `tool_call_id`, `request_id`, and retry/attempt IDs. +4. Make retry, stop, idle, and requires-action states machine-readable unions instead of loose strings. +5. Be explicit about delta vs snapshot semantics and replay behavior. + +## Fabro Baseline + +Fabro's current strategy is documented in `docs-internal/events-strategy.md`. The canonical external shape is: + +```json +{ + "id": "uuidv7", + "ts": "2026-03-30T12:00:01.000Z", + "run_id": "01JQ...", + "event": "agent.tool.started", + "session_id": "ses_child", + "parent_session_id": "ses_parent", + "node_id": "code", + "node_label": "Code", + "properties": { "...": "..." } +} +``` + +That envelope is stronger than most comparator systems. It gives Fabro stable top-level metadata, keeps event-specific data inside `properties`, and avoids flattening arbitrary fields into the root. + +Relevant current Fabro sources: + +- `docs-internal/events-strategy.md` +- `lib/crates/fabro-workflow/src/event.rs` +- `lib/crates/fabro-types/src/run_event/mod.rs` +- `lib/crates/fabro-agent/src/types.rs` + +## Comparison Matrix + +| System | Public event surface | Discriminator | Universal envelope fields on every event | Replay / ordering story | Main strength | Main weakness | +| --- | --- | --- | --- | --- | --- | --- | +| Claude Sessions | 20-event public union | `type` | `id`, `processed_at` | Event IDs exist; replay semantics are not part of the event payload | Very explicit, stable union with typed nested states | Less transport detail and fewer workflow-specific events | +| Claude Code | 24 core SDK messages, 31 stdout/control variants | `type`, often `subtype` | Usually `uuid`, `session_id`; no universal timestamp | Streaming is batched/coalesced; control and domain share the same channel | Rich task, hook, status, and tool progress | Nested stream payload is opaque at runtime; control and data are mixed | +| Goose | 7 `MessageEvent` variants | `type` | None in JSON payload; SSE `id` is outside payload | Strong SSE replay via monotonic seq + `Last-Event-ID` + replay buffer | Reattach/replay semantics are clear | Transport fields are injected outside schema; payload typing is shallow | +| Codex SDK | 8 thread events | `type` | No universal ID/timestamp | Ordered stream, no replay contract in payload | Very simple client model | Too minimal for rich UIs and analytics | +| Codex app-server | 49 notification methods | `method` + `params` | No universal ID/timestamp in params | Ordered notifications, no seq/replay field | Richest low-level protocol in the set | Fragmented event story; transport shape leaks into the schema | +| OpenCode | 45 generated event variants | `type` + `properties` | No universal ID/timestamp | Plain SSE; client supports SSE IDs but server does not emit them | Broadest app/runtime event coverage | Wire/schema drift and no universal envelope | +| pi-mono | 12 assistant stream events, 10 agent events, 14 session events | `type` | Session header only; not per event | JSONL stream, no replay contract | Excellent streaming lifecycle grammar | No durable universal envelope for downstream consumers | + +## System Notes + +### Claude Sessions + +What it does well: + +- One explicit public union. +- Every event has `id`, `type`, and `processed_at`. +- Tool confirmation, custom tool results, MCP tool use, session errors, session status, and model-span events are all first-class. +- Terminal and waiting states are structured. `session.status_idle.stop_reason` is not a loose string; it is a small union. +- Error reporting is structured. `session.error.error` is a tagged union, not just a message. + +Why it matters for Fabro: + +- This is the cleanest example of a public agent-session event API that is still small enough to understand. +- The main idea to copy is not the exact event list. It is the discipline: explicit tagged unions for stop reasons, errors, and status transitions. + +Sources: + +- `https://platform.claude.com/docs/en/api/beta/sessions/events/stream` +- `https://platform.claude.com/docs/specs/merged.53db30dfcc06f431.json.gz` + +### Claude Code + +Schema shape: + +- `SDKMessageSchema` contains 24 core message variants. +- `StdoutMessageSchema` expands the stdout protocol to 31 variants once control messages and keep-alives are included. +- Many events use `type: "system"` plus a `subtype`, for example `init`, `status`, `api_retry`, `hook_started`, `task_progress`, and `session_state_changed`. +- Streaming assistant output is wrapped as `type: "stream_event"`. + +What it does well: + +- It covers more than just model output: task lifecycle, hook lifecycle, compaction boundaries, retries, authentication state, file persistence, tool progress, prompt suggestions. +- It carries `uuid` and `session_id` widely, which is useful for correlation. +- It includes explicit session-state transitions (`idle`, `running`, `requires_action`). + +What is weak: + +- The nested streaming payload is not explicitly validated at runtime. `RawMessageStreamEventPlaceholder` is `z.unknown()`. +- Control protocol messages live in the same stream as domain messages. +- `type: "system"` plus `subtype` is workable, but less ergonomic than a flatter public union. +- There is no universal top-level timestamp on every event. + +Why it matters for Fabro: + +- Copy the breadth, not the shape. +- Avoid opaque inner payloads in public schemas. +- Avoid mixing keep-alive/control/config traffic into the same schema that product consumers use for analytics and UI rendering. + +Sources: + +- `/Users/bhelmkamp/p/AnkanMisra/claude-code/src/entrypoints/sdk/coreSchemas.ts` +- `/Users/bhelmkamp/p/AnkanMisra/claude-code/src/entrypoints/sdk/controlSchemas.ts` +- `/Users/bhelmkamp/p/AnkanMisra/claude-code/src/remote/sdkMessageAdapter.ts` +- `/Users/bhelmkamp/p/AnkanMisra/claude-code/src/cli/transports/ccrClient.ts` +- `/Users/bhelmkamp/p/AnkanMisra/claude-code/src/utils/sdkEventQueue.ts` + +### Goose + +Schema shape: + +- One small SSE payload union: `Message`, `Error`, `Finish`, `Notification`, `UpdateConversation`, `ActiveRequests`, `Ping`. +- SSE `id:` carries a monotonic sequence number. +- Session replay uses `Last-Event-ID` plus a replay buffer. +- `request_id` and `chat_request_id` are injected at the SSE framing layer, not modeled in the payload type. + +What it does well: + +- Clear reattach story. +- Monotonic sequence numbers are transport-level, not payload-level. +- `ActiveRequests` lets the client discover in-flight work when reconnecting. + +What is weak: + +- The public event payload omits fields the client actually consumes. +- `Notification.message` is effectively an untyped object. +- The session stream also emits comment heartbeats outside the schema, and there is a separate `Ping` payload variant in the shared enum. That split is easy to drift. + +Why it matters for Fabro: + +- Goose is the best example here for replay and reconnect semantics. +- The lesson is not "put sequence numbers in the payload." The lesson is "formalize replay outside the payload, and do not rely on undocumented injected fields." + +Sources: + +- `/Users/bhelmkamp/p/block/goose/crates/goose-server/src/routes/reply.rs` +- `/Users/bhelmkamp/p/block/goose/crates/goose-server/src/routes/session_events.rs` +- `/Users/bhelmkamp/p/block/goose/crates/goose-server/src/session_event_bus.rs` +- `/Users/bhelmkamp/p/block/goose/ui/desktop/openapi.json` +- `/Users/bhelmkamp/p/block/goose/ui/desktop/src/hooks/useSessionEvents.ts` + +### OpenAI Codex + +There are really two event systems: + +1. The TypeScript SDK `ThreadEvent` surface. +2. The app-server `ServerNotification` protocol. + +SDK shape: + +- 8 high-level events: thread started, turn started/completed/failed, item started/updated/completed, fatal stream error. +- Rich detail is pushed down into `ThreadItem`, which includes `agent_message`, `reasoning`, `command_execution`, `file_change`, `mcp_tool_call`, `web_search`, `todo_list`, and `error`. + +App-server shape: + +- 49 server notification methods. +- Notifications are discriminated by `method`, with a typed `params` object. +- Coverage includes thread lifecycle, turn lifecycle, item lifecycle, deltas, token usage, command output, MCP progress, model reroutes, config warnings, and experimental realtime notifications. + +What it does well: + +- Good separation of a simple developer-facing SDK from a richer system protocol. +- The low-level protocol is broad and explicit. +- Experimental notifications are clearly labeled as experimental. + +What is weak: + +- The event story is fragmented. "Which schema should I build against?" depends on which integration layer you pick. +- There is no universal timestamp or universal event ID in the event bodies. +- `method` + `params` is transport-shaped. It works well for JSON-RPC, but it is not as clean as a transport-agnostic event envelope. + +Why it matters for Fabro: + +- If Fabro needs both a high-level SDK and a low-level protocol, document the layering explicitly. +- If Fabro only needs one event stream, a single canonical envelope is simpler than method-shaped notifications. + +Sources: + +- `/Users/bhelmkamp/p/openai/codex/sdk/typescript/src/events.ts` +- `/Users/bhelmkamp/p/openai/codex/sdk/typescript/src/items.ts` +- `/Users/bhelmkamp/p/openai/codex/sdk/typescript/src/thread.ts` +- `/Users/bhelmkamp/p/openai/codex/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts` +- `/Users/bhelmkamp/p/openai/codex/codex-rs/app-server-protocol/src/protocol/common.rs` +- `/Users/bhelmkamp/p/openai/codex/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartedNotification.ts` +- `/Users/bhelmkamp/p/openai/codex/codex-rs/app-server-protocol/schema/typescript/v2/TurnCompletedNotification.ts` +- `/Users/bhelmkamp/p/openai/codex/codex-rs/app-server-protocol/schema/typescript/v2/AgentMessageDeltaNotification.ts` +- `/Users/bhelmkamp/p/openai/codex/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecOutputDeltaNotification.ts` + +### OpenCode + +Schema shape: + +- One generated `Event` union with 45 variants. +- `GlobalEvent` adds `directory` plus `payload: Event`. +- Events use `type` plus a `properties` object. +- Coverage includes questions, permissions, messages, message parts, session status/idle/compacted/error/diff, workspace readiness, PTYs, worktrees, VCS, file edits, MCP, TUI commands, and more. + +What it does well: + +- Broad coverage. +- Generated API types from the server surface. +- Clear split between session-scoped events and global events. +- Status is partly structured. `SessionStatus` is a union of `idle`, `retry`, and `busy`. + +What is weak: + +- No universal event ID. +- No universal timestamp. +- No replay cursor or sequence field. +- The wire stream emits `server.heartbeat`, but the generated `Event` union does not include it. +- The global event schema says `directory` is present, but the initial global `server.connected` and heartbeat frames omit it. + +Why it matters for Fabro: + +- OpenCode shows how far a generated event surface can go. +- It also shows the cost of not having a canonical envelope: clients must reconstruct correlation from nested `sessionID`, `messageID`, `partID`, and route-specific wrappers. + +Sources: + +- `/Users/bhelmkamp/p/anomalyco/opencode/packages/sdk/js/src/v2/gen/types.gen.ts` +- `/Users/bhelmkamp/p/anomalyco/opencode/packages/sdk/js/src/v2/gen/core/serverSentEvents.gen.ts` +- `/Users/bhelmkamp/p/anomalyco/opencode/packages/opencode/src/server/server.ts` +- `/Users/bhelmkamp/p/anomalyco/opencode/packages/opencode/src/server/routes/global.ts` +- `/Users/bhelmkamp/p/anomalyco/opencode/packages/opencode/src/server/event.ts` +- `/Users/bhelmkamp/p/anomalyco/opencode/packages/web/src/content/docs/server.mdx` + +### pi-mono + +Schema shape: + +- `AssistantMessageEvent` has 12 streaming variants: `start`, block start/delta/end for text, thinking, and tool calls, then `done` or `error`. +- `AgentEvent` has 10 lifecycle variants across agent, turn, message, and tool execution. +- `AgentSessionEvent` extends `AgentEvent` with 4 session-only retry/compaction events. +- JSON mode starts with a session header, then emits JSONL events. + +What it does well: + +- Excellent streaming lifecycle grammar. +- Strong layering: + - low-level assistant stream events + - mid-level agent lifecycle events + - high-level session events +- The proxy mode has a bandwidth-optimized streaming shape that intentionally strips partial message snapshots and reconstructs them client-side. + +What is weak: + +- There is no universal per-event envelope. +- There are no event IDs or replay semantics. +- Timestamping is inconsistent. The session header has a timestamp, and some embedded message objects have timestamps, but not every event line does. + +Why it matters for Fabro: + +- pi-mono is the best example here for event layering and start/delta/end/done grammar. +- Fabro should borrow that lifecycle discipline if it expands live agent streaming, but keep Fabro's stronger envelope. + +Sources: + +- `/Users/bhelmkamp/p/badlogic/pi-mono/packages/ai/src/types.ts` +- `/Users/bhelmkamp/p/badlogic/pi-mono/packages/agent/src/types.ts` +- `/Users/bhelmkamp/p/badlogic/pi-mono/packages/agent/src/proxy.ts` +- `/Users/bhelmkamp/p/badlogic/pi-mono/packages/coding-agent/src/core/agent-session.ts` +- `/Users/bhelmkamp/p/badlogic/pi-mono/packages/coding-agent/docs/json.md` + +## Cross-System Patterns + +### Patterns worth copying + +- One obvious discriminator per public event. +- Explicit unions for error state, stop reason, retry state, and requires-action state. +- Stable correlation IDs for session/thread/turn/message/tool levels. +- A documented replay story for long-running streams. +- Generated public schemas from one source of truth. +- A clear distinction between snapshot events and delta events. + +### Patterns worth avoiding + +- Opaque `unknown` payloads inside otherwise typed events. +- Server-injected fields that the public schema does not model. +- Mixing keep-alives, control RPCs, and domain events in one event contract. +- Event systems that only make sense in the context of one transport, for example JSON-RPC `method`/`params`, when the real need is a transport-agnostic event log. +- No universal ID or timestamp on durable events. + +## Recommendations For Fabro + +### 1. Keep the canonical envelope + +Fabro should keep `id`, `ts`, `run_id`, `event`, `session_id`, `parent_session_id`, `node_id`, and `node_label` exactly as the backbone of the public schema. That is already better than every comparator except Claude Sessions on consistency. + +### 2. Do not let transport metadata leak informally + +If Fabro supports SSE replay or live reattach, define transport rules explicitly: + +- SSE `id` +- replay cursor semantics +- comment heartbeat vs payload heartbeat +- reconnect guarantees + +Do not make clients depend on extra fields injected by one server path that are absent from the formal schema. + +### 3. Add deeper correlation IDs where the product needs them + +Fabro already has run/session/node metadata. The next likely additions are: + +- `turn_id` +- `message_id` +- `tool_call_id` +- `request_id` +- `attempt` + +Those should be explicit schema fields, not encoded into ad hoc strings. + +### 4. Prefer unions over stringly terminal state + +If Fabro expands live session or agent events, model terminal and waiting states like Claude Sessions does: + +- `stop_reason` +- `retry_status` +- `requires_action` +- `error_kind` + +Avoid free-form strings when a small tagged union will do. + +### 5. Standardize lifecycle families + +If Fabro emits live agent output, choose one lifecycle grammar and document it: + +- `.started` +- `.delta` +- `.completed` +- `.failed` + +If snapshot replacement events also exist, mark them clearly and document when clients should treat them as authoritative replacement vs append-only updates. + +### 6. Keep domain events separate from control and keep-alive traffic + +Claude Code shows the downside of multiplexing control requests, control responses, keep-alives, and domain messages in one stream contract. Fabro's durable run events should stay product-facing and analyzable. + +### 7. Add schema-drift tests for streamed events + +OpenCode and Goose both show how easy it is for the wire stream to diverge from the published schema. Fabro should keep tests that validate: + +- every emitted streamed payload is representable by the public schema +- no consumer-visible fields are injected outside the schema +- replay/heartbeat frames are documented and tested separately + +## Bottom Line + +Fabro does not need to copy any one competitor's schema wholesale. + +The best composite design is: + +- Claude Sessions' explicit unions for status and errors +- Goose's replay semantics +- Codex's separation between a simple high-level client view and a richer low-level view, if Fabro ever needs both +- OpenCode's breadth of runtime events +- pi-mono's streaming lifecycle grammar +- Fabro's existing canonical envelope as the foundation + +That combination would produce an event model that is both durable and ergonomic: good for live UI streaming, replay, analytics, tests, and long-term compatibility. diff --git a/docs-internal/events-strategy.md b/docs-internal/events-strategy.md index 88a3e40b9..2ec664223 100644 --- a/docs-internal/events-strategy.md +++ b/docs-internal/events-strategy.md @@ -1,34 +1,35 @@ # Fabro Events Strategy -Fabro emits structured **workflow run events** during execution for observability. Events are the durable audit trail for a run: they drive `progress.jsonl`, `live.json`, the run store, SSE streaming, CLI progress rendering, and retro analysis. +Fabro emits structured **workflow run events** during execution for observability. Events are the durable audit trail for a run: they drive the run store, SSE streaming, CLI progress rendering, retro analysis, and optional JSONL sinks. Events are distinct from tracing logs. Tracing is developer diagnostics; events are product-facing state transitions and activity records that other systems consume. -Detached runs rely on this distinction. If something needs to be visible after reattach, emit a `WorkflowRunEvent` rather than only logging to stderr or `detach.log`. +Detached runs rely on this distinction. If something needs to be visible after reattach, emit a `Event` rather than only logging to stderr or `detach.log`. ## Architecture ```text -Engine/Handler -> WorkflowRunEvent -> EventEmitter::emit() - |- trace(raw event) - |- canonicalize -> RunEventEnvelope - `- on_event(&RunEventEnvelope) - |- progress.jsonl + live.json +Engine/Handler -> Event -> Emitter::emit() + |- trace(raw event) + |- canonicalize -> RunEvent + `- on_event(&RunEvent) |- run store |- SSE + |- optional JSONL/debug sinks `- CLI / tests / metrics listeners ``` -The canonical envelope is built exactly once in `fabro-workflow/src/event.rs`. +The canonical `RunEvent` is built exactly once in `fabro-workflow/src/event.rs`. -- `WorkflowRunEvent` remains the internal typed source of truth. -- `EventEmitter` owns an immutable `run_id` and converts typed events into `RunEventEnvelope`. -- Every listener receives `&RunEventEnvelope`, not `&WorkflowRunEvent`. -- Bypass paths that cannot go through the emitter must call `canonicalize_event()` once and reuse the same envelope for every sink. +- `Event` (in `fabro-workflow`) is the internal typed event emitted by engine and handlers. +- `Emitter` owns an immutable `run_id` and converts `Event` into `RunEvent` via `to_run_event_at()`. +- `RunEvent` (in `fabro-types`) holds envelope metadata plus a typed `body: EventBody`. It has no cached JSON fields; the wire format is produced only during serialization. +- Every listener receives `&RunEvent`, not `&Event`. +- Bypass paths that cannot go through the emitter must call `to_run_event()` once and reuse the same `RunEvent` for every sink. ## Canonical Envelope -Each line in `progress.jsonl` is a `RunEventEnvelope`: +Each serialized `RunEvent` uses this canonical envelope: ```json { @@ -100,43 +101,48 @@ Agent events now use explicit session links: ## Direct-Write Paths -Most events flow through `EventEmitter::emit()`. The remaining direct-write paths must use: +Most events flow through `Emitter::emit()`. The remaining direct-write paths must use: -1. `canonicalize_event(run_id, event)` +1. `to_run_event(run_id, event)` 2. Serialize and redact once -3. Reuse that exact serialized envelope for every sink +3. Reuse that exact `RunEvent` for every sink -Never canonicalize the same logical event twice if multiple sinks receive it. +Never build the same `RunEvent` twice if multiple sinks receive it. ## Adding A New Event ### 1. Add the typed event -Add a variant to `WorkflowRunEvent`, `AgentEvent`, or `SandboxEvent` as appropriate. +Add a variant to `Event`, `AgentEvent`, or `SandboxEvent` as appropriate. ### 2. Add tracing -Extend `WorkflowRunEvent::trace()` so the raw event is observable in tracing output. +Extend `Event::trace()` so the raw event is observable in tracing output. ### 3. Add an external name Extend `event_name()` with the new lowercase dot-notation string. -### 4. Map envelope fields +### 4. Add the `EventBody` variant -Update `extract_envelope_fields()`: +Add a variant to `EventBody` in `fabro-types/src/run_event/mod.rs` with a corresponding props struct. Use `#[serde(rename = "dotted.name")]` matching the external name from step 3. + +### 5. Map envelope fields and construct `EventBody` + +Update `stored_event_fields()` and `event_body_from_event()` in `fabro-workflow/src/event.rs`: - Move `node_id`, `node_label`, `session_id`, and `parent_session_id` into the envelope when appropriate. -- Keep event-specific data in `properties`. -- Flatten structured failure details into explicit property keys when needed. +- Construct the `EventBody` variant directly from the `Event` fields. +- For `Event::Agent` sub-variants, merge `visit` into the inner props and lift `stage` to `node_id`. +- For `Event::Sandbox` sub-variants, unwrap and flatten into the corresponding `EventBody` variant. -### 5. Emit it +### 6. Emit it -Prefer `EventEmitter::emit(&WorkflowRunEvent::...)`. +Prefer `Emitter::emit(&Event::...)`. -Use `canonicalize_event()` only for true bypass paths. +Use `to_run_event()` only for true bypass paths. -### 6. Update consumers +### 7. Update consumers Check: @@ -148,17 +154,23 @@ Check: ## Consumer Guidance -When writing listeners: +When writing Rust consumers (listeners, store projections, CLI progress): -- Match on `envelope.event`, not Rust variant names. -- Read event payload from `envelope.properties`. -- Read stage/branch identity from `node_id` and `node_label`. -- Read agent hierarchy from `session_id` and `parent_session_id`. +- Match on `event.body` using `EventBody::*` variants. This gives you typed access to event-specific fields. +- Use `event.node_id`, `event.node_label`, `event.session_id`, and `event.parent_session_id` for envelope metadata. +- Only use `event.event_name()` or `event.properties()` for generic/display purposes (logging, forwarding). These involve serialization and should not be used on hot paths. -Do not rebuild or mutate the envelope in downstream listeners. +When writing external JSON consumers (SSE clients, JSONL parsers): + +- Match on the `"event"` field for the dot-notation event name. +- Read event-specific data from `"properties"`. +- Read stage/branch identity from `"node_id"` and `"node_label"`. +- Read agent hierarchy from `"session_id"` and `"parent_session_id"`. + +Do not rebuild or mutate the `RunEvent` in downstream listeners. ## Bypass And Persistence Guarantees -`progress.jsonl`, the run store, and SSE should reflect the same canonical envelope bytes after redaction. +Any JSONL sink, the run store, and SSE should reflect the same canonical envelope bytes after redaction. `status.json` remains the authoritative completion signal for detached runs. Terminal run status should only be written after all post-run work is finished. diff --git a/docs-internal/events.md b/docs-internal/events.md new file mode 100644 index 000000000..71174e0d7 --- /dev/null +++ b/docs-internal/events.md @@ -0,0 +1,2149 @@ +# Events + +Every serialized run event envelope, whether streamed over SSE, returned by `fabro logs`, or written to a JSONL sink, uses this structure: + +```json +{ + "id": "019234ab-cdef-7890-abcd-ef1234567890", + "ts": "2026-04-01T12:00:00.123Z", + "run_id": "01JQXYZ...", + "event": "stage.completed", + "session_id": "ses_abc", + "parent_session_id": "ses_parent", + "node_id": "code", + "node_label": "Write Code", + "properties": { ... } +} +``` + +### Envelope fields + +| Field | Type | Description | +|-------|------|-------------| +| `id` | string | UUID v7 (time-ordered), unique per event | +| `ts` | string | RFC 3339 timestamp with millisecond precision | +| `run_id` | string | ULID of the run | +| `event` | string | Dot-notation event name | +| `session_id` | string? | Agent session id (agent events only) | +| `parent_session_id` | string? | Parent agent session id (agent events only) | +| `node_id` | string? | Node id (stage, checkpoint, agent, parallel branch, and other node-scoped events) | +| `node_label` | string? | Display label for the node (defaults to `node_id` when not set separately) | +| `properties` | object | Event-specific fields | + +--- + +## Run events + +### `run.started` + +Emitted when the workflow run begins. + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "run.started", + "properties": { + "name": "my-workflow", + "base_branch": "main", + "base_sha": "abc123...", + "run_branch": "fabro/run-01JQXYZ", + "worktree_dir": "/tmp/fabro-worktrees/...", + "goal": "Fix the login bug" + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `name` | string | Workflow name | +| `base_branch` | string? | Base git branch | +| `base_sha` | string? | Base commit SHA | +| `run_branch` | string? | Git branch created for this run | +| `worktree_dir` | string? | Worktree directory path | +| `goal` | string? | Workflow goal text | + +Note: `run_id` is in the envelope, not in properties. + +### `run.completed` + +Emitted when the workflow run finishes successfully (or with partial success). + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "run.completed", + "properties": { + "duration_ms": 45000, + "artifact_count": 3, + "status": "success", + "total_cost": 0.15, + "final_git_commit_sha": "def456...", + "usage": { + "input_tokens": 15000, + "output_tokens": 5000, + "total_tokens": 20000, + "reasoning_tokens": 2000, + "cache_read_tokens": 8000, + "cache_write_tokens": 3000, + "speed": "standard" + } + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `duration_ms` | number | Total run duration in milliseconds | +| `artifact_count` | number | Number of artifacts produced | +| `status` | string | Final status (`"success"`, `"fail"`, `"partial_success"`) | +| `total_cost` | number? | Aggregate cost in USD | +| `final_git_commit_sha` | string? | Final HEAD SHA | +| `usage` | object? | Aggregate token usage | +| `usage.input_tokens` | number | Total input tokens | +| `usage.output_tokens` | number | Total output tokens | +| `usage.total_tokens` | number | Total tokens (input + output) | +| `usage.reasoning_tokens` | number? | Total reasoning/thinking tokens | +| `usage.cache_read_tokens` | number? | Total cache read tokens | +| `usage.cache_write_tokens` | number? | Total cache write tokens | +| `usage.speed` | string? | Speed tier | +| `usage.raw` | object? | Raw provider-specific usage data | + +### `run.failed` + +Emitted when the workflow run fails. + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "run.failed", + "properties": { + "error": "Handler error: compilation failed", + "duration_ms": 12000, + "git_commit_sha": "abc123..." + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `error` | string | Error message (Display representation) | +| `duration_ms` | number | Run duration before failure | +| `git_commit_sha` | string? | HEAD SHA at time of failure | + +### `run.notice` + +Informational, warning, or error notice emitted during the run. + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "run.notice", + "properties": { + "level": "warn", + "code": "missing_env_var", + "message": "GITHUB_TOKEN not set, PR creation will be skipped" + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `level` | string | `"info"`, `"warn"`, or `"error"` | +| `code` | string | Machine-readable notice code | +| `message` | string | Human-readable message | + +--- + +## Stage events + +### `stage.started` + +Emitted when a workflow node begins execution. + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "stage.started", + "node_id": "code", + "node_label": "Write Code", + "properties": { + "index": 1, + "handler_type": "agent", + "attempt": 1, + "max_attempts": 3 + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `index` | number | Stage execution order index | +| `handler_type` | string | Handler type (`"agent"`, `"prompt"`, `"command"`, `"conditional"`, `"human"`, `"parallel"`, etc.) | +| `attempt` | number | Current attempt number (1-based) | +| `max_attempts` | number | Maximum attempts allowed | + +### `stage.completed` + +Emitted when a workflow node finishes execution. + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "stage.completed", + "node_id": "code", + "node_label": "Write Code", + "properties": { + "index": 1, + "duration_ms": 8000, + "status": "success", + "preferred_label": "tests_pass", + "suggested_next_ids": ["review"], + "usage": { + "model": "claude-sonnet-4-20250514", + "input_tokens": 5000, + "output_tokens": 2000, + "cache_read_tokens": 3000, + "cache_write_tokens": 1000, + "reasoning_tokens": 500, + "speed": "standard", + "cost": 0.05 + }, + "error": "lint failed", + "failure_class": "deterministic", + "failure_signature": "clippy::unused_import", + "context_updates": {"response.code": "done"}, + "jump_to_node": "review", + "context_values": {"response.code": "done"}, + "node_visits": {"code": 1}, + "loop_failure_signatures": {"code|deterministic|clippy::unused_import": 2}, + "restart_failure_signatures": {"code|transient_infra|timeout": 1}, + "response": "done", + "notes": "All tests passing", + "files_touched": ["src/main.rs", "src/lib.rs"], + "attempt": 1, + "max_attempts": 3 + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `index` | number | Stage execution order index | +| `duration_ms` | number | Stage duration in milliseconds | +| `status` | string | `"success"`, `"fail"`, `"skipped"`, `"partial_success"`, `"retry"` | +| `preferred_label` | string? | Edge label hint for routing | +| `suggested_next_ids` | string[] | Suggested successor node ids | +| `usage` | object? | Token usage for this stage | +| `usage.model` | string | Model identifier | +| `usage.input_tokens` | number | Input tokens | +| `usage.output_tokens` | number | Output tokens | +| `usage.cache_read_tokens` | number? | Cache read tokens | +| `usage.cache_write_tokens` | number? | Cache write tokens | +| `usage.reasoning_tokens` | number? | Reasoning/thinking tokens | +| `usage.speed` | string? | Speed tier | +| `usage.cost` | number? | Estimated cost in USD | +| `error` | string? | Error message (flattened from failure detail) | +| `failure_class` | string? | `"transient_infra"`, `"deterministic"`, `"budget_exhausted"`, `"compilation_loop"`, `"canceled"`, `"structural"` | +| `failure_signature` | string? | Dedup key for repeated failures | +| `context_updates` | object? | Context delta written by this stage | +| `jump_to_node` | string? | Non-edge jump target | +| `context_values` | object? | Full context snapshot after the stage | +| `node_visits` | object? | Node visit counts after the stage | +| `loop_failure_signatures` | object? | Loop failure signature counts | +| `restart_failure_signatures` | object? | Restart failure signature counts | +| `response` | string? | Full LLM or agent response text when produced by the stage | +| `notes` | string? | Free-text notes | +| `files_touched` | string[] | File paths modified | +| `attempt` | number | Attempt number (1-based) | +| `max_attempts` | number | Maximum attempts allowed | + +Note: `failure` is flattened — the `failure.message` becomes `error`, `failure.failure_class` becomes `failure_class`, `failure.failure_signature` becomes `failure_signature`. + +### `stage.failed` + +Emitted when a stage fails (before retry decision). + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "stage.failed", + "node_id": "code", + "node_label": "Write Code", + "properties": { + "index": 1, + "error": "compilation failed", + "failure_class": "deterministic", + "failure_signature": "rustc::E0308", + "will_retry": true + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `index` | number | Stage execution order index | +| `error` | string | Error message (flattened from failure detail) | +| `failure_class` | string | Failure category | +| `failure_signature` | string? | Dedup key for repeated failures | +| `will_retry` | boolean | Whether the stage will be retried | + +### `stage.retrying` + +Emitted when a stage is about to be retried. + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "stage.retrying", + "node_id": "code", + "node_label": "Write Code", + "properties": { + "index": 1, + "attempt": 2, + "max_attempts": 3, + "delay_ms": 1000 + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `index` | number | Stage execution order index | +| `attempt` | number | Next attempt number | +| `max_attempts` | number | Maximum attempts allowed | +| `delay_ms` | number | Delay before retry in milliseconds | + +### `stage.prompt` + +Emitted when a prompt is rendered for an LLM stage. + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "stage.prompt", + "node_id": "code", + "node_label": "code", + "properties": { + "text": "You are a coding agent. Fix the bug in..." + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `text` | string | Rendered prompt text | + +--- + +## Parallel events + +### `parallel.started` + +Emitted when a parallel node begins executing branches. + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "parallel.started", + "properties": { + "branch_count": 3, + "join_policy": "all" + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `branch_count` | number | Number of parallel branches | +| `join_policy` | string | Join policy | + +### `parallel.branch.started` + +Emitted when a parallel branch begins. + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "parallel.branch.started", + "node_id": "branch_a", + "node_label": "branch_a", + "properties": { + "index": 0 + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `index` | number | Branch index | + +### `parallel.branch.completed` + +Emitted when a parallel branch finishes. + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "parallel.branch.completed", + "node_id": "branch_a", + "node_label": "branch_a", + "properties": { + "index": 0, + "duration_ms": 5000, + "status": "success" + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `index` | number | Branch index | +| `duration_ms` | number | Branch duration in milliseconds | +| `status` | string | Branch outcome status | + +### `parallel.completed` + +Emitted when all parallel branches have finished. + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "parallel.completed", + "properties": { + "duration_ms": 12000, + "success_count": 2, + "failure_count": 1 + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `duration_ms` | number | Total parallel duration | +| `success_count` | number | Branches that succeeded | +| `failure_count` | number | Branches that failed | + +--- + +## Interview events + +### `interview.started` + +Emitted when a human-in-the-loop question is posed. + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "interview.started", + "node_id": "review", + "node_label": "review", + "properties": { + "question": "Does this look correct?", + "question_type": "approval" + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `question` | string | Question text | +| `question_type` | string | Type of question | + +### `interview.completed` + +Emitted when a human answers. + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "interview.completed", + "properties": { + "question": "Does this look correct?", + "answer": "yes", + "duration_ms": 30000 + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `question` | string | Question text | +| `answer` | string | Human's answer | +| `duration_ms` | number | Time waiting for answer | + +### `interview.timeout` + +Emitted when a human question times out. + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "interview.timeout", + "node_id": "review", + "node_label": "review", + "properties": { + "question": "Does this look correct?", + "duration_ms": 300000 + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `question` | string | Question text | +| `duration_ms` | number | Time waited before timeout | + +--- + +## Checkpoint events + +### `checkpoint.completed` + +Emitted after a checkpoint is saved. + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "checkpoint.completed", + "node_id": "code", + "node_label": "code", + "properties": { + "status": "success", + "git_commit_sha": "abc123...", + "diff": "diff --git a/src/lib.rs b/src/lib.rs\n..." + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `status` | string | Checkpoint status | +| `git_commit_sha` | string? | Commit SHA at checkpoint time | +| `diff` | string? | Git diff captured for the checkpointed node | + +### `checkpoint.failed` + +Emitted when checkpoint saving fails. + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "checkpoint.failed", + "node_id": "code", + "node_label": "code", + "properties": { + "error": "git commit failed: ..." + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `error` | string | Error message | + +--- + +## Git events + +### `git.commit` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "git.commit", + "node_id": "code", + "node_label": "code", + "properties": { + "sha": "abc123..." + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `sha` | string | Commit SHA | + +Note: `node_id` is optional — may be absent for non-stage commits. + +### `git.push` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "git.push", + "properties": { + "branch": "fabro/run-01JQXYZ", + "success": true + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `branch` | string | Branch name | +| `success` | boolean | Whether push succeeded | + +### `git.branch` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "git.branch", + "properties": { + "branch": "fabro/run-01JQXYZ", + "sha": "abc123..." + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `branch` | string | Branch name | +| `sha` | string | Branch HEAD SHA | + +### `git.worktree.added` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "git.worktree.added", + "properties": { + "path": "/tmp/fabro-worktrees/...", + "branch": "fabro/run-01JQXYZ" + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `path` | string | Worktree directory path | +| `branch` | string | Branch name | + +### `git.worktree.removed` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "git.worktree.removed", + "properties": { + "path": "/tmp/fabro-worktrees/..." + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `path` | string | Worktree directory path | + +### `git.fetch` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "git.fetch", + "properties": { + "branch": "main", + "success": true + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `branch` | string | Branch name | +| `success` | boolean | Whether fetch succeeded | + +### `git.reset` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "git.reset", + "properties": { + "sha": "abc123..." + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `sha` | string | Target commit SHA | + +--- + +## Routing events + +### `edge.selected` + +Emitted when the engine selects the next edge to traverse. + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "edge.selected", + "properties": { + "from_node": "code", + "to_node": "review", + "label": "tests_pass", + "condition": "outcome=success", + "reason": "condition", + "preferred_label": "tests_pass", + "suggested_next_ids": ["review"], + "stage_status": "success", + "is_jump": false + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `from_node` | string | Source node id | +| `to_node` | string | Target node id | +| `label` | string? | Edge label | +| `condition` | string? | Edge condition expression | +| `reason` | string | Selection reason (`"condition"`, `"preferred_label"`, `"jump"`, etc.) | +| `preferred_label` | string? | Stage's preferred label hint | +| `suggested_next_ids` | string[] | Stage's suggested next node ids | +| `stage_status` | string | Outcome status that influenced routing | +| `is_jump` | boolean | Whether this bypassed normal edge selection | + +### `loop.restart` + +Emitted when execution loops back to an earlier node. + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "loop.restart", + "properties": { + "from_node": "review", + "to_node": "code" + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `from_node` | string | Node that triggered the restart | +| `to_node` | string | Node to restart from | + +--- + +## Agent events + +All agent events have `node_id` (the workflow stage), `node_label`, `session_id`, and `parent_session_id` in the envelope. The `properties` contain the inner agent event fields. + +### `agent.session.started` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "agent.session.started", + "node_id": "code", "node_label": "code", + "session_id": "ses_abc", "parent_session_id": null, + "properties": {} +} +``` + +No properties. + +### `agent.session.ended` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "agent.session.ended", + "node_id": "code", "node_label": "code", + "session_id": "ses_abc", + "properties": {} +} +``` + +No properties. + +### `agent.processing.end` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "agent.processing.end", + "node_id": "code", "node_label": "code", + "session_id": "ses_abc", + "properties": {} +} +``` + +No properties. + +### `agent.input` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "agent.input", + "node_id": "code", "node_label": "code", + "session_id": "ses_abc", + "properties": { + "text": "Fix the login bug in auth.rs" + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `text` | string | User input text | + +### `agent.output.start` + +Signals the beginning of assistant text output. + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "agent.output.start", + "node_id": "code", "node_label": "code", + "session_id": "ses_abc", + "properties": {} +} +``` + +No properties. + +### `agent.output.replace` + +Replaces the current in-progress assistant output buffers. + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "agent.output.replace", + "node_id": "code", "node_label": "code", + "session_id": "ses_abc", + "properties": { + "text": "I'll fix the login bug by...", + "reasoning": "The user wants..." + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `text` | string | Replacement assistant text | +| `reasoning` | string? | Replacement reasoning text | + +### `agent.message` + +Emitted when the assistant produces a complete message. + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "agent.message", + "node_id": "code", "node_label": "code", + "session_id": "ses_abc", + "properties": { + "text": "I've fixed the bug in auth.rs by...", + "model": "claude-sonnet-4-20250514", + "usage": { + "input_tokens": 3000, + "output_tokens": 1500, + "total_tokens": 4500, + "reasoning_tokens": 200, + "cache_read_tokens": 1000, + "cache_write_tokens": 500 + }, + "tool_call_count": 2 + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `text` | string | Assistant message text | +| `model` | string | Model identifier | +| `usage` | object | Token usage for this message | +| `usage.input_tokens` | number | Input tokens | +| `usage.output_tokens` | number | Output tokens | +| `usage.total_tokens` | number | Total tokens | +| `usage.reasoning_tokens` | number? | Reasoning tokens | +| `usage.cache_read_tokens` | number? | Cache read tokens | +| `usage.cache_write_tokens` | number? | Cache write tokens | +| `usage.speed` | string? | Speed tier | +| `usage.raw` | object? | Raw provider-specific usage | +| `tool_call_count` | number | Number of tool calls in this turn | + +### `agent.text.delta` + +Streaming text chunk from the assistant. + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "agent.text.delta", + "node_id": "code", "node_label": "code", + "session_id": "ses_abc", + "properties": { + "delta": "I'll start by reading" + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `delta` | string | Text chunk | + +### `agent.reasoning.delta` + +Streaming reasoning/thinking chunk from the assistant. + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "agent.reasoning.delta", + "node_id": "code", "node_label": "code", + "session_id": "ses_abc", + "properties": { + "delta": "The user needs me to..." + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `delta` | string | Reasoning text chunk | + +### `agent.tool.started` + +Emitted when the agent begins a tool call. + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "agent.tool.started", + "node_id": "code", "node_label": "code", + "session_id": "ses_abc", + "properties": { + "tool_name": "read_file", + "tool_call_id": "call_abc123", + "arguments": {"path": "src/auth.rs"} + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `tool_name` | string | Tool name | +| `tool_call_id` | string | Unique tool call id | +| `arguments` | object | Tool call arguments | + +### `agent.tool.output.delta` + +Streaming tool output chunk. + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "agent.tool.output.delta", + "node_id": "code", "node_label": "code", + "session_id": "ses_abc", + "properties": { + "delta": "fn login(user: &str)..." + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `delta` | string | Output text chunk | + +### `agent.tool.completed` + +Emitted when a tool call finishes. + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "agent.tool.completed", + "node_id": "code", "node_label": "code", + "session_id": "ses_abc", + "properties": { + "tool_name": "read_file", + "tool_call_id": "call_abc123", + "output": "fn login(user: &str) -> Result...", + "is_error": false + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `tool_name` | string | Tool name | +| `tool_call_id` | string | Unique tool call id | +| `output` | any | Tool output (string or structured) | +| `is_error` | boolean | Whether the tool returned an error | + +### `agent.error` + +Emitted when the agent encounters an error. + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "agent.error", + "node_id": "code", "node_label": "code", + "session_id": "ses_abc", + "properties": { + "error": { ... } + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `error` | object | AgentError (serialized) | + +### `agent.warning` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "agent.warning", + "node_id": "code", "node_label": "code", + "session_id": "ses_abc", + "properties": { + "kind": "token_limit", + "message": "Approaching context window limit", + "details": {} + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `kind` | string | Warning kind | +| `message` | string | Warning message | +| `details` | object | Additional details | + +### `agent.loop.detected` + +Emitted when the agent detects a tool-use loop. + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "agent.loop.detected", + "node_id": "code", "node_label": "code", + "session_id": "ses_abc", + "properties": {} +} +``` + +No properties. + +### `agent.turn.limit` + +Emitted when the agent reaches its maximum turn count. + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "agent.turn.limit", + "node_id": "code", "node_label": "code", + "session_id": "ses_abc", + "properties": { + "max_turns": 25 + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `max_turns` | number | Maximum turns allowed | + +### `agent.skill.expanded` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "agent.skill.expanded", + "node_id": "code", "node_label": "code", + "session_id": "ses_abc", + "properties": { + "skill_name": "read_file" + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `skill_name` | string | Expanded skill name | + +### `agent.steering.injected` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "agent.steering.injected", + "node_id": "code", "node_label": "code", + "session_id": "ses_abc", + "properties": { + "text": "Remember to run tests after changes" + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `text` | string | Injected steering text | + +### `agent.compaction.started` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "agent.compaction.started", + "node_id": "code", "node_label": "code", + "session_id": "ses_abc", + "properties": { + "estimated_tokens": 50000, + "context_window_size": 128000 + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `estimated_tokens` | number | Estimated tokens before compaction | +| `context_window_size` | number | Model context window size | + +### `agent.compaction.completed` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "agent.compaction.completed", + "node_id": "code", "node_label": "code", + "session_id": "ses_abc", + "properties": { + "original_turn_count": 40, + "preserved_turn_count": 10, + "summary_token_estimate": 2000, + "tracked_file_count": 5 + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `original_turn_count` | number | Turns before compaction | +| `preserved_turn_count` | number | Turns preserved | +| `summary_token_estimate` | number | Token estimate for summary | +| `tracked_file_count` | number | Files being tracked | + +### `agent.llm.retry` + +Emitted when an LLM API call is retried. + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "agent.llm.retry", + "node_id": "code", "node_label": "code", + "session_id": "ses_abc", + "properties": { + "provider": "anthropic", + "model": "claude-sonnet-4-20250514", + "attempt": 2, + "delay_secs": 1.5, + "error": { ... } + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `provider` | string | LLM provider name | +| `model` | string | Model identifier | +| `attempt` | number | Retry attempt number | +| `delay_secs` | number | Delay before retry in seconds | +| `error` | object | SdkError (serialized) | + +### `agent.sub.spawned` + +Emitted when a sub-agent is spawned. + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "agent.sub.spawned", + "node_id": "code", "node_label": "code", + "session_id": "ses_abc", + "properties": { + "agent_id": "sub_xyz", + "depth": 1, + "task": "Write unit tests for auth.rs" + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `agent_id` | string | Sub-agent identifier | +| `depth` | number | Nesting depth | +| `task` | string | Task description | + +### `agent.sub.completed` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "agent.sub.completed", + "node_id": "code", "node_label": "code", + "session_id": "ses_abc", + "properties": { + "agent_id": "sub_xyz", + "depth": 1, + "success": true, + "turns_used": 8 + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `agent_id` | string | Sub-agent identifier | +| `depth` | number | Nesting depth | +| `success` | boolean | Whether the sub-agent succeeded | +| `turns_used` | number | Number of turns used | + +### `agent.sub.failed` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "agent.sub.failed", + "node_id": "code", "node_label": "code", + "session_id": "ses_abc", + "properties": { + "agent_id": "sub_xyz", + "depth": 1, + "error": { ... } + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `agent_id` | string | Sub-agent identifier | +| `depth` | number | Nesting depth | +| `error` | object | AgentError (serialized) | + +### `agent.sub.closed` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "agent.sub.closed", + "node_id": "code", "node_label": "code", + "session_id": "ses_abc", + "properties": { + "agent_id": "sub_xyz", + "depth": 1 + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `agent_id` | string | Sub-agent identifier | +| `depth` | number | Nesting depth | + +### `agent.mcp.ready` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "agent.mcp.ready", + "node_id": "code", "node_label": "code", + "session_id": "ses_abc", + "properties": { + "server_name": "filesystem", + "tool_count": 5 + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `server_name` | string | MCP server name | +| `tool_count` | number | Number of tools available | + +### `agent.mcp.failed` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "agent.mcp.failed", + "node_id": "code", "node_label": "code", + "session_id": "ses_abc", + "properties": { + "server_name": "filesystem", + "error": "Connection refused" + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `server_name` | string | MCP server name | +| `error` | string | Error message | + +### `agent.failover` + +Emitted when the agent fails over to a different LLM provider/model. + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "agent.failover", + "node_id": "code", + "node_label": "code", + "properties": { + "from_provider": "anthropic", + "from_model": "claude-sonnet-4-20250514", + "to_provider": "openai", + "to_model": "gpt-4o", + "error": "rate limited" + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `from_provider` | string | Original provider | +| `from_model` | string | Original model | +| `to_provider` | string | Failover provider | +| `to_model` | string | Failover model | +| `error` | string | Error that triggered failover | + +--- + +## Subgraph events + +### `subgraph.started` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "subgraph.started", + "node_id": "pipeline", + "node_label": "pipeline", + "properties": { + "start_node": "sub_start" + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `start_node` | string | First node in the subgraph | + +### `subgraph.completed` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "subgraph.completed", + "node_id": "pipeline", + "node_label": "pipeline", + "properties": { + "steps_executed": 4, + "status": "success", + "duration_ms": 25000 + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `steps_executed` | number | Number of steps executed | +| `status` | string | Subgraph outcome status | +| `duration_ms` | number | Subgraph duration | + +--- + +## Sandbox events + +Sandbox events have the nested `SandboxEvent` unwrapped into `properties`. + +### `sandbox.initializing` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "sandbox.initializing", + "properties": { + "provider": "daytona" + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `provider` | string | Sandbox provider name | + +### `sandbox.ready` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "sandbox.ready", + "properties": { + "provider": "daytona", + "duration_ms": 5000, + "name": "sandbox-01JQXYZ", + "cpu": 4.0, + "memory": 8.0, + "url": "https://sandbox.example.com" + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `provider` | string | Sandbox provider name | +| `duration_ms` | number | Initialization duration | +| `name` | string? | Sandbox instance name | +| `cpu` | number? | CPU cores allocated | +| `memory` | number? | Memory in GB allocated | +| `url` | string? | Sandbox URL | + +### `sandbox.failed` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "sandbox.failed", + "properties": { + "provider": "daytona", + "error": "workspace creation failed", + "duration_ms": 3000 + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `provider` | string | Sandbox provider name | +| `error` | string | Error message | +| `duration_ms` | number | Time before failure | + +### `sandbox.initialized` + +Emitted after the engine completes sandbox initialization (distinct from `sandbox.ready` which comes from the sandbox provider). + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "sandbox.initialized", + "properties": { + "working_directory": "/workspace/my-project", + "provider": "daytona", + "identifier": "sandbox-123", + "host_working_directory": "/tmp/fabro-run/worktree", + "container_mount_point": "/workspace" + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `working_directory` | string | Working directory inside sandbox | +| `provider` | string | Sandbox provider | +| `identifier` | string? | Provider-specific sandbox identifier | +| `host_working_directory` | string? | Host-side working directory | +| `container_mount_point` | string? | Container mount point inside the sandbox | + +### `sandbox.cleanup.started` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "sandbox.cleanup.started", + "properties": { + "provider": "daytona" + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `provider` | string | Sandbox provider name | + +### `sandbox.cleanup.completed` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "sandbox.cleanup.completed", + "properties": { + "provider": "daytona", + "duration_ms": 2000 + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `provider` | string | Sandbox provider name | +| `duration_ms` | number | Cleanup duration | + +### `sandbox.cleanup.failed` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "sandbox.cleanup.failed", + "properties": { + "provider": "daytona", + "error": "workspace not found" + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `provider` | string | Sandbox provider name | +| `error` | string | Error message | + +### `sandbox.snapshot.pulling` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "sandbox.snapshot.pulling", + "properties": { + "name": "my-image:latest" + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `name` | string | Image/snapshot name | + +### `sandbox.snapshot.pulled` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "sandbox.snapshot.pulled", + "properties": { + "name": "my-image:latest", + "duration_ms": 15000 + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `name` | string | Image/snapshot name | +| `duration_ms` | number | Pull duration | + +### `sandbox.snapshot.ensuring` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "sandbox.snapshot.ensuring", + "properties": { + "name": "my-snapshot" + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `name` | string | Snapshot name | + +### `sandbox.snapshot.creating` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "sandbox.snapshot.creating", + "properties": { + "name": "my-snapshot" + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `name` | string | Snapshot name | + +### `sandbox.snapshot.ready` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "sandbox.snapshot.ready", + "properties": { + "name": "my-snapshot", + "duration_ms": 30000 + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `name` | string | Snapshot name | +| `duration_ms` | number | Creation duration | + +### `sandbox.snapshot.failed` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "sandbox.snapshot.failed", + "properties": { + "name": "my-snapshot", + "error": "disk quota exceeded" + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `name` | string | Snapshot name | +| `error` | string | Error message | + +### `sandbox.git.started` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "sandbox.git.started", + "properties": { + "url": "https://github.com/org/repo.git", + "branch": "main" + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `url` | string | Repository URL | +| `branch` | string? | Branch to clone | + +### `sandbox.git.completed` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "sandbox.git.completed", + "properties": { + "url": "https://github.com/org/repo.git", + "duration_ms": 8000 + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `url` | string | Repository URL | +| `duration_ms` | number | Clone duration | + +### `sandbox.git.failed` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "sandbox.git.failed", + "properties": { + "url": "https://github.com/org/repo.git", + "error": "authentication failed" + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `url` | string | Repository URL | +| `error` | string | Error message | + +--- + +## Setup events + +### `setup.started` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "setup.started", + "properties": { + "command_count": 3 + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `command_count` | number | Number of setup commands | + +### `setup.command.started` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "setup.command.started", + "properties": { + "command": "npm install", + "index": 0 + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `command` | string | Command being run | +| `index` | number | Command index | + +### `setup.command.completed` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "setup.command.completed", + "properties": { + "command": "npm install", + "index": 0, + "exit_code": 0, + "duration_ms": 5000 + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `command` | string | Command that ran | +| `index` | number | Command index | +| `exit_code` | number | Process exit code | +| `duration_ms` | number | Command duration | + +### `setup.completed` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "setup.completed", + "properties": { + "duration_ms": 15000 + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `duration_ms` | number | Total setup duration | + +### `setup.failed` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "setup.failed", + "properties": { + "command": "npm install", + "index": 1, + "exit_code": 1, + "stderr": "npm ERR! ..." + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `command` | string | Command that failed | +| `index` | number | Command index | +| `exit_code` | number | Process exit code | +| `stderr` | string | Standard error output | + +--- + +## CLI ensure events + +### `cli.ensure.started` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "cli.ensure.started", + "properties": { + "cli_name": "aider", + "provider": "openai" + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `cli_name` | string | CLI tool name | +| `provider` | string | LLM provider | + +### `cli.ensure.completed` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "cli.ensure.completed", + "properties": { + "cli_name": "aider", + "provider": "openai", + "already_installed": true, + "node_installed": false, + "duration_ms": 500 + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `cli_name` | string | CLI tool name | +| `provider` | string | LLM provider | +| `already_installed` | boolean | Whether it was already present | +| `node_installed` | boolean | Whether Node.js was installed | +| `duration_ms` | number | Duration | + +### `cli.ensure.failed` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "cli.ensure.failed", + "properties": { + "cli_name": "aider", + "provider": "openai", + "error": "pip install failed", + "duration_ms": 3000 + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `cli_name` | string | CLI tool name | +| `provider` | string | LLM provider | +| `error` | string | Error message | +| `duration_ms` | number | Duration | + +--- + +## Pull request events + +### `pull_request.created` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "pull_request.created", + "properties": { + "pr_url": "https://github.com/org/repo/pull/42", + "pr_number": 42, + "draft": true + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `pr_url` | string | Pull request URL | +| `pr_number` | number | Pull request number | +| `draft` | boolean | Whether the PR is a draft | + +### `pull_request.failed` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "pull_request.failed", + "properties": { + "error": "insufficient permissions" + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `error` | string | Error message | + +--- + +## Devcontainer events + +### `devcontainer.resolved` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "devcontainer.resolved", + "properties": { + "dockerfile_lines": 15, + "environment_count": 3, + "lifecycle_command_count": 2, + "workspace_folder": "/workspace" + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `dockerfile_lines` | number | Lines in generated Dockerfile | +| `environment_count` | number | Environment variables defined | +| `lifecycle_command_count` | number | Lifecycle commands to run | +| `workspace_folder` | string | Workspace folder path | + +### `devcontainer.lifecycle.started` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "devcontainer.lifecycle.started", + "properties": { + "phase": "postCreateCommand", + "command_count": 2 + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `phase` | string | Lifecycle phase name | +| `command_count` | number | Commands in this phase | + +### `devcontainer.lifecycle.command.started` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "devcontainer.lifecycle.command.started", + "properties": { + "phase": "postCreateCommand", + "command": "npm install", + "index": 0 + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `phase` | string | Lifecycle phase name | +| `command` | string | Command being run | +| `index` | number | Command index | + +### `devcontainer.lifecycle.command.completed` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "devcontainer.lifecycle.command.completed", + "properties": { + "phase": "postCreateCommand", + "command": "npm install", + "index": 0, + "exit_code": 0, + "duration_ms": 8000 + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `phase` | string | Lifecycle phase name | +| `command` | string | Command that ran | +| `index` | number | Command index | +| `exit_code` | number | Process exit code | +| `duration_ms` | number | Command duration | + +### `devcontainer.lifecycle.completed` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "devcontainer.lifecycle.completed", + "properties": { + "phase": "postCreateCommand", + "duration_ms": 12000 + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `phase` | string | Lifecycle phase name | +| `duration_ms` | number | Phase duration | + +### `devcontainer.lifecycle.failed` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "devcontainer.lifecycle.failed", + "properties": { + "phase": "postCreateCommand", + "command": "npm install", + "index": 0, + "exit_code": 1, + "stderr": "npm ERR! ..." + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `phase` | string | Lifecycle phase name | +| `command` | string | Command that failed | +| `index` | number | Command index | +| `exit_code` | number | Process exit code | +| `stderr` | string | Standard error output | + +--- + +## Asset events + +### `asset.captured` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "asset.captured", + "node_id": "code", + "node_label": "code", + "properties": { + "attempt": 1, + "node_slug": "code", + "path": "screenshot.png", + "mime": "image/png", + "content_md5": "d41d8cd98f00b204e9800998ecf8427e", + "content_sha256": "e3b0c44298fc1c149afbf4c8996fb924...", + "bytes": 45000 + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `attempt` | number | Attempt number | +| `node_slug` | string | Node slug for asset path | +| `path` | string | Asset file path | +| `mime` | string | MIME type | +| `content_md5` | string | MD5 hash | +| `content_sha256` | string | SHA-256 hash | +| `bytes` | number | File size in bytes | + +--- + +## SSH events + +### `ssh.ready` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "ssh.ready", + "properties": { + "ssh_command": "ssh user@host -p 2222" + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `ssh_command` | string | SSH command to connect | + +--- + +## Watchdog events + +### `watchdog.timeout` + +Emitted when the stall watchdog detects no progress. + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "watchdog.timeout", + "node_id": "code", + "node_label": "code", + "properties": { + "idle_seconds": 1800 + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `idle_seconds` | number | Seconds since last activity | + +--- + +## Retro events + +### `retro.started` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "retro.started", + "properties": { + "prompt": "Analyze the workflow run data at `/tmp/retro_data/` ...", + "provider": "anthropic", + "model": "claude-sonnet-4-20250514" + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `prompt` | string? | Prompt sent to the retro agent | +| `provider` | string? | LLM provider for the retro agent | +| `model` | string? | Model used for the retro agent | + +### `retro.completed` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "retro.completed", + "properties": { + "duration_ms": 5000, + "response": "The run was mostly smooth...", + "retro": {"smoothness": "smooth"} + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `duration_ms` | number | Retro duration | +| `response` | string? | Raw assistant response from the retro agent | +| `retro` | object? | Parsed `Retro` payload | + +### `retro.failed` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "retro.failed", + "properties": { + "error": "LLM request failed", + "duration_ms": 3000 + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `error` | string | Error message | +| `duration_ms` | number | Duration before failure | diff --git a/docs-internal/fabro-event-schema-v2-concrete-shape.md b/docs-internal/fabro-event-schema-v2-concrete-shape.md new file mode 100644 index 000000000..b4032a495 --- /dev/null +++ b/docs-internal/fabro-event-schema-v2-concrete-shape.md @@ -0,0 +1,484 @@ +# Fabro Event Schema V2: Concrete Shape + +Date: 2026-04-09 + +Status: implemented + +This document turns the settled design decisions from the event-schema discussion into a concrete wire-contract proposal. + +It intentionally supersedes the earlier framing in [fabro-event-schema-v2-proposal.md](/Users/bhelmkamp/p/fabro-sh/fabro/docs-internal/fabro-event-schema-v2-proposal.md) for: + +- proposal 1: one canonical persisted log, not two truths +- proposal 2: formalize and generalize the existing `since_seq` replay contract, rather than inventing replay from scratch + +## Design Decisions Carried Forward + +- one canonical persisted event log +- plain hand-coded Rust structs are the authoritative source of truth for the event contract +- `RunEvent` remains the canonical semantic event type +- `seq` remains outside `RunEvent`, in the store/API envelope +- replay stays built around ordered `since_seq` cursors +- typed Rust consumers matching on `EventBody` remain the primary consumer model +- the envelope widens only modestly for execution topology and tool-call correlation: `stage_id`, `parallel_group_id`, `parallel_branch_id`, `tool_call_id` +- existing durable event families stay broadly intact +- live token/delta noise does not become part of the durable persisted Rust event contract +- snapshots are out of scope for both the durable event contract and the attach API + +## Contract Source Of Truth + +V2 does not adopt schema generation or a registry-first workflow. + +The authoritative source of truth for the event contract should be plain, hand-coded Rust structs and enums that model the public wire shape directly. + +Implications: + +- the Rust event types are the canonical contract +- this document describes that contract and should stay aligned with the Rust types +- any TypeScript types, JSON Schema, or OpenAPI fragments are secondary artifacts, not the source of truth +- codegen is explicitly out of scope for the initial V2 implementation + +## Why Evolve The Current Model + +V2 should evolve Fabro's existing event architecture rather than replace it with a generic event platform. + +Earlier drafts of this document proposed a generic reducer contract, a larger ontology-first envelope, and a narrower replacement event catalog. V2 walks that back. The current code's boundary between internal workflow events, `RunEvent`, and `EventEnvelope` is stronger and simpler than it first appeared, so evolving that model is cheaper and clearer than replacing it. + +The current code already has a strong separation of concerns: + +- internal workflow/runtime events in `fabro-workflow` +- one canonical semantic `RunEvent` +- a store/API envelope that carries `seq` outside the event payload + +That separation is worth preserving. The main V2 changes should be: + +- modest envelope widening for execution topology +- cleanup and clarification of event-family boundaries +- keeping the durable event catalog semantic and typed + +V2 should not introduce: + +- a generic reducer contract based on `entity_type` / `event_role` +- canonical persisted token deltas +- snapshot events as a second truth layer + +## Capability Coverage Decisions + +V2 is evolutionary over the current `RunEvent` surface. It keeps the existing durable event families broadly intact rather than replacing them with a new ontology. + +The main additions are: + +- `stage_id` in the envelope for concrete stage execution identity +- `parallel_group_id` in the envelope for one execution of a parallel node +- `parallel_branch_id` in the envelope for one branch inside a parallel execution +- `tool_call_id` in the envelope for agent tool lifecycle events that need a stable cross-family join key + +Everything else should remain in typed `EventBody` props unless there is a strong cross-family reason to promote it. `session_id` already exists in the envelope today and stays as-is. `tool_call_id` is promoted now because `agent.tool.*` events already carry a stable tool-call identity that other durable families can reference when needed. `turn_id` is deferred because Fabro does not yet have a durable turn identity that spans the families that would need to join on it. + +## Exact Delta From Current Code + +This is the implementation delta from the current Rust codebase, not the full history of how the design was reached. + +### Add + +- add `stage_id: Option` to `RunEvent` +- add `parallel_group_id: Option` to `RunEvent` +- add `parallel_branch_id: Option` to `RunEvent` +- add `tool_call_id: Option` to `RunEvent` +- add `actor: Option` to `RunEvent` +- extend envelope extraction in `stored_event_fields()` to populate the new execution-topology fields when known +- extend envelope extraction in `stored_event_fields()` to populate `tool_call_id` on tool-lifecycle events when known +- update `RunEvent` serialization and parsing so the new optional envelope fields round-trip cleanly + +### Keep As-Is + +- `RunEvent` remains the canonical semantic event type +- `EventBody` remains the typed tagged union of durable event families +- `EventBody::Unknown` remains the compatibility valve for unknown event names on read +- `EventEnvelope` remains the ordered outer wrapper with `seq` outside the event payload +- `EventEnvelope.payload` remains `EventPayload`, not `RunEvent` +- the internal/store `EventEnvelope` Rust type stays wrapped as `{ seq, payload }` +- attach/replay remains exact ordered replay from `since_seq`, followed by live tailing +- current durable event families stay broadly intact +- live token/delta noise remains outside the durable persisted contract +- snapshots remain out of scope + +### Do Not Do + +- do not inline `seq` into `RunEvent` +- do not introduce `entity_type`, `entity_id`, or `event_role` +- do not replace typed Rust consumers with a generic reducer model +- do not redesign the store envelope +- do not add snapshot events or attach-time synthetic snapshots +- do not persist token deltas or other live UI noise as durable `RunEvent`s + +## Canonical Rust Shapes + +V2 should model the public contract directly as hand-coded Rust types, following the existing architecture. + +```rust +pub struct RunEvent { + pub id: String, + pub ts: DateTime, + pub run_id: RunId, + pub node_id: Option, + pub node_label: Option, + pub stage_id: Option, + pub parallel_group_id: Option, + pub parallel_branch_id: Option, + pub session_id: Option, + pub parent_session_id: Option, + pub tool_call_id: Option, + pub actor: Option, + pub body: EventBody, +} + +pub struct EventEnvelope { + pub seq: u32, + pub payload: EventPayload, +} + +pub struct ActorRef { + pub kind: ActorKind, + pub id: Option, + pub display: Option, +} + +pub enum ActorKind { + User, + Agent, + System, +} +``` + +`RunEvent` remains the semantic product event. `EventEnvelope` remains the ordered store/API wrapper. The store continues to persist validated JSON `EventPayload`, not typed `RunEvent` structs. + +For wire JSON, `EventEnvelope` should serialize in flattened form so clients see: + +```json +{ + "seq": 4861, + "id": "...", + "ts": "...", + "run_id": "...", + "event": "...", + "properties": { ... } +} +``` + +That flattening is a wire concern only. It does not move `seq` into `RunEvent`, and it does not change the internal/store Rust shape of `EventEnvelope`. + +`EventBody` remains a hand-coded tagged enum serialized as: + +```json +{ + "event": "stage.completed", + "properties": { "...": "..." } +} +``` + +V2 should also preserve the current unknown-event fallback shape: + +```rust +EventBody::Unknown { + name: String, + properties: serde_json::Value, +} +``` + +This fallback already exists in the current code and should be kept. + +### Envelope Rules + +- `id`, `ts`, `run_id`, and `event` are always present on the serialized `RunEvent`. +- `seq` is not part of `RunEvent`. It stays in the outer `EventEnvelope`. +- Optional envelope fields are omitted, never serialized as `null`. +- The existing top-level envelope fields remain: + - `node_id` + - `node_label` + - `session_id` + - `parent_session_id` +- V2 adds only these new optional envelope fields: + - `stage_id` + - `parallel_group_id` + - `parallel_branch_id` + - `tool_call_id` +- Other relationship identifiers stay inside typed `properties`. +- `turn_id` remains in typed `properties`; see the deferral decision in `Capability Coverage Decisions`. +- `actor` is optional. When present, it identifies the primary actor for the event. +- Set `actor` on human- or agent-initiated events where that identity matters to consumers. Example: `run.cancel.requested` should identify the user who initiated the cancel. +- Set `actor` on durable agent output when the producing session identity matters. Example: `agent.message` should identify the agent session. +- Omit `actor` for routine runtime events with no meaningful primary actor. Example: `stage.started`. + +### ID Format Conventions + +- `run_id` keeps Fabro's current format: an unprefixed ULID string. +- `stage_id` keeps Fabro's current format: `"{node_id}@{visit}"`. +- `node_id` is the stable graph node identifier from the workflow definition. +- `parallel_group_id` should be the durable identity of one execution of a parallel node. The default format should be `"{node_id}@{visit}"`. +- `parallel_branch_id` should be the durable identity of one branch within a parallel execution. The default format should be `"{parallel_group_id}:{index}"`. +- Consumers should otherwise treat IDs as opaque strings. + +### Presence Expectations + +- `stage_id` is present on events tied to a concrete stage execution. +- `parallel_group_id` is present on `parallel.*` events and on events emitted inside a parallel execution when that scope is known. +- `parallel_branch_id` is present on `parallel.branch.*` events and on nested events emitted inside a specific branch when that scope is known. +- `session_id` and `parent_session_id` keep their current meaning for forwarded agent/session activity. +- `tool_call_id` is present on `agent.tool.*` events and on other durable events that directly describe the same tool call. +- `node_label` remains in the envelope for display-oriented consumers. +- `actor` is expected on control actions and durable agent output when there is a meaningful user or agent identity to expose. It is usually omitted on routine runtime lifecycle events. + +## Consumer Model + +Rust consumers should keep matching on `RunEvent.body` using typed `EventBody` variants. + +This document does not adopt: + +- `entity_type` +- `entity_id` +- `event_role` +- a generic reducer contract + +External JSON consumers should continue to: + +- match on `"event"` +- read event-specific values from `"properties"` +- read `"seq"` from the flattened outer event envelope on API/SSE responses +- use envelope metadata only for cross-cutting context such as stage, session, execution topology, and tool-call correlation + +## Replay Contract + +Fabro keeps the current replay model: + +- ordered events are stored as `EventEnvelope { seq, payload }` +- API/SSE serialization of `EventEnvelope` should flatten `seq` into the top-level JSON object returned to clients +- attach starts from `since_seq` +- the server replays exact persisted envelopes and then tails live envelopes while the run is active +- SSE keepalive comments are transport frames, not events + +V2 does not introduce: + +- `run.snapshot` +- `session.snapshot` +- API-level attach snapshots +- persisted snapshot events of any kind + +The durable model remains simple: replay ordered events, no duplicate truth layer. + +## Implementation Checklist + +An engineer implementing this proposal should make only these structural changes unless a later section explicitly says otherwise. + +1. Update [`RunEvent`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/mod.rs) to add: + - `stage_id` + - `parallel_group_id` + - `parallel_branch_id` + - `tool_call_id` + - `actor` +2. Update `RunEvent::to_value()` and `RunEvent` parsing in [`run_event/mod.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/mod.rs) so the new envelope fields serialize and deserialize. +3. Extend `StoredEventFields` and `stored_event_fields()` in [`event.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-workflow/src/event.rs) to populate: + - `stage_id` + - `parallel_group_id` + - `parallel_branch_id` + - `tool_call_id` on tool-lifecycle events + - `actor` when there is a clear primary actor + These values should come from the emitter's current execution context for stage and parallel scope, and from event-specific payloads for `tool_call_id`. +4. Leave [`EventEnvelope`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-store/src/types.rs) structurally unchanged: + - `seq: u32` + - `payload: EventPayload` +5. Update API/SSE envelope serialization so wire JSON is flattened: + - top-level `seq` + - then the `RunEvent` payload fields alongside it + - no `"payload": { ... }` wrapper in JSON responses +6. Leave the replay/attach flow unchanged in behavior: + - persisted replay from `since_seq` + - live tail after replay + - no snapshots +7. Keep the current `EventBody` family surface unless there is an explicit product reason to change a specific family. +8. Keep streaming-noise agent events out of durable `RunEvent` conversion. +9. Update the HTTP/API schema docs to reflect both: + - new `RunEvent` envelope fields + - flattened JSON serialization of `EventEnvelope` + +## EventBody And Property Model + +V2 should keep the current hand-coded domain split for prop structs: + +- run props in [`run.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/run.rs) +- stage and checkpoint props in [`stage.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/stage.rs) +- agent props in [`agent.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/agent.rs) +- infra/setup/devcontainer props in [`infra.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/infra.rs) +- parallel/interview/git/misc props in [`misc.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/misc.rs) + +That split is part of the design quality. V2 should keep adding hand-coded prop structs, not collapse everything into generic maps. + +## Durable Event Surface + +V2 keeps the current durable family surface broadly intact. + +### Run + +- `run.created` +- `run.started` +- `run.submitted` +- `run.starting` +- `run.running` +- `run.removing` +- `run.cancel.requested` +- `run.pause.requested` +- `run.unpause.requested` +- `run.paused` +- `run.unpaused` +- `run.rewound` +- `run.completed` +- `run.failed` +- `run.notice` + +### Stage And Prompt + +- `stage.started` +- `stage.completed` +- `stage.failed` +- `stage.retrying` +- `stage.prompt` +- `prompt.completed` + +### Parallel + +- `parallel.started` +- `parallel.branch.started` +- `parallel.branch.completed` +- `parallel.completed` + +### Interview / Human Input + +- `interview.started` +- `interview.completed` +- `interview.timeout` +- `interview.interrupted` + +### Checkpoint + +- `checkpoint.completed` +- `checkpoint.failed` + +### Agent Durable Events + +- `agent.session.started` +- `agent.session.ended` +- `agent.processing.end` +- `agent.input` +- `agent.message` +- `agent.tool.started` +- `agent.tool.completed` +- `agent.error` +- `agent.warning` +- `agent.loop.detected` +- `agent.turn.limit` +- `agent.steering.injected` +- `agent.compaction.started` +- `agent.compaction.completed` +- `agent.llm.retry` +- `agent.sub.spawned` +- `agent.sub.completed` +- `agent.sub.failed` +- `agent.sub.closed` +- `agent.mcp.ready` +- `agent.mcp.failed` +- `agent.failover` + +### Git + +- `git.commit` +- `git.push` +- `git.branch` +- `git.worktree.added` +- `git.worktree.removed` +- `git.fetch` +- `git.reset` + +### Infra And Execution + +- `sandbox.*` +- `setup.*` +- `cli.ensure.*` +- `command.*` +- `agent.cli.*` +- `devcontainer.*` +- `pull_request.*` +- `artifact.captured` +- `ssh.ready` +- `subgraph.*` +- `edge.selected` +- `loop.restart` +- `retro.*` + +## Explicitly Non-Durable Streaming Noise + +The current boundary that keeps live token/delta noise out of `RunEvent` should remain in place. + +These stay outside the durable persisted contract: + +- `agent.output.start` +- `agent.output.replace` +- `agent.text.delta` +- `agent.reasoning.delta` +- `agent.tool.output.delta` +- `agent.skill.expanded` + +`agent.skill.expanded` stays in this non-durable bucket because it is display-oriented expansion metadata, not a durable workflow fact. + +If Fabro needs those for UI, they belong in a separate transient stream, not in the canonical persisted Rust event contract. + +## Example Shapes + +### Flattened Wire JSON + +```json +{ + "seq": 4861, + "id": "evt_01JSE1N7RJD1NW2JSDT3W0YQ92", + "ts": "2026-04-08T16:21:11.106Z", + "run_id": "01JSE1M0Q0P8P6KQW9Q6D58Q0E", + "event": "agent.tool.completed", + "stage_id": "code@1", + "node_id": "code", + "node_label": "Code", + "session_id": "ses_child", + "tool_call_id": "call_1", + "parent_session_id": "ses_parent", + "properties": { + "tool_name": "read_file", + "output": { + "summary": "Read docs-internal/events-strategy.md" + }, + "is_error": false, + "visit": 1 + } +} +``` + +In Rust, `EventEnvelope` still remains `{ seq, payload: EventPayload }`. The example above is only the flattened API/SSE JSON form of that envelope. + +## Practical Guidance + +- Preserve the current one-time canonicalization boundary from internal `Event` to external `RunEvent`. +- Keep `RunEvent` semantic and typed. Do not turn it into a generic reducer envelope. +- Keep `seq` outside the event payload. +- Widen the envelope only modestly: `stage_id`, `parallel_group_id`, `parallel_branch_id`, and `tool_call_id`. +- Keep `session_id` as the existing top-level session field. +- Keep event-specific detail inside typed props. +- Preserve `EventBody::Unknown` as the compatibility valve for unknown event names on read. +- Do not store token deltas or other live UI noise as durable `RunEvent`s. +- Do not add snapshot events or attach-time synthetic snapshots. +- When adding a new durable event, update the current Rust boundary cleanly: + - internal `Event` + - `event_name()` + - envelope extraction + - `EventBody` + - typed props + - affected consumers + +## Open Follow-Up + +- `correlation_id`-style cross-entity grouping remains deferred until Fabro has a concrete consumer and explicit propagation rules diff --git a/docs-internal/fabro-event-schema-v2-proposal.md b/docs-internal/fabro-event-schema-v2-proposal.md new file mode 100644 index 000000000..ee8714766 --- /dev/null +++ b/docs-internal/fabro-event-schema-v2-proposal.md @@ -0,0 +1,633 @@ +# Fabro Event Schema V2 Proposal + +Date: 2026-04-08 + +Status: proposal + +Assumptions: + +- greenfield redesign +- no production deployments +- no backward-compatibility constraints +- optimize for the best long-term public event contract + +This proposal turns the earlier ideation into concrete schema changes. + +## Design Goal + +Fabro should expose: + +1. a durable, append-only event log for audit, storage, replay, and projections +2. a separate live stream for UI-oriented snapshots, deltas, and fast progress + +They should share IDs and correlation fields, but they should not be the same contract. + +## Top 10 Concrete Improvements + +### 1. Split the single event story into two concrete public APIs + +#### Proposal + +Introduce two top-level event contracts: + +- `DurableEvent` +- `LiveEvent` + +Endpoints: + +- `GET /runs/:run_id/events` + - append-only durable events + - replayable + - no keep-alive payload events +- `GET /runs/:run_id/live` + - live UI stream + - snapshots + deltas + keep-alives + - resumable with cursor + +#### Durable event shape + +```json +{ + "kind": "durable", + "id": "evt_01960d0c...", + "seq": 182, + "ts": "2026-04-08T15:01:02.123Z", + "run_id": "run_01JQ...", + "event": "agent.tool.started", + "session_id": "ses_123", + "node_id": "code", + "properties": { + "tool_call_id": "tool_abc", + "tool_name": "read_file", + "arguments": { "path": "src/main.rs" } + } +} +``` + +#### Live event shape + +```json +{ + "kind": "live", + "id": "levt_01960d0d...", + "seq": 991, + "ts": "2026-04-08T15:01:03.000Z", + "run_id": "run_01JQ...", + "event": "message.delta", + "session_id": "ses_123", + "message_id": "msg_456", + "part_id": "part_1", + "properties": { + "block_type": "text", + "delta": "Let me check that file..." + } +} +``` + +#### Why this is better + +- Durable events stay stable and analyzable. +- Live events can be noisy and UI-oriented without polluting projections. +- Keeps Fabro from repeating the Claude Code / OpenCode problem of mixing control, transport, and product semantics. + +### 2. Add explicit stream ordering, replay, and recovery semantics + +#### Proposal + +Every durable and live stream event gets: + +- `seq: u64` +- SSE `id:` = `seq` +- replay semantics based on `Last-Event-ID` + +Server rules: + +- if `Last-Event-ID` is present and still buffered, replay `seq > cursor` +- if cursor is too old, return a structured reset event in live streams and `409 replay_reset_required` in durable streams +- durable streams never emit synthetic snapshots +- live streams may start with a `*.snapshot` event after reconnect + +#### New live-only events + +- `stream.heartbeat` +- `run.snapshot` +- `session.snapshot` +- `node.snapshot` +- `stream.reset_required` + +#### Example `stream.reset_required` + +```json +{ + "kind": "live", + "id": "levt_01960d0e...", + "seq": 1200, + "ts": "2026-04-08T15:02:00.000Z", + "run_id": "run_01JQ...", + "event": "stream.reset_required", + "properties": { + "reason": "cursor_too_old", + "expected_from_seq": 1170 + } +} +``` + +#### Why this is better + +- Reattach behavior becomes deterministic. +- Clients no longer guess whether they missed data. +- Replay is part of the contract, not an implementation detail. + +### 3. Expand the envelope into a first-class correlation model + +#### Proposal + +Extend the shared envelope with these optional fields: + +- `workflow_id` +- `stage_id` +- `branch_id` +- `checkpoint_id` +- `session_id` +- `parent_session_id` +- `turn_id` +- `message_id` +- `part_id` +- `tool_call_id` +- `request_id` +- `causation_id` +- `correlation_id` + +Rules: + +- `id` is the event's own identity +- `causation_id` points to the immediate triggering event, if any +- `correlation_id` groups a whole logical operation, for example one user request or one retry attempt tree +- `request_id` is transport/API request scoped, not workflow scoped + +#### Concrete change + +Move these IDs out of ad hoc `properties` payloads when they are structural identifiers. + +Good: + +```json +{ + "event": "agent.tool.completed", + "tool_call_id": "tool_abc", + "message_id": "msg_456", + "properties": { + "tool_name": "read_file", + "is_error": false + } +} +``` + +Bad: + +```json +{ + "event": "agent.tool.completed", + "properties": { + "tool_call_id": "tool_abc", + "message_id": "msg_456", + "tool_name": "read_file" + } +} +``` + +#### Why this is better + +- Correlation becomes universal instead of event-family-specific. +- UI and analytics consumers can join without parsing `properties`. +- Parent/child agent and retry trees become much easier to reason about. + +### 4. Replace stringly state with concrete tagged unions + +#### Proposal + +Define explicit union types for stateful fields. + +Examples: + +```ts +type StopReason = + | { type: "completed" } + | { type: "requires_input"; request_id: string } + | { type: "interrupted"; interrupt_reason: InterruptReason } + | { type: "failed"; error_kind: ErrorKind } + | { type: "retries_exhausted"; attempts: number }; + +type RetryStatus = + | { type: "not_retrying" } + | { type: "retry_scheduled"; attempt: number; next_retry_at: string } + | { type: "retrying"; attempt: number } + | { type: "retries_exhausted"; attempts: number }; + +type ApprovalStatus = + | { type: "not_required" } + | { type: "requested"; approval_id: string } + | { type: "approved"; approval_id: string; actor: string } + | { type: "denied"; approval_id: string; actor: string; reason?: string }; +``` + +#### Concrete fields to replace + +- `status` +- `reason` +- `failure_class` +- `interrupt_reason` +- `stop_reason` +- `approval_status` + +#### Why this is better + +- Eliminates string drift. +- Makes reducers and policy engines much safer. +- Makes test fixtures much more stable. + +### 5. Standardize event family grammar across the entire product + +#### Proposal + +Use one lifecycle vocabulary: + +- `.created` +- `.started` +- `.snapshot` +- `.delta` +- `.updated` +- `.completed` +- `.failed` +- `.cancelled` +- `.interrupted` +- `.deleted` + +Apply it consistently to the same kinds of things: + +- `run.*` +- `stage.*` +- `session.*` +- `turn.*` +- `message.*` +- `message.part.*` +- `tool.*` +- `command.*` +- `checkpoint.*` +- `parallel.branch.*` +- `retro.*` + +#### Concrete renames + +Current style is already decent, but V2 should be stricter. + +Examples: + +- `agent.output.start` -> `message.part.started` +- `agent.text.delta` -> `message.part.delta` +- `agent.tool.output.delta` -> `tool.output.delta` +- `agent.processing.end` -> `turn.completed` or `session.idle`, depending on actual semantics + +#### Why this is better + +- Consumers can infer behavior from naming alone. +- Reduces one-off event families that encode bespoke lifecycle semantics. + +### 6. Introduce typed content blocks and block-level deltas + +#### Proposal + +Represent streamable content as typed message parts. + +Base union: + +```ts +type MessagePart = + | { type: "text"; part_id: string; text: string } + | { type: "reasoning"; part_id: string; text: string } + | { type: "tool_call"; part_id: string; tool_call_id: string; tool_name: string; input: unknown } + | { type: "tool_result"; part_id: string; tool_call_id: string; output: unknown; is_error: boolean } + | { type: "patch"; part_id: string; patch_ref: string } + | { type: "file_ref"; part_id: string; file_id: string; path: string } + | { type: "artifact_ref"; part_id: string; artifact_id: string; label: string } + | { type: "plan"; part_id: string; items: PlanItem[] } + | { type: "todo"; part_id: string; items: TodoItem[] } + | { type: "command_output"; part_id: string; command_id: string; stream: "stdout" | "stderr"; text: string }; +``` + +Live delta event: + +```json +{ + "event": "message.part.delta", + "message_id": "msg_456", + "part_id": "part_1", + "properties": { + "part_type": "text", + "delta": "checking src/main.rs" + } +} +``` + +Durable completion event: + +```json +{ + "event": "message.completed", + "message_id": "msg_456", + "properties": { + "parts": [ + { "type": "text", "part_id": "part_1", "text": "checking src/main.rs" } + ] + } +} +``` + +#### Why this is better + +- Supports rich UI without reparsing free-form text. +- Supports structured summarization, compaction, and retro generation. +- Aligns Fabro with the best parts of Claude Sessions and pi-mono. + +### 7. Make approvals, questions, and operator interventions first-class durable events + +#### Proposal + +Add explicit event families: + +- `approval.requested` +- `approval.responded` +- `question.asked` +- `question.answered` +- `interrupt.requested` +- `interrupt.applied` +- `resume.required` +- `resume.applied` + +#### Example `approval.requested` + +```json +{ + "kind": "durable", + "id": "evt_01960d0f...", + "seq": 201, + "ts": "2026-04-08T15:03:00.000Z", + "run_id": "run_01JQ...", + "session_id": "ses_123", + "tool_call_id": "tool_abc", + "event": "approval.requested", + "properties": { + "approval_id": "apr_1", + "scope": "tool_call", + "tool_name": "exec_command", + "request": { + "cmd": "git push origin branch" + } + } +} +``` + +#### Example `approval.responded` + +```json +{ + "kind": "durable", + "id": "evt_01960d10...", + "seq": 202, + "ts": "2026-04-08T15:03:10.000Z", + "run_id": "run_01JQ...", + "event": "approval.responded", + "properties": { + "approval_id": "apr_1", + "result": { + "type": "approved", + "actor": "user" + } + } +} +``` + +#### Why this is better + +- Human-in-loop behavior becomes queryable and replayable. +- Workflow interruption is no longer hidden in transport or UI state. + +### 8. Add real snapshot events instead of relying on ad hoc reconstruction + +#### Proposal + +Define explicit snapshot events for live attach and projection recovery: + +- `run.snapshot` +- `session.snapshot` +- `node.snapshot` +- `checkpoint.saved` + +#### Example `session.snapshot` + +```json +{ + "kind": "live", + "id": "levt_01960d11...", + "seq": 1500, + "ts": "2026-04-08T15:04:00.000Z", + "run_id": "run_01JQ...", + "session_id": "ses_123", + "event": "session.snapshot", + "properties": { + "state": { "type": "running" }, + "turn_id": "turn_9", + "messages": [ + { + "message_id": "msg_456", + "role": "assistant", + "parts": [ + { "type": "text", "part_id": "part_1", "text": "checking src/main.rs" } + ] + } + ], + "active_tool_calls": [ + { + "tool_call_id": "tool_abc", + "tool_name": "read_file", + "status": "running" + } + ] + } +} +``` + +#### Concrete rule + +- snapshots are authoritative replacement state for live consumers +- snapshots are optional in durable streams +- checkpoints are durable domain snapshots, not just UI snapshots + +#### Why this is better + +- Fast attach becomes trivial. +- Projections can self-heal from snapshots. +- Checkpoint semantics become explicit rather than emergent. + +### 9. Make model, tool, command, and MCP work first-class span families + +#### Proposal + +Create event families with shared semantics: + +- `model.request.started` +- `model.request.completed` +- `model.request.failed` +- `tool.started` +- `tool.output.delta` +- `tool.completed` +- `tool.failed` +- `command.started` +- `command.output.delta` +- `command.completed` +- `command.failed` +- `mcp.call.started` +- `mcp.call.progress` +- `mcp.call.completed` +- `mcp.call.failed` + +#### Example `model.request.completed` + +```json +{ + "kind": "durable", + "id": "evt_01960d12...", + "seq": 220, + "ts": "2026-04-08T15:05:00.000Z", + "run_id": "run_01JQ...", + "session_id": "ses_123", + "turn_id": "turn_9", + "request_id": "req_llm_1", + "event": "model.request.completed", + "properties": { + "provider": "anthropic", + "model": "claude-sonnet-4", + "latency_ms": 1834, + "usage": { + "input_tokens": 1400, + "output_tokens": 380, + "reasoning_tokens": 120, + "cache_read_tokens": 900, + "cache_write_tokens": 0 + }, + "retry_status": { "type": "not_retrying" } + } +} +``` + +#### Why this is better + +- Cost and latency analysis become first-class. +- Policy engines can reason about real operations, not just stage summaries. +- Cross-provider comparison gets much easier. + +### 10. Generate and enforce the public schema, docs, and examples from one registry + +#### Proposal + +Build a single `event_schema_registry` source that defines: + +- envelope fields +- event families +- payload types +- union types +- versioning +- example payloads + +Artifacts generated from it: + +- Rust types +- TypeScript types +- JSON Schema +- OpenAPI / SSE docs +- sample event fixtures +- validation tests + +#### Concrete rules + +- every public event must have: + - one schema definition + - one example payload + - one validation test +- no endpoint may inject extra consumer-visible fields outside the schema +- keep-alive frames are documented separately from payload events + +#### Why this is better + +- Prevents the OpenCode and Goose class of drift. +- Makes Fabro's event API publishable and stable from day one. + +## Recommended V2 Event Families + +If Fabro were starting from scratch, I would structure the public families like this: + +- `run.*` +- `stage.*` +- `checkpoint.*` +- `parallel.branch.*` +- `session.*` +- `turn.*` +- `message.*` +- `message.part.*` +- `model.request.*` +- `tool.*` +- `command.*` +- `mcp.call.*` +- `approval.*` +- `question.*` +- `interrupt.*` +- `resume.*` +- `compaction.*` +- `retro.*` +- `artifact.*` +- `stream.*` (live only) + +## Recommended Field Placement Rules + +Top-level envelope: + +- identity and correlation +- ordering +- timestamps +- scope + +`properties`: + +- event-family-specific payload +- business data +- structured state payloads + +Never in `properties` if they are structural: + +- `run_id` +- `seq` +- `event` +- `session_id` +- `message_id` +- `tool_call_id` +- `request_id` +- `causation_id` +- `correlation_id` + +## Bottom Line + +The best greenfield version of Fabro is not "the current schema plus more events." + +It is: + +- separate durable and live contracts +- replayable ordered streams +- a richer envelope +- typed state unions +- typed content blocks +- explicit snapshots +- first-class HITL events +- first-class span families +- generated schema/docs/tests from one registry + +That would give Fabro a better event platform than any of the compared systems. diff --git a/docs-internal/logging-strategy.md b/docs-internal/logging-strategy.md index 0b6b0d4b1..08d6a8a09 100644 --- a/docs-internal/logging-strategy.md +++ b/docs-internal/logging-strategy.md @@ -1,6 +1,6 @@ # Fabro Logging Strategy -Fabro uses the `tracing` crate for structured, file-based logging. Logs write to `~/.fabro/logs/YYYY-MM-DD.log`, controlled by the `FABRO_LOG` env var (default: `info`). Logs are for **developers debugging issues after the fact** — they are not user-facing output. +Fabro uses the `tracing` crate for structured, file-based logging. Logs write to `~/.fabro/logs/{prefix}.YYYY-MM-DD.log` (e.g. `cli.2026-04-06.log`, `server.2026-04-06.log`), rotated daily by `tracing-appender`. Logs older than 7 days are cleaned up on startup. Controlled by the `FABRO_LOG` env var (default: `info`). Logs are for **developers debugging issues after the fact** — they are not user-facing output. Production runs at INFO level. INFO should be low-volume and high-signal — the summary of what happened. When something goes wrong, developers enable `FABRO_LOG=debug` to get the full picture. DEBUG can be as verbose as needed since it's only turned on temporarily. @@ -23,7 +23,7 @@ Production runs at INFO level. INFO should be low-volume and high-signal — the - Hot loops or per-token streaming events (use DEBUG only if truly needed for diagnosis) - Data that belongs in user-facing output (`eprintln!` for interactive CLI feedback, not tracing) -- Detached user-visible warnings or errors that need to survive `attach`/`logs` (`detach.log` is debug-only; emit a `WorkflowRunEvent` into `progress.jsonl` instead) +- Detached user-visible warnings or errors that need to survive `attach`/`logs` (`detach.log` is debug-only; emit an `Event` into the run event stream instead) - Redundant information already captured by a parent event (if you logged "starting X", you don't need to log every sub-step at the same level) - Events that are already traced via `EventEnum::trace()` — the event enums (`AgentEvent`, `PipelineEvent`, `ExecutionEnvEvent`) each have a `trace()` method called automatically at their emit site; do not add manual `info!`/`debug!` calls that duplicate what `trace()` already emits - Wrapper/forwarding variants that re-emit an inner event — `PipelineEvent::Agent`, `PipelineEvent::ExecutionEnv`, and `AgentEvent::SubAgentEvent` are no-ops in `trace()` because the inner event is already traced at its origin diff --git a/docs-internal/plan-events-as-source-of-truth-follow-ups.md b/docs-internal/plan-events-as-source-of-truth-follow-ups.md new file mode 100644 index 000000000..65dd707ca --- /dev/null +++ b/docs-internal/plan-events-as-source-of-truth-follow-ups.md @@ -0,0 +1,457 @@ +# Plan: Events as Source of Truth Follow-Ups + +Close the remaining event-contract gaps required before we can execute `~/.claude/plans/memoized-pondering-knuth.md` and make projected `RunState` the primary read model. + +## Context + +`docs-internal/plan-events-as-source-of-truth.md` has mostly landed. The event stream is materially stronger now, but it still does not cover every field that `memoized-pondering-knuth.md` wants to derive from events. + +The next step is not a broad store refactor. It is a narrow follow-up pass that: + +- finishes the missing event coverage +- makes the remaining source-of-truth boundaries explicit +- leaves `memoized-pondering-knuth.md` with no hidden event-contract assumptions + +This plan is a prerequisite plan, not the full event-sourced store migration. + +## Simplification Rules + +These rules govern every follow-up event change in this document: + +- enrich an existing semantic event before inventing a new one +- keep run-level summary data on run-level events +- keep handler-specific metadata on handler-specific events +- avoid storage-shaped event names like `*.recorded`, `*.persisted`, or `*.written` +- explicitly retain non-event concerns instead of half-eventizing them + +If a proposed event change violates one of those rules, prefer a simpler shape. + +## Goal + +After this plan lands, the later `memoized-pondering-knuth.md` work should be able to: + +- build a projected `RunState` without inventing missing data +- replace direct read-side APIs with projection helpers +- remove duplicated write-side persistence for all event-backed fields + +without first having to stop and redesign the event model again. + +## Relationship To The Existing Plans + +### What `plan-events-as-source-of-truth.md` already solved + +- `run.created` +- `stage.completed.response` +- `sandbox.initialized` sandbox metadata +- `checkpoint.completed.diff` +- `command.started` / `command.completed` +- `retro.started.prompt/provider/model` +- `retro.completed.response/retro` + +### What is still missing for `memoized-pondering-knuth.md` + +- semantic run status events +- checkpoint events that can reconstruct full checkpoint snapshots and history +- full pull request record coverage +- final patch coverage +- provider-used coverage +- parallel-results coverage +- an explicit decision for fields that should remain non-event for now + +## Decisions + +### 1. Use semantic run lifecycle events, not a generic `run.status_changed` + +Status should be reconstructed from explicit run lifecycle events rather than a generic “status changed” envelope. + +Add event coverage for: + +- `run.submitted` +- `run.starting` +- `run.running` +- `run.paused` +- `run.removing` +- `run.completed` +- `run.failed` +- `run.dead` + +Projection rule: + +- the latest status-bearing run event defines `RunStatusRecord.status` +- event-specific fields define `RunStatusRecord.reason` +- envelope `ts` defines `RunStatusRecord.updated_at` + +`run.started` remains the start-record / execution-metadata event, not the canonical status event. + +This separation is intentional: + +- `run.started` answers "when and how did execution begin?" +- `run.running` answers "what is the run's status?" + +Status mapping table: + +| Event | Projected `RunStatus` | `StatusReason` rule | +|---|---|---| +| `run.submitted` | `Submitted` | `None` | +| `run.starting` | `Starting` | optional if the emitter has a concrete reason, otherwise `None` | +| `run.running` | `Running` | `None` | +| `run.paused` | `Paused` | preserve emitted reason if present | +| `run.removing` | `Removing` | `None` | +| `run.completed` | `Succeeded` | preserve emitted reason if present; default should remain `Completed` or `PartialSuccess` based on terminal outcome | +| `run.failed` | `Failed` | preserve emitted reason if present; expected common reasons include workflow/bootstrap/sandbox failures | +| `run.dead` | `Dead` | preserve emitted reason if present; otherwise `None` | + +If any current status mutation cannot be represented cleanly by this table, fix the event model in this follow-up plan rather than pushing ambiguity into the later projector. + +### 2. Keep the boundary tight: not every stored value must become an event in this pass + +This follow-up plan should only eventize the fields that block the later projected-state cutover. + +Retain as non-event concerns for now: + +- binary assets +- artifact value blobs / offloaded context artifacts + +This means `memoized-pondering-knuth.md` should be updated afterwards so `artifact_values` is no longer listed as a required event-backed row for the first cut. + +### 3. Prefer complete event payloads over event joins that require hidden store lookups + +If a projected record needs fields that do not already exist in another authoritative event, add them directly to the relevant event. + +Do not rely on: + +- sidecar JSON files +- legacy store records +- “the caller can join this with some other direct read” + +Prefer one self-contained semantic event over reconstructing a record from several unrelated low-level events when that reconstruction adds complexity for little value. + +## Follow-Up Coverage Matrix + +This matrix is the contract for this plan. Each row must be green before `memoized-pondering-knuth.md` starts removing read/write APIs. + +| Field / record needed later | Current direct source | Current event state | Follow-up required | +|---|---|---|---| +| `RunStatusRecord` | `put_status` in create/start/resume/finalize/disk paths | Incomplete; no semantic status event family | Add semantic run lifecycle events and a status mapping table | +| `StartRecord` | `put_start` | Mostly covered by `run.started` | Verify `run.started` fully covers `run_branch`, `base_sha`, `start_time`; no shape change if already true | +| latest `Checkpoint` | `put_checkpoint` | Incomplete; `checkpoint.completed` only carries `node_id`, `status`, `git_commit_sha`, `diff` | Enrich `checkpoint.completed` to carry a full checkpoint snapshot payload | +| checkpoint history | `append_checkpoint` / `list_checkpoints` | Incomplete; history cannot be rebuilt from current event payload | Use fully-populated `checkpoint.completed` as append-only checkpoint history | +| `Conclusion` | `put_conclusion` | Partially covered by terminal events plus stage aggregation | Verify the projector can derive full `Conclusion`, including retries/tokens/stage summaries, from existing events; if projection stays awkward, enrich terminal run events instead of adding new conclusion-only events | +| `PullRequestRecord` | `put_pull_request` | Incomplete; `pull_request.created` only carries URL/number/draft | Enrich `pull_request.created` to carry full `PullRequestRecord` fields | +| final patch | `put_final_patch` | Incomplete; terminal run event carries final SHA but not patch text | Enrich `run.completed` with `final_patch` | +| node provider metadata | `put_node_provider_used` | Incomplete; prompt/CLI events carry provider/model, but agent-mode still relies on sidecar sync | Project from existing handler-specific events and enrich forwarded agent session events if needed | +| node parallel results | `put_node_parallel_results` | Incomplete; `parallel.branch.completed.head_sha` is not enough | Enrich `parallel.completed` to carry the final results payload | +| node diff | `put_node_diff` | Partially covered by `checkpoint.completed.diff` | Decide and document whether node diff is sourced from the latest checkpoint event for that node or a dedicated node diff event; keep one canonical rule | +| retro prompt/response/retro payload | `put_retro_prompt`, `put_retro_response`, `put_retro` | Covered | No new event work; just parity-test it | +| sandbox record | `put_sandbox` | Covered | No new event work; just parity-test it | + +## Required Event Changes + +### 1. Add semantic run lifecycle events + +Add new `Event` variants for: + +- `RunSubmitted` +- `RunStarting` +- `RunRunning` +- `RunPaused` +- `RunRemoving` +- `RunDead` + +Existing terminal events remain: + +- `run.completed` +- `run.failed` + +Required payload fields: + +- `reason: Option` where applicable +- any extra fields already emitted on terminal events should stay there + +Emit from the same places that currently call `put_status`: + +- `lib/crates/fabro-workflow/src/operations/create.rs` +- `lib/crates/fabro-workflow/src/operations/start.rs` +- `lib/crates/fabro-workflow/src/operations/resume.rs` +- `lib/crates/fabro-workflow/src/pipeline/finalize.rs` +- `lib/crates/fabro-workflow/src/lifecycle/disk.rs` +- CLI administrative flows that directly mutate status: + - `lib/crates/fabro-cli/src/commands/runs/rm.rs` + - `lib/crates/fabro-cli/src/commands/run/rewind.rs` + +### 2. Enrich `checkpoint.completed` to carry a full checkpoint snapshot + +Current `checkpoint.completed` is not enough to rebuild `Checkpoint`. + +Add fields covering: + +- `timestamp` is still the envelope `ts` +- `current_node` +- `completed_nodes` +- `node_retries` +- `context_values` +- `node_outcomes` +- `next_node_id` +- `git_commit_sha` +- `loop_failure_signatures` +- `restart_failure_signatures` +- `node_visits` +- `diff` + +Emitter seam: + +- `lib/crates/fabro-workflow/src/lifecycle/event.rs` + +Producer seam for the source checkpoint object: + +- `lib/crates/fabro-workflow/src/lifecycle/disk.rs` + +Design rule: + +- one `checkpoint.completed` event must be sufficient to reconstruct one historical checkpoint record without replaying prior stage events + +That keeps checkpoint history export and `rebuild_meta` simple. + +Do not split checkpoint reconstruction back across `stage.completed` and other incidental events unless there is a strong size or performance reason. A saved checkpoint is a first-class domain event and should be self-contained. + +### 3. Enrich `pull_request.created` to carry the full record + +Current event payload is too small for `PullRequestRecord`. + +Add: + +- `html_url` +- `number` +- `owner` +- `repo` +- `base_branch` +- `head_branch` +- `title` +- `draft` + +Producer seam: + +- `lib/crates/fabro-workflow/src/pipeline/pull_request.rs` + +After this lands, `put_pull_request` should become removable during the later memoized-state cutover. + +Do not add a second storage-oriented PR event. The semantic event is already "pull request created"; it just needs the full payload. + +### 4. Enrich `run.completed` with the final patch + +Current final patch only exists via direct store writes. + +Do not add a separate storage-shaped event. The final patch is run-level terminal metadata, so it belongs on the terminal success event. + +Enrich `run.completed`, using the patch already computed from: + +- `lib/crates/fabro-workflow/src/lifecycle/git.rs` + +Required payload: + +- `final_patch: Option` + +Projection rule: + +- `RunState.final_patch` projects from `run.completed.properties.final_patch` + +This keeps final run summary data in one place alongside: + +- `status` +- `duration_ms` +- `artifact_count` +- `final_git_commit_sha` + +If failed runs later need final patch coverage too, extend the terminal failure event deliberately. Do not introduce a separate patch-persistence event unless terminal events prove insufficient. + +### 5. Finish provider-used coverage using existing handler-specific events + +The current system still reads `provider_used.json` from disk and syncs it into the store. That is not event-sourced. + +Replace that with one explicit projection rule based on existing handler-specific events. + +Use: + +- `stage.prompt` for prompt-mode stages +- forwarded `agent.session.started` for agent-mode stages +- `agent.cli.started` for CLI-backed agent stages + +If agent-mode forwarded session events still do not carry enough metadata, enrich `AgentEvent::SessionStarted` rather than adding a new stage-wide event. + +Required projected output: + +- `mode` +- `provider` +- `model` +- any existing raw provider-used JSON fields that are still needed by consumers + +Likely seams: + +- `lib/crates/fabro-workflow/src/handler/agent.rs` +- `lib/crates/fabro-workflow/src/handler/llm/api.rs` +- `lib/crates/fabro-workflow/src/pipeline/retro.rs` if retro uses the same forwarded agent session path +- any CLI-backed LLM path if it still produces `provider_used.json` + +Do not keep the current “read JSON sidecar, then `put_node_provider_used`” pattern once this event exists. + +Do not add a stage-generic provider-used event. Provider metadata is transport-specific and should stay attached to the prompt/agent/CLI events that actually know it. + +### 6. Enrich `parallel.completed` with the final results payload + +`parallel.branch.completed.head_sha` is useful but not enough to replace `put_node_parallel_results`. + +Use the existing terminal parallel event rather than adding a storage-shaped event name. + +Add to `parallel.completed`, emitted from: + +- `lib/crates/fabro-workflow/src/handler/parallel.rs` + +Required new payload: + +- `results` as the same JSON array currently persisted to `parallel_results.json` + +Projection rule: + +- `NodeState.parallel_results` projects from `parallel.completed.properties.results` + +Why this is the right event: + +- it is emitted after all branch executions have joined +- the final result set has already been assembled +- it represents completion of the parallel node’s branch-collection phase + +The workflow-level outcome of the node still comes from `stage.completed`; `parallel.completed` just becomes the canonical source for the branch result set. + +Do not add `parallel.results_recorded` or similar. The semantic event already exists. + +### 7. Lock down the node diff rule + +We already added `checkpoint.completed.diff`, but the memoized plan should not proceed until there is one explicit derivation rule for node diff. + +Decision required: + +- either `NodeState.diff` is “latest checkpoint diff for that node visit” +- or add a dedicated `node.diff_generated` event + +This follow-up plan should pick one and update docs/tests accordingly. + +Given the current code, using `checkpoint.completed.diff` is the simpler option unless multiple diffs per node visit need to be preserved. + +Prefer `checkpoint.completed.diff` unless a concrete consumer proves that diff generation and checkpoint persistence are semantically different moments. + +## File Map + +Likely files to touch: + +- `docs-internal/events.md` +- `docs-internal/run-directory-keys.md` +- `docs-internal/events-strategy.md` +- `lib/crates/fabro-workflow/src/event.rs` +- `lib/crates/fabro-workflow/src/lifecycle/event.rs` +- `lib/crates/fabro-workflow/src/lifecycle/disk.rs` +- `lib/crates/fabro-workflow/src/lifecycle/git.rs` +- `lib/crates/fabro-workflow/src/operations/create.rs` +- `lib/crates/fabro-workflow/src/operations/start.rs` +- `lib/crates/fabro-workflow/src/operations/resume.rs` +- `lib/crates/fabro-workflow/src/pipeline/finalize.rs` +- `lib/crates/fabro-workflow/src/pipeline/pull_request.rs` +- `lib/crates/fabro-workflow/src/handler/agent.rs` +- `lib/crates/fabro-workflow/src/handler/parallel.rs` +- `lib/crates/fabro-cli/src/commands/runs/rm.rs` +- `lib/crates/fabro-cli/src/commands/run/rewind.rs` +- tests in `fabro-workflow`, `fabro-cli`, and `fabro-store` + +## Phases + +### Phase 1: Define the missing event contract + +- add semantic run lifecycle events +- enrich `checkpoint.completed` +- enrich `pull_request.created` +- enrich `run.completed` with `final_patch` +- add provider-used event coverage +- add parallel-results event coverage +- document the node diff derivation rule + +This phase is complete when every row in the follow-up coverage matrix is backed by an explicit event contract. + +Priority during this phase: + +- first enrich existing semantic events +- only add a truly new event when no existing semantic event owns the data + +### Phase 2: Emit the new events everywhere status/data currently writes directly + +Replace silent state mutation with canonical event emission first. + +Important rule: + +- do not remove direct store writes yet +- dual-write is acceptable in this phase +- the purpose is to prove event completeness before the memoized-state migration begins + +### Phase 3: Add parity tests against current stored records + +Add tests that build real event sequences and verify the future projector contract is now possible for: + +- status reconstruction +- start record reconstruction +- checkpoint reconstruction +- checkpoint history reconstruction +- pull request reconstruction +- final patch reconstruction +- provider-used reconstruction +- parallel-results reconstruction + +Where a direct legacy store record still exists, compare the event-derived value against the legacy persisted value. + +Keep the tests shaped around semantic events, not internal store APIs. The point is to prove the event contract is sufficient. + +Minimum parity scenarios: + +- create-only run before execution starts +- normal started/running run +- resumed run +- rewound run +- successful git-backed run with final patch +- PR-producing run +- parallel run with branch results +- retro-enabled run +- failed run that exits through terminal/drop-guard paths + +### Phase 4: Update the downstream migration plan + +Once this follow-up plan lands: + +- update `~/.claude/plans/memoized-pondering-knuth.md` +- remove any rows that were intentionally retained as non-event concerns +- mark the newly-completed event-backed rows as ready +- delete stale “likely needs to be added” wording that is no longer true + +This keeps the later store-migration plan honest and implementation-ready. + +## Verification + +1. `cargo build --workspace` +2. `cargo clippy --workspace -- -D warnings` +3. `cargo nextest run -p fabro-workflow` +4. `cargo nextest run -p fabro-cli` +5. `cargo nextest run -p fabro-store` +6. `cargo nextest run --workspace` +7. Manual: + - create a run and inspect `progress.jsonl` + - verify semantic run lifecycle events appear in the expected order + - verify a run with git changes emits `run.completed.properties.final_patch` + - verify a PR-producing run emits full PR metadata in `pull_request.created` + - verify a parallel run emits canonical final results on `parallel.completed` without reading `parallel_results.json` + +## Exit Criteria + +This follow-up plan is complete when: + +- every row in the follow-up coverage matrix is either event-backed or explicitly retained as non-event +- direct store writes are no longer the only source for status, checkpoint history, pull request record, final patch, provider-used, or parallel results +- projector parity tests prove the later `RunState` refactor has the event data it needs +- `memoized-pondering-knuth.md` can be updated to proceed without hidden event-contract gaps + +At that point, the later migration work should mostly be mechanical projection and API cleanup, not more event-model design. diff --git a/docs-internal/plan-events-as-source-of-truth.md b/docs-internal/plan-events-as-source-of-truth.md new file mode 100644 index 000000000..c9bedb45f --- /dev/null +++ b/docs-internal/plan-events-as-source-of-truth.md @@ -0,0 +1,163 @@ +# Plan: Events as Source of Truth + +Make all run directory key data derivable from events in `progress.jsonl`. + +## Summary of changes + +### Clarified derivation rules + +- Envelope fields count as event sources. Any file field may be sourced from event envelope metadata (`run_id`, `ts`, `node_id`, `node_label`) as well as `properties`. +- When a file is reconstructed from multiple events, the derivation should be documented explicitly in `events.md` and `run-directory-keys.md`; consumers should not need to infer cross-event joins. +- `retro.json` is reconstructed from multiple events: `run.started` provides `workflow_name` and `goal`, `retro.completed` provides the parsed `retro` payload, and the retro event envelope provides the completion timestamp. +- `conclusion.json.final_git_commit_sha` is normalized from the terminal run event: `run.completed.properties.final_git_commit_sha` on success/partial success, `run.failed.properties.git_commit_sha` on failure. + +### New events + +| Event | Purpose | +|-------|---------| +| `run.created` | Full run definition (settings, graph, workflow source/config, labels, paths). Emitted at end of CREATE operation, before START. | +| `command.started` | Command node invocation: script, language, timeout_ms | +| `command.completed` | Command node result: stdout, stderr, exit_code, duration_ms, timed_out | +| `agent.cli.started` | CLI-backend LLM invocation: mode, provider, model, command | +| `agent.cli.completed` | CLI-backend LLM result: stdout, stderr | + +### Enriched events + +| Event | New fields | +|-------|------------| +| `stage.started` | Remove `script` (moved to `command.started`), make `handler_type` non-optional | +| `stage.completed` | `context_updates`, `jump_to_node`, `context_values`, `node_visits`, `loop_failure_signatures`, `restart_failure_signatures`, `response` | +| `sandbox.initialized` | `provider`, `identifier`, `host_working_directory`, `container_mount_point` | +| `checkpoint.completed` | `diff` | +| `parallel.branch.completed` | `head_sha` | +| `agent.session.started` | `mode`, `provider`, `model` | +| `stage.prompt` | `mode`, `provider`, `model` | +| `retro.started` | `prompt`, `provider`, `model` | +| `retro.completed` | `response`, `retro` (full Retro struct) | + +### Already covered (no changes needed) + +| Key | Event source | +|-----|--------------| +| `start.json` | `run.started` | +| `nodes/{node_id}/prompt.md` | `stage.prompt` | +| `nodes/{node_id}/status.json` | `stage.completed` | +| `retro/status.json` | `retro.completed` / `retro.failed` | +| `conclusion.json` | `run.completed` / `run.failed` + aggregation from `stage.completed` | +| `checkpoints/*.json` | Same as `checkpoint.json` — derived from `stage.completed` replay | + +--- + +## Detailed decisions + +### 1–2. `_init.json` + `run.json` — new `run.created` event + +**Decision:** Emit a `run.created` event at the end of the CREATE operation (before START). Carries everything needed to persist the run: + +- From `_init.json`: `created_at`, `db_prefix`, `run_dir` +- From `run.json`: `settings`, `graph`, `workflow_slug`, `working_directory`, `host_repo_path`, `base_branch`, `labels` +- From `workflow.fabro`/`workflow.toml`: `workflow_source` (raw dot text), `workflow_config` (raw TOML text) + +The existing `run.started` stays lightweight — it signals execution has begun. The CREATE→START boundary is: `run.created` persists the run definition, `run.started` marks execution start. + +Derivation details: +- `_init.json.run_id` and `run.json.run_id` come from `run.created` envelope `run_id` +- `_init.json.created_at` and `run.json.created_at` come from `run.created` envelope `ts` + +### 3. `checkpoint.json` — internal engine state + +**Decision:** Add `context_updates`, `jump_to_node`, `context_values`, `node_visits`, `loop_failure_signatures`, and `restart_failure_signatures` to the `stage.completed` event properties. All checkpoint fields become derivable from replaying stage events. + +New fields on `stage.completed`: +- `context_updates` — map of context key → JSON value set by this stage +- `jump_to_node` — non-edge jump target (optional) +- `context_values` — full accumulated context map after this stage +- `node_visits` — map of node id → visit count after this stage +- `loop_failure_signatures` — failure signature → count after this stage +- `restart_failure_signatures` — failure signature → count after this stage + +Derivation details: +- `checkpoint.json.timestamp` comes from the corresponding `checkpoint.completed` envelope `ts` +- `checkpoints/{seq:04}-{epoch_ms}.json` uses the same derivation as `checkpoint.json`, with snapshot timestamp taken from each `checkpoint.completed` envelope `ts` + +### 4. `retro.json` — LLM-generated retro content + +**Decision:** Embed the full `Retro` struct as a `retro` property on the `retro.completed` event. + +Derivation details: +- `retro.json.run_id` comes from the `retro.completed` envelope `run_id` +- `retro.json.workflow_name` comes from `run.started.properties.name` +- `retro.json.goal` comes from `run.started.properties.goal` +- `retro.json.timestamp` comes from the `retro.completed` envelope `ts` +- The remaining retro content comes from `retro.completed.properties.retro` + +### 5. `sandbox.json` — missing fields + +**Decision:** Add `host_working_directory`, `container_mount_point`, `provider`, and `identifier` to `sandbox.initialized` so it alone is sufficient to reconstruct `sandbox.json`. + +### 6. `workflow.fabro` + `workflow.toml` — raw source files + +**Decision:** Add `workflow_source` (raw dot text) and `workflow_config` (raw TOML text) as string fields on `run.created`. Covered by gap 1–2. + +### 7. `nodes/{node_id}/response.md` — LLM response text + +**Decision:** Add a `response` string field to `stage.completed` for LLM stages. + +### 8. `nodes/{node_id}/prompt.md` — already covered + +No change needed. `stage.prompt` is emitted per handler invocation (including retries) with the full rendered prompt text. + +### 9–10. `stdout.log`, `stderr.log`, `script_timing.json` — new `command.completed` event + +**Decision:** Emit a `command.completed` event after a command node finishes. Fields: +- `stdout`, `stderr`, `exit_code`, `duration_ms`, `timed_out` + +The `node_id` is in the envelope. + +### 11. `nodes/{node_id}/script_invocation.json` — new `command.started` event + +**Decision:** Emit a `command.started` event with `script`, `language`, `timeout_ms`. Pairs with `command.completed`. Remove `script` from `stage.started` — it's handler-specific data that belongs on the handler-specific event (same pattern as `agent.session.started` and `stage.prompt`). + +### 12. `cli_stdout.log`, `cli_stderr.log`, `provider_used.json` (CLI) — new `agent.cli.started` / `agent.cli.completed` events + +**Decision:** Emit an `agent.cli.started` event before the CLI subprocess starts, with `mode` ("cli"), `provider`, `model`, `command`. Emit `agent.cli.completed` after it finishes, with `stdout`, `stderr`. The `node_id` is in the envelope. + +### 13. `nodes/{node_id}/diff.patch` — git diff + +**Decision:** Add a `diff` string field to `checkpoint.completed`. + +### 14. `nodes/{node_id}/provider_used.json` — provider metadata + +**Decision:** Add provider metadata to handler-specific events: +- `agent.session.started` gets `mode` ("agent"), `provider`, `model` +- `agent.cli.started` gets `mode` ("cli"), `provider`, `model`, `command` +- `stage.prompt` gets `mode` ("prompt"), `provider`, `model` + +### 15. `parallel_results.json` — `head_sha` + +**Decision:** Add an optional `head_sha` field to `parallel.branch.completed`. + +### 16. `retro/prompt.md` + `retro/response.md` + +**Decision:** Add `prompt` to `retro.started`. Add `response` to `retro.completed` (alongside the parsed retro struct). + +### 17. `retro/provider_used.json` + +**Decision:** Add `provider` and `model` to `retro.started` alongside the prompt. + +### Conclusion timestamps and final SHA normalization + +**Decision:** Treat terminal run events as the authoritative source for `conclusion.json`. + +Derivation details: +- `conclusion.json.timestamp` comes from the terminal event envelope `ts` (`run.completed` or `run.failed`) +- `conclusion.json.final_git_commit_sha` comes from `run.completed.properties.final_git_commit_sha` on success/partial success +- On failure, `conclusion.json.final_git_commit_sha` is reconstructed from `run.failed.properties.git_commit_sha` + +--- + +## Follow-up improvements + +- `node_visits`, `loop_failure_signatures`, `restart_failure_signatures` on `stage.completed` are snapshots of accumulated state. In the future, these could be derived from event replay instead of being carried on each event — removing them would reduce event size but require replay logic in every consumer. +- `context_values` is the full accumulated context map. If context maps grow large, a future optimization could emit only `context_updates` (the delta) and require consumers to accumulate. For now, shipping the full snapshot is simpler. +- `stdout`/`stderr` on `command.completed` could be large. If this becomes a problem, consider a size threshold with truncation or a separate blob store with a reference in the event. diff --git a/docs-internal/run-directory-keys.md b/docs-internal/run-directory-keys.md new file mode 100644 index 000000000..4d5adc8e7 --- /dev/null +++ b/docs-internal/run-directory-keys.md @@ -0,0 +1,42 @@ +# Run Scratch Files + +This document maps the files that still live under a run scratch directory. Durable run state lives in the run store and metadata branch; scratch is mostly local runtime state and caches. + +Scope: +- Scratch root: `~/.fabro/scratch/YYYYMMDD-{run_id}/` +- This covers local run files only +- Persistent store keys live in `lib/crates/fabro-store/src/keys.rs` +- Artifact object-store keys live in `lib/crates/fabro-store/src/artifact_store.rs` + +There is no `_init.json` anymore. Run existence in the database is determined by stored run events, and local scratch directories are managed separately under `scratch/`. + +## Root-Level Files + +| File | Purpose | Source | +|---|---|---| +| `workflow_bundle.json` | Bundled workflow input used by `start` to restore `workflow_path` and bundled child workflows/files | Written during create from the resolved workflow bundle | +| `run.pid` | Legacy detached-run pid file from older runs | Legacy only; current flows do not rely on it | + +## Local-Only Directories + +These paths are local runtime state, not canonical event projections. + +| Path | Purpose | +|---|---| +| `worktree/` | Git worktree used by checkpointed runs | +| `runtime/blobs/` | Materialized local blob payloads for file-backed `fabro+blob://` references | +| `runtime/worker.stderr.log` | Server-managed worker stderr capture | +| `nodes/{manager_node}_{visit}/child/` | Nested scratch root for manager-loop child workflows | + +## Reconstructed / Exported Files + +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 +- Retro-agent temp uploads named `progress.jsonl`, `checkpoint.json`, `run.json`, and `start.json` inside the retro sandbox + +## Notes + +- Artifact binaries are no longer stored in the SlateDB keyspace. They live in `ArtifactStore`; the run scratch tree only contains local cached copies when a workflow stage writes them to disk. +- Final diffs for checkpointed runs are projected from the run store; they are no longer written as scratch files. diff --git a/docs-internal/slow-test-opportunities-2026-04-07.md b/docs-internal/slow-test-opportunities-2026-04-07.md new file mode 100644 index 000000000..2fe511ff4 --- /dev/null +++ b/docs-internal/slow-test-opportunities-2026-04-07.md @@ -0,0 +1,410 @@ +# Slow Test Improvement Opportunities + +Date: 2026-04-07 + +All measurements in this note were taken with `ulimit -n 4096` in the test subshell. + +Primary timing dataset: +- `/tmp/fabro-slow-tests-ulimit.7FJkSO/slow_tests_passing.csv` +- `/tmp/fabro-slow-tests-ulimit.7FJkSO/report_passing.txt` + +Passing suite baseline: +- `cargo nextest run --workspace --no-fail-fast --status-level fail --final-status-level fail --show-progress none` +- Result: `3631 passed, 182 skipped` + +Method: +- Ranking is based on the 5-pass passing dataset above. +- Impact estimates are aggregate median test-time reductions, not additive suite wall-clock reductions. +- Where a number is inferred rather than directly measured, that is called out explicitly. + +## Top 10 + +### [x] 1. Change two slow `exec` mock responses from retriable `500` to non-retriable `400` + +Files: +- `lib/crates/fabro-cli/tests/it/cmd/exec.rs` + +Measured evidence: +- `fabro-cli::it::cmd::exec::exec_cli_server_target_overrides_configured_server_target`: `6.858s` median +- `fabro-cli::it::cmd::exec::exec_server_target_uses_remote_transport_instead_of_local_api_key_resolution`: `6.787s` median +- Direct microbenchmark of the same CLI path: + - mocked `500`: `7.49s` median + - mocked `400`: `0.053s` median + +Implementation status: +- Implemented in `lib/crates/fabro-cli/tests/it/cmd/exec.rs` +- Verified with `ulimit -n 4096` via 5 targeted nextest runs per test +- Post-change nextest exec-time medians: + - `exec_server_target_uses_remote_transport_instead_of_local_api_key_resolution`: `1.580s` + - `exec_cli_server_target_overrides_configured_server_target`: `1.563s` + +Estimated impact: +- About `13.54s` aggregate median test time + +Complexity: +- Low + +Pros: +- Pure test change +- Strongest measured single win +- Keeps the same assertion shape if the response body marker is preserved + +Cons: +- If retry-on-5xx coverage matters, keep one dedicated retry-focused test elsewhere + +--- + +### 2. Short-circuit delete-path worker grace for already-terminal runs + +Files: +- `lib/crates/fabro-server/src/server.rs` + +Measured evidence: +- `fabro-cli::it::cmd::system_prune::system_prune_yes_deletes_matching_runs`: `10.477s` +- `fabro-cli::it::cmd::rm::rm_deletes_completed_run`: `5.359s` +- `fabro-cli::it::cmd::rm::rm_partial_failure_reports_which_identifiers_failed`: `5.293s` +- `fabro-cli::it::cmd::rm::rm_partial_failure_json_includes_removed_and_errors`: `5.257s` +- `fabro-cli::it::cmd::rm::rm_force_deletes_run_without_sandbox_json_when_store_has_sandbox`: `5.275s` +- Server code uses `WORKER_CANCEL_GRACE = 5s` in `terminate_worker_for_deletion()` + +Estimated impact: +- Roughly `20-25s` aggregate across the measured completed-run delete tests +- This is an inference from the timing cluster plus the `5s` grace, not a standalone delta benchmark + +Complexity: +- Medium + +Pros: +- Helps real behavior, not just tests +- Likely addresses the single slowest test too + +Cons: +- Needs careful correctness review around active worker shutdown semantics +- Higher overlap with several delete-related tests + +--- + +### [x] 3. Collapse the five abnormally slow `help` integration tests into one smoke test or a lighter harness + +Files: +- `lib/crates/fabro-cli/tests/it/cmd/artifact.rs` +- `lib/crates/fabro-cli/tests/it/cmd/artifact_list.rs` +- `lib/crates/fabro-cli/tests/it/cmd/artifact_cp.rs` +- `lib/crates/fabro-cli/tests/it/cmd/config.rs` +- `lib/crates/fabro-cli/tests/it/cmd/attach.rs` + +Measured evidence: +- Slow `help` tests: + - `artifact_list::help`: `1.659s` + - `artifact_cp::help`: `1.656s` + - `artifact::help`: `1.640s` + - `config::help`: `1.592s` + - `attach::help`: `1.559s` +- Aggregate median across those 5 tests: `8.106s` +- Direct command timings: + - `artifact list --help`: about `10ms` + - `attach --help`: about `9ms` + - `settings --help`: about `10ms` + +Estimated impact: +- Conservative recoverable time: about `6.45s` + +Implementation status: +- Implemented by removing the 5 command-owned help tests and replacing them with `scenario::smoke::help_smoke_covers_high_cost_commands` +- Verified with `ulimit -n 4096; cargo nextest run -p fabro-cli help_smoke_covers_high_cost_commands completion_smoke_covers_help_and_generation --status-level fail --final-status-level fail --show-progress none` +- Verification result: `2 passed` +- Post-change timing over 5 targeted runs: + - `fabro-cli::it::scenario::smoke::help_smoke_covers_high_cost_commands`: `2.164s` median + +Complexity: +- Low + +Pros: +- Pure harness cleanup +- Clearly process/setup dominated rather than command-work dominated + +Cons: +- Less granular failure reporting + +--- + +### [x] 4. Replace `doctor_no_color_when_no_color_set` with a render-path assertion + +Files: +- `lib/crates/fabro-cli/tests/it/cmd/doctor.rs` +- `lib/crates/fabro-util/src/check_report.rs` + +Measured evidence: +- `fabro-cli::it::cmd::doctor::doctor_no_color_when_no_color_set`: `5.131s` +- Direct timing of `fabro doctor` under minimal test-like env: `5.115s` +- Existing unit coverage already exercises no-color report rendering + +Estimated impact: +- About `5.13s` + +Implementation status: +- Implemented by deleting `fabro-cli::it::cmd::doctor::doctor_no_color_when_no_color_set` +- Added a unit-level render assertion in `lib/crates/fabro-cli/src/commands/doctor.rs`: + - `render_report_text_without_color_has_no_ansi` +- Verified with `ulimit -n 4096; cargo nextest run -p fabro-cli render_report_text_without_color_has_no_ansi --status-level fail --final-status-level fail --show-progress none` +- Verification result: `1 passed` +- Removed median cost from the suite: `5.131s` + +Complexity: +- Low to medium + +Pros: +- Same intent can likely be covered without a full diagnostics run +- Very high return for a single test + +Cons: +- Slightly less end-to-end than the current test +- Requires choosing the right lower-level render assertion + +--- + +### [x] 5. Fix local Unix-socket autostart so it doesn't burn the full 5s readiness wait + +Files: +- `lib/crates/fabro-cli/src/server_client.rs` +- `lib/crates/fabro-cli/tests/it/cmd/server_start.rs` + +Measured evidence: +- Pre-fix 5-run timing for `fabro-cli::it::cmd::server_start::concurrent_autostart_converges_on_one_shared_daemon_and_cleans_up`: + - runs: `6.786998459`, `6.833243958`, `6.832145834`, `6.851452709`, `6.768980709` + - median: `6.832s` + - stdev: `0.035s` +- Direct measurement showed the real bottleneck was not the test's polling loops: + - two concurrent fresh `fabro --json settings` calls each took about `5.1s` + - `fabro server stop --timeout 0` only took about `0.12s` +- Root cause: the Unix-socket client was doing a full `wait_for_server_ready()` loop before attempting local autostart when no daemon was running + +Estimated impact: +- Measured win on the original `server_start` test: about `5.02s` +- This also speeds up other fresh local Unix-socket autostart paths that hit the same client logic + +Complexity: +- Medium + +Pros: +- Fixes a real product-path inefficiency instead of just shaving test harness overhead +- Large win on the original slow test + +Cons: +- Touched shared local-server connection logic, so verification needs to cover the autostart path itself + +Implementation status: +- Implemented by splitting the Unix-socket connection path into: + - a single immediate health probe before autostart + - the existing retrying readiness wait after autostart +- Kept the original integration test coverage in `lib/crates/fabro-cli/tests/it/cmd/server_start.rs` +- Verified with `ulimit -n 4096; cargo nextest run -p fabro-cli concurrent_autostart_converges_on_one_shared_daemon_and_cleans_up --status-level fail --final-status-level fail --show-progress none` +- Verification result: `1 passed` +- Post-change 5-run timing for `fabro-cli::it::cmd::server_start::concurrent_autostart_converges_on_one_shared_daemon_and_cleans_up`: + - runs: `1.801041000`, `1.812114833`, `1.760408750`, `1.817953958`, `1.958977916` + - median: `1.812s` + - stdev: `0.075s` +- Aggregate measured change for the original test: + - before: `6.832s` + - after: `1.812s` + - saved: `5.020s` +- Direct post-change autostart probe across 3 fresh concurrent runs: + - median process runtime: `0.092s` + - stdev: `0.028s` + +--- + +### [x] 6. Collapse three lightweight `attach` smoke tests into one scenario-style test + +Files: +- `lib/crates/fabro-cli/tests/it/cmd/attach.rs` + +Measured evidence: +- `attach_requires_run_arg`: `1.595s` +- `attach_uses_configured_server_target_without_server_flag`: `1.555s` +- `attach_errors_when_live_stream_ends_before_terminal_event`: `1.629s` +- Aggregate median: `4.779s` +- Direct `fabro attach` parse-error path is about `12ms` + +Estimated impact: +- Conservative recoverable time: about `3.15s` + +Implementation status: +- Implemented by removing the 3 command-owned smoke tests from `lib/crates/fabro-cli/tests/it/cmd/attach.rs` +- Added `fabro-cli::it::scenario::smoke::attach_smoke_covers_arg_validation_and_remote_server_behaviors` +- Verified with `ulimit -n 4096; cargo nextest run -p fabro-cli attach_smoke_covers_arg_validation_and_remote_server_behaviors --status-level fail --final-status-level fail --show-progress none` +- Verification result: `1 passed` +- Post-change timing over 5 targeted runs: + - `fabro-cli::it::scenario::smoke::attach_smoke_covers_arg_validation_and_remote_server_behaviors`: `1.584s` median +- Aggregate measured change for the full 3-test batch: + - before: `4.779s` median test-time sum + - after: `1.584s` median test-time sum + - saved: `3.195s` + +Complexity: +- Low to medium + +Pros: +- Fits the user's preference for merging complex cmd coverage into more natural scenarios +- Mostly harness/process cost + +Cons: +- Bundles distinct failure modes together + +--- + +### [x] 7. Collapse the three `completion` tests + +Files: +- `lib/crates/fabro-cli/tests/it/cmd/completion.rs` + +Measured evidence: +- `completion::generates_zsh_completions`: `1.567s` +- `completion::generates_fish_completions`: `1.566s` +- `completion::help`: `1.564s` +- Aggregate median: `4.697s` +- Direct command timings: + - `completion zsh`: about `13ms` + - `completion fish`: about `13ms` + - `completion --help`: about `10ms` + +Estimated impact: +- Conservative recoverable time: about `3.13s` + +Implementation status: +- Implemented by removing the 3 command-owned completion smoke tests and replacing them with `scenario::smoke::completion_smoke_covers_help_and_generation` +- Post-change timing over 5 targeted runs: + - `fabro-cli::it::scenario::smoke::completion_smoke_covers_help_and_generation`: `2.147s` median +- Aggregate measured change for the full 8-test batch: + - before: `12.803s` median test-time sum + - after: `4.310s` median test-time sum + - saved: `8.492s` + +Complexity: +- Low + +Pros: +- Very safe refactor +- Strong evidence that cost is test harness overhead + +Cons: +- Less granular failures if combined too aggressively + +--- + +### [x] 8. Remove or merge the duplicate attach replay test + +Files: +- `lib/crates/fabro-cli/tests/it/cmd/attach.rs` + +Measured evidence: +- `attach_replays_completed_detached_run`: `2.696s` +- `attach_replays_from_store_without_run_json_or_progress_jsonl`: `2.675s` +- The two tests are currently identical in code and assertions + +Implementation status: +- Implemented by removing the duplicate test from `lib/crates/fabro-cli/tests/it/cmd/attach.rs` +- Verified with `ulimit -n 4096; cargo nextest run -p fabro-cli attach_replays_completed_detached_run --status-level fail --final-status-level fail --show-progress none` +- Verification result: `1 passed` + +Estimated impact: +- Immediate `2.675s` if the duplicate is removed + +Complexity: +- Low + +Pros: +- Full savings on one test +- Strongest low-risk cleanup in `attach.rs` + +Cons: +- If the intended missing-file case matters, the merged test should actually delete `run.json` / `progress.jsonl` + +--- + +### [x] 9. Make `attach_before_completion_streams_to_finished_state` event-driven instead of sleep-driven + +Files: +- `lib/crates/fabro-cli/tests/it/cmd/attach.rs` + +Measured evidence: +- `attach_before_completion_streams_to_finished_state`: `3.043s` +- The test includes `sleep(Duration::from_secs(1))` +- `write_gated_workflow()` adds another fixed `sleep 0.2` + +Implementation status: +- Implemented by replacing the fixed 1-second gate-release sleep with a real attach-output signal in `lib/crates/fabro-cli/tests/it/cmd/attach.rs` +- The test now spawns `fabro attach`, waits for replayed stderr output (`✓ start`), then releases the workflow gate +- Verified with `ulimit -n 4096; cargo nextest run -p fabro-cli attach_before_completion_streams_to_finished_state --status-level fail --final-status-level fail --show-progress none` +- Verification result: `1 passed` +- Isolated A/B benchmark over 5 targeted nextest runs with `ulimit -n 4096`, comparing the current workspace to a detached `HEAD` worktree using the same `CARGO_TARGET_DIR`: + - before (`HEAD` sleep-driven test): `8.013s` median, `0.227s` stdev + - after (current event-driven test): `6.933s` median, `0.016s` stdev + - saved: `1.079s` + +Estimated impact: +- Measured isolated saving: `1.079s` +- Full-suite saving should be at least about `1.0s` + +Complexity: +- Low + +Pros: +- Removes an explicit fixed delay +- Makes the test more deterministic + +Cons: +- Overlaps with the broader gated-workflow helper improvement below + +--- + +### [x] 10. Remove or parameterize the fixed `sleep 0.2` in `write_gated_workflow()` + +Files: +- `lib/crates/fabro-cli/tests/it/cmd/support.rs` + +Measured evidence: +- `write_gated_workflow()` hardcodes `sleep 0.2` +- The helper is used in 6 cmd tests + +Implementation status: +- Implemented by deleting the fixed `sleep 0.2` from `write_gated_workflow()` in `lib/crates/fabro-cli/tests/it/cmd/support.rs` +- Verified with `ulimit -n 4096; cargo nextest run -p fabro-cli -E 'test(attach_before_completion_streams_to_finished_state) | test(ctrl_c_cancels_active_run_via_server) | test(rm_force_terminates_active_run_worker) | test(start_rejects_already_active_or_completed_run) | test(start_runs_under_server_ownership_without_launcher_record)' --status-level fail --final-status-level fail --show-progress none` +- Verification result: `5 passed` +- Targeted 5-pass benchmark with `ulimit -n 4096` over the 5 tests that currently use the helper: + - before: `24.783s` aggregate median test-time sum + - after: `23.558s` aggregate median test-time sum + - saved: `1.225s` +- Per-test median deltas: + - `attach_before_completion_streams_to_finished_state`: `1.977s -> 1.635s` + - `ctrl_c_cancels_active_run_via_server`: `6.781s -> 6.681s` + - `rm_force_terminates_active_run_worker`: `11.984s -> 11.876s` + - `start_rejects_already_active_or_completed_run`: `2.041s -> 1.684s` + - `start_runs_under_server_ownership_without_launcher_record`: `2.000s -> 1.682s` + +Estimated impact: +- Measured aggregate saving across current helper users: `1.225s` + +Complexity: +- Low + +Pros: +- Small suite-wide gain +- Straightforward helper cleanup + +Cons: +- Overlaps slightly with item 9 + +## Notes On Excluded Ideas + +- I did not rank broad `rm` test consolidation highly on its own because the dominant cost appears to be the delete path itself, not just fixture setup. +- I did not rank `system_prune_dry_run_lists_matching_runs_without_deleting` because it is already fast at `0.183s`; the expensive case is specifically `--yes`. +- I did not rank `attach_json_errors_without_prompting_for_human_input` higher than item 8 because it is clearly slow (`6.882s`) but I did not finish a direct before/after measurement for replacing its `logs --json` polling loop with direct event-store polling. + +## Suggested First Pass + +If optimizing for highest impact with lowest complexity: + +1. Change the two slow `exec` tests to use non-retriable mock statuses +2. Remove or fix the duplicate attach replay test +3. Collapse the slow `help` / `completion` smoke tests into lighter coverage diff --git a/docs-internal/updating-web-screenshots.md b/docs-internal/updating-web-screenshots.md index 9b3e9dc80..18f3e8775 100644 --- a/docs-internal/updating-web-screenshots.md +++ b/docs-internal/updating-web-screenshots.md @@ -17,7 +17,7 @@ docker compose -f docker/docker-compose.yaml up api web -d Wait ~10 seconds for both services to be ready, then verify: ```bash -curl -s -o /dev/null -w "%{http_code}" http://localhost:5173/runs +curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/runs # Should return 200 ``` diff --git a/docs/administration/deploy-server.mdx b/docs/administration/deploy-server.mdx index c946b27f5..04b45d89f 100644 --- a/docs/administration/deploy-server.mdx +++ b/docs/administration/deploy-server.mdx @@ -1,26 +1,26 @@ --- -title: "Server Mode" +title: "Running the Fabro Server" description: "Run Fabro as an API server with a web UI, concurrent runs, and team access" --- - Server mode is in private early access. Contact [bryan@qlty.sh](mailto:bryan@qlty.sh) if you're interested in trying it. + The server interface is in private early access. Contact [bryan@qlty.sh](mailto:bryan@qlty.sh) if you're interested in trying it. -Fabro has two modes: **standalone** and **server**. Standalone mode (`fabro run`) executes a single workflow synchronously in your terminal. Server mode (`fabro server start`) starts an HTTP API that queues runs, streams events, and serves a web UI — so you can close your laptop and let workflows run. +Fabro has two interfaces to the same workflow engine. You can run a workflow directly in the CLI with `fabro run`, or start the HTTP server with `fabro server start` to queue runs, stream events, and serve the web UI. -Both modes use the same workflow engine, the same Graphviz files, and the same sandbox providers. The difference is how you interact with them. +Both interfaces use the same workflow engine, the same Graphviz files, and the same sandbox providers. The difference is how you interact with them. -## Standalone vs. server mode +## Direct CLI Runs vs. Server Interface -| | Standalone | Server | +| | Direct CLI runs | Server interface | |---|---|---| | **Command** | `fabro run workflow.fabro` | `fabro server start` | | **Best for** | Local development, one-off runs, CI/CD | Production, team use, running at scale | | **Execution** | Synchronous, one run per process | Asynchronous, queued with configurable concurrency | | **Human-in-the-loop** | Terminal prompts | Web UI or HTTP endpoints | | **Events** | Printed to stderr | Streamed via SSE | -| **Persistence** | Checkpoint files only | SQLite database + checkpoint files | +| **Persistence** | Checkpoint files only | Persistent run store + checkpoint files | | **Web UI** | Not available | Full React interface | | **Authentication** | None | JWT and/or mTLS | @@ -34,7 +34,7 @@ This starts the API on `127.0.0.1:3000` by default. To also run the web UI: ```bash fabro server start # API on port 3000 -cd apps/fabro-web && bun run dev # Web UI on port 5173 +cd apps/fabro-web && bun run dev # rebuilds web assets on change; refresh the browser ``` Common flags: @@ -47,16 +47,14 @@ Common flags: | `--sandbox` | — | Override default sandbox provider | | `--max-concurrent-runs` | `5` | Maximum concurrent run executions | -See [Server Configuration](/administration/server-configuration) for the full `server.toml` reference. +See [Server Configuration](/administration/server-configuration) for the full `settings.toml` reference. ## Submitting runs -In server mode, workflows are submitted via the REST API and executed in the background: +In the server interface, workflows are submitted via the REST API and executed in the background. The exact request body is documented in the API reference: ```bash -curl -X POST http://localhost:3000/api/v1/runs \ - -H "Content-Type: application/json" \ - -d '{"workflow": "implement-feature", "goal": "Add user authentication"}' +curl -X POST http://localhost:3000/api/v1/runs ``` The server returns immediately with a run ID. A background scheduler promotes queued runs to `Running` in FIFO order, up to the concurrency limit. @@ -93,11 +91,11 @@ The API streams run events via [Server-Sent Events (SSE)](/api-reference/runs/st ## Human-in-the-loop -In server mode, human-in-the-loop questions are served over HTTP instead of terminal prompts. The engine blocks the current stage until an answer is submitted, then continues execution. See the [list questions](/api-reference/human-in-the-loop/list-run-questions) and [submit answer](/api-reference/human-in-the-loop/submit-run-answer) API reference pages. +In the server interface, human-in-the-loop questions are served over HTTP instead of terminal prompts. The engine blocks the current stage until an answer is submitted, then continues execution. See the [list questions](/api-reference/human-in-the-loop/list-run-questions) and [submit answer](/api-reference/human-in-the-loop/submit-run-answer) API reference pages. ## Authentication -Server mode supports two authentication strategies, configurable in `server.toml`: +The server supports two authentication strategies, configurable in `settings.toml`: - **JWT** — EdDSA-signed bearer tokens. Used by the web UI. See [API Overview](/api-reference/overview#jwt-bearer-token) for token format. - **mTLS** — Mutual TLS with client certificates. Used for service-to-service communication. See [API Overview](/api-reference/overview#mtls-mutual-tls) for setup. @@ -110,28 +108,28 @@ Send the `X-Fabro-Demo: 1` header on any API request to get static mock data wit ## Pointing the CLI at a server -The CLI can delegate commands to a running Fabro server instead of executing locally. Set `mode = "server"` in `~/.fabro/user.toml`: - -```toml title="user.toml" -mode = "server" +The CLI can target a running Fabro server for commands that support a remote API. Configure `~/.fabro/settings.toml`: +```toml title="settings.toml" [server] -base_url = "https://fabro.example.com:3000/api/v1" +target = "https://fabro.example.com:3000/api/v1" ``` -Or use the `--server-url` flag: +Or use the `--server` flag: ```bash -fabro --server-url https://fabro.example.com:3000/api/v1 model list +fabro model list --server https://fabro.example.com:3000/api/v1 ``` -This applies to commands like `fabro model list`, `fabro llm chat`, and `fabro exec`. See [User Configuration](/reference/user-configuration#mode) for the full options including mTLS setup. +`fabro model list` and `fabro model test` honor `[server].target` by default unless you explicitly pass `--storage-dir`. `fabro exec` remains a local agent session and only uses the server when you pass `--server`. + +See [User Configuration](/reference/user-configuration#server-section) for the full connection options, including mTLS setup. ## Next steps - Full server.toml reference — authentication, TLS, run defaults, and more. + Full settings.toml reference — authentication, TLS, run defaults, and more. Step-by-step guide for deploying Fabro on Railway. @@ -140,6 +138,6 @@ This applies to commands like `fabro model list`, `fabro llm chat`, and `fabro e REST API for submitting runs, streaming events, and managing resources. - The workflow engine that powers both modes. + The workflow engine that powers both interfaces. diff --git a/docs/administration/sandboxing.mdx b/docs/administration/sandboxing.mdx index 9e5fabfb0..377c2f4ce 100644 --- a/docs/administration/sandboxing.mdx +++ b/docs/administration/sandboxing.mdx @@ -11,6 +11,6 @@ Fabro supports three sandbox providers: `local` (no isolation), `docker` (contai For cloud sandboxes (Daytona), you can control outbound network access with the `network` field in `[sandbox.daytona]`. Three modes are available: `"allow_all"` (default), `"block"`, and `{ allow_list = ["..."] }` for CIDR-based egress filtering. -Server defaults in `server.toml` apply when a run config doesn't specify `network`. Individual run configs can override the server default. +Server defaults in `settings.toml` apply when a run config doesn't specify `network`. Individual run configs can override the server default. See [Environments — Network access](/execution/environments#network-access) for syntax examples and the full reference. diff --git a/docs/administration/security.mdx b/docs/administration/security.mdx index 15f2b3e4c..24c574921 100644 --- a/docs/administration/security.mdx +++ b/docs/administration/security.mdx @@ -29,14 +29,14 @@ Fabro is single-tenant software designed for small, trusted teams. The following ### Authentication - **Enable authentication.** Fabro supports GitHub OAuth and Tailscale header-based auth for the web app. Do not use `insecure_disabled` outside of local development. -- **Configure a username allowlist.** Both GitHub and Tailscale auth support `allowed_usernames` in `server.toml`. An empty allowlist rejects all requests. +- **Configure a username allowlist.** Both GitHub and Tailscale auth support `allowed_usernames` in `settings.toml`. An empty allowlist rejects all requests. - **Use JWT to connect the web app to the API.** Configure `FABRO_JWT_PRIVATE_KEY` on the web app and `FABRO_JWT_PUBLIC_KEY` on the API server. JWT tokens are Ed25519-signed and short-lived (30 seconds). -- **Use mTLS for machine-to-machine API access.** Configure `[api.tls]` in `server.toml` with server cert, key, and CA. Set client auth to `Required` for programmatic clients (CI, scripts). +- **Use mTLS for machine-to-machine API access.** Configure `[api.tls]` in `settings.toml` with server cert, key, and CA. Set client auth to `Required` for programmatic clients (CI, scripts). ### Secrets - **Keep API keys out of sandboxes.** The local sandbox strips environment variables ending in `_API_KEY`, `_SECRET`, `_TOKEN`, `_PASSWORD`, or `_CREDENTIAL`, but Docker and Daytona sandboxes provide stronger isolation — only explicitly configured variables are passed through. -- **Use `.env` files for credentials.** Fabro loads credentials from `~/.fabro/.env` and the project-root `.env`. Do not commit these files to version control. +- **Use server-owned secrets or process env vars for credentials.** For server-backed workflows, persist credentials with `fabro provider login` / `fabro secret set`, which stores them under the server data directory. Do not commit secrets to version control. - **Rotate the session secret.** The `SESSION_SECRET` environment variable encrypts web app sessions. Rotate it periodically and use a strong random value. ### Execution diff --git a/docs/administration/server-configuration.mdx b/docs/administration/server-configuration.mdx index 44bcafe53..ac931fb63 100644 --- a/docs/administration/server-configuration.mdx +++ b/docs/administration/server-configuration.mdx @@ -1,116 +1,157 @@ --- title: "Server Configuration" -description: "Server config file, CLI overrides, and environment variables" +description: "Server-owned settings.toml sections, CLI overrides, and environment variables" --- ## Config file -The server config file at `~/.fabro/server.toml` controls how `fabro server start` behaves — API binding, authentication, run defaults, and more. The [Quick Start](/getting-started/quick-start) doesn't require one, but production deployments should configure it explicitly. +`fabro server start` reads `~/.fabro/settings.toml` by default. This is the same file schema used by the CLI. + +On a same-machine setup, the CLI and server share one `settings.toml`. On a remote deployment, the server machine has its own `settings.toml`, and the client machine keeps a separate local `settings.toml` for CLI-only values such as `[cli.target]`. + + +Legacy `server.toml`, `user.toml`, and `cli.toml` are ignored with a warning. Rename them to `settings.toml`. + + +### Which sections are server-owned + +| Scope | Examples | +|---|---| +| Server-owned (runtime-only from local `settings.toml`) | `[server.listen]`, `[server.api]`, `[server.web]`, `[server.auth]`, `[server.storage]`, `[server.artifacts]`, `[server.slatedb]`, `[server.scheduler]`, `[server.logging]`, `[server.integrations]`, `[features]` | +| Shared run defaults (layered through `.fabro/project.toml`/`workflow.toml`) | `[run.model]`, `[run.prepare]`, `[run.sandbox]`, `[run.checkpoint]`, `[run.inputs]`, `[run.pull_request]`, `[run.git]`, `[run.hooks]`, `[run.agent]` | + +The CLI-only `[cli.*]` sections (including `[cli.target]`) belong in the client machine's `settings.toml`. They tell CLI commands how to reach a server. The server process does not read `[cli.*]` for its own binding or routing. ### Full reference -```toml title="server.toml" -# Maximum concurrent workflow runs (default: 5) -max_concurrent_runs = 8 +```toml title="settings.toml" +_version = 1 -# Override the default data directory (default: ~/.fabro) -data_dir = "/var/lib/fabro" +[server.listen] +type = "tcp" +address = "0.0.0.0:3000" -[api] -base_url = "https://fabro.example.com/api/v1" - -[api.tls] +[server.listen.tls] cert = "/etc/fabro/tls/cert.pem" key = "/etc/fabro/tls/key.pem" ca = "/etc/fabro/tls/ca.pem" -# Authentication strategies (array of Jwt or Mtls) -[[api.authentication_strategies]] -type = "Jwt" +[server.api] +url = "https://fabro.example.com/api/v1" -[web] +[server.auth.api.jwt] +enabled = true + +[server.web] +enabled = true url = "https://fabro-web.example.com" -[web.auth] -provider = "Github" +[server.auth.web] allowed_usernames = ["alice", "bob"] -[git] -provider = "Github" +[server.auth.web.providers.github] +enabled = true +client_id = "Iv1.abc123" + +[server.integrations.github] app_id = "123456" client_id = "Iv1.abc123" -[log] +[server.integrations.github.webhooks] +strategy = "tailscale_funnel" + +[server.storage] +root = "/var/lib/fabro" + +[server.scheduler] +max_concurrent_runs = 8 + +[server.logging] level = "info" -[git.author] +# Run defaults — applied to every run unless overridden by workflow/project config +[run.model] +name = "claude-sonnet-4-5" +provider = "anthropic" +fallbacks = ["gemini", "openai"] + +[[run.prepare.steps]] +script = "npm install" + +[run.sandbox] +provider = "daytona" + +[run.sandbox.daytona] +auto_stop_interval = 60 + +[run.sandbox.daytona.labels] +team = "platform" + +[run.checkpoint] +exclude_globs = ["**/node_modules/**", "**/.cache/**"] + +[run.inputs] +default_branch = "main" + +[run.git.author] name = "fabro-bot" email = "fabro-bot@company.com" -[git.webhooks] -strategy = "tailscale_funnel" - -# Run defaults — applied to every run unless overridden by the run config -[llm] -model = "claude-sonnet-4-5" -provider = "anthropic" - -[llm.fallbacks] -anthropic = ["gemini", "openai"] - -[setup] -commands = ["npm install"] -timeout_ms = 120000 - -[sandbox] -provider = "daytona" - -[sandbox.daytona] -auto_stop_interval = 60 - -[sandbox.daytona.labels] -team = "platform" - [features] -retros = true - -[checkpoint] -exclude_globs = ["**/node_modules/**", "**/.cache/**"] - -[vars] -default_branch = "main" +session_sandboxes = true ``` ### CLI overrides -Several `server.toml` settings can be overridden via `fabro server start` flags: +Several `settings.toml` settings can be overridden via `fabro server start` flags: | Flag | Default | Description | |---|---|---| -| `--port` | `3000` | Port to listen on | -| `--host` | `127.0.0.1` | Host address to bind to | +| `--bind` | `~/.fabro/fabro.sock` | Address to bind: `IP` or `IP:port` for TCP, or a path for Unix socket | +| `--web` | enabled | Enable the embedded web UI, browser auth routes, and web-only helper endpoints | +| `--no-web` | disabled | Disable the embedded web UI, browser auth routes, and web-only helper endpoints | +| `--foreground` | — | Run in the foreground instead of daemonizing | | `--model` | — | Override default LLM model | | `--provider` | — | Override default LLM provider | | `--sandbox` | — | Override default sandbox provider | | `--max-concurrent-runs` | `5` | Maximum concurrent run executions | -| `--config` | `~/.fabro/server.toml` | Path to server config file | +| `--config` | `~/.fabro/settings.toml` | Path to server config file | | `--dry-run` | — | Execute with simulated LLM backend | -CLI flags take precedence over `server.toml` values. See [Run Configuration — Precedence](/execution/run-configuration#precedence) for the full resolution order. +CLI flags take precedence over `settings.toml` values. See [Run Configuration — Precedence](/execution/run-configuration#precedence) for the full resolution order. + +### `[server.web]` section + +Control the embedded SPA and browser-oriented routes. + +| Key | Description | Default | +|---|---|---| +| `enabled` | Serve the embedded SPA, `/auth/*`, and the web-only helper endpoints under `/api/v1` | `true` | +| `url` | External web UI URL used for OAuth redirects | none (no implicit derivation from `server.listen`) | + +When `enabled = false`, the server still exposes the machine API and `/health`, but `/`, `/auth/*`, SPA client routes, `/api/v1/auth/me`, `/api/v1/setup/*`, and `/api/v1/demo/toggle` all return `404`. ### Run defaults -The `[llm]`, `[setup]`, `[sandbox]`, `[checkpoint]`, and `[vars]` sections in `server.toml` act as defaults for every run. A run config TOML can override any of these. For `[vars]`, Daytona labels, and checkpoint exclude globs, values are **merged** — the run config wins on key collisions. All other fields use "first non-empty wins" precedence. +The `[run.*]` sections in `settings.toml` act as defaults for every run. -### `[log]` section +On a same-machine setup, `settings.toml` is the shared machine-default layer under `workflow.toml` and `.fabro/project.toml`. -Configure the default log level without environment variables. Precedence: `FABRO_LOG` env var > `--debug` flag > `[log]` level > `"info"`. +On a remote setup, the client bundles workflow, project, and user config into the run manifest. The server then layers those bundled client configs over its own local defaults for run-shaped fields. Server-owned values like `[server.storage]`, `[server.api]`, `[server.web]`, `[features]`, and `[server.scheduler]` always come from the server machine's own `settings.toml` or `fabro server start` flags. + +Merge rules follow the normative matrix: `[run.inputs]` replaces wholesale, `[run.sandbox.env]` and `[run.sandbox.daytona.labels]` merge by key, `[run.prepare.steps]` replaces whole-list, and `[[run.hooks]]` merge by optional `id`. Most other fields use "higher-precedence wins" field-wise merging. + +### `[server.logging]` section + +Configure the default server log level. Precedence: `FABRO_LOG` env var > `--debug` flag > `[server.logging].level` > `"info"`. | Key | Description | Default | |---|---|---| | `level` | Log level: `error`, `warn`, `info`, `debug`, `trace` | `"info"` | -### `[git.author]` section +The CLI has its own `[cli.logging]` section. + +### `[run.git.author]` section Customize the git author identity used for checkpoint commits. When not set, defaults to `fabro` / `fabro@local`. @@ -119,27 +160,39 @@ Customize the git author identity used for checkpoint commits. When not set, def | `name` | Git author name | `"fabro"` | | `email` | Git author email | `"fabro@local"` | -The CLI can also set `[git.author]` in `user.toml` to override the server default. +### `[server.integrations.github]` section -### `[git.webhooks]` section +Configure GitHub integration auth. `strategy = "gh_cli"` is the default and uses a stored `GITHUB_CLI_TOKEN` from the vault. `strategy = "app"` enables the GitHub App flow, browser OAuth, and webhooks. -Enable automatic GitHub webhook delivery via Tailscale funnel. When configured, `fabro server start` binds a local HTTP listener, exposes it through `tailscale funnel`, and updates the GitHub App's webhook URL on startup. Incoming webhooks are verified with HMAC-SHA256. +```toml title="settings.toml" +[server.integrations.github] +strategy = "gh_cli" +``` -| Key | Description | Values | -|---|---|---| -| `strategy` | Webhook delivery method | `"tailscale_funnel"` | +For GitHub App mode, set `strategy = "app"` and include `app_id`, `client_id`, and `slug`. Webhook delivery is configured under `[server.integrations.github.webhooks]`: -Requires a configured GitHub App (`[git]` section with `app_id` and `client_id`) and the `GITHUB_APP_WEBHOOK_SECRET` environment variable. +```toml title="settings.toml" +[server.integrations.github] +strategy = "app" +app_id = "123456" +client_id = "Iv1.abc123" +slug = "fabro-app" -### `[checkpoint]` section +[server.integrations.github.webhooks] +strategy = "tailscale_funnel" +``` + +When `webhooks.strategy = "tailscale_funnel"` is configured, `fabro server start` binds a local HTTP listener, exposes it through `tailscale funnel`, and updates the GitHub App's webhook URL on startup. Incoming webhooks are verified with HMAC-SHA256. Requires the `GITHUB_APP_WEBHOOK_SECRET` environment variable. + +### `[run.checkpoint]` section Configure checkpoint behavior for all runs. | Key | Description | |---|---| -| `exclude_globs` | Glob patterns for files to exclude from checkpoint commits (e.g. `["**/node_modules/**"]`) | +| `exclude_globs` | Glob patterns for files to exclude from checkpoint commits (for example, `["**/node_modules/**"]`) | -Exclude globs from `server.toml` and run configs are merged (union, deduplicated). See [Run Configuration — Checkpoint](/execution/run-configuration#checkpoint) for per-run configuration. +`exclude_globs` replaces across layers — the highest-precedence layer wins wholesale. See [Run Configuration — Checkpoint](/execution/run-configuration#runcheckpoint) for per-run configuration. ### `[features]` section @@ -147,17 +200,23 @@ Toggle experimental or opt-in features. All features default to `false`. | Key | Description | |---|---| -| `retros` | Enable automatic [retro](/execution/retros) generation after workflow runs (experimental) | | `session_sandboxes` | Enable session sandboxes in the web UI | -The same `[features]` section can be set in `fabro.toml` (project-level) to enable features per-project. +The same `[features]` section can be set in `.fabro/project.toml` (project-level) to enable features per-project. -## Environment variables +## Secrets and environment variables -Fabro reads environment variables from a `.env` file in the working directory (if present) and from the shell environment. Provider API keys are required for the models you want to use; everything else is optional. +Fabro splits secrets into two scopes: + +- Server runtime secrets live in `/server.env` and resolve with precedence `process env -> server.env`. +- Workflow-visible secrets live in `/secrets.json` (the vault). Anything stored in the vault may be used by workflows. + +Fabro no longer auto-loads `.env` files. Provider API keys are required for the models you want to use; everything else is optional. ### LLM provider keys +Fabro's built-in provider access resolves these from `process env -> vault`. + | Variable | Provider | |---|---| | `ANTHROPIC_API_KEY` | Anthropic (Claude) | @@ -177,13 +236,23 @@ Fabro reads environment variables from a `.env` file in the working directory (i ### Server authentication +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) | -### GitHub App (optional) +### GitHub integration (optional) + +| Variable | Description | +|---|---| +| `GITHUB_CLI_TOKEN` | Token captured from `gh auth token` and stored by `fabro install` when `strategy = "gh_cli"` | + +### GitHub App extras (optional) + +Fabro resolves these from `process env -> server.env`. | Variable | Description | |---|---| diff --git a/docs/administration/troubleshooting.mdx b/docs/administration/troubleshooting.mdx index e1327a515..ede660bd9 100644 --- a/docs/administration/troubleshooting.mdx +++ b/docs/administration/troubleshooting.mdx @@ -8,21 +8,21 @@ description: "Diagnosing and resolving common issues with Fabro" The `fabro doctor` command validates your installation: ```bash -fabro doctor # Check local configuration -fabro doctor --live # Also probe live services (LLM APIs, sandbox, Brave Search) -fabro doctor --verbose # Show detailed output for each check +fabro doctor # Local config checks + live server diagnostics +fabro doctor --verbose # Show detailed output for each check +fabro doctor --server https://fabro.example.com:3000/api/v1 ``` It checks: -- System dependencies (`openssl`, `node`, `gh`, `dot`) -- LLM provider API keys -- Sandbox availability (Docker daemon, Daytona API key) -- JWT key configuration -- Brave Search API key +- Local user config and legacy `~/.fabro/.env` warnings +- Server-reported LLM provider connectivity +- GitHub App, sandbox, and Brave Search credentials +- Server authentication and crypto configuration +- Server-side Graphviz availability ## Common issues -**"No API key configured"** — Set at least one provider key in `.env` or your shell environment. Run `fabro doctor --live` to verify connectivity. +**"No API key configured"** — Set at least one provider key with `fabro provider login` or `fabro secret set`, or export it in the server process environment. Run `fabro doctor` to verify connectivity. **Stall watchdog timeouts** — If runs are cancelled unexpectedly, the agent may be stuck or the LLM provider may be slow. Check `FABRO_LOG=debug` output for `Agent.LlmRetry` events. Increase `stall_timeout` in the graph if needed, or add [fallback providers](/core-concepts/models) to handle outages. diff --git a/docs/agents/hooks.mdx b/docs/agents/hooks.mdx index b415da6a3..ef634b4f8 100644 --- a/docs/agents/hooks.mdx +++ b/docs/agents/hooks.mdx @@ -105,9 +105,9 @@ Each hook fires on a specific lifecycle event: Hooks are defined as `[[hooks]]` entries in any of these TOML config files: -- **`fabro.toml`** — project-level hooks, apply to all workflows in the project +- **`.fabro/project.toml`** — project-level hooks, apply to all workflows in the project - **`workflow.toml`** — per-workflow hooks -- **`~/.fabro/user.toml`** or **`~/.fabro/server.toml`** — global defaults for all runs +- **`~/.fabro/settings.toml`** — global defaults for all runs See [Merging hook configs](#merging-hook-configs) for how these layers combine. @@ -344,11 +344,11 @@ Command hooks do **not** fail open. A non-zero exit code (other than 0 or 2) pro Hooks from multiple config files are merged in this order (later layers win on name collisions): -1. **`~/.fabro/user.toml`** or **`~/.fabro/server.toml`** — global defaults -2. **`fabro.toml`** — project-level overrides +1. **`~/.fabro/settings.toml`** — global defaults +2. **`.fabro/project.toml`** — project-level overrides 3. **`workflow.toml`** — per-workflow overrides -This lets you define global hooks at the server level, project-wide hooks in `fabro.toml`, and override or extend them per workflow. +This lets you define global hooks at the server level, project-wide hooks in `.fabro/project.toml`, and override or extend them per workflow. ## Full example diff --git a/docs/agents/mcp.mdx b/docs/agents/mcp.mdx index 188438c47..f6d74d057 100644 --- a/docs/agents/mcp.mdx +++ b/docs/agents/mcp.mdx @@ -30,7 +30,7 @@ For example, a server named `filesystem` exposing a `read_file` tool becomes `mc MCP servers can be configured in two places: -- **`~/.fabro/user.toml`** — applies to `fabro exec` sessions. See [User Configuration](/reference/user-configuration#mcp_servers-section). +- **`~/.fabro/settings.toml`** — applies to `fabro exec` sessions. See [User Configuration](/reference/user-configuration#mcp_servers-section). - **Run config TOML** — applies to workflow runs (`fabro run`). See [Run Configuration](/execution/run-configuration#mcp_servers). Each server entry specifies a transport type and optional timeouts. The server name is the TOML table key and is used in qualified tool names. @@ -152,25 +152,29 @@ If the server marks the result as an error (`is_error: true`), the tool result i A workflow that uses Playwright MCP to automate a browser inside a Daytona sandbox: ```toml title="run.toml" -version = 1 -goal = "Test the login page" +_version = 1 + +[workflow] graph = "workflow.fabro" -[sandbox] +[run] +goal = "Test the login page" + +[run.sandbox] provider = "daytona" -[sandbox.daytona.snapshot] +[run.sandbox.daytona.snapshot] name = "daytona-medium" -[assets] +[run.artifacts] include = ["screenshots/**"] -[mcp_servers.playwright] +[run.agent.mcps.playwright] type = "sandbox" command = ["npx", "@playwright/mcp@latest", "--port", "3100", "--headless", "--browser", "chromium"] port = 3100 -startup_timeout_secs = 60 -tool_timeout_secs = 120 +startup_timeout = "60s" +tool_timeout = "2m" ``` After startup, the agent sees 22 Playwright tools including: @@ -181,7 +185,7 @@ After startup, the agent sees 22 Playwright tools including: - `mcp__playwright__browser_type` - `mcp__playwright__browser_fill_form` -The agent uses `browser_snapshot` (accessibility tree) for structured page understanding and `browser_take_screenshot` to save visual captures. Screenshots saved to `screenshots/` are automatically collected as [assets](/execution/run-configuration#assets). +The agent uses `browser_snapshot` (accessibility tree) for structured page understanding and `browser_take_screenshot` to save visual captures. Screenshots saved to `screenshots/` are automatically collected as [artifacts](/execution/run-configuration#assets). When using Playwright MCP with the sandbox transport, call the `browser_install` tool first to ensure the Playwright browser binaries are available inside the sandbox. diff --git a/docs/agents/outputs.mdx b/docs/agents/outputs.mdx index 2cbb143f1..0b240e14f 100644 --- a/docs/agents/outputs.mdx +++ b/docs/agents/outputs.mdx @@ -3,7 +3,7 @@ title: "Outputs & Artifacts" description: "How Fabro captures agent responses, tracks file changes, and collects test assets" --- -When an agent or prompt node finishes, Fabro captures its response text and produces an **outcome** that feeds into context, transition logic, and downstream nodes. Fabro also tracks every file change per stage, offloads large outputs to disk, and automatically collects test artifacts like screenshots and reports. +When an agent or prompt node finishes, Fabro captures its response text and produces an **outcome** that feeds into context, transition logic, and downstream nodes. Fabro also tracks every file change per stage, offloads large outputs into content-addressed blob storage, and automatically collects test artifacts like screenshots and reports. ## Response capture @@ -119,7 +119,7 @@ The tracked paths are stored as `files_touched` on the stage outcome: | Location | How it's used | |---|---| -| `StageCompleted` event | Emitted with `files_touched` in the event stream and `progress.jsonl` | +| `StageCompleted` event | Emitted with `files_touched` in the event stream and surfaced by `fabro logs` / exported event streams | | Preambles | Listed under each completed stage so downstream agents know what changed | | Retros | Included per-stage and aggregated across the full run | | `status.json` | Written to the stage's logs directory after each node completes | @@ -132,88 +132,52 @@ For the **CLI backend**, Fabro takes a different approach: it runs `git diff --n ## Artifact offloading -When a stage produces a large context value -- an LLM response, command output, or any context update -- Fabro automatically offloads it to disk instead of keeping it in memory. This prevents large outputs from bloating checkpoint files and overwhelming preamble summaries. +When a stage produces a large context value -- an LLM response, command output, or any context update -- Fabro automatically offloads it into a global content-addressed blob store instead of leaving the full value inline in durable context. ### How offloading works -After each node completes, Fabro checks every context update. If the serialized JSON of a value exceeds **100KB**, it is written to the artifact store on disk and replaced in the context with a `file://` pointer: +After each node completes, Fabro checks every context update. If the serialized JSON of a value exceeds **100KB**, it is stored once by SHA-256 hash and replaced with a durable blob ref: ``` -response.plan --> file:///path/to/logs/cache/artifacts/values/response.plan.json -command.output --> file:///path/to/logs/cache/artifacts/values/command.output.json +response.plan --> blob://sha256/2cf24dba5fb0... +command.output --> blob://sha256/a4f3c1d9c2e1... ``` -Values under 100KB remain in the context as-is. +Values under 100KB remain inline. -### Artifact storage layout - -Offloaded artifacts are written to the run's directory: - -``` -~/.fabro/runs/{run_id}/ - cache/ - artifacts/ - values/ - response.plan.json - response.implement.json - command.output.json -``` - -Each file contains the full serialized JSON value. The `ArtifactStore` manages reads and writes, and cleans up files when artifacts are removed. +Checkpoints, checkpoint-completed events, forks, and resumes persist these `blob://` refs, not host-specific file paths. ### Preamble rendering -When Fabro builds a [preamble](/execution/context#preamble-construction) for a downstream stage, it resolves `file://` pointers and renders a reference instead of inlining the full content: +When Fabro builds a [preamble](/execution/context#preamble-construction) for a downstream stage, it first materializes any blob refs into execution-local files and then renders a reference instead of inlining the full content: ```markdown ## Completed stages - **plan**: success - Model: claude-sonnet-4-5, 12.4k tokens in / 3.2k out - Files: src/main.rs, tests/api_test.rs - - Response: See: /path/to/logs/cache/artifacts/values/response.plan.json + - Response: See: /path/to/runtime/blobs/.json - **test**: success - Script: `cargo test 2>&1 || true` - - Stdout: See: /path/to/logs/cache/artifacts/values/command.output.json + - Stdout: See: /path/to/runtime/blobs/.json ``` This keeps preambles concise while still giving agents a path to read the full output if needed. ## Git storage -Artifact data is persisted on the Git [metadata branch](/execution/checkpoints#metadata-branch) alongside checkpoint data. Each time a checkpoint is written, any file-backed artifacts are included as additional entries: +Large offloaded context values are not stored on the Git [metadata branch](/execution/checkpoints#metadata-branch). The metadata branch keeps checkpoint JSON and stage metadata; blob payloads live in the durable blob store and are referenced by `blob://sha256/...`. -``` -fabro/meta/{run_id} - run.json - start.json - checkpoint.json - artifacts/ - response.plan.json - command.output.json -``` - -This means artifact data survives process restarts and can be recovered when resuming a run from a Git branch. +Captured stage artifacts such as screenshots, videos, reports, and traces still use the artifact store and metadata export paths described below. ## Remote sandbox syncing -For remote sandboxes (Docker, Daytona), artifact files stored on the host are not directly accessible inside the sandbox. Before a stage executes, Fabro syncs any `file://` pointers to the sandbox filesystem. +For remote sandboxes (Docker, Daytona), execution-time file access happens inside the sandbox filesystem. -For each pointer in the context updates: +- Blob refs are materialized into `{working_directory}/.fabro/blobs/{blob_id}.json` +- Explicit non-blob `file://` refs keep the existing copy-on-demand behavior and are copied into `{working_directory}/.fabro/artifacts/{filename}` when needed -1. Fabro checks whether the file is already accessible inside the sandbox -2. If not, it reads the local file and uploads it via the sandbox's `write_file` interface -3. The file is placed at `{working_directory}/.fabro/artifacts/{filename}` -4. The pointer is rewritten to reference the remote path - -``` -# Before sync (host path) -file:///home/user/.fabro/runs/01JK.../cache/artifacts/values/response.plan.json - -# After sync (sandbox path) -file:///workspace/.fabro/artifacts/response.plan.json -``` - -This ensures agents running in remote sandboxes can read offloaded artifacts using the same `file://` pointer mechanism. +In both cases, downstream handlers and agents continue to consume ordinary `file://` pointers during execution. For local sandboxes, syncing is a no-op since the agent can already access the host filesystem directly. @@ -225,9 +189,9 @@ After each node executes a command, Fabro automatically scans the sandbox for te ### How asset capture works -1. **Before** the command runs, Fabro takes a baseline snapshot of known asset paths in the sandbox +1. **Before** the command runs, Fabro takes a baseline snapshot of known artifact paths in the sandbox 2. **After** the command completes, Fabro re-scans and diffs against the baseline -3. Files that are new or modified since the command started are downloaded to the stage's asset directory +3. Files that are new or modified since the command started are downloaded to the stage's artifact directory Only files modified after the command started are collected. Files that match the baseline fingerprint (same size and mtime) are skipped. Individual files over 10 MB and total collections over 50 MB are also skipped. @@ -256,37 +220,15 @@ Tool caches and dependency directories (`node_modules`, `.cache/ms-playwright`, Collected assets are written to the run's directory, organized by node and retry attempt: ``` -~/.fabro/runs/{run_id}/ +~/.fabro/scratch/{run_id}/ cache/ artifacts/ - assets/ + files/ {node_slug}/ retry_1/ test-results/ screenshot.png video.webm - manifest.json -``` - -Each collection writes a `manifest.json` summarizing what was captured: - -```json -{ - "files_copied": 3, - "total_bytes": 245760, - "files_skipped": 0, - "download_errors": 0, - "hash_errors": 0, - "captured_assets": [ - { - "path": "test-results/screenshot.png", - "mime": "image/png", - "content_md5": "a1b2c3...", - "content_sha256": "d4e5f6...", - "bytes": 81920 - } - ] -} ``` ## Observability diff --git a/docs/agents/prompts.mdx b/docs/agents/prompts.mdx index 3901c9162..4a7df5d93 100644 --- a/docs/agents/prompts.mdx +++ b/docs/agents/prompts.mdx @@ -47,23 +47,24 @@ File references are resolved relative to the Graphviz file's directory first, th ### Variable expansion -Prompts support `$variable` placeholders that expand at runtime. Currently the only built-in variable is `$goal`, which resolves to the graph-level `goal` attribute: +Prompts support MiniJinja-style templates. Prompt rendering has access to the workflow goal and typed run inputs: ```dot title="pipeline.fabro" digraph Pipeline { graph [goal="Add a /health endpoint to the API server"] - implement [prompt="Implement the following: $goal"] + implement [prompt="Implement the following: {{ goal }}"] } ``` -At runtime, `$goal` becomes `Add a /health endpoint to the API server`. +At runtime, `{{ goal }}` becomes `Add a /health endpoint to the API server`. -| Variable | Resolves to | +| Expression | Resolves to | |---|---| -| `$goal` | The graph-level `goal` attribute | +| `{{ goal }}` | The graph-level `goal` attribute | +| `{{ inputs.name }}` | A value from `[run.inputs]` | -A `$` not followed by an identifier character (e.g. `$5`) is left as-is. An undefined variable like `$foo` produces a runtime error, catching typos early. +Prompt templates use strict undefined handling, so an expression like `{{ inputs.foo }}` fails fast if `foo` is not defined. Environment variables are not available in prompt templates. ### Fallback to label diff --git a/docs/api-reference/client-sdks.mdx b/docs/api-reference/client-sdks.mdx index 3e852fbf9..e0170abf4 100644 --- a/docs/api-reference/client-sdks.mdx +++ b/docs/api-reference/client-sdks.mdx @@ -34,31 +34,34 @@ console.log(data); The generated client includes a typed API class for each endpoint group: `RunsApi`, `WorkflowsApi`, `SessionsApi`, `VerificationsApi`, `InsightsApi`, and others. -## Rust (Types Only) +## Rust (Types + Client) -The `fabro-api-types` crate generates Rust structs and enums from the OpenAPI component schemas at compile time using [typify](https://github.com/oxidecomputer/typify). This provides type-safe representations of all API models but does not include an HTTP client. +The `fabro-api` crate generates Rust structs, enums, and a `reqwest`-based HTTP client from the full OpenAPI spec at compile time using [progenitor](https://github.com/oxidecomputer/progenitor). This provides type-safe representations of all API models and a builder-style client for every endpoint. ### How It Works -A `build.rs` script reads `docs/api-reference/fabro-api.yaml`, extracts `components/schemas`, and feeds them to typify. The generated code is written to `OUT_DIR` and included via: +A `build.rs` script reads `docs/api-reference/fabro-api.yaml`, patches it from OpenAPI 3.1 to 3.0 for progenitor compatibility, and generates both types and a client. The generated code is written to `OUT_DIR` and included via: ```rust -// lib/crates/fabro-api-types/src/lib.rs -include!(concat!(env!("OUT_DIR"), "/openapi_types.rs")); +// lib/crates/fabro-api/src/lib.rs +include!(concat!(env!("OUT_DIR"), "/codegen.rs")); ``` ### Regenerating -The types are regenerated automatically on every `cargo build` when the OpenAPI spec changes: +The types and client are regenerated automatically on every `cargo build` when the OpenAPI spec changes: ```bash -cargo build -p fabro-api-types +cargo build -p fabro-api ``` ### Usage ```rust -use fabro_api_types::RunListItem; +use fabro_api::types::RunListItem; +use fabro_api::Client; + +let client = Client::new("http://localhost:3000"); ``` -All generated types derive `serde::Deserialize` and `serde::Serialize`, so they work directly with any Rust HTTP client for request and response parsing. +All generated types derive `serde::Deserialize` and `serde::Serialize`. The client uses builder-style methods for each endpoint. diff --git a/docs/api-reference/fabro-api.yaml b/docs/api-reference/fabro-api.yaml index 33eb06edd..2cb28f1ea 100644 --- a/docs/api-reference/fabro-api.yaml +++ b/docs/api-reference/fabro-api.yaml @@ -12,27 +12,23 @@ tags: - name: Human-in-the-Loop description: Questions, answers, and steering for runs - name: Run Outputs - description: Files and verifications produced by runs + description: Files produced by runs - name: Run Internals description: Internal run details (stages, turns, context, configuration) - name: Workflows description: Workflow definitions and execution - - name: Verification - description: Verification criteria and controls - - name: Usage - description: Token and cost usage + - name: Billing + description: Token counts and billed totals - name: Insights description: SQL query editor and history - - name: Sessions - description: Interactive chat sessions - - name: Retros - description: Run retrospectives - name: Models description: Available LLM models - name: Completions description: Single-turn LLM completions - name: Settings description: Platform configuration + - name: System + description: Server runtime, maintenance, and event streaming security: - BearerAuth: [] @@ -71,6 +67,20 @@ paths: schema: $ref: "#/components/schemas/HealthResponse" + /api/v1/health/diagnostics: + post: + operationId: runDiagnostics + tags: [Discovery] + summary: Run server health diagnostics + description: Probes external services and server configuration. May be slow. + responses: + "200": + description: Diagnostics report + content: + application/json: + schema: + $ref: "#/components/schemas/DiagnosticsReport" + /api/v1/openapi.json: get: operationId: getOpenApiSpec @@ -113,28 +123,27 @@ paths: operationId: listRuns tags: [Runs] summary: List Runs - description: Returns a paginated list of runs for the board view, ordered by recency. - parameters: - - $ref: "#/components/parameters/PageLimit" - - $ref: "#/components/parameters/PageOffset" + description: Returns durable run summaries from the backing store, including runs persisted before the current server boot. responses: "200": - description: Paginated list of runs for the board view + description: Durable run summaries content: application/json: schema: - $ref: "#/components/schemas/PaginatedRunList" + type: array + items: + $ref: "#/components/schemas/StoreRunSummary" post: - operationId: startRun + operationId: createRun tags: [Runs] - summary: Start Run - description: Queues a new workflow run from a Graphviz graph source. The run is created in `queued` status and will be picked up by the scheduler. + summary: Create Run + description: Creates a new workflow run in `submitted` status from a self-contained manifest. requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/StartRunRequest" + $ref: "#/components/schemas/RunManifest" responses: "201": description: Run created @@ -149,21 +158,100 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" + /api/v1/preflight: + post: + operationId: runPreflight + tags: [Runs] + summary: Validate Workflow Manifest + description: Validates a workflow manifest without creating a run. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RunManifest" + responses: + "200": + description: Preflight report + content: + application/json: + schema: + $ref: "#/components/schemas/PreflightResponse" + "400": + description: Invalid manifest or workflow + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + /api/v1/graph/render: + post: + operationId: renderWorkflowGraph + tags: [Runs] + summary: Render Workflow Graph + description: Validates and renders a workflow manifest as SVG or PNG without creating a run. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RenderWorkflowGraphRequest" + responses: + "200": + description: Rendered graph image + content: + image/svg+xml: + schema: + type: string + format: binary + image/png: + schema: + type: string + format: binary + "400": + description: Invalid manifest or workflow + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "502": + description: Graphviz rendering failed + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + /api/v1/runs/{id}: get: operationId: retrieveRun tags: [Runs] summary: Retrieve Run - description: Returns the current status of a run, including error details and queue position if applicable. + description: Returns the durable run summary for a run. parameters: - $ref: "#/components/parameters/RunId" responses: "200": - description: Run status + description: Durable run summary content: application/json: schema: - $ref: "#/components/schemas/RunStatusResponse" + $ref: "#/components/schemas/StoreRunSummary" + "404": + description: Run not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + delete: + operationId: deleteRun + tags: [Runs] + summary: Delete Run + description: Deletes durable store state for a run. This does not remove any local run directory. + parameters: + - $ref: "#/components/parameters/RunId" + responses: + "204": + description: Run deleted or already absent "404": description: Run not found content: @@ -199,6 +287,40 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" + /api/v1/runs/{id}/start: + post: + operationId: startRun + tags: [Runs] + summary: Start Run + description: Starts a submitted run, queuing it for execution. Provide `resume=true` to resume an interrupted run from checkpoint. Returns 409 if the run is not startable. + parameters: + - $ref: "#/components/parameters/RunId" + requestBody: + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/StartRunRequest" + responses: + "200": + description: Run started + content: + application/json: + schema: + $ref: "#/components/schemas/RunStatusResponse" + "404": + description: Run not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "409": + description: Run is not in submitted status + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + /api/v1/runs/{id}/pause: post: operationId: pauseRun @@ -307,22 +429,38 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" - /api/v1/runs/{id}/context: + /api/v1/boards/runs: get: - operationId: retrieveRunContext + operationId: listBoardRuns + tags: [Runs] + summary: List Board Runs + description: Temporary board-view list of managed runs. This endpoint is UI-oriented and may change as the app evolves. + parameters: + - $ref: "#/components/parameters/PageLimit" + - $ref: "#/components/parameters/PageOffset" + responses: + "200": + description: Paginated list of runs for the board view + content: + application/json: + schema: + $ref: "#/components/schemas/PaginatedRunList" + + /api/v1/runs/{id}/state: + get: + operationId: getRunState tags: [Run Internals] - summary: Retrieve Run Context - description: Returns the key-value context map accumulated during the run. Empty if the run has not started. + summary: Get Run State + description: Returns the internal event-sourced run projection. This is not a stable public contract. parameters: - $ref: "#/components/parameters/RunId" responses: "200": - description: Context key-value map + description: Current run projection content: application/json: schema: - type: object - additionalProperties: true + $ref: "#/components/schemas/RunProjection" "404": description: Run not found content: @@ -332,12 +470,69 @@ paths: /api/v1/runs/{id}/events: get: - operationId: streamRunEvents - tags: [Runs] - summary: Stream Run Events - description: Opens a server-sent event (SSE) stream for real-time run updates. Returns 410 if the stream has been closed. + operationId: listRunEvents + tags: [Run Internals] + summary: List Run Events + description: Returns a paginated JSON list of stored run events. parameters: - $ref: "#/components/parameters/RunId" + - $ref: "#/components/parameters/SinceSeq" + - $ref: "#/components/parameters/EventLimit" + responses: + "200": + description: Paginated list of run events + content: + application/json: + schema: + $ref: "#/components/schemas/PaginatedEventList" + "404": + description: Run not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + post: + operationId: appendRunEvent + tags: [Run Internals] + summary: Append Run Event + description: Appends a validated event to the run event log. Intended for trusted internal callers. + parameters: + - $ref: "#/components/parameters/RunId" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RunEvent" + responses: + "200": + description: Event appended + content: + application/json: + schema: + $ref: "#/components/schemas/AppendEventResponse" + "400": + description: Invalid event payload + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "404": + description: Run not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + /api/v1/runs/{id}/attach: + get: + operationId: attachRunEvents + tags: [Run Internals] + summary: Attach Run Events + description: Opens an ordered server-sent event stream starting at `since_seq`, replaying persisted events and continuing with live updates while the run remains active. + parameters: + - $ref: "#/components/parameters/RunId" + - $ref: "#/components/parameters/SinceSeq" responses: "200": description: Server-sent event stream @@ -351,8 +546,73 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorResponse" - "410": - description: Event stream closed + + /api/v1/runs/{id}/blobs: + post: + operationId: writeRunBlob + tags: [Run Internals] + summary: Write Run Blob + description: Writes an opaque binary blob and returns its content-addressed blob identifier. + parameters: + - $ref: "#/components/parameters/RunId" + requestBody: + required: true + content: + application/octet-stream: + schema: + type: string + format: binary + multipart/form-data: + schema: + type: object + required: + - manifest + properties: + manifest: + $ref: "#/components/schemas/ArtifactBatchUploadManifest" + additionalProperties: + type: string + format: binary + description: | + Strict multipart upload format. The `manifest` part must arrive first with JSON + matching `ArtifactBatchUploadManifest`. Each subsequent file part name must match + a manifest entry `part` value. + encoding: + manifest: + contentType: application/json + responses: + "200": + description: Blob written + content: + application/json: + schema: + $ref: "#/components/schemas/WriteBlobResponse" + "404": + description: Run not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + /api/v1/runs/{id}/blobs/{blobId}: + get: + operationId: readRunBlob + tags: [Run Internals] + summary: Read Run Blob + description: Reads a previously stored blob by identifier. + parameters: + - $ref: "#/components/parameters/RunId" + - $ref: "#/components/parameters/BlobId" + responses: + "200": + description: Blob contents + content: + application/octet-stream: + schema: + type: string + format: binary + "404": + description: Run or blob not found content: application/json: schema: @@ -419,30 +679,6 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" - /api/v1/runs/{id}/retro: - get: - operationId: retrieveRetro - tags: [Retros] - summary: Retrieve Retro - description: Returns the retrospective analysis for a completed run, or null if the retro has not been generated yet. - parameters: - - $ref: "#/components/parameters/RunId" - responses: - "200": - description: Retro data (null if not yet available) - content: - application/json: - schema: - oneOf: - - $ref: "#/components/schemas/RetroDetail" - - type: "null" - "404": - description: Run not found - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - /api/v1/runs/{id}/stages: get: operationId: listRunStages @@ -492,24 +728,21 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" - /api/v1/runs/{id}/files: + /api/v1/runs/{id}/artifacts: get: - operationId: retrieveRunFiles - tags: [Run Outputs] - summary: Retrieve Run Files - description: Returns a paginated list of file-level diffs produced by the run, optionally filtered to a specific checkpoint. + operationId: listRunArtifacts + tags: [Run Internals] + summary: List Run Artifacts + description: Lists captured artifact files for a run. parameters: - $ref: "#/components/parameters/RunId" - - $ref: "#/components/parameters/CheckpointFilter" - - $ref: "#/components/parameters/PageLimit" - - $ref: "#/components/parameters/PageOffset" responses: "200": - description: Paginated list of file diffs + description: Artifact files captured for the run content: application/json: schema: - $ref: "#/components/schemas/PaginatedRunFileList" + $ref: "#/components/schemas/RunArtifactListResponse" "404": description: Run not found content: @@ -517,21 +750,66 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" - /api/v1/runs/{id}/usage: + /api/v1/runs/{id}/stages/{stageId}/artifacts: get: - operationId: retrieveRunUsage - tags: [Run Outputs] - summary: Retrieve Run Usage - description: Returns token and cost usage broken down by stage and model for a specific run. + operationId: listStageArtifacts + tags: [Run Internals] + summary: List Stage Artifacts + description: Lists artifact filenames stored for a stage. parameters: - $ref: "#/components/parameters/RunId" + - $ref: "#/components/parameters/StageId" responses: "200": - description: Usage data + description: Artifact filenames for the stage content: application/json: schema: - $ref: "#/components/schemas/RunUsage" + $ref: "#/components/schemas/ArtifactListResponse" + "404": + description: Run not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + post: + operationId: putStageArtifact + tags: [Run Internals] + summary: Put Stage Artifact + description: | + Uploads one or more artifacts for a stage. Intended for trusted internal callers. + + The server accepts both: + - `application/octet-stream` for single-file uploads with the `filename` query parameter + - strict manifest-first `multipart/form-data` uploads documented by `ArtifactBatchUploadManifest` + + The generated Rust client currently exposes the octet-stream variant because the OpenAPI + code generator in this repo does not support multiple request media types on one operation. + parameters: + - $ref: "#/components/parameters/RunId" + - $ref: "#/components/parameters/StageId" + - name: filename + in: query + required: false + description: Relative artifact path for `application/octet-stream` uploads. Ignored for multipart uploads. + schema: + type: string + requestBody: + required: true + content: + application/octet-stream: + schema: + type: string + format: binary + responses: + "204": + description: Artifact written + "400": + description: Invalid filename, multipart manifest, checksum, or upload body + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" "404": description: Run not found content: @@ -539,23 +817,52 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" - /api/v1/runs/{id}/verification: + /api/v1/runs/{id}/stages/{stageId}/artifacts/download: get: - operationId: retrieveRunVerification - tags: [Run Outputs] - summary: Retrieve Run Verification - description: Returns verification results for a run, organized by criterion with individual control statuses. + operationId: getStageArtifact + tags: [Run Internals] + summary: Get Stage Artifact + description: Downloads an artifact by filename. parameters: - $ref: "#/components/parameters/RunId" - - $ref: "#/components/parameters/PageLimit" - - $ref: "#/components/parameters/PageOffset" + - $ref: "#/components/parameters/StageId" + - $ref: "#/components/parameters/ArtifactFilename" responses: "200": - description: Array of verification criteria with controls + description: Artifact contents + content: + application/octet-stream: + schema: + type: string + format: binary + "400": + description: Missing filename content: application/json: schema: - $ref: "#/components/schemas/PaginatedRunVerificationList" + $ref: "#/components/schemas/ErrorResponse" + "404": + description: Run, stage, or artifact not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + /api/v1/runs/{id}/billing: + get: + operationId: retrieveRunBilling + tags: [Run Outputs] + summary: Retrieve Run Billing + description: Returns token counts and billed totals broken down by stage and model for a specific run. + parameters: + - $ref: "#/components/parameters/RunId" + responses: + "200": + description: Billing data + content: + application/json: + schema: + $ref: "#/components/schemas/RunBilling" "404": description: Run not found content: @@ -585,42 +892,12 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" - /api/v1/runs/{id}/steer: - post: - operationId: steerRun - tags: [Human-in-the-Loop] - summary: Steer Run - description: Sends inline guidance to a running agent, targeting a specific file and line. The guidance is delivered asynchronously. - parameters: - - $ref: "#/components/parameters/RunId" - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/SteerRequest" - responses: - "202": - description: Steering accepted for processing - "404": - description: Run not found - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - "409": - description: Run is not in a steerable state - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - /api/v1/runs/{id}/preview: post: operationId: generatePreviewUrl tags: [Human-in-the-Loop] summary: Preview URL - description: Generates a time-limited preview URL for a port exposed by the run's sandbox environment. + description: Generates a preview URL for a port exposed by the run's sandbox environment. parameters: - $ref: "#/components/parameters/RunId" requestBody: @@ -649,366 +926,142 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" - # ── Workflows ───────────────────────────────────────────────────────── - - /api/v1/workflows: - get: - operationId: listWorkflows - tags: [Workflows] - summary: List Workflows - description: Returns a paginated list of workflow definitions available for execution. - parameters: - - $ref: "#/components/parameters/PageLimit" - - $ref: "#/components/parameters/PageOffset" - responses: - "200": - description: Paginated list of workflows - content: - application/json: - schema: - $ref: "#/components/schemas/PaginatedWorkflowList" - - /api/v1/workflows/{name}: - get: - operationId: retrieveWorkflow - tags: [Workflows] - summary: Retrieve Workflow - description: Returns the full detail of a workflow including its Graphviz graph, resolved settings, and description. - parameters: - - $ref: "#/components/parameters/WorkflowName" - responses: - "200": - description: Workflow detail - content: - application/json: - schema: - $ref: "#/components/schemas/WorkflowDetail" - "404": - description: Workflow not found - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - - /api/v1/workflows/{name}/runs: - get: - operationId: listWorkflowRuns - tags: [Workflows] - summary: List Workflow Runs - description: Returns a paginated list of runs filtered to a specific workflow. - parameters: - - $ref: "#/components/parameters/WorkflowName" - - $ref: "#/components/parameters/PageLimit" - - $ref: "#/components/parameters/PageOffset" - responses: - "200": - description: Paginated list of runs - content: - application/json: - schema: - $ref: "#/components/schemas/PaginatedRunList" - "404": - description: Workflow not found - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - - # ── Verification ────────────────────────────────────────────────────── - - /api/v1/verification/criteria: - get: - operationId: listVerificationCriteria - tags: [Verification] - summary: List Verification Criteria - description: Returns paginated verification criteria with their controls and performance metrics. Each criterion contains controls; retrieve a specific control via `/api/v1/verification/controls/{id}`. - parameters: - - $ref: "#/components/parameters/PageLimit" - - $ref: "#/components/parameters/PageOffset" - responses: - "200": - description: Array of verification criteria - content: - application/json: - schema: - $ref: "#/components/schemas/PaginatedVerificationCriterionList" - - /api/v1/verification/criteria/{id}: - get: - operationId: retrieveVerificationCriterion - tags: [Verification] - summary: Retrieve Verification Criterion - description: Returns a specific verification criterion with its controls and performance metrics. - parameters: - - $ref: "#/components/parameters/CriterionId" - responses: - "200": - description: Verification criterion detail - content: - application/json: - schema: - $ref: "#/components/schemas/VerificationCriterionDetail" - "404": - description: Criterion not found - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - - /api/v1/verification/controls: - get: - operationId: listVerificationControls - tags: [Verification] - summary: List Verification Controls - description: Returns a flat paginated list of all verification controls across all criteria. - parameters: - - $ref: "#/components/parameters/PageLimit" - - $ref: "#/components/parameters/PageOffset" - responses: - "200": - description: Array of verification controls - content: - application/json: - schema: - $ref: "#/components/schemas/PaginatedVerificationControlList" - - /api/v1/verification/controls/{id}: - get: - operationId: retrieveVerificationControl - tags: [Verification] - summary: Retrieve Verification Control - description: Returns detailed information about a specific verification control, including performance data, recent results, and sibling controls in the same criterion. - parameters: - - $ref: "#/components/parameters/ControlId" - responses: - "200": - description: Verification control detail - content: - application/json: - schema: - $ref: "#/components/schemas/VerificationDetailResponse" - "404": - description: Control not found - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - - /api/v1/verification/signoffs: - get: - operationId: listSignoffs - tags: [Verification] - summary: List Signoffs - description: Returns a paginated list of signoffs, optionally filtered by control, repository, and/or commit SHA. - parameters: - - $ref: "#/components/parameters/SignoffControlFilter" - - $ref: "#/components/parameters/SignoffRepositoryFilter" - - $ref: "#/components/parameters/SignoffCommitShaFilter" - - $ref: "#/components/parameters/PageLimit" - - $ref: "#/components/parameters/PageOffset" - responses: - "200": - description: Paginated list of signoffs - content: - application/json: - schema: - $ref: "#/components/schemas/PaginatedSignoffList" + /api/v1/runs/{id}/ssh: post: - operationId: createSignoff - tags: [Verification] - summary: Create Signoff - description: Creates a new signoff for a (control, repository, commit SHA) tuple. Multiple signoffs are allowed per tuple; the latest one wins for display purposes. + operationId: createRunSshAccess + tags: [Human-in-the-Loop] + summary: SSH Access + description: Creates a time-limited SSH command for the run's sandbox environment. + parameters: + - $ref: "#/components/parameters/RunId" requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/CreateSignoffRequest" + $ref: "#/components/schemas/SshAccessRequest" responses: "201": - description: Signoff created + description: SSH command created content: application/json: schema: - $ref: "#/components/schemas/Signoff" - "400": - description: Invalid request - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - - /api/v1/verification/signoffs/{id}: - get: - operationId: retrieveSignoff - tags: [Verification] - summary: Retrieve Signoff - description: Returns a specific signoff by ID. - parameters: - - $ref: "#/components/parameters/SignoffId" - responses: - "200": - description: Signoff detail - content: - application/json: - schema: - $ref: "#/components/schemas/Signoff" + $ref: "#/components/schemas/SshAccessResponse" "404": - description: Signoff not found + description: Run not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "409": + description: Run has no active sandbox or provider does not support SSH content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" - # ── Retros ──────────────────────────────────────────────────────────── - - /api/v1/retros: + /api/v1/runs/{id}/sandbox/files: get: - operationId: listRetros - tags: [Retros] - summary: List Retros - description: Returns a paginated list of run retrospectives ordered by recency, with smoothness ratings and summary statistics. + operationId: listSandboxFiles + tags: [Human-in-the-Loop] + summary: List Sandbox Files + description: Lists directory entries from the run's sandbox environment. parameters: - - $ref: "#/components/parameters/RetroWorkflowFilter" - - $ref: "#/components/parameters/RetroSmoothnessFilter" - - $ref: "#/components/parameters/PageLimit" - - $ref: "#/components/parameters/PageOffset" - responses: - "200": - description: Paginated list of retros - content: - application/json: - schema: - $ref: "#/components/schemas/PaginatedRetroList" - - # ── Sessions ────────────────────────────────────────────────────────── - - /api/v1/sessions: - get: - operationId: listSessions - tags: [Sessions] - summary: List Sessions - description: Returns sessions ordered by recency (newest first). - parameters: - - $ref: "#/components/parameters/PageLimit" - - $ref: "#/components/parameters/PageOffset" - responses: - "200": - description: Paginated list of sessions - content: - application/json: - schema: - $ref: "#/components/schemas/PaginatedSessionList" - post: - operationId: createSession - tags: [Sessions] - summary: Create Session - description: Start a new interactive chat session. The initial user prompt is required; a model may optionally be specified. - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/CreateSessionRequest" - responses: - "201": - description: Session created - content: - application/json: - schema: - $ref: "#/components/schemas/CreateSessionResponse" - - /api/v1/sessions/{id}: - get: - operationId: retrieveSession - tags: [Sessions] - summary: Retrieve Session - description: Returns the full session detail including all conversation turns. - parameters: - - $ref: "#/components/parameters/SessionId" - responses: - "200": - description: Session detail - content: - application/json: - schema: - $ref: "#/components/schemas/SessionDetail" - "404": - description: Session not found - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - - /api/v1/sessions/{id}/messages: - post: - operationId: sendSessionMessage - tags: [Sessions] - summary: Send Session Message - description: Append a user message to an existing session. The server will process it and produce assistant and tool turns asynchronously via the event stream. - parameters: - - $ref: "#/components/parameters/SessionId" - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/SendMessageRequest" - responses: - "202": - description: Message accepted for processing - content: - application/json: - schema: - $ref: "#/components/schemas/SendMessageResponse" - "404": - description: Session not found - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - - /api/v1/sessions/{id}/events: - get: - operationId: streamSessionEvents - tags: [Sessions] - summary: Stream Session Events - description: | - Opens a server-sent event (SSE) stream for real-time session updates. - - Each SSE frame includes a sequential numeric `id:` field that supports - resumption via the `Last-Event-ID` request header. - - The stream emits the following SSE event types: - - - `event: content_delta` — data: `{"delta": "..."}` (incremental text chunk) - - `event: assistant_turn` — data: `AssistantTurn` JSON object - - `event: tool_turn` — data: `ToolTurn` JSON object - - `event: done` — data: `{}` (stream complete) - - `event: error` — data: `{"message": "..."}` (error occurred) - - Each SSE frame has an `id:` line (sequential integer), an `event:` line (the event type), - and a `data:` line (the JSON payload). - parameters: - - $ref: "#/components/parameters/SessionId" - - name: Last-Event-ID - in: header + - $ref: "#/components/parameters/RunId" + - in: query + name: path + required: true + schema: + type: string + - in: query + name: depth required: false - description: > - SSE reconnection header. When provided, the server resumes the - stream after the event with this ID. IDs are 0-based sequential - integers assigned to each emitted SSE frame. + schema: + type: integer + minimum: 1 + responses: + "200": + description: Directory entries + content: + application/json: + schema: + $ref: "#/components/schemas/SandboxFileListResponse" + "404": + description: Run not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "409": + description: Run has no active sandbox + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + /api/v1/runs/{id}/sandbox/file: + get: + operationId: getSandboxFile + tags: [Human-in-the-Loop] + summary: Download Sandbox File + description: Downloads a file from the run's sandbox environment. + parameters: + - $ref: "#/components/parameters/RunId" + - in: query + name: path + required: true schema: type: string responses: "200": - description: Server-sent event stream + description: File contents content: - text/event-stream: + application/octet-stream: schema: type: string + format: binary "404": - description: Session not found + description: Run or file not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "409": + description: Run has no active sandbox + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + put: + operationId: putSandboxFile + tags: [Human-in-the-Loop] + summary: Upload Sandbox File + description: Uploads a file into the run's sandbox environment. + parameters: + - $ref: "#/components/parameters/RunId" + - in: query + name: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/octet-stream: + schema: + type: string + format: binary + responses: + "204": + description: File written + "404": + description: Run not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "409": + description: Run has no active sandbox content: application/json: schema: @@ -1158,21 +1211,203 @@ paths: schema: $ref: "#/components/schemas/PaginatedHistoryEntryList" - # ── Usage ──────────────────────────────────────────────────────────── + # ── Billing ────────────────────────────────────────────────────────── - /api/v1/usage: + /api/v1/billing: get: - operationId: getAggregateUsage - tags: [Usage] - summary: Aggregate Usage - description: Returns aggregate token/cost usage across all completed runs since server start. + operationId: getAggregateBilling + tags: [Billing] + summary: Aggregate Billing + description: Returns aggregate token counts and billed totals across all completed runs since server start. responses: "200": - description: Aggregate usage data + description: Aggregate billing data content: application/json: schema: - $ref: "#/components/schemas/AggregateUsage" + $ref: "#/components/schemas/AggregateBilling" + + # ── System ─────────────────────────────────────────────────────────── + + /api/v1/attach: + get: + operationId: attachEvents + tags: [System] + summary: Attach Global Events + description: Opens a server-sent event stream for live run events across the server. + parameters: + - name: run_id + in: query + required: false + description: Optional comma-separated list of run IDs to include. + schema: + type: string + responses: + "200": + description: Server-sent event stream + content: + text/event-stream: + schema: + type: string + + /api/v1/system/info: + get: + operationId: getSystemInfo + tags: [System] + summary: Retrieve System Info + description: Returns runtime details about the active Fabro server process. + responses: + "200": + description: System information + content: + application/json: + schema: + $ref: "#/components/schemas/SystemInfoResponse" + + /api/v1/system/df: + get: + operationId: getSystemDiskUsage + tags: [System] + summary: Retrieve System Disk Usage + description: Returns disk usage for the server storage directory. + parameters: + - name: verbose + in: query + required: false + description: Include per-run disk usage rows. + schema: + type: boolean + default: false + responses: + "200": + description: Disk usage summary + content: + application/json: + schema: + $ref: "#/components/schemas/DiskUsageResponse" + + /api/v1/system/prune/runs: + post: + operationId: pruneRuns + tags: [System] + summary: Prune Runs + description: Deletes completed runs matching the provided filters, or previews the deletion set when dry-run is enabled. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PruneRunsRequest" + responses: + "200": + description: Prune result + content: + application/json: + schema: + $ref: "#/components/schemas/PruneRunsResponse" + "400": + description: Invalid prune request + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + # ── Secrets ────────────────────────────────────────────────────────── + + /api/v1/secrets: + get: + operationId: listSecrets + tags: [Secrets] + summary: List vault secrets + description: Returns workflow-visible vault secret names and timestamps. Secret values are never exposed. + responses: + "200": + description: Secret metadata list + content: + application/json: + schema: + $ref: "#/components/schemas/SecretListResponse" + post: + operationId: createSecret + tags: [Secrets] + summary: Store or update a vault secret + description: Stores a secret in the workflow-visible vault. Anything stored here may be used by workflows. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateSecretRequest" + responses: + "200": + description: Secret stored + content: + application/json: + schema: + $ref: "#/components/schemas/SecretMetadata" + "400": + description: Invalid secret name or request body + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + delete: + operationId: deleteSecretByName + tags: [Secrets] + summary: Delete a vault secret + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteSecretRequest" + responses: + "204": + description: Secret deleted + "400": + description: Invalid secret name or request body + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "404": + description: Secret not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "500": + description: Secret store write failed + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + # ── Repos ──────────────────────────────────────────────────────────── + + /api/v1/repos/github/{owner}/{name}: + get: + operationId: getGithubRepo + tags: [Repos] + summary: Check server access to a GitHub repository + parameters: + - name: owner + in: path + required: true + schema: + type: string + - name: name + in: path + required: true + schema: + type: string + responses: + "200": + description: Repository access details + content: + application/json: + schema: + $ref: "#/components/schemas/RepoCheckResponse" # ── Models ─────────────────────────────────────────────────────────── @@ -1183,6 +1418,8 @@ paths: summary: List Models description: Returns a paginated list of available LLM models from the built-in catalog. parameters: + - $ref: "#/components/parameters/ModelProviderFilter" + - $ref: "#/components/parameters/ModelQueryFilter" - $ref: "#/components/parameters/PageLimit" - $ref: "#/components/parameters/PageOffset" responses: @@ -1192,6 +1429,12 @@ paths: application/json: schema: $ref: "#/components/schemas/PaginatedModelList" + "400": + description: Invalid filter value + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" /api/v1/models/{id}/test: post: @@ -1206,6 +1449,7 @@ paths: schema: type: string description: The model identifier. + - $ref: "#/components/parameters/ModelTestModeParam" responses: "200": description: Test result @@ -1213,6 +1457,12 @@ paths: application/json: schema: $ref: "#/components/schemas/ModelTestResult" + "400": + description: Invalid test mode + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" "404": description: Model not found content: @@ -1299,24 +1549,56 @@ components: type: string example: 01JNQVR7M0EJ5GKAT2SC4ERS1Z - SessionId: - name: id - in: path - required: true - description: Unique session identifier. - schema: - type: string - format: uuid - example: a1b2c3d4-e5f6-7890-abcd-ef1234567890 - StageId: name: stageId in: path required: true - description: Identifier of a stage within a run's workflow graph. + description: Identifier of a stage within a run's workflow graph, serialized as `node_id@visit`. schema: type: string - example: propose-changes + example: code@2 + + BlobId: + name: blobId + in: path + required: true + description: Content-addressed blob identifier. + schema: + type: string + pattern: '^[0-9a-f]{64}$' + example: 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 + + ArtifactFilename: + name: filename + in: query + required: true + description: Relative artifact path. `/` is allowed as a path separator. Backslash, empty segments, and traversal segments (`.` and `..`) are invalid. + schema: + type: string + example: src/lib.rs + + SinceSeq: + name: since_seq + in: query + required: false + description: First event sequence number to include. + schema: + type: integer + minimum: 1 + default: 1 + example: 42 + + EventLimit: + name: limit + in: query + required: false + description: Maximum number of events to return. + schema: + type: integer + minimum: 1 + maximum: 1000 + default: 100 + example: 100 QuestionId: name: qid @@ -1327,69 +1609,6 @@ components: type: string example: q-001 - WorkflowName: - name: name - in: path - required: true - description: URL-safe slug identifying a workflow definition. - schema: - type: string - example: fix_build - - CriterionId: - name: id - in: path - required: true - description: URL-safe slug identifying a verification criterion. - schema: - type: string - example: traceability - - ControlId: - name: id - in: path - required: true - description: URL-safe slug identifying a verification control. - schema: - type: string - example: motivation - - SignoffId: - name: id - in: path - required: true - description: Unique identifier of a signoff (ULID). - schema: - type: string - example: 01JQVKX0001SIGNOFF00001 - - SignoffControlFilter: - name: control - in: query - required: false - description: Filter signoffs by control slug. - schema: - type: string - example: motivation - - SignoffRepositoryFilter: - name: repository - in: query - required: false - description: Filter signoffs by repository name. - schema: - type: string - example: api-server - - SignoffCommitShaFilter: - name: commit_sha - in: query - required: false - description: Filter signoffs by commit SHA. - schema: - type: string - example: a1b2c3d4e5f6 - InsightQueryId: name: id in: path @@ -1408,24 +1627,6 @@ components: type: string example: cp-3 - RetroWorkflowFilter: - name: workflow - in: query - required: false - description: Filter retros by workflow slug. - schema: - type: string - example: implement - - RetroSmoothnessFilter: - name: smoothness - in: query - required: false - description: Filter retros by smoothness rating. - schema: - $ref: "#/components/schemas/SmoothnessRating" - example: bumpy - PageLimit: name: page[limit] in: query @@ -1449,6 +1650,33 @@ components: default: 0 example: 0 + ModelProviderFilter: + name: provider + in: query + required: false + description: Filter models by provider name. Invalid values return `400`. + schema: + type: string + example: anthropic + + ModelQueryFilter: + name: query + in: query + required: false + description: Case-insensitive substring search across `id`, `display_name`, and `aliases`. + schema: + type: string + example: opus + + ModelTestModeParam: + name: mode + in: query + required: false + description: Test mode for the single-model test endpoint. Defaults to `basic`. + schema: + $ref: "#/components/schemas/ModelTestMode" + example: basic + schemas: # ── Pagination ─────────────────────────────────────────────────────── @@ -1477,48 +1705,6 @@ components: meta: $ref: "#/components/schemas/PaginationMeta" - PaginatedWorkflowList: - description: Paginated list of workflows. - type: object - required: - - data - - meta - properties: - data: - type: array - items: - $ref: "#/components/schemas/WorkflowListItem" - meta: - $ref: "#/components/schemas/PaginationMeta" - - PaginatedRetroList: - description: Paginated list of run retrospectives. - type: object - required: - - data - - meta - properties: - data: - type: array - items: - $ref: "#/components/schemas/RetroListItem" - meta: - $ref: "#/components/schemas/PaginationMeta" - - PaginatedSessionList: - description: Paginated list of sessions. - type: object - required: - - data - - meta - properties: - data: - type: array - items: - $ref: "#/components/schemas/SessionListItem" - meta: - $ref: "#/components/schemas/PaginationMeta" - PaginatedModelList: description: Paginated list of models. type: object @@ -1649,7 +1835,7 @@ components: description: Whether this is the default model for its provider. ModelTestResult: - description: Result of testing a model with a simple prompt. + description: Result of testing a model in `basic` or `deep` mode. type: object required: - model_id @@ -1670,6 +1856,13 @@ components: nullable: true description: Error details when status is "error". + ModelTestMode: + description: Single-model test mode. + type: string + enum: + - basic + - deep + # ── Completion Schemas ───────────────────────────────────────────── CompletionMessage: @@ -1881,40 +2074,13 @@ components: meta: $ref: "#/components/schemas/PaginationMeta" - PaginatedRunVerificationList: - description: Paginated list of run verification categories. - type: object - required: - - data - - meta - properties: - data: - type: array - items: - $ref: "#/components/schemas/RunVerification" - meta: - $ref: "#/components/schemas/PaginationMeta" - - PaginatedVerificationCriterionList: - description: Paginated list of verification criteria. - type: object - required: - - data - - meta - properties: - data: - type: array - items: - $ref: "#/components/schemas/VerificationCriterion" - meta: - $ref: "#/components/schemas/PaginationMeta" - # ── Run Schemas ────────────────────────────────────────────────────── RunStatus: description: Lifecycle status of a run. type: string enum: + - submitted - queued - starting - running @@ -1923,16 +2089,369 @@ components: - cancelled - paused - StartRunRequest: - description: Request body for starting a new run from a Graphviz graph source. + RunManifest: + description: Self-contained workflow run manifest. type: object required: - - dot_source + - version + - cwd + - target + - workflows properties: - dot_source: + version: + type: integer + description: Manifest schema version. + example: 1 + run_id: type: string - description: Graphviz DOT language source defining the workflow graph. - example: 'digraph { start [shape=Mdiamond]; exit [shape=Msquare]; start -> exit }' + nullable: true + description: Optional pre-generated run ID to use instead of allocating a new ULID. + example: "01HV6D7S5YF4Z4B2M7K4N0Q6T9" + cwd: + type: string + description: CLI working directory at invocation time. + example: "/tmp/project" + git: + $ref: "#/components/schemas/ManifestGit" + goal: + $ref: "#/components/schemas/ManifestGoal" + args: + $ref: "#/components/schemas/ManifestArgs" + target: + $ref: "#/components/schemas/ManifestTarget" + configs: + type: array + items: + $ref: "#/components/schemas/ManifestConfig" + workflows: + type: object + additionalProperties: + $ref: "#/components/schemas/ManifestWorkflow" + + ManifestGit: + description: Observable git state from the CLI working directory. + type: object + required: + - origin_url + - branch + - sha + - clean + properties: + origin_url: + type: string + description: Remote origin URL with any embedded credentials removed. + example: "https://github.com/acme/my-app.git" + branch: + type: string + description: Current branch name. + example: feature/foo + sha: + type: string + description: Current commit SHA. + example: abc123def + clean: + type: boolean + description: Whether the working tree has uncommitted changes. + + ManifestGoal: + description: Resolved goal with provenance. + type: object + required: + - type + - text + properties: + type: + type: string + enum: + - value + - file + - graph + text: + type: string + description: Resolved goal content. + path: + type: string + nullable: true + description: Original goal file path when the goal came from a file. + + ManifestArgs: + description: Sparse command-local args that affect run settings. + type: object + properties: + model: + type: string + provider: + type: string + sandbox: + type: string + verbose: + type: boolean + dry_run: + type: boolean + auto_approve: + type: boolean + no_retro: + type: boolean + preserve_sandbox: + type: boolean + label: + type: array + items: + type: string + + ManifestTarget: + type: object + required: + - identifier + - path + properties: + identifier: + type: string + description: What the user typed. + example: smoke + path: + type: string + description: Resolved path that keys into the workflows map. + example: .fabro/workflows/smoke/workflow.fabro + + ManifestConfig: + type: object + required: + - type + properties: + type: + type: string + enum: + - project + - user + path: + type: string + nullable: true + source: + type: string + nullable: true + + ManifestWorkflowConfig: + type: object + required: + - path + - source + properties: + path: + type: string + source: + type: string + + ManifestFileEntry: + description: A bundled file with discovery metadata. + type: object + required: + - content + - ref + properties: + content: + type: string + ref: + $ref: "#/components/schemas/ManifestFileRef" + + ManifestFileRef: + type: object + required: + - type + - original + properties: + type: + type: string + enum: + - file_inline + - import + - dockerfile + original: + type: string + from: + type: string + nullable: true + + ManifestWorkflow: + type: object + required: + - source + properties: + source: + type: string + config: + $ref: "#/components/schemas/ManifestWorkflowConfig" + files: + type: object + additionalProperties: + $ref: "#/components/schemas/ManifestFileEntry" + + PreflightResponse: + type: object + required: + - ok + - workflow + - checks + properties: + ok: + type: boolean + description: Whether preflight passed using the CLI-compatible success rule. + workflow: + $ref: "#/components/schemas/PreflightWorkflowSummary" + checks: + $ref: "#/components/schemas/PreflightCheckReport" + + RenderWorkflowGraphRequest: + type: object + required: + - manifest + properties: + manifest: + $ref: "#/components/schemas/RunManifest" + format: + $ref: "#/components/schemas/RenderWorkflowGraphFormat" + direction: + $ref: "#/components/schemas/RenderWorkflowGraphDirection" + + RenderWorkflowGraphFormat: + type: string + enum: + - svg + - png + + RenderWorkflowGraphDirection: + type: string + enum: + - lr + - tb + + PreflightWorkflowSummary: + type: object + required: + - name + - nodes + - edges + - goal + - diagnostics + properties: + name: + type: string + graph_path: + type: string + nullable: true + nodes: + type: integer + edges: + type: integer + goal: + type: string + diagnostics: + type: array + items: + $ref: "#/components/schemas/WorkflowDiagnostic" + + WorkflowDiagnostic: + type: object + required: + - rule + - severity + - message + properties: + rule: + type: string + severity: + type: string + enum: + - error + - warning + - info + message: + type: string + node_id: + type: string + nullable: true + edge: + type: array + nullable: true + minItems: 2 + maxItems: 2 + items: + type: string + fix: + type: string + nullable: true + + PreflightCheckReport: + type: object + required: + - title + - sections + properties: + title: + type: string + sections: + type: array + items: + $ref: "#/components/schemas/PreflightCheckSection" + + PreflightCheckSection: + type: object + required: + - title + - checks + properties: + title: + type: string + checks: + type: array + items: + $ref: "#/components/schemas/PreflightCheckResult" + + PreflightCheckResult: + type: object + required: + - name + - status + - summary + - details + properties: + name: + type: string + status: + type: string + enum: + - pass + - warning + - error + summary: + type: string + details: + type: array + items: + $ref: "#/components/schemas/PreflightCheckDetail" + remediation: + type: string + nullable: true + + PreflightCheckDetail: + type: object + required: + - text + - warn + properties: + text: + type: string + warn: + type: boolean + + StartRunRequest: + description: Request body for starting or resuming a run. + type: object + properties: + resume: + type: boolean + description: Resume from checkpoint instead of starting from submitted state. + default: false RunStatusResponse: description: Current status of a run with optional error and queue position. @@ -1954,6 +2473,14 @@ components: type: integer description: Position in the queue (1-based). Only present when status is `queued`. example: 3 + status_reason: + allOf: + - $ref: "#/components/schemas/StatusReason" + nullable: true + pending_control: + allOf: + - $ref: "#/components/schemas/RunControlAction" + nullable: true created_at: type: string format: date-time @@ -1982,6 +2509,7 @@ components: required: - id - text + - stage - question_type - options - allow_freeform @@ -1994,6 +2522,10 @@ components: type: string description: The question text displayed to the user. example: Should we proceed with the proposed changes? + stage: + type: string + description: Workflow stage identifier that produced the question. + example: gate question_type: $ref: "#/components/schemas/QuestionType" options: @@ -2005,6 +2537,17 @@ components: type: boolean description: Whether the user may provide freeform text in addition to selecting options. example: true + timeout_seconds: + type: number + format: double + nullable: true + description: Timeout for the question when configured by the workflow. + example: 30 + context_display: + type: string + nullable: true + description: Optional contextual text shown alongside the question. + example: Latest draft QuestionType: description: The interaction type of a human-in-the-loop question. @@ -2070,6 +2613,491 @@ components: items: $ref: "#/components/schemas/ErrorResponseEntry" + ActorKind: + description: High-level category of an event actor. + type: string + enum: + - user + - agent + - system + + ActorRef: + description: > + Optional primary actor associated with a run event. Present on control + actions and durable agent output where a stable user or agent identity + matters; omitted on routine runtime lifecycle events. + type: object + required: + - kind + properties: + kind: + $ref: "#/components/schemas/ActorKind" + id: + type: string + description: Stable actor identifier when available. + display: + type: string + description: Display-friendly label for the actor. + + RunEvent: + description: > + Internal RunEvent-compatible JSON payload. The server validates this + body by deserializing into the typed RunEvent struct. + type: object + required: + - id + - ts + - run_id + - event + properties: + id: + type: string + ts: + type: string + format: date-time + run_id: + type: string + node_id: + type: string + nullable: true + node_label: + type: string + nullable: true + stage_id: + type: string + nullable: true + description: Stage execution identity, formatted as "{node_id}@{visit}". + parallel_group_id: + type: string + nullable: true + description: > + Durable identity of one execution of a parallel node, formatted as + "{node_id}@{visit}". + parallel_branch_id: + type: string + nullable: true + description: > + Durable identity of one branch within a parallel execution, + formatted as "{parallel_group_id}:{index}". + session_id: + type: string + nullable: true + parent_session_id: + type: string + nullable: true + tool_call_id: + type: string + nullable: true + description: > + Stable identifier for a tool call, present on agent.tool.* events + and other durable events that directly describe the same tool + call. + actor: + allOf: + - $ref: "#/components/schemas/ActorRef" + nullable: true + event: + type: string + description: Event type discriminator. + example: stage.started + properties: + type: object + additionalProperties: true + additionalProperties: true + + EventSeq: + description: Assigned sequence number component of a stored event envelope. + type: object + required: + - seq + properties: + seq: + type: integer + description: Assigned event sequence number. + example: 42 + + EventEnvelope: + description: > + Stored event envelope with assigned sequence number. On the wire the + envelope is flattened: seq sits alongside the RunEvent payload fields + at the top level of the JSON object. + allOf: + - $ref: "#/components/schemas/EventSeq" + - $ref: "#/components/schemas/RunEvent" + + PaginatedEventList: + description: Paginated list of stored run events. + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: "#/components/schemas/EventEnvelope" + meta: + $ref: "#/components/schemas/PaginationMeta" + + AppendEventResponse: + description: Assigned sequence number for an appended event. + type: object + required: + - seq + properties: + seq: + type: integer + description: Assigned event sequence number. + example: 42 + + WriteBlobResponse: + description: Content-addressed identifier for a stored blob. + type: object + required: + - id + properties: + id: + type: string + description: Blob identifier. + example: 550e8400-e29b-41d4-a716-446655440000 + + ArtifactEntry: + description: A single artifact filename. + type: object + required: + - filename + properties: + filename: + type: string + description: Artifact filename. + example: src/lib.rs + + ArtifactListResponse: + description: List of artifact filenames for a stage. + type: object + required: + - data + properties: + data: + type: array + items: + $ref: "#/components/schemas/ArtifactEntry" + + ArtifactBatchUploadEntry: + description: One file entry in a strict multipart artifact upload manifest. + type: object + required: + - part + - path + properties: + part: + type: string + description: Multipart field name for the file part. + example: file1 + path: + type: string + description: Relative artifact path to store. + example: src/lib.rs + sha256: + type: string + nullable: true + description: Optional lowercase hex SHA-256 checksum for the file contents. + example: 3f785df4c5b7d3f1f4c1f0ecb0f55f1d9f6f6a3d9f0a8a98f7a74f29d1f81a2c + expected_bytes: + type: integer + format: int64 + nullable: true + minimum: 0 + description: Optional exact byte length expected for the file part. + example: 1234 + content_type: + type: string + nullable: true + description: Optional client-supplied content type for the file part. + example: text/plain + + ArtifactBatchUploadManifest: + description: Manifest for strict multipart artifact uploads. + type: object + required: + - entries + properties: + entries: + type: array + minItems: 1 + items: + $ref: "#/components/schemas/ArtifactBatchUploadEntry" + + RunArtifactEntry: + description: A captured artifact file for a run. + type: object + required: + - stage_id + - node_slug + - retry + - relative_path + - size + properties: + stage_id: + type: string + description: Stage ID in `node@visit` form. + node_slug: + type: string + description: Node slug that produced the artifact. + retry: + type: integer + format: int32 + description: Retry attempt number. + relative_path: + type: string + description: Artifact path relative to the stage artifact capture directory. + size: + type: integer + format: int64 + description: Artifact size in bytes. + + RunArtifactListResponse: + description: List of captured artifact files for a run. + type: object + required: + - data + properties: + data: + type: array + items: + $ref: "#/components/schemas/RunArtifactEntry" + + InternalRunStatus: + description: Internal event-sourced run status. + type: string + enum: + - submitted + - starting + - running + - paused + - removing + - succeeded + - failed + - dead + + StatusReason: + description: Optional reason attached to a run status transition. + type: string + enum: + - completed + - partial_success + - workflow_error + - cancelled + - terminated + - transient_infra + - budget_exhausted + - launch_failed + - bootstrap_failed + - sandbox_init_failed + - sandbox_initializing + + RunControlAction: + description: Run control action requested by the API. + type: string + enum: + - cancel + - pause + - unpause + + RunStatusRecord: + description: Internal run status record from the event projection. + type: object + required: + - status + - updated_at + properties: + status: + $ref: "#/components/schemas/InternalRunStatus" + reason: + oneOf: + - $ref: "#/components/schemas/StatusReason" + - type: "null" + updated_at: + type: string + format: date-time + + InternalStageStatus: + description: Internal stage status from outcomes and node status records. + type: string + enum: + - success + - fail + - skipped + - partial_success + - retry + + NodeStatusRecord: + description: Internal node status record. + type: object + required: + - status + - timestamp + properties: + status: + $ref: "#/components/schemas/InternalStageStatus" + notes: + type: string + nullable: true + failure_reason: + type: string + nullable: true + timestamp: + type: string + format: date-time + + NodeState: + description: Internal node projection state. + type: object + properties: + prompt: + type: string + nullable: true + response: + type: string + nullable: true + status: + oneOf: + - $ref: "#/components/schemas/NodeStatusRecord" + - type: "null" + provider_used: + nullable: true + diff: + type: string + nullable: true + script_invocation: + nullable: true + script_timing: + nullable: true + parallel_results: + nullable: true + stdout: + type: string + nullable: true + stderr: + type: string + nullable: true + + RunProjection: + description: Raw internal run projection derived from the event log. + type: object + required: + - nodes + properties: + run: + type: object + additionalProperties: true + nullable: true + graph_source: + type: string + nullable: true + start: + type: object + additionalProperties: true + nullable: true + status: + oneOf: + - $ref: "#/components/schemas/RunStatusRecord" + - type: "null" + checkpoint: + oneOf: + - $ref: "#/components/schemas/RunCheckpoint" + - type: "null" + checkpoints: + type: array + description: Sequence-tagged checkpoint history entries as `[seq, checkpoint]`. + items: + type: array + minItems: 2 + maxItems: 2 + items: + oneOf: + - type: integer + - $ref: "#/components/schemas/RunCheckpoint" + conclusion: + type: object + additionalProperties: true + nullable: true + retro: + type: object + additionalProperties: true + nullable: true + retro_prompt: + type: string + nullable: true + retro_response: + type: string + nullable: true + sandbox: + type: object + additionalProperties: true + nullable: true + final_patch: + type: string + nullable: true + pull_request: + type: object + additionalProperties: true + nullable: true + nodes: + type: object + description: Map from StageId (`node_id@visit`) to NodeState. + additionalProperties: + $ref: "#/components/schemas/NodeState" + + StoreRunSummary: + description: Durable run summary derived from the backing store. + type: object + required: + - run_id + - labels + properties: + run_id: + type: string + workflow_name: + type: string + nullable: true + workflow_slug: + type: string + nullable: true + goal: + type: string + nullable: true + labels: + type: object + additionalProperties: + type: string + host_repo_path: + type: string + nullable: true + start_time: + type: string + format: date-time + nullable: true + status: + type: string + nullable: true + status_reason: + type: string + nullable: true + pending_control: + allOf: + - $ref: "#/components/schemas/RunControlAction" + nullable: true + duration_ms: + type: integer + format: int64 + minimum: 0 + nullable: true + total_usd_micros: + type: integer + format: int64 + nullable: true + # ── Run Board Schemas ──────────────────────────────────────────────── BoardColumn: @@ -2160,24 +3188,13 @@ components: description: Repository name. example: api-server - CriterionReference: - description: Reference to a verification criterion by name. - type: object - required: - - name - properties: - name: - type: string - description: Criterion name. - example: Traceability - - TokenUsage: - description: Token and cost usage totals. + BilledTokenCounts: + description: Token counts with optional billed USD micros totals. type: object required: - input_tokens - output_tokens - - cost + - total_tokens properties: input_tokens: type: integer @@ -2187,10 +3204,28 @@ components: type: integer description: Number of output tokens generated. example: 8750 - cost: - type: number - description: Cost in USD. - example: 0.72 + total_tokens: + type: integer + description: Total billable tokens aggregated across categories. + example: 37390 + reasoning_tokens: + type: integer + description: Number of reasoning tokens. + example: 1200 + cache_read_tokens: + type: integer + description: Number of cache read tokens. + example: 4800 + cache_write_tokens: + type: integer + description: Number of cache write tokens. + example: 1500 + total_usd_micros: + type: integer + format: int64 + nullable: true + description: Billed USD amount in micros. + example: 720000 CodeLocation: description: A file and line location in the codebase. @@ -2301,14 +3336,14 @@ components: description: Question text. example: Accept or push for another round? - AggregateUsageTotals: - description: Aggregate usage totals across all runs. + AggregateBillingTotals: + description: Aggregate billing totals across all runs. type: object required: - runs - input_tokens - output_tokens - - cost + - total_tokens - runtime_secs properties: runs: @@ -2323,45 +3358,35 @@ components: type: integer description: Total output tokens. example: 189720 - cost: - type: number - description: Total cost in USD. - example: 20.34 + total_tokens: + type: integer + description: Total tokens aggregated across all billing categories. + example: 833580 + reasoning_tokens: + type: integer + description: Total reasoning tokens. + example: 12040 + cache_read_tokens: + type: integer + description: Total cache read tokens. + example: 85400 + cache_write_tokens: + type: integer + description: Total cache write tokens. + example: 9200 + total_usd_micros: + type: integer + format: int64 + nullable: true + description: Total billed USD amount in micros. + example: 20340000 runtime_secs: type: number description: Total runtime in seconds. example: 3501.0 - WorkflowSchedule: - description: Schedule configuration for a workflow. - type: object - required: - - expression - properties: - expression: - type: string - description: Cron-like schedule expression. - example: "0 */6 * * *" - next_run: - type: string - format: date-time - description: ISO 8601 timestamp of the next scheduled run. - example: "2025-09-15T18:00:00Z" - - WorkflowLastRun: - description: Information about a workflow's most recent run. - type: object - required: - - ran_at - properties: - ran_at: - type: string - format: date-time - description: ISO 8601 timestamp of the most recent run. - example: "2025-09-15T12:00:00Z" - - UsageStageRef: - description: Reference to a usage stage. + BillingStageRef: + description: Reference to a billing stage. type: object required: - id @@ -2679,36 +3704,36 @@ components: meta: $ref: "#/components/schemas/PaginationMeta" - # ── Usage Schemas ──────────────────────────────────────────────────── + # ── Billing Schemas ────────────────────────────────────────────────── - UsageStage: - description: Token and cost usage for a single stage within a run. + RunBillingStage: + description: Token counts and billed totals for a single stage within a run. type: object required: - stage - model - - usage + - billing - runtime_secs properties: stage: - $ref: "#/components/schemas/UsageStageRef" + $ref: "#/components/schemas/BillingStageRef" model: $ref: "#/components/schemas/ModelReference" - usage: - $ref: "#/components/schemas/TokenUsage" + billing: + $ref: "#/components/schemas/BilledTokenCounts" runtime_secs: type: number description: Wall-clock runtime in seconds. example: 154.0 - UsageTotals: - description: Aggregate usage totals across all stages of a run. + RunBillingTotals: + description: Aggregate billing totals across all stages of a run. type: object required: - runtime_secs - input_tokens - output_tokens - - cost + - total_tokens properties: runtime_secs: type: number @@ -2722,18 +3747,36 @@ components: type: integer description: Total output tokens generated. example: 21080 - cost: - type: number - description: Total cost in USD. - example: 2.26 + total_tokens: + type: integer + description: Total tokens aggregated across all billing categories. + example: 92620 + reasoning_tokens: + type: integer + description: Total reasoning tokens. + example: 3400 + cache_read_tokens: + type: integer + description: Total cache read tokens. + example: 22000 + cache_write_tokens: + type: integer + description: Total cache write tokens. + example: 4500 + total_usd_micros: + type: integer + format: int64 + nullable: true + description: Total billed USD amount in micros. + example: 2260000 - UsageByModel: - description: Usage statistics grouped by model. + BillingByModel: + description: Billing statistics grouped by model. type: object required: - model - stages - - usage + - billing properties: model: $ref: "#/components/schemas/ModelReference" @@ -2741,11 +3784,11 @@ components: type: integer description: Number of stages that used this model. example: 2 - usage: - $ref: "#/components/schemas/TokenUsage" + billing: + $ref: "#/components/schemas/BilledTokenCounts" - RunUsage: - description: Complete usage breakdown for a single run. + RunBilling: + description: Complete billing breakdown for a single run. type: object required: - stages @@ -2754,119 +3797,31 @@ components: properties: stages: type: array - description: Per-stage usage breakdown. + description: Per-stage billing breakdown. items: - $ref: "#/components/schemas/UsageStage" + $ref: "#/components/schemas/RunBillingStage" totals: - $ref: "#/components/schemas/UsageTotals" + $ref: "#/components/schemas/RunBillingTotals" by_model: type: array - description: Usage grouped by model. + description: Billing grouped by model. items: - $ref: "#/components/schemas/UsageByModel" + $ref: "#/components/schemas/BillingByModel" - AggregateUsage: - description: Aggregate token and cost usage across all runs since server start. + AggregateBilling: + description: Aggregate token counts and billed totals across all runs since server start. type: object required: - totals - by_model properties: totals: - $ref: "#/components/schemas/AggregateUsageTotals" + $ref: "#/components/schemas/AggregateBillingTotals" by_model: type: array - description: Usage grouped by model. + description: Billing grouped by model. items: - $ref: "#/components/schemas/UsageByModel" - - # ── Verification Schemas ───────────────────────────────────────────── - - VerificationResult: - description: > - Outcome of a verification control evaluation. - `skip`: evaluation was intentionally skipped (e.g., control is disabled). - `na`: control does not apply to this run (e.g., Python lint on a Rust-only change). - type: string - enum: - - pass - - fail - - skip - - na - - VerificationType: - description: The evaluation method used by a verification control. - type: string - enum: - - ai - - automated - - analysis - - ai-analysis - - RunVerificationControl: - description: A verification control result within a run. - type: object - required: - - name - - slug - - description - - type - - status - properties: - name: - type: string - description: Human-readable control name. - example: Motivation - slug: - type: string - description: URL-safe slug for linking to verification detail page. - example: motivation - description: - type: string - description: Short description of what the control verifies. - example: Origin of proposal identified - type: - $ref: "#/components/schemas/VerificationType" - status: - $ref: "#/components/schemas/VerificationResult" - - RunVerification: - description: Verification results for a category within a run. - type: object - required: - - name - - question - - status - - controls - properties: - name: - type: string - description: Category name. - example: Traceability - question: - type: string - description: The guiding question for this verification category. - example: Do we understand what this change is and why we're making it? - status: - $ref: "#/components/schemas/VerificationResult" - controls: - type: array - description: Individual control results within this category. - items: - $ref: "#/components/schemas/RunVerificationControl" - - SteerRequest: - description: Request body for sending inline steering guidance to a running agent. - type: object - required: - - guidance - properties: - location: - $ref: "#/components/schemas/CodeLocation" - guidance: - type: string - description: Guidance text for the agent. - example: Use a sliding window algorithm instead of fixed window. + $ref: "#/components/schemas/BillingByModel" PreviewUrlRequest: description: Request body for generating a preview URL from a sandbox port. @@ -2885,6 +3840,10 @@ components: minimum: 1 maximum: 86400 example: 3600 + signed: + type: boolean + description: When true, return a signed URL that does not require a preview token header. + default: false PreviewUrlResponse: description: Response containing the generated preview URL. @@ -2894,963 +3853,65 @@ components: properties: url: type: string - description: Time-limited preview URL. + description: Preview URL. example: "https://preview.example.com/sb-a1b2c3d4/3000" + token: + type: string + description: Preview token header value for unsigned preview URLs. + example: "preview-token-123" - # ── Workflow Schemas ───────────────────────────────────────────────── + SshAccessRequest: + description: Request body for creating SSH access for a sandbox-backed run. + type: object + required: + - ttl_minutes + properties: + ttl_minutes: + type: number + description: Time-to-live for the SSH command in minutes. + minimum: 1 + maximum: 1440 + example: 60 - WorkflowListItem: - description: Summary of a workflow shown in list views. + SshAccessResponse: + description: Response containing an SSH command for the sandbox. + type: object + required: + - command + properties: + command: + type: string + description: SSH command to connect to the sandbox. + example: ssh daytona@preview.example.com -p 2222 + + SandboxFileEntry: + description: A directory entry in a run sandbox. type: object required: - name - - slug - - filename + - is_dir properties: name: type: string - description: Human-readable workflow name. - example: Fix Build - slug: - type: string - description: URL-safe slug used in API paths. - example: fix_build - filename: - type: string - description: Graphviz graph filename. - example: fix_build.fabro - last_run: - $ref: "#/components/schemas/WorkflowLastRun" - schedule: - $ref: "#/components/schemas/WorkflowSchedule" - - WorkflowDetail: - description: Full detail of a workflow definition including graph and resolved settings. - type: object - required: - - name - - slug - - filename - - description - - settings - - graph - properties: - name: - type: string - description: Human-readable workflow name. - example: Fix Build - slug: - type: string - description: URL-safe slug used in API paths. - example: fix_build - filename: - type: string - description: Graphviz graph filename. - example: fix_build.fabro - description: - type: string - description: Prose description of what the workflow does. - example: Automatically diagnoses and fixes CI build failures. - settings: - $ref: "#/components/schemas/RunSettings" - graph: - type: string - description: Graphviz DOT language source defining the workflow graph. - example: "digraph fix_build { rankdir=LR; start -> diagnose -> fix -> validate }" - - # ── Verification Detail Schemas ────────────────────────────────────── - - VerificationMode: - description: Operational mode of a verification control. - type: string - enum: - - active - - evaluate - - disabled - - VerificationControl: - description: A verification control within a category, with performance metrics. - type: object - required: - - name - - slug - - description - - type - properties: - name: - type: string - description: Human-readable control name. - example: Motivation - slug: - type: string - description: URL-safe slug for API lookups. - example: motivation - description: - type: string - description: Short description of what the control verifies. - example: Origin of proposal identified - type: - $ref: "#/components/schemas/VerificationType" - mode: - $ref: "#/components/schemas/VerificationMode" - f1: - type: number - description: F1 score of the control's AI evaluator. - example: 0.87 - pass_at_1: - type: number - description: Pass@1 rate — probability of passing on the first evaluation. - example: 0.82 - evaluations: - type: array - description: Recent evaluation results (newest first). - items: - $ref: "#/components/schemas/VerificationResult" - - VerificationCriterion: - description: A group of related verification controls. - type: object - required: - - name - - question - - controls - properties: - name: - type: string - description: Criterion name. - example: Traceability - question: - type: string - description: Guiding question for the criterion. - example: Do we understand what this change is and why we're making it? - controls: - type: array - description: Verification controls in this criterion. - items: - $ref: "#/components/schemas/VerificationControl" - - VerificationControlListItem: - description: A verification control in a flat list view with criterion reference. - type: object - required: - - name - - slug - - description - - type - - criterion - properties: - name: - type: string - description: Human-readable control name. - example: Motivation - slug: - type: string - description: URL-safe slug for API lookups. - example: motivation - description: - type: string - description: Short description of what the control verifies. - example: Origin of proposal identified - type: - $ref: "#/components/schemas/VerificationType" - mode: - $ref: "#/components/schemas/VerificationMode" - f1: - type: number - description: F1 score of the control's AI evaluator. - example: 0.87 - pass_at_1: - type: number - description: Pass@1 rate. - example: 0.82 - criterion: - $ref: "#/components/schemas/CriterionReference" - - PaginatedVerificationControlList: - description: Paginated list of verification controls. - type: object - required: - - data - - meta - properties: - data: - type: array - items: - $ref: "#/components/schemas/VerificationControlListItem" - meta: - $ref: "#/components/schemas/PaginationMeta" - - SignoffStatus: - description: Status of a signoff. - type: string - enum: - - pass - - fail - - pending - - ControlReference: - description: Reference to a verification control by slug. - type: object - required: - - slug - properties: - slug: - type: string - description: Control slug. - example: motivation - - Signoff: - description: A stamp of approval for a (control, repository, commit SHA) tuple. - type: object - required: - - id - - control - - repository - - commit_sha - - status - - created_at - properties: - id: - type: string - description: Unique identifier (ULID). - example: 01JQVKX0001SIGNOFF00001 - control: - $ref: "#/components/schemas/ControlReference" - repository: - $ref: "#/components/schemas/RepositoryReference" - commit_sha: - type: string - description: Git commit SHA this signoff applies to. - example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 - status: - $ref: "#/components/schemas/SignoffStatus" - url: - type: string - nullable: true - description: Optional URL with more details about the signoff. - example: https://github.com/acme/api-server/actions/runs/12345 - description: - type: string - nullable: true - description: Optional human-readable description. - example: All tests passed on CI - source: - type: string - nullable: true - description: Freeform string identifying the logical origin of the signoff. - example: github-actions - created_at: - type: string - format: date-time - description: When the signoff was created. - example: "2025-09-15T12:00:00Z" - - CreateSignoffRequest: - description: Request body to create a new signoff. - type: object - required: - - control - - repository - - commit_sha - - status - properties: - control: - type: string - description: Control slug or ID. - example: motivation - repository: - type: string - description: Repository name. - example: api-server - commit_sha: - type: string - description: Git commit SHA. - example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 - status: - $ref: "#/components/schemas/SignoffStatus" - url: - type: string - description: Optional URL with more details. - example: https://github.com/acme/api-server/actions/runs/12345 - description: - type: string - description: Optional human-readable description. - example: All tests passed on CI - source: - type: string - description: Freeform string identifying the logical origin. - example: github-actions - - PaginatedSignoffList: - description: Paginated list of signoffs. - type: object - required: - - data - - meta - properties: - data: - type: array - items: - $ref: "#/components/schemas/Signoff" - meta: - $ref: "#/components/schemas/PaginationMeta" - - VerificationCriterionDetail: - description: Detail view of a verification criterion with inline controls and performance metrics. - type: object - required: - - name - - question - - controls - properties: - name: - type: string - description: Criterion name. - example: Traceability - question: - type: string - description: Guiding question for the criterion. - example: Do we understand what this change is and why we're making it? - controls: - type: array - description: Verification controls in this criterion with performance metrics. - items: - $ref: "#/components/schemas/VerificationControl" - - ControlInfo: - description: Core metadata about a verification control. - type: object - required: - - name - - slug - - description - - criterion - properties: - name: - type: string - description: Human-readable control name. - example: Motivation - slug: - type: string - description: URL-safe slug. - example: motivation - description: - type: string - description: Short description of what the control verifies. - example: Origin of proposal identified - type: - $ref: "#/components/schemas/VerificationType" - criterion: - $ref: "#/components/schemas/CriterionReference" - - ControlPerformance: - description: Performance metrics for a verification control. - type: object - required: - - mode - - evaluations - properties: - mode: - $ref: "#/components/schemas/VerificationMode" - f1: - type: number - description: F1 score of the control's AI evaluator. - example: 0.87 - pass_at_1: - type: number - description: Pass@1 rate. - example: 0.82 - evaluations: - type: array - description: Recent evaluation results (newest first). - items: - $ref: "#/components/schemas/VerificationResult" - - ControlDetail: - description: Detailed information about a verification control including checks and examples. - type: object - required: - - rationale - - checks - - pass_example - - fail_example - properties: - rationale: - type: string - description: Detailed prose description of the control's purpose and rationale. - example: Verifies that every change traces back to a clear origin. - checks: - type: array - description: Specific checks performed by this control. - items: - type: string - example: ["PR body explains why the change is needed", "Commit messages reference a ticket"] - pass_example: - type: string - description: Example scenario where the control passes. - example: PR links to JIRA-1234 and explains the user-facing pain point. - fail_example: - type: string - description: Example scenario where the control fails. - example: PR description is empty or says only 'fix stuff'. - - RecentControlResult: - description: Result of a recent verification control evaluation for a specific run. - type: object - required: - - run - - workflow - - result - - timestamp - properties: - run: - $ref: "#/components/schemas/RunReference" - workflow: - $ref: "#/components/schemas/WorkflowReference" - result: - $ref: "#/components/schemas/VerificationResult" - timestamp: - type: string - format: date-time - description: ISO 8601 timestamp of the evaluation. - example: "2025-09-15T12:00:00Z" - - SiblingControl: - description: Summary of a sibling verification control in the same category. - type: object - required: - - name - - slug - properties: - name: - type: string - description: Human-readable control name. - example: Specifications - slug: - type: string - description: URL-safe slug. - example: specifications - type: - $ref: "#/components/schemas/VerificationType" - mode: - $ref: "#/components/schemas/VerificationMode" - - VerificationDetailResponse: - description: Complete detail view of a verification control with performance, examples, and recent results. - type: object - required: - - control - - performance - - control_detail - - recent_results - - siblings - properties: - control: - $ref: "#/components/schemas/ControlInfo" - performance: - $ref: "#/components/schemas/ControlPerformance" - control_detail: - $ref: "#/components/schemas/ControlDetail" - recent_results: - type: array - description: Recent evaluation results across runs. - items: - $ref: "#/components/schemas/RecentControlResult" - siblings: - type: array - description: Other controls in the same category. - items: - $ref: "#/components/schemas/SiblingControl" - - # ── Retro Schemas ──────────────────────────────────────────────────── - - SmoothnessRating: - description: Qualitative assessment of how smoothly a run executed. - type: string - enum: - - effortless - - smooth - - bumpy - - struggled - - failed - - RetroStats: - description: Summary statistics for a run retrospective. - type: object - required: - - total_duration_ms - - total_retries - - files_touched - - stages_completed - - stages_failed - properties: - total_duration_ms: - type: integer - description: Total run duration in milliseconds. - example: 389000 - total_cost: - type: number - description: Total cost in USD. Absent when cost data is unavailable from the model provider. - example: 2.78 - total_retries: - type: integer - description: Total number of retries across all stages. - example: 0 - files_touched: - type: array - description: List of files modified during the run. - items: - type: string - example: ["src/middleware/rate-limit.ts", "src/routes/auth.ts"] - stages_completed: - type: integer - description: Number of stages that completed successfully. - example: 4 - stages_failed: - type: integer - description: Number of stages that failed. - example: 0 - - RetroListItem: - description: Summary of a run retrospective shown in list views. - type: object - required: - - run - - workflow - - timestamp - - stats - - friction_point_count - properties: - run: - $ref: "#/components/schemas/RunReference" - workflow: - $ref: "#/components/schemas/WorkflowReference" - timestamp: - type: string - format: date-time - description: Timestamp when the retro was generated. - example: "2026-02-28T14:32:00Z" - smoothness: - description: Absent when the retro has been generated from quantitative data but not yet enriched by the retro agent. - $ref: "#/components/schemas/SmoothnessRating" - stats: - $ref: "#/components/schemas/RetroStats" - friction_point_count: - type: integer - description: Number of friction points identified in the retro. - example: 0 - - RetroDetail: - description: Full retrospective analysis for a completed run. - type: object - required: - - run_id - - workflow_name - - goal - - timestamp - - stages - - stats - properties: - run_id: - type: string - description: Unique run identifier. - example: run-1 - workflow_name: - type: string - description: Workflow slug that produced this run. - example: implement - goal: - type: string - description: The goal that was set for the run. - example: Add rate limiting to auth endpoints - timestamp: - type: string - format: date-time - description: ISO 8601 timestamp when the retro was generated. - example: "2026-02-28T14:32:00Z" - smoothness: - description: Absent when the retro has been generated from quantitative data but not yet enriched by the retro agent. - $ref: "#/components/schemas/SmoothnessRating" - stages: - type: array - description: Per-stage retrospective data. - items: - $ref: "#/components/schemas/StageRetro" - stats: - $ref: "#/components/schemas/RetroStats" - intent: - type: string - description: What the agent intended to accomplish. - example: Implement token-bucket rate limiting on /auth/login and /auth/register. - outcome: - type: string - description: What actually happened during the run. - example: Rate limiter deployed with configurable per-IP limits. - learnings: - type: array - description: Insights discovered during the run. - items: - $ref: "#/components/schemas/Learning" - friction_points: - type: array - description: Points where the run encountered difficulty. - items: - $ref: "#/components/schemas/FrictionPoint" - open_items: - type: array - description: Follow-up items identified during the run. - items: - $ref: "#/components/schemas/OpenItem" - - StageRetro: - description: Retrospective data for a single stage in the workflow. - type: object - required: - - stage_id - - stage_label - - status - - duration_ms - - retries - - files_touched - properties: - stage_id: - type: string - description: Identifier of the stage in the workflow graph. - example: propose-changes - stage_label: - type: string - description: Human-readable label for the stage. - example: Propose Changes - status: - type: string - description: Final status of the stage. - example: completed - duration_ms: - type: integer - description: Stage duration in milliseconds. - example: 154000 - retries: - type: integer - description: Number of retries for this stage. - example: 0 - cost: - type: number - description: Cost in USD for this stage. Absent when cost data is unavailable. - example: 1.12 - notes: - type: string - description: Optional notes about this stage's execution. - failure_reason: - type: string - description: Reason the stage failed, if applicable. - files_touched: - type: array - description: Files modified during this stage. - items: - type: string - example: ["src/middleware/rate-limit.ts", "src/routes/auth.ts"] - - LearningCategory: - description: Category of a learning insight. - type: string - enum: - - repo - - code - - workflow - - tool - - Learning: - description: An insight discovered during the run. - type: object - required: - - category - - text - properties: - category: - $ref: "#/components/schemas/LearningCategory" - text: - type: string - description: Description of the learning. - example: Auth middleware chain order matters. - - FrictionKind: - description: Type of friction encountered during a run. - type: string - enum: - - retry - - timeout - - wrong_approach - - tool_failure - - ambiguity - - FrictionPoint: - description: A point where the run encountered difficulty. - type: object - required: - - kind - - description - properties: - kind: - $ref: "#/components/schemas/FrictionKind" - description: - type: string - description: Description of the friction encountered. - example: Nested route outlet types were incorrect on first 3 attempts. - stage_id: - type: string - description: Stage where the friction occurred, if applicable. - example: apply-changes - - OpenItemKind: - description: Type of open item identified during a run. - type: string - enum: - - tech_debt - - follow_up - - investigation - - test_gap - - OpenItem: - description: A follow-up item identified during the run. - type: object - required: - - kind - - description - properties: - kind: - $ref: "#/components/schemas/OpenItemKind" - description: - type: string - description: Description of the open item. - example: Add rate-limit headers (X-RateLimit-Remaining) to response. - - # ── Session Schemas ────────────────────────────────────────────────── - - SessionListItem: - description: Summary of a session shown in list views. - type: object - required: - - id - - title - - model - - last_message_preview - - created_at - - updated_at - properties: - id: - type: string - format: uuid - description: Unique session identifier. - example: a1b2c3d4-e5f6-7890-abcd-ef1234567890 - title: - type: string - description: Short title summarizing the session topic. - example: Add rate limiting to auth endpoints - model: - $ref: "#/components/schemas/ModelReference" - last_message_preview: - type: string - description: Truncated snippet of the most recent turn's content. - example: "Done. I've created the rate limiter and wired it up..." - created_at: - type: string - format: date-time - description: Timestamp when the session was created. - example: "2026-03-06T14:30:00Z" - updated_at: - type: string - format: date-time - description: Timestamp when the session was last updated (e.g. new turn added). - example: "2026-03-06T15:45:00Z" - - - SessionTurn: - description: A single turn in a session conversation — a user message, assistant response, or tool invocation block. - discriminator: - propertyName: kind - mapping: - user: "#/components/schemas/UserTurn" - assistant: "#/components/schemas/AssistantTurn" - tool: "#/components/schemas/ToolTurn" - oneOf: - - $ref: "#/components/schemas/UserTurn" - - $ref: "#/components/schemas/AssistantTurn" - - $ref: "#/components/schemas/ToolTurn" - - UserTurn: - description: A user message turn. - type: object - required: - - kind - - content - - created_at - properties: - kind: - type: string - enum: [user] - content: - type: string - description: Text content of the user message. - example: Add rate limiting to the auth endpoints using a sliding window approach with Redis. - created_at: - type: string - format: date-time - description: Timestamp when the turn was created. - example: "2026-02-28T10:00:00Z" - - AssistantTurn: - description: An assistant response turn. - type: object - required: - - kind - - content - - created_at - properties: - kind: - type: string - enum: [assistant] - content: - type: string - description: Text content of the assistant response. - example: I'll implement sliding window rate limiting using Redis. - created_at: - type: string - format: date-time - description: Timestamp when the turn was created. - example: "2026-02-28T10:01:00Z" - - ToolTurn: - description: A tool invocation turn. - type: object - required: - - kind - - tools - - created_at - properties: - kind: - type: string - enum: [tool] - tools: - type: array - description: Tool invocations for this turn. - items: - $ref: "#/components/schemas/ToolUse" - created_at: - type: string - format: date-time - description: Timestamp when the turn was created. - example: "2026-02-28T10:01:05Z" - - SessionDetail: - description: Full session record including metadata and the complete conversation history. - type: object - required: - - id - - title - - model - - created_at - - updated_at - - turns - properties: - id: - type: string - format: uuid - description: Unique session identifier. - example: a1b2c3d4-e5f6-7890-abcd-ef1234567890 - title: - type: string - description: Short title summarizing the session topic. - example: Add rate limiting to auth endpoints - model: - $ref: "#/components/schemas/ModelReference" - created_at: - type: string - format: date-time - description: Timestamp when the session was created. - example: "2026-03-06T14:30:00Z" - updated_at: - type: string - format: date-time - description: Timestamp when the session was last updated (e.g. new turn added). - example: "2026-03-06T15:45:00Z" - turns: - type: array - description: Ordered list of conversation turns. - items: - $ref: "#/components/schemas/SessionTurn" - - CreateSessionRequest: - description: Request body for starting a new session. - type: object - required: - - content - properties: - content: - type: string - description: The initial user message to start the session. - example: Add rate limiting to the auth endpoints using a sliding window approach with Redis, 10 requests per minute per IP. - model: - type: string - description: LLM model to use. If omitted, the server default is used. - example: claude-opus-4-6 - system: - type: string - description: System prompt for the session. - example: You are a helpful coding assistant. - - CreateSessionResponse: - description: Response returned after successfully creating a session. - type: object - required: - - id - - title - - model - - created_at - - updated_at - properties: - id: - type: string - format: uuid - description: Unique identifier for the newly created session. - example: a1b2c3d4-e5f6-7890-abcd-ef1234567890 - title: - type: string - description: Server-generated title for the session. - example: Add rate limiting to auth endpoints - model: - $ref: "#/components/schemas/ModelReference" - created_at: - type: string - format: date-time - description: Timestamp when the session was created. - example: "2026-03-06T16:00:00Z" - updated_at: - type: string - format: date-time - description: Timestamp when the session was last updated (equal to created_at at creation time). - example: "2026-03-06T16:00:00Z" - - SendMessageRequest: - description: Request body for sending a follow-up message in an existing session. - type: object - required: - - content - properties: - content: - type: string - description: The user message text. - example: Can you also add a bypass for internal health-check IPs? - - SendMessageResponse: - description: Acknowledgement that the message was accepted for asynchronous processing. - type: object - required: - - accepted - properties: - accepted: + description: Basename of the entry. + is_dir: type: boolean - description: Whether the message was accepted for processing. - example: true + description: Whether the entry is a directory. + size: + type: integer + format: int64 + description: File size in bytes when known. + + SandboxFileListResponse: + description: Non-paginated list of sandbox directory entries. + type: object + required: + - data + properties: + data: + type: array + items: + $ref: "#/components/schemas/SandboxFileEntry" # ── Insights Schemas ───────────────────────────────────────────────── @@ -3983,507 +4044,235 @@ components: # ── Settings Schemas ───────────────────────────────────────────────── - RunSettings: - description: Structured run settings mirroring FabroSettings. + ServerSettings: + description: | + Non-secret view of the server's effective v2 settings. + + Wire shape mirrors `fabro_types::settings::SettingsFile` with the + secret-bearing subtrees dropped before serialization: + + - `server.listen.*` (bind address, TLS key material) + - `server.auth.api.{jwt,mtls}` internals + - `server.artifacts.s3` / `server.slatedb.s3` credentials + - `server.integrations.github.webhooks`, Slack/Discord/Teams tokens + - Every `{{ env.NAME }}` InterpString is serialized in its unresolved + template form, never the resolved secret value. + + The top-level object keys follow the v2 schema: `_version`, `project`, + `workflow`, `run`, `cli`, `server`, `features`. + + See `lib/crates/fabro-types/src/settings/tree.rs` for the full type. + type: object + additionalProperties: true + + RunSettings: + description: | + The merged, persisted v2 `[run]` subtree for a specific run, serialized + as the wrapping `SettingsFile` shape (so `settings.run.*` holds the run + config). Matches `fabro_types::settings::SettingsFile` minus secret + subtrees, identical to ServerSettings' redaction rules. + + See `lib/crates/fabro-types/src/settings/run.rs` for the full type. + type: object + additionalProperties: true + + SystemInfoResponse: + description: Runtime information for the active Fabro server process. type: object - required: - - version - - graph properties: version: + type: string + description: Server version string. + git_sha: + type: string + nullable: true + description: Build git SHA when available. + build_date: + type: string + nullable: true + description: Build date when available. + os: + type: string + description: Target operating system. + arch: + type: string + description: Target CPU architecture. + storage_engine: + type: string + description: Backing run storage engine. + storage_dir: + type: string + description: Configured storage directory. + uptime_secs: type: integer - description: Settings schema version. - example: 1 - goal: + format: int64 + description: Seconds since this server process started. + runs: + $ref: "#/components/schemas/SystemRunCounts" + sandbox_provider: type: string - description: Goal description for the run. - example: Diagnose and fix CI build failures - graph: - type: string - description: Graphviz graph filename. - example: fix_build.fabro - work_dir: - type: string - description: Working directory for the run. - llm: - $ref: "#/components/schemas/LlmSettings" - setup: - $ref: "#/components/schemas/SetupSettings" - sandbox: - $ref: "#/components/schemas/SandboxSettings" - vars: - type: object - additionalProperties: - type: string - description: Variable map for template expansion. - hooks: + description: Effective sandbox provider for launched runs. + + SystemRunCounts: + description: Counts of known runs in the active server process. + type: object + properties: + total: + type: integer + format: int64 + description: Total runs tracked by the server process. + active: + type: integer + format: int64 + description: Runs currently queued or executing. + + DiskUsageResponse: + description: Disk usage summary for server-managed data. + type: object + properties: + summary: type: array items: - $ref: "#/components/schemas/HookDefinition" - - LlmSettings: - description: LLM provider and model settings. - type: object - properties: - model: - type: string - description: Model identifier. - example: claude-sonnet - provider: - type: string - description: Provider name. - example: anthropic - fallbacks: - type: object - additionalProperties: - type: array - items: - type: string - description: Provider fallback chains. - - SetupSettings: - description: Setup commands run before the workflow. - type: object - required: - - commands - properties: - commands: + $ref: "#/components/schemas/DiskUsageSummaryRow" + total_size_bytes: + type: integer + format: int64 + description: Total size of all tracked system data. + total_reclaimable_bytes: + type: integer + format: int64 + description: Total bytes reclaimable by deleting inactive runs and logs. + runs: type: array + nullable: true + description: Per-run usage rows when verbose output is requested. items: - type: string - description: Shell commands to execute. - timeout_ms: - type: integer - description: Timeout per command in milliseconds. + $ref: "#/components/schemas/DiskUsageRunRow" - SandboxSettings: - description: Sandbox execution environment settings. + DiskUsageSummaryRow: + description: One top-level disk usage category. type: object properties: - provider: + type: type: string - description: Sandbox provider name. - example: daytona - preserve: - type: boolean - description: Whether to preserve the sandbox after the run. - devcontainer: - type: boolean - description: Whether to use a devcontainer for the sandbox. - daytona: - $ref: "#/components/schemas/DaytonaSettings" - local: - $ref: "#/components/schemas/LocalSandboxSettings" - env: - type: object - additionalProperties: - type: string - description: Environment variables injected into the sandbox. - - LocalSandboxSettings: - description: Local sandbox settings. - type: object - properties: - worktree_mode: - type: string - description: Git worktree mode for local sandbox. - enum: [always, clean, dirty, never] - default: clean - - DaytonaSettings: - description: Daytona-specific sandbox settings. - type: object - properties: - auto_stop_interval: + description: Category name, such as runs or logs. + count: type: integer - description: Auto-stop interval in seconds. + format: int64 + description: Number of items in the category. + active: + type: integer + format: int64 + nullable: true + description: Number of active items when applicable. + size_bytes: + type: integer + format: int64 + description: Total bytes used by the category. + reclaimable_bytes: + type: integer + format: int64 + nullable: true + description: Bytes reclaimable by pruning the category. + + DiskUsageRunRow: + description: Per-run disk usage information. + type: object + properties: + run_id: + type: string + description: Run identifier. + workflow_name: + type: string + description: Workflow display name. + status: + type: string + description: Current run status. + start_time: + type: string + description: Human-readable start timestamp. + size_bytes: + type: integer + format: int64 + description: Size used by the run scratch directory. + reclaimable: + type: boolean + description: Whether the run is inactive and reclaimable. + + PruneRunsRequest: + description: Filters for system run pruning. + type: object + properties: + dry_run: + type: boolean + description: Preview matching runs without deleting them. + default: true + before: + type: string + description: Include runs started before this YYYY-MM-DD prefix. + workflow: + type: string + description: Filter by workflow name substring. labels: type: object additionalProperties: type: string - description: Labels applied to the sandbox. - snapshot: - $ref: "#/components/schemas/DaytonaSnapshotSettings" - network: - description: "Network access mode: \"block\", \"allow_all\", or {\"allow_list\": [...]}." - oneOf: - - type: string - enum: - - block - - allow_all - - type: object - required: - - allow_list - properties: - allow_list: - type: array - items: - type: string - description: CIDR allowlist for network access. - skip_clone: + description: Label filters applied with AND semantics. + orphans: type: boolean + description: Include orphan run directories without run metadata. default: false - description: Skip git repo detection and cloning during initialization. + older_than: + type: string + description: Include only runs older than this duration, such as 24h or 7d. - DaytonaSnapshotSettings: - description: Snapshot configuration for Daytona sandboxes. + PruneRunsResponse: + description: Result of a prune preview or deletion. type: object - required: - - name properties: - name: - type: string - description: Snapshot name. - cpu: - type: integer - description: CPU cores. - memory: - type: integer - description: Memory in GB. - disk: - type: integer - description: Disk in GB. - dockerfile: - type: string - description: Dockerfile content for snapshot creation. - - HookDefinition: - description: | - A single hook definition. The type discriminator and variant fields are flattened into this object. - - Field-to-type mapping: - - `command`: requires `command` - - `http`: requires `url`; optional `headers`, `allowed_env_vars`, `tls` - - `prompt`: requires `prompt`; optional `model` - - `agent`: requires `prompt`; optional `model`, `max_tool_rounds` - - Top-level `command` without `type` is shorthand for type=command. - type: object - required: - - event - properties: - name: - type: string - description: Human-readable hook name. - event: - type: string - description: Event that triggers this hook. - enum: - - run_start - - run_complete - - stage_start - - stage_complete - command: - type: string - description: Shell command (shorthand for type=command). - type: - type: string - description: Hook execution type. - enum: - - command - - http - - prompt - - agent - url: - type: string - description: URL for HTTP hooks. - headers: - type: object - additionalProperties: - type: string - description: Headers for HTTP hooks. - allowed_env_vars: - type: array - items: - type: string - description: Environment variables allowed in HTTP hook headers. - tls: - type: string - description: TLS verification mode for HTTP hooks. - enum: - - verify - - no_verify - - "off" - prompt: - type: string - description: Prompt text for prompt/agent hooks. - model: - type: string - description: Model for prompt/agent hooks. - max_tool_rounds: - type: integer - description: Max tool rounds for agent hooks. - matcher: - type: string - description: Regex matched against node_id or handler_type. - blocking: + dry_run: type: boolean - description: Whether this hook blocks execution. - timeout_ms: + description: Whether this response is a dry-run preview. + runs: + type: array + nullable: true + description: Matched runs when dry-run is enabled. + items: + $ref: "#/components/schemas/PruneRunEntry" + total_count: type: integer - description: Timeout in milliseconds. - sandbox: - type: boolean - description: Whether hook runs in sandbox. - - ServerSettings: - description: Structured server settings mirroring FabroSettings. - type: object - properties: - storage_dir: - type: string - description: Storage directory path. - max_concurrent_runs: + format: int64 + description: Count of runs matching the prune filters. + total_size_bytes: type: integer - description: Maximum concurrent runs. - web: - $ref: "#/components/schemas/WebSettings" - api: - $ref: "#/components/schemas/ApiSettings" - git: - $ref: "#/components/schemas/GitSettings" - features: - $ref: "#/components/schemas/Features" - log: - $ref: "#/components/schemas/LogSettings" - work_dir: - type: string - description: Default working directory. - llm: - $ref: "#/components/schemas/LlmSettings" - setup: - $ref: "#/components/schemas/SetupSettings" - sandbox: - $ref: "#/components/schemas/SandboxSettings" - vars: - type: object - additionalProperties: - type: string - description: Default variable map. - checkpoint: - $ref: "#/components/schemas/CheckpointSettings" - pull_request: - $ref: "#/components/schemas/PullRequestSettings" - hooks: - type: array - items: - $ref: "#/components/schemas/HookDefinition" - assets: - $ref: "#/components/schemas/AssetsSettings" - mcp_servers: - type: object - additionalProperties: - $ref: "#/components/schemas/McpServerEntry" - description: Default MCP server configurations. - github: - $ref: "#/components/schemas/GitHubSettings" - - GitHubSettings: - description: GitHub App token injection configuration. - type: object - properties: - permissions: - type: object - additionalProperties: - type: string - description: GitHub API permissions to request (e.g. contents = write). - - McpServerEntry: - description: MCP server connection entry. - type: object - properties: - type: - type: string - description: Transport type (stdio or http). - command: - type: array - items: - type: string - description: Command and arguments for stdio transport. - env: - type: object - additionalProperties: - type: string - description: Environment variables for stdio transport. - url: - type: string - description: URL for http transport. - headers: - type: object - additionalProperties: - type: string - description: HTTP headers for http transport. - startup_timeout_secs: + format: int64 + description: Total bytes of the matching runs. + deleted_count: type: integer - description: Startup timeout in seconds. - tool_timeout_secs: + format: int64 + description: Number of runs deleted when dry-run is false. + freed_bytes: type: integer - description: Tool call timeout in seconds. + format: int64 + description: Estimated freed bytes when deletion occurs. - AssetsSettings: - description: Asset collection configuration. + PruneRunEntry: + description: One run matched by a prune preview. type: object properties: - include: - type: array - items: - type: string - description: Glob patterns for files to collect as run assets. - - LogSettings: - description: Logging configuration. - type: object - properties: - level: + run_id: type: string - description: Log level (e.g. trace, debug, info). - - CheckpointSettings: - description: Checkpoint configuration for file exclusion. - type: object - properties: - exclude_globs: - type: array - items: - type: string - description: Glob patterns to exclude from checkpoints. - - PullRequestSettings: - description: Pull request creation configuration. - type: object - properties: - enabled: - type: boolean - description: Whether to create a pull request after a successful run. - draft: - type: boolean - description: Whether to create the pull request as a draft. - auto_merge: - type: boolean - description: Whether to enable GitHub auto-merge on the created PR. Implies draft = false. - merge_strategy: + description: Run identifier. + dir_name: type: string - enum: [squash, merge, rebase] - description: Merge strategy for auto-merge. - - WebSettings: - description: Web UI configuration. - type: object - properties: - url: + description: Scratch directory name for the run. + workflow_name: type: string - description: Web UI URL. - auth: - $ref: "#/components/schemas/AuthSettings" - - AuthSettings: - description: Authentication configuration. - type: object - properties: - provider: - type: string - description: Auth provider. - enum: - - github - - insecure_disabled - allowed_usernames: - type: array - items: - type: string - description: Allowed usernames. - - ApiSettings: - description: API server configuration. - type: object - properties: - base_url: - type: string - description: API base URL. - authentication_strategies: - type: array - items: - type: string - enum: - - jwt - - mtls - description: Authentication strategies. - tls: - $ref: "#/components/schemas/TlsSettings" - - TlsSettings: - description: TLS certificate configuration. - type: object - required: - - cert - - key - - ca - properties: - cert: - type: string - description: Certificate file path. - key: - type: string - description: Key file path. - ca: - type: string - description: CA certificate file path. - - GitSettings: - description: Git provider configuration. - type: object - properties: - provider: - type: string - description: Git provider. - enum: - - github - app_id: - type: string - description: GitHub App ID. - client_id: - type: string - description: GitHub App Client ID. - slug: - type: string - description: GitHub App slug. - author: - $ref: "#/components/schemas/GitAuthorSettings" - webhooks: - $ref: "#/components/schemas/WebhookSettings" - - GitAuthorSettings: - description: Git commit author configuration. - type: object - properties: - name: - type: string - description: Author name for commits. - email: - type: string - description: Author email for commits. - - WebhookSettings: - description: Webhook delivery configuration. - type: object - required: - - strategy - properties: - strategy: - type: string - description: Webhook delivery strategy. - enum: - - tailscale_funnel - - Features: - description: Feature flags. - type: object - properties: - session_sandboxes: - type: boolean - description: Enable session sandboxes. - retros: - type: boolean - description: "Experimental: enable automatic retro generation after workflow runs." + description: Workflow display name. + size_bytes: + type: integer + format: int64 + description: Bytes used by the run scratch directory. # ── Discovery Schemas ──────────────────────────────────────────────── @@ -4522,11 +4311,199 @@ components: type: object required: - status + - version properties: status: type: string description: Health status indicator. example: ok + version: + type: string + description: Server version string. + example: "0.176.2" + + SecretType: + description: The way a secret is consumed by the sandbox. + type: string + enum: + - environment + - file + + CreateSecretRequest: + description: Request to store or update a secret. + type: object + required: + - name + - value + - type + properties: + name: + type: string + description: Secret name or destination path for file secrets. + value: + type: string + description: The secret value to store. + type: + $ref: "#/components/schemas/SecretType" + description: + type: string + description: Optional operator-facing description of the secret. + + DeleteSecretRequest: + description: Request to delete a secret by name. + type: object + required: + - name + properties: + name: + type: string + description: Secret name or destination path for file secrets. + + SecretMetadata: + description: Metadata for a stored secret (value is never exposed). + type: object + required: + - name + - type + - created_at + - updated_at + properties: + name: + type: string + description: Secret key name or destination path. + example: ANTHROPIC_API_KEY + type: + $ref: "#/components/schemas/SecretType" + description: + type: string + description: Optional operator-facing description of the secret. + created_at: + type: string + format: date-time + description: When the secret was first stored. + updated_at: + type: string + format: date-time + description: When the secret was last updated. + + SecretListResponse: + description: List of stored secret metadata. + type: object + required: + - data + properties: + data: + type: array + items: + $ref: "#/components/schemas/SecretMetadata" + + RepoCheckResponse: + description: Repository access check result. + type: object + required: + - owner + - name + - accessible + properties: + owner: + type: string + description: GitHub repository owner. + example: acme-corp + name: + type: string + description: GitHub repository name. + example: my-app + accessible: + type: boolean + description: Whether the server has read-write access to this repository. + default_branch: + type: string + nullable: true + description: Default branch name, if accessible. + example: main + private: + type: boolean + nullable: true + description: Whether the repository is private, if accessible. + permissions: + type: object + nullable: true + description: Detected permission levels. + properties: + pull: + type: boolean + push: + type: boolean + admin: + type: boolean + install_url: + type: string + nullable: true + description: GitHub App installation URL when the repo is not yet accessible. + + DiagnosticsReport: + description: Server health diagnostics report. + type: object + required: + - version + - sections + properties: + version: + type: string + description: Server version. + sections: + type: array + items: + $ref: "#/components/schemas/DiagnosticsSection" + + DiagnosticsSection: + type: object + required: + - title + - checks + properties: + title: + type: string + checks: + type: array + items: + $ref: "#/components/schemas/DiagnosticsCheck" + + DiagnosticsCheck: + type: object + required: + - name + - status + - summary + properties: + name: + type: string + status: + type: string + enum: + - pass + - warning + - error + summary: + type: string + details: + type: array + items: + $ref: "#/components/schemas/DiagnosticsDetail" + remediation: + type: string + nullable: true + + DiagnosticsDetail: + type: object + required: + - text + - warn + properties: + text: + type: string + warn: + type: boolean UserResponse: description: Information about the authenticated user. diff --git a/docs/api-reference/overview.mdx b/docs/api-reference/overview.mdx index 2f1db6a2b..45d0b12cb 100644 --- a/docs/api-reference/overview.mdx +++ b/docs/api-reference/overview.mdx @@ -17,20 +17,20 @@ The versioned API is served by `fabro server start`, which defaults to: http://localhost:3000/api/v1 ``` -The base URL is configurable via `server.toml`: +The base URL is configurable via `settings.toml`: -```toml title="server.toml" -[api] -base_url = "https://fabro.example.com/api/v1" +```toml title="settings.toml" +[server.api] +url = "https://fabro.example.com/api/v1" ``` ## Authentication -The API supports two authentication strategies, configured in `server.toml`: +The API supports two authentication strategies, configured in `settings.toml`: -```toml title="server.toml" -[api] -authentication_strategies = ["jwt"] +```toml title="settings.toml" +[server.auth.api.jwt] +enabled = true ``` ### JWT (Bearer Token) @@ -56,13 +56,17 @@ Set the verification key via the `FABRO_JWT_PUBLIC_KEY` environment variable (PE ### mTLS (Mutual TLS) -With mTLS, the client authenticates using a TLS client certificate. Configure both the strategy and TLS paths: +With mTLS, the client authenticates using a TLS client certificate. Configure the strategy and shared listener TLS: -```toml title="server.toml" -[api] -authentication_strategies = ["mtls"] +```toml title="settings.toml" +[server.auth.api.mtls] +enabled = true -[api.tls] +[server.listen] +type = "tcp" +address = "0.0.0.0:3000" + +[server.listen.tls] cert = "~/.fabro/certs/server.crt" key = "~/.fabro/certs/server.key" ca = "~/.fabro/certs/ca.crt" @@ -72,11 +76,14 @@ The Common Name (CN) from the client certificate identifies the user. ### Multiple Strategies -You can configure both strategies. They are tried in order — the first successful match wins: +You can enable both strategies. They are tried in order — the first successful match wins: -```toml title="server.toml" -[api] -authentication_strategies = ["jwt", "mtls"] +```toml title="settings.toml" +[server.auth.api.jwt] +enabled = true + +[server.auth.api.mtls] +enabled = true ``` ## Errors diff --git a/docs/brainstorms/2026-04-08-eliminate-scratch-artifact-cache-requirements.md b/docs/brainstorms/2026-04-08-eliminate-scratch-artifact-cache-requirements.md new file mode 100644 index 000000000..6e9aea286 --- /dev/null +++ b/docs/brainstorms/2026-04-08-eliminate-scratch-artifact-cache-requirements.md @@ -0,0 +1,69 @@ +--- +date: 2026-04-08 +topic: eliminate-scratch-artifact-cache +--- + +# Eliminate Scratch Artifact Cache + +## Problem Frame + +The artifact collection pipeline uses `scratch/cache/artifacts/files/` as a staging area for CLI uploads: files are downloaded from the sandbox to local disk, hashed, then uploaded to the server's `ArtifactStore`. Now that `ArtifactStore` (backed by object store) is the durable source of truth, this persistent local cache is unnecessary overhead. It adds disk usage and complicates the scratch directory contract. It also obscures a separate bug: server-managed runs currently start `ArtifactLifecycle` with no artifact sink configured, so collected artifacts are discarded. Eliminating the cache alone does not fix that bug; server-managed runs also need a direct `ArtifactStore` write path. + +## Requirements + +**Pipeline: Replace persistent cache with transient local staging** + +- R1. `collect_artifacts` must download each file from the sandbox into a transient per-attempt local directory tree that preserves each artifact's relative path, compute MD5/SHA256 from those transient local files, and keep that transient tree available until the configured artifact sink has finished consuming it, including upload retries. Delete the transient tree immediately after direct store writes or HTTP uploads finish. No persistent `cache/artifacts/` directory. +- R2. `CapturedArtifactInfo` (path, mime, hashes, bytes) must still be produced for each collected file and emitted via `ArtifactCaptured` events. + +**Store: Write artifacts directly to ArtifactStore** + +- R3. When running server-side, `ArtifactLifecycle` writes collected artifacts directly to the server's `ArtifactStore`, fixing the current gap where server-managed runs configure no artifact sink and silently discard artifacts. +- R4. When running via CLI, the existing HTTP upload path (`HttpArtifactUploader`) continues to work. Internal uploader abstractions may change as needed, but the CLI must continue uploading artifacts to the server rather than writing directly to `ArtifactStore`. +- R5. `ArtifactLifecycle` must be configured with exactly one artifact sink for artifact-enabled runs: either a direct `ArtifactStore`-backed sink for server-managed runs or an HTTP uploader-backed sink for CLI runs. It must also hold a `RunId` for direct `ArtifactStore::put` calls. The internal representation of that sink (enum, unified trait, or refined existing trait) is deferred to planning. + +**Scratch cleanup** + +- R6. Remove `artifact_cache_dir()`, `artifact_files_dir()`, and `artifact_stage_dir()` from `RunScratch` in `fabro-config/src/storage.rs`. +- R7. Remove the `create_dir_all(self.artifact_files_dir())` from `RunScratch::create()`. +- R8. Update `docs/reference/run-directory.mdx` and `docs-internal/run-directory-keys.md` to remove all `cache/artifacts/` entries (including `cache/artifacts/files/`). +- R10. Update integration tests in `fabro-workflow/tests/it/integration.rs` and `daytona_integration.rs` that assert on `artifact_stage_dir` paths to verify artifacts via `ArtifactStore` or uploader behavior instead of local filesystem paths. + +**CLI output** + +- R9. The post-run "=== Artifacts ===" output must stop printing local scratch paths. It should list durable artifact identifiers derived from stored metadata, at minimum `node_slug`, `retry`, and `relative_path`, and reference `fabro artifact cp` for retrieval. +- R11. Removing `artifact_stage_dir()` must not change durable per-stage/per-attempt grouping. Artifacts must still be addressable by `StageId`, and CLI/server artifact surfaces must continue exposing `node_slug`, `retry`, and `relative_path` from store-backed metadata rather than reconstructing local scratch paths. + +## Success Criteria + +- Server-managed workflow runs produce artifacts in `ArtifactStore` (previously they did not). +- No `cache/artifacts/` directory is created or written to during any workflow run. +- `fabro artifact list` and `fabro artifact cp` continue to work unchanged. +- The post-run artifact summary prints logical artifact identifiers (`node_slug`, `retry`, `relative_path`) instead of local scratch paths. +- `ArtifactCaptured` events still contain correct hashes and byte counts. + +## Scope Boundaries + +- No changes to `fabro artifact list` or `fabro artifact cp` commands. +- No changes to the `Sandbox` trait (no streaming download API). +- No changes to the HTTP artifact upload protocol between CLI and server. +- `runtime/blobs/` (context value materialization) is a separate concern, unchanged. + +## Key Decisions + +- **Transient local staging, not persistent scratch cache**: The `Sandbox` trait only has `download_file_to_local`. Rather than adding a streaming API to all sandbox impls, use transient local files outside `RunScratch`, but keep them alive until the configured sink has finished consuming them. +- **Server writes to ArtifactStore directly**: The server already owns the `ArtifactStore` instance. Server-managed runs should use that directly instead of relying on an uploader being present. +- **CLI keeps HTTP upload path**: The CLI continues uploading artifacts to the server over HTTP. The internal uploader interface may change, but the wire protocol stays the same. Smaller blast radius than giving the CLI its own local `ArtifactStore`. + +## Outstanding Questions + +### Deferred to Planning + +- [Affects R5][Technical] What internal representation should `ArtifactLifecycle` use for the exactly-one artifact sink? Options: an enum with server/CLI variants, a new unified trait, or a refactor of the existing uploader trait. +- [Affects R1][Technical] Should `collect_artifacts` return transient local handles/paths alongside `CapturedArtifactInfo`, or should store/upload happen inside the collection step while the transient files are definitely still present? +- [Affects R1][Technical] Should transient staging use `tempfile::TempDir` (auto-cleanup on drop) or manual `std::fs::remove_dir_all`? The former is more robust against panics. +- [Affects R9][Needs research] What does the current CLI output look like for artifacts, and what is the best replacement format? Check `lib/crates/fabro-cli/src/commands/run/output.rs`. + +## Next Steps + +-> `/ce:plan` for structured implementation planning diff --git a/docs/brainstorms/2026-04-08-settings-toml-redesign-requirements.md b/docs/brainstorms/2026-04-08-settings-toml-redesign-requirements.md new file mode 100644 index 000000000..412981a1e --- /dev/null +++ b/docs/brainstorms/2026-04-08-settings-toml-redesign-requirements.md @@ -0,0 +1,544 @@ +--- +date: 2026-04-08 +topic: settings-toml-redesign +--- + +# Settings TOML Redesign + +## Problem Frame + +Fabro has three layered TOML config files: + +- `~/.fabro/settings.toml` for machine defaults +- `fabro.toml` for project defaults +- `workflow.toml` for workflow-local defaults + +All three layer into one unified settings object. In same-host setups, the CLI and server may both read `~/.fabro/settings.toml`. In split-host setups, the CLI host and server host each read their own local `settings.toml` and consume only the sections relevant to that process. + +The current config shape grew organically. It now has naming drift, mixed ownership boundaries, uneven merge semantics, and several top-level sections that no longer reflect a clean mental model. Fabro is still greenfield with no deployed compatibility burden, so this is the right time to make a hard cut and establish a coherent, future-proof config language. + +The new design must optimize for: + +- a small, elegant top-level structure +- coherent ownership boundaries between run, CLI, server, project, and workflow concerns +- paste-anywhere ergonomics across the three config files +- explicit and predictable layering semantics +- future provider growth without provider-specific sprawl in the core model + +## Requirements + +**Config language and layering** + +- R1. `settings.toml`, `fabro.toml`, and `workflow.toml` must share the same schema. Files differ by precedence only, not by allowed sections. +- R2. Any config section may appear in any Fabro TOML file. Consumers must ignore sections they do not use. +- R3. The top-level schema must be strictly namespaced. The only top-level config domains are `[project]`, `[workflow]`, `[run]`, `[cli]`, `[server]`, and `[features]`, plus reserved underscore-prefixed meta keys. +- R4. The schema version key must be `_version`, not `version`. +- R5. Underscore-prefixed keys are reserved only at the top level for config-language metadata. Nested underscore keys are not part of the language. +- R6. The config language must not add a general unset mechanism in this pass. +- R7. Unknown config keys against the full union schema must be hard errors. This is schema validation, not consumer-specific validation. +- R8. Duplicate keys and duplicate hook `id` values within the same file must be hard errors. + +**Object model and namespace boundaries** + +- R9. `[workflow]` and `[run]` must be sibling top-level sections. Do not nest `[workflow.run]`. +- R10. `[workflow]` is descriptive for now. It must support first-class fields such as `name`, `description`, optional `graph`, and `metadata`. Structured workflow inputs are deferred. +- R11. `workflow.toml` remains the canonical workflow config filename. The default graph file remains `workflow.fabro`, with optional `[workflow].graph` override. +- R12. `[project]` must be a first-class project object with fields such as `name`, `description`, `directory`, and `metadata`. +- R13. `project.directory` replaces the old Fabro project root concept and means the Fabro-managed project directory inside the repo, defaulting to `fabro/`. +- R14. Workflow discovery remains conventional: `/workflows//workflow.toml`. Do not add a separate configurable workflows directory. +- R15. `[run]` is the shared execution domain. It may appear in all three files and layer normally. +- R16. `[cli]` and `[server]` are owner-first process domains. Settings belong to the process that reads them, not to whether the host is “local” or “remote.” For trust-boundary reasons, CLI and server processes consume their owner-specific sections only from the local `~/.fabro/settings.toml` plus explicit process-local overrides. Same-shaped `cli.*` and `server.*` stanzas in `fabro.toml` and `workflow.toml` remain schema-valid but inert for those processes. +- R17. `[features]` is a reserved cross-cutting namespace for Fabro capability flags only. It must have a high admission bar and must not become a junk drawer. +- R18. Logging is process-owned. Use `[cli.logging]` and `[server.logging]`; do not keep a shared logging section. + +**Run model** + +- R19. `[run]` must keep a small direct manifest surface for cross-cutting run fields such as `goal` and `working_dir`. +- R20. `working_dir` replaces `work_dir`. +- R21. `metadata` replaces Fabro-owned `labels` and exists on `project`, `workflow`, and `run` as flat string-to-string maps. +- R22. `run.inputs` replaces `vars`. `run.inputs` must accept TOML scalar values. `metadata` remains string-to-string. `run.inputs` intentionally replaces the full inherited map rather than merging by key. +- R23. `[run.model]` is the default model selection surface for LLM-backed workflow stages. `[run.agent]` is only for agent-specific settings. +- R24. `[run.agent]` owns agent-only knobs such as `permissions` and `mcps`. `[run.sandbox]` owns the sandbox selection and execution-environment surface, including `provider`, shared sandbox knobs, `env`, and provider-specific nested tables. +- R25. `run.agent.permissions` must remain a simple enum string, not an object. +- R26. `[run.git]` and `[run.scm]` must remain separate concepts. `git` is local Git behavior such as commit author; `scm` is remote host/provider behavior. +- R27. `[run.pull_request]` remains the provider-neutral run surface for PR behavior. +- R28. `[run.prepare]` is the run preparation surface and replaces the old `setup` naming. +- R29. `run.prepare` must be an ordered list of steps at `[[run.prepare.steps]]`. +- R30. `run.prepare.steps` replaces as a whole ordered list across layers. +- R31. `[run.execution]` groups run-conduct knobs such as `mode`, `approval`, and `retros`. In the first pass, `mode` is `normal | dry_run`, `approval` is `prompt | auto`, and `retros` is a positive-form boolean. Do not keep negated or ambiguous booleans like `no_retro`. +- R32. `[run.checkpoint]` remains its own run domain. `[[run.hooks]]` is the ordered run-hook surface for run lifecycle automation. +- R33. `[run.artifacts]` defines what run artifacts are collected. Server-side artifact storage is separate. +- R34. `[run.notifications.]` is a keyed set of named notification routes. Notification routes merge by field across layers and support `enabled = false`. +- R35. `[run.interviews]` is a single optional external/default interview delivery surface. HTTP/API answering is always available and is not modeled as an interview provider. +- R36. Notification and interview event selection must use raw Fabro event names, not a second notification-specific vocabulary. + +**CLI model** + +- R37. CLI target resolution lives under `[cli.target]`, not `[server]` or `[cli.remote]`. +- R38. CLI target transport must be explicit with `type = "http" | "unix"` and transport-specific fields, not overloaded scheme strings. +- R39. CLI transport TLS lives under `[cli.target.tls]`. +- R40. CLI auth is a separate domain at `[cli.auth]`, with explicit `strategy` selection. `strategy = "none"` explicitly disables inherited auth. +- R41. `fabro exec` defaults live under `[cli.exec]`, with `[cli.exec.model]` and `[cli.exec.agent]` split cleanly. +- R42. Generic CLI output defaults live under `[cli.output]`, not under `exec`. +- R43. Upgrade checks live under `[cli.updates]`. +- R44. Idle sleep prevention lives under `[cli.exec]`. + +**Server model** + +- R45. `[server]` is a namespace container. Actual settings live in named subdomains. +- R46. The server binds the API and web surfaces on one shared listener. Bind transport must live under `[server.listen]`, not separately under `[server.api]` and `[server.web]`. +- R47. `[server.listen]` must use explicit transport types such as `tcp` and `unix`. +- R48. Shared listener TLS must live under `[server.listen.tls]`. +- R49. `[server.api]` holds only API-surface settings such as public URL, not bind/auth/TLS settings. +- R50. `[server.web]` holds only web-surface settings such as `enabled` and public URL, not auth settings. +- R51. Server auth is a cohesive domain at `[server.auth]`. +- R52. `[server.auth.api]` must support multiple strategies concurrently. +- R53. `[server.auth.web]` must support multiple providers concurrently via `[server.auth.web.providers.]`. +- R54. Web-auth access rules remain provider-neutral on `[server.auth.web]`; provider-specific config lives under each provider subtable. +- R55. Web-auth providers must support `enabled = true|false` to disable inherited provider config cleanly. +- R56. Inbound provider webhooks belong under provider integrations such as `[server.integrations.github.webhooks]`, not under generic server auth or web sections. +- R57. `[server.storage]` refers only to a managed local disk root on the host. It must expose a single managed `root`. +- R58. `[server.artifacts]` is separate from `[server.storage]` and is backed by an object store provider. +- R59. `[server.slatedb]` is separate from both `[server.storage]` and `[server.artifacts]`. It is backed by its own object store provider and may include database-specific tunables such as `flush_interval`. +- R60. `[server.scheduler]` owns server-managed execution policy such as concurrency limits. It must not compete with `[run]`. + +**Provider and future-proofing rules** + +- R61. Core Fabro concepts should be provider-neutral. Provider-specific details should live in provider-specific nested tables where the domain genuinely requires them. +- R62. Sandbox config must remain provider-specific because provider differences are too large to hide behind one flat abstraction. +- R63. Model config must remain intentionally provider-neutral. It should not grow provider-specific subtables. `run.model.fallbacks` is a single ordered array of model references. Each entry may be a bare provider token such as `openai`, a bare model alias or model id such as `gpt-5.4`, or a qualified reference such as `gemini/gemini-flash`. Bare references are allowed only when unambiguous. Ambiguous bare references must hard-error and require qualification. A bare provider token means “choose the best matching model from that provider.” +- R64. SCM config must be provider-neutral at the core (`[run.scm]`) with room for provider-specific nested tables such as `[run.scm.github]` only where necessary. +- R65. Chat platforms such as Slack, Discord, and Teams are integrations. Their server-owned setup lives under `[server.integrations.]`; run behavior lives under `[run.notifications.*]` and `[run.interviews]`. +- R66. Object-store-backed domains must use a shared pattern: a small provider-neutral envelope plus provider-specific nested tables. +- R67. For local object-store providers, default to `server.storage.root`, but allow explicit local override roots when needed. + +**Merge, validation, and runtime semantics** + +- R68. Scalars replace. +- R69. Structured tables merge by field. +- R70. Freeform maps replace by default. +- R71. A small, explicit set of maps may merge by key where additive inheritance is the least surprising behavior, including `run.sandbox.env` and provider-native maps such as `run.sandbox.daytona.labels`. These maps are intentionally sticky in v1: higher-precedence layers may overwrite keys but cannot remove inherited keys. +- R72. Arrays replace by default. +- R73. Arrays must support splice semantics via `...` in declared splice-capable string arrays, for example `["...", "c"]` for append and `["a", "..."]` for prepend. At most one exact `"..."` marker may appear per array. In the base layer with no inherited parent, the splice marker resolves to an empty inherited segment. In splice-capable arrays, the literal string value `"..."` is reserved and may not be used as data. +- R74. Security and policy lists must replace by default and only inherit via explicit `...`. +- R75. Keyed named objects such as notifications, MCPs, and web-auth providers must merge by field across layers. User-defined keyed object names in namespaces that also host provider-specific subtables must not equal built-in provider identifiers, to avoid ambiguous shapes such as `[run.notifications.slack.slack]`. +- R76. Named keyed objects that may need to be disabled must support `enabled = false`. +- R77. `[[run.hooks]]` remains an ordered list. Hooks may define an optional `id`; `name` remains human-facing only. Hooks without `id` append. Hooks with the same `id` replace whole entries in place. Hooks without `id` from a higher-precedence layer append after the fully merged inherited hook list, preserving per-file declaration order. Hook ordering remains significant. +- R78. Provider-specific required fields should only be validated when that provider/section is actually consumed. +- R79. Unresolved `${env.NAME}` references should only error when the field is actually consumed. +- R80. The config language must not require separate validation modes for CLI, server, and run config in this pass. Runtime consumption drives context-specific validation. + +**String interpolation and value formats** + +- R81. Any string field may use `${env.NAME}` interpolation, either as the whole value or as a substring inside a larger string. Multiple `${env.NAME}` tokens may appear in the same string. +- R82. Do not support config-to-config references such as `${run.inputs.foo}` in this pass. +- R83. All time-like values should use human-readable durations such as `"30s"`, `"1m"`, or `"1h"`, not `_ms` or `_secs` fields. +- R84. Memory and disk settings should accept generous human-readable size syntax. Bare values such as `8`, plus `8G`, `8GB`, and `8GiB`, should all parse successfully. +- R85. Docs and examples should use `GB` as the canonical style. Parsing should remain generous. +- R86. CPU remains an integer core count. + +**Command execution shape** + +- R87. Shell-evaluated actions use `script = "..."`. +- R88. Direct process launches use `command = ["..."]`. +- R89. `script` and `command` are mutually exclusive. +- R90. The `script` xor `command` rule must apply consistently across prepare steps, hooks, and MCP transports that launch a local process. Non-launching MCP transports such as plain HTTP do not use either field. + +## Precedence and Override Order + +The config language has one schema but two consumption models. + +Shared layered domains such as `[project]`, `[workflow]`, `[run]`, and `[features]` use this override order: + +1. Explicit process-local command args or flags +2. Explicit process-local environment override channels, where Fabro defines them +3. `workflow.toml` +4. `fabro.toml` +5. `~/.fabro/settings.toml` +6. Built-in defaults + +Owner-specific process domains use a narrower trust boundary: + +1. Explicit process-local command args or flags +2. Explicit process-local environment override channels, where Fabro defines them +3. `~/.fabro/settings.toml` +4. Built-in defaults + +Additional rules: + +- String interpolation via `${env.NAME}` is not a separate precedence layer. It is value resolution inside the winning layered config value. +- Server start flags override only the server-consumed settings for that process invocation. They do not change persisted TOML values. +- CLI flags override only the CLI-consumed settings for that process invocation. +- `cli.*` and `server.*` stanzas in `fabro.toml` and `workflow.toml` remain parseable but are not part of runtime precedence for those processes. +- If a future env override channel exists for a setting, it must sit between explicit args/flags and layered TOML. + +## Validation Boundary + +Schema validation and runtime validation are separate concerns: + +- All config files validate against the full union schema before consumer-specific filtering. +- Unknown-key validation and duplicate-key validation run at schema-validation time, not at consumer-specific runtime. +- Lazy validation applies only to provider-specific required fields, selected strategies/providers, and `${env.NAME}` resolution for fields that a consumer actually uses. +- Unused but schema-valid `cli.*` and `server.*` stanzas in lower-trust files remain inert rather than invalid. + +## Disable Semantics + +The config language must use one explicit rule for inherited config suppression: + +- Absence means inherit or express no opinion. +- `enabled = false` disables inherited keyed named objects such as notification routes, MCP entries, and web-auth providers. +- `provider = "none"` or `strategy = "none"` disables inherited singleton selectable sections such as interviews or auth. +- Disabled sections suppress provider-specific required-field validation for their disabled subtree. + +## Public URL Semantics + +`server.listen` is only the bind transport. It must not be treated as a public URL source. + +- `server.api.url` and `server.web.url` are optional public URLs. +- They are not derived from `server.listen`. +- They are not derived from each other. +- If omitted, Fabro must treat the corresponding public URL as unspecified rather than synthesizing one implicitly. + +## Normative Merge Matrix + +This redesign should specify exact merge behavior for the first-pass config surface rather than relying only on structural categories. + +| Path | Merge behavior | +|---|---| +| `project` direct scalar fields such as `name`, `description`, and `directory` | replace by field | +| `project.metadata` | replace | +| `workflow` direct scalar fields such as `name`, `description`, and `graph` | replace by field | +| `workflow.metadata` | replace | +| `run` direct scalar fields such as `goal` and `working_dir` | replace by field | +| `run.metadata` | replace | +| `run.inputs` | replace | +| `run.model` direct scalar fields such as `provider` and `name` | replace by field | +| `run.model.fallbacks` | replace, with `...` splice support | +| `run.git.author` | merge by field | +| `run.execution` | merge by field | +| `run.checkpoint` | merge by field | +| `run.sandbox` direct scalar fields such as `provider` and `preserve` | merge by field | +| `run.sandbox.` | merge by field | +| `run.sandbox.env` | merge by key | +| provider-native maps such as `run.sandbox.daytona.labels` | merge by key | +| notification route `events` arrays | replace, with `...` splice support | +| `run.pull_request` | merge by field | +| `run.interviews` | merge by field | +| `run.interviews.` | merge by field | +| `run.prepare.steps` | replace whole ordered list | +| `run.notifications.` | merge by field | +| `run.notifications..` | merge by field | +| `run.agent.mcps.` | merge by field | +| `cli.target` | merge by field | +| `cli.auth` | merge by field | +| `cli.exec` | merge by field | +| `cli.exec.model` | merge by field | +| `cli.exec.agent` | merge by field | +| `cli.output` | merge by field | +| `cli.updates` | merge by field | +| `server.listen` | merge by field | +| `server.api` | merge by field | +| `server.web` | merge by field | +| `server.auth.api` | merge by field | +| `server.auth.api.` | merge by field | +| `server.auth.web.providers.` | merge by field | +| `server.storage` | merge by field | +| `server.artifacts` | merge by field | +| `server.artifacts.` | merge by field | +| `server.slatedb` | merge by field | +| `server.slatedb.` | merge by field | +| `server.scheduler` | merge by field | +| `[[run.hooks]]` | ordered list with special optional-`id` replacement rule | + +New config paths added later should declare one of these behaviors explicitly in docs and implementation. Do not let merge behavior be accidental from Rust type shape alone. + +## Canonical Rendering + +Fabro should parse generously but render consistently in docs and config-inspection output. + +- Durations should render in human-readable form such as `30s`, `1m`, or `1h`. +- Memory and disk should render using `GB` in user-facing examples and normalized output. +- `fabro settings` or equivalent config-inspection output should emit canonicalized values rather than the user's original alternate spelling when values have been normalized internally. +- `fabro settings` or equivalent config-inspection output must redact values that were sourced from `${env.NAME}` by default, rather than printing the resolved secret-bearing value verbatim. + +## Object Store Credential Semantics + +First-pass object store configuration must work without a Fabro-specific secret reference language. + +- Object store providers may rely on provider-native ambient auth such as IAM roles, workload identity, local credential files, or equivalent external mechanisms. +- Provider-specific object-store config fields may also take ordinary string values populated via `${env.NAME}`. +- This redesign does not add `${secret.NAME}` or a separate secret-backend reference syntax. + +## Executable Config Trust Boundary + +Config-executed actions are part of Fabro's trusted configuration model, not the agent permission model. + +- `script` and `command` in prepare steps, hooks, and launching MCP transports are executable configuration, not passive metadata. +- These actions execute under the trust boundary of the consuming process. +- They are not mediated by `run.agent.permissions` or `cli.exec.agent.permissions`. +- Users should treat `fabro.toml` and `workflow.toml` as executable project configuration, not as untrusted data blobs. + +## Migration and Failure Behavior + +This is a hard-cut redesign, but migration still needs explicit failure semantics. + +- Missing `_version` defaults to `1` in the first pass. +- `_version` values higher than the parser supports must hard-fail with an upgrade hint before deeper validation continues. +- The legacy top-level `version` key must hard-fail with a targeted rename hint to `_version`. +- Historical keys and obsolete top-level shapes should hard-fail rather than silently aliasing forward. +- Error messages should point to the new replacement path whenever the replacement is known. +- Historical file names that are no longer read should fail or warn deterministically with a rename hint. +- There should be no silent compatibility layer that keeps old and new shapes both alive indefinitely. +- Migration guidance must explicitly call out that the new default `project.directory = "fabro/"` changes workflow discovery relative to the old implicit project-root behavior. +- Historical string command forms such as `command = "cargo fmt"` must migrate to either `script = "cargo fmt"` or `command = ["cargo", "fmt"]`. +- Historical hook `name` remains display-only in the new language. Cross-layer hook replacement uses the optional `id` field, so users must add `id` explicitly where merge identity is intended. + +Known first-pass migration mappings: + +| Old shape | New shape | +|---|---| +| `version = 1` | `_version = 1` | +| top-level `goal` | `[run].goal` | +| top-level `work_dir` or `directory` | `[run].working_dir` | +| top-level `labels` | `[run.metadata]` | +| `[vars]` | `[run.inputs]` | +| `[llm]` | `[run.model]` | +| `[setup]` | `[run.prepare]` | +| `[sandbox]` | `[run.sandbox]` | +| `[checkpoint]` | `[run.checkpoint]` | +| `[pull_request]` | `[run.pull_request]` | +| `[artifacts]` | `[run.artifacts]` | +| `[exec]` | `[cli.exec]` | +| `[mcp_servers]` | `[run.agent.mcps]` or `[cli.exec.agent.mcps]`, depending on the consumer | +| `[api]` | `[server.api]` | +| `[web]` | `[server.web]` | +| `[artifact_storage]` | `[server.artifacts]` | +| Git commit author settings | `[run.git.author]` | +| GitHub App and webhook settings | `[server.integrations.github]` | + +## Success Criteria + +- The new config language has a small, defensible top-level schema with clear object ownership boundaries. +- Users can paste a stanza between `settings.toml`, `fabro.toml`, and `workflow.toml` and still parse successfully. +- Same-host and split-host deployments both fit the model without separate schema branches. +- Merge behavior is predictable enough that users can explain it from the docs without reading implementation code. +- Provider growth in SCM, chat integrations, and object stores does not force repeated top-level redesigns. +- Users can disable inherited singleton and keyed-object behavior without a general unset language. +- Users can predict flag/env/TOML precedence without reading implementation code. +- When users supply old config keys, Fabro fails with targeted upgrade guidance rather than silently ignoring or partially accepting them. + +## Scope Boundaries + +- No backwards-compatibility requirements. This is a hard-cut redesign. +- No secret-reference syntax such as `${secret.NAME}` in this pass. +- No secret backend configuration in this pass. +- No structured workflow input schema in this pass. +- No prompt-specific run config section in this pass. +- No separate validation modes such as “validate as server config” in this pass. +- No automatic migration tool in this pass. + +## Key Decisions + +- **Strict top-level namespaces**: Keep the root schema extremely small and reserve underscore-prefixed top-level keys for config-language metadata. +- **Same schema everywhere**: File type controls precedence, not which sections are legal. +- **Owner-first process config**: CLI and server settings belong to the process that reads them, even in same-host setups. +- **Provider-neutral core with provider-specific leaves**: Use this for SCM, notifications, interviews, sandboxes, and object stores where it improves long-term coherence. +- **No general unset**: Prefer explicit disable mechanisms such as `enabled = false` and `"none"` selectors. +- **Lazy validation for unused stanzas**: This preserves the “paste any stanza anywhere” rule without weakening strict unknown-key validation. +- **Shared server listener**: Bind transport and transport TLS are shared at `[server.listen]`; API and web remain separate surfaces above that. +- **Separate storage, artifacts, and SlateDB**: These are materially different server concerns and should not be collapsed into one storage section. +- **Ordered lists are rare**: Keep them where order is semantically important, especially hooks and prepare steps. Prefer keyed named objects elsewhere. +- **Hard-fail migration**: The system should aggressively reject obsolete keys and point users at replacements instead of carrying a compatibility burden into the new language. + +## Canonical Shape + +```toml +_version = 1 + +[project] +[workflow] +[run] +[cli] +[server] +[features] +``` + +Representative subtree: + +```toml +_version = 1 + +[project] +name = "Fabro" +description = "AI workflow orchestration" +directory = "fabro/" + +[workflow] +name = "Implement Feature" +description = "Turns a request into a code change" + +[run] +goal = "Implement OAuth refresh tokens" +working_dir = "/workspace" + +[run.model] +provider = "anthropic" +name = "sonnet" +fallbacks = ["openai", "gpt-5.4", "gemini/gemini-flash"] + +[run.agent] +permissions = "read-write" + +[run.notifications.ops] +enabled = true +provider = "slack" +events = ["run.failed"] + +[run.notifications.ops.slack] +channel = "#ops" + +[run.interviews] +provider = "slack" + +[run.interviews.slack] +channel = "#approvals" + +[cli.target] +type = "http" +url = "https://fabro.example.com/api/v1" + +[cli.auth] +strategy = "mtls" + +[cli.exec.model] +provider = "anthropic" +name = "claude-opus" + +[cli.exec.agent] +permissions = "read-write" + +[server.listen] +type = "tcp" +address = "127.0.0.1:32276" + +[server.api] +url = "https://fabro.example.com/api/v1" + +[server.web] +enabled = true +url = "https://fabro.example.com" + +[server.storage] +root = "/var/lib/fabro" + +[server.artifacts] +provider = "s3" +prefix = "artifacts" + +[server.slatedb] +provider = "s3" +prefix = "runs" +flush_interval = "1s" +``` + +## Canonical File Examples + +Minimal `~/.fabro/settings.toml`: + +```toml +_version = 1 + +[cli.target] +type = "unix" +path = "~/.fabro/fabro.sock" + +[cli.exec] +prevent_idle_sleep = true + +[cli.exec.model] +provider = "anthropic" +name = "claude-opus" + +[cli.exec.agent] +permissions = "read-write" + +[cli.output] +format = "text" +verbosity = "normal" + +[cli.updates] +check = true + +[server.listen] +type = "unix" +path = "~/.fabro/fabro.sock" + +[server.storage] +root = "~/.fabro/storage" + +[run.interviews] +provider = "slack" + +[run.interviews.slack] +channel = "#approvals" +``` + +Minimal `fabro.toml`: + +```toml +_version = 1 + +[project] +name = "Fabro" +description = "AI workflow orchestration" +directory = "fabro/" + +[run.model] +provider = "anthropic" +name = "sonnet" + +[run.sandbox] +provider = "daytona" + +[[run.prepare.steps]] +script = "bun install" +``` + +Minimal `workflow.toml`: + +```toml +_version = 1 + +[workflow] +name = "Implement Feature" +description = "Turns a request into a code change" + +[run] +goal = "Implement OAuth refresh tokens" + +[run.inputs] +repo = "fabro" + +[run.notifications.ops] +enabled = true +provider = "slack" +events = ["run.failed", "run.completed"] + +[run.notifications.ops.slack] +channel = "#ops" +``` + +## Outstanding Questions + +### Deferred to Planning + +- [Affects R64][Technical] What exact run-side SCM targeting fields should live under `[run.scm]` in the first pass: repo slug, owner/repo split, base branch defaults, or additional checkout/ref context? +- [Affects R66][Technical] What exact shared field set should the object-store envelope expose before provider-specific subtables begin? +- [Affects R90][Technical] What exact field set should the MCP launcher schema expose in addition to `script` xor `command`, `type`, and timeouts? +- [Affects R34][Technical] What minimal first-pass notification route surface is required beyond `enabled`, `provider`, and `events`? +- [Affects R83][Technical] What duration parser will Fabro standardize on, and what canonical normalization should be shown in error messages and generated examples? + +## Next Steps + +- Update the user-facing config docs to match this new object model. +- `/ce:plan` for a migration and implementation plan covering parser changes, merge semantics, docs, and test updates. diff --git a/docs/changelog/2026-03-02.mdx b/docs/changelog/2026-03-02.mdx index 5ce901351..94cad1f13 100644 --- a/docs/changelog/2026-03-02.mdx +++ b/docs/changelog/2026-03-02.mdx @@ -20,7 +20,7 @@ Persistent chat sessions with SQLite storage. Start a conversation with an agent -- `fabro llm chat` command for interactive multi-turn conversations with any configured model +- Introduced interactive CLI chat sessions via the now-removed `fabro llm chat` command diff --git a/docs/changelog/2026-03-03.mdx b/docs/changelog/2026-03-03.mdx index 1834534a8..2801c34a8 100644 --- a/docs/changelog/2026-03-03.mdx +++ b/docs/changelog/2026-03-03.mdx @@ -22,10 +22,12 @@ The wizard detects your current configuration, prompts for missing values, and w A single command to check your entire installation: system dependencies, cryptographic key validation, LLM provider connectivity, and web server configuration. ```bash -fabro doctor # quick local checks -fabro doctor --live # includes real-time API probes to each configured provider +fabro doctor +fabro doctor --verbose ``` +Later releases removed the separate `--live` mode; doctor now always uses live server-backed diagnostics. + If something is misconfigured, `fabro doctor` tells you exactly what's wrong and how to fix it. ## More diff --git a/docs/changelog/2026-03-07.mdx b/docs/changelog/2026-03-07.mdx index c6a3a4d1c..624d25d72 100644 --- a/docs/changelog/2026-03-07.mdx +++ b/docs/changelog/2026-03-07.mdx @@ -7,12 +7,7 @@ date: "2026-03-07" Fabro now exposes a `POST /completions` endpoint for single-turn LLM completions. You can use it for structured output via JSON Schema, one-off prompts, or building custom frontends on top of Fabro's model routing. Streaming uses Anthropic-style SSE events (`message_start`, `content_block_delta`, `message_stop`, etc.), so you get tokens as they're generated rather than waiting for the full response. -The CLI's `fabro llm` commands can now target the server instead of calling providers directly: - -```bash -fabro llm prompt "Summarize this file" --server-url http://localhost:3000 -fabro llm chat --server-url http://localhost:3000 -``` +At the time of this release, the CLI's `fabro llm` commands could target the server instead of calling providers directly. That CLI namespace has since been removed. ## Two new sandbox providers: exe.dev and Sprites @@ -63,14 +58,13 @@ To migrate, regenerate your TypeScript client and update any direct API calls. - New `POST /completions` endpoint for single-turn LLM completions with SSE streaming and structured output via JSON Schema - New `GET /models` endpoint exposes the full LLM model catalog with pagination - New `POST /models/{id}/test` endpoint for testing model connectivity in server mode -- Session endpoints for interactive LLM chat via `fabro llm chat --server-url http://localhost:3000` +- Session endpoints for interactive LLM chat; the old `fabro llm chat` CLI wrapper has since been removed - Verification API reorganized: `/verifications` split into `/verification/criteria` and `/verification/controls` -- `fabro llm prompt --server-url ` routes prompts through the Fabro server -- `fabro llm chat --server-url ` enables interactive chat sessions through the server -- `fabro model list --server-url ` fetches the model list from the Fabro server +- This release added `fabro llm prompt/chat --server-url`, but the `fabro llm` CLI namespace was later removed +- `fabro model list --server ` now fetches the model list from the Fabro server - Added `--goal` arg to `fabro run start` to override the workflow goal from the command line - Turn and tool-call counts now display correctly in non-TTY mode diff --git a/docs/changelog/2026-03-08.mdx b/docs/changelog/2026-03-08.mdx index 66a60e023..292968344 100644 --- a/docs/changelog/2026-03-08.mdx +++ b/docs/changelog/2026-03-08.mdx @@ -54,7 +54,7 @@ To migrate, update any scripts or aliases: - Script nodes now execute inside the configured sandbox instead of on the host — fixes issues where agents edited files in the sandbox but lint/test commands ran on the host -- Asset collection is now opt-in via `[assets]` config with custom include globs, eliminating ~30s file scans per stage when not needed +- Artifact collection is now opt-in via `[artifacts]` config with custom include globs, eliminating ~30s file scans per stage when not needed - Workflow runs now fail immediately on git checkpoint commit failure instead of silently continuing - GitHub webhook listener via Tailscale funnel — auto-configures webhook URL on startup - `[sandbox.env]` support passes environment variables through to sandbox tool execution diff --git a/docs/changelog/2026-03-10.mdx b/docs/changelog/2026-03-10.mdx index 5ff4dbca3..10e74bd50 100644 --- a/docs/changelog/2026-03-10.mdx +++ b/docs/changelog/2026-03-10.mdx @@ -35,7 +35,7 @@ devcontainer = true ``` -**`--no-dotenv` flag removed.** Fabro now always loads `~/.fabro/.env` and never loads a local `./.env` file. If you were relying on project-local `.env` files, move those values to `~/.fabro/.env`. +**Historical note.** This release temporarily standardized on `~/.fabro/.env`, but later releases removed automatic dotenv loading in favor of server-owned secrets plus explicit process environment variables. ## Manage PRs with fabro pr @@ -81,7 +81,7 @@ fabro preview - `fabro setup` renamed to `fabro install` for clarity - Added `fabro ssh` command for direct SSH access to Daytona sandboxes -- `fabro doctor` now runs live service probes by default; use `--dry-run` to skip +- `fabro doctor` now runs live service probes by default - `fabro doctor` now validates GitHub App configuration and private key - `fabro doctor` now hides unconfigured LLM providers for a cleaner output - `fabro doctor` sections reordered: Config, LLM, GitHub App, Cloud sandbox, Brave Search diff --git a/docs/changelog/2026-03-15.mdx b/docs/changelog/2026-03-15.mdx index 3453b9578..abfb9589d 100644 --- a/docs/changelog/2026-03-15.mdx +++ b/docs/changelog/2026-03-15.mdx @@ -42,7 +42,7 @@ fabro ps -a # all runs including completed ## More -- Added `fabro asset list` to view run artifacts and `fabro asset cp` to copy them locally with optional `--tree` directory structure +- Added `fabro artifact list` to view run artifacts and `fabro artifact cp` to copy them locally with optional `--tree` directory structure - Added `fabro rm` command to remove runs by ID with sandbox cleanup - Added `--direction` (`-d`) flag to `fabro graph` for overriding layout direction (`lr` or `tb`) - Added `-p` short alias for `--pretty` in `fabro logs` @@ -63,7 +63,7 @@ fabro ps -a # all runs including completed - Command output in stage preambles now truncated to the last 25-50 lines, reducing token waste from verbose build logs -- Asset collection now enforces a 100-file limit and excludes `.venv`, `venv`, `.cache`, `.tox`, `.pytest_cache`, `.mypy_cache`, and `dist` directories +- Artifact collection now enforces a 100-file limit and excludes `.venv`, `venv`, `.cache`, `.tox`, `.pytest_cache`, `.mypy_cache`, and `dist` directories - Unified dry-run behavior across all handler types — wait and human-in-the-loop nodes now properly simulate without side effects diff --git a/docs/changelog/2026-03-18.mdx b/docs/changelog/2026-03-18.mdx index 2c2978461..9987ad160 100644 --- a/docs/changelog/2026-03-18.mdx +++ b/docs/changelog/2026-03-18.mdx @@ -5,12 +5,11 @@ date: "2026-03-18" ## Secret management from the CLI -Managing API keys and credentials previously meant manually editing `~/.fabro/.env`. The new `fabro secret` commands let you get, set, list, and remove secrets directly from the CLI. +Managing API keys and credentials previously meant manually editing `~/.fabro/.env`. This release introduced `fabro secret` commands; later releases made secrets server-owned and write-only. ```bash fabro secret set ANTHROPIC_API_KEY sk-ant-... fabro secret list -fabro secret get ANTHROPIC_API_KEY fabro secret rm ANTHROPIC_API_KEY ``` @@ -30,4 +29,5 @@ The old `fabro init` still works but prints a deprecation warning. - Moved `fabro init` to `fabro repo init` with a backwards-compatible deprecation shim - Added `--show-values` flag to `fabro secret list` to reveal secret values + Later releases removed this flag when secrets became write-only diff --git a/docs/changelog/2026-03-19.mdx b/docs/changelog/2026-03-19.mdx index 6567f1ab8..26028c951 100644 --- a/docs/changelog/2026-03-19.mdx +++ b/docs/changelog/2026-03-19.mdx @@ -11,7 +11,7 @@ Re-authenticating with LLM providers previously required re-running the full `fa fabro provider login --provider openai ``` -For OpenAI, this launches the browser-based OAuth PKCE flow with an automatic fallback to manual API key entry. All other providers prompt for an API key with validation. Credentials are merged non-destructively into `~/.fabro/.env`. +For OpenAI, this launches the browser-based OAuth PKCE flow with an automatic fallback to manual API key entry. All other providers prompt for an API key with validation. Later releases moved these credentials into the server-owned secret store. ## Auto-detect default LLM provider diff --git a/docs/changelog/2026-03-30.mdx b/docs/changelog/2026-03-30.mdx new file mode 100644 index 000000000..def782923 --- /dev/null +++ b/docs/changelog/2026-03-30.mdx @@ -0,0 +1,46 @@ +--- +title: "CLI restructuring, shell completions, and API versioning" +date: "2026-03-30" +--- + +## CLI command restructuring + +The CLI command tree has been reorganized for consistency and discoverability. Sandbox-related commands (`cp`, `ssh`, `preview`) now live under `fabro sandbox`, the server command is now `fabro server start`, settings are shown with `fabro settings`, and the deprecated `fabro init` command has been removed. + + +**Breaking CLI changes.** Several commands have moved or been renamed: + +- `fabro serve` → `fabro server start` +- `fabro config show` → `fabro settings` +- `fabro cp` → `fabro sandbox cp` +- `fabro ssh` → `fabro sandbox ssh` +- `fabro preview` → `fabro sandbox preview` +- `fabro init` has been removed + + +## Shell completions + +You can now generate shell completions for Bash, Zsh, Fish, Elvish, and PowerShell using the new `fabro completion` subcommand. + +```bash +fabro completion zsh > ~/.zfunc/_fabro +``` + + +**API routes versioned.** All REST API routes are now prefixed with `/api/v1`. Update any direct API integrations accordingly. + + +## More + + +- Added `fabro completion` subcommand for Bash, Zsh, Fish, Elvish, and PowerShell +- Added help text snapshots for all subcommands + + + +- Typed `RunId` used consistently across the codebase + + + +- Fixed cli-table ignoring `NO_COLOR` environment variable + diff --git a/docs/changelog/2026-03-31.mdx b/docs/changelog/2026-03-31.mdx new file mode 100644 index 000000000..a1fdf6aea --- /dev/null +++ b/docs/changelog/2026-03-31.mdx @@ -0,0 +1,25 @@ +--- +title: "Global JSON output mode" +date: "2026-03-31" +--- + +## Global JSON output mode + +Every CLI command now supports a `--json` flag for machine-readable output. Previously, only a few commands had JSON variants. Now you can pipe any Fabro command into `jq` or feed it directly into scripts and CI pipelines. + +```bash +fabro runs list --json | jq '.[].id' +fabro system df --json +fabro preflight --json +``` + +## More + + +- Stale git worktrees are now pruned automatically before branch creation in worktree sandboxes + + + +- Fixed `logs --follow` timing out on long-running runs due to slow manifest polling +- Fixed rewind snapshot filtering when using short commit SHAs + diff --git a/docs/changelog/2026-04-01.mdx b/docs/changelog/2026-04-01.mdx new file mode 100644 index 000000000..8fc9443ee --- /dev/null +++ b/docs/changelog/2026-04-01.mdx @@ -0,0 +1,24 @@ +--- +title: "Server-backed web dashboard and event-sourced run state" +date: "2026-04-01" +--- + +## Server-backed web dashboard + +The Fabro web app is now a single-page application served directly by the Fabro server. Previously, the frontend required a separate Node.js server with server-side rendering. Now `fabro server start` serves both the API and the web dashboard from the same origin, simplifying deployment and eliminating the need for a separate frontend process. + +## Event-sourced run state + +Run state is now fully derived from events rather than written to disk as independent files. This means runs are more reliable, resumable, and inspectable — every piece of run metadata (status, provider info, checkpoints, PR data) is reconstructed from the event stream. The legacy SQLite metadata store and file-based projections have been retired. + +## More + + +- `unsafe_code` is now denied workspace-wide for security hardening +- Dry-run mode no longer activates automatically when LLM providers are missing — runs now fail explicitly, making misconfiguration easier to diagnose + + + +- Fixed `attach` hanging on Linux when the workflow engine exits before the attach process starts +- Fixed login route collision after the SPA cutover + diff --git a/docs/changelog/2026-04-02.mdx b/docs/changelog/2026-04-02.mdx new file mode 100644 index 000000000..8c5c8be8a --- /dev/null +++ b/docs/changelog/2026-04-02.mdx @@ -0,0 +1,24 @@ +--- +title: "Background server daemon" +date: "2026-04-02" +--- + +## Background server daemon + +The Fabro server now runs as a managed background daemon instead of requiring a dedicated terminal tab. `fabro server start` launches the daemon in the background with file-lock-based lifecycle management, and `fabro server stop` shuts it down gracefully. The daemon listens on a Unix socket by default for fast local communication. + +```bash +fabro server start # launch background daemon +fabro server status # check running state, PID, uptime +fabro server status --json # machine-readable status +fabro server stop # graceful shutdown +fabro server start --foreground # old blocking behavior +``` + +The `--bind` flag replaces the previous `--host`/`--port` flags and supports both Unix sockets and TCP addresses. + +## More + + +- Workflow state is now fully derivable from events, making run replay and debugging more reliable + diff --git a/docs/changelog/2026-04-04.mdx b/docs/changelog/2026-04-04.mdx new file mode 100644 index 000000000..544bef246 --- /dev/null +++ b/docs/changelog/2026-04-04.mdx @@ -0,0 +1,20 @@ +--- +title: "Run lifecycle API refinement" +date: "2026-04-04" +--- + +## Separate run create and start + +The `POST /api/v1/runs` endpoint now creates a run in `submitted` status without immediately queuing it. A new `POST /api/v1/runs/{id}/start` endpoint transitions the run to `queued` and notifies the scheduler. This two-step lifecycle gives API consumers more control — you can inspect or modify a run's configuration between creation and execution. + +## More + + +- `POST /api/v1/runs` now returns a run in `submitted` status +- New `POST /api/v1/runs/{id}/start` endpoint queues a submitted run for execution +- Removed unused `GET /api/v1/runs/{id}/context` endpoint + + + +- Run artifacts are now stored as blobs in the run store instead of on disk + diff --git a/docs/changelog/2026-04-06.mdx b/docs/changelog/2026-04-06.mdx new file mode 100644 index 000000000..1a068d1d0 --- /dev/null +++ b/docs/changelog/2026-04-06.mdx @@ -0,0 +1,28 @@ +--- +title: "System management API" +date: "2026-04-06" +--- + +## System management API + +New server-backed system commands give you visibility into Fabro's operational state through both the CLI and the REST API. `fabro system info` reports server version, uptime, and run counts. `fabro system events` streams the global event log. `fabro system df` and `fabro system prune` now route through the server for consistent behavior across local and remote setups. + +```bash +fabro system info # server version, uptime, run counts +fabro system info --json # machine-readable +fabro system events # global event stream +fabro system prune # clean up completed runs +``` + +## More + + +- New `GET /api/v1/system/info` endpoint returns server version, uptime, and run statistics +- New `GET /api/v1/system/events` endpoint streams the global event log via SSE +- New `GET /api/v1/system/df` endpoint returns disk usage by run +- New `POST /api/v1/system/prune` endpoint removes completed run data + + + +- Settings and model commands now route through the server daemon for consistency + diff --git a/docs/core-concepts/how-fabro-works.mdx b/docs/core-concepts/how-fabro-works.mdx index 70cf0c37d..a4f9ff071 100644 --- a/docs/core-concepts/how-fabro-works.mdx +++ b/docs/core-concepts/how-fabro-works.mdx @@ -9,14 +9,14 @@ Fabro is a workflow engine that reads a graph definition, executes nodes one at How Fabro works: author-time inputs flow into the workflow engine, which dispatches to handlers that interact with LLMs, sandboxes, and humans -## Two ways to run Fabro +## Two ways to use Fabro Fabro has two interfaces, both backed by the same workflow engine: -- **Standalone mode** (`fabro run`) — Run a single workflow synchronously in your terminal. Best for local development, one-off runs, and CI/CD. -- **Server mode** (`fabro server start`) — Start an HTTP API server with a web UI, concurrent run scheduling, and team access. Best for production use and running at scale. +- **Direct CLI runs** (`fabro run`) — Run a single workflow synchronously in your terminal. Best for local development, one-off runs, and CI/CD. +- **Server interface** (`fabro server start`) — Start an HTTP API server with a web UI, concurrent run scheduling, and team access. Best for production use and running at scale. -Both modes parse the same Graphviz files, use the same execution engine, and support the same sandbox providers. See [Server Mode](/administration/deploy-server) for a detailed comparison and setup guide, or [Architecture](/reference/architecture) for internals. +Both interfaces parse the same Graphviz files, use the same execution engine, and support the same sandbox providers. See [Running the Fabro Server](/administration/deploy-server) for a detailed comparison and setup guide, or [Architecture](/reference/architecture) for internals. ## Author time @@ -24,11 +24,11 @@ You provide three inputs: 1. **Workflow graph** (`.fabro`) — A Graphviz file defining nodes, edges, and their attributes. This is the core of what Fabro executes. See [Workflows](/core-concepts/workflows). 2. **Run config** (`.toml`, optional) — Overrides for the default model, sandbox provider, setup commands, and variables. See [Run Configuration](/execution/run-configuration). -3. **API keys** (`.env`) — Provider credentials for LLM APIs. See [Quick Start](/getting-started/quick-start). +3. **Credentials** — Provider credentials from the server-owned secret store or the invoking shell environment. See [Quick Start](/getting-started/quick-start). ## Parse and validate -When you run `fabro run`, Fabro: +When you run `fabro run` or submit a run to the server, Fabro: 1. Parses the Graphviz file into an in-memory graph of nodes and edges 2. Validates the graph structure (exactly one start node, one exit node, all edges point to valid nodes) @@ -96,4 +96,3 @@ fabro resume ``` The engine restores the full context, node visit counts, and retry state from the run directory, then continues execution from the next node. - diff --git a/docs/core-concepts/models.mdx b/docs/core-concepts/models.mdx index 5ad270cd6..7ea0f25f5 100644 --- a/docs/core-concepts/models.mdx +++ b/docs/core-concepts/models.mdx @@ -92,16 +92,17 @@ These flags set the default model for all nodes that don't have an explicit mode For repeatable runs, set the model in a run config file: ```toml title="run.toml" -version = 1 -goal = "Implement the feature" +_version = 1 + +[workflow] graph = "implement.fabro" -[llm] -model = "claude-sonnet-4-5" +[run] +goal = "Implement the feature" -[llm.fallbacks] -anthropic = ["gemini", "openai"] -gemini = ["anthropic", "openai"] +[run.model] +name = "claude-sonnet-4-5" +fallbacks = ["gemini", "openai"] ``` Then launch with: @@ -110,7 +111,7 @@ Then launch with: fabro run run.toml ``` -The `[llm.fallbacks]` table is optional. It maps each provider to an ordered list of fallback providers to try when the primary is unavailable. +The `fallbacks` array is optional. Each entry may be a bare provider token (like `"gemini"`), a bare model alias (like `"gpt-5.4"`), or a qualified `"provider/model"` reference. Fabro tries them in order when the primary provider is unavailable. The precedence order is: node-level stylesheet > run config TOML > CLI flags > server defaults. More specific settings always win. diff --git a/docs/docs.json b/docs/docs.json index 7397e739f..fb1d74a40 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -185,6 +185,7 @@ "GET /api/v1/runs", "POST /api/v1/runs", "GET /api/v1/runs/{id}", + "POST /api/v1/runs/{id}/start", "POST /api/v1/runs/{id}/cancel", "POST /api/v1/runs/{id}/pause", "POST /api/v1/runs/{id}/unpause", @@ -198,7 +199,6 @@ "pages": [ "GET /api/v1/runs/{id}/questions", "POST /api/v1/runs/{id}/questions/{qid}/answer", - "POST /api/v1/runs/{id}/steer", "POST /api/v1/runs/{id}/preview" ] }, @@ -206,14 +206,13 @@ "group": "Run Outputs", "icon": "file-export", "pages": [ - "GET /api/v1/runs/{id}/files", + "GET /api/v1/runs/{id}/artifacts", "GET /api/v1/runs/{id}/usage", { "group": "Run Internals", "icon": "microchip", "pages": [ "GET /api/v1/runs/{id}/checkpoint", - "GET /api/v1/runs/{id}/context", "GET /api/v1/runs/{id}/stages", "GET /api/v1/runs/{id}/stages/{stageId}/turns", "GET /api/v1/runs/{id}/settings" @@ -221,15 +220,6 @@ } ] }, - { - "group": "Workflows", - "icon": "diagram-project", - "pages": [ - "GET /api/v1/workflows", - "GET /api/v1/workflows/{name}", - "GET /api/v1/workflows/{name}/runs" - ] - }, { "group": "More", "icon": "ellipsis", @@ -248,17 +238,6 @@ "GET /api/v1/models", "POST /api/v1/models/{id}/test" ] - }, - { - "group": "Sessions", - "icon": "comments", - "pages": [ - "GET /api/v1/sessions", - "POST /api/v1/sessions", - "GET /api/v1/sessions/{id}", - "POST /api/v1/sessions/{id}/messages", - "GET /api/v1/sessions/{id}/events" - ] } ] } @@ -268,10 +247,22 @@ "tab": "Changelog", "icon": "clock-rotate-left", "groups": [ + { + "group": "April 2026", + "icon": "clock-rotate-left", + "pages": [ + "changelog/2026-04-06", + "changelog/2026-04-04", + "changelog/2026-04-02", + "changelog/2026-04-01" + ] + }, { "group": "March 2026", "icon": "clock-rotate-left", "pages": [ + "changelog/2026-03-31", + "changelog/2026-03-30", "changelog/2026-03-29", "changelog/2026-03-28", "changelog/2026-03-27", diff --git a/docs/examples/clone-substack.mdx b/docs/examples/clone-substack.mdx index fd9ad1799..0c1656a81 100644 --- a/docs/examples/clone-substack.mdx +++ b/docs/examples/clone-substack.mdx @@ -75,7 +75,7 @@ GitHub to Railway validated by code review only — no live deployment execution expand_spec [ label="Expand Spec", - prompt="Goal: $goal\n\n\ + prompt="Goal: {{ goal }}\n\n\ The project specification is at substack-spec-v01.md and the Definition of Done \ is at substack-dod-v01.md. The UI flow diagram is at substack-spec-v01-ui.gv.\n\n\ Read all three files. Scratch artifacts go under .workflow/.\n\n\ @@ -100,7 +100,7 @@ adequate, skip." plan_a [ label="Plan A", class="branch-a", - prompt="Goal: $goal\n\n\ + prompt="Goal: {{ goal }}\n\n\ Read .workflow/spec.md and .workflow/definition_of_done.md. If those files do not \ exist, fall back to reading substack-spec-v01.md and substack-dod-v01.md directly. \ If .workflow/postmortem_latest.md exists, incorporate its lessons.\n\n\ @@ -126,7 +126,7 @@ Write to .workflow/plan_a.md." plan_b [ label="Plan B", class="branch-b", - prompt="Goal: $goal\n\n\ + prompt="Goal: {{ goal }}\n\n\ Read .workflow/spec.md and .workflow/definition_of_done.md. If those files do not \ exist, fall back to reading substack-spec-v01.md and substack-dod-v01.md directly. \ If .workflow/postmortem_latest.md exists, incorporate its lessons.\n\n\ @@ -184,7 +184,7 @@ Write the final plan to .workflow/plan_final.md." class="hard", max_tokens=32768, label="Implement", - prompt="Goal: $goal\n\n\ + prompt="Goal: {{ goal }}\n\n\ Read .workflow/plan_final.md, .workflow/spec.md, and \ .workflow/definition_of_done.md. If the spec or DoD files do not exist at those \ paths, fall back to reading substack-spec-v01.md and substack-dod-v01.md directly.\n\n\ diff --git a/docs/execution/checkpoints.mdx b/docs/execution/checkpoints.mdx index 60f77298a..4c8ae13aa 100644 --- a/docs/execution/checkpoints.mdx +++ b/docs/execution/checkpoints.mdx @@ -3,7 +3,7 @@ title: "Checkpoints" description: "How Fabro uses Git to checkpoint and resume workflow runs" --- -Fabro checkpoints every workflow run using Git. After each node completes, Fabro commits the file changes and execution state so that interrupted runs can be resumed exactly where they left off. This happens automatically — no configuration required beyond running inside a Git repository. +Fabro checkpoints every workflow run using Git plus the durable run store. After each node completes, Fabro commits the file changes and execution state so that interrupted runs can be resumed exactly where they left off. This happens automatically — no configuration required beyond running inside a Git repository. ## Two branches, two purposes @@ -18,7 +18,7 @@ The run branch is a regular Git branch that grows one commit per completed node. ### Run branch commits -After each node finishes, Fabro stages file changes and creates a commit on the run branch. Files matching `[checkpoint] exclude_globs` patterns (configured in [run.toml](/execution/run-configuration#checkpoint) or [server.toml](/administration/server-configuration#checkpoint-section)) are excluded from staging: +After each node finishes, Fabro stages file changes and creates a commit on the run branch. Files matching `[checkpoint] exclude_globs` patterns (configured in [run.toml](/execution/run-configuration#checkpoint) or [settings.toml](/administration/server-configuration#checkpoint-section)) are excluded from staging: ``` fabro(01JKXYZ...): plan (success) @@ -69,7 +69,7 @@ The `checkpoint.json` captures everything needed to resume a run: | `loop_failure_signatures` | Failure signature counts for loop detection | | `restart_failure_signatures` | Failure signature counts across loop-restart edges | -The checkpoint is also saved to `checkpoint.json` in the run directory for quick local access. +The durable run store also keeps the current checkpoint so `resume`, `inspect`, and API reads do not need to rely on scratch files. ## Worktrees @@ -90,20 +90,20 @@ For Daytona sandboxes, the worktree is created inside the remote sandbox instead ## Resuming a run -Resume an interrupted run from its checkpoint on disk: +Resume an interrupted run from its durable checkpoint: ```bash fabro resume 01JKXYZ ``` -Fabro looks up the run directory by ID prefix, loads `checkpoint.json` and `run.json` from the run directory, and spawns a new engine process to continue execution. No workflow file or override flags are needed — all configuration is read from the persisted run state. +Fabro resolves the run by ID prefix, validates that durable state contains a checkpoint, and asks the server to continue execution. No workflow file or override flags are needed — all configuration is restored from persisted state. 1. Fabro looks up the run directory by ID prefix -2. Validates that `checkpoint.json` exists and no engine process is already running -3. Cleans stale artifacts from the previous execution (conclusion, PID file, etc.) +2. Validates that a checkpoint exists in durable state and no engine process is already running +3. Cleans stale local artifacts from the previous execution 4. Resets status to `Submitted` and spawns a new engine subprocess with `--resume` -5. The engine loads `run.json` and `checkpoint.json`, restores the full context, completed node list, retry counts, and failure signatures +5. The engine restores the full context, completed node list, retry counts, and failure signatures from durable state 6. If the checkpointed node used `full` fidelity, downgrades the first resumed node to `summary:high` (since the original conversation thread no longer exists in memory) 7. Continues execution from `next_node_id` @@ -112,12 +112,12 @@ Fabro looks up the run directory by ID prefix, loads `checkpoint.json` and `run. Here's the full sequence that runs after every node completes: -1. **Save checkpoint to disk** — Write `checkpoint.json` to the run directory +1. **Append checkpoint event** — Persist the new checkpoint into durable run state 2. **Write metadata branch** — Serialize the checkpoint and any new artifacts to the metadata branch (shadow commit) 3. **Commit to run branch** — Stage all file changes, commit with structured trailers linking to the shadow commit SHA -4. **Update checkpoint** — Re-save `checkpoint.json` with the `git_commit_sha` field set +4. **Update durable checkpoint** — Persist the `git_commit_sha` associated with the run-branch commit -Steps 2-4 are best-effort — if any Git operation fails, the run continues and emits a `RunNotice` warning event. The disk checkpoint from step 1 is always available as a fallback. +Steps 2-4 are best-effort — if any Git operation fails, the run continues and emits a `RunNotice` warning event. Resume still uses the durable checkpoint in the run store. ## Inspecting run history diff --git a/docs/execution/context.mdx b/docs/execution/context.mdx index 40b50b75c..6d0baa0f5 100644 --- a/docs/execution/context.mdx +++ b/docs/execution/context.mdx @@ -196,15 +196,22 @@ Internal keys (prefixed with `internal.`, `current`, `graph.`, `thread.`, `respo ## Artifact offloading -When a stage produces a large output (over 100KB of serialized JSON), Fabro automatically offloads it to the **artifact store** on disk rather than keeping it in the in-memory context. The context value is replaced with a `file://` pointer: +When a stage produces a large output (over 100KB of serialized JSON), Fabro stores the serialized bytes in a global content-addressed blob store and replaces the context value with a durable blob ref: ``` -response.plan → file:///tmp/logs/cache/artifacts/values/response.plan.json +response.plan → blob://sha256/2cf24dba5fb0... ``` -The preamble renderer resolves these pointers and displays a reference to the file path. For remote sandboxes (Docker, Daytona), Fabro syncs artifact files to the sandbox at `{working_directory}/.fabro/artifacts/` so agents can read them. +Checkpoints and checkpoint-completed events persist these `blob://` refs, not host-specific file paths. -This keeps the context lean — large LLM responses, test output, and file listings don't bloat checkpoint files or overwhelm preamble summaries. +Before Fabro builds a preamble or starts the next stage, it resolves any blob refs into execution-local files so handlers and agents still see normal `file://` references: + +- Local execution materializes blobs under `{run_dir}/runtime/blobs/{blob_id}.json` +- Remote sandboxes materialize blobs under `{working_directory}/.fabro/blobs/{blob_id}.json` + +These materialized `file://` paths are runtime-only. They are not written back into durable context snapshots. + +This keeps the durable context lean and portable while still giving downstream stages a filesystem path they can read. ## Context compaction diff --git a/docs/execution/devcontainers.mdx b/docs/execution/devcontainers.mdx index 6db34fb0f..c98e8bbf3 100644 --- a/docs/execution/devcontainers.mdx +++ b/docs/execution/devcontainers.mdx @@ -7,13 +7,15 @@ Fabro can use your project's [devcontainer](https://containers.dev/) configurati ## Enabling devcontainer support -Set `devcontainer = true` in the `[sandbox]` section of your run config: +Set `devcontainer = true` in the `[run.sandbox]` section of your run config: ```toml title="run.toml" -version = 1 +_version = 1 + +[workflow] graph = "workflow.fabro" -[sandbox] +[run.sandbox] provider = "daytona" devcontainer = true ``` diff --git a/docs/execution/environments.mdx b/docs/execution/environments.mdx index f58adc377..ca70264e2 100644 --- a/docs/execution/environments.mdx +++ b/docs/execution/environments.mdx @@ -26,7 +26,7 @@ fabro run workflow.fabro --sandbox daytona ```toml title="run.toml" # Run config TOML -[sandbox] +[run.sandbox] provider = "daytona" ``` @@ -94,7 +94,7 @@ fabro run workflow.fabro --sandbox docker --preserve-sandbox Or in the run config: ```toml title="run.toml" -[sandbox] +[run.sandbox] provider = "docker" preserve = true ``` @@ -123,17 +123,17 @@ The Daytona sandbox runs all tool operations inside a cloud-hosted VM managed by Snapshots let you pre-build an environment image so each run starts with dependencies already installed. If the named snapshot doesn't exist and a `dockerfile` is provided, Fabro creates it automatically and polls until it's ready (up to 10 minutes). ```toml title="run.toml" -[sandbox] +[run.sandbox] provider = "daytona" -[sandbox.daytona] +[run.sandbox.daytona] auto_stop_interval = 60 -[sandbox.daytona.snapshot] +[run.sandbox.daytona.snapshot] name = "rust-dev" cpu = 4 -memory = 8 -disk = 20 +memory = "8GB" +disk = "20GB" dockerfile = "FROM rust:1.85-slim-bookworm\nRUN apt-get update && apt-get install -y git ripgrep" ``` @@ -152,7 +152,7 @@ If the snapshot already exists and is in `Active` state, Fabro uses it directly. Attach key-value labels to sandboxes for filtering and identification in the Daytona dashboard: ```toml title="run.toml" -[sandbox.daytona.labels] +[run.sandbox.daytona.labels] project = "fabro" env = "ci" team = "platform" @@ -185,7 +185,7 @@ Fabro prints the sandbox name so you can find it in the [Daytona dashboard](http The `auto_stop_interval` setting (in minutes) tells Daytona to stop the sandbox after a period of inactivity. This saves costs for long-running sandboxes that may sit idle: ```toml title="run.toml" -[sandbox.daytona] +[run.sandbox.daytona] auto_stop_interval = 30 ``` @@ -215,15 +215,15 @@ Each provider handles outbound network access differently: | `docker` | Bridge network | Set via the `network_mode` config option. Supports all Docker network modes (`bridge`, `none`, `host`, etc.). | | `daytona` | Full access | Configurable via the `network` setting with three modes: `"allow_all"`, `"block"`, or CIDR-based allow lists. | -For Daytona, network access is configured in the `[sandbox.daytona]` section: +For Daytona, network access is configured in the `[run.sandbox.daytona]` section: ```toml title="run.toml" # Block all egress -[sandbox.daytona] +[run.sandbox.daytona] network = "block" # Allow only specific CIDRs -[sandbox.daytona] +[run.sandbox.daytona] network = { allow_list = ["208.80.154.232/32", "10.0.0.0/8"] } ``` diff --git a/docs/execution/failures.mdx b/docs/execution/failures.mdx index d820951f7..5a219da33 100644 --- a/docs/execution/failures.mdx +++ b/docs/execution/failures.mdx @@ -115,16 +115,13 @@ When a handler returns a `Retry` status instead of `Fail`, retries always procee When a model provider fails with a transient error or quota exhaustion, Fabro can automatically switch to a different provider. Configure fallback chains in your [run configuration](/execution/run-configuration): ```toml title="run.toml" -[llm] -model = "claude-opus-4-6" +[run.model] +name = "claude-opus-4-6" provider = "anthropic" - -[llm.fallbacks] -anthropic = ["gemini", "openai"] -gemini = ["anthropic", "openai"] +fallbacks = ["gemini", "openai"] ``` -When Anthropic is unavailable, Fabro tries Gemini first, then OpenAI. For each fallback provider, Fabro selects the closest model by matching required capabilities (tool use, vision, reasoning) and minimizing cost difference. +When Anthropic is unavailable, Fabro tries Gemini first, then OpenAI. Each fallback entry may be a bare provider token (like `"gemini"`), a bare model alias (like `"gpt-5.4"`), or a qualified `"provider/model"` reference. For each fallback provider, Fabro selects the closest model by matching required capabilities (tool use, vision, reasoning) and minimizing cost difference. ### What triggers failover diff --git a/docs/execution/observability.mdx b/docs/execution/observability.mdx index 47148c782..80eff8ed8 100644 --- a/docs/execution/observability.mdx +++ b/docs/execution/observability.mdx @@ -9,10 +9,11 @@ Fabro captures a structured event for every significant action during a workflow Every workflow run emits a sequence of canonical **run event envelopes** that are: -- Written to `progress.jsonl` in the run directory +- Stored durably in the run store - Broadcast over SSE to connected API clients - Stored for later analysis and retro generation - Rendered by CLI progress and log tooling +- Optionally materialized into JSONL by export/debug paths ### Event names @@ -28,7 +29,7 @@ Event names use lowercase dot notation, for example: ### Envelope format -Each line in `progress.jsonl` is a JSON object with a stable envelope: +Each serialized event envelope has a stable JSON shape: ```json { @@ -64,25 +65,23 @@ Envelope fields: Only `id`, `ts`, `run_id`, and `event` are always present. Optional fields are omitted when they do not apply. -## Reading `progress.jsonl` +## Reading the event stream Because event payload lives in `properties`, most shell queries should look there. ```bash # Count tool calls in a run -jq -r 'select(.event == "agent.tool.started") | .properties.tool_name' \ - ~/.fabro/runs/01JKXYZ.../progress.jsonl | wc -l +fabro logs 01JKXYZ... | jq -r 'select(.event == "agent.tool.started") | .properties.tool_name' | wc -l # Find stage failures -jq 'select(.event == "stage.failed")' \ - ~/.fabro/runs/01JKXYZ.../progress.jsonl +fabro logs 01JKXYZ... | jq 'select(.event == "stage.failed")' # See which edges were taken jq '{from: .properties.from_node, to: .properties.to_node, label: .properties.label}' \ - ~/.fabro/runs/01JKXYZ.../progress.jsonl | head + <(fabro logs 01JKXYZ...) | head ``` -`live.json` is still a pretty-printed copy of the most recent event envelope. +If you need files on disk for offline analysis, `fabro store dump` exports `events.jsonl` plus run-state projections. ## Event categories @@ -111,7 +110,7 @@ Lifecycle events such as `agent.sub.spawned` and `agent.sub.completed` are emitt ### API: Server-Sent Events -When running workflows through the API server, subscribe to the [run events endpoint](/api-reference/runs/stream-run-events). Each SSE payload is a serialized run event envelope in the same shape used by `progress.jsonl`. +When running workflows through the API server, subscribe to the [run events endpoint](/api-reference/runs/stream-run-events). Each SSE payload is a serialized run event envelope in the same shape used by `fabro logs` and `events.jsonl` exports. ### Web UI @@ -127,16 +126,12 @@ The CLI renders live progress from the same envelope format. This is written to ## Post-run analysis -Run artifacts still include: +Post-run analysis surfaces include: -| File | Description | +| Surface | Description | |---|---| -| `run.json` | Run metadata and graph | -| `start.json` | Start record | -| `progress.jsonl` | Full event envelope stream | -| `live.json` | Latest event envelope | -| `checkpoint.json` | Final execution state | -| `retro.json` | Retrospective, when enabled | -| `conclusion.json` | Terminal summary | +| `fabro logs ` | Full event envelope stream as NDJSON | +| `fabro inspect ` | Current durable run state, including run/start/checkpoint/conclusion records | +| `fabro store dump --output ` | Exported `events.jsonl` plus reconstructed JSON and node files | See [retros](/execution/retros), [stages](/api-reference/run-internals/list-run-stages), and [turns](/api-reference/run-internals/list-stage-turns) for higher-level analysis views built on top of this event stream. diff --git a/docs/execution/retros.mdx b/docs/execution/retros.mdx index d6b5ad4f5..200bd52c8 100644 --- a/docs/execution/retros.mdx +++ b/docs/execution/retros.mdx @@ -4,7 +4,7 @@ description: "Automatic retrospectives that analyze every workflow run" --- -**Experimental feature.** Retros are disabled by default. Enable them with `[features] retros = true` in your project config or server config. +**Experimental feature.** Retros are disabled by default. Enable them by setting `retros = true` under `[run.execution]` in your project or workflow config. After every workflow run, Fabro can generate a **retro** — a structured retrospective that captures what happened, what went well, and what didn't. Retros combine deterministic metrics extracted from the run's checkpoint with a qualitative narrative produced by an LLM agent that analyzes the full event stream. @@ -30,7 +30,7 @@ The quantitative layer is extracted directly from the [checkpoint](/execution/ch ### Narrative layer -An LLM agent reads the run's `progress.jsonl` event stream and produces a structured analysis: +An LLM agent reads the run's full event stream and produces a structured analysis: | Field | Description | |---|---| @@ -99,9 +99,9 @@ Open items capture follow-up work identified during the run: Retro generation happens in two phases after a run completes: -1. **Derive** — Fabro extracts stage durations from `progress.jsonl` and builds a retro from the checkpoint data. This is deterministic, fast, and produces the quantitative layer. The retro is saved immediately as `retro.json` in the run's directory. +1. **Derive** — Fabro extracts stage durations from durable run events and builds a retro from the checkpoint data. This is deterministic, fast, and produces the quantitative layer. -2. **Narrate** — An LLM agent session analyzes the run data. The agent has read access to `progress.jsonl`, `checkpoint.json`, `run.json`, and `start.json`. It uses grep and read tools to find interesting signals — failures, retries, errors, approach changes — then calls a `submit_retro` tool with its structured analysis. The narrative fields are merged into the existing retro and saved. +2. **Narrate** — An LLM agent session analyzes the run data. The agent receives temp files named `progress.jsonl`, `checkpoint.json`, `run.json`, and `start.json` inside its sandbox so it can grep and read the event stream and run state. The narrative fields are merged back into durable retro state. Both phases run automatically at the end of every CLI run. The API server derives the quantitative layer but does not currently run the narrative agent. @@ -113,19 +113,12 @@ Both phases run automatically at the end of every CLI run. The API server derive ### CLI -Retros are saved to `{run_dir}/retro.json` after every run. The path is printed at the end of the run output: +To enable retros for your project, set `retros = true` under `[run.execution]` in your `.fabro/project.toml`: -``` -Retro: smooth — Successfully implemented the feature - Retro saved to ~/fabro-logs/01JKXYZ.../retro.json -``` +```toml title=".fabro/project.toml" +_version = 1 -To enable retros for your project, set `retros = true` in the `[features]` section of your `fabro.toml`: - -```toml title="fabro.toml" -version = 1 - -[features] +[run.execution] retros = true ``` @@ -135,10 +128,12 @@ To skip retro generation for a single run when retros are enabled, pass `--no-re fabro run workflow.fabro --no-retro ``` -Retros can also be enabled server-wide in `server.toml`: +Retros can also be enabled server-wide in `settings.toml`: -```toml title="server.toml" -[features] +```toml title="settings.toml" +_version = 1 + +[run.execution] retros = true ``` @@ -148,4 +143,4 @@ Retros are also available via the REST API. See the [list retros](/api-reference ## Storage -Retros are stored as `retro.json` in the run's directory alongside `checkpoint.json` and `progress.jsonl`. They are plain JSON files — easy to parse, query, or pipe into other tools. +Retros are stored in durable run state. If you need files on disk, `fabro store dump` materializes the retro as `retro.json` alongside other exported run data. diff --git a/docs/execution/run-configuration.mdx b/docs/execution/run-configuration.mdx index ceeb2f483..3c5dc09e6 100644 --- a/docs/execution/run-configuration.mdx +++ b/docs/execution/run-configuration.mdx @@ -3,7 +3,7 @@ title: "Run Configuration" description: "Configure workflow runs with TOML files" --- -A run config is a TOML file that bundles a workflow graph with all the settings needed to execute it — the goal, model, sandbox, setup commands, variables, and hooks. Instead of passing a dozen CLI flags, you check a `.toml` file into version control and launch with a single command: +A run config is a TOML file that bundles a workflow graph with all the settings needed to execute it — the goal, model, sandbox, prepare steps, inputs, and hooks. Instead of passing a dozen CLI flags, you check a `.toml` file into version control and launch with a single command: ```bash fabro run run.toml @@ -11,142 +11,154 @@ fabro run run.toml ## Minimal example -A run config requires two fields: +A run config needs at minimum a schema version and a goal: ```toml title="run.toml" -version = 1 +_version = 1 + +[workflow] graph = "workflow.fabro" + +[run] goal = "Implement the login feature" ``` | Field | Required | Description | |---|---|---| -| `version` | Yes | Config format version. Must be `1`. | -| `graph` | Yes | Path to the Graphviz workflow file, resolved relative to the TOML file's directory. | -| `goal` | No | What the workflow should accomplish. Passed to agents and used in retrospectives. Can also be provided via `--goal` CLI flag or Graphviz graph `goal` attribute. | +| `_version` | No (defaults to `1`) | Schema version. Must be `1` in the first pass. | +| `[workflow].graph` | No | Path to the Graphviz workflow file, relative to the TOML file's directory. Defaults to `workflow.fabro`. | +| `[run].goal` | No | What the workflow should accomplish. Passed to agents and used in retrospectives. Can also be provided via `--goal` CLI flag or Graphviz graph `goal` attribute. | -Goal precedence: CLI `--goal` > TOML `goal` > Graphviz graph attribute. +Goal precedence: CLI `--goal` > `[run].goal` > Graphviz graph attribute. ## Full example ```toml title="run.toml" -version = 1 -goal = "Run the CI pipeline for $repo_name" -graph = "fabro/workflows/ci.fabro" -directory = "/tmp/workdir" +_version = 1 -[llm] -model = "claude-sonnet-4-5" +[workflow] +graph = ".fabro/workflows/ci.fabro" -[llm.fallbacks] -anthropic = ["gemini", "openai"] -gemini = ["anthropic", "openai"] +[run] +goal = "Run the CI pipeline" +working_dir = "/tmp/workdir" -[setup] -commands = ["git clone $repo_url repo", "cd repo && npm install"] -timeout_ms = 120000 +[run.model] +name = "claude-sonnet-4-5" +fallbacks = ["openai", "gemini"] -[sandbox] +[[run.prepare.steps]] +script = "git clone https://github.com/fabro-sh/fabro repo" + +[[run.prepare.steps]] +script = "cd repo && npm install" + +[run.sandbox] provider = "daytona" preserve = false -[sandbox.daytona] +[run.sandbox.daytona] auto_stop_interval = 60 -[sandbox.daytona.labels] +[run.sandbox.daytona.labels] project = "fabro" env = "ci" -[sandbox.daytona.snapshot] +[run.sandbox.daytona.snapshot] name = "node-20" cpu = 4 -memory = 8 -disk = 20 +memory = "8GB" +disk = "20GB" dockerfile = "FROM node:20-slim\nRUN apt-get update && apt-get install -y git" -[sandbox.env] -API_KEY = "${env.MY_API_KEY}" +[run.sandbox.env] +API_KEY = "{{ env.MY_API_KEY }}" NODE_ENV = "production" -[checkpoint] +[run.checkpoint] exclude_globs = ["**/node_modules/**", "**/.cache/**"] -[vars] +[run.inputs] repo_name = "fabro" repo_url = "https://github.com/fabro-sh/fabro" -[assets] +[run.artifacts] include = ["test-results/**", "playwright-report/**"] -[mcp_servers.playwright] +[run.agent.mcps.playwright] type = "sandbox" command = ["npx", "@playwright/mcp@latest", "--port", "3100", "--headless"] port = 3100 -[pull_request] +[run.pull_request] enabled = true draft = false -[[hooks]] +[[run.hooks]] +id = "pre-check" event = "stage_start" -command = "./scripts/pre-check.sh" +script = "./scripts/pre-check.sh" blocking = true sandbox = false -[[hooks]] +[[run.hooks]] event = "run_complete" -command = "echo done" +script = "echo done" ``` ## Sections -### `[llm]` +### `[run.model]` Override the default model and provider for all nodes that don't have an explicit model assigned via a [stylesheet](/workflows/stylesheets). ```toml title="run.toml" -[llm] -model = "claude-sonnet-4-5" +[run.model] +name = "claude-sonnet-4-5" ``` | Field | Description | |---|---| -| `model` | Model ID or alias (e.g. `claude-sonnet-4-5`, `opus`, `gemini-pro`). See [Models](/core-concepts/models). | +| `name` | Model ID or alias (e.g. `claude-sonnet-4-5`, `opus`, `gemini-pro`). See [Models](/core-concepts/models). | | `provider` | Provider name (optional — auto-inferred from the model catalog). Only needed for models not in the catalog or to force a specific provider. | +| `fallbacks` | Ordered list of model references to try when the primary is unavailable. Entries can be bare provider tokens (`"openai"`), bare model aliases, or qualified `"provider/model"` references. | -#### `[llm.fallbacks]` +#### Fallbacks with splice -Map each provider to an ordered list of fallback providers. When the primary provider is unavailable, Fabro tries the fallbacks in order: +Use the reserved `"..."` marker in `fallbacks` to splice in the inherited list from lower-precedence layers: ```toml title="run.toml" -[llm.fallbacks] -anthropic = ["gemini", "openai"] -gemini = ["anthropic", "openai"] +[run.model] +# Prepend "anthropic" to whatever fallbacks the project config already defines. +fallbacks = ["anthropic", "..."] ``` -### `[setup]` +### `[run.prepare]` -Shell commands to run before the workflow starts. Use this to clone repositories, install dependencies, or prepare the environment. +Ordered list of steps to run before the workflow starts. Use this to clone repositories, install dependencies, or prepare the environment. ```toml title="run.toml" -[setup] -commands = ["pip install -r requirements.txt", "npm install"] -timeout_ms = 60000 +[[run.prepare.steps]] +script = "pip install -r requirements.txt" + +[[run.prepare.steps]] +script = "npm install" ``` | Field | Description | |---|---| -| `commands` | List of shell commands, executed sequentially via `sh -c`. | -| `timeout_ms` | Per-command timeout in milliseconds. Default: `300000` (5 minutes). | +| `script` | Shell-evaluated command (runs through `sh -c`). | +| `command` | Argv-style command, mutually exclusive with `script`. | +| `env` | Additional environment variables for this step. | -Each command must exit with status 0. If any command fails or times out, the run aborts before the workflow starts. +Each step must exit with status 0. If any step fails, the run aborts before the workflow starts. Prepare steps replace across layers — the higher-precedence layer wins wholesale. -### `[sandbox]` +### `[run.sandbox]` Configure how agent tools (bash, file edits) are executed. ```toml title="run.toml" -[sandbox] +[run.sandbox] provider = "docker" preserve = true ``` @@ -157,23 +169,23 @@ preserve = true | `preserve` | When `true`, keep the sandbox alive after the run finishes. Useful for debugging. | | `devcontainer` | When `true`, use the repo's `devcontainer.json` to configure the sandbox. See [Devcontainers](/execution/devcontainers). | -#### `[sandbox.daytona]` +#### `[run.sandbox.daytona]` Additional settings when using the Daytona cloud sandbox: ```toml title="run.toml" -[sandbox.daytona] +[run.sandbox.daytona] auto_stop_interval = 60 -[sandbox.daytona.labels] +[run.sandbox.daytona.labels] project = "fabro" env = "staging" -[sandbox.daytona.snapshot] +[run.sandbox.daytona.snapshot] name = "my-snapshot" cpu = 4 -memory = 8 -disk = 20 +memory = "8GB" +disk = "20GB" dockerfile = "FROM rust:1.85-slim-bookworm\nRUN apt-get update" # Or reference an external Dockerfile: # dockerfile = { path = "./Dockerfile" } @@ -182,20 +194,20 @@ dockerfile = "FROM rust:1.85-slim-bookworm\nRUN apt-get update" | Field | Description | |---|---| | `auto_stop_interval` | Minutes of inactivity before the sandbox auto-stops. | -| `labels` | Key-value labels attached to the sandbox for filtering and identification. | +| `labels` | Key-value labels attached to the sandbox for filtering and identification. Labels merge across layers (sticky merge-by-key). | | `snapshot.name` | Snapshot name to create or use for the sandbox. | -| `snapshot.cpu` | CPU cores for the snapshot. | -| `snapshot.memory` | Memory in GB for the snapshot. | -| `snapshot.disk` | Disk in GB for the snapshot. | +| `snapshot.cpu` | CPU cores for the snapshot (integer). | +| `snapshot.memory` | Memory size using human-readable units: `"8GB"`, `"16GiB"`, or bare integers that default to GB. | +| `snapshot.disk` | Disk size using the same units as `memory`. | | `snapshot.dockerfile` | Dockerfile content (inline string) or path (`{ path = "..." }`) for building the snapshot image. Paths are resolved relative to the TOML file's directory. | | `network` | Network access mode: `"allow_all"` (default), `"block"`, or `{ allow_list = ["..."] }`. See [Sandboxing](/administration/sandboxing#network-access-control). | -#### `[sandbox.local]` +#### `[run.sandbox.local]` Additional settings when using the local sandbox: ```toml title="run.toml" -[sandbox.local] +[run.sandbox.local] worktree_mode = "always" ``` @@ -203,29 +215,31 @@ worktree_mode = "always" |---|---| | `worktree_mode` | When to create a git worktree for the run: `always`, `clean` (default — only when the working tree is clean), `dirty` (also when dirty), or `never`. | -#### `[sandbox.env]` +#### `[run.sandbox.env]` -Pass environment variables into sandbox command and agent execution. Values can be literal strings or host environment passthrough using `${env.VARNAME}` syntax: +Pass environment variables into sandbox command and agent execution. Values can be literal strings or host environment references using `{{ env.VARNAME }}` syntax: ```toml title="run.toml" -[sandbox.env] -API_KEY = "${env.MY_API_KEY}" +[run.sandbox.env] +API_KEY = "{{ env.MY_API_KEY }}" NODE_ENV = "production" +SERVICE_URL = "https://api.{{ env.REGION }}.example.com" ``` | Syntax | Description | |---|---| | `"literal"` | Static value passed as-is | -| `"${env.VARNAME}"` | Resolved from the host environment at load time. Missing vars produce a hard error. | +| `"{{ env.VARNAME }}"` | Whole-value reference resolved from the host environment at consumption time | +| `"prefix-{{ env.X }}-suffix"` | Substring interpolation; multiple tokens per string are supported | -Host env references must be whole-value only — partial interpolation like `"prefix-${env.X}"` is not supported. Sandbox env vars from `server.toml` defaults and the run config are merged, with the run config winning on key collisions. +Missing host variables produce a hard error pointing at the specific field and unresolved token. `run.sandbox.env` is a sticky merge-by-key map: entries from all layers combine, with higher-precedence layers overriding individual keys. -### `[checkpoint]` +### `[run.checkpoint]` Configure how git checkpoint commits behave. ```toml title="run.toml" -[checkpoint] +[run.checkpoint] exclude_globs = ["**/node_modules/**", "**/.cache/**", "**/dist/**"] ``` @@ -233,37 +247,39 @@ exclude_globs = ["**/node_modules/**", "**/.cache/**", "**/dist/**"] |---|---| | `exclude_globs` | Glob patterns for files to exclude from checkpoint commits. Uses git pathspec `:(glob,exclude)` syntax. | -Exclude globs from `server.toml` defaults and the run config are merged (union, deduplicated). +`exclude_globs` replaces across layers — the higher-precedence layer wins wholesale. -### `[vars]` +### `[run.inputs]` -Define variables that are expanded into the Graphviz source before the graph is parsed. See [Variables](/workflows/variables) for the full reference. +Define inputs that are expanded into the Graphviz source before the graph is parsed. See [Variables](/workflows/variables) for the full reference. ```toml title="run.toml" -[vars] +[run.inputs] repo_name = "fabro" repo_url = "https://github.com/fabro-sh/fabro" language = "rust" ``` -Variables can be used anywhere in the Graphviz file with `$name` syntax: +Inputs can be used anywhere in the Graphviz file with `{{ inputs.name }}` syntax: ```dot title="c-i.fabro" digraph CI { - graph [goal="Run tests for $repo_name"] - clone [shape=parallelogram, script="git clone $repo_url repo"] - test [label="Test", prompt="Run the $language test suite."] + graph [goal="Run tests for {{ inputs.repo_name }}"] + clone [shape=parallelogram, script="git clone {{ inputs.repo_url }} repo"] + test [label="Test", prompt="Run the {{ inputs.language }} test suite."] } ``` -If a `$variable` in the Graphviz file has no matching entry in `[vars]`, Fabro raises an error immediately. A bare `$` not followed by an identifier (e.g. `costs $5`) is left as-is. +If a workflow template references an undefined input like `{{ inputs.langauge }}`, Fabro raises an error immediately. -### `[assets]` +`[run.inputs]` replaces wholesale across layers. Unlike labels, inputs do not merge by key — the highest-precedence layer that sets `inputs` wins its entire map. + +### `[run.artifacts]` Configure automatic collection of test artifacts (Playwright reports, JUnit XML, screenshots, etc.) from the execution environment after each stage. ```toml title="run.toml" -[assets] +[run.artifacts] include = ["test-results/**", "playwright-report/**", "*.trace.zip"] ``` @@ -271,40 +287,41 @@ include = ["test-results/**", "playwright-report/**", "*.trace.zip"] |---|---| | `include` | Glob patterns for files to collect as assets. Matched against the working directory after each stage completes. | -Asset collection is opt-in — when no `[assets]` section is present, no file scanning occurs. This avoids the overhead of scanning large working directories when assets aren't needed. +Artifact collection is opt-in — when no `[run.artifacts]` section is present, no file scanning occurs. -### `[mcp_servers]` +### `[run.agent.mcps]` -Configure [MCP servers](/agents/mcp) available to agent stages during the workflow run. Each server is a named TOML table. All three transport types are supported: `stdio`, `http`, and `sandbox`. +Configure [MCP servers](/agents/mcp) available to agent stages during the workflow run. Each server is a named TOML table under `[run.agent.mcps]`. All three transport types are supported: `stdio`, `http`, and `sandbox`. ```toml title="run.toml" -[mcp_servers.playwright] +[run.agent.mcps.playwright] type = "sandbox" command = ["npx", "@playwright/mcp@latest", "--port", "3100", "--headless", "--browser", "chromium"] port = 3100 -startup_timeout_secs = 60 -tool_timeout_secs = 120 +startup_timeout = "60s" +tool_timeout = "2m" ``` | Field | Description | Default | |---|---|---| | `type` | Transport type: `"stdio"`, `"http"`, or `"sandbox"`. | — | -| `command` | (stdio, sandbox) Array: executable + arguments. | — | +| `script` | (stdio, sandbox) Shell-evaluated startup command, mutually exclusive with `command`. | — | +| `command` | (stdio, sandbox) Argv array: executable + arguments. | — | | `port` | (sandbox) Port the server listens on inside the sandbox. | — | | `url` | (http) The MCP server endpoint URL. | — | | `env` | (stdio, sandbox) Additional environment variables. | `{}` | | `headers` | (http) Optional HTTP headers for authentication. | `{}` | -| `startup_timeout_secs` | Max seconds for server startup + MCP handshake. | `10` | -| `tool_timeout_secs` | Max seconds for a single tool call. | `60` | +| `startup_timeout` | Max duration for server startup + MCP handshake (e.g. `"10s"`, `"1m"`). | `"10s"` | +| `tool_timeout` | Max duration for a single tool call. | `"60s"` | The `sandbox` transport runs the MCP server inside the workflow's sandbox. This is useful for tools that need access to the sandbox environment, such as browser automation with Playwright. See [MCP](/agents/mcp#sandbox) for details. -### `[pull_request]` +### `[run.pull_request]` Automatically open a GitHub pull request when the workflow run completes successfully. Requires a [GitHub App](/integrations/github) to be configured. ```toml title="run.toml" -[pull_request] +[run.pull_request] enabled = true draft = true auto_merge = false @@ -318,64 +335,46 @@ merge_strategy = "squash" | `auto_merge` | When `true`, enables GitHub auto-merge on the created PR. Implies `draft = false` since GitHub doesn't allow auto-merge on draft PRs. The repository must have auto-merge enabled in GitHub settings. Default: `false`. | | `merge_strategy` | Merge method when `auto_merge` is enabled: `squash` (default), `merge`, or `rebase`. | -### `[github]` - -Request a scoped GitHub Installation Access Token and inject it into the sandbox as `GITHUB_TOKEN`. The token is minted from the configured [GitHub App](/integrations/github) with only the permissions you specify. - -```toml title="run.toml" -[github] -permissions = { contents = "write", pull_requests = "read" } -``` - -| Field | Description | -|---|---| -| `permissions` | Map of GitHub API permission names to access levels (`"read"` or `"write"`). Only the listed permissions are requested. | - -This requires a GitHub App to be configured. If the app is missing or the repository doesn't have an installation, the run logs a warning and continues without injecting the token. - -### `[[hooks]]` +### `[[run.hooks]]` Define hooks that run in response to lifecycle events. Each hook is a TOML array entry: ```toml title="run.toml" -[[hooks]] -name = "pre-check" +[[run.hooks]] +id = "pre-check" +name = "Pre-check script" event = "stage_start" -command = "./scripts/pre-check.sh" +script = "./scripts/pre-check.sh" matcher = "agent_loop" blocking = true -timeout_ms = 30000 +timeout = "30s" sandbox = false ``` | Field | Description | |---|---| +| `id` | Optional merge identity. Hooks with the same `id` replace each other across layers. | | `name` | Optional display name for the hook. | -| `event` | Lifecycle event: `run_start`, `run_complete`, `stage_start`, `stage_complete`. | -| `command` | Shell command to execute (shorthand for `type = "command"`). | +| `event` | Lifecycle event: `run_start`, `run_complete`, `stage_start`, `stage_complete`, etc. | +| `script` | Shell-evaluated command (equivalent to the old `type = "command"` shorthand). | +| `command` | Argv-style command (alternative to `script`). | | `matcher` | Regex matched against node ID or handler type. Limits which stages trigger this hook. | | `blocking` | Whether the hook must complete before execution continues. Defaults vary by event. | -| `timeout_ms` | Hook timeout in milliseconds. Default: `60000` (60s). | +| `timeout` | Human-readable hook timeout (e.g. `"30s"`, `"1m"`). Default: `"60s"`. | | `sandbox` | Run inside the sandbox (`true`, default) or on the host (`false`). | -See [Hooks](/agents/hooks) for hook types beyond simple commands (HTTP, prompt, agent). +Hook merge semantics: hooks with matching `id` values replace in place. Hooks without an `id` from a higher-precedence layer append after the fully merged inherited hook list. -## Top-level fields - -In addition to the sections above, two optional top-level fields are available: - -| Field | Description | -|---|---| -| `directory` | Working directory for the run. Defaults to the current directory. | +See [Hooks](/agents/hooks) for hook types beyond scripts (HTTP, prompt, agent). ## Graph path resolution -The `graph` path is resolved relative to the TOML file's parent directory, not the current working directory. This means a run config and its workflow can live side by side: +The `[workflow].graph` path is resolved relative to the TOML file's parent directory, not the current working directory. This means a run config and its workflow can live side by side: ``` project/ runs/ - ci.toml # graph = "ci.fabro" + ci.toml # [workflow] graph = "ci.fabro" ci.fabro ``` @@ -388,67 +387,47 @@ Settings can come from multiple sources. Fabro resolves them in this order (firs | Source | Priority | |---|---| | Node-level [stylesheet](/workflows/stylesheets) | Highest | -| Run config TOML | | | CLI flags (`--model`, `--provider`, `--sandbox`) | | -| Project defaults (`fabro.toml`) | | -| Server defaults (`~/.fabro/server.toml`) | | +| Run config TOML (`workflow.toml` or equivalent) | | +| Project defaults (`.fabro/project.toml`) | | +| Machine defaults (`~/.fabro/settings.toml`) | | | Graphviz graph attributes (`default_model`, `default_provider`) | | | Built-in defaults | Lowest | -For model and provider specifically, the precedence is: CLI flags > TOML config > project defaults > server defaults > Graphviz graph attributes > built-in defaults. Stylesheet rules on individual nodes always take priority over all of these. +Stylesheet rules on individual nodes always take priority over run config values. -### Project defaults (`fabro.toml`) +### Project defaults (`.fabro/project.toml`) -The `fabro.toml` project config can set default values for `[llm]`, `[setup]`, `[sandbox]`, `[vars]`, `[checkpoint]`, `[pull_request]`, `[github]`, `[assets]`, `[[hooks]]`, and `[mcp_servers]`. These defaults apply to all runs in the project unless the run config overrides them: +The `.fabro/project.toml` project config can set default values for any of the `[run.*]` sections described above. These defaults apply to all runs in the project unless the workflow config overrides them: -```toml title="fabro.toml" -version = 1 +```toml title=".fabro/project.toml" +_version = 1 -[llm] -model = "claude-sonnet-4-5" +[run.model] +name = "claude-sonnet-4-5" -[sandbox] +[run.sandbox] provider = "daytona" -[sandbox.daytona.snapshot] +[run.sandbox.daytona.snapshot] name = "my-project-snapshot" - -[github] -permissions = { contents = "write" } ``` -Project defaults are merged with run config values using the same rules as server defaults — run config wins on key collisions. +Project defaults and workflow config values merge per the normative merge matrix: most fields merge by field (higher-precedence wins per key), `run.inputs` replaces wholesale, `run.sandbox.env` sticky-merges by key, and `run.prepare.steps` replaces whole-list. -### Server defaults +### Machine defaults -When running via `fabro server start`, the server config at `~/.fabro/server.toml` can set default values for `[llm]`, `[setup]`, `[sandbox]`, and `[vars]`. These defaults are applied to every run unless the run config overrides them. - -For variables, defaults and run config are **merged** — the run config wins on key collisions: - -```toml -# ~/.fabro/server.toml -[vars] -default_key = "from_server" -shared = "from_server" - -# run.toml -[vars] -shared = "from_run" # wins -task_key = "from_run" -``` - -The same merge behavior applies to Daytona labels. All other fields use simple "first non-empty wins" precedence. +When running locally, the machine defaults at `~/.fabro/settings.toml` can set run-scoped defaults too. Same merge rules apply. ## Validation Fabro validates the run config when it loads: -- **Version check** — Only `version = 1` is accepted. Other versions are rejected immediately. -- **Required fields** — `version` and `graph` are required. `goal` is optional (can be provided via `--goal` or Graphviz graph attribute). -- **Unknown fields** — Extra fields not listed above are silently ignored. -- **Variable check** — Any `$variable` in the Graphviz file without a matching `[vars]` entry produces an error. +- **`_version` check** — Only `_version = 1` (or missing, which defaults to `1`) is accepted. The legacy top-level `version` key is rejected with a rename hint. +- **Unknown keys** — Any top-level key not in `[project]`, `[workflow]`, `[run]`, `[cli]`, `[server]`, `[features]`, or `_version` is rejected with a targeted rename hint pointing at the v2 replacement path. +- **Variable check** — Any undefined workflow template variable in the Graphviz file produces an error. Use `fabro preflight` to validate a run config without executing it: diff --git a/docs/getting-started/quick-start.mdx b/docs/getting-started/quick-start.mdx index 6fcd44250..561398101 100644 --- a/docs/getting-started/quick-start.mdx +++ b/docs/getting-started/quick-start.mdx @@ -37,7 +37,7 @@ cd my-repo/ fabro repo init ``` -This creates a default workflow and configuration in your project directory. +This creates `.fabro/project.toml` and a starter workflow under `.fabro/workflows/hello/`. ## Configure API keys diff --git a/docs/human-tools/interviews.mdx b/docs/human-tools/interviews.mdx index bc2f32092..1d513c7e6 100644 --- a/docs/human-tools/interviews.mdx +++ b/docs/human-tools/interviews.mdx @@ -44,7 +44,7 @@ Answers are one of seven variants: | `No` | Negative response | | `Selected(key)` | A specific option was chosen (carries the option key) | | `Text(string)` | Free-text input | -| `Aborted` | No answer was obtained because the prompt ended early (EOF, cancel, disconnected session, exhausted replay/queue) | +| `Interrupted` | No answer was obtained because the prompt ended early (EOF, cancel, disconnected session, exhausted replay/queue) | | `Skipped` | Legacy skip-style answer; not treated as approval by human gates | | `Timeout` | The question's timeout elapsed without a response | @@ -77,7 +77,7 @@ fabro run workflow.fabro # Select: ``` -If the console prompt is canceled or stdin is already closed, the interviewer returns `Aborted`. That does not count as approval. +If the console prompt is canceled or stdin is already closed, the interviewer returns `Interrupted`. That does not count as approval. ### Web @@ -85,7 +85,7 @@ The default for API server runs. The web interviewer holds questions in a queue This decoupling means the workflow engine and the user interface can run in different processes. The web UI polls for pending questions and posts answers back to the API. -If the pending session disappears before an answer is submitted, the waiting question resolves as `Aborted`. +If the pending session disappears before an answer is submitted, the waiting question resolves as `Interrupted`. ### Slack @@ -121,4 +121,4 @@ The human handler then checks the node's `human.default_choice` attribute. If se approve [shape=hexagon, label="Approve?", human.default_choice="deploy"] ``` -Outside of timeout defaults, human gates fail closed: `Aborted` and `Skipped` answers do not fall through to ordinary approval edges. To model an explicit unanswered path, add an edge such as `condition="outcome=fail"` or configure a `retry_target`. +Outside of timeout defaults, human gates fail closed: `Interrupted` and `Skipped` answers do not fall through to ordinary approval edges. To model an explicit unanswered path, add an edge such as `condition="outcome=fail"` or configure a `retry_target`. diff --git a/docs/human-tools/ssh-access.mdx b/docs/human-tools/ssh-access.mdx index 7683148e4..41fd8934f 100644 --- a/docs/human-tools/ssh-access.mdx +++ b/docs/human-tools/ssh-access.mdx @@ -37,11 +37,11 @@ Without `--preserve-sandbox`, the SSH session is terminated when the run ends an You can also set `auto_stop_interval` in your run config to control how long an idle sandbox stays alive: ```toml title="run.toml" -[sandbox] +[run.sandbox] provider = "daytona" preserve = true -[sandbox.daytona] +[run.sandbox.daytona] auto_stop_interval = 60 ``` diff --git a/docs/human-tools/steering.mdx b/docs/human-tools/steering.mdx index 77e163cde..e834889e2 100644 --- a/docs/human-tools/steering.mdx +++ b/docs/human-tools/steering.mdx @@ -11,14 +11,12 @@ A steering message is injected into the agent's conversation as a user-role mess The delivery flow: -1. You send a `POST /api/v1/runs/{id}/steer` request with your guidance text +1. You send a steering request with your guidance text 2. The message is queued on the agent session's steering queue 3. Before the next LLM call, Fabro drains the queue and injects each message as a `Steering` turn in the conversation history 4. The LLM sees the guidance alongside its existing context and adjusts accordingly -Steering is **asynchronous** — the API returns `202 Accepted` immediately. The agent picks up the message at its next natural pause point (between tool calls), not mid-execution. - -See the [Steer Run API reference](/api-reference/human-in-the-loop/steer-run) for the full request and response schema. +Steering is **asynchronous** — the agent picks up the message at its next natural pause point (between tool calls), not mid-execution. ## When steering is delivered diff --git a/docs/ideation/2026-04-02-slatedb-consolidation-ideation.md b/docs/ideation/2026-04-02-slatedb-consolidation-ideation.md new file mode 100644 index 000000000..59d683202 --- /dev/null +++ b/docs/ideation/2026-04-02-slatedb-consolidation-ideation.md @@ -0,0 +1,86 @@ +--- +date: 2026-04-02 +topic: slatedb-consolidation +focus: Consolidate SlateDB from one-per-run to single instance owned by fabro server, all access via HTTP/Unix socket +--- + +# Ideation: SlateDB Consolidation Behind Server + +## Codebase Context + +**Project shape:** Rust workspace (30 crates) + React 19 frontend. AI-powered workflow orchestration platform. + +**Current storage architecture:** +- `fabro-store` crate defines `Store` and `RunStore` async traits with `SlateStore` and `InMemoryStore` impls +- Each workflow run gets its own SlateDB database (unique `db_prefix` per run) +- `SlateRunDb` enum distinguishes Writer (`slatedb::Db`) and Reader (`DbReader`) +- CLI opens SlateDB directly via `build_store()` (~25 call sites) +- Server also opens SlateDB directly +- Unix socket binding and server daemon management already implemented + +**Key constraints:** +- SlateDB chosen for "bottomless" S3 storage path — not replaceable with SQLite +- Greenfield app, not yet deployed — no migration concerns +- Events-as-source-of-truth migration actively in progress +- Moving away from SQLite dependency, not toward it + +## Ranked Ideas + +### 1. Events-First Server Architecture +**Description:** Make events the sole write path. CLI POSTs events to the server, server materializes all state (run record, node outcomes, checkpoints, etc.) from events via projection. SSE pushes events to subscribers instantly. The Writer/Reader distinction is eliminated. The API is coarser-grained REST shaped by domain needs (POST /runs/{id}/events, GET /runs/{id}/state, GET /runs/{id}/events?stream SSE) — not a 1:1 mirror of the 40-method RunStore trait. +**Rationale:** Collapses 40 put methods to a single append endpoint. Converges with the events-as-source-of-truth work already in progress. Makes SSE push, Writer/Reader elimination, trait flattening, and in-memory projection all fall out as natural consequences. Both critics called this "the plan." +**Downsides:** Requires events-as-source-of-truth to be complete enough that all RunStore state is derivable from events. The follow-ups doc lists gaps. +**Confidence:** 90% +**Complexity:** High +**Status:** In Progress + +### 2. Auto-Start Server Daemon +**Description:** When a CLI command needs store access and `fabro.sock` is missing (or unreachable), automatically run `fabro server start` before proceeding. Uses existing daemon management and readiness probe. +**Rationale:** Without this, every CLI command fails unless the user manually starts the server first. The daemon infrastructure already exists (start.rs has try_connect readiness probe, daemon spawning, flock locking). Auto-start is the difference between smooth DX and broken DX. +**Downsides:** First command in a session pays ~1-2s startup cost. Edge cases around stale sockets and failed starts. +**Confidence:** 95% +**Complexity:** Low +**Status:** Unexplored + +### 3. Thin HTTP Client for CLI +**Description:** An HTTP-backed `Store`/`RunStore` implementation that connects to the server over Unix socket. With events-first (#1), this is thin — mostly `POST /events`, SSE subscription, and a handful of read endpoints. Replaces the ~25 `build_store()` call sites. +**Rationale:** The mechanical bridge between CLI and server. The trait boundary is the right seam. `InMemoryStore` tests are completely unaffected. +**Downsides:** Error handling for server-down scenarios (mitigated by auto-start #2). +**Confidence:** 95% +**Complexity:** Medium +**Status:** Unexplored + +## Rejection Summary + +| # | Idea | Reason Rejected | +|---|------|-----------------| +| 1 | Batch write API | Premature optimization — measure Unix socket latency first | +| 2 | Run-prefixed key namespace | Implementation detail, falls out of engine design | +| 3 | Streaming binary assets | YAGNI — no evidence of multi-MB blob problems | +| 4 | Graceful fallback / offline mode | Fights the architecture — auto-start is the answer | +| 5 | Connection pooling | Default HTTP client behavior, not a decision | +| 6 | In-memory projection cache | Redundant with events-first — materialization IS the cache | +| 7 | Eliminate SlateDB (BTreeMap + WAL) | Building a database; SlateDB provides S3 path | +| 8 | Auto-generate HTTP client from trait | Contradicts coarser-grained API design | +| 9 | Lazy-load sequence counters | Micro-optimization; events-first removes the problem | +| 10 | Kill HTTP; shared SlateDB as IPC | Contradicts architecture; couples to storage internals | +| 11 | Flip dependency (server in CLI) | Architecturally backwards from stated direction | +| 12 | Event log as IPC (no HTTP) | Shared file append is fragile; HTTP serializes for free | +| 13 | Consolidate catalog only | Half-measure creating a third topology | +| 14 | CLI event-streaming client | Subsumed by events-first | +| 15 | Time-travel / run replay | Not a consolidation decision; events-first makes it free later | +| 16 | Multi-workspace fan-in | Feature creep; no current demand | +| 17 | Reactive web UI via events | Orthogonal frontend concern | +| 18 | Persistent run queue | Orthogonal scheduler feature | +| 19 | MCP host for external agents | Orthogonal; fabro-mcp exists | +| 20 | Tombstone soft delete | Implementation detail of engine | +| 21 | Intent log for crash recovery | Redundant — event log IS the intent log | +| 22 | Column families | SlateDB doesn't support them; premature | +| 23 | Optimistic concurrency control | Single server owns writes — no concurrent writer problem | +| 24 | Backpressure / write batching | Premature; no write pressure evidence | +| 25 | SQLite catalog + cross-run queries | Moving away from SQLite dependency; use SlateDB-based index | +| 26 | Migration command | Greenfield app, not yet deployed | +| 27 | Consolidate to SQLite | SlateDB provides bottomless S3 storage | + +## Session Log +- 2026-04-02: Initial ideation — 35 candidates generated (6 agents), 6 survived critique, 3 accepted by user (rejected: SQLite catalog, migration command, SQLite as engine) diff --git a/docs/ideation/2026-04-08-fabro-event-schema-ideation.md b/docs/ideation/2026-04-08-fabro-event-schema-ideation.md new file mode 100644 index 000000000..d3cb82638 --- /dev/null +++ b/docs/ideation/2026-04-08-fabro-event-schema-ideation.md @@ -0,0 +1,134 @@ +--- +date: 2026-04-08 +topic: fabro-event-schema +focus: greenfield redesign of Fabro's event schemas and streaming contract +--- + +# Ideation: Fabro Event Schema Redesign + +Assumption: greenfield reset. No production deployments, no backward-compatibility constraints, optimize for the best long-term event model rather than incremental migration cost. + +These are not ten unrelated features. They are the ten strongest changes to make as one coherent event-platform redesign. + +## Codebase Context + +- Fabro already has the strongest core envelope in the comparison set: `id`, `ts`, `run_id`, `event`, optional `session_id`, `parent_session_id`, `node_id`, `node_label`, plus `properties` +- `docs-internal/events-strategy.md` already treats events as the durable audit trail that powers storage, SSE, CLI progress, retro analysis, and JSONL sinks +- `RunEvent` and `EventBody` already give Fabro a typed internal model, but the public contract is still more convention-driven than schema-first +- Recent internal plans already push Fabro toward events as source of truth, checkpoint derivation, and simplified `RunEvent`, so the repo is directionally aligned with a stronger event platform +- The biggest remaining gaps are cross-cutting ones: replay, correlation depth, public schema generation, live-stream semantics, and structured status/error/approval states + +## Ranked Ideas + +### 1. Split Fabro into two event products: a durable event log and a live UI stream +**Description:** Define two first-class streams instead of one overloaded one. The durable log contains immutable domain facts suitable for audit, replay, storage, and projections. The live stream contains UI-oriented deltas, snapshots, keep-alives, and fast-changing progress. They share IDs and correlation fields, but they are different contracts. +**Rationale:** This is the highest-leverage fix. Most competitors get into trouble by mixing audit events, high-frequency streaming deltas, control frames, and reconnect machinery into one schema. Fabro should not. Durable events should be boring and trustworthy. Live events should be optimized for interactivity. +**Downsides:** Two contracts are more work than one. Emitters must decide whether an event is durable, live-only, or both. +**Confidence:** 98% +**Complexity:** High +**Status:** Recommended + +### 2. Add a formal replay and resume contract with ordered cursors and snapshot handshakes +**Description:** Every run and session stream gets a monotonic `seq` plus a documented resume protocol: subscribe from cursor, receive the latest snapshot if needed, then apply deltas after `seq > cursor`. Define SSE `id` semantics, dedupe rules, replay buffer guarantees, and failure behavior when a client falls too far behind. +**Rationale:** Goose is the clearest proof that reconnect semantics need to be part of the design, not a side effect. Greenfield Fabro should make "reattach to a long-running workflow" a first-class use case. +**Downsides:** The server needs replay buffers or snapshot storage, and clients need to implement cursor logic correctly. +**Confidence:** 96% +**Complexity:** High +**Status:** Recommended + +### 3. Expand the envelope into a real correlation graph +**Description:** Keep the existing strong envelope and add the IDs Fabro will actually want long term: `workflow_id`, `branch_id`, `checkpoint_id`, `turn_id`, `message_id`, `tool_call_id`, `request_id`, `causation_id`, and `correlation_id`. Not every event sets every field, but the contract makes those slots explicit. +**Rationale:** Current Fabro is strong at run/session/node identity, but still thin below that. The next generation of debugging, UI, projection, and analytics work will want to join events by turn, message, tool call, branch, checkpoint, and request without reconstructing those edges from payloads. +**Downsides:** Emitters and handlers must be stricter about ID ownership and propagation. +**Confidence:** 95% +**Complexity:** Medium +**Status:** Recommended + +### 4. Make the public event contract schema-first and generated from one registry +**Description:** Keep Fabro's `event` + event-specific payload shape, but stop treating the public wire contract as implicit. Generate JSON Schema, TypeScript types, Rust validators, docs, and streaming examples from one source of truth. Every public event family gets an explicit schema version from day one. +**Rationale:** This is the cleanest fix for drift. Claude Sessions benefits from having a clear public union. OpenCode shows how useful generated event types are. Fabro should combine both while preserving its stronger envelope. +**Downsides:** Codegen and schema governance add process overhead. Some engineers will fight the discipline. +**Confidence:** 94% +**Complexity:** High +**Status:** Recommended + +### 5. Normalize lifecycle grammar across all streamable entities +**Description:** Standardize event families around a small lifecycle vocabulary: `.started`, `.delta`, `.snapshot`, `.completed`, `.failed`, `.cancelled`, `.interrupted`. Apply it consistently to runs, stages, sessions, turns, messages, tool calls, commands, checkpoints, parallel branches, and retro work where relevant. +**Rationale:** pi-mono is strongest here. Clients get dramatically simpler when every streamable thing follows the same lifecycle rules instead of bespoke one-off semantics. +**Downsides:** Some event families will feel slightly unnatural if forced into the same lifecycle vocabulary. Discipline matters. +**Confidence:** 93% +**Complexity:** Medium +**Status:** Recommended + +### 6. Replace stringly status and failure fields with explicit tagged unions +**Description:** Stop representing terminal and waiting states as loosely-typed strings where possible. Add typed unions for `stop_reason`, `retry_status`, `approval_status`, `wait_reason`, `error_kind`, `failure_class`, and `interrupt_reason`. Preserve display strings separately when useful. +**Rationale:** Claude Sessions gets this exactly right. This is a major quality jump for policy engines, UIs, analytics, and test fixtures. It also reduces accidental schema drift where one code path emits `"timed_out"` and another emits `"timeout"`. +**Downsides:** More up-front schema design. Adding new states later requires more care. +**Confidence:** 96% +**Complexity:** Medium +**Status:** Recommended + +### 7. Introduce typed content blocks and block-level deltas for agent output +**Description:** Model agent-facing content as typed blocks instead of mostly strings: `text`, `reasoning`, `tool_call`, `tool_result`, `patch`, `file_ref`, `artifact_ref`, `plan`, `todo`, `command_output`, and `summary`. Live deltas target block IDs rather than appending ambiguous raw text. +**Rationale:** This is the difference between a transcript that only humans can read and one that both humans and tools can reason over. It unlocks richer UIs, selective re-rendering, better retro analysis, and much cleaner summarization and compaction. +**Downsides:** The model is more complex than "event name plus string payload." Poorly chosen block boundaries can make clients awkward. +**Confidence:** 92% +**Complexity:** High +**Status:** Recommended + +### 8. Make human-in-the-loop and control-plane semantics first-class events +**Description:** Promote approvals, questions, interrupts, resumes, compactions, and operator interventions into explicit event families: `approval.requested`, `approval.responded`, `question.asked`, `question.answered`, `interrupt.requested`, `interrupt.applied`, `resume.required`, `compaction.started`, `compaction.completed`, `compaction.failed`. +**Rationale:** This is one of the clearest wins from Claude Sessions and OpenCode. Human-in-loop behavior is not edge-case control traffic. It is core workflow state and deserves durable, typed representation. +**Downsides:** It increases surface area. Some flows that are currently implicit must become explicit state machines. +**Confidence:** 94% +**Complexity:** Medium +**Status:** Recommended + +### 9. Add first-class snapshot events for fast attach and projection repair +**Description:** Introduce self-contained snapshot events such as `run.snapshot`, `session.snapshot`, and `checkpoint.saved` that intentionally duplicate enough state to let clients and projectors reattach without replaying the full history. Treat these as part of the contract, not ad hoc recovery hacks. +**Rationale:** This complements replay. Durable facts remain append-only, but long-running workflows need efficient recovery points. Greenfield Fabro can design snapshots deliberately instead of letting checkpoint semantics and UI recovery drift apart. +**Downsides:** Snapshot compaction and retention rules must be explicit or the event system becomes harder to reason about. +**Confidence:** 90% +**Complexity:** High +**Status:** Recommended + +### 10. Promote model, tool, and command work into first-class span-style event families +**Description:** Treat model requests, tool execution, MCP calls, shell commands, and patch application as first-class event families with started/completed/error plus usage, latency, retries, provider, model, routing, approval outcome, and output references. Do not hide these behind generic stage completion summaries. +**Rationale:** Fabro is an AI workflow product. The event model should expose the actual unit economics and failure surfaces of AI work. This gives better debugging, cost analysis, policy enforcement, and product telemetry than aggregating everything back into stage summaries. +**Downsides:** More event volume. Care is needed to keep live deltas separate from durable summaries. +**Confidence:** 93% +**Complexity:** Medium +**Status:** Recommended + +## What This Adds Up To + +If Fabro adopted all ten, the resulting idealized model would look like this: + +- one durable event log for facts +- one live stream for interactive state +- one shared envelope with strong correlation IDs +- one generated public schema registry +- one consistent lifecycle grammar +- one explicit replay/snapshot story +- typed blocks and typed states instead of strings and ad hoc payloads + +That is materially better than any single comparator repo. + +## Rejection Summary + +| # | Idea | Reason Rejected | +|---|------|-----------------| +| 1 | Keep one stream and just document it better | Not enough — durable and live concerns have different optimization goals | +| 2 | Flatten all event-specific fields into the top level | Root churn would make the schema worse, not better | +| 3 | Switch to JSON-RPC-style `method`/`params` notifications | Too transport-shaped for Fabro's broader event-log use case | +| 4 | Use timestamps alone for replay ordering | Weak contract; reconnect needs explicit sequence semantics | +| 5 | Eventize binary artifacts and all large blobs directly | Expensive and noisy; use refs/metadata instead | +| 6 | Keep string errors but standardize message text | Still not machine-readable enough | +| 7 | Make snapshots the only source of truth | Loses auditability and event-sourced advantages | +| 8 | Encode every UI concern in the durable log | Durable logs should stay trustworthy and projection-friendly | +| 9 | Preserve current schema and only add more event names | Misses the deeper contract problems | +| 10 | Treat approvals and questions as transport-level control traffic | These are product-level workflow semantics and belong in the event model | + +## Session Log + +- 2026-04-08: Grounded ideation from current Fabro event docs and code, plus comparison against Claude Sessions, Claude Code, Goose, OpenAI Codex, OpenCode, and pi-mono. Survivors intentionally optimized for greenfield quality rather than migration ease. diff --git a/docs/ideation/2026-04-08-fabro-uninstall-ideation.md b/docs/ideation/2026-04-08-fabro-uninstall-ideation.md new file mode 100644 index 000000000..117eed371 --- /dev/null +++ b/docs/ideation/2026-04-08-fabro-uninstall-ideation.md @@ -0,0 +1,82 @@ +--- +date: 2026-04-08 +topic: fabro-uninstall +focus: add `fabro uninstall` command — the opposite of install +--- + +# Ideation: `fabro uninstall` Command + +## Codebase Context + +- `fabro install` creates `~/.fabro/` with settings.toml, certs/, storage/ (secrets, server state, logs, scratch, store, artifacts), skills/, workflows/, logs/, tmp/ +- `install.sh` modifies shell configs (.zshrc/.bashrc/.config/fish) adding PATH entries with `# fabro` sentinel comment +- `Home` (fabro-util) and `Storage` (fabro-config) structs are canonical path registries +- `system prune` provides the established dry-run/--yes/size-reporting UX pattern +- `server stop` handles graceful SIGTERM→SIGKILL with socket cleanup +- Binary installed to `~/.fabro/bin/fabro` by install.sh; brew/cargo installs go elsewhere + +## Ranked Ideas + +### 1. Core Mirror Uninstall +**Description:** `fabro uninstall` removes `~/.fabro/` entirely, using `Home` and `Storage` structs as the canonical path registry. Compose existing deletion logic rather than writing new teardown code. +**Rationale:** Home/Storage structs enumerate every path, so future paths added to install automatically appear in uninstall — zero maintenance drift. +**Downsides:** None significant — table stakes. +**Confidence:** 95% +**Complexity:** Low +**Status:** Unexplored + +### 2. Server Shutdown First +**Description:** Before removing any files, detect a running server and gracefully stop it, reusing `server stop` logic (SIGTERM→SIGKILL). Only then proceed with file deletion. +**Rationale:** Deleting files under a running server creates orphaned processes, dangling sockets, and corrupt state. +**Downsides:** None — correctness requirement. +**Confidence:** 95% +**Complexity:** Low +**Status:** Unexplored + +### 3. Shell Config Cleanup +**Description:** Remove PATH lines from `.zshrc`/`.bashrc`/`.config/fish` that `install.sh` added, using the `# fabro` sentinel comment as grep target. +**Rationale:** Stale PATH entries are the #1 complaint after CLI uninstalls. Sentinel comment makes surgical removal safe. +**Downsides:** Shell config surgery requires careful testing across shells. +**Confidence:** 85% +**Complexity:** Medium +**Status:** Unexplored + +### 4. Dry-Run by Default +**Description:** Follow `system prune` pattern: list everything that would be removed with sizes, require `--yes` to confirm. Support `--json`. +**Rationale:** Already proven UX in the codebase. Prevents "oops" moments. +**Downsides:** None. +**Confidence:** 95% +**Complexity:** Low +**Status:** Unexplored + +### 5. Binary Self-Removal +**Description:** If binary lives inside `~/.fabro/bin/` (install.sh path), delete it as final step. For brew/cargo, print a hint instead. +**Rationale:** Users expect "uninstall" to mean "gone." Simple conditional based on `current_exe()` path. +**Downsides:** Self-deleting binary must be last step. Detection heuristic could be wrong if user moved the binary. +**Confidence:** 75% +**Complexity:** Low +**Status:** Unexplored + +## Rejection Summary + +| # | Idea | Reason Rejected | +|---|------|-----------------| +| 1 | Selective component uninstall | YAGNI — users can back up manually | +| 2 | GitHub App deregistration | Scope creep — remote API during teardown is fragile | +| 3 | Export before destroy | Gold-plating — `cp -r ~/.fabro` suffices | +| 4 | Telemetry farewell event | Marginal value, unwelcome phoning home | +| 5 | Install manifest | Over-engineering for small deterministic footprint | +| 6 | Per-repo cascade cleanup | Filesystem scanning is slow and presumptuous | +| 7 | Secure secrets shredding | Security theater on modern SSDs | +| 8 | Reframe as `system reset` | Poor discoverability | +| 9 | Doctor extension | Mixing diagnostic and destructive ops | +| 10 | Install --undo flag | Terrible discoverability | +| 11 | Teardown facet trait | Premature abstraction for ~5 steps | +| 12 | Event-sourced uninstall | Requires rewriting install first | +| 13 | Restoration script | Worse version of export (already cut) | +| 14 | Server-mediated uninstall | Circular dependency | +| 15 | Manifest + Selective | Both halves cut | +| 16 | Doctor-informed uninstall | Coupling for no benefit | + +## Session Log +- 2026-04-08: Initial ideation — ~40 generated across 5 agents, deduped to 22, 5 survived. All 5 accepted as facets of one command. Proceeding to plan. diff --git a/docs/images/how-fabro-works.svg b/docs/images/how-fabro-works.svg index a8ec6a045..5e14c861c 100644 --- a/docs/images/how-fabro-works.svg +++ b/docs/images/how-fabro-works.svg @@ -3,7 +3,7 @@ "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> - + -HowArcWorks +HowFabroWorks cluster_author diff --git a/docs/integrations/brave-search.mdx b/docs/integrations/brave-search.mdx index 263a9de93..7d7463875 100644 --- a/docs/integrations/brave-search.mdx +++ b/docs/integrations/brave-search.mdx @@ -9,16 +9,16 @@ Fabro's [`web_search`](/agents/tools#web_search) tool lets agents search the web 1. Get a Brave Search API key from the [Brave Search API dashboard](https://brave.com/search/api/) -2. Add it to your `.env` file: +2. Store it on the Fabro server: ```bash -export BRAVE_SEARCH_API_KEY=BSA... +fabro secret set BRAVE_SEARCH_API_KEY BSA... ``` 3. Verify the key is working: ```bash -fabro doctor --live +fabro doctor ``` The doctor output should show **Brave Search** as "connected". If the key is missing, web search is reported as a warning — workflows still run, but `web_search` calls return an error. @@ -64,7 +64,7 @@ digraph Research { start [shape=Mdiamond, label="Start"] exit [shape=Msquare, label="Exit"] - research [label="Research", prompt="Use web_search to find recent information about $goal. Save your findings to research.md."] + research [label="Research", prompt="Use web_search to find recent information about {{ goal }}. Save your findings to research.md."] summarize [label="Summarize", shape=tab, prompt="Read research.md and write a concise summary of the key findings."] start -> research -> summarize -> exit @@ -73,7 +73,7 @@ digraph Research { ## Troubleshooting -**"BRAVE_SEARCH_API_KEY environment variable is not set"** — Add the key to `.env` or your shell environment. Run `fabro doctor --live` to verify. +**"BRAVE_SEARCH_API_KEY environment variable is not set"** — Add the key with `fabro secret set` or export it in the server process environment. Run `fabro doctor` to verify. **"Brave Search API returned status 401"** — The API key is invalid or expired. Generate a new key from the [Brave Search API dashboard](https://brave.com/search/api/). diff --git a/docs/integrations/daytona.mdx b/docs/integrations/daytona.mdx index cb6e5e07f..51a4b8bad 100644 --- a/docs/integrations/daytona.mdx +++ b/docs/integrations/daytona.mdx @@ -18,7 +18,7 @@ description: "Run Fabro workflows in sandboxed Daytona cloud environments" ## Prerequisites - A `DAYTONA_API_KEY` environment variable (get one from [app.daytona.io](https://app.daytona.io)) -- A [GitHub App](/integrations/github) configured via the web UI (required for private repository cloning and checkpoint pushing) +- GitHub access configured via the default `gh_cli` strategy or a [GitHub App](/integrations/github) (required for private repository cloning and checkpoint pushing) ## Configuration @@ -29,30 +29,30 @@ fabro run workflow.fabro --sandbox daytona ``` ```toml title="run.toml" -[sandbox] +[run.sandbox] provider = "daytona" ``` A full configuration example with all Daytona-specific options: ```toml title="run.toml" -[sandbox] +[run.sandbox] provider = "daytona" preserve = false -[sandbox.daytona] +[run.sandbox.daytona] auto_stop_interval = 60 -[sandbox.daytona.labels] +[run.sandbox.daytona.labels] project = "fabro" env = "staging" team = "platform" -[sandbox.daytona.snapshot] +[run.sandbox.daytona.snapshot] name = "rust-dev" cpu = 4 -memory = 8 -disk = 20 +memory = "8GB" +disk = "20GB" dockerfile = "FROM rust:1.85-slim-bookworm\nRUN apt-get update && apt-get install -y git ripgrep" ``` @@ -64,15 +64,15 @@ Control outbound network access with the `network` field. Three modes are availa ```toml title="run.toml" # Full access (default) -[sandbox.daytona] +[run.sandbox.daytona] network = "allow_all" # Block all egress -[sandbox.daytona] +[run.sandbox.daytona] network = "block" # CIDR-based allow list -[sandbox.daytona] +[run.sandbox.daytona] network = { allow_list = ["208.80.154.232/32", "10.0.0.0/8"] } ``` @@ -99,14 +99,13 @@ If a snapshot is configured by name but doesn't exist and no `dockerfile` is pro ## Private repositories -Fabro automatically clones the current repository into the sandbox at `/home/daytona/workspace`. Public repositories work without extra configuration. Private repositories require a [GitHub App](/integrations/github) — Fabro uses short-lived Installation Access Tokens scoped to the specific repository. +Fabro automatically clones the current repository into the sandbox at `/home/daytona/workspace`. Public repositories work without extra configuration. Private repositories require GitHub access. In `gh_cli` mode, Fabro uses the stored CLI token directly. In `app` mode, Fabro uses short-lived Installation Access Tokens scoped to the specific repository. -If the clone fails without a GitHub App configured, Fabro suggests running the setup flow: +If the clone fails without GitHub access configured, Fabro suggests running the setup flow: ``` Git clone failed: ... If this is a private repository, -configure a GitHub App with `fabro install` and install it -for your organization. +run `gh auth login` or `fabro install` to configure GitHub access. ``` ## SSH access @@ -138,7 +137,7 @@ fabro run workflow.fabro --sandbox daytona --preserve-sandbox Or in the run config: ```toml title="run.toml" -[sandbox] +[run.sandbox] provider = "daytona" preserve = true ``` @@ -150,13 +149,13 @@ When preserved, Fabro prints the sandbox name so you can find it in the [Daytona The `auto_stop_interval` setting tells Daytona to stop the sandbox after a period of inactivity, saving costs for preserved or long-running sandboxes: ```toml title="run.toml" -[sandbox.daytona] +[run.sandbox.daytona] auto_stop_interval = 30 ``` ## Server defaults -When running via `fabro server start`, the server config at `~/.fabro/server.toml` can set default Daytona settings for all runs. Run config TOML values override server defaults. Labels are **merged** — run config labels win on key collisions. The `network` setting uses simple override (run config replaces the server default entirely). +When running via `fabro server start`, the server config at `~/.fabro/settings.toml` can set default Daytona settings for all runs. Run config TOML values override server defaults. Labels are **merged** — run config labels win on key collisions. The `network` setting uses simple override (run config replaces the server default entirely). See [Server Configuration](/administration/server-configuration) for details. @@ -164,7 +163,7 @@ See [Server Configuration](/administration/server-configuration) for details. ### "Failed to create Daytona sandbox" -The `DAYTONA_API_KEY` environment variable is missing or invalid. Verify it's set in your `.env` file and check that it's a valid key from [app.daytona.io](https://app.daytona.io). +The `DAYTONA_API_KEY` environment variable is missing or invalid. Store it with `fabro secret set DAYTONA_API_KEY ...` or export it in the server process environment, then check that it's a valid key from [app.daytona.io](https://app.daytona.io). ### "Snapshot does not exist and no dockerfile provided" diff --git a/docs/integrations/github.mdx b/docs/integrations/github.mdx index d4b225c20..db44151c8 100644 --- a/docs/integrations/github.mdx +++ b/docs/integrations/github.mdx @@ -3,18 +3,36 @@ title: "GitHub" description: "Integrate Fabro with GitHub for repository access and OAuth login" --- -Fabro uses a [GitHub App](https://docs.github.com/en/apps/overview) to authenticate users in the web UI and to clone private repositories into remote sandboxes. The GitHub App is created automatically through a guided setup flow — no manual app configuration required. +Fabro supports two GitHub integration strategies: -## What the GitHub App enables +- `gh_cli` — the default for local and individual use. Fabro captures `gh auth token` during `fabro install`, stores it as `GITHUB_CLI_TOKEN`, and uses that token directly for repo access, pull requests, and sandbox `GITHUB_TOKEN` injection. +- `app` — the team-oriented option. Fabro registers a [GitHub App](https://docs.github.com/en/apps/overview), uses installation tokens for repo access, and enables browser OAuth and webhooks. + +`gh_cli` changes GitHub integration auth only. It does not provide browser sign-in, so the embedded web UI is disabled when `strategy = "gh_cli"`. + +## Strategy matrix + +| Capability | `gh_cli` | `app` | +|---|---|---| +| CLI pull requests | Yes | Yes | +| Private repo cloning | Yes | Yes | +| Sandbox `GITHUB_TOKEN` | Direct CLI token | Scoped installation token | +| Browser sign-in | No | Yes | +| Web UI routes | Disabled | Enabled | +| Webhooks | No | Yes | + +## GitHub App mode + +The rest of this page describes the `app` strategy, which is required for browser auth and webhooks. | Feature | How it's used | |---|---| | **OAuth login** | Users sign in to the web UI with their GitHub account | | **Private repo cloning** | Daytona and Docker sandboxes clone private repositories using short-lived Installation Access Tokens | | **Checkpoint pushing** | After each workflow stage, Fabro pushes the run branch and metadata branch back to origin from inside the sandbox | -| **Auto-PR** | When `[pull_request] enabled = true` in the [run config](/execution/run-configuration#pull_request), Fabro opens a PR from the agent's working branch after a successful run | -| **Auto-merge** | When `[pull_request] auto_merge = true`, Fabro enables GitHub's auto-merge on created PRs so they merge automatically once required checks pass | -| **Sandbox GITHUB_TOKEN** | When `[github] permissions` are declared in the run config, Fabro mints a scoped Installation Access Token and injects it as `GITHUB_TOKEN` in the sandbox | +| **Auto-PR** | When `[run.pull_request] enabled = true` in the [run config](/execution/run-configuration#runpull_request), Fabro opens a PR from the agent's working branch after a successful run | +| **Auto-merge** | When `[run.pull_request] auto_merge = true`, Fabro enables GitHub's auto-merge on created PRs so they merge automatically once required checks pass | +| **Sandbox GITHUB_TOKEN** | When `[server.integrations.github.permissions]` are declared in the server config, Fabro mints a scoped Installation Access Token and injects it as `GITHUB_TOKEN` in the sandbox | ## Setup @@ -25,7 +43,7 @@ Fabro uses a [GitHub App](https://docs.github.com/en/apps/overview) to authentic ### Register the GitHub App -1. Navigate to the web app (default `http://localhost:5173`). If no GitHub App is configured, you'll be redirected to the setup page automatically. +1. Navigate to the web app (default `http://localhost:3000`). If no GitHub App is configured, you'll be redirected to the setup page automatically. 2. Click **Register GitHub App**. This takes you to GitHub with a pre-filled [App Manifest](https://docs.github.com/en/apps/sharing-github-apps/registering-a-github-app-from-a-manifest) containing: @@ -42,10 +60,9 @@ Fabro uses a [GitHub App](https://docs.github.com/en/apps/overview) to authentic 4. GitHub redirects back to Fabro, which automatically: - Exchanges the temporary code for permanent app credentials - - Writes `app_id`, `client_id`, and `slug` to `~/.fabro/server.toml` - - Writes `GITHUB_APP_CLIENT_SECRET`, `GITHUB_APP_WEBHOOK_SECRET`, and `GITHUB_APP_PRIVATE_KEY` to `.env` - - Generates a `SESSION_SECRET` for web app sessions - - Redirects you to the login page + - Writes `app_id`, `client_id`, and `slug` to `~/.fabro/settings.toml` + - Stores `GITHUB_APP_CLIENT_SECRET`, `GITHUB_APP_WEBHOOK_SECRET`, and `GITHUB_APP_PRIVATE_KEY` in `/server.env` + - Marks the change as restart-bound so the server must be restarted before login 5. **Install the app** on your GitHub account or organization. Go to `https://github.com/settings/apps//installations` and install it on the repositories Fabro should access. @@ -61,11 +78,11 @@ The GitHub App check verifies five fields: | Field | Source | |---|---| -| `git.app_id` | `~/.fabro/server.toml` | -| `git.client_id` | `~/.fabro/server.toml` | -| `GITHUB_APP_CLIENT_SECRET` | `.env` | -| `GITHUB_APP_WEBHOOK_SECRET` | `.env` | -| `GITHUB_APP_PRIVATE_KEY` | `.env` | +| `server.integrations.github.app_id` | `~/.fabro/settings.toml` | +| `server.integrations.github.client_id` | `~/.fabro/settings.toml` | +| `GITHUB_APP_CLIENT_SECRET` | `/server.env` | +| `GITHUB_APP_WEBHOOK_SECRET` | `/server.env` | +| `GITHUB_APP_PRIVATE_KEY` | `/server.env` | If all five are set, the check passes. If none are set, it warns (GitHub integration is optional). If some are set but others are missing, it errors with the specific missing fields. @@ -73,11 +90,10 @@ If all five are set, the check passes. If none are set, it warns (GitHub integra The GitHub App configuration lives in two places: -### `~/.fabro/server.toml` +### `~/.fabro/settings.toml` -```toml title="server.toml" -[git] -provider = "github" +```toml title="settings.toml" +[server.integrations.github] app_id = "123456" client_id = "Iv1.abc123def" slug = "fabro-a3f2" @@ -85,21 +101,30 @@ slug = "fabro-a3f2" | Field | Description | |---|---| -| `provider` | Always `"github"` (the only supported provider) | | `app_id` | Numeric GitHub App ID | | `client_id` | OAuth Client ID for the app | | `slug` | App slug, used for linking to the GitHub App settings page | -### `.env` +### `server.env` -```bash -GITHUB_APP_CLIENT_SECRET=... # OAuth client secret -GITHUB_APP_WEBHOOK_SECRET=... # Webhook validation secret (reserved for future use) -GITHUB_APP_PRIVATE_KEY=... # RSA private key, base64-encoded PEM -``` +Fabro stores the GitHub App secrets in `/server.env` under these keys: + +- `GITHUB_APP_CLIENT_SECRET` +- `GITHUB_APP_WEBHOOK_SECRET` +- `GITHUB_APP_PRIVATE_KEY` The private key is stored as base64-encoded PEM. Fabro also accepts raw PEM format (starting with `-----BEGIN`). +## `gh_cli` mode + +Choose **GitHub CLI** in `fabro install` to use the default local-user flow. The installer: + +1. Runs `gh auth token` +2. Stores the token as `GITHUB_CLI_TOKEN` +3. Writes `strategy = "gh_cli"` under `[server.integrations.github]` + +In this mode, Fabro disables the embedded web UI and browser auth routes. Machine API routes and `/health` continue to work. + ## How it works ### OAuth login @@ -111,11 +136,11 @@ The web app uses the GitHub App's OAuth credentials to authenticate users: 3. User authorizes the app on GitHub 4. GitHub redirects back with an authorization code 5. Fabro exchanges the code for an access token and fetches the user's profile and verified email -6. Fabro checks the username against the `allowed_usernames` list in `server.toml` +6. Fabro checks the username against the `allowed_usernames` list in `settings.toml` -Configure allowed users in `server.toml`: +Configure allowed users in `settings.toml`: -```toml title="server.toml" +```toml title="settings.toml" [web.auth] provider = "github" allowed_usernames = ["alice", "bob"] @@ -147,7 +172,7 @@ permissions = { contents = "write", pull_requests = "write" } Only the listed permissions are requested — the token is scoped to the minimum access needed. If the GitHub App isn't configured or the repository lacks an installation, the run logs a warning and continues without the token. -This also works in `fabro.toml` as a project-level default, so all workflows in the project automatically get a `GITHUB_TOKEN` without repeating the config in each run TOML. +This also works in `.fabro/project.toml` as a project-level default, so all workflows in the project automatically get a `GITHUB_TOKEN` without repeating the config in each run TOML. ### Checkpoint pushing @@ -191,7 +216,7 @@ The app is installed but doesn't have access to this specific repository. Update ### "GitHub App authentication failed" -The `app_id` in `server.toml` or the `GITHUB_APP_PRIVATE_KEY` environment variable is incorrect. Re-run the setup flow or verify the values match your GitHub App. +The `app_id` in `settings.toml` or the `GITHUB_APP_PRIVATE_KEY` environment variable is incorrect. Re-run the setup flow or verify the values match your GitHub App. ### Clone fails for private repositories diff --git a/docs/integrations/slack.mdx b/docs/integrations/slack.mdx index 3eb55c786..e9c44fcf3 100644 --- a/docs/integrations/slack.mdx +++ b/docs/integrations/slack.mdx @@ -87,7 +87,7 @@ FABRO_SLACK_APP_TOKEN=xapp-your-app-token Optionally, set a default channel in your [server configuration](/administration/server-configuration): -```toml title="server.toml" +```toml title="settings.toml" [slack] default_channel = "#fabro-reviews" ``` diff --git a/docs/plans/2026-04-02-001-feat-server-daemon-management-plan.md b/docs/plans/2026-04-02-001-feat-server-daemon-management-plan.md new file mode 100644 index 000000000..cd2d56bff --- /dev/null +++ b/docs/plans/2026-04-02-001-feat-server-daemon-management-plan.md @@ -0,0 +1,446 @@ +--- +title: "feat: Add server daemon management with Unix socket support" +type: feat +status: completed +date: 2026-04-02 +deepened: 2026-04-02 +--- + +# feat: Add server daemon management with Unix socket support + +## Overview + +Transform `fabro server` from a foreground-only TCP server into a proper daemon with background/foreground modes, stop/status lifecycle commands, Unix socket binding, and flock-based locking to prevent thundering herd when multiple CLI invocations auto-start the server. + +## Problem Frame + +The fabro server currently runs only in the foreground on a TCP port. Users must manually manage the process lifecycle. There is no way to check if a server is running, stop it gracefully, or prevent duplicate instances. When the CLI eventually auto-starts the server on demand, concurrent CLI invocations could race to start multiple servers simultaneously. + +## Requirements Trace + +- R1. `fabro server start` launches as a background daemon by default +- R2. `fabro server start --foreground` retains current blocking behavior +- R3. `fabro server stop` sends SIGTERM, waits, escalates to SIGKILL +- R4. `fabro server status` reports running/stopped with PID, bind address, uptime +- R5. A JSON server record tracks PID and metadata; stale records are auto-cleaned +- R6. `--bind` replaces `--host`/`--port`, supporting both Unix sockets and TCP addresses +- R7. Default bind is `{storage_dir}/fabro.sock` (Unix socket) +- R8. flock-based locking prevents concurrent start attempts (thundering herd) +- R9. Only one server instance can run at a time per storage directory +- R10. TLS is not supported on Unix sockets (only on TCP) + +## Scope Boundaries + +- No systemd/launchd integration (out of scope) +- No log rotation or `server logs` subcommand beyond basic prev-file rotation (future work) +- Breaking change: `--host` and `--port` are removed, replaced by `--bind` +- Client-side Unix socket connectivity (TypeScript Axios client, CLI-to-server calls) is tracked as a follow-up concern -- this plan covers the server side only + +## Context & Research + +### Relevant Code and Patterns + +- `lib/crates/fabro-cli/src/commands/run/launcher.rs` -- JSON-based launcher records with PID tracking, stale detection via `process_alive()` + `ps` command-line matching, lazy cleanup on read +- `lib/crates/fabro-cli/src/commands/run/start.rs` -- Self-re-exec pattern: spawns `fabro __detached` with `pre_exec_setsid`, stdout/stderr to log file, writes record after spawn, checks `try_wait()` for immediate failure, forwards `--storage-dir` to child +- `lib/crates/fabro-cli/src/commands/run/detached.rs` -- `scopeguard::guard(launcher_path, remove_launcher_record)` for guaranteed cleanup on exit; `title_init()`/`title_set()` for proctitle; receives record path via `--launcher-path` arg +- `lib/crates/fabro-proc/src/` -- `process_alive`, `sigterm`, `sigkill`, `pre_exec_setsid`, `title_init`/`title_set`. All functions are thin libc wrappers -- no retry loops or higher-level logic +- `lib/crates/fabro-server/src/serve.rs` -- Current `serve_command()` with `ServeArgs`, config polling, webhook manager lifecycle. No graceful shutdown wired. TLS path uses manual `loop { listener.accept() }` via `hyper_util`, not `axum::serve` +- `lib/crates/fabro-cli/src/args.rs:650` -- `#[command(name = "__detached", hide = true)]` hidden subcommand pattern with its own args struct +- `lib/crates/fabro-cli/src/args.rs:972-984` -- `ServerNamespace`/`ServerCommand` enum +- `lib/crates/fabro-cli/src/main.rs:108-195` -- Config log level extraction and command dispatch for server + +### Institutional Learnings + +No `docs/solutions/` directory exists. Patterns are embedded in the launcher record system. + +## Key Technical Decisions + +- **Hidden subcommand for daemon child, not a flag**: The daemon spawns `fabro server __serve --record-path --bind ...` as a detached child. `__serve` is a hidden `ServerCommand` variant with its own args struct, matching the `__detached` pattern in `RunCommands`. This keeps `ServeArgs` focused on server runtime concerns and process lifecycle in `fabro-cli`. + +- **Process lifecycle stays in fabro-cli**: `fabro-server` has no dependency on `fabro-proc` and should not gain one. A `foreground.rs` in `fabro-cli/src/commands/server/` wraps `serve_command()` with record writes, scopeguard cleanup, and proctitle management -- same boundary as `detached.rs` wrapping workflow operations. + +- **Record ownership: parent writes, child cleans up**: In daemon mode, the parent writes `server.json` after spawn (and cleans up on failure). The child receives the record path via `--record-path` and sets up a `scopeguard` to remove it on exit. This matches the launcher record ownership model exactly. + +- **Server record, not bare PID file**: A JSON `ServerRecord` (pid, bind: Bind, log_path, started_at) stored at `{storage_dir}/server.json`, following the `LauncherRecord` pattern. The `bind` field uses the `Bind` enum (not a raw string) so consumers get type-safe access without re-parsing. Enables `status` to report rich info and supports PID-command-line validation against PID reuse. + +- **flock on a separate lock file**: `{storage_dir}/server.lock` is acquired with `LOCK_EX | LOCK_NB` before any start attempt. Losers of the race block on the lock (with timeout), then discover the server already running. The lock file is separate from `server.json` so the record can be atomically rewritten without interfering with the lock. `flock()` auto-releases on process crash. + +- **Keep fabro-proc thin**: Only `try_flock_exclusive(file) -> io::Result` goes in `fabro-proc` (thin libc wrapper). The timeout/retry loop lives in the caller (`server/start.rs`), consistent with every other function in `fabro-proc` being a single-syscall wrapper. + +- **`Bind` enum defined in fabro-server**: A `Bind` enum (`Bind::Unix(PathBuf) | Bind::Tcp(SocketAddr)`) lives in `fabro-server` since it's a server concern ("what am I binding to?"). Used in both `ServeArgs` resolution and `ServerRecord`. Serializes cleanly via serde tagged enum: `{"unix": "/path"}` or `{"tcp": "127.0.0.1:3000"}`. Consumers (stop, status) can match on the variant directly instead of re-parsing a string -- e.g., `stop` knows to clean up the socket file when `bind` is `Bind::Unix`. + +- **Bind address parsing**: `parse_bind(s: &str) -> Result` in `fabro-server`. If the value contains `/`, it's a Unix socket path. Otherwise it's `host:port` TCP. Default: `{storage_dir}/fabro.sock`. Unix socket paths are validated against the 104-byte limit on macOS (108 on Linux). + +- **Graceful shutdown via SIGTERM handler**: Wire `tokio::signal::unix::signal(SignalKind::terminate())` into `axum::serve().with_graceful_shutdown()`. The foreground mode also handles SIGINT (ctrl-c). **Known limitation:** The TLS codepath uses a manual `loop { listener.accept() }` via `hyper_util` and cannot use `with_graceful_shutdown`. Under TLS, `server stop` will rely on SIGTERM causing process exit (default behavior) but may need SIGKILL escalation. This is acceptable for now. + +- **Process title includes bind address**: `fabro: server {bind}` to support PID-command-line validation and disambiguate if the single-instance invariant ever relaxes. + +- **No TLS on Unix sockets**: When bind is a Unix socket, skip the TLS codepath entirely. TLS only applies to TCP binds. + +- **Log rotation on start**: Rename existing `server.log` to `server.log.prev` before starting, so crash diagnostics from the previous run are preserved. + +## Open Questions + +### Resolved During Planning + +- **Where does flock live?** Only `try_flock_exclusive` in `fabro-proc` (thin wrapper). Timeout loop in caller. +- **Default bind address?** `{storage_dir}/fabro.sock` (Unix socket). Resolved storage dir is used so it respects `--storage-dir` and config overrides. +- **How does the parent know the daemon is ready?** Poll-connect to the socket/port with a short timeout (up to 5s). Same approach as pg_ctl. +- **What happens to TLS + Unix socket?** Not supported. If `--bind` is a socket path and TLS is configured, emit a warning and skip TLS. +- **Hidden flag or hidden subcommand?** Hidden subcommand (`__serve`), matching the `__detached` pattern. Not a flag on `ServeArgs`. +- **Where does process lifecycle code live?** In `fabro-cli/src/commands/server/`, not in `fabro-server`. Same boundary as `detached.rs`. +- **Client connectivity with Unix socket default?** Tracked as follow-up -- this plan covers server-side only. The `server.json` record stores the bind address so clients can discover it. + +### Deferred to Implementation + +- Exact readiness polling interval and timeout values (start with 50ms interval, 5s timeout) +- Whether `server stop` should print tail of server.log on timeout before SIGKILL +- Exact chmod permissions on the Unix socket file (start with default, tighten if needed) + +## 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.* + +``` + fabro server start + | + acquire flock(server.lock) + / \ + (got lock) (blocked) + | | + read server.json wait for lock (retry loop in start.rs) + / \ | + (live pid) (none/stale) (got lock) + | | | + "already rotate server.log -> server.log.prev + running" spawn: fabro server __serve --record-path ... --bind ... + exit 1 pre_exec_setsid, stdout/stderr -> server.log + | + parent: write server.json with child PID + parent: poll-connect to bind address + / \ + (connected) (timeout) + | | + exit 0 print error + log tail + clean up server.json + exit 1 + + fabro server __serve --record-path --bind ... + | + title_init() + title_set("fabro: server ") + scopeguard(record_path, remove_server_record) + register SIGTERM/SIGINT shutdown + | + bind listener (Unix or TCP) + axum::serve(...).with_graceful_shutdown(...) + | + (on shutdown signal) + scopeguard fires: remove server.json + remove socket file (if Unix) +``` + +```mermaid +graph TB + U1[Unit 1: flock in fabro-proc] + U2[Unit 2: ServerRecord] + U3[Unit 3: Bind enum + --bind + Unix socket] + U4[Unit 4: daemon spawn + __serve] + U5[Unit 5: server stop] + U6[Unit 6: server status] + U7[Unit 7: main.rs dispatch] + + U1 --> U4 + U3 --> U2 + U2 --> U4 + U2 --> U5 + U2 --> U6 + U3 --> U4 + U4 --> U7 + U5 --> U7 + U6 --> U7 +``` + +Units 1 and 3 can run in parallel. Unit 2 depends on Unit 3 (for the `Bind` enum type). Units 5 and 6 can run in parallel after Unit 2. Unit 7 ties everything together. + +## Implementation Units + +- [x] **Unit 1: Add flock wrapper to fabro-proc** + + **Goal:** Provide `try_flock_exclusive` in `fabro-proc` as a thin libc wrapper for advisory file locking. + + **Requirements:** R8 + + **Dependencies:** None + + **Files:** + - Create: `lib/crates/fabro-proc/src/flock.rs` + - Modify: `lib/crates/fabro-proc/src/lib.rs` + - Test: `lib/crates/fabro-proc/src/flock.rs` (inline tests) + + **Approach:** + - `try_flock_exclusive(file: &File) -> io::Result` -- `libc::flock(fd, LOCK_EX | LOCK_NB)`, returns `Ok(false)` on `EWOULDBLOCK` + - `flock_unlock(file: &File) -> io::Result<()>` -- `libc::flock(fd, LOCK_UN)` for explicit unlock + - Unix-only (`#[cfg(unix)]`), consistent with existing `fabro-proc` gating + - No retry loops or timeout logic -- keep this crate at the syscall-wrapper level + + **Patterns to follow:** + - `lib/crates/fabro-proc/src/signal.rs` -- same style: thin wrapper over libc, `#[cfg(unix)]` gating, pub functions re-exported from `lib.rs` + - `lib/crates/fabro-proc/src/pre_exec.rs` -- unsafe block style and safety comments + + **Test scenarios:** + - Happy path: acquire lock on a temp file, confirm returns true + - Happy path: release lock (drop file), re-acquire succeeds + - Edge case: try_flock_exclusive on already-locked file (held by another fd) returns Ok(false) + + **Verification:** + - `cargo nextest run -p fabro-proc` passes + - `cargo clippy -p fabro-proc -- -D warnings` clean + +- [x] **Unit 2: Add ServerRecord and lifecycle helpers** + + **Goal:** Create a `ServerRecord` struct with read/write/remove/is_running helpers, mirroring `LauncherRecord`. + + **Requirements:** R5, R9 + + **Dependencies:** Unit 3 (for `Bind` enum type) + + **Files:** + - Create: `lib/crates/fabro-cli/src/commands/server/record.rs` + - Create: `lib/crates/fabro-cli/src/commands/server/mod.rs` + - Modify: `lib/crates/fabro-cli/src/commands/mod.rs` (add `pub(crate) mod server;` gated with `#[cfg(feature = "server")]`) + + **Approach:** + - `ServerRecord { pid: u32, bind: Bind, log_path: PathBuf, started_at: DateTime }` where `Bind` is the enum from `fabro-server` (Unit 3). The `bind` field serializes as a tagged enum (`{"unix": "/path"}` or `{"tcp": "127.0.0.1:3000"}`), giving consumers type-safe access -- e.g., `stop` matches on `Bind::Unix` to know it should remove the socket file + - Paths: `server_record_path(storage_dir) -> {storage_dir}/server.json`, `server_lock_path(storage_dir) -> {storage_dir}/server.lock`, `server_log_path(storage_dir) -> {storage_dir}/server.log` + - `server_record_is_running(record) -> bool` -- `process_alive(pid)` + `ps` command-line match for "fabro" and "server" + - `active_server_record(storage_dir) -> Option` -- read, check liveness, lazy-clean stale + - Path helpers only -- lock acquisition logic lives in Unit 4 + + **Patterns to follow:** + - `lib/crates/fabro-cli/src/commands/run/launcher.rs` -- identical record lifecycle pattern: `write_*`, `read_*`, `remove_*`, `*_is_running`, `active_*` + + **Test scenarios:** + - Happy path: write record, read it back, fields match + - Happy path: active_server_record returns None when no record file exists + - Edge case: active_server_record returns None and removes file when PID is dead (use pid u32::MAX) + - Edge case: active_server_record returns None and removes file when process doesn't match command-line check + + **Verification:** + - `cargo nextest run -p fabro-cli` for the new module's tests pass + +- [x] **Unit 3: Add Bind enum, replace --host/--port with --bind, add Unix socket listener** + + **Goal:** Define the `Bind` enum as the shared type for bind addresses. Change `ServeArgs` to use `--bind` instead of `--host`/`--port`. Support both Unix socket and TCP binding in `serve_command`. Wire graceful shutdown. + + **Requirements:** R6, R7, R10 + + **Dependencies:** None (parallel with Unit 1) + + **Files:** + - Create: `lib/crates/fabro-server/src/bind.rs` (Bind enum + parse_bind + Display impl) + - Modify: `lib/crates/fabro-server/src/lib.rs` (pub mod bind) + - Modify: `lib/crates/fabro-server/src/serve.rs` + - Modify: `lib/crates/fabro-server/Cargo.toml` (ensure tokio `net` feature includes unix support) + - Modify: `lib/crates/fabro-cli/src/main.rs` (update dispatch if ServeArgs shape changes) + - Test: `lib/crates/fabro-server/tests/it/api.rs` (update any tests using --host/--port) + - Test: `lib/crates/fabro-server/src/bind.rs` (inline tests for parse_bind) + + **Approach:** + - Define `Bind` enum in `bind.rs`: `Bind::Unix(PathBuf) | Bind::Tcp(SocketAddr)` with `Serialize`/`Deserialize` (serde tagged enum), `Display`, `Clone`, `Debug`, `PartialEq`. This type is used by both `serve_command` and `ServerRecord` (Unit 2) + - `parse_bind(bind: &str) -> Result`. Contains `/` -> Unix socket; otherwise `host:port` parsed as `SocketAddr`. Validate Unix socket path length (104 bytes on macOS, 108 on Linux) + - `Bind::display()` shows the address for human-readable output (used in proctitle, status, logs) + - Replace `--host` and `--port` on `ServeArgs` with `--bind` (Option, no default in clap -- default computed at runtime from resolved storage dir using the socket path) + - In `serve_command`, branch on `Bind` variant: `UnixListener::bind` vs `TcpListener::bind` + - For Unix sockets: remove stale socket file before bind, skip TLS codepath (log warning if TLS configured) + - Wire `axum::serve(listener, router).with_graceful_shutdown(shutdown_signal())` for the non-TLS path, where `shutdown_signal` awaits SIGTERM or SIGINT. **Note:** The TLS path (`serve_tls`) uses a manual accept loop and cannot use `with_graceful_shutdown` -- document as known limitation, SIGTERM will still cause process exit + - Derive `Clone` on `ServeArgs` to simplify the config polling task (currently manually clones each field) + + **Patterns to follow:** + - Current `serve.rs` listener binding and axum::serve pattern + - Axum 0.8 supports `UnixListener` directly via `axum::serve` + + **Test scenarios:** + - Happy path: parse_bind with "127.0.0.1:3000" returns Tcp variant + - Happy path: parse_bind with "/tmp/fabro.sock" returns Unix variant + - Edge case: parse_bind with invalid address returns error + - Edge case: parse_bind with path exceeding 104 bytes returns error on macOS + - Happy path: server binds to Unix socket and accepts HTTP requests over it + - Happy path: server binds to TCP address (existing behavior preserved) + - Edge case: stale socket file is removed before binding + - Integration: graceful shutdown on SIGTERM -- server stops accepting connections and exits cleanly + + **Verification:** + - `cargo nextest run -p fabro-server` passes + - Existing server tests still pass (adapted for --bind) + +- [x] **Unit 4: Add daemon spawn, __serve hidden subcommand, and foreground wrapper** + + **Goal:** Make `server start` launch a background daemon by default. `--foreground` retains current behavior. Both modes write/clean server records. Daemon mode uses flock to prevent thundering herd. `__serve` is the hidden subcommand the daemon child runs. + + **Requirements:** R1, R2, R8, R9 + + **Dependencies:** Units 1, 2, 3 + + **Files:** + - Create: `lib/crates/fabro-cli/src/commands/server/start.rs` (daemon spawn logic + flock retry loop) + - Create: `lib/crates/fabro-cli/src/commands/server/foreground.rs` (wraps `serve_command` with record lifecycle, scopeguard, proctitle) + - Modify: `lib/crates/fabro-cli/src/args.rs` (add `__Serve` hidden variant to `ServerCommand` with its own args struct; add `--foreground` flag to `Start` variant args) + - Modify: `lib/crates/fabro-cli/src/commands/server/mod.rs` (dispatch) + + **Approach:** + - **`ServerCommand::__Serve(ServeChildArgs)`**: Hidden subcommand with `--record-path`, `--bind`, plus forwarded args (`--model`, `--provider`, `--dry-run`, `--sandbox`, `--max-concurrent-runs`, `--config`, `--storage-dir`). Dispatches to `foreground.rs`. + - **`foreground.rs`**: `title_init()` + `title_set("fabro: server {bind}")`, `scopeguard::guard(record_path, remove_server_record)`, then calls `serve_command()`. Mirrors `detached.rs` wrapping workflow operations. + - **`start.rs` daemon path**: Open `server.lock`, retry `try_flock_exclusive` in a loop (50ms intervals, 5s timeout); check `active_server_record`; if running, print "already running" and exit 1; rotate `server.log` to `server.log.prev`; spawn self with `fabro server __serve --record-path --bind ...` using `pre_exec_setsid`, stdout/stderr to `server.log`, stdin null, `env_remove("FABRO_JSON")`; write `server.json` with child PID; check `try_wait()` for immediate failure; poll-connect to bind address (50ms intervals, 5s timeout); on success print "server started (pid N) on ", exit 0; on failure print error + tail of log, clean up, exit 1 + - **`start.rs` foreground path** (`--foreground`): Write `server.json`, register scopeguard for cleanup, then call `serve_command()` directly (no re-exec) + - **Forward all relevant args to child**: `--storage-dir`, `--config`, `--model`, `--provider`, `--dry-run`, `--sandbox`, `--max-concurrent-runs` + + **Patterns to follow:** + - `lib/crates/fabro-cli/src/commands/run/start.rs` -- self-re-exec with `pre_exec_setsid`, log redirect, record write, `try_wait` check, `--storage-dir` forwarding, `env_remove("FABRO_JSON")` + - `lib/crates/fabro-cli/src/commands/run/detached.rs` -- `scopeguard` cleanup, `title_init`/`title_set`, receives record path via arg + - `lib/crates/fabro-cli/src/args.rs:650` -- `#[command(name = "__detached", hide = true)]` pattern + + **Test scenarios:** + - Happy path: `server start` spawns daemon, writes server.json, exits 0 + - Happy path: `server start --foreground` runs in foreground, writes server.json, cleans up on exit + - Edge case: `server start` when already running prints "already running" and exits 1 + - Edge case: `server start` with stale server.json (dead PID) cleans up and starts fresh + - Happy path: flock prevents concurrent start -- second caller waits and finds server running + - Edge case: daemon fails to start (bad bind address) -- parent reports error, cleans up record + - Edge case: daemon child exits immediately -- parent detects via try_wait, reports error + - Integration: after daemon start, server.json contains correct PID and bind address + - Integration: server.log.prev contains previous log content after restart + + **Verification:** + - `cargo nextest run -p fabro-cli` passes + - Manual: `fabro server start` starts daemon, `fabro server start` again says "already running" + +- [x] **Unit 5: Add server stop subcommand** + + **Goal:** `fabro server stop` sends SIGTERM, waits for graceful exit, escalates to SIGKILL, cleans up. + + **Requirements:** R3 + + **Dependencies:** Unit 2 + + **Files:** + - Create: `lib/crates/fabro-cli/src/commands/server/stop.rs` + - Modify: `lib/crates/fabro-cli/src/args.rs` (add `Stop` variant to `ServerCommand` with `StopArgs { timeout }`) + - Modify: `lib/crates/fabro-cli/src/commands/server/mod.rs` + + **Approach:** + - Read `active_server_record` -- if None, print "not running", exit 1 + - `fabro_proc::sigterm(pid)` + - Poll `process_alive(pid)` at 100ms intervals up to `--timeout` (default 10s) + - If still alive after timeout, `fabro_proc::sigkill(pid)` + - Remove `server.json` and socket file (match on `record.bind` -- if `Bind::Unix(path)`, remove the socket file) + - Print "server stopped" + + **Patterns to follow:** + - `fabro_proc::sigterm`/`sigkill`/`process_alive` for signal management + - `launcher.rs::remove_launcher_record` for cleanup + + **Test scenarios:** + - Happy path: stop a running server -- sends SIGTERM, process exits, record cleaned up + - Edge case: stop when not running -- prints "not running", exits 1 + - Edge case: stop with stale record (dead PID) -- cleans up record, prints "not running", exits 1 + - Edge case: process doesn't exit within timeout -- escalates to SIGKILL + - Happy path: Unix socket file is removed after stop + + **Verification:** + - `cargo nextest run -p fabro-cli` passes + - Manual: `fabro server start && fabro server stop` completes cleanly + +- [x] **Unit 6: Add server status subcommand** + + **Goal:** `fabro server status` reports running/stopped state with metadata. Supports `--json`. + + **Requirements:** R4 + + **Dependencies:** Unit 2 + + **Files:** + - Create: `lib/crates/fabro-cli/src/commands/server/status.rs` + - Modify: `lib/crates/fabro-cli/src/args.rs` (add `Status` variant to `ServerCommand` with `StatusArgs { json }`) + - Modify: `lib/crates/fabro-cli/src/commands/server/mod.rs` + + **Approach:** + - Read `active_server_record` -- if None, print "not running", exit 1 + - Compute uptime from `started_at` + - Human output: "running (pid N) on , started X ago" + - `--json`: `{ "status": "running", "pid": N, "bind": "...", "started_at": "...", "uptime_seconds": N }` + - Exit code: 0 = running, 1 = not running (same as `pg_ctl status`) + + **Patterns to follow:** + - Other CLI commands that support `--json` output (check `globals.json` usage pattern) + + **Test scenarios:** + - Happy path: status when running -- prints info, exits 0 + - Happy path: status --json when running -- outputs valid JSON with expected fields + - Edge case: status when not running -- prints "not running", exits 1 + - Edge case: status with stale record -- cleans up, prints "not running", exits 1 + + **Verification:** + - `cargo nextest run -p fabro-cli` passes + +- [x] **Unit 7: Update main.rs dispatch and config loading for new server subcommands** + + **Goal:** Wire all server subcommands (start, stop, status, __serve) into CLI dispatch. Fix config log level extraction to handle new ServerCommand variants. + + **Requirements:** R1, R2, R3, R4 + + **Dependencies:** Units 4, 5, 6 + + **Files:** + - Modify: `lib/crates/fabro-cli/src/main.rs` + - Modify: `lib/crates/fabro-cli/src/args.rs` (update `Commands::name()` match arm for all new variants) + + **Approach:** + - Change `let ServerCommand::Start(args) = ns.command;` to `match ns.command { Start(..) => ..., Stop(..) => ..., Status(..) => ..., __Serve(..) => ... }` in both the config log level block and the dispatch block + - `__Serve` and `Start` (when in foreground/daemon mode) load server settings for log level; `Stop` and `Status` load user settings + - Update `Commands::name()` to return `"server start"`, `"server stop"`, `"server status"`, `"server __serve"` for telemetry + - Log prefix: `"server"` for `Start`/`__Serve`, `"cli"` for `Stop`/`Status` + - `#[cfg(feature = "server")]` gating on all new paths + + **Patterns to follow:** + - Existing dispatch pattern in `main.rs` for other namespace commands (e.g., `Commands::RunCmd`) + - Feature gating on all server references + + **Test scenarios:** + - Happy path: `fabro server stop --help` prints help text + - Happy path: `fabro server status --help` prints help text + - Happy path: `fabro server start --help` still works with new --bind flag and --foreground flag + - Happy path: telemetry name returns correct values for each subcommand + + **Verification:** + - `cargo nextest run -p fabro-cli` passes (including existing server tests) + - `cargo clippy --workspace -- -D warnings` clean + +## System-Wide Impact + +- **Interaction graph:** `serve_command()` gains a graceful shutdown signal handler. A new `foreground.rs` wrapper in `fabro-cli` manages server record lifecycle around `serve_command()`. Config polling task and webhook manager lifecycle preserved unchanged. Webhook manager shutdown (line 274-277 of `serve.rs`) becomes reachable for the first time via graceful shutdown -- the existing code is correct but the implementing agent should not add a scopeguard that drops the tokio runtime before async shutdown runs. +- **Error propagation:** Daemon start failures surface to the parent via poll-connect timeout + log tail. Stop failures surface via exit code. +- **State lifecycle risks:** Stale `server.json` after crash -- mitigated by PID liveness + command-line check on every read, with lazy cleanup (same proven pattern as launcher records). Stale socket file -- removed before bind attempt. +- **API surface parity:** The `--bind` change is breaking for anyone using `--host`/`--port`. No API endpoint changes. Client-side connectivity to Unix sockets (TypeScript Axios client, CLI HTTP calls) is not addressed in this plan and needs follow-up. +- **Integration coverage:** Unit tests can verify record lifecycle and parse_bind. Integration tests should cover the full start/status/stop cycle with a real server process. +- **Unchanged invariants:** All HTTP routes, auth, config reloading, webhook manager, and SSE streaming behavior are unchanged. The server's runtime behavior is identical once it's listening. + +## Risks & Dependencies + +| Risk | Mitigation | +|------|------------| +| PID reuse after crash leads to signaling wrong process | Two-phase check: `process_alive` + `ps` command-line matching for "fabro" and "server" with bind address in proctitle, same proven pattern as launcher records | +| flock not supported on all filesystems (e.g., NFS) | Storage dir is local by convention (~/.fabro). Document that network filesystems are unsupported for storage | +| Unix socket path exceeds 104-byte limit on macOS | `parse_bind` validates path length at parse time with a clear error message | +| Axum 0.8 UnixListener support | Axum 0.8 is already the workspace version; `serve()` accepts `UnixListener` natively | +| Breaking --host/--port removal | Acceptable per scope decision. Users see a clear clap error pointing to --bind | +| Race between parent writing server.json and child exiting | Check `try_wait()` immediately after spawn (same pattern as `start.rs`); if child already exited, clean up and report error | +| TLS path lacks graceful shutdown | Documented as known limitation. SIGTERM still causes process exit; stop command escalates to SIGKILL after timeout. TLS + daemon is an uncommon combination | +| Client-side code assumes TCP | Tracked as follow-up. Server record stores bind address for client discovery | + +## Sources & References + +- Related code: `lib/crates/fabro-cli/src/commands/run/launcher.rs`, `lib/crates/fabro-cli/src/commands/run/start.rs`, `lib/crates/fabro-cli/src/commands/run/detached.rs` +- Related code: `lib/crates/fabro-proc/src/signal.rs`, `lib/crates/fabro-proc/src/pre_exec.rs` +- Related code: `lib/crates/fabro-server/src/serve.rs` +- Related code: `lib/crates/fabro-cli/src/args.rs:650` (`__detached` hidden subcommand pattern), `lib/crates/fabro-cli/src/args.rs:972-984` (`ServerCommand`) +- Related code: `lib/crates/fabro-cli/src/main.rs:108-195` diff --git a/docs/plans/2026-04-02-002-feat-http-store-client-auto-start-plan.md b/docs/plans/2026-04-02-002-feat-http-store-client-auto-start-plan.md new file mode 100644 index 000000000..c63ad7c81 --- /dev/null +++ b/docs/plans/2026-04-02-002-feat-http-store-client-auto-start-plan.md @@ -0,0 +1,566 @@ +--- +title: "feat: auto-start server and route CLI store access over HTTP" +type: feat +status: active +date: 2026-04-02 +origin: docs/ideation/2026-04-02-slatedb-consolidation-ideation.md +deepened: 2026-04-04 +--- + +# feat: auto-start server and route CLI store access over HTTP + +## Overview + +Phase 1 replaces direct CLI access to SlateDB with a Unix-socket HTTP client that talks to `fabro server`, and auto-starts that daemon whenever CLI store access is needed. Use the generated `fabro-api` Rust client as the transport surface where the existing API already matches the needed operations. + +Phase 2 is an explicit follow-on decision: if we require strict single-owner semantics for all workflow execution writes, detached/start/resume execution must also move under server ownership rather than remaining in CLI-spawned engine processes. + +## Problem Frame + +The old plan assumed most of the HTTP surface and daemon infrastructure still needed to be built. That is no longer true. + +Current state in the repo: + +- Server daemon management is already implemented in `fabro-cli`: + - `lib/crates/fabro-cli/src/commands/server/start.rs` + - `lib/crates/fabro-cli/src/commands/server/record.rs` +- Unix socket bind support is already implemented in `fabro-server`: + - `lib/crates/fabro-server/src/serve.rs` + - `lib/crates/fabro-server/src/bind.rs` +- The server already owns a single `SlateStore` instance and already exposes store-backed endpoints for: + - run state + - events + - live event attach over SSE + - blobs + - checkpoint + - retro + - stage artifacts +- The generated `fabro-api` client is reqwest-based and can be constructed with a custom `reqwest::Client`. +- Reqwest 0.13 in this repo supports Unix sockets via `ClientBuilder::unix_socket(...)`. + +The real remaining work is narrower: + +1. CLI store access still opens `SlateStore` directly from local disk. +2. CLI does not auto-start the server when store access is needed. +3. Some API gaps remain, especially durable run listing and durable run deletion. +4. Several CLI command paths still assume local engine processes own workflow execution and store writes. + +## Requirements Trace + +- R1. CLI commands that need store access auto-start `fabro server` when no active daemon is available. +- R2. CLI store reads and writes stop opening SlateDB directly and instead use the server over HTTP via Unix socket. +- R3. `fabro server` becomes the only process that opens SlateDB for migrated CLI store-access flows in phase 1, with full execution-path consolidation tracked separately in Unit 6 if strict single-owner semantics remain required. +- R4. The generated `fabro-api` Rust client is used for server communication where its current API surface applies. +- R5. Existing `InMemoryStore`-based tests and server-internal `SlateStore` usage remain unaffected. +- R6. Event streaming remains live and efficient for attach/log-follow workflows. +- R7. Binary transfer for blobs and artifacts remains raw bytes over HTTP. +- R8. Error messages for server startup and server-unreachable cases are explicit and actionable. +- R9. The plan must reflect the current repo state rather than the assumptions in the original 2026-04-02 draft. + +## Scope Boundaries + +In scope: + +- CLI auto-start of the local server daemon +- HTTP-backed client access for CLI store consumers +- Server API additions needed for store parity +- Migration of CLI run discovery, logs/attach, blob/artifact access, and delete flows to server-backed access + +Out of scope for the first pass: + +- TypeScript/web client changes +- Replacing server-internal `SlateStore` +- Removing local run directories or runtime files +- Re-architecting workflow execution to be fully server-owned in the same change set +- Full trait refactors across `fabro-workflow` unless they are required to unblock the CLI migration + +Follow-on scope, likely separate plan or addressed by Unit 6's decision fork: + +- Consolidating detached/resume/start execution so workflow engine writes are also server-owned end-to-end + +## Current-State Audit + +### Already Implemented + +- Daemon lifecycle: + - `lib/crates/fabro-cli/src/commands/server/start.rs` + - `lib/crates/fabro-cli/src/commands/server/status.rs` + - `lib/crates/fabro-cli/src/commands/server/stop.rs` + - `lib/crates/fabro-cli/src/commands/server/record.rs` +- Unix socket server binding: + - `lib/crates/fabro-server/src/serve.rs` + - `lib/crates/fabro-server/src/bind.rs` +- Existing server routes relevant to store access: + - `GET /api/v1/runs/{id}/state` + - `GET /api/v1/runs/{id}/events` + - `GET /api/v1/runs/{id}/attach` + - `POST /api/v1/runs/{id}/events` + - `POST /api/v1/runs/{id}/blobs` + - `GET /api/v1/runs/{id}/blobs/{blobId}` + - `GET|POST /api/v1/runs/{id}/stages/{stageId}/artifacts` + - `GET /api/v1/runs/{id}/stages/{stageId}/artifacts/download` + - `GET /api/v1/runs/{id}/checkpoint` + - `GET /api/v1/runs/{id}/retro` +- `RunProjection` already contains much more than the old plan assumed: + - checkpoint and checkpoint history + - retro and retro prompt/response + - sandbox + - final patch + - pull request + +### Still Missing or Mismatched + +- `lib/crates/fabro-cli/src/store.rs` still builds a local `SlateStore` +- CLI commands are typed against concrete `SlateStore` / `SlateRunStore` +- Server `GET /api/v1/runs` is not durable store-backed; it serves in-memory managed runs only +- No durable delete endpoint exists for runs +- Artifact listing CLI still reads local artifact directories directly rather than using the server +- Detached CLI execution still opens the store directly and runs workflow engine code locally + +## Key Technical Decisions + +- **Use `fabro-api::Client` over Unix socket, not a custom hyper-only client.** + - The previous plan's "reqwest cannot do Unix sockets" assumption is stale. + - The generated client already covers `state`, `events`, `attach`, `blobs`, and artifact endpoints. + - Build a thin `fabro-cli` client wrapper around: + - `fabro_api::Client` + - `reqwest::ClientBuilder::unix_socket(socket_path)` + - For any operation not yet present in the OpenAPI spec, add the endpoint to the spec and regenerate `fabro-api`. + +- **Do not introduce a new transport crate until there is a clear reuse case.** + - The immediate consumers are in `fabro-cli`. + - A small `fabro-cli::server_client` module is enough for the first migration. + - If a second Rust crate later needs the same client, extract then. + +- **Auto-start belongs in CLI store/bootstrap code, not in the generated client.** + - The generated client should stay transport-only. + - Server lifecycle discovery/start remains in `fabro-cli`. + +- **Treat `RunProjection` as the primary read snapshot.** + - The existing `/runs/{id}/state` endpoint already returns the coarse-grained shape most CLI reads need. + - This should replace many fine-grained local store reads without widening the API. + +- **Split the migration into two layers.** + - Layer 1: CLI read/write operations that are naturally expressible against the current server API + - Layer 2: execution-path consolidation for detached/resume/start flows if we want the server, not CLI subprocesses, to be the sole write owner + +- **Preserve local run-directory access where it is orthogonal to SlateDB.** + - Run discovery still needs local run-dir paths for UI output and fallback/orphan detection. + - Runtime interview files and launcher metadata remain file-based unless separately redesigned. + +## Open Questions + +### Resolved for This Plan + +- **Should we use the generated `fabro-api` client?** + - Yes. It matches the repo direction and now works with Unix socket transport via reqwest. + +- **Do we need a brand-new HTTP store crate first?** + - No. Start with a thin CLI-side server client and only extract if a second consumer appears. + +- **Does the server already expose enough snapshot data?** + - Mostly yes. `RunProjection` already covers much more than the earlier plan assumed. + +### Deferred to Implementation + +- Whether to model the new CLI-side access layer as: + - a direct "server client" API, or + - a local wrapper that mimics `SlateStore` / `SlateRunStore` +- Whether artifact-list CLI should remain filesystem-based for local-only debugging or migrate fully to server-backed listing in phase 1 +- Whether detached engine execution should be migrated in the same branch or explicitly deferred behind a feature boundary + +## Relevant Code and Patterns + +### Daemon and Unix Socket Patterns + +- `lib/crates/fabro-cli/src/commands/server/start.rs` +- `lib/crates/fabro-cli/src/commands/server/record.rs` +- `lib/crates/fabro-server/src/serve.rs` +- `lib/crates/fabro-server/src/bind.rs` + +### Existing Generated Client + +- `lib/crates/fabro-api/src/lib.rs` +- `lib/crates/fabro-api/build.rs` +- `docs/api-reference/fabro-api.yaml` + +### Store-Backed Server Routes + +- `lib/crates/fabro-server/src/server.rs` + +### CLI Entry Points That Must Migrate + +- `lib/crates/fabro-cli/src/store.rs` +- `lib/crates/fabro-cli/src/commands/runs/list.rs` +- `lib/crates/fabro-cli/src/commands/run/logs.rs` +- `lib/crates/fabro-cli/src/commands/run/attach.rs` +- `lib/crates/fabro-cli/src/commands/run/diff.rs` +- `lib/crates/fabro-cli/src/commands/run/ssh.rs` +- `lib/crates/fabro-cli/src/commands/run/output.rs` +- `lib/crates/fabro-cli/src/commands/store/dump.rs` +- `lib/crates/fabro-cli/src/commands/pr/create.rs` +- `lib/crates/fabro-cli/src/commands/pr/list.rs` +- `lib/crates/fabro-cli/src/commands/pr/view.rs` +- `lib/crates/fabro-cli/src/commands/runs/rm.rs` +- `lib/crates/fabro-cli/src/commands/system/df.rs` +- `lib/crates/fabro-cli/src/commands/artifact/list.rs` +- `lib/crates/fabro-cli/src/commands/artifact/cp.rs` +- `lib/crates/fabro-cli/src/commands/run/wait.rs` +- `lib/crates/fabro-cli/src/commands/run/preview.rs` +- `lib/crates/fabro-cli/src/commands/run/rewind.rs` + +### Run Discovery Coupling + +- `lib/crates/fabro-workflow/src/run_lookup.rs` + +### Execution-Path Coupling + +- `lib/crates/fabro-cli/src/commands/run/create.rs` +- `lib/crates/fabro-cli/src/commands/run/start.rs` +- `lib/crates/fabro-cli/src/commands/run/detached.rs` +- `lib/crates/fabro-cli/src/commands/run/resume.rs` + +## High-Level Design + +### Layer 1: CLI server-backed store access + +`fabro-cli` gains a small client/bootstrap layer: + +1. Resolve active server from `server.json` +2. If absent or stale, auto-start daemon using existing lock/spawn/readiness logic +3. Construct `reqwest::Client` bound to the Unix socket +4. Construct `fabro_api::Client` with base URL like `http://fabro` +5. Expose helper methods for: + - run state + - run events list + - run events attach SSE stream + - blob read/write + - stage artifact list/read/write + - durable run list + - durable run delete + +CLI command handlers stop calling `build_store()` and instead call a new helper such as: + +`store::connect_server(storage_dir) -> Result` + +### Layer 2: API parity for run discovery and deletion + +The server adds durable endpoints for: + +- listing runs from the store catalog rather than only in-memory managed runs +- deleting run store state + +The CLI keeps local run-dir fallback/orphan detection logic from `run_lookup.rs`, but its durable run summary source becomes the server. + +### Layer 3: Optional execution consolidation + +If we want strict compliance with "server is the only process accessing SlateDB", then detached/resume/start flows must stop opening run stores locally. That likely means: + +- CLI creates/starts runs by calling server APIs +- server-owned scheduler/executor performs writes +- attach/logs become purely server-backed observers + +This is separable from the read-path migration and should be treated as a deliberate second stage. + +### Artifact handling boundary in phase 1 + +Artifact metadata and binary reads can move to server-backed endpoints in phase 1, but local run-directory artifact scanning may remain temporarily filesystem-based where commands are acting as local debugging tools rather than store clients. Implementation should make that boundary explicit rather than leaving a silent mixed-mode design. + +## Implementation Units + +- [ ] **Unit 1: Add CLI server bootstrap and generated-client construction** + +**Goal:** Replace raw local `SlateStore` bootstrap in `fabro-cli` with "find or start server, then connect over Unix socket". + +**Requirements:** R1, R2, R4, R5, R8 + +**Files:** +- Modify: `lib/crates/fabro-cli/src/store.rs` +- Create: `lib/crates/fabro-cli/src/server_client.rs` +- Modify: `lib/crates/fabro-cli/src/main.rs` +- Modify: `lib/crates/fabro-cli/Cargo.toml` + +**Approach:** +- Add `ensure_server_running(storage_dir: &Path) -> Result` in `server/start.rs` or a sibling helper module. +- Reuse: + - `acquire_lock` + - `active_server_record` + - daemon spawn path + - readiness polling +- Fast path: if `active_server_record()` exists, return its bind. +- If no record exists, start the daemon on the default Unix socket path and wait for readiness. +- In a new `server_client.rs`, build: + - `reqwest::ClientBuilder::new().unix_socket(socket_path)` + - `fabro_api::Client::new_with_client("http://fabro", reqwest_client)` + +**Patterns to follow:** +- `lib/crates/fabro-cli/src/commands/server/start.rs` +- `lib/crates/fabro-cli/src/commands/server/record.rs` +- `lib/crates/fabro-api/src/lib.rs` + +**Test scenarios:** +- Existing active server record returns immediately without spawning +- Missing server record triggers daemon start and waits for readiness +- Stale server record is ignored and replaced by a working daemon +- Unix socket connection errors produce a clear CLI-facing error + +**Verification:** +- `cargo build -p fabro-cli` +- targeted tests for server start/status helpers in `fabro-cli` + +- [ ] **Unit 2: Add durable server endpoints needed for CLI parity** + +**Goal:** Close the durable API gaps that block CLI migration. + +**Requirements:** R2, R3, R4, R5 + +**Files:** +- Modify: `docs/api-reference/fabro-api.yaml` +- Modify: `lib/crates/fabro-server/src/server.rs` +- Modify: `lib/crates/fabro-api/build.rs` only if codegen constraints require it +- Regenerate: `lib/crates/fabro-api` via normal build + +**Required endpoints:** +- `GET /api/v1/store/runs` + - durable run summaries from `state.store.list_runs(...)` +- `DELETE /api/v1/store/runs/{id}` + - durable store deletion for a run + +**Contract guardrail:** +- Do not change existing `GET /api/v1/runs` response semantics in this unit. +- The existing `/runs` route remains the board-oriented runtime view backed by `state.runs`. +- Durable catalog access belongs on the distinct `/api/v1/store/runs` surface unless a separate reviewed plan intentionally merges the concepts. + +**Optional endpoint for phase-1 cleanup if needed:** +- `GET /api/v1/runs/{id}/artifacts` + - if we decide to stop using local artifact directory scanning for listing/copy + +**Patterns to follow:** +- Existing store-backed handlers in `lib/crates/fabro-server/src/server.rs` + +**Test scenarios:** +- Durable run list returns runs persisted before current server boot +- Durable run delete removes store state for existing run +- Deleting missing run is idempotent or returns a clearly documented 404 behavior +- New endpoints are represented in `fabro-api` codegen output + +**Verification:** +- `cargo build -p fabro-api` +- `cargo nextest run -p fabro-server` + +- [ ] **Unit 3: Migrate run discovery to server-backed durable summaries** + +**Goal:** Stop CLI run discovery from reading store summaries via direct `SlateStore`. + +**Requirements:** R2, R3, R4 + +**Files:** +- Modify: `lib/crates/fabro-workflow/src/run_lookup.rs` +- Modify: `lib/crates/fabro-cli/src/commands/runs/list.rs` +- Modify: `lib/crates/fabro-cli/src/commands/system/df.rs` +- Modify: `lib/crates/fabro-cli/src/commands/pr/list.rs` +- Modify: `lib/crates/fabro-cli/src/commands/run/mod.rs` +- Add tests in: + - `lib/crates/fabro-cli/tests/it/cmd/` + - `lib/crates/fabro-workflow` tests if `run_lookup` signatures change + +**Approach:** +- Decouple `run_lookup` from concrete `SlateStore` inputs. +- Introduce a smaller input shape for durable summaries, likely: + - a plain `Vec`, or + - a small trait implemented by both local tests and the new CLI client wrapper +- Preserve local run-dir fallback/orphan detection from filesystem scanning. +- Use server-provided durable summaries as the authoritative store source. + +**Patterns to follow:** +- `lib/crates/fabro-workflow/src/run_lookup.rs` + +**Test scenarios:** +- Persisted run appears in list even after server restart +- Local orphan run still appears when store summary is absent +- Prefix resolution still works against durable summaries plus local paths +- `runs list` behavior remains unchanged for filters and JSON output + +**Verification:** +- `cargo nextest run -p fabro-cli runs_list` +- targeted `run_lookup` tests + +- [ ] **Unit 4: Migrate read-heavy CLI commands to server-backed state/events/blob/artifact access** + +**Goal:** Move the majority of CLI read flows off direct SlateDB access. + +**Requirements:** R2, R3, R4, R6, R7, R8 + +**Files:** +- Modify: `lib/crates/fabro-cli/src/commands/run/logs.rs` +- Modify: `lib/crates/fabro-cli/src/commands/run/attach.rs` +- Modify: `lib/crates/fabro-cli/src/commands/run/diff.rs` +- Modify: `lib/crates/fabro-cli/src/commands/run/ssh.rs` +- Modify: `lib/crates/fabro-cli/src/commands/run/output.rs` +- Modify: `lib/crates/fabro-cli/src/commands/store/dump.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/run/wait.rs` +- Modify: `lib/crates/fabro-cli/src/commands/run/preview.rs` + +**Approach:** +- Replace direct `open_run_reader()` usage with calls to: + - `get_run_state` + - `list_run_events` + - `attach_run_events` + - `read_run_blob` + - `list_stage_artifacts` + - artifact download endpoint +- Build one SSE parsing helper in CLI for `attach_run_events()` byte streams. +- Continue using `RunProjection` as the coarse-grained read model. + +**Patterns to follow:** +- `lib/crates/fabro-server/src/server.rs` attach SSE response shape +- Existing CLI attach/log rendering logic + +**Test scenarios:** +- `logs --follow` continues to stream events to completion +- `attach` replays existing events then follows live events +- state-driven commands still read final patch, PR data, sandbox, checkpoint, and retro from `RunProjection` +- blob and artifact download remain raw bytes + +**Verification:** +- targeted `fabro-cli` command tests: + - logs + - attach + - diff + - pr view/create + - store dump + +- [ ] **Unit 5: Migrate write/delete CLI operations that should hit the server** + +**Goal:** Stop CLI deletion and similar store mutations from touching SlateDB directly. + +**Requirements:** R2, R3, R4, R8 + +**Files:** +- Modify: `lib/crates/fabro-cli/src/commands/runs/rm.rs` +- Modify: `lib/crates/fabro-cli/src/commands/run/start.rs` if status validation moves to server reads only +- Modify: `lib/crates/fabro-cli/src/commands/run/rewind.rs` only if it currently depends on direct store writes in the targeted path + +**Approach:** +- Replace direct `store.delete_run()` calls with server API delete. +- Replace direct event append for `RunRemoving` with `POST /runs/{id}/events`. +- Keep local run-dir deletion and sandbox cleanup local unless a server-owned deletion flow is explicitly introduced. + +**Test scenarios:** +- removing a completed run deletes local run dir and durable store state +- removing a run with missing store state still behaves predictably +- server-unreachable deletion path returns actionable error text + +**Verification:** +- `cargo nextest run -p fabro-cli runs_rm` + +- [ ] **Unit 6: Decide and document the execution-ownership boundary** + +**Goal:** Explicitly close the gap between "CLI reads via server" and "server is the only process accessing SlateDB". + +**Requirements:** R3, R9 + +**Files:** +- Modify: this plan document after implementation decision, or create follow-on plan +- Review: + - `lib/crates/fabro-cli/src/commands/run/create.rs` + - `lib/crates/fabro-cli/src/commands/run/start.rs` + - `lib/crates/fabro-cli/src/commands/run/detached.rs` + - `lib/crates/fabro-cli/src/commands/run/resume.rs` + +**Decision fork:** + +Option A: **Phase-1 complete means CLI read/write store access is server-backed, but local engine subprocesses remain** +- Faster +- Leaves a strict reading of R3 partially unmet + +Option B: **Phase-2 also migrates execution ownership to the server** +- CLI create/start/resume become HTTP calls +- Detached local engine path is retired or reduced to server-only launch +- Strictly satisfies "server is the only process accessing SlateDB" + +**Recommendation:** +- Treat Option A as the first implementation milestone +- Open a follow-on plan immediately for Option B if the requirement remains strict + +**Implementation note (2026-04-04):** +- This branch follows Option A. +- CLI durable run discovery, wait/logs/diff/preview/PR listing, and run deletion are moving behind the server-backed store client. +- Detached/create/start/resume execution ownership remains local for now and still needs a follow-on server-owned execution plan if strict single-owner SlateDB semantics remain required. + +**Test scenarios:** +- explicit documentation of whichever boundary we choose +- no silent mixing of local-store and server-store code paths remains after the chosen milestone + +## Sequencing + +1. Unit 1 first: auto-start and client bootstrap +2. Unit 2 second: add missing durable API surface +3. Unit 3 third: migrate run discovery and durable summary reads +4. Unit 4 fourth: migrate read-heavy commands +5. Unit 5 fifth: migrate store mutations that still happen in CLI +6. Unit 6 last: finalize the execution-ownership boundary and either defer or continue + +## Risks and Mitigations + +- **Risk: `/api/v1/runs` semantics are runtime-board state, not durable store state** + - Mitigation: add distinct `/api/v1/store/runs` routes rather than silently repurposing `/runs` + +- **Risk: `run_lookup` is coupled to concrete `SlateStore`** + - Mitigation: extract a smaller durable-summary input shape before touching multiple commands + +- **Risk: SSE parsing via generated client is lower-level than current in-process stream usage** + - Mitigation: centralize byte-stream-to-event parsing in one helper and cover it with focused tests + +- **Risk: some CLI commands still depend on local runtime files rather than store data** + - Mitigation: migrate only true store access in this plan; do not conflate run-dir filesystem concerns with SlateDB consolidation + +- **Risk: execution paths still open the store directly after read-path migration** + - Mitigation: explicitly treat execution consolidation as a tracked decision point, not an accidental omission + +## Verification Strategy + +Per-unit targeted verification: + +- `cargo build -p fabro-api` +- `cargo nextest run -p fabro-server` +- `cargo nextest run -p fabro-cli` + +Focused command coverage should include: + +- server start/status/stop +- runs list +- logs +- attach +- diff +- pr view/create/list +- runs rm +- store dump + +Manual smoke flow after Units 1-5: + +1. stop any running server +2. run a CLI command that needs store access +3. verify server auto-starts +4. verify the command succeeds through the Unix socket path +5. verify no CLI path in the tested flow opens local SlateDB directly + +Durability smoke flow: + +1. create or identify a persisted run +2. stop the server +3. start the server again +4. verify durable run listing still finds the run through the HTTP path +5. verify run state for that run is still readable through the HTTP path + +## Change Summary + +The old plan is no longer the right implementation guide. The repo already has daemon management, Unix socket support, store-backed server endpoints, and a generated Rust client that can be used over UDS. The remaining plan is to: + +- auto-start the server from CLI store bootstrap +- use `fabro-api::Client` over Unix socket +- add the missing durable run-list/delete endpoints +- migrate CLI store consumers off direct `SlateStore` +- then explicitly decide whether execution ownership also moves fully into the server diff --git a/docs/plans/2026-04-04-run-event-simplification-plan.md b/docs/plans/2026-04-04-run-event-simplification-plan.md new file mode 100644 index 000000000..0f7777a9c --- /dev/null +++ b/docs/plans/2026-04-04-run-event-simplification-plan.md @@ -0,0 +1,74 @@ +# Simplify `RunEvent` While Keeping Wire JSON Stable + +## Summary +- Refactor the event model so `RunEvent` stores only envelope metadata plus a typed `EventBody`; the JSON wire format stays `{ ..., "event": "...", "properties": { ... } }`. +- Treat this as an internal Rust API break now: remove public `RunEvent.event` and `RunEvent.properties`, update repo call sites in one pass, and align the code with `docs-internal/events-strategy.md`'s "canonical envelope built once" rule. +- Preserve forward-compatibility for unknown stored events explicitly instead of relying on duplicate cached fields. +- Phase the refactor into three commits so the direct `Event -> RunEvent` mapping can land and be verified before the cached-field removal. + +## Implementation Changes +- Sequencing + - Commit 1: add `EventBody::event_name() -> &str`, remove `event_name_from_body()` and `properties_from_body()`, and replace both with explicit implementations that keep cached fields working through commits 1 and 2. + - Commit 2: rework `fabro-workflow` to construct `RunEvent` directly from `Event`; this is the main structural refactor and the primary regression risk. + - Commit 3: remove cached `RunEvent.event` / `RunEvent.properties`, update callers and tests, and replace `EventBody::Unknown` with a raw-preserving variant. +- `lib/crates/fabro-types/src/run_event/mod.rs` + - Redefine `RunEvent` to contain `id`, `ts`, `run_id`, optional envelope metadata, and `body: EventBody` only. + - Keep `RunEvent::from_value`, `from_json_str`, and `to_value`, but make them thin wire-boundary helpers around a private raw wire struct for `{ event, properties }`. + - Replace `EventBody::Unknown` with a raw-preserving variant such as `Unknown { name: String, properties: Value }`. + - Add `EventBody::event_name() -> &str` implemented as an exhaustive `match` returning the serde rename string for each known variant and `name.as_str()` for `Unknown`. + - In commit 1, replace `properties_from_body()` with an explicit property-serialization helper that derives the inner properties payload without the current serialize-and-pluck helper pattern; it may still serialize as an interim step, but it should exist only to support cached fields and wire serialization during the transition. + - Keep JSON property extraction as a serialization helper, not a hot-path public API. Use it only in `RunEvent::to_value` / `Serialize` and in wire-shape tests that need JSON-level assertions. + - Remove `refresh_cache`, `event_name_from_body`, and `properties_from_body`. + - Call out unknown-event fallback explicitly: `Unknown { name, properties }` cannot rely on `#[serde(other)]`, so `RunEvent::from_value` must use a custom fallback path that preserves raw `event` and `properties` when typed `EventBody` deserialization fails. +- `lib/crates/fabro-workflow/src/event.rs` + - Split the current conversion into two explicit pieces: envelope metadata extraction and `Event -> EventBody` construction. + - Rework `to_run_event_at()` to build `RunEvent` directly, not via `json!` plus `RunEvent::from_value`. + - Keep all existing canonicalization rules, but express them as Rust matches: `run_id` stripping, node/session extraction, node-label defaults, failure/error normalization, and agent/sandbox nested event flattening. + - Treat `Event::Agent` and `Event::Sandbox` as the bulk of the work: + - `Event::Agent` must expand each `AgentEvent` sub-variant into the corresponding `EventBody` variant while also lifting `stage -> node_id`, preserving `session_id` / `parent_session_id` in the envelope, and merging `visit` into the inner props where required. + - `Event::Sandbox` must unwrap each `SandboxEvent` sub-variant into the corresponding `EventBody` variant while preserving the current flattened wire shape. + - `Event::WorkflowRunFailed` must continue converting `FabroError` into the stored string form used by `RunFailedProps`. + - stage/parallel/prompt/watchdog variants must continue moving `node_id`/`stage`/`branch`/`node` into the envelope with the same current `node_label` defaults. + - Make the lossy cross-crate conversions explicit in the implementation and guard them with wire-shape characterization tests: + - `fabro_agent::AgentError -> String` + - `fabro_llm::error::SdkError -> string fields in retry props` + - `fabro_llm` usage types -> `fabro_types` usage structs + - `fabro_workflow::error::FabroError -> String` + - Delete `tagged_variant_fields*` once all variant mapping is direct and covered by tests. + - Keep redaction/persistence logic driven by serialized `RunEvent` wire value; no wire-shape change and no redaction contract change. + - Explicitly keep the `build_redacted_event_payload` pipeline out of scope for this pass: no changes to `to_value() -> normalize -> to_string -> redact -> from_str`. +- `lib/crates/fabro-store/src/types.rs`, `lib/crates/fabro-store/src/run_state.rs`, and repo consumers + - Update call sites to stop reading `RunEvent.event` and `RunEvent.properties` directly. + - Default rule: production consumers match on `body`; only serialization/wire tests should rely on JSON property extraction. + - Route store decoding through one helper path (`TryFrom<&EventPayload>` or `RunEvent::from_value`) and keep clone-based payload parsing for now; zero-copy parsing is out of scope for this pass. + - Update strategy/docs terminology only, not code naming: leave `RunEvent` as the code type in this pass and align `docs-internal/events-strategy.md` if needed. + +## Test Plan +- `lib/crates/fabro-types/src/run_event/mod.rs` + - known event round-trip preserves the wire JSON shape + - unknown event round-trip preserves raw `event` and `properties` + - known event name with invalid properties still fails deserialization + - absent optional envelope fields serialize as omitted fields, not `null` +- `lib/crates/fabro-workflow/src/event.rs` + - characterization tests for representative variants: stage event, agent event, sandbox event, and run failure + - assert direct construction produces the same wire JSON and envelope fields as today + - assert `build_redacted_event_payload` still returns a valid `EventPayload` + - add focused coverage for agent flattening and sandbox flattening, since those wrappers are the highest-risk conversion paths +- `lib/crates/fabro-store/src/run_state.rs` or adjacent store tests + - replay persisted payloads into `RunProjection` still reconstructs run, status, checkpoint, retro, and pull-request state correctly +- Test migration rules + - behavior tests should prefer matching on typed `body` instead of reintroducing JSON-shaped assertions + - wire-contract tests should assert on `to_value()` / serialized JSON when the exact `properties` shape matters + - do not replace all former `stored.properties["foo"]` assertions with a general-purpose allocating helper in production code + - `fabro-cli/src/commands/run/run_progress/event.rs::from_run_event()` is already aligned with the target design because it matches on `EventBody`; only any remaining CLI tests asserting through `stored.properties` need migration in commit 3 +- Verification + - run `cargo nextest run -p fabro-types` + - run `cargo nextest run -p fabro-workflow` + - run `cargo nextest run -p fabro-store` + - run `cargo fmt --check --all` and `cargo clippy --workspace -- -D warnings` + +## Assumptions +- The JSON wire protocol stays compatible; only the internal Rust representation and helper APIs change. +- Internal Rust API break is acceptable now; tests and internal consumers will be updated in the same pass. +- Unknown stored events are a supported forward-compatibility case and must survive parse/serialize unchanged. +- This pass optimizes for simplicity and maintainability first; deeper read-side performance work such as borrowed parsing, eliminating `Value` clones in projection, or optimizing the redaction pipeline can follow separately. diff --git a/docs/plans/2026-04-04-shared-nextest-test-daemon-plan.md b/docs/plans/2026-04-04-shared-nextest-test-daemon-plan.md new file mode 100644 index 000000000..1db01f396 --- /dev/null +++ b/docs/plans/2026-04-04-shared-nextest-test-daemon-plan.md @@ -0,0 +1,126 @@ +# Shared Test Storage + Production Auto-Start for CLI Tests + +## Summary +Use one shared test `storage_dir` per test session and rely on the existing production auto-start behavior to converge on a single shared daemon for that storage root. + +Session identity rules: +- when `NEXTEST_RUN_ID` is present, use one shared storage dir for the full `cargo nextest run` +- when `NEXTEST_RUN_ID` is absent, use one shared storage dir per test process + +The test harness will not implement its own daemon bootstrap protocol. Instead: +- every test process points `FABRO_STORAGE_DIR` at the same test-run storage root +- the first CLI command that needs the daemon triggers normal production auto-start +- existing `server.lock`, `server.json`, and `fabro.sock` behavior prevents duplicate servers for that shared storage dir +- the test harness is responsible only for: + - selecting the shared test storage root + - making test assertions/helpers safe under shared storage + - cleaning up the shared daemon and temp root at the end of the test run + +This keeps test behavior aligned with production daemon identity semantics. + +## Implementation Changes +### 1. Shared `storage_dir` in `fabro-test` +In `lib/crates/fabro-test/src/lib.rs`: +- Change `TestContext::new` to detect `NEXTEST_RUN_ID`. +- Derive a shared test root under temp: + - if `NEXTEST_RUN_ID` is present: `$TMPDIR/fabro-nextest//` + - otherwise: `$TMPDIR/fabro-test-process//` + - shared storage dir: `/storage` +- Keep `temp_dir` and `home_dir` per context; only `storage_dir` becomes shared for the session. +- Do not explicitly start the daemon from the harness. +- Keep using the normal CLI command path so the first server-backed command triggers production auto-start against the shared storage dir. +- Add concrete session cleanup coordination using marker files: + - under the session root, create `clients/` marker files + - protect create/remove/scan operations with a session lock file + - on `TestContext` init: + - acquire lock + - create or refresh this process marker + - remove stale markers for dead PIDs + - release lock + - on process teardown: + - acquire lock + - remove this process marker + - remove any other stale dead markers + - if no markers remain, call `fabro server stop --storage-dir ` and remove the shared temp root + - release lock +- Crash behavior: + - crashed processes may leave stale markers behind + - future init/teardown paths reap dead markers under the same lock + - teardown cleanup is best-effort; if `fabro server stop` or root removal fails, later harness initialization remains responsible for authoritative stale-session reaping +- Add stale-run cleanup on harness initialization: + - scan old `fabro-nextest/*` roots + - if all tracked PIDs for a root are dead, stop any server tied to that root and remove it + - likewise scan old `fabro-test-process/*` roots and reap dead process-owned sessions + +The harness should only coordinate test-run ownership and cleanup, not daemon startup. + +### 2. Per-test labeling for shared-state safety +Add a per-`TestContext` test case ULID and expose: +- `fabro_test_run=` +- `fabro_test_case=` + +Update run-creation helpers to append these labels to created runs: +- `run` +- `create` +- detached/create-start helpers +- any workflow fixture helpers that create runs internally + +Use existing production `--label KEY=VALUE` support; do not invent a new namespacing mechanism. + +### 3. Replace isolated-storage helper assumptions +Update helpers in: +- `lib/crates/fabro-cli/tests/it/cmd/support.rs` +- `lib/crates/fabro-cli/tests/it/workflow/mod.rs` + +Specifically: +- remove helpers that assume there is exactly one run in `storage_dir/runs` +- replace `only_run(context)`-style logic with: + - exact run-id lookup when the helper already has the run id, or + - test-case-label-based lookup when the helper needs to discover “the run created by this test” +- keep direct store inspection helpers, but always resolve a concrete run first + +### 4. Make shared-daemon tests robust +Update tests to match the shared-daemon model: +- exact-run tests remain strict and use ULIDs directly +- global/listing tests (`ps`, `runs list`, `system df`, workflow-slug/recency lookup) should assert the presence/properties of the current test’s run(s), not exact global emptiness/counts unless explicitly scoped +- when a command offers structured output, prefer parsing that output and filtering to the current test’s run(s) over broad transcript snapshots or loose substring matching +- destructive tests must always be scoped: + - exact run IDs when possible + - otherwise use existing `--label` filters +- broad destructive operations like “delete everything” are not allowed in shared-daemon tests + +Rewrite current tests that depend on ambient exclusivity, especially: +- helpers asserting a single run in storage +- `ps` tests expecting global count equality +- `system prune` tests filtering only by common workflow names like `Simple` + +## Important Interface / Behavior Changes +- `fabro-test::TestContext` uses a shared `storage_dir` per nextest run when `NEXTEST_RUN_ID` is set. +- Test-created runs gain deterministic labels: + - `fabro_test_run` + - `fabro_test_case` +- Test code must treat shared storage as normal under nextest and avoid assumptions based on storage exclusivity. + +## Test Plan +- `fabro-test` coverage: + - multiple contexts in one nextest run resolve to the same shared storage dir + - multiple contexts without `NEXTEST_RUN_ID` but in the same process resolve to the same shared storage dir + - contexts still get distinct `temp_dir` and `home_dir` + - marker-file cleanup removes stale prior session roots + - last-process cleanup stops the daemon and removes the shared root +- CLI integration updates: + - helper coverage for resolving runs by exact ULID or test-case label + - `ps`, `runs list`, and `system prune` tests updated to shared-storage-safe assertions + - destructive tests verified to target only test-owned runs +- End-to-end acceptance: + - a full `cargo nextest run -p fabro-cli` should converge on one daemon per `NEXTEST_RUN_ID` storage root + - a `cargo test --workspace` invocation should converge on one daemon per test process, not per `TestContext` + - after the run, no test-owned `fabro.sock` daemon remains for that root + - add an early validation test or harness check that concurrent auto-start against the same shared `storage_dir` converges on one daemon under parallel load + +## Assumptions and Defaults +- Production auto-start semantics for a single `storage_dir` are the source of truth and already provide duplicate-server protection via the existing lock/record path. +- `NEXTEST_RUN_ID` is used when available to derive the shared nextest-run root; otherwise the current process PID is used to derive a shared per-process root. +- The shared test storage root is fully separate from production storage in both modes. +- No separate harness-managed daemon bootstrap protocol is added. +- No changes to production daemon lifetime semantics are part of this plan. diff --git a/docs/plans/2026-04-05-cli-deglobalize-server-url-and-storage-dir-plan.md b/docs/plans/2026-04-05-cli-deglobalize-server-url-and-storage-dir-plan.md new file mode 100644 index 000000000..020fff85c --- /dev/null +++ b/docs/plans/2026-04-05-cli-deglobalize-server-url-and-storage-dir-plan.md @@ -0,0 +1,419 @@ +# CLI De-globalize Server URL And Storage Dir + +## Summary +Make `--storage-dir` and `--server-url` command-scoped instead of global so the CLI surface matches the architecture we now have. + +This pass should: + +- keep only truly global flags in `GlobalArgs` +- move local-storage selection onto the commands that actually use local storage +- move remote-server selection onto the commands that actually support remote targeting +- remove false affordances from help output, docs, and env-var wiring + +This plan is the direct follow-on to [2026-04-05-next-steps-after-cli-mode-removal.md](../ideation/2026-04-05-next-steps-after-cli-mode-removal.md). + +## Scope Boundaries +In scope: +- de-globalize `--storage-dir` / `FABRO_STORAGE_DIR` +- de-globalize `--server-url` / `FABRO_SERVER_URL` +- update clap types, parser tests, dispatch signatures, and command help +- keep `model` and `exec` behavior consistent with the recent cleanup, but expressed through command-local args +- update docs/examples that still show top-level target flags + +Out of scope: +- changing server/runtime behavior for `run`, `model`, or `exec` +- adding new remote-capable commands +- making `exec` server-owned +- changing `[server].base_url` semantics again +- broad config/schema changes outside the CLI arg surface + +## Problem Frame +The code no longer has a meaningful whole-program “mode”, but the CLI still advertises target-selection flags as if every command can choose between local storage and a remote server. + +That is now misleading in several different ways: + +- many commands still show `--server-url` even though they never use it +- many commands still show `--storage-dir` even though they do not read local runtime state +- docs still describe these flags as global CLI surface area +- help snapshot churn is broader than the real behavior surface because the flags leak into unrelated commands + +The goal is not to invent new behavior. The goal is to make the CLI honest about which commands actually support which target-selection controls. + +## Key Decisions +- `GlobalArgs` remains, but only for true global flags: + - `--json` + - `--debug` + - `--no-upgrade-check` + - `--quiet` + - `--verbose` +- `--storage-dir` and `--server-url` stop being top-level/global clap args entirely. +- target-selection args belong to leaf commands, not parent namespaces. + - Rationale: keep natural syntax such as: + - `fabro run foo.fabro --storage-dir /tmp/fabro` + - `fabro model list --server-url https://fabro.example.com/api/v1` + - `fabro server start --storage-dir /tmp/fabro` + - Avoid awkward parent-namespace syntax like `fabro model --server-url ... list`. +- `FABRO_STORAGE_DIR` and `FABRO_SERVER_URL` remain supported, but only for commands that define the corresponding arg. +- command-local target syntax becomes the supported surface. + - Old forms like `fabro --server-url ... model list` and `fabro --storage-dir ... run ...` are intentionally removed in this pass. +- during implementation, temporary duplication between global target args and the new leaf-command args is acceptable. + - Rationale: this refactor touches many commands, so the migration should stay compile-safe until the old global fields are fully unused and can be removed in one cleanup step. +- `settings` keeps `--storage-dir`, because it changes the resolved local settings output. +- `settings` does not keep `--server-url`. + - Rationale: command-local remote target overrides should not masquerade as merged durable config. +- `preflight` keeps `--storage-dir`, because it resolves a local run-oriented settings stack and should continue to allow explicit local storage selection. +- `exec` keeps only `--server-url`. + - `exec` does not need `--storage-dir`. +- `model list` and `model test` keep both: + - `--server-url` for explicit remote server targeting + - `--storage-dir` for explicit local auto-start/storage selection +- the existing `model` config/defaulting contract stays intact: + - explicit `--server-url` wins + - explicit `--storage-dir` suppresses configured `[server].base_url` + - otherwise `model` may default to configured `[server].base_url` +- the existing `exec` contract stays intact: + - server routing only happens when the command-local `server_url` value is set, whether by CLI flag or `FABRO_SERVER_URL` + - configured `[server].base_url` alone still does not reroute `exec` +- `fabro model` with no subcommand remains the convenience alias for default listing behavior. + - target overrides are not a compatibility goal for the bare alias in this pass; users should use `fabro model list ...` when specifying target args explicitly. + +## Command Matrix +### `--server-url` only +- `fabro exec` + +### `--storage-dir` and `--server-url` +- `fabro model list` +- `fabro model test` + +### `--storage-dir` only +- `fabro run` +- `fabro create` +- `fabro start` +- `fabro attach` +- `fabro logs` +- `fabro resume` +- `fabro rewind` +- `fabro fork` +- `fabro wait` +- `fabro diff` +- hidden `fabro __runner` +- `fabro ps` +- `fabro rm` +- `fabro inspect` +- `fabro artifact list` +- `fabro artifact cp` +- `fabro sandbox cp` +- `fabro sandbox preview` +- `fabro sandbox ssh` +- `fabro store dump` +- `fabro pr create` +- `fabro pr list` +- `fabro pr view` +- `fabro pr merge` +- `fabro pr close` +- `fabro system prune` +- `fabro system df` +- `fabro server start` +- `fabro server stop` +- `fabro server status` +- hidden `fabro server __serve` +- `fabro settings` +- `fabro preflight` + +### Neither target arg +- `fabro validate` +- `fabro graph` +- `fabro parse` +- `fabro doctor` +- `fabro install` +- `fabro repo init` +- `fabro repo deinit` +- `fabro workflow list` +- `fabro workflow create` +- `fabro provider login` +- hidden `fabro skill install` +- `fabro secret get` +- `fabro secret list` +- `fabro secret set` +- `fabro secret rm` +- `fabro docs` +- `fabro discord` +- `fabro completion` +- `fabro upgrade` +- internal analytics/panic commands + +## Implementation Changes +### 1. Narrow `GlobalArgs` and add command-scoped target arg structs +In `lib/crates/fabro-cli/src/args.rs`: +- add the new command-scoped target arg structs first, while temporarily leaving `storage_dir` and `server_url` on `GlobalArgs` +- keep `GlobalArgs` as the long-term home only for the true-global output/logging/upgrade-check surface +- add small reusable arg structs: + - `StorageDirArgs { storage_dir: Option }` + - `ServerUrlArgs { server_url: Option }` + - `ModelTargetArgs { storage_dir: Option, server_url: Option }` +- keep the `storage_dir` / `server_url` conflict on `ModelTargetArgs` +- keep the existing env bindings: + - `FABRO_STORAGE_DIR` + - `FABRO_SERVER_URL` + +This temporary duplication is intentional. The crate should continue to compile while commands migrate off `GlobalArgs`. + +Because the target args will now live on leaf commands, convert inline/anonymous clap variants into explicit args structs where needed. The important conversions are: +- `Exec(AgentArgs)` -> `Exec(ExecArgs)` where `ExecArgs` flattens: + - `ServerUrlArgs` + - `fabro_agent::cli::AgentArgs` +- `ModelsCommand::List { ... }` -> `ModelsCommand::List(ModelListArgs)` +- `ModelsCommand::Test { ... }` -> `ModelsCommand::Test(ModelTestArgs)` +- `RunCommands::Start { run: String }` -> `RunCommands::Start(StartArgs)` where `StartArgs` flattens `StorageDirArgs` +- `RunCommands::Attach { run: String }` -> `RunCommands::Attach(AttachArgs)` where `AttachArgs` flattens `StorageDirArgs` +- `RunCommands::Runner { ... }` -> `RunCommands::Runner(RunnerArgs)` where `RunnerArgs` flattens `StorageDirArgs` +- `ServerCommand::Start { ... }` -> `ServerCommand::Start(ServerStartArgs)` where `ServerStartArgs` flattens: + - `StorageDirArgs` + - the existing `foreground` flag + - `ServeArgs` +- `ServerCommand::Stop { ... }` -> `ServerCommand::Stop(ServerStopArgs)` where `ServerStopArgs` flattens `StorageDirArgs` +- `ServerCommand::Status { ... }` -> `ServerCommand::Status(ServerStatusArgs)` where `ServerStatusArgs` flattens `StorageDirArgs` +- `ServerCommand::Serve { ... }` -> `ServerCommand::Serve(ServerServeArgs)` where `ServerServeArgs` flattens: + - `StorageDirArgs` + - the existing `record_path` field + - `ServeArgs` + +Also flatten `StorageDirArgs` into the leaf arg types that currently rely on global storage selection, including: +- `RunArgs` +- `LogsArgs` +- `DiffArgs` +- `ResumeArgs` +- `RewindArgs` +- `ForkArgs` +- `WaitArgs` +- `RunsListArgs` +- `RunsRemoveArgs` +- `InspectArgs` +- `ArtifactListArgs` +- `ArtifactCpArgs` +- `CpArgs` +- `PreviewArgs` +- `SshArgs` +- `StoreDumpArgs` +- `PrCreateArgs` +- `PrListArgs` +- `PrViewArgs` +- `PrMergeArgs` +- `PrCloseArgs` +- `RunsPruneArgs` +- `DfArgs` +- `SettingsArgs` +- `PreflightArgs` + +### 2. Rework parser and dispatch wiring around leaf-command target args +In `lib/crates/fabro-cli/src/main.rs`: +- keep `Cli { globals, command }`, but with the narrower `GlobalArgs` +- update dispatch pattern matches to use the new wrapper arg structs: + - `ExecArgs` + - `ModelListArgs` + - `ModelTestArgs` + - `StartArgs` + - `AttachArgs` + - `RunnerArgs` +- replace parser tests that assume global target flags with command-local parser tests + +Parser behavior to lock down: +- `fabro run test/simple.fabro --storage-dir /tmp/fabro` parses +- `fabro model list --server-url http://localhost:3000/api/v1` parses +- `fabro exec --server-url http://localhost:3000/api/v1 "prompt"` parses +- `fabro model list --storage-dir /tmp/fabro --server-url http://localhost:3000/api/v1` fails with the command-local conflict +- `fabro --server-url http://localhost:3000/api/v1 model list` no longer parses +- `fabro --storage-dir /tmp/fabro run test/simple.fabro` no longer parses + +These parser assertions belong at the end of the migration, after `storage_dir` and `server_url` have actually been removed from `GlobalArgs`. Before that cleanup step, the old top-level forms will still parse and should not be treated as failures yet. + +### 3. Replace “with globals” settings helpers with explicit local/remote override helpers +The current helper layer in `lib/crates/fabro-cli/src/user_config.rs` is still shaped around global target args. That should be simplified to explicit command-local override helpers. + +In `lib/crates/fabro-cli/src/user_config.rs`: +- remove or rename the generic helpers that imply target args are global: + - `user_layer_with_globals(...)` + - `load_user_settings_with_globals(...)` + - `apply_global_overrides(...)` +- replace them with explicit helpers such as: + - `user_layer_with_storage_dir(storage_dir: Option<&Path>) -> anyhow::Result` + - `load_user_settings_with_storage_dir(storage_dir: Option<&Path>) -> anyhow::Result` + - `exec_server_target(args: &ServerUrlArgs, settings: &Settings) -> Option` + - `model_server_target(args: &ModelTargetArgs, settings: &Settings) -> Option` +- keep `build_server_client(...)` + +Behavior to preserve: +- local-storage commands can still resolve settings with an explicit storage-dir override +- `model` keeps its existing defaulting behavior +- `exec` only resolves a remote target when its command-local `server_url` value is set +- TLS continues to come from `[server].tls` when a remote target is selected + +### 4. Repoint command implementations to explicit target args +Update the commands that currently read target selection through `GlobalArgs`. + +In `lib/crates/fabro-cli/src/commands/model.rs`: +- switch from `GlobalArgs`-based target lookup to `ModelTargetArgs` +- keep the recent server-canonical behavior unchanged + +In `lib/crates/fabro-cli/src/commands/exec.rs`: +- switch from `GlobalArgs`-based target lookup to `ServerUrlArgs` +- remove any remaining dependence on `storage_dir` +- load settings with plain `load_user_settings()`, not a storage-dir-aware helper + - `exec` does not need local storage override semantics anymore + - it should then pass `&ServerUrlArgs` to `exec_server_target(...)` + +In the local-storage command modules: +- replace `load_user_settings_with_globals(globals)` with the explicit storage-dir helper +- read `storage_dir` from the command’s own args struct, not from `globals` + +Key files here include: +- `lib/crates/fabro-cli/src/commands/run/mod.rs` +- `lib/crates/fabro-cli/src/commands/run/command.rs` +- `lib/crates/fabro-cli/src/commands/run/logs.rs` +- `lib/crates/fabro-cli/src/commands/run/resume.rs` +- `lib/crates/fabro-cli/src/commands/run/rewind.rs` +- `lib/crates/fabro-cli/src/commands/run/fork.rs` +- `lib/crates/fabro-cli/src/commands/run/wait.rs` +- `lib/crates/fabro-cli/src/commands/run/diff.rs` +- `lib/crates/fabro-cli/src/commands/run/cp.rs` +- `lib/crates/fabro-cli/src/commands/run/preview.rs` +- `lib/crates/fabro-cli/src/commands/run/ssh.rs` +- `lib/crates/fabro-cli/src/commands/runs/list.rs` +- `lib/crates/fabro-cli/src/commands/runs/rm.rs` +- `lib/crates/fabro-cli/src/commands/runs/inspect.rs` +- `lib/crates/fabro-cli/src/commands/artifact/list.rs` +- `lib/crates/fabro-cli/src/commands/artifact/cp.rs` +- `lib/crates/fabro-cli/src/commands/store/dump.rs` +- `lib/crates/fabro-cli/src/commands/pr/*.rs` +- `lib/crates/fabro-cli/src/commands/system/*.rs` +- `lib/crates/fabro-cli/src/commands/server/mod.rs` +- `lib/crates/fabro-cli/src/commands/preflight.rs` +- `lib/crates/fabro-cli/src/commands/config/mod.rs` + +Important internal-path detail: +- hidden commands still need explicit local storage wiring +- `fabro server __serve` must keep receiving `--storage-dir` from `lib/crates/fabro-cli/src/commands/server/start.rs` +- hidden `fabro __runner` should continue to accept explicit storage-dir selection through its own args struct rather than through `GlobalArgs` + +### 5. Make help output and docs reflect the new command contract +In `docs/reference/cli.mdx`: +- remove `--storage-dir` and `--server-url` from the “Global options” table +- add a short “Command-scoped target selection” section or matrix +- update examples to use command-local placement: + - `fabro model list --server-url ...` + - `fabro server start --storage-dir ...` + +In `docs/reference/user-configuration.mdx`: +- keep `[server].base_url` documentation +- clarify that it is a default only for commands that support remote server targets +- keep the `model` / `exec` asymmetry explicit +- update examples away from top-level `fabro --server-url ...` + +In `docs/administration/deploy-server.mdx`: +- update “point the CLI at a server” examples to command-local syntax +- clarify that `model` can use configured `[server].base_url`, while `exec` still needs explicit `--server-url` + +In `docs/reference/run-directory.mdx`: +- change “global `--storage-dir` flag” wording to command-local run-family wording + +Also scan for stale examples in nearby docs and changelog/admin references and update only the ones that would now be actively misleading. + +### 6. Update unit tests, parser tests, and help snapshots +#### Unit and parser coverage +In `lib/crates/fabro-cli/src/main.rs` tests: +- replace the old global target-flag parse tests with command-local parse tests +- add the explicit “old global placement no longer parses” cases + +In `lib/crates/fabro-cli/src/user_config.rs` tests: +- update helper tests to use the new arg structs +- preserve coverage for: + - `exec` CLI/env `server_url` routing + - `exec` ignoring configured `[server].base_url` + - `model` configured `[server].base_url` defaulting + - `model` `storage_dir` suppressing configured remote target + - TLS inheritance + +#### Integration coverage +Update behavior/help coverage in: +- `lib/crates/fabro-cli/tests/it/cmd/exec.rs` +- `lib/crates/fabro-cli/tests/it/cmd/model.rs` +- `lib/crates/fabro-cli/tests/it/cmd/model_list.rs` +- `lib/crates/fabro-cli/tests/it/cmd/model_test.rs` +- `lib/crates/fabro-cli/tests/it/cmd/config.rs` +- `lib/crates/fabro-cli/tests/it/cmd/run.rs` +- `lib/crates/fabro-cli/tests/it/cmd/create.rs` +- `lib/crates/fabro-cli/tests/it/cmd/attach.rs` +- `lib/crates/fabro-cli/tests/it/cmd/logs.rs` +- `lib/crates/fabro-cli/tests/it/cmd/resume.rs` +- `lib/crates/fabro-cli/tests/it/cmd/rewind.rs` +- `lib/crates/fabro-cli/tests/it/cmd/wait.rs` +- `lib/crates/fabro-cli/tests/it/cmd/diff.rs` +- `lib/crates/fabro-cli/tests/it/cmd/runner.rs` +- `lib/crates/fabro-cli/tests/it/cmd/ps.rs` +- `lib/crates/fabro-cli/tests/it/cmd/inspect.rs` +- `lib/crates/fabro-cli/tests/it/cmd/store.rs` +- `lib/crates/fabro-cli/tests/it/cmd/store_dump.rs` +- `lib/crates/fabro-cli/tests/it/cmd/artifact_list.rs` +- `lib/crates/fabro-cli/tests/it/cmd/artifact_cp.rs` +- `lib/crates/fabro-cli/tests/it/cmd/sandbox_cp.rs` +- `lib/crates/fabro-cli/tests/it/cmd/sandbox_preview.rs` +- `lib/crates/fabro-cli/tests/it/cmd/sandbox_ssh.rs` +- `lib/crates/fabro-cli/tests/it/cmd/pr.rs` +- `lib/crates/fabro-cli/tests/it/cmd/pr_list.rs` +- `lib/crates/fabro-cli/tests/it/cmd/pr_view.rs` +- `lib/crates/fabro-cli/tests/it/cmd/pr_merge.rs` +- `lib/crates/fabro-cli/tests/it/cmd/pr_close.rs` +- `lib/crates/fabro-cli/tests/it/cmd/system.rs` +- `lib/crates/fabro-cli/tests/it/cmd/system_df.rs` +- `lib/crates/fabro-cli/tests/it/cmd/system_prune.rs` +- `lib/crates/fabro-cli/tests/it/cmd/server_start.rs` +- `lib/crates/fabro-cli/tests/it/cmd/server_stop.rs` +- `lib/crates/fabro-cli/tests/it/cmd/server_status.rs` +- `lib/crates/fabro-cli/tests/it/cmd/preflight.rs` +- `lib/crates/fabro-cli/tests/it/cmd/fabro.rs` + +Behavior scenarios to add or preserve: +- `exec` still routes through the server only when its command-local `server_url` is set +- `model` still honors configured `[server].base_url` when no explicit `storage_dir` is set +- `model` command-local `server_url` still overrides configured base URL +- `model` command-local `storage_dir` still forces local behavior +- old top-level target-flag placement fails at parse time + +For snapshot churn: +- use the repo workflow from `CLAUDE.md` + - `cargo insta pending-snapshots` + - verify the expected help/output changes + - `cargo insta accept` + +## Dependencies And Sequencing +Apply the change in this order: + +1. add the new command-scoped target arg structs in `args.rs`, but keep `storage_dir` and `server_url` on `GlobalArgs` temporarily so the crate still compiles during migration +2. add the new explicit helpers in `user_config.rs` alongside the old global-based helpers +3. repoint `exec` and `model` to the new target arg structs and helpers +4. repoint the local-storage and server command modules, plus test harness/env wiring, off `GlobalArgs` +5. remove `storage_dir` and `server_url` from `GlobalArgs`, then delete the old global-based helper path once it is unused +6. update parser tests for the final clap shape, then update docs and help snapshots + +This ordering keeps the refactor compile-safe: helper and command migration happen before the old global fields are removed, and parser assertions about the old top-level syntax move to the final cleanup step where they become true. + +## Test Plan +- targeted parser/unit tests: + - `cargo test -p fabro-cli --lib main::tests -- --nocapture` + - `cargo test -p fabro-cli --lib user_config::tests -- --nocapture` +- targeted CLI integration tests: + - `cargo nextest run -p fabro-cli cmd::exec:: --no-fail-fast` + - `cargo nextest run -p fabro-cli cmd::model:: cmd::model_list:: cmd::model_test:: cmd::config:: --no-fail-fast` + - `cargo nextest run -p fabro-cli cmd::run:: cmd::create:: cmd::attach:: cmd::logs:: cmd::resume:: cmd::rewind:: cmd::wait:: cmd::diff:: cmd::runner:: --no-fail-fast` + - `cargo nextest run -p fabro-cli cmd::server_start:: cmd::server_stop:: cmd::server_status:: --no-fail-fast` +- broader CLI sweep after targeted coverage is green: + - `cargo nextest run -p fabro-cli --no-fail-fast` +- final verification: + - `cargo fmt --check --all` + - `cargo clippy --workspace --all-targets -- -D warnings` + +## Assumptions And Defaults +- Pre-production status means removing the old global target-flag placement is acceptable; no compatibility shim is required. +- `FABRO_STORAGE_DIR` and `FABRO_SERVER_URL` remain useful and should stay, but only where the corresponding command actually supports the underlying behavior. +- The recent `model` and `exec` behavioral contracts are already correct; this pass is about CLI honesty and arg ownership, not product redefinition. +- If we later want a broader “remote-capable command matrix” abstraction, it should be built on top of these command-local args rather than by reintroducing misleading global target flags. diff --git a/docs/plans/2026-04-05-cli-exec-explicit-local-and-mode-removal-plan.md b/docs/plans/2026-04-05-cli-exec-explicit-local-and-mode-removal-plan.md new file mode 100644 index 000000000..4b3f0c56e --- /dev/null +++ b/docs/plans/2026-04-05-cli-exec-explicit-local-and-mode-removal-plan.md @@ -0,0 +1,242 @@ +# CLI Exec Explicit Local Sessions And Mode Removal + +## Summary +Simplify the last misleading CLI mode seam by: + +- making `fabro exec` explicitly CLI-owned/local +- removing the global `ExecutionMode` / `resolve_mode` abstraction entirely +- deleting `mode` from user config and resolved `Settings` +- treating Fabro server usage as a command-specific connection choice instead of a whole-program execution mode + +This pass does **not** move the agent loop into the server. `fabro exec` remains a local agent session. The only server-backed `exec` behavior in scope is optional model transport through `FabroServerAdapter` and the existing `/completions` endpoint. + +## Scope Boundaries +In scope: +- `fabro exec` +- removal of `ExecutionMode`, `ResolvedMode`, and `resolve_mode` +- removal of `mode` from `user.toml` / `Settings` +- command-scoped server-target resolution for `exec` +- small adjacent `model` cleanup needed so `[server]` remains meaningful after `mode` is removed +- docs/help/config/test cleanup for the new contract + +Out of scope: +- server-owned interactive agent sessions +- new `/exec`, `/sessions`, or worker-RPC server APIs +- moving tool execution, MCP, permissions, or output rendering into the server +- changing the run lifecycle commands +- changing the `/completions` API contract +- adding a new persistent `exec` config field for default server routing + +## Key Decisions +- `fabro exec` always owns the agent session locally: + - prompt loop + - tool execution + - permission prompts + - MCP connections + - event/output rendering +- when `fabro exec` uses a Fabro server, only the LLM transport changes: + - requests go through `fabro_llm::providers::FabroServerAdapter` + - the adapter continues to call the existing `/completions` endpoint +- remove `mode` entirely instead of narrowing it. + - Once `exec` stops using it, there is no legitimate runtime consumer left. +- `fabro exec` remote routing stays an explicit per-invocation choice in this pass: + - `--server-url` enables server-routed model transport + - configured `[server].base_url` alone does **not** reroute `exec` + - Rationale: this keeps `exec` honest as a local command and avoids inventing a new “force direct” override surface immediately. +- `[server]` remains a real config surface after `mode` removal, but its meaning changes: + - it stores connection information for commands that support a remote Fabro server target + - it is not a whole-program execution mode switch +- `fabro model` should honor configured `[server].base_url` as its default remote target once `mode` is gone. + - `--server-url` still overrides config + - explicit `--storage-dir` still forces local auto-start for `model` + - this is a small collateral cleanup to keep the config contract coherent +- keep the global `--server-url` / `--storage-dir` clap conflict unchanged in this pass. + - Rationale: reducing the misleading mode abstraction does not require broad CLI flag-surface churn, and `exec` does not need both simultaneously. +- global help/docs must stop claiming: + - `--storage-dir` implies standalone mode + - `--server-url` implies server mode +- no backward-compatibility shim is required for `mode` in `user.toml`, `fabro settings`, or docs. +- `mode = "server"` / `mode = "standalone"` should disappear completely from the supported config surface: + - remove it from docs, examples, tests, and resolved settings output + - existing user config files that still contain it are out of contract after this pass + - if deserialization happens to ignore the stale key, that is incidental behavior, not preserved product surface + +## Implementation Changes +### 1. Remove global mode from shared config and settings types +Delete the dead mode concept from the shared config graph. + +In `lib/crates/fabro-types/src/settings/user.rs`: +- delete `ExecutionMode` + +In `lib/crates/fabro-types/src/settings/mod.rs`: +- remove the `ExecutionMode` re-export +- remove `Settings.mode` + +In `lib/crates/fabro-config/src/user.rs`: +- stop re-exporting `ExecutionMode` + +In `lib/crates/fabro-config/src/config.rs`: +- remove `ConfigLayer.mode` +- remove `mode` combine logic + +In `lib/crates/fabro-config/src/settings.rs`: +- stop mapping `value.mode` into `Settings` + +Behavior: +- `fabro settings` no longer emits a `mode` field +- `user.toml` no longer documents or accepts `mode` as a meaningful setting in this pass + +### 2. Replace `resolve_mode` with command-scoped server-target helpers +Stop encoding command behavior as a fake global mode decision. + +In `lib/crates/fabro-cli/src/user_config.rs`: +- delete: + - `ResolvedMode` + - `resolve_mode(...)` +- keep `build_server_client(...)` +- add one small shared remote-target type, for example: + - `ServerTarget { base_url: String, tls: Option }` +- update `apply_global_overrides(...)` so it only applies: + - `storage_dir` + - `server.base_url` + - and does **not** synthesize any `mode` +- if `apply_global_overrides(...)` becomes a trivial two-field merge helper after `mode` removal, inline it at the call sites instead of preserving it mechanically +- add concrete helpers that reflect the real command boundaries: + - `exec_server_target(globals: &GlobalArgs, settings: &Settings) -> Option` + - `model_server_target(globals: &GlobalArgs, settings: &Settings) -> Option` + +Expected helper semantics: +- `exec` helper: + - returns a remote server target only when `--server-url` is present + - uses configured `[server].tls` if available + - ignores configured `[server].base_url` when no CLI flag is present +- `model` helper: + - returns `--server-url` when present + - otherwise returns configured `[server].base_url` when present and no explicit `--storage-dir` was provided + - otherwise returns no remote target so the command falls back to local server auto-start + +This helper split is intentional. `exec` and `model` are both server-aware, but they do not have the same defaulting rules. + +### 3. Make `fabro exec` explicitly local with optional server-routed model transport +Remove the last server-vs-standalone branching from the command implementation. + +In `lib/crates/fabro-cli/src/commands/exec.rs`: +- remove the `resolve_mode(...)` call +- remove the `match resolved.mode { ... }` branch +- always build the session as a local CLI-owned agent session +- when the `exec` server-target helper returns `Some(target)`: + - build the HTTP client with `build_server_client(...)` + - create a `FabroServerAdapter` + - register it on a `fabro_llm::Client` + - call `run_with_args_and_client(...)` +- when the helper returns `None`: + - keep the direct provider path via `run_with_args(...)` +- replace logging from `mode = "server"/"standalone"` to something transport-shaped such as: + - `transport = "server"` + - `transport = "direct"` + +Keep unchanged: +- permission behavior +- MCP server wiring +- output format behavior +- sub-agent behavior +- sandbox/tool execution ownership + +### 4. Keep `[server]` meaningful after removing `mode` +Make the shared server config still useful without preserving the abstract mode layer. + +In `lib/crates/fabro-cli/src/commands/model.rs`: +- stop hand-rolling the `globals.server_url` match +- use the new model-target helper from `user_config.rs` +- preserve current user-visible `model` behavior apart from the new config defaulting: + - remote target when `--server-url` is passed + - remote target when `[server].base_url` is configured and no explicit `--storage-dir` is passed + - local auto-start otherwise + +This is the only planned collateral behavior change outside `exec`, and it exists to keep `[server]` coherent after `mode` removal. + +### 5. Remove stale docs and help text +Rewrite the user-facing contract around explicit server targets instead of execution modes. + +In `lib/crates/fabro-cli/src/args.rs`: +- no `--mode` flag exists today, so there is no parser flag removal in this file +- update the global flag docstrings: + - `--storage-dir` should describe local data/storage selection only + - `--server-url` should describe targeting a Fabro API server for commands that support it +- remove any wording that says either flag “implies” a mode + +In docs: +- `docs/reference/user-configuration.mdx` + - remove the `mode` section + - rewrite `[server]` as connection info for remote-target-capable commands + - clarify the `exec` vs `model` behavior split explicitly +- `docs/reference/cli.mdx` + - update the global option descriptions for `--storage-dir` and `--server-url` +- `docs/administration/deploy-server.mdx` + - remove guidance telling users to set `mode = "server"` in `user.toml` + - rewrite the “Pointing the CLI at a server” section around: + - `--server-url` + - configured `[server].base_url` + - command-specific behavior +- update any nearby docs that still describe “whole CLI server mode” rather than explicit server-target selection + +### 6. Remove or rewrite stale tests +Delete tests that only exist to preserve `mode` semantics, and add coverage for the real command boundaries. + +In `lib/crates/fabro-cli/src/user_config.rs` tests: +- replace `resolve_mode_*` tests with helper-focused tests covering: + - `exec` has no server target by default + - `exec` uses CLI `--server-url` + - `exec` ignores configured `[server].base_url` without CLI `--server-url` + - `model` uses configured `[server].base_url` + - `model` CLI `--server-url` overrides configured base URL + - `model` explicit `--storage-dir` suppresses configured remote targeting + - TLS is still taken from `[server].tls` when a remote target is selected + +In `lib/crates/fabro-cli/tests/it/cmd/exec.rs`: +- update help snapshots for the new global flag wording +- add one behavior test proving `--server-url` changes the failure mode away from local missing-provider-key validation + - for example: with no local provider key configured and an unreachable `--server-url`, the command should fail on remote connection rather than `API key not set for provider ...` +- add one regression test proving configured `[server].base_url` alone does not reroute `exec` + - expected outcome: with no local provider key, `exec` still fails on the local missing-key path +- add one explicit override test proving CLI `--server-url` wins over configured `[server].base_url` for `exec` + - expected outcome: with both present, the command targets the CLI URL and the failure shape reflects that URL/path rather than the configured one + +In `lib/crates/fabro-cli/tests/it/cmd/model.rs` and/or `lib/crates/fabro-cli/tests/it/cmd/model_list.rs`: +- add coverage showing configured `[server].base_url` is honored without passing `--server-url` + +In `lib/crates/fabro-cli/tests/it/cmd/config.rs`: +- remove `ExecutionMode` imports and expectations +- update `fabro settings` assertions so `mode` is no longer expected in resolved output +- keep coverage that `--server-url` still overrides configured `[server].base_url` + +In help snapshots: +- update any snapshots whose global options block still mentions implied server/standalone mode + - especially: + - `lib/crates/fabro-cli/tests/it/cmd/fabro.rs` + - `lib/crates/fabro-cli/tests/it/cmd/exec.rs` + - `lib/crates/fabro-cli/tests/it/cmd/model.rs` + - `lib/crates/fabro-cli/tests/it/cmd/model_list.rs` + - `lib/crates/fabro-cli/tests/it/cmd/config.rs` +- use the repo snapshot workflow from `CLAUDE.md`: + - run `cargo insta pending-snapshots` + - verify the expected help/output changes + - then run `cargo insta accept` + +## Test Plan +- Targeted unit tests: + - `cargo test -p fabro-cli user_config::tests -- --nocapture` +- Targeted CLI integration tests: + - `cargo nextest run -p fabro-cli cmd::exec:: cmd::model:: cmd::model_list:: cmd::config:: --no-fail-fast` +- Broader regression sweep after the targeted tests are green: + - `cargo nextest run -p fabro-cli --no-fail-fast` +- Final verification: + - `cargo fmt --check --all` + - `cargo clippy --workspace --all-targets -- -D warnings` + +## Assumptions And Defaults +- `fabro exec` remains a local agent session in this pass. If we later want server-owned interactive sessions, that should be a separate product/architecture plan. +- The existing `/completions` endpoint and `FabroServerAdapter` are sufficient for optional server-routed `exec` model traffic. +- Pre-production status means removing `mode` outright is acceptable; no shim or migration warning is required. +- `[server].base_url` remains valuable as a remote target config surface for commands like `model`, even after `mode` is removed. +- `exec` staying CLI-flag-only for server routing is intentional in this pass. If users later need a default server-routed `exec`, that should be introduced as an explicit `exec`-scoped config surface rather than reintroducing a fake whole-program mode. diff --git a/docs/plans/2026-04-05-cli-model-server-canonical-and-llm-removal-plan.md b/docs/plans/2026-04-05-cli-model-server-canonical-and-llm-removal-plan.md new file mode 100644 index 000000000..11630b591 --- /dev/null +++ b/docs/plans/2026-04-05-cli-model-server-canonical-and-llm-removal-plan.md @@ -0,0 +1,364 @@ +# CLI Model Server Canonicalization And LLM Namespace Removal + +## Summary +Take the next low-risk simplification step after the run lifecycle cleanup by: + +- removing the entire unused `fabro llm` CLI namespace +- making the `fabro model` family fully server-canonical +- leaving `fabro exec` unchanged for now + +This pass is intentionally asymmetric: +- `fabro llm` is removed from the CLI surface entirely +- `fabro model` remains, but becomes a server-backed command family instead of a mixed standalone/server command +- `fabro model test` keeps its current bulk/fan-out role in the CLI, but the server endpoint remains single-model only with an explicit test mode + +Because backward compatibility is not required yet, this pass should prefer simplification over shims. `model test --deep` stays, but it becomes an explicit server capability via `mode=basic|deep` on the single-model test endpoint instead of relying on the old mixed local/server split. + +## Scope Boundaries +In scope: +- remove `fabro llm` +- add `provider` and `query` filters to `GET /api/v1/models` +- keep `POST /api/v1/models/{id}/test` as a single-model health/test endpoint +- add optional `mode=basic|deep` to `POST /api/v1/models/{id}/test` +- make `fabro model list` and `fabro model test` always use the server + +Out of scope: +- `fabro exec` +- broader removal of `ExecutionMode` / `resolve_mode` outside the `model` command family +- changing `POST /models/{id}/test` into a bulk endpoint +- changing model storage/catalog ownership away from the server’s built-in catalog +- deleting lower-level `fabro_llm::cli` prompt/chat helpers unless they become dead and trivially removable during the refactor + +## Key Decisions +- `GET /api/v1/models` gets flat query params, not a generic filter object: + - `provider=` + - `query=` +- `query` matches `id`, `display_name`, and `aliases`, case-insensitively. +- Pagination applies after filtering. +- filtered `/api/v1/models` results preserve the built-in catalog order. +- invalid `provider` filter values are rejected with `400`, not treated as “no filter” or “no matches”. +- "invalid" means the query value fails to parse as a known `fabro_model::Provider` enum variant. +- a parsed `provider` value that happens to match zero catalog entries still returns `200` with an empty page. +- `POST /api/v1/models/{id}/test` continues to test exactly one model per request and gains one optional query param: + - `mode=basic|deep` + - default: `basic` +- CLI fan-out stays in `fabro model test`, not in the HTTP API. +- `model test --deep` is preserved and maps to `mode=deep` on repeated single-model server calls. +- `fabro model` should use the generated `fabro_api::Client` for model HTTP calls, not ad hoc raw `reqwest` + URL assembly. +- `fabro model` gets a command-specific server-target helper rather than reusing global `ExecutionMode` branching. + - if `--server-url` is present, use remote HTTP(S) with configured TLS + - otherwise use local server auto-start + Unix socket for the selected storage dir + - this does not change `resolve_mode` behavior for other commands +- `basic` is the explicit name for the current simple health check mode. + - Rationale: an enum-shaped mode is clearer than `deep=true` and leaves room for future test kinds without changing the endpoint shape. +- `POST /api/v1/models/{id}/test` accepts either a canonical model ID or an alias. + - The server resolves aliases using the built-in catalog. + - The response should always return the canonical `model_id`, not the alias string from the path. +- `model test --model ` should POST the provided value directly to `/api/v1/models/{id-or-alias}/test`. + - the CLI does not pre-resolve aliases via `GET /models` +- CLI `model list --json` preserves its current output contract as a plain array of model objects. + - The server remains paginated internally, but the CLI should flatten that to preserve the existing CLI JSON surface. +- Deep-mode API results remain binary at the schema level: + - success cases return `status: ok` + - any deep validation failure returns `status: error` + - the failure details go in `error_message` + - no new warning/partial result state is introduced in this pass + - models without required tool support still return HTTP `200` with `status: error` + - absence of reasoning traces alone does not fail deep mode in this pass + +## Implementation Changes +### 1. Remove the `fabro llm` CLI namespace +Update the CLI surface so `fabro llm` no longer exists. + +In `lib/crates/fabro-cli/src/args.rs`: +- remove the `Commands::Llm` variant +- remove `LlmNamespace` and `LlmCommand` +- remove `ChatArgs` / `PromptArgs` imports from `fabro_llm::cli` +- remove command-name mapping for `llm prompt` and `llm chat` + +In `lib/crates/fabro-cli/src/main.rs`: +- remove the `Commands::Llm(...)` dispatch arm + +In `lib/crates/fabro-cli/src/commands/mod.rs`: +- remove `pub(crate) mod llm;` + +Delete: +- `lib/crates/fabro-cli/src/commands/llm/mod.rs` +- `lib/crates/fabro-cli/src/commands/llm/chat.rs` +- `lib/crates/fabro-cli/src/commands/llm/prompt.rs` + +Test/support cleanup: +- remove `mod llm;` and `mod llm_prompt;` from `lib/crates/fabro-cli/tests/it/cmd/mod.rs` +- delete: + - `lib/crates/fabro-cli/tests/it/cmd/llm.rs` + - `lib/crates/fabro-cli/tests/it/cmd/llm_prompt.rs` +- remove the now-unused `TestContext::llm()` helper from `lib/crates/fabro-test/src/lib.rs` +- update top-level help snapshots in `lib/crates/fabro-cli/tests/it/cmd/fabro.rs` and any parser/help coverage that still mentions `llm` + +This pass should only remove the CLI namespace. Do not widen the blast radius by opportunistically deleting unrelated lower-level LLM helpers unless the compiler proves they are now dead and the deletion is mechanical. + +Because `fabro llm` is the only CLI surface for these paths today, expect some prompt/chat internals to become dead as a direct consequence of this removal. If the compiler confirms there are no remaining callers, delete the dead items in this pass rather than preserving unreachable code: +- `PromptArgs` +- `ChatArgs` +- `run_prompt` +- `run_chat` +- `run_prompt_via_server` +- `run_chat_via_server` + +### 2. Make `/api/v1/models` a real server-owned list endpoint +Treat `/api/v1/models` as a canonical non-demo API surface. + +In `docs/api-reference/fabro-api.yaml`: +- add optional query parameters to `GET /api/v1/models`: + - `provider` + - `query` +- document `query` matching semantics explicitly: + - substring match + - case-insensitive + - fields: `id`, `display_name`, `aliases` +- add optional query parameter to `POST /api/v1/models/{id}/test`: + - `mode=basic|deep` + - default behavior when omitted: `basic` + - document `basic` as the current simple prompt/availability check + - document `deep` as the multi-turn tool-use / reasoning round-trip check + +In `lib/crates/fabro-server/src/server.rs`: +- keep `/models/{id}/test` wired to the real server handler +- stop treating `/models` as demo semantics only +- replace the `demo::list_models` route usage with a non-demo handler +- implement a small query extractor for model filters and pagination in the real server path +- implement a small query extractor for model test mode in the `test_model` handler +- fix the `test_model` response to use `info.id` (the canonical model ID from the catalog lookup) instead of the raw `id` path parameter in all response branches, so alias lookups return the canonical ID + +In `lib/crates/fabro-server/src/demo/mod.rs`: +- `demo::list_models` is currently wired in both `demo_routes()` and `real_routes()`; replace both usages with the new non-demo handler +- remove the now-unused `list_models` helper from the demo module + +Behavior: +- server builds the list from `fabro_model::Catalog::builtin()` +- applies `provider` filter if present +- applies `query` filter if present +- paginates the filtered list +- returns the same `PaginatedModelList` schema shape as today +- preserves built-in catalog order after filtering +- rejects unknown `provider` values with `400` + - this means parse failure against `fabro_model::Provider` + - a valid parsed provider with zero matching models still returns `200` and an empty page +- `POST /models/{id}/test`: + - defaults to `basic` + - accepts either a canonical model ID or an alias + - runs the current simple one-prompt check in `basic` mode + - runs the deeper multi-turn tool-use / reasoning check in `deep` mode + - still returns one result object for one model + +### 3. Make `fabro model` server-canonical +Remove standalone/server branching from the CLI `model` command family. + +In `lib/crates/fabro-cli/src/commands/model.rs`: +- stop using `resolve_mode` +- stop passing `Option` into `run_models(...)` +- construct a typed `fabro_api::Client` up front and pass it through unconditionally + +In `lib/crates/fabro-cli/src/server_client.rs`: +- keep the existing local auto-start + Unix-socket path for storage-backed connections +- add one small model-command helper that returns a typed `fabro_api::Client` from either: + - explicit remote base URL + configured TLS (`--server-url`), or + - local auto-start + Unix socket for the selected storage dir +- if needed, split the current Unix-only local connect helper into: + - a local auto-start helper, and + - a thin typed-client constructor for remote HTTP(S) base URLs +- do not route `model` through `ServerStoreClient`; `model` should use the generated API client directly + +This change is intentionally command-specific: +- `--server-url` keeps working for remote server usage +- `--storage-dir` or default local storage uses local server auto-start +- `model` no longer branches on `ExecutionMode` +- global `resolve_mode` behavior for other commands stays unchanged in this pass + +### 4. Simplify model selection and test orchestration +Keep the CLI in charge of bulk orchestration, but move catalog authority to the server. + +In `lib/crates/fabro-llm/src/cli.rs`: +- replace the raw `reqwest` + `base_url` model HTTP helpers with generated `fabro_api::Client` calls +- update `fetch_models_from_server(...)` to forward `provider` and `query` to the server instead of filtering locally after the response +- update the fetch helper to follow pagination until `meta.has_more` is false instead of assuming one page is enough +- remove local provider filtering from the fetch helper +- remove the local query filtering in `run_models(...)` (the `if let Some(q) = &query { ... models.retain(...) }` block), since the server now handles query filtering +- simplify `run_models(...)` so it no longer accepts an optional server connection; `model` should always call the server-backed path +- keep `model test` fan-out behavior in the CLI: + - `model test --model ` calls `POST /models/{id-or-alias}/test` once + - `model test --provider ` first resolves the filtered list via `GET /models?provider=...`, then POSTs `/test` once per returned model + - bare `model test` first resolves the full list via `GET /models`, then POSTs `/test` once per model + - `model test --deep` maps each request to `POST /models/{id}/test?mode=deep` + - default `model test` behavior maps each request to `basic` mode + - any shared fetch helper used from test fan-out paths passes `query=None`, because query filtering remains list-only in this pass + +Preserve current broad behavior where reasonable: +- `--model` remains the direct single-model selector and should pass the user-supplied ID or alias through unchanged +- unknown model inputs should continue to surface an “unknown model” style error when the server returns 404 +- CLI fan-out result formatting and failure aggregation should remain substantially the same +- `model list --json` should continue to print a plain JSON array, not the server’s paginated envelope +- CLI output order should preserve the server/catalog order +- CLI selection behavior remains: + - `--model` takes precedence over `--provider` + - query filtering applies only to `list`, not `test`, unless explicitly added later + +### 5. Preserve `model test --deep` via server-owned test modes +`--deep` remains useful, but it should stop depending on the old local test execution branch. The server should own both single-model test modes, and the CLI should only orchestrate fan-out. + +Server-side implementation: +- extract the single-model test logic out of CLI-only code and into a reusable non-CLI helper in `fabro-llm` so the server can execute: + - `basic` mode + - `deep` mode +- keep that logic in `fabro-llm`, not inline in the axum handler; this is LLM-domain behavior, not route-local glue +- shape the extracted helper around an internal outcome type that already matches the binary API contract: + - success => `status: ok` + - failure => `status: error` plus message detail +- preserve the current timeout budgets: + - `basic` uses the current short timeout budget (`30s`) + - `deep` uses the current long timeout budget (`90s`) +- keep deep mode's current in-process tool-closure pattern from `build_deep_test_params`; this pass does not add RPC or external tool-runner infrastructure +- `mode=deep` for a model without tool support returns HTTP `200` with `status: error` and a clear `error_message` +- if a reasoning-capable model completes deep mode without reasoning traces, do not fail for that fact alone in this pass +- the initial source material is the existing logic in `lib/crates/fabro-llm/src/cli.rs`: + - `build_deep_test_params` + - `validate_deep_result` + - current one-model test logic +- the end state should be: + - server calls a reusable `fabro-llm` helper for one-model test execution + - CLI no longer owns authoritative one-model test behavior +- do not leave deep behavior only in the CLI-local path once `model` becomes server-canonical + +CLI-side implementation: +- keep `deep` on `ModelsCommand::Test` +- update the server-call helper(s) to pass `mode=deep` when requested +- remove only the dead local standalone branches once the server owns both modes +- remove the `"Warning: --deep is not supported in server mode"` diagnostic from `test_models_via_server`, since deep mode is now a server-owned capability +- remove the `deep_unsupported` field from `ModelTestOutput` JSON serialization, since deep mode is now fully supported via the server + +In CLI help/snapshots: +- keep `--deep` on `model test --help` +- update wording if needed so it reflects a server-backed deep test rather than a local-only path + +### 6. Dependencies And Sequencing +Apply this in order so the refactor has stable interfaces to land on: + +- land the OpenAPI changes for `/models` filters and `/models/{id}/test?mode=basic|deep` first +- update the server list/test handlers, including provider parsing, alias handling, and canonical response IDs +- extract the reusable single-model `basic` / `deep` test helper in `fabro-llm` +- regenerate the Rust typed API client via `cargo build -p fabro-api` +- repoint `fabro model` and its fetch/test helpers to the generated `fabro_api::Client` +- remove the `fabro llm` CLI namespace and any compiler-confirmed dead prompt/chat code +- regenerate the TypeScript client once the server contract is settled + +Section 2's endpoint and mode changes must land before replacing the deep-mode server flow described in Section 5. + +### 7. Regenerate typed API clients through the normal workflow +Because `/api/v1/models` changes, follow the repo’s API workflow instead of hand-editing generated clients. + +Source of truth: +- `docs/api-reference/fabro-api.yaml` + +Generated/regenerated artifacts: +- Rust API client/types via `cargo build -p fabro-api` +- TypeScript client via `cd lib/packages/fabro-api-client && bun run generate` + +Expected generated updates include: +- `lib/packages/fabro-api-client/src/api/models-api.ts` +- related generated model/type files under `lib/packages/fabro-api-client/src/models/` + +## Important Interface / Behavior Changes +- `fabro llm` is removed from the CLI surface completely. +- `fabro model` always talks to the server. +- `GET /api/v1/models` accepts: + - `provider` + - `query` +- invalid `provider` values return `400` +- invalid `provider` means the value does not parse as a known `fabro_model::Provider` +- valid provider filters that simply match zero models still return `200` with an empty page +- filtered results preserve built-in catalog order +- `POST /api/v1/models/{id}/test` accepts: + - optional `mode=basic|deep` + - default mode `basic` +- `/models/{id}/test` accepts aliases but returns the canonical `model_id` +- `model test --model ` posts that alias directly and relies on server-side alias resolution +- `model test --deep` remains supported and maps to `mode=deep`. +- `model list --json` continues to output a plain JSON array. + +## Test Plan +CLI command surface: +- `lib/crates/fabro-cli/tests/it/cmd/fabro.rs` + - top-level help no longer lists `llm` +- `lib/crates/fabro-cli/tests/it/cmd/model.rs` + - bare `model` and `model list` still work + - provider/query list behavior still matches expected snapshots + - JSON output still parses as a plain array + - `model list` still works with an auto-started local server and with `--server-url` +- `lib/crates/fabro-cli/tests/it/cmd/model_list.rs` + - help text still matches the new server-backed behavior +- `lib/crates/fabro-cli/tests/it/cmd/model_test.rs` + - help text still mentions `--deep` + - unknown model still errors cleanly + - `--model ` calls the single-model test endpoint directly and succeeds via server-side alias resolution + - deep mode still routes through the server-backed model test flow + - JSON output no longer includes `deep_unsupported` +- delete now-obsolete `llm` CLI tests: + - `lib/crates/fabro-cli/tests/it/cmd/llm.rs` + - `lib/crates/fabro-cli/tests/it/cmd/llm_prompt.rs` + +Server/API behavior: +- `lib/crates/fabro-server/src/server.rs` + - add tests for `GET /api/v1/models` with: + - no filters + - `provider` + - `query` + - combined `provider + query` + - case-insensitive query matching + - pagination applied after filtering + - invalid provider returns `400` + - valid parsed provider + no matching query returns `200` with an empty page + - filtered results preserve catalog order + - extend `POST /api/v1/models/{id}/test` coverage for: + - omitted mode defaults to `basic` + - explicit `mode=basic` + - explicit `mode=deep` + - invalid mode rejected cleanly + - alias path values resolve successfully and return the canonical `model_id` + - unknown model still returns 404 + - `mode=deep` on a model without tool support returns `200` with `status: error` + +LLM CLI internals: +- `lib/crates/fabro-llm/src/cli.rs` + - update `fetch_models_from_server` unit tests to assert outgoing `provider` / `query` params + - add coverage that multi-page server responses are fully traversed + - update/remove tests that assumed client-side provider filtering + - add coverage that test fan-out paths call the shared fetch helper with `query=None` + - add or update server-call tests so deep mode is forwarded as `mode=deep` + - add helper-level coverage for the binary deep-mode contract: + - tool-less models return `error` + - missing reasoning traces alone do not fail + - remove only the dead standalone-only test coverage after server ownership is in place + +Full verification: +- `cargo build -p fabro-api` +- `cargo nextest run -p fabro-cli --no-fail-fast` +- `cargo nextest run -p fabro-server` +- `cargo fmt --check --all` +- `cargo clippy --workspace -- -D warnings` +- `cd lib/packages/fabro-api-client && bun run generate` + +## Assumptions And Defaults +- `fabro llm` is dead product surface and should be removed cleanly, not hidden. +- `exec` remains a separate product surface and is intentionally untouched in this pass. +- `GET /api/v1/models` remains a built-in catalog view; this pass does not introduce live provider discovery. +- CLI model fetches must traverse paginated `/models` responses until exhaustion. +- `model test` remains a CLI orchestrator over repeated single-model HTTP calls. +- local-storage-backed `fabro model` may auto-start the local server on first use. +- for local auto-started servers, model testing assumes the daemon inherits the CLI environment, including provider credentials. +- for explicit remote `--server-url` usage, provider credential availability is the remote server operator's responsibility. +- `POST /api/v1/models/{id}/test` uses a single optional query param for mode selection: + - `basic` + - `deep` +- Deep-mode validation failures are represented as `status: error` plus `error_message`, not a new intermediate status. +- If both `--model` and `--provider` are supplied to `model test`, keep the current effective behavior of prioritizing the explicit model selection rather than inventing a new validation rule in this pass. +- No backward-compatibility shims are needed for removed CLI commands or flags. diff --git a/docs/plans/2026-04-05-cli-run-lifecycle-cleanup-compaction-plan.md b/docs/plans/2026-04-05-cli-run-lifecycle-cleanup-compaction-plan.md new file mode 100644 index 000000000..db4176f59 --- /dev/null +++ b/docs/plans/2026-04-05-cli-run-lifecycle-cleanup-compaction-plan.md @@ -0,0 +1,100 @@ +# CLI Run Lifecycle Cleanup Compaction + +## Summary +Simplify the proven server-backed CLI run lifecycle without changing execution topology in this pass. + +This pass keeps `/api/v1/runs/*` as the canonical run API, ignores `/boards/runs`, and treats the current server-owned in-process execution model as the stable behavior for now. The goal is to remove obsolete local-launcher compatibility, collapse duplicated server-backed lookup code, and leave the CLI in a cleaner client-to-server shape. The hidden internal command is renamed from `__detached` to `__runner` now so the code reflects its future role, but this pass does not yet make the server spawn it. + +## Key Changes +### 1. Lock the cleanup boundary +- Treat the run lifecycle surface as server-backed and cleanup-eligible: + - `run`, `create`, `start`, `resume`, `attach`, `wait`, `logs`, `diff`, `preview`, `ssh`, `runs/*`, `pr/*`, `artifact/*`, `system df/prune`, `store dump` +- Leave these out of scope for this pass: + - global `ExecutionMode` / `resolve_mode` + - `model`, `exec`, `llm chat`, `llm prompt` + - `/boards/runs` + - changing `POST /runs/{id}/start` to spawn a worker + - introducing worker-RPC execution + +### 2. Rename and shrink the hidden worker entrypoint +- Rename the hidden internal subcommand from `__detached` to `__runner`. +- Rename the enum variant, parser tests, help snapshots, and the command module/test names so internal terminology matches the future worker model. +- Narrow the hidden command contract to the minimum needed for its current thin behavior: + - keep `run_id` + - keep `resume` + - keep storage selection via globals/settings + - remove `run_dir` + - remove `launcher_path` +- Keep its implementation simple for now: + - connect to the server + - call `POST /runs/{id}/start` + - poll terminal status +- Do not keep `__detached` as an alias. This pass is the rename. + +### 3. Remove launcher-era compatibility +- Delete the launcher-record subsystem in `commands/run/launcher.rs`: + - record file creation/removal + - stale PID cleanup + - process-command matching + - run-id fallback via launcher metadata +- Simplify `attach` to a server-only model when no explicit child handle is supplied: + - infer run ID from the explicit argument or `run_dir/id.txt` + - infer storage dir from the `runs/` path shape only + - cancel via `POST /runs/{id}/cancel` + - remove launcher-PID probing, launcher-based kill logic, and “server-owned vs launcher-owned” branching +- Remove the now-dead `engine_child` compatibility path from `attach` if it is no longer used by production callers. +- Update `run`, `resume`, and related comments/tests so they describe server-owned execution accurately instead of launcher/detach-era behavior. + +### 4. Collapse duplicated server-backed run resolution +- Add one small internal helper layer for server-backed run lookup and state access. +- Move the repeated pattern out of individual commands: + - connect to server for storage dir + - list durable run summaries + - resolve a user-supplied run selector against `runs_base(...)` + - optionally fetch current state/events +- Repoint already-server-backed commands to this helper so they stop hand-rolling the same summary lookup logic. +- Keep behavior unchanged; this is compaction, not a semantic migration. + +### 5. Preserve the single-store ownership invariant explicitly +- Do not add any direct SlateDB access to `__runner`. +- Treat the server as the only SlateDB reader/writer. +- Keep the future `__runner` model aligned with server-owned storage: + - the worker process may execute workflow logic + - all durable run mutation must still go through server HTTP endpoints over the Unix socket +- In this cleanup pass, reflect that invariant in naming, comments, and any internal helper boundaries so later worker-RPC work does not have to undo new local-store assumptions. + +### 6. Remove stale transitional tests and comments +- Rewrite or delete tests that only exist to preserve launcher-record or `__detached` behavior. +- Keep tests that prove the intended architecture: + - server-backed detached run creation/start/attach + - no launcher record is created in normal run/start/resume flow + - hidden `__runner` still parses and works with its reduced contract +- Update misleading comments and docstrings that still describe local detached engine ownership. + +## Test Plan +- Targeted CLI tests: + - `cmd::start::*` + - `cmd::attach::*` + - `cmd::detached::*` renamed to `cmd::runner::*` + - `cmd::resume::*` + - `cmd::logs::*` + - `cmd::wait::*` +- Add or update focused tests for: + - attach infers run ID from `id.txt` without launcher metadata + - ctrl-c / detach uses server cancel for active runs + - `__runner` help/parser no longer mentions launcher-era flags + - no launcher files are created anywhere in the normal run/start/resume flow +- Run full verification: + - `cargo nextest run -p fabro-cli --no-fail-fast` + - `cargo nextest run -p fabro-server` + - `cargo fmt --check --all` + - `cargo clippy --workspace -- -D warnings` + +## Assumptions And Defaults +- `/boards/runs` is demo-only and ignored in this pass. +- `POST /runs/{id}/start` continues to queue and execute runs inside the server process for now. +- The future worker model is ordinary server-owned child processes, not detached launcher-managed processes. +- The server remains the single SlateDB reader/writer; future `__runner` processes must use server HTTP endpoints over the Unix socket for durable mutation. +- `__runner` is retained now because a later plan will make it the server-launched per-run worker, but that worker flip is explicitly not part of this cleanup pass. +- The local run directory still exists and remains a valid source for `id.txt` and runtime/artifact paths where the current code already depends on it. +- Broader standalone-to-server cleanup outside the proven run lifecycle area is deferred to a later plan. diff --git a/docs/plans/2026-04-05-run-adjacent-server-only-cleanup-plan.md b/docs/plans/2026-04-05-run-adjacent-server-only-cleanup-plan.md new file mode 100644 index 000000000..e5667dbe5 --- /dev/null +++ b/docs/plans/2026-04-05-run-adjacent-server-only-cleanup-plan.md @@ -0,0 +1,570 @@ +# Run-Adjacent Server-Only Cleanup + +## Summary + +Make the remaining user-facing run-adjacent commands server-only: + +- `fabro resume` +- `fabro diff` +- `fabro fork` +- `fabro rewind` +- `fabro artifact list` +- `fabro artifact cp` +- `fabro pr create|list|view|merge|close` +- `fabro sandbox preview` +- `fabro sandbox ssh` +- `fabro sandbox cp` + +After this pass, those commands should no longer resolve runs through local `runs/` directories or flatten `--storage-dir`. They should all target a server the same way the core lifecycle commands already do: + +1. explicit `--server` / `FABRO_SERVER` +2. configured `[server].target` +3. default local server instance, auto-started via the default local storage dir + +There is intentionally no separate “force local” override for these commands in this pass. If `[server].target` is configured, that target wins unless the user passes an explicit `--server` value pointing at a local Unix socket. This is a simplicity tradeoff, not an accidental regression. + +This is cleanup/compaction, not a new subsystem. The goal is to finish the architectural move already made for `run`, `create`, `start`, `attach`, `logs`, `wait`, `ps`, `inspect`, and `rm`. + +## Scope Boundaries + +In scope: +- the commands listed above +- shared run-resolution helpers in `fabro-cli` +- missing thin server APIs needed to make sandbox-oriented commands truly server-only +- CLI help/docs/snapshots for the new contract + +Out of scope: +- `fabro store dump` +- `fabro system df` +- `fabro system prune` +- hidden/internal commands like `__runner` +- changing run execution topology +- changing checkpoint format or git metadata format +- changing GitHub auth/secrets flows + +## Problem Frame + +The run lifecycle is only partially simplified today. + +Core lifecycle commands are already server-only, but the rest of the run-adjacent surface still falls back to local path-based lookup through `ServerRunLookup` in [server_runs.rs](lib/crates/fabro-cli/src/server_runs.rs). That creates three kinds of drift: + +- some commands still expose `--storage-dir` even though the real abstraction is now a server target +- some commands read local run files even when equivalent data already exists in server state/store +- some commands still reconnect directly from the CLI to sandboxes, which breaks the “server-owned runs” abstraction for remote targets + +That is unnecessary complexity in a greenfield app with no production compatibility constraints. + +## Key Decisions + +- All in-scope commands become server-only. + - They flatten `ServerTargetArgs`, not `StorageDirArgs`. + - `--storage-dir` is removed from their public CLI surface. + - `FABRO_STORAGE_DIR` is not part of the public contract for these commands. + - If `[server].target` is configured, these commands use it unless an explicit `--server` is passed. + - There is no separate “force local default instance” flag. + +- `ServerRunLookup` becomes local/admin-only. + - Keep it only for genuinely local maintenance commands like `store dump`, `system df`, and `system prune`. + - User-facing run commands should resolve selectors through `ServerSummaryLookup` plus server state/events. + +- `fabro diff` is simplified to stored server-backed output only. + - Drop the live sandbox reconnect fallback. + - Drop `--stat` and `--shortstat`. + - Output comes only from stored diff data already present in run state (`final_patch` and per-node `diff`). + - If no stored diff exists, return a clear error. + +- `fabro fork` and `fabro rewind` remain local git operations, but not local run-store operations. + - Run selection and run event/state loading come from the target server. + - Repo mutation still happens against the caller’s local checkout, which is the correct boundary. + - Before mutating the repo, the CLI should compare the current checkout against a durable stored repo identity and fail fast on obvious mismatch. + +- `fabro pr *` stays CLI-owned for GitHub network calls, but server-owned for run lookup and pull-request record loading. + - This keeps the current GitHub App model intact while removing local run-dir dependence. + - `pr create` should use the same repo-mismatch guard as `fork` and `rewind` before operating on the caller’s checkout. + +- `fabro artifact *` should reuse the existing stage artifact API instead of walking local `artifacts/` directories. + - The CLI can derive relevant stage IDs from `RunProjection.nodes` and call the existing stage artifact list/download endpoints. + +- `fabro sandbox preview`, `fabro sandbox ssh`, and `fabro sandbox cp` should stop reconnecting to sandboxes directly from the CLI. + - The server owns the sandbox record and should own the reconnect. + - The CLI becomes a thin consumer of server responses. + +- The server API should stay thin and capability-shaped. + - Reuse existing APIs where they already exist. + - Add only the missing endpoints required for truly server-only sandbox operations. + +- Backward compatibility is not a goal. + - Remove obsolete flags and behavior instead of shimming them. + - Prefer deleting speculative unused surface over preserving it. + +## Sequencing + +This plan should land in two phases. + +Phase 1: pure CLI/server-state cleanup using APIs that already exist + +- `resume` +- `fork` +- `rewind` +- `pr *` +- `artifact list` +- `artifact cp` (the stage artifact list/download routes already exist and are implemented) +- `diff` +- Phase 1 OpenAPI cleanup for `diff`: + - delete `/api/v1/runs/{id}/files` +- shared `ServerSummaryLookup` / `ServerRunLookup` narrowing +- help/docs/snapshot churn for those commands + +Phase 2: thin server API completion for sandbox-owned commands + +- `sandbox preview` +- `sandbox ssh` +- `sandbox cp` + +This keeps the simpler repoints from being blocked on the few missing server capability routes. + +The final `ServerRunLookup` narrowing should happen only after the last in-scope command that still imports it has been migrated. Do not try to enforce an admin-only boundary halfway through the sequence while migrated and unmigrated command families still coexist. + +## Implementation Changes + +### 0. Add a durable repo identity field for local-repo operations + +The current `RunRecord.host_repo_path` is a filesystem path, not a durable cross-machine identity. It should not be used for a server-targeted repo-mismatch guard. + +Add a new persisted field to `RunRecord`, for example: + +- `repo_origin_url: Option` + +Recommended semantics: + +- source it from `RunManifest.git.origin_url`, which is already sanitized before it reaches the server +- persist it in the run record when a manifest-sourced run is created +- treat it as the canonical repo-identity signal for CLI commands that mutate or inspect the caller’s local checkout on behalf of a server-selected run + +Normalization rules should be explicit and shared: + +- strip embedded credentials +- normalize GitHub-style SSH URLs to HTTPS form +- trim a trailing `.git` +- trim trailing `/` + +Guard behavior: + +- `fork`, `rewind`, and `pr create` detect the current checkout’s origin URL locally +- normalize it with the same helper used for the stored field +- if both sides are present and clearly differ, fail with a targeted repo-mismatch error +- if the stored field is absent, skip the guard rather than inventing a heuristic fallback + +Files expected to change: + +- [run.rs](lib/crates/fabro-types/src/run.rs) +- the manifest-backed run creation path that already receives `manifest.git.origin_url` +- any serialization/projection paths that persist and reload `RunRecord` + +### 1. Convert remaining run-adjacent args to `ServerTargetArgs` + +In [args.rs](lib/crates/fabro-cli/src/args.rs): + +- replace `StorageDirArgs` with `ServerTargetArgs` for: + - `ArtifactListArgs` + - `ArtifactCpArgs` + - `CpArgs` + - `PreviewArgs` + - `SshArgs` + - `DiffArgs` + - `ResumeArgs` + - `RewindArgs` + - `ForkArgs` + - `PrCreateArgs` + - `PrListArgs` + - `PrViewArgs` + - `PrMergeArgs` + - `PrCloseArgs` + +Also simplify `DiffArgs`: + +- remove `stat` +- remove `shortstat` +- update help text to describe stored diff output only + +Resulting CLI contract: + +- `fabro diff --server http://127.0.0.1:3000/api/v1` +- `fabro artifact list --server /var/run/fabro.sock` +- `fabro sandbox ssh --server https://fabro.example.com/api/v1` +- no `--storage-dir` on these commands +- no public `FABRO_STORAGE_DIR` support on these commands + +### 2. Narrow shared lookup helpers + +In [server_runs.rs](lib/crates/fabro-cli/src/server_runs.rs): + +- keep `ServerSummaryLookup` as the default user-facing selector path +- add any missing helper methods needed for: + - selector resolution + - filtered summary listing + - summary-to-state/event follow-up work +- keep `ServerRunLookup` only for commands that remain explicitly local/admin-only + +Do not try to finish that narrowing until the last in-scope migrated command is off `ServerRunLookup`. + +In [server_client.rs](lib/crates/fabro-cli/src/server_client.rs): + +- add thin wrappers for the server APIs the CLI now needs, split by phase: + - Phase 1: + - list stage artifacts + - download stage artifact + - Phase 2: + - generate preview URL + - create SSH access + - sandbox file listing/download/upload + +Do not introduce a parallel target-resolution stack. Reuse: + +- `connect_server_only(...)` +- `server_only_command_connection(...)` + +If tests still need `FABRO_STORAGE_DIR` internally to steer the default local server instance, treat that as harness-only plumbing rather than user-facing behavior. + +### 3. Repoint `resume` to the server-only lifecycle helpers + +In [resume.rs](lib/crates/fabro-cli/src/commands/run/resume.rs): + +- stop using `ServerRunLookup` +- resolve the run via `ServerSummaryLookup` +- call the existing direct-client start helper +- for foreground resume: + - attach through the existing direct-client attach helper + - print the existing server-backed summary output with no local `run_dir` + +This is intentionally a small mechanical repoint. It should make `resume` match the already-simplified `start`/`attach` contract without introducing new behavior. + +### 4. Repoint `fork` and `rewind` to server-backed run state + +In [fork.rs](lib/crates/fabro-cli/src/commands/run/fork.rs) and [rewind.rs](lib/crates/fabro-cli/src/commands/run/rewind.rs): + +- stop using `ServerRunLookup` +- resolve the run via `ServerSummaryLookup` +- fetch events/state via the resolved `ServerStoreClient` +- keep the local git/checkpoint mutation logic unchanged +- before mutating the local repo, validate obvious identity against stored run metadata: + - compare the current checkout’s detected repo identity against `RunRecord.repo_origin_url` when present + - if they clearly do not match, fail with a targeted error instead of mutating the wrong checkout + +In [rewind.rs](lib/crates/fabro-cli/src/commands/run/rewind.rs): + +- remove dependence on `run.path` for rewound-state cleanup +- if a small local run-dir cleanup is still required, compute it server-side or remove it +- keep durable run-state restoration (`run.rewound`, restored checkpoint, `run.submitted`) server-backed + +The point is to make rewind/fork depend on the local repo, not on local run-store layout. + +### 5. Repoint `pr *` to server-backed run selection and record loading + +In [pr/mod.rs](lib/crates/fabro-cli/src/commands/pr/mod.rs) and subcommands: + +- remove `runs_base(...)` / `ServerRunLookup::connect_from_runs_base(...)` +- resolve runs through `ServerSummaryLookup` +- load pull-request state from `get_run_state(...)` + +Specific changes: + +- [pr/list.rs](lib/crates/fabro-cli/src/commands/pr/list.rs) + - replace `scan_runs_with_summaries(...)` with summary iteration from `ServerSummaryLookup` + - keep current GitHub detail-fetch fanout in the CLI + +- [pr/create.rs](lib/crates/fabro-cli/src/commands/pr/create.rs) + - rebuild run state from server events instead of local path lookup + - keep local repo detection via `detect_repo_info(...)` + - apply the same repo-mismatch guard used by `fork` / `rewind` before proceeding + +- [pr/view.rs](lib/crates/fabro-cli/src/commands/pr/view.rs) +- [pr/close.rs](lib/crates/fabro-cli/src/commands/pr/close.rs) +- [pr/merge.rs](lib/crates/fabro-cli/src/commands/pr/merge.rs) + - load the stored PR record from the target server only + +### 6. Make `diff` fully server-backed and simpler + +In [diff.rs](lib/crates/fabro-cli/src/commands/run/diff.rs): + +- stop using `ServerRunLookup` +- resolve via `ServerSummaryLookup` +- load `RunProjection` from the target server +- keep only two sources of diff output: + - per-node `diff` + - run-level `final_patch` +- remove sandbox reconnect and live diff generation entirely + +Behavior: + +- `fabro diff ` prints `final_patch` +- `fabro diff --node ` prints the stored node diff +- if no stored diff exists, error with a clear message + +Because this pass intentionally simplifies the product surface: + +- remove `--stat` +- remove `--shortstat` +- update docs/tests accordingly + +Also remove dead speculative server API surface tied to the old diff shape: + +- delete `/api/v1/runs/{id}/files` from [fabro-api.yaml](docs/api-reference/fabro-api.yaml) +- remove the corresponding `not_implemented` route from [server.rs](lib/crates/fabro-server/src/server.rs) + +This is a Phase 1 OpenAPI/spec change and should be treated as part of that phase explicitly. + +That API is currently unimplemented and unused by the CLI. Keeping it around only adds drift. + +### 7. Repoint `artifact list` and `artifact cp` to the existing server artifact API + +In [artifact/list.rs](lib/crates/fabro-cli/src/commands/artifact/list.rs) and [artifact/cp.rs](lib/crates/fabro-cli/src/commands/artifact/cp.rs): + +- stop using `ServerRunLookup` and `RuntimeState` +- resolve the run via `ServerSummaryLookup` +- fetch `RunProjection` +- enumerate stage IDs from `RunProjection.nodes` +- use the existing stage artifact routes for each relevant stage: + - list artifact filenames + - download artifact bytes + +Keep filtering behavior in the CLI: + +- `--node` +- `--retry` +- tree/no-tree output layout +- filename collision handling + +This preserves the current artifact UX while removing local artifact-dir reads. + +### 8. Finish preview/SSH/file-transfer as real server-owned sandbox operations + +This is the only part of the plan that needs new or completed server APIs. + +#### 8a. Preview + +The route already exists in [fabro-api.yaml](docs/api-reference/fabro-api.yaml) and [server.rs](lib/crates/fabro-server/src/server.rs), but the real handler is still `not_implemented`. + +Implement it in [server.rs](lib/crates/fabro-server/src/server.rs): + +- load the run’s sandbox record from store/state +- reconnect server-side +- for Daytona: + - generate signed or unsigned preview URL as requested +- return `409` if: + - no active sandbox + - sandbox provider does not support preview + +Then repoint [preview.rs](lib/crates/fabro-cli/src/commands/run/preview.rs) to the server API instead of direct Daytona reconnect. + +#### 8b. SSH + +Add a new route to [fabro-api.yaml](docs/api-reference/fabro-api.yaml): + +- `POST /api/v1/runs/{id}/ssh` + +Recommended request/response shape: + +- request: + - `ttl_minutes` +- response: + - `command` + +Implement the handler in [server.rs](lib/crates/fabro-server/src/server.rs): + +- load sandbox record +- reconnect server-side +- generate SSH access for supported providers +- return `409` when unsupported or unavailable + +Then repoint [ssh.rs](lib/crates/fabro-cli/src/commands/run/ssh.rs): + +- `--print` prints the returned command +- non-`--print` locally `exec`s the returned command + +That preserves current UX while removing direct CLI sandbox reconnect. + +#### 8c. Sandbox file transfer (`fabro sandbox cp`) + +Add a small server-owned file-transfer surface for sandboxes. + +Recommended routes: + +- `GET /api/v1/runs/{id}/sandbox/files` + - query: + - `path` + - optional `depth` + - returns directory entries + +- `GET /api/v1/runs/{id}/sandbox/file` + - query: + - `path` + - returns raw file bytes + +- `PUT /api/v1/runs/{id}/sandbox/file` + - query: + - `path` + - request body: + - raw file bytes + +Implementation in [server.rs](lib/crates/fabro-server/src/server.rs): + +- load sandbox record +- reconnect server-side +- delegate to the existing `Sandbox` trait: + - `list_directory` + - `download_file_to_local` equivalent via temp file or direct read/write helper + - `upload_file_from_local` equivalent via temp file or direct write helper + +CLI changes in [cp.rs](lib/crates/fabro-cli/src/commands/run/cp.rs): + +- stop reconnecting to sandboxes directly +- resolve runs via `ServerSummaryLookup` +- for recursive download: + - list directory via server + - download files one by one via server +- for upload: + - recursively walk local input + - upload files one by one via server + +This keeps the current UX and avoids inventing a tar/archive protocol. + +### 9. Update docs/help text to match the new surface + +Update: + +- [docs/reference/cli.mdx](docs/reference/cli.mdx) +- [docs/reference/user-configuration.mdx](docs/reference/user-configuration.mdx) +- [docs/core-concepts/how-fabro-works.mdx](docs/core-concepts/how-fabro-works.mdx) + +The docs should explicitly reflect: + +- in-scope run-adjacent commands now use `--server`, not `--storage-dir` +- `diff` is stored-output only +- sandbox preview/SSH/file transfer are server-mediated +- local storage-dir maintenance commands still exist, but they are not the normal user-facing run lifecycle + +## Test Plan + +### Help/parser coverage + +Update snapshots in: + +- [artifact_list.rs](lib/crates/fabro-cli/tests/it/cmd/artifact_list.rs) +- [artifact_cp.rs](lib/crates/fabro-cli/tests/it/cmd/artifact_cp.rs) +- [diff.rs](lib/crates/fabro-cli/tests/it/cmd/diff.rs) +- [fork.rs](lib/crates/fabro-cli/tests/it/cmd/fork.rs) +- [resume.rs](lib/crates/fabro-cli/tests/it/cmd/resume.rs) +- [rewind.rs](lib/crates/fabro-cli/tests/it/cmd/rewind.rs) +- [pr_create.rs](lib/crates/fabro-cli/tests/it/cmd/pr_create.rs) +- [pr_list.rs](lib/crates/fabro-cli/tests/it/cmd/pr_list.rs) +- [pr_view.rs](lib/crates/fabro-cli/tests/it/cmd/pr_view.rs) +- [pr_close.rs](lib/crates/fabro-cli/tests/it/cmd/pr_close.rs) +- [pr_merge.rs](lib/crates/fabro-cli/tests/it/cmd/pr_merge.rs) +- [sandbox_cp.rs](lib/crates/fabro-cli/tests/it/cmd/sandbox_cp.rs) +- [sandbox_preview.rs](lib/crates/fabro-cli/tests/it/cmd/sandbox_preview.rs) +- [sandbox_ssh.rs](lib/crates/fabro-cli/tests/it/cmd/sandbox_ssh.rs) + +Scenarios: + +- help shows `--server` +- help no longer shows `--storage-dir` +- `diff --help` no longer shows `--stat` or `--shortstat` +- no docs/help text implies these commands honor `FABRO_STORAGE_DIR` + +Use the normal snapshot workflow: + +1. `cargo insta pending-snapshots` +2. inspect changes +3. `cargo insta accept` + +### CLI targeting behavior + +Add or update CLI tests for each in-scope command family: + +- explicit `--server` wins +- configured `[server].target` is used when no flag is passed +- no explicit target uses the default local server instance + +Concrete tests: + +- `resume` uses configured server target without local run-dir lookup +- `artifact list` uses configured server target without local artifact-dir lookup +- `artifact cp` uses configured server target and downloads through the server +- `pr list` uses configured server target without scanning local runs/ +- `pr view`/`pr merge`/`pr close` resolve records from the server target +- `sandbox preview` uses the server endpoint instead of direct Daytona reconnect +- `sandbox ssh --print` uses the server endpoint and prints the returned command +- `sandbox cp` upload/download works against a target server without CLI-side sandbox reconnect +- when `[server].target` is configured, these commands use it by default +- there is no separate local override path besides passing an explicit local `--server` + +### Diff behavior coverage + +In [diff.rs](lib/crates/fabro-cli/tests/it/cmd/diff.rs): + +- completed run with stored final patch still prints patch +- missing stored final patch errors cleanly +- stored node diff still works +- remove tests that depend on live diff fallback semantics + +### Fork/rewind/resume behavior coverage + +In: + +- [fork.rs](lib/crates/fabro-cli/tests/it/cmd/fork.rs) +- [rewind.rs](lib/crates/fabro-cli/tests/it/cmd/rewind.rs) +- [resume.rs](lib/crates/fabro-cli/tests/it/cmd/resume.rs) + +Add server-target coverage: + +- configured `[server].target` works without local run-store lookup +- explicit `--server` overrides configured target +- rewind/fork/resume continue to mutate only the local repo, not local run-store metadata files +- rewind/fork/pr-create fail fast when the selected run’s stored `repo_origin_url` clearly does not match the current checkout +- rewind/fork/pr-create skip the guard cleanly when older runs do not have a stored durable repo identity yet + +### Server API coverage + +Add server tests in [server.rs](lib/crates/fabro-server/src/server.rs) or the server integration suite for: + +- preview URL generation for a supported sandbox +- preview rejects missing/unsupported sandboxes with `409` +- SSH command generation for a supported sandbox +- SSH rejects missing/unsupported sandboxes with `409` +- sandbox file list/download/upload round-trip +- stage artifact list/download continues to work for the CLI use case + +### Full verification + +- `cargo fmt --check --all` +- `cargo clippy --workspace --all-targets -- -D warnings` +- `cargo nextest run --workspace` + +## Risks + +- The biggest risk is accidentally preserving local run-path assumptions under a server-only CLI surface. + - Mitigation: delete `--storage-dir` from in-scope commands and remove local lookup usage outright instead of trying to support both models. + +- `fork`, `rewind`, and `pr create` still depend on the caller’s local repo matching the selected run closely enough. + - Mitigation: keep that boundary, but add an explicit repo-mismatch guard using stored run metadata so obvious mistakes fail fast. + +- `sandbox cp` is the largest unit because it needs a new server-owned file transfer surface. + - Mitigation: keep the API thin and capability-shaped; do not design a generic virtual filesystem protocol. + +- Preview/SSH capability is provider-specific. + - Mitigation: standardize on `409` for unsupported or unavailable sandbox capability. + +## Follow-on + +After this lands, the remaining local/admin seam should be small and explicit: + +- `store dump` +- `system df` +- `system prune` +- any hidden/internal commands that truly operate on local storage + +At that point, `ServerRunLookup` should either: + +- be deleted entirely if those commands are also repointed later, or +- be clearly renamed/documented as a local maintenance helper rather than a normal user-facing run abstraction diff --git a/docs/plans/2026-04-05-run-create-server-target-plan.md b/docs/plans/2026-04-05-run-create-server-target-plan.md new file mode 100644 index 000000000..6665ce289 --- /dev/null +++ b/docs/plans/2026-04-05-run-create-server-target-plan.md @@ -0,0 +1,243 @@ +# Run/Create Server Target Support + +## Summary +Make `fabro run` and `fabro create` targetable via `--server` / `[server].target` now that run submission is manifest-based and server-owned. + +This pass should: + +- add `--server` support to `fabro run` and `fabro create` +- align their target-resolution semantics with `preflight`, `validate`, and `graph` +- remove the last local-storage-only assumptions from the run submission path +- keep local run behavior unchanged when a local server is selected + +This is primarily cleanup/compaction, not a new subsystem. The manifest refactor already made remote submission possible; the CLI surface just has not caught up yet. + +## Scope Boundaries +In scope: +- `fabro run` +- `fabro create` +- the internal start/attach/summary helpers required for `fabro run` to work against an explicit server target +- docs/help/tests for the new targeting contract + +Out of scope: +- adding `--server` to top-level `fabro start`, `fabro attach`, `fabro wait`, `fabro logs`, `fabro inspect`, `fabro diff`, `fabro resume`, or `fabro rewind` +- changing the HTTP API +- changing manifest structure or workflow bundle persistence +- changing server-side run ownership or execution topology + +Accepted temporary asymmetry: +- `fabro create --server ...` will be supported in this pass even though the standalone follow-up lifecycle commands remain local-only. +- That is acceptable because `create` already prints only a run ID and is useful for automation. A later pass can broaden remote targeting across the rest of the run lifecycle surface. + +## Problem Frame +The CLI/server boundary is now inconsistent: + +- `preflight`, `validate`, and `graph` already build manifests and target either a local auto-started server or an explicit remote `--server` +- `run` and `create` already build manifests, but they still only flatten `--storage-dir` and then hard-wire submission to `connect_server(settings.storage_dir())` +- `run` still assumes every submitted run has a meaningful local run directory for attach and final summary output + +That is architectural drift. The system is already manifest-first and server-canonical. `run` and `create` are the remaining commands that still behave as if run submission is inherently local. + +## Key Decisions +- `RunArgs` should flatten `ServerConnectionArgs`, not `StorageDirArgs`. + - `fabro create` inherits the same args because it already reuses `RunArgs`. +- `run` and `create` should use the same connection contract as other server-backed commands: + - explicit `--server` wins + - explicit `--storage-dir` selects a local server and suppresses configured `[server].target` + - otherwise configured `[server].target` may be used + - otherwise the command defaults to the local server for the resolved storage dir +- `fabro run` should use one resolved server connection end-to-end for: + - manifest submission + - `POST /runs/{id}/start` + - live attach / polling +- `fabro create` should remain run-ID-only output. + - It should not pretend there is always a local `run_dir`. +- foreground `fabro run` against a remote/configured server should attach successfully. + - This requires decoupling attach from local run-dir inference. +- remote foreground `run` should print a server-backed final summary that omits local-only fields. + - Keep: run ID, status, duration, cost/tokens, failure reason, PR URL, final output + - Omit: local run directory path and local artifact listing when there is no local run dir +- local `run` behavior should remain unchanged. + - If the resolved connection is local, keep the existing local run-dir summary and asset listing behavior. +- No OpenAPI change is required. + - This is CLI cleanup on top of the existing manifest-backed `POST /runs`. + +## Implementation Changes + +### 1. Add target args to `run` / `create` +In `lib/crates/fabro-cli/src/args.rs`: +- change `RunArgs` to flatten `ServerConnectionArgs` +- remove the dedicated `StorageDirArgs` field from `RunArgs` +- keep all existing workflow/run override flags unchanged + +This updates both: +- `fabro run` +- `fabro create` + +Help/CLI contract to lock down: +- `fabro run foo.fabro --server http://127.0.0.1:3000/api/v1` +- `fabro create foo.fabro --server /var/run/fabro.sock` +- `fabro run foo.fabro --storage-dir /tmp/fabro` +- `fabro create foo.fabro` still defaults to local storage unless `[server].target` is configured + +### 2. Resolve run/create connections the same way as other server-backed commands +In `lib/crates/fabro-cli/src/commands/run/command.rs` and `lib/crates/fabro-cli/src/commands/run/create.rs`: +- keep using local user-config resolution for manifest defaults + - load settings with storage-dir override only, using the command-local `storage_dir` value if present +- stop deriving the submission client from `settings.storage_dir()` +- instead resolve the server connection with the existing server-backed connection logic in `lib/crates/fabro-cli/src/user_config.rs` +- connect using the resolved connection, not a hard-coded local store path + +Recommended shape: +- let `create_run(...)` return a richer value than `(RunId, PathBuf)`, for example: + - `CreatedRun { run_id, local_run_dir: Option, connection: ServerConnection }` + +Rationale: +- `command::execute()` needs more than a run ID now +- remote runs have no trustworthy local run dir +- passing the resolved connection forward keeps the rest of the flow honest + +In `lib/crates/fabro-cli/src/server_client.rs`: +- add a small helper that returns a `ServerStoreClient` from a resolved `ServerConnection` +- reuse the existing resolved API-client path rather than introducing parallel target parsing + +### 3. Refactor `run` to start and attach through the resolved server connection +In `lib/crates/fabro-cli/src/commands/run/start.rs`: +- keep the current public/local helper for top-level `fabro start` +- add a connection-agnostic helper that can start a run from an already-connected `ServerStoreClient` + +In `lib/crates/fabro-cli/src/commands/run/attach.rs`: +- preserve the existing top-level `attach_run(...)` entrypoint for local-storage workflows +- extract the existing server-backed attach logic into a helper that accepts: + - `&ServerStoreClient` + - `&RunId` + - `Option<&Path>` for a local run dir + - existing `kill_on_detach`, `styles`, and `json_output` flags +- make the current top-level local path delegate to that extracted helper after doing its storage-dir/run-id inference + +In `lib/crates/fabro-cli/src/commands/run/command.rs`: +- for `fabro run`, use the resolved connection returned by `create_run(...)` +- if `--detach` is set: + - print the run ID and exit exactly as today +- otherwise: + - start via the resolved server client + - attach via the extracted direct-client attach helper + - print the final run summary using the same resolved connection + +This keeps `fabro run` coherent for both: +- local auto-started server flows +- explicit/configured remote server flows + +### 4. Decouple final summary rendering from local run-dir assumptions +In `lib/crates/fabro-cli/src/commands/run/output.rs`: +- split summary fetching from summary rendering +- make the renderer accept: + - server-backed run state / conclusion / checkpoint + - `Option<&Path>` for a local run dir + +Concrete behavior: +- when `local_run_dir` is present: + - keep printing the local run path + - keep printing local artifact listings +- when `local_run_dir` is absent: + - do not print a local run path line + - do not attempt local artifact discovery + - still print the rest of the run conclusion and final output + +This is the smallest cleanup that makes remote foreground `run` feel intentional without broadening the whole remote lifecycle command surface. + +### 5. Keep standalone follow-up lifecycle commands local for now +Do **not** add `--server` to these commands in this pass: +- `fabro start` +- `fabro attach` +- `fabro wait` +- `fabro logs` +- `fabro inspect` +- `fabro diff` +- `fabro resume` +- `fabro rewind` + +Rationale: +- they form a larger remote lifecycle surface with selector semantics, replay UX, and local-path assumptions of their own +- broadening them now would turn a cleanup pass into a larger capability expansion + +But the plan should call the temporary boundary out explicitly in docs/help text where useful: +- `fabro create --server ...` is valid, but follow-up manipulation of that run outside `fabro run` remains a later pass + +### 6. Update docs and help text +Update the user-facing references that describe server targeting: +- `docs/reference/cli.mdx` +- `docs/reference/user-configuration.mdx` +- `docs/administration/deploy-server.mdx` + +The docs should explicitly say: +- `fabro run` and `fabro create` now honor `--server` / `[server].target` +- `fabro exec` still requires explicit `--server` +- top-level run lifecycle follow-up commands are still local-storage commands in this pass + +## Test Plan + +### CLI help / parser surface +Update snapshots in: +- `lib/crates/fabro-cli/tests/it/cmd/run.rs` +- `lib/crates/fabro-cli/tests/it/cmd/create.rs` + +Scenarios: +- `run --help` shows both `--storage-dir` and `--server` +- `create --help` shows both `--storage-dir` and `--server` + +### `create` targeting behavior +In `lib/crates/fabro-cli/tests/it/cmd/create.rs`: +- `create --server ` submits to the explicit server and prints the created run ID +- configured `[server].target` reroutes `create` when no explicit target args are passed +- explicit `--storage-dir` suppresses configured `[server].target` +- explicit `--server` overrides configured `[server].target` +- remote-targeted `create` does not require local run-dir inspection to succeed + +### `run` targeting behavior +In `lib/crates/fabro-cli/tests/it/cmd/run.rs`: +- `run --server --detach ...` submits and prints a run ID without relying on a local run dir +- foreground `run --server ...` creates, starts, attaches, and exits successfully +- configured `[server].target` reroutes `run` when no explicit target args are passed +- explicit `--storage-dir` suppresses configured `[server].target` +- explicit `--server` overrides configured `[server].target` +- remote foreground `run` prints a final summary without a local run-directory line +- local `run --storage-dir ...` still prints the local run-directory line and local artifact section exactly as today + +### Test infrastructure +Prefer a real TCP-bound fabro test server over `httpmock` for `run`. + +Reason: +- `run` needs multiple real endpoints (`POST /runs`, `POST /runs/{id}/start`, event replay, run-state polling, question polling) +- mocking all of that would verify request wiring but not the actual remote run lifecycle + +If the current CLI integration helpers do not already provide this, add a small reusable helper in: +- `lib/crates/fabro-cli/tests/it/support.rs` +or +- `lib/crates/fabro-test/src/lib.rs` + +That helper should: +- launch a real fabro server bound to loopback TCP +- return a usable `http://127.0.0.1:PORT/api/v1` target string +- keep fixture storage isolated from the invoking CLI’s local storage dir + +## Risks +- The biggest risk is hidden local-run-dir assumptions in attach/summary code. + - Mitigation: refactor those surfaces explicitly rather than trying to fake a local path for remote runs. +- Config-target defaulting could surprise users if docs are not updated. + - Mitigation: update CLI docs and help snapshots in the same pass. +- Supporting `create --server` before the broader remote lifecycle commands is intentionally asymmetric. + - Mitigation: call it out in the plan/docs instead of pretending the whole run lifecycle is remote-ready. + +## Follow-on +After this lands, the next logical cleanup is a dedicated remote run-lifecycle plan for: +- `start` +- `attach` +- `wait` +- `logs` +- `inspect` +- `diff` +- `resume` +- `rewind` + +That should be a separate pass, not folded into this one. diff --git a/docs/plans/2026-04-05-run-manifest-and-preflight-plan.md b/docs/plans/2026-04-05-run-manifest-and-preflight-plan.md new file mode 100644 index 000000000..08ee2be92 --- /dev/null +++ b/docs/plans/2026-04-05-run-manifest-and-preflight-plan.md @@ -0,0 +1,842 @@ +# Run Manifest and Preflight + +## Summary + +Replace the current `POST /runs` request — which sends a filesystem path and relies on the server reading workflow definition files from disk — with a self-contained **run manifest**. The CLI gathers all workflow-definition inputs (DOT source, TOML configs, referenced prompt files, imported graphs, child workflows) into a single JSON payload. The server owns all interpretation: config merging, variable expansion, transforms, validation. + +This also introduces `POST /api/v1/preflight`, which accepts the same manifest and returns a structured health report without creating a run. + +After this change, the server no longer reads workflow/config/prompt/import files from the CLI's filesystem. It still uses the manifest `cwd` / resolved working directory for execution context (for example local sandbox and repo-aware behavior). The path-based `workflow_path` submission mode is removed. + +## Scope Boundaries + +In scope: +- define the `RunManifest` schema in the OpenAPI spec +- CLI-side manifest builder that walks the workflow tree and bundles all referenced files +- file resolver abstraction for transforms (bundle-backed instead of disk-backed) +- refactor `FileInliningTransform` and `ImportTransform` to use file resolver +- server-side config resolution from manifest layers (args, workflow TOML, project TOML, user TOML) +- child workflow resolution from the manifest's workflow map +- replace `POST /api/v1/runs` request body with the manifest +- new `POST /api/v1/preflight` endpoint using the same manifest +- update `fabro run`, `fabro create`, `fabro preflight` to build and send manifests +- demo mode for preflight and updated run creation + +Out of scope: +- changes to run execution, checkpointing, or resume +- changes to sandbox creation or the execution engine +- changes to `fabro exec` +- encrypted or compressed manifests +- manifest size limits or streaming upload + +## Manifest Shape + +```json +{ + "version": 1, + "run_id": "01HV6D7S5YF4Z4B2M7K4N0Q6T9", + "cwd": "/Users/user/p/my-project", + "git": { + "origin_url": "https://github.com/acme/my-app.git", + "branch": "feature/foo", + "sha": "abc123", + "clean": true + }, + "goal": { + "type": "file", + "path": "goal.md", + "text": "Build and test the app..." + }, + "args": { + "model": "claude-opus-4-6", + "sandbox": "local" + }, + "target": { + "identifier": "smoke", + "path": "fabro/workflows/smoke/workflow.fabro" + }, + "configs": [ + { "type": "project", "path": "fabro.toml", "source": "[fabro]\nroot = \"fabro/\"\n..." }, + { "type": "user", "path": "/Users/user/.fabro/user.toml", "source": "..." } + ], + "workflows": { + "fabro/workflows/smoke/workflow.fabro": { + "source": "digraph { ... }", + "config": { + "path": "fabro/workflows/smoke/workflow.toml", + "source": "version = 1\n[vars]\nlanguage = \"rust\"" + }, + "files": { + "prompts/review.md": { + "content": "You are a code reviewer...", + "ref": { "type": "file_inline", "original": "@prompts/review.md", "from": "workflow.fabro" } + }, + "validate.fabro": { + "content": "digraph { ... }", + "ref": { "type": "import", "original": "./validate.fabro", "from": "workflow.fabro" } + } + } + }, + "fabro/workflows/implement-plan/workflow.fabro": { + "source": "digraph { ... }", + "files": { + "prompts/simplify.md": { + "content": "...", + "ref": { "type": "file_inline", "original": "@prompts/simplify.md", "from": "workflow.fabro" } + } + } + } + } +} +``` + +Field semantics: +- `version` — manifest schema version, currently `1` +- `run_id` — optional pre-generated run ID. Used by detached/local create flows that allocate the run ID in the CLI before submission +- `cwd` — the CLI's working directory at invocation time +- `git` — optional, observable git state from the CLI's working directory. Omitted if not in a git repo + - `origin_url` — remote origin URL, **sanitized** (credentials stripped from HTTPS URLs to prevent token leakage) + - `branch` — current branch name + - `sha` — current commit SHA + - `clean` — whether the working tree has uncommitted changes +- `goal` — resolved goal with provenance, always includes `text` (the content) and `type` (`"value"` for literal string, `"file"` for file-sourced, `"graph"` for graph-attribute-sourced). When `type` is `"file"`, includes `path` (original file path from TOML or CLI `--goal-file`). The server uses `text` directly and clears any merged `goal_file` path +- `args` — command-local run/preflight args that affect run settings. Sparse: omitted flags are absent. This is not a generic env layer or a dump of global CLI flags +- `target.identifier` — what the user typed (slug like `"smoke"` or path like `"./custom.fabro"`) +- `target.path` — resolved path, keys into the `workflows` map +- `configs` — non-workflow config sources, each with `type` (`"project"` or `"user"`), `path`, and raw TOML `source` +- `workflows` — flat map of all workflows (root + children), keyed by resolved path + - `source` — raw DOT source (unexpanded, pre-transform) + - `config` — optional, the workflow's TOML config with `path` and `source` + - `files` — map of normalized logical path (relative to that workflow's root directory) to file entry, each with `content` (file content) and `ref` (discovery metadata: `type`, `original` reference string, and optional `from` logical path for nested imports). Types: `file_inline` (`@file`), `import`, `dockerfile`. Note: `goal_file` no longer appears here — goals are in the top-level `goal` object + +## Key Decisions + +- **CLI gathers, server transforms.** The CLI's only job is reading files from disk and bundling them. All interpretation — TOML parsing, config merging, variable expansion, graph transforms, validation — happens server-side. +- **Detached/create flows keep CLI-allocated run IDs.** The manifest carries an optional `run_id`, preserving the current `run -d` / `create` behavior where the CLI can pre-generate the run ID before submission. +- **Flat workflow map.** All workflows (root and children, at any nesting depth) are in a single flat `workflows` map. Relationships are implicit via `stack.child_workflow` attributes in the DOT source. This avoids deep nesting and naturally deduplicates shared children. +- **Inline child workflows stay inline.** `stack.child_dot_source` continues to work exactly as it does today and does not need manifest bundling. Manifest child-workflow support is specifically for `stack.child_workflow` / `stack.child_dotfile`. +- **Child workflows don't have their own settings.** Today `parse_child_graph()` passes `Settings::default()` to children and never loads their TOML. The manifest preserves this — child workflow entries carry their DOT source and files but no separate config layers. If a child has a `workflow.toml`, it can optionally be included in `config` for future use, but the server does not merge it today. +- **Config resolution moves to the server.** The CLI currently merges `cli_args.combine(workflow_config).combine(project_config).combine(user_config).resolve()`. The manifest ships the raw layers and the server performs the merge. Merge precedence is determined by the server based on config `type`, not by array order. +- **Server merge precedence:** `args` > workflow `config` > `project` config > `user` config > server defaults. There is no separate manifest `env` layer. Server-owned operational settings such as `storage_dir`, `[server]`, `api`, `web`, `features`, `log`, and `exec` are ignored from manifest configs; the active server instance owns those. +- **File resolution must stay contextual.** Transforms currently resolve relative paths based on the current graph/file location. The manifest refactor cannot collapse that to `resolve("foo.md") -> content`; the resolver must accept the current logical directory so nested imports like `subflow/imported.fabro -> @prompts/foo.md` still resolve correctly. +- **Goal is resolved by the CLI and travels as a top-level object.** The CLI resolves the final goal using the current precedence rules (`--goal` / `--goal-file` over merged config `goal` / `goal_file`, otherwise graph-level `goal`) and sends it as `manifest.goal`. The server applies `manifest.goal.text` after config merge and clears `goal_file`, so goal handling never requires filesystem reads server-side. +- **Git state travels in the manifest.** The CLI captures origin URL (sanitized — credentials stripped from HTTPS URLs), current branch, commit SHA, and clean/dirty status. This replaces the server's need to run git commands or access the repo filesystem. Credential sanitization is mandatory to prevent token leakage in HTTPS URLs with embedded PATs or installation tokens. +- **`workflow_path` mode is removed.** After migration, the server only accepts manifests. The `dot_source` / `workflow_path` fields in `CreateRunRequest` are replaced by the manifest. +- **Preflight uses the same manifest.** `POST /api/v1/preflight` accepts a `RunManifest` and returns a `PreflightResponse` with workflow diagnostics plus the rendered checks payload. No validated manifest round-trip. +- **Manifest discovery walks the DOT AST.** The CLI must parse the DOT source enough to find `@file` references (in `prompt` and `goal` attributes), `import` attributes, and `stack.child_workflow` / `stack.child_dotfile` attributes. It does NOT run the full transform pipeline — just scans for file references. + +## Implementation Changes + +### 1. File resolver abstraction + +Create `lib/crates/fabro-workflow/src/file_resolver.rs`. + +```rust +pub trait FileResolver: Send + Sync { + /// Resolve a logical reference string relative to the current logical directory. + /// Returns the normalized logical path plus file content. + fn resolve(&self, current_dir: &Path, reference: &str) -> Option; +} + +pub struct ResolvedFile { + pub logical_path: PathBuf, + pub content: String, +} +``` + +One implementation: + +**`BundleFileResolver`** — reads from a manifest's files map: +```rust +pub struct BundleFileResolver { + files: HashMap, +} +``` +The resolver normalizes `current_dir.join(reference)` into a workflow-relative logical path (strip leading `./`, collapse `.` / `..`) and looks up that normalized key. This preserves the current import/file-inlining semantics without any filesystem access. The `files` map is built from the manifest's `ManifestFileEntry` objects (extracting `content` by normalized logical path key). + +No `DiskFileResolver` is needed — this is a hard cutover. The existing filesystem-based resolution logic in the transforms is replaced entirely. + +Add `pub mod file_resolver;` to `lib/crates/fabro-workflow/src/lib.rs`. + +Tests: +- `BundleFileResolver` with test data, verify exact key lookup works +- Verify `None` for missing files +- Verify path normalization handles `./` prefix stripping and nested `..` +- Verify nested import scoping resolves relative to the imported file's logical directory + +### 2. Refactor FileInliningTransform + +In `lib/crates/fabro-workflow/src/transforms/file_inlining.rs`: + +Change the struct to hold a resolver instead of paths: +```rust +pub struct FileInliningTransform { + resolver: Arc, +} + +impl FileInliningTransform { + pub fn new(resolver: Arc) -> Self { + Self { resolver } + } +} +``` + +Update `resolve_file_ref` to use the resolver: +- strip the `@` prefix to get the relative path +- call `self.resolver.resolve(current_dir, path_str)` instead of `std::fs::read_to_string` +- thread the current logical directory through the transform so imported files can inline their own relative references correctly +- remove tilde expansion and `canonicalize` logic (the CLI resolved all paths during bundling) + +The `apply()` method stays structurally the same — it iterates node prompts and graph goal, calling the updated resolution logic. + +### 3. Refactor ImportTransform + +In `lib/crates/fabro-workflow/src/transforms/import.rs`: + +Same pattern — hold a resolver: +```rust +pub struct ImportTransform { + resolver: Arc, +} +``` + +Update `resolve_import_path` and `prepare_import`: +- `resolve_import_path` uses `self.resolver.resolve(current_dir, path_str)` instead of filesystem canonicalize +- `prepare_import` gets the file content from the resolver instead of `std::fs::read_to_string` +- when applying `FileInliningTransform` to imported content, pass the same resolver plus the imported file's logical parent directory + +The recursive import expansion and circular import detection stay the same. + +### 4. Update transform pipeline + +In `lib/crates/fabro-workflow/src/pipeline/transform.rs`: + +Change `TransformOptions` to carry a resolver: +```rust +pub struct TransformOptions { + pub file_resolver: Option>, + pub custom_transforms: Vec>, +} +``` + +Update the `transform` function: +- where it currently checks `options.base_dir.is_some()` to gate `ImportTransform` and `FileInliningTransform`, check `options.file_resolver.is_some()` instead +- construct the transforms with the resolver + +### 5. Manifest schema in OpenAPI + +In `docs/api-reference/fabro-api.yaml`, add schemas: + +```yaml +RunManifest: + description: Self-contained workflow run manifest. + type: object + required: + - version + - cwd + - target + - workflows + properties: + version: + type: integer + description: Manifest schema version. + example: 1 + run_id: + type: string + nullable: true + description: Optional pre-generated run ID to use instead of allocating a new ULID. + example: "01HV6D7S5YF4Z4B2M7K4N0Q6T9" + cwd: + type: string + description: CLI working directory at invocation time. + git: + $ref: "#/components/schemas/ManifestGit" + goal: + $ref: "#/components/schemas/ManifestGoal" + args: + $ref: "#/components/schemas/ManifestArgs" + target: + $ref: "#/components/schemas/ManifestTarget" + configs: + type: array + items: + $ref: "#/components/schemas/ManifestConfig" + workflows: + type: object + additionalProperties: + $ref: "#/components/schemas/ManifestWorkflow" + +ManifestGit: + description: Observable git state from the CLI working directory. + type: object + required: + - origin_url + - branch + - sha + - clean + properties: + origin_url: + type: string + description: > + Remote origin URL, sanitized (credentials stripped from HTTPS URLs). + e.g. https://user:token@github.com/acme/app.git becomes https://github.com/acme/app.git + example: "https://github.com/acme/my-app.git" + branch: + type: string + description: Current branch name. + example: feature/foo + sha: + type: string + description: Current commit SHA. + example: abc123def + clean: + type: boolean + description: Whether the working tree has uncommitted changes. + +ManifestGoal: + description: Resolved goal with provenance. + type: object + required: + - type + - text + properties: + type: + type: string + enum: + - value + - file + - graph + description: > + How the goal was sourced: "value" (literal from TOML goal field or --goal flag), + "file" (resolved from goal_file), "graph" (from graph-level goal attribute in DOT). + text: + type: string + description: The resolved goal content. Server uses this directly. + path: + type: string + description: Original file path (only present when type is "file"). + +ManifestTarget: + type: object + required: + - identifier + - path + properties: + identifier: + type: string + description: What the user typed (slug or path). + example: smoke + path: + type: string + description: Resolved path, keys into the workflows map. + example: fabro/workflows/smoke/workflow.fabro + +ManifestConfig: + type: object + required: + - type + properties: + type: + type: string + enum: + - project + - user + path: + type: string + description: Filesystem path to the config file. + source: + type: string + description: Raw TOML source of the config file. + +ManifestWorkflowConfig: + type: object + required: + - path + - source + properties: + path: + type: string + description: Path to the workflow TOML file. + source: + type: string + description: Raw TOML source. + +ManifestArgs: + description: Command-local run/preflight flags that affect run settings. All fields optional (sparse). + type: object + properties: + model: + type: string + provider: + type: string + sandbox: + type: string + verbose: + type: boolean + dry_run: + type: boolean + auto_approve: + type: boolean + no_retro: + type: boolean + preserve_sandbox: + type: boolean + label: + type: array + items: + type: string + +ManifestFileEntry: + description: A bundled file with discovery metadata. + type: object + required: + - content + - ref + properties: + content: + type: string + description: File content. + ref: + $ref: "#/components/schemas/ManifestFileRef" + +ManifestFileRef: + description: How this file was discovered. + type: object + required: + - type + - original + properties: + type: + type: string + enum: + - file_inline + - import + - dockerfile + description: Discovery type. + original: + type: string + description: The reference string as it appeared in the DOT/TOML. + example: "@prompts/review.md" + from: + type: string + description: Optional logical path of the file/graph that referenced this entry. + +ManifestWorkflow: + type: object + required: + - source + properties: + source: + type: string + description: Raw DOT source (unexpanded, pre-transform). + config: + $ref: "#/components/schemas/ManifestWorkflowConfig" + files: + type: object + additionalProperties: + $ref: "#/components/schemas/ManifestFileEntry" + description: > + Map of normalized logical path to file entry with content and discovery metadata. +``` + +Replace the `CreateRunRequest` schema with `RunManifest` on `POST /api/v1/runs`. + +Add `POST /api/v1/preflight`: +```yaml +/api/v1/preflight: + post: + operationId: runPreflight + tags: [Runs] + summary: Validate a workflow manifest without creating a run. + description: > + Accepts the same manifest as POST /runs. Validates the workflow, + checks sandbox availability, LLM provider access, and GitHub token + minting. Returns a structured pass/fail report. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RunManifest" + responses: + "200": + description: Preflight report. + content: + application/json: + schema: + $ref: "#/components/schemas/PreflightResponse" +``` + +The preflight response should preserve the current CLI JSON contract: +- `workflow` summary/diagnostics from validation +- `checks` as the rendered report payload + +So add: +```yaml +PreflightResponse: + type: object + required: + - workflow + - checks + properties: + workflow: + $ref: "#/components/schemas/PreflightWorkflowSummary" + checks: + $ref: "#/components/schemas/DiagnosticsReport" +``` + +Rebuild `fabro-api`: +``` +cargo build -p fabro-api +``` + +### 6. Server-side config resolution from manifest + +Create `lib/crates/fabro-server/src/manifest.rs`. + +This module: + +1. **Parses run-relevant config layers from the manifest:** + - `args` → converted to a `ConfigLayer` (map CLI arg names to `ConfigLayer` fields, similar to `TryFrom<&RunArgs>` in `overrides.rs`) + - workflow `config.source` → parsed as TOML via `fabro_config` and converted to `ConfigLayer` + - each `configs[]` entry → parsed as TOML and converted to `ConfigLayer` + - strip or ignore non-run fields from uploaded configs (`storage_dir`, `[server]`, `api`, `web`, `features`, `log`, `exec`, `max_concurrent_runs`) + +2. **Merges in server-determined precedence:** + ```rust + pub fn resolve_settings(manifest: &RunManifest, server_defaults: &Settings) -> Result { + let args_layer = parse_args_layer(&manifest.args)?; + let workflow_layer = parse_workflow_config(manifest)?; + let project_layer = find_config_layer(manifest, "project")?; + let user_layer = find_config_layer(manifest, "user")?; + + args_layer + .combine(workflow_layer) + .combine(project_layer) + .combine(user_layer) + .resolve() + } + ``` + +3. **Applies the top-level goal after merge.** + - if `manifest.goal` is present, set `settings.goal = Some(manifest.goal.text.clone())` + - clear `settings.goal_file` so server-side goal handling never tries to read the filesystem + +4. **Builds a `BundleFileResolver`** from the target workflow's `files` map. + +5. **Constructs a `CreateRunInput`** with: + - `WorkflowInput::DotSource { source, base_dir: None }` — using the raw DOT from the manifest + - resolved `Settings` + - `cwd` from the manifest + - a `file_resolver` for the transform pipeline + +The `parse_args_layer` function maps manifest `args` keys to `ConfigLayer` fields. The mapping mirrors the current `TryFrom<&RunArgs>` / `TryFrom<&PreflightArgs>` logic in `overrides.rs`, but without `goal` / `goal_file` because those are represented by the top-level `goal` object. The keys are the same field names: `model`, `provider`, `sandbox`, `verbose`, `dry_run`, `auto_approve`, `no_retro`, `preserve_sandbox`, `label`. + +### 7. Update POST /runs handler + +In `lib/crates/fabro-server/src/server.rs`: + +Replace the current `create_run` handler. The new handler: + +1. Deserializes the request body as `RunManifest`. +2. Validates manifest version is supported. +3. Calls `manifest::resolve_settings(&manifest, &state.settings)` to merge configs. +4. Looks up the root workflow in `manifest.workflows` using `manifest.target.path`. +5. Builds a `BundleFileResolver` from the root workflow's `files` map. +6. Parses `manifest.run_id` when present, preserving the current detached/local create behavior. +7. Constructs `CreateRunInput`: + ```rust + CreateRunInput { + workflow: WorkflowInput::DotSource { + source: root_workflow.source.clone(), + base_dir: None, + }, + settings, + cwd: PathBuf::from(&manifest.cwd), + workflow_slug: Some(manifest.target.identifier.clone()), + run_id: Some(run_id), + host_repo_path: None, + base_branch: None, + } + ``` +8. Passes the manifest's workflow map to `operations::create()` so child workflows can be resolved later. + +The `operations::create()` and `validate()` paths in `fabro-workflow` need to accept the file resolver (via `TransformOptions`) and the workflow map (for child resolution). This requires updating `CreateRunInput`, `ValidateInput`, or shared workflow-resolution state: + +```rust +pub struct CreateRunInput { + pub workflow: WorkflowInput, + pub settings: Settings, + pub cwd: PathBuf, + pub workflow_slug: Option, + pub run_id: Option, + pub host_repo_path: Option, + pub base_branch: Option, + pub file_resolver: Option>, + pub workflow_bundle: Option>, +} +``` + +In `operations::create()` (`create.rs`) and `validate()` (`validate.rs`), when `file_resolver` is `Some`, use it in `TransformOptions` instead of relying on `base_dir`. + +### 8. Child workflow resolution from manifest + +In `lib/crates/fabro-workflow/src/handler/manager_loop.rs`: + +`parse_child_graph()` currently resolves `stack.child_workflow` as `WorkflowInput::Path` and reads from disk. Update it to check for a workflow bundle first: + +```rust +fn parse_child_graph(node: &Node, services: &EngineServices) -> Result<...> { + // ... existing stack.child_dot_source handling ... + + if let Some(path) = node.attr("stack.child_workflow").or(node.attr("stack.child_dotfile")) { + let bundle = services.workflow_bundle.as_ref() + .ok_or_else(|| anyhow!("no workflow bundle available"))?; + let child = bundle.get(path) + .ok_or_else(|| anyhow!("child workflow not found in manifest: {path}"))?; + let resolver = BundleFileResolver::new(child.files.clone()); + // Pass resolver plus the child's logical root to validate() + Ok(WorkflowInput::DotSource { + source: child.source.clone(), + base_dir: None, + }) + } +} +``` + +Add `workflow_bundle: Option>>` to `EngineServices` so it is accessible during execution. + +When the child workflow is resolved from the bundle, its own `files` map provides a scoped `BundleFileResolver` for that child's transforms (`@file` refs, imports within the child). + +### 9. CLI manifest builder + +Create `lib/crates/fabro-cli/src/manifest_builder.rs`. + +This module builds a `RunManifest` from CLI inputs: + +```rust +pub struct ManifestBuilder; + +impl ManifestBuilder { + pub fn build_for_run(cwd: PathBuf, args: &RunArgs) -> Result { ... } + pub fn build_for_preflight(cwd: PathBuf, args: &PreflightArgs) -> Result { ... } +} +``` + +The build process: + +1. **Resolve workflow path**: call `project_config::resolve_workflow_path(&args.workflow, &cwd)` to get the `.fabro` file path and optional `.toml` config path. + +2. **Read the root workflow**: + - read the `.fabro` file: `std::fs::read_to_string(&dot_path)` + - if a `.toml` exists, read it: `std::fs::read_to_string(&toml_path)` + +3. **Discover file references in the DOT source**: + - parse the DOT source with `parser::parse(&source)` + - scan all nodes for `prompt` attributes starting with `@` → collect file paths + - scan graph-level `goal` attribute for `@` prefix → collect file path + - scan all nodes for `import` attributes → collect file paths + - scan all nodes for `stack.child_workflow` / `stack.child_dotfile` attributes → collect child workflow paths + +4. **Resolve the final goal**: + - compute the final goal using the current precedence rules (`--goal` / `--goal-file` over merged config `goal` / `goal_file`, otherwise graph-level `goal`) + - store it in top-level `manifest.goal` + - do **not** add `goal_file` to the workflow `files` map + +5. **Resolve file references from the TOML config**: + - if the TOML has `sandbox.daytona.snapshot.dockerfile.path`, read the Dockerfile and add to `files` + +6. **Read all discovered files** into the `files` map, keyed by normalized logical path relative to the workflow root. Resolve relative to the `.fabro` file's parent directory, with `~/.fabro` as fallback (matching current `resolve_file_ref` logic). + +7. **Recursively process child workflows** (step 3-6 for each child). Children go into the flat `workflows` map. Detect circular references via a visited set. + +8. **Process imported `.fabro` files**: imports also go into the workflow's `files` map (they are read and their content is stored under workflow-relative logical paths). Imported files may themselves contain `@file` refs and nested imports — the builder must recursively discover these too. + +9. **Gather configs**: + - read `fabro.toml` via `project_config::discover_project_config()` + - read `~/.fabro/user.toml` via the user config path + - for each, record `type`, `path`, and raw `source` + +10. **Gather args**: serialize the command-local args that affect run settings. Use the same field names as `ConfigLayer` (`model`, `provider`, `sandbox`, etc.) but exclude `goal` / `goal_file` because those are represented by top-level `manifest.goal`. Only include fields that the user actually set (sparse). + +11. **Carry the optional run ID** from `RunArgs.run_id` when present. + +12. **Assemble and return** the `RunManifest`. + +The DOT parser is already available in `fabro-workflow`. The CLI already depends on `fabro-workflow` (it calls `validate()`). The manifest builder reuses the parser for discovery but does NOT run transforms. + +### 10. Update CLI commands + +In `lib/crates/fabro-cli/src/commands/run/create.rs`: + +Replace the current flow: +```rust +// Before (sends path + pre-resolved settings): +let settings = cli_args_config.combine(workflow_config).combine(cli_defaults).resolve()?; +client.create_run_from_workflow_path(workflow_path, &cwd, &settings, run_id) + +// After (sends manifest): +let manifest = ManifestBuilder::build_for_run(cwd, &args)?; +client.create_run_from_manifest(&manifest) +``` + +Remove `create_run_from_workflow_path` from `server_client.rs`. Add `create_run_from_manifest` that POSTs the manifest JSON. + +The optional local validation step (lines 39-51) can be removed — the server validates as part of run creation. Or it can stay as a fast-fail with a note that it won't catch everything the server checks. + +In `lib/crates/fabro-cli/src/commands/run/overrides.rs`: + +The `TryFrom<&RunArgs> for ConfigLayer` conversion is no longer needed for the settings merge (the server does it). But the args serialization for the manifest's `args` field needs similar logic. Consider: +- keeping the conversion as a helper for building the manifest's `args` object +- or writing a new `RunArgs::to_manifest_args()` method that produces a JSON map + +In `lib/crates/fabro-cli/src/commands/preflight.rs`: + +Replace the current flow: +```rust +// Before (runs all checks CLI-side): +let settings = cli_args_config.combine(workflow_config).combine(cli_defaults).resolve()?; +validate(ValidateInput { ... })?; +run_preflight(&settings, ...)?; + +// After (sends manifest to server): +let manifest = ManifestBuilder::build_for_preflight(cwd, &args)?; +let cli_settings = load_user_settings_with_storage_dir(args.storage_dir.as_deref())?; +let client = server_client::connect_server(&cli_settings.storage_dir()).await?; +let response = client.run_preflight().body(manifest).send().await?; +render_report(&response.checks); +``` + +Remove all local preflight check functions. The CLI becomes a thin client that builds the manifest, sends it, and renders the response. + +### 11. Preflight handler on server + +In `lib/crates/fabro-server/src/server.rs`: + +```rust +async fn run_preflight( + _auth: AuthenticatedService, + State(state): State>, + Json(manifest): Json, +) -> Response { + let report = preflight::run_preflight(&state, &manifest).await; + (StatusCode::OK, Json(report)).into_response() +} +``` + +Create `lib/crates/fabro-server/src/preflight.rs`. + +This module adapts the checks from the current CLI-side `preflight.rs`: + +1. **Resolve settings** from manifest via `manifest::resolve_settings()`. +2. **Validate the workflow** — parse, transform (using `BundleFileResolver`), validate. +3. **Check sandbox** — resolve sandbox provider from settings, attempt to create/initialize a test sandbox. +4. **Check LLM providers** — for each model used by the graph, verify the provider is configured (secret exists in secret store) and reachable. +5. **Check GitHub token** — if the workflow has `github.permissions`, attempt to mint a test installation access token. + +Each check produces a `CheckResult`. The function returns a `PreflightResponse` with: +- `workflow` summary (`name`, node/edge counts, goal, diagnostics) +- `checks` as the `DiagnosticsReport` payload + +Add route in `real_routes()`: +```rust +.route("/preflight", post(run_preflight)) +``` + +### 12. Demo mode + +In `lib/crates/fabro-server/src/demo/mod.rs`: + +**Run creation demo**: The demo handler already exists for `POST /runs`. Update it to accept the manifest schema. The demo can ignore the manifest contents and return a canned run response as it does today. + +**Preflight demo**: Return an all-passing preflight report: +```rust +pub(crate) async fn run_preflight( + _auth: AuthenticatedService, + State(_state): State>, + Json(_manifest): Json, +) -> Response { + (StatusCode::OK, Json(serde_json::json!({ + "workflow": { + "name": "demo", + "nodes": 3, + "edges": 2, + "goal": "Demo", + "diagnostics": [] + }, + "checks": { + "version": fabro_util::version::FABRO_VERSION, + "sections": [ + { + "title": "Workflow", + "checks": [ + { "name": "Parse & Validate", "status": "pass", "summary": "3 nodes, 2 edges, goal set", "details": [] }, + ] + }, + { + "title": "Sandbox", + "checks": [ + { "name": "Provider", "status": "pass", "summary": "local sandbox available", "details": [] }, + ] + }, + { + "title": "LLM", + "checks": [ + { "name": "Providers", "status": "pass", "summary": "Anthropic, OpenAI reachable", "details": [] }, + ] + }, + ] + } + }))).into_response() +} +``` + +Wire in `demo_routes()`: +```rust +.route("/preflight", post(demo::run_preflight)) +``` + +### 13. Cleanup + +After the manifest is working: + +- **Remove `workflow_path` mode** from the `POST /runs` handler. Remove the `workflow_path`, `cwd`, `settings_json` fields from the request schema. Remove `CreateRunRequest` from the OpenAPI spec and replace with `RunManifest`. +- **Keep `WorkflowInput::Path` for local-only CLI commands.** Do not remove it from `source.rs`; commands like `validate` and `graph` still use local path resolution. The cleanup is server-specific: remove the server's path-based submission path, not the workflow crate's local path input abstraction. +- **No `DiskFileResolver` to remove** — it was never created (hard cutover). +- **Remove `create_run_from_workflow_path`** from `server_client.rs`. +- **Remove `ConfigLayer::for_workflow()`** usage from CLI run/preflight commands (the server does config resolution now). The function itself may still be useful for other CLI code paths. +- **Remove CLI-side preflight check functions** from `preflight.rs`. +- **Remove `TryFrom<&RunArgs> for ConfigLayer`** if replaced by manifest args serialization. + +## Implementation Order + +``` + 1 File resolver trait + BundleFileResolver (no deps) + 2 Refactor FileInliningTransform to use resolver (depends on 1) + 3 Refactor ImportTransform to use resolver (depends on 1) + 4 Update transform pipeline (TransformOptions) (depends on 2, 3) + 5 Manifest schema in OpenAPI + rebuild fabro-api (no deps, parallel with 1-4) + 6 Server-side config resolution (manifest.rs) (depends on 5) + 7 CLI manifest builder (depends on 5) + 8 Update POST /runs handler to accept manifest (depends on 4, 6) + 9 Child workflow resolution from manifest (depends on 8) +10 Update CLI run/create to send manifest (depends on 7, 8) +11 Preflight server handler (depends on 4, 6) +12 Update CLI preflight to send manifest (depends on 7, 11) +13 Demo mode (depends on 5) +14 Cleanup: remove workflow_path mode + dead code (depends on 10, 12) +``` + +Steps 1-4 (resolver refactor) and 5 (schema) can proceed in parallel. Step 7 (CLI builder) and 6 (server config) can proceed in parallel once the schema exists. + +## Resolved Questions + +1. **`args` field schema**: **Typed and limited to run settings.** The OpenAPI schema defines explicit fields matching the command-local run/preflight overrides (model, provider, sandbox, verbose, dry_run, auto_approve, no_retro, preserve_sandbox, label). `goal` / `goal_file` are excluded because the final goal is represented by top-level `manifest.goal`. All fields are optional (sparse — only set fields are present). + +2. **Import path scoping**: **Workflow-relative logical paths plus contextual resolution.** All file paths in a workflow's `files` map are normalized logical paths relative to that workflow's root directory. Each file entry carries `ref` metadata with `type`, `original`, and optional `from`. The server resolves nested imports and `@file` references by passing the current logical directory into the resolver; it does not use metadata fields for lookup. + +3. **Backward compatibility**: **Hard cutover.** The old `CreateRunRequest` (workflow_path/dot_source) is removed when the manifest lands. CLI and local server are the same binary, so they upgrade together. Remote servers need coordinated upgrade. + +4. **DOT parser for discovery**: **Full parse.** The CLI uses the existing `fabro-workflow` parser (already a dependency). It's fast, handles all edge cases (quoted strings, comments, escapes), and is more reliable than regex scanning. + +5. **No manifest `env` layer**: the current CLI does not have a separate run-settings env layer like `FABRO_MODEL` or `FABRO_PROVIDER`. Manifest config precedence is `args` > workflow config > project config > user config > server defaults, with server-owned operational settings stripped from uploaded configs. diff --git a/docs/plans/2026-04-05-server-canonical-secrets-doctor-repo-plan.md b/docs/plans/2026-04-05-server-canonical-secrets-doctor-repo-plan.md new file mode 100644 index 000000000..c68f072bd --- /dev/null +++ b/docs/plans/2026-04-05-server-canonical-secrets-doctor-repo-plan.md @@ -0,0 +1,984 @@ +# Server-Canonical Secrets, Doctor, and Repo Init + +## Summary + +Migrate five CLI command families from local-only to server-canonical: + +- **secrets** — move from `~/.fabro/.env` to server-owned JSON store with write-only API +- **provider login** — keep validation in CLI, save credentials via server API +- **repo init** — call server to verify repo access after scaffolding +- **doctor** — replace local probing with a single server diagnostics endpoint +- **health** — add server version to `GET /health` for CLI/server parity checks + +After this pass, the CLI has no direct file I/O for secrets and no direct probing of external services for health checks. The server is the single owner of credentials and the single source of diagnostic truth. + +This plan deliberately excludes the run manifest (server-canonical `POST /runs` body). That is a separate, larger effort. + +## Scope Boundaries + +In scope: +- add `version` to `GET /health` +- server-side secret store (JSON file, in-memory cache, store-backed secret accessors) +- `PUT /api/v1/secrets/{name}`, `DELETE /api/v1/secrets/{name}`, `GET /api/v1/secrets` +- rewrite `fabro secret set`, `fabro secret list`, `fabro secret rm` as API clients +- remove `fabro secret get` (secrets are write-only) +- rewrite `fabro provider login` to save credentials via the server +- update `fabro install` to save GitHub App secrets via the local server and print restart guidance when needed +- `GET /api/v1/repos/github/{owner}/{name}` for repo access checks +- update `fabro repo init` to call repo endpoint +- `POST /api/v1/health/diagnostics` with server-side health probing +- rewrite `fabro doctor` as API client + version parity check + retained local config warning +- demo mode handlers for all new endpoints +- add shared `--server ` support for these server-canonical CLI commands, where `` is either an HTTP(S) base URL or an absolute Unix socket path + +Out of scope: +- run manifest / workflow packaging +- changes to `fabro exec` credential handling (`OPENAI_API_KEY=secret fabro exec ...` is the intended path) +- remote server auth (mTLS, JWT) — endpoints follow existing auth patterns +- encrypted-at-rest secret storage — JSON file with filesystem permissions is sufficient for now +- openssl system dependency check (being removed soon) +- broader CLI target cleanup outside the command families touched by this plan + +## Key Decisions + +- Secrets are **write-only**. No endpoint exposes secret values after they are stored. `GET /api/v1/secrets` returns names and timestamps only. `fabro secret get` is removed. +- Secret storage is a JSON file at `/secrets.json` under the active server data dir. +- The server does **not** mutate process env vars on secret writes. Server-side secret consumers read through a shared store-backed adapter so updated secrets take effect immediately for request-time flows. +- Startup-time components that only initialize once at server boot are allowed to require restart after credential changes. `fabro install` should print that restart requirement when it detects the server was already running. +- `GET /health` gains a `version` field. The CLI checks version parity before rendering diagnostics. +- `POST /api/v1/health/diagnostics` (not GET) because it triggers expensive external probes (LLM providers, GitHub, sandbox). The response reuses the shape of the existing `CheckReport` struct. +- `GET /api/v1/repos/github/{owner}/{name}` is intentionally GitHub-specific in the URL. Another segment can be added for other providers later. +- `provider login` keeps its interactive prompting and OAuth flow on the CLI side. After obtaining credentials, it saves them via `PUT /api/v1/secrets/{name}`. +- `doctor` keeps one local CLI check for user config files and legacy `.env` warning, then requires a connected server for everything else. There is no fast/offline mode. +- `dot` system dependency check moves server-side. `openssl` check is dropped. `node` check is dropped (build-time dependency only). +- The `--show-values` flag on `fabro secret list` is removed as a consequence of the write-only secret model. +- `PUT /api/v1/secrets/{name}` accepts any env-var-like key name and rejects invalid names with `400`. +- These server-canonical CLI commands use one explicit override flag, `--server `, where `` is either an HTTP(S) URL or an absolute Unix socket path. When omitted, they connect to the local server for the active storage dir, starting it if necessary. +- `[server].base_url` is replaced by `[server].target`, using the same string syntax as `--server`. +- `fabro install` is local-only. It never targets a remote server. +- All new endpoints have demo mode handlers. + +## Implementation Changes + +### 1. Add version to `GET /health` + +In `docs/api-reference/fabro-api.yaml`: +- add `version` field to `HealthResponse` schema: + ```yaml + HealthResponse: + description: Service health check response. + type: object + required: + - status + - version + properties: + status: + type: string + description: Health status indicator. + example: ok + version: + type: string + description: Server version string. + example: "0.176.2" + ``` + +In `lib/crates/fabro-server/src/server.rs`: +- update the `health` handler to include the version: + ```rust + async fn health() -> Response { + Json(serde_json::json!({ + "status": "ok", + "version": fabro_util::version::FABRO_VERSION, + })) + .into_response() + } + ``` + +Rebuild `fabro-api` to pick up the schema change: +``` +cargo build -p fabro-api +``` + +Add a test in `server.rs` inline tests: +- send `GET /health`, assert status 200, assert `version` field is a non-empty string, assert `status` is `"ok"`. + +### 2. Server-side secret store + +Create `lib/crates/fabro-server/src/secret_store.rs`. + +This module owns a JSON file and an in-memory cache: + +```rust +pub struct SecretEntry { + pub value: String, + pub created_at: String, // ISO 8601 + pub updated_at: String, // ISO 8601 +} + +pub struct SecretMetadata { + pub name: String, + pub created_at: String, + pub updated_at: String, +} + +pub struct SecretStore { + path: PathBuf, + entries: HashMap, +} +``` + +Public API: +- `SecretStore::load(path: PathBuf) -> Result` — reads JSON file (or creates empty if missing), parses into `entries`. +- `store.set(name: &str, value: &str) -> Result` — validates the key name, upserts entry with current timestamp, writes atomically (write to temp file, rename to `secrets.json`), returns metadata. +- `store.remove(name: &str) -> Result<()>` — validates the key name, removes entry (error if not found), writes atomically (write to temp file, rename). +- `store.list() -> Vec` — returns names + timestamps, sorted by name. No values. +- `store.get(name: &str) -> Option<&str>` — reads a single secret value for server-side consumers. +- `store.snapshot() -> HashMap` — clones the current key/value map for request-time consumers that need a full view. +- `SecretStore::validate_name(name: &str) -> Result<()>` — accept only env-var-like keys (`[A-Za-z_][A-Za-z0-9_]*`). + +The JSON file format: +```json +{ + "ANTHROPIC_API_KEY": { + "value": "sk-ant-...", + "created_at": "2026-04-05T10:30:00Z", + "updated_at": "2026-04-05T10:30:00Z" + } +} +``` + +File permissions: `0o600` on Unix (same as current `.env`). + +Inline tests in `secret_store.rs`: +- `load` from empty/missing file returns empty store +- `set` creates entry, verify file written +- `set` existing key updates `updated_at`, preserves `created_at` +- `remove` deletes entry, verify file written +- `remove` missing key returns error +- `list` returns sorted metadata without values +- invalid names are rejected +- use `tempdir` for file paths in tests + +In `lib/crates/fabro-server/src/server.rs`: +- add `pub secret_store: tokio::sync::RwLock` to `AppState` +- update `build_app_state` to derive `secrets.json` from the active server data dir and call `SecretStore::load(path)?` +- update `create_app_state` (test helper) to use a temp path + +In `lib/crates/fabro-server/src/lib.rs`: +- add `pub mod secret_store;` + +Also in the server layer: +- add small store-backed adapters for the server-side secret consumers touched by this plan instead of continuing to call `std::env::var(...)` / `from_env()` +- the adapters only need to cover the flows touched by this plan: + - LLM client construction for diagnostics/model probing + - GitHub App credentials for repo checks + - GitHub client secret and session secret reads in web auth + - diagnostics secret presence/probe checks +- request-time server flows should read from the current `SecretStore` snapshot so new credentials take effect immediately +- startup-time flows may continue to require restart if they only read credentials during boot + +### 3. Secret CRUD API endpoints + +In `docs/api-reference/fabro-api.yaml`: +- add schemas: + ```yaml + SetSecretRequest: + description: Request to store a secret value. + type: object + required: + - value + properties: + value: + type: string + description: The secret value to store. + + SecretMetadata: + description: Metadata for a stored secret (value is never exposed). + type: object + required: + - name + - created_at + - updated_at + properties: + name: + type: string + description: Secret key name. + example: ANTHROPIC_API_KEY + created_at: + type: string + format: date-time + description: When the secret was first stored. + updated_at: + type: string + format: date-time + description: When the secret was last updated. + + SecretListResponse: + description: List of stored secret metadata. + type: object + required: + - data + properties: + data: + type: array + items: + $ref: "#/components/schemas/SecretMetadata" + ``` + +- add paths: + ```yaml + /api/v1/secrets: + get: + operationId: listSecrets + tags: [Secrets] + summary: List stored secrets (names and timestamps only). + responses: + "200": + description: Secret metadata list. + content: + application/json: + schema: + $ref: "#/components/schemas/SecretListResponse" + + /api/v1/secrets/{name}: + put: + operationId: setSecret + tags: [Secrets] + summary: Store or update a secret. + parameters: + - name: name + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SetSecretRequest" + responses: + "200": + description: Secret stored. + content: + application/json: + schema: + $ref: "#/components/schemas/SecretMetadata" + "400": + description: Invalid secret name or request body. + delete: + operationId: deleteSecret + tags: [Secrets] + summary: Delete a stored secret. + parameters: + - name: name + in: path + required: true + schema: + type: string + responses: + "204": + description: Secret deleted. + "400": + description: Invalid secret name. + "404": + description: Secret not found. + "500": + description: Secret store write failed. + ``` + +Rebuild `fabro-api`: +``` +cargo build -p fabro-api +``` + +In `lib/crates/fabro-server/src/server.rs`: +- add handlers: + + ```rust + async fn list_secrets( + _auth: AuthenticatedService, + State(state): State>, + ) -> Response { + let store = state.secret_store.read().await; + let data = store.list(); + (StatusCode::OK, Json(serde_json::json!({ "data": data }))).into_response() + } + + async fn set_secret( + _auth: AuthenticatedService, + State(state): State>, + Path(name): Path, + Json(body): Json, + ) -> Response { + let mut store = state.secret_store.write().await; + match store.set(&name, &body.value) { + Ok(meta) => (StatusCode::OK, Json(meta)).into_response(), + Err(SecretStoreError::InvalidName(_)) => { + ApiError::new(StatusCode::BAD_REQUEST, "invalid secret name").into_response() + } + Err(e) => ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), + } + } + + async fn delete_secret( + _auth: AuthenticatedService, + State(state): State>, + Path(name): Path, + ) -> Response { + let mut store = state.secret_store.write().await; + match store.remove(&name) { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(SecretStoreError::InvalidName(_)) => StatusCode::BAD_REQUEST.into_response(), + Err(SecretStoreError::NotFound(_)) => StatusCode::NOT_FOUND.into_response(), + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } + } + ``` + +- update the `axum::routing` import to include `put` and `delete` +- ensure request-body logging/redaction treats `PUT /secrets/{name}` values as sensitive and never logs the raw secret +- add routes in `real_routes()`: + ```rust + .route("/secrets", get(list_secrets)) + .route("/secrets/{name}", put(set_secret).delete(delete_secret)) + ``` + +- add routes in `demo_routes()` pointing to demo handlers (see §10). + +Tests in `server.rs` inline tests: +- `PUT /secrets/TEST_KEY` with `{"value": "test-val"}` → 200, response has `name`, `created_at`, `updated_at` +- `GET /secrets` → 200, `data` array contains the key just set, no `value` field present +- `PUT` same key again → 200, `updated_at` changes +- `PUT /secrets/NOT-VALID` → 400 +- `DELETE /secrets/TEST_KEY` → 204 +- `DELETE /secrets/NONEXISTENT` → 404 +- `GET /secrets` after delete → empty `data` + +### 4. Rewrite `fabro secret` CLI commands as API clients + +In `lib/crates/fabro-cli/src/commands/secret/mod.rs`: +- remove `SecretCommand::Get` variant +- make `execute` async (it currently dispatches to sync functions) +- each subcommand uses the shared explicit server-target helper, then the generated `fabro_api::Client` + +In `lib/crates/fabro-cli/src/args.rs`: +- add `ServerTargetArgs`: + ```rust + #[derive(Args, Debug, Clone, Default)] + pub(crate) struct ServerTargetArgs { + /// Fabro server target: http(s) URL or absolute Unix socket path + #[arg(long = "server", env = "FABRO_SERVER")] + pub(crate) target: Option, + } + ``` +- flatten `ServerTargetArgs` into: + - `SecretNamespace` + - `ProviderLoginArgs` + - `DoctorArgs` (convert the inline `Doctor { ... }` variant to a named args struct) + - `RepoInitArgs` (convert the inline `RepoCommand::Init { ... }` variant to a named args struct) +- do **not** add `ServerTargetArgs` to `InstallArgs`; `install` stays local-only + +In `lib/crates/fabro-cli/src/user_config.rs`: +- replace `[server].base_url` support with `[server].target` +- add a shared parser/resolver for explicit server targets used by these command families: + - `http://...` or `https://...` => remote HTTP target + - absolute path => Unix socket target + - anything else => clear parse error +- when `ServerTargetArgs` is absent, fall back to the local storage-dir/default-storage-dir server and auto-start it if necessary +- use this helper from `secret`, `provider login`, `repo init`, and `doctor` + +In `lib/crates/fabro-cli/src/commands/secret/set.rs`: +- replace the body with: + ```rust + pub(super) async fn set_command( + args: &SecretSetArgs, + server: &ServerTargetArgs, + globals: &GlobalArgs, + ) -> Result<()> { + let client = secret_client(server).await?; + let meta = client.set_secret() + .name(&args.key) + .body(types::SetSecretRequest { value: args.value.clone() }) + .send() + .await?; + if globals.json { + print_json_pretty(&meta)?; + } else { + eprintln!("Set {}", args.key); + } + Ok(()) + } + ``` + +In `lib/crates/fabro-cli/src/commands/secret/list.rs`: +- remove `--show-values` flag from `SecretListArgs` +- replace the body with: + ```rust + pub(super) async fn list_command( + args: &SecretListArgs, + server: &ServerTargetArgs, + globals: &GlobalArgs, + ) -> Result<()> { + let client = secret_client(server).await?; + let resp = client.list_secrets().send().await?; + if globals.json { + print_json_pretty(&resp.data)?; + } else { + for secret in &resp.data { + println!("{}\t{}", secret.name, secret.updated_at); + } + } + Ok(()) + } + ``` + +In `lib/crates/fabro-cli/src/commands/secret/rm.rs`: +- replace the body with: + ```rust + pub(super) async fn rm_command( + args: &SecretRmArgs, + server: &ServerTargetArgs, + globals: &GlobalArgs, + ) -> Result<()> { + let client = secret_client(server).await?; + client.delete_secret().name(&args.key).send().await?; + if globals.json { + print_json_pretty(&serde_json::json!({ "key": args.key }))?; + } else { + eprintln!("Removed {}", args.key); + } + Ok(()) + } + ``` + +Delete `lib/crates/fabro-cli/src/commands/secret/get.rs`. + +In `lib/crates/fabro-cli/src/args.rs`: +- remove `SecretCommand::Get` and `SecretGetArgs` + +Update any integration tests that test `fabro secret get` — remove them. + +### 5. Rewrite `fabro provider login` to save via server + +In `lib/crates/fabro-cli/src/commands/provider/login.rs`: +- after obtaining validated `env_pairs` (the `Vec<(String, String)>` of env var name → key value), replace the `provider_auth::write_env_file(...)` call with API calls: + ```rust + let client = provider_secret_client(&args.server).await?; + for (env_var, key) in &env_pairs { + client.set_secret() + .name(env_var) + .body(types::SetSecretRequest { value: key.clone() }) + .send() + .await + .with_context(|| format!("failed to save {env_var} to server"))?; + } + ``` +- remove the `provider_auth::write_env_file` call +- add a temporary warning if a legacy `.env` file exists under the active local storage dir: + ``` + Warning: ~/.fabro/.env is no longer read by fabro server. Re-enter credentials with `fabro provider login` or `fabro secret set`. + ``` + +In `lib/crates/fabro-cli/src/shared/provider_auth.rs`: +- `write_env_file` may become dead code after this change. If no other callers exist, delete it. +- `validate_api_key` currently calls `std::env::set_var` temporarily to validate. This still works because validation happens before saving. However, consider whether the validation should instead construct the LLM client explicitly with the key rather than mutating process env. This is a follow-up concern — for now the existing validation approach is fine since the CLI process is single-threaded for this flow. + +### 5a. Update `fabro install` to save GitHub App secrets via the local server + +In `lib/crates/fabro-cli/src/commands/install.rs`: +- keep `install` local-only. It should always operate on the local server for the active storage dir and never accept `--server` +- after writing `server.toml` / `user.toml` and producing GitHub App secret env pairs, persist those secret values via the local server's `PUT /secrets/{name}` API instead of writing `.env` +- detect whether the local server was already running before `install` +- if the server was not running, letting the local client auto-start it is fine +- if the server was already running, print a clear restart warning after saving secrets: + ``` + Fabro server was already running. Restart it to pick up startup-time credential changes (for example webhook listener configuration). + ``` +- remove the `.env` reload +- update the final doctor invocation to the new signature / args shape + +This is intentionally a hard break from `.env`, but `install` should print a temporary migration warning if it sees a legacy `.env` file in the local storage dir. + +### 6. `GET /api/v1/repos/github/{owner}/{name}` endpoint + +In `docs/api-reference/fabro-api.yaml`: +- add schema: + ```yaml + RepoCheckResponse: + description: Repository access check result. + type: object + required: + - owner + - name + - accessible + properties: + owner: + type: string + description: GitHub repository owner. + example: acme-corp + name: + type: string + description: GitHub repository name. + example: my-app + accessible: + type: boolean + description: Whether the server has read-write access to this repository. + default_branch: + type: string + nullable: true + description: Default branch name, if accessible. + example: main + private: + type: boolean + nullable: true + description: Whether the repository is private, if accessible. + permissions: + type: object + nullable: true + description: Detected permission levels. + properties: + pull: + type: boolean + push: + type: boolean + admin: + type: boolean + install_url: + type: string + nullable: true + description: GitHub App installation URL when the repo is not yet accessible. + ``` + +- add path: + ```yaml + /api/v1/repos/github/{owner}/{name}: + get: + operationId: getGithubRepo + tags: [Repos] + summary: Check server access to a GitHub repository. + parameters: + - name: owner + in: path + required: true + schema: + type: string + - name: name + in: path + required: true + schema: + type: string + responses: + "200": + description: Repository access details. + content: + application/json: + schema: + $ref: "#/components/schemas/RepoCheckResponse" + ``` + +Rebuild `fabro-api`: +``` +cargo build -p fabro-api +``` + +In `lib/crates/fabro-server/Cargo.toml`: +- add `fabro-github` as a dependency if not already present + +In `lib/crates/fabro-server/src/server.rs`: +- add handler: + ```rust + async fn get_github_repo( + _auth: AuthenticatedService, + State(state): State>, + Path((owner, name)): Path<(String, String)>, + ) -> Response + ``` + The handler: + 1. Reads non-secret GitHub App config (`app_id`, `slug`) from `Settings`. Reads `GITHUB_APP_PRIVATE_KEY` from `SecretStore`. + 2. Signs a JWT via `fabro_github::sign_app_jwt`. + 3. Calls `GET /repos/{owner}/{name}/installation` to check if the App is installed. + 4. If installed, mints an installation token and calls `GET /repos/{owner}/{name}` to get repo details (default branch, private flag, permissions). + 5. If not installed, returns `accessible: false` with null optional fields and, when possible, an `install_url`. + 6. Returns `RepoCheckResponse`. + + If GitHub App credentials are not configured (missing from settings or secret store), return `accessible: false` with a descriptive error or a 503. + +- add route in `real_routes()`: + ```rust + .route("/repos/github/{owner}/{name}", get(get_github_repo)) + ``` +- add route in `demo_routes()` pointing to demo handler (see §10). + +Tests: +- Testing the real handler requires mocking the GitHub API or the `HttpClient` trait. Use a unit test that exercises the response shape with a mock `AppState` that has a test GitHub client, or test at the integration level with the demo handler. +- At minimum, test the demo handler returns 200 with the expected shape. + +### 7. Update `fabro repo init` to call repo endpoint + +In `lib/crates/fabro-cli/src/commands/repo/init.rs`: +- replace `check_github_app_installation()` with a server call: + ```rust + async fn check_repo_access(owner: &str, name: &str, args: &RepoInitArgs) -> Result<()> { + let client = repo_client(&args.server).await?; + let resp = client.get_github_repo() + .owner(owner) + .name(name) + .send() + .await?; + if resp.accessible { + println!(" {} GitHub repo {}/{} is accessible", green_check, owner, name); + if let Some(branch) = &resp.default_branch { + println!(" Default branch: {branch}"); + } + } else { + println!(" {} GitHub repo {}/{} is not accessible", yellow_warn, owner, name); + println!(" Install the GitHub App to enable PR creation and webhook triggers."); + if let Some(url) = &resp.install_url { + println!(" Install at: {url}"); + } + } + Ok(()) + } + ``` +- The function still parses the git remote to extract `owner`/`name` — that stays CLI-side since it reads the local git config. +- Remove the direct `fabro_github::sign_app_jwt`, `fabro_github::check_app_installed`, `build_github_app_credentials` calls. +- Keep the `fabro-github` dependency in `fabro-cli/Cargo.toml` — it has many other callers (pr/*, preflight.rs, shared/github.rs). +- preserve the current interactive UX: + - when the repo is not yet accessible and stdin is a terminal, print the install URL, wait for Enter, then call the repo endpoint again + - print the second check result after the re-check + +### 8. `POST /api/v1/health/diagnostics` endpoint + +In `docs/api-reference/fabro-api.yaml`: +- add schemas: + ```yaml + DiagnosticsReport: + description: Server health diagnostics report. + type: object + required: + - version + - sections + properties: + version: + type: string + description: Server version. + sections: + type: array + items: + $ref: "#/components/schemas/DiagnosticsSection" + + DiagnosticsSection: + type: object + required: + - title + - checks + properties: + title: + type: string + checks: + type: array + items: + $ref: "#/components/schemas/DiagnosticsCheck" + + DiagnosticsCheck: + type: object + required: + - name + - status + - summary + properties: + name: + type: string + status: + type: string + enum: + - pass + - warning + - error + summary: + type: string + details: + type: array + items: + $ref: "#/components/schemas/DiagnosticsDetail" + remediation: + type: string + nullable: true + + DiagnosticsDetail: + type: object + required: + - text + - warn + properties: + text: + type: string + warn: + type: boolean + ``` + +- add path: + ```yaml + /api/v1/health/diagnostics: + post: + operationId: runDiagnostics + tags: [Discovery] + summary: Run server health diagnostics. + description: Probes external services (LLM providers, GitHub, sandbox) and checks server configuration. May be slow. + responses: + "200": + description: Diagnostics report. + content: + application/json: + schema: + $ref: "#/components/schemas/DiagnosticsReport" + ``` + +Rebuild `fabro-api`: +``` +cargo build -p fabro-api +``` + +Create `lib/crates/fabro-server/src/diagnostics.rs`. + +This module contains the server-side check functions. Many can be adapted from the existing `doctor.rs` in `fabro-cli`. The key checks, grouped into sections: + +**Section "Credentials":** +- `check_llm_providers` — for each provider in `Provider::ALL`, check if a secret exists in the store and probe connectivity by sending a test message. +- `check_github_app` — check that `GITHUB_APP_ID`, `GITHUB_APP_PRIVATE_KEY`, etc. exist in settings/store, validate JWT signing, and probe `GET /app` on GitHub API. +- `check_sandbox` — check `DAYTONA_API_KEY` exists. Probe Daytona API. +- `check_brave_search` — check `BRAVE_SEARCH_API_KEY` exists. Probe Brave API. + +**Section "System":** +- `check_system_dep_dot` — check `dot` is in PATH and version ≥ 2.0.0. + +**Section "Configuration":** +- `check_crypto` — validate mTLS certs/keys, JWT keys, session secret (same checks as current doctor). + +Dropped from diagnostics (compared to current doctor): +- `check_api` / `check_web` — the CLI's ability to call the diagnostics endpoint is itself the connectivity check. No circular self-check. +- `check_system_dep_node` — node is a build-time dependency only, not needed at server runtime. +- `check_system_dep_openssl` — being removed soon. + +The handler: + +```rust +async fn run_diagnostics( + _auth: AuthenticatedService, + State(state): State>, +) -> Response { + let report = diagnostics::run_all(&state).await; + (StatusCode::OK, Json(report)).into_response() +} +``` + +`diagnostics::run_all` runs all checks concurrently (where possible) and returns a `DiagnosticsReport`. The probes (LLM, GitHub, Daytona, Brave) should be run concurrently via `tokio::join!` or `futures::join!`. +- apply explicit timeouts to the live probes so `doctor` cannot hang indefinitely +- keep concurrency bounded to this fixed set of checks; do not allow unbounded fan-out + +Each check function returns a `DiagnosticsCheck` struct that maps 1:1 to the API schema. The existing `CheckResult` from `fabro_util::check_report` is very close — consider either: +- reusing `CheckResult` directly and serializing it (it already has `Serialize`) +- or mapping to the generated `fabro_api` types + +For the wire contract, prefer an explicit conversion step rather than assuming `CheckResult` serialization is automatically stable enough for the API surface. + +Add route in `real_routes()`: +```rust +.route("/health/diagnostics", post(run_diagnostics)) +``` + +Add route in `demo_routes()` pointing to demo handler (see §10). + +Tests: +- test that `POST /health/diagnostics` returns 200 with a `version` field and a non-empty `sections` array +- test that each section has a `title` and `checks` array +- test the demo handler returns the same shape + +### 9. Rewrite `fabro doctor` as API client + +In `lib/crates/fabro-cli/src/commands/doctor.rs`: +- replace `run_doctor` with a thin client: + ```rust + pub async fn run_doctor(args: &DoctorArgs, globals: &GlobalArgs) -> Result<()> { + let client = doctor_client(&args.server).await?; + + // Local config warning block + let local_checks = render_local_config_checks()?; + + // Version parity check + let health = client.get_health().send().await?; + let server_version = &health.version; + let cli_version = fabro_util::version::FABRO_VERSION; + + // Run diagnostics + let report = client.run_diagnostics().send().await?; + + if globals.json { + print_json_pretty(&report)?; + return Ok(()); + } + + // Version parity + if server_version != cli_version { + eprintln!( + "⚠ Version mismatch: CLI={cli_version} Server={server_version}" + ); + } + + // Render the retained local config warnings first, then the server diagnostics report. + render_local_checks(&local_checks); + render_diagnostics(&report); + + Ok(()) + } + ``` +- the rendering logic from `CheckReport::render()` can be reused. Either: + - convert the API response into a `CheckReport` and call `render()` + - or extract the rendering logic into a function that takes the diagnostics fields directly +- remove the local dry-run / offline mode flag +- keep the local user-config and legacy `.env` checks +- remove the other local check functions (`check_llm_providers`, `check_github_app`, `check_sandbox`, `check_brave_search`, `check_system_deps`, `check_api`, `check_web`, `check_crypto`) +- remove the corresponding test functions if they only tested the removed local checks +- keep the `CheckReport`/`CheckResult` rendering code in `fabro_util::check_report` — it is still useful for rendering the server's response + +If the server client connection fails (server unreachable), the error message should be clear: +``` +Error: could not connect to fabro server. Run `fabro server start` or check `fabro server status`. +``` + +Also: +- if a legacy local `.env` file exists, print a temporary warning that the server no longer reads it +- `doctor` should still succeed in rendering that local warning even when the diagnostics call later fails + +### 10. Demo mode handlers + +In `lib/crates/fabro-server/src/demo/mod.rs`: + +**Secrets demo:** +- maintain a static in-memory `HashMap` with pre-populated fake secrets: + ```rust + pub(crate) async fn list_secrets(...) -> Response { + let data = vec![ + serde_json::json!({ + "name": "ANTHROPIC_API_KEY", + "created_at": "2026-01-15T09:00:00Z", + "updated_at": "2026-03-20T14:30:00Z", + }), + serde_json::json!({ + "name": "OPENAI_API_KEY", + "created_at": "2026-01-15T09:05:00Z", + "updated_at": "2026-02-10T11:00:00Z", + }), + serde_json::json!({ + "name": "GITHUB_APP_PRIVATE_KEY", + "created_at": "2026-01-15T09:10:00Z", + "updated_at": "2026-01-15T09:10:00Z", + }), + ]; + (StatusCode::OK, Json(serde_json::json!({ "data": data }))).into_response() + } + + pub(crate) async fn set_secret(...) -> Response { + // return fake metadata with current timestamps + } + + pub(crate) async fn delete_secret(...) -> Response { + StatusCode::NO_CONTENT.into_response() + } + ``` + +**Repo demo:** +- return a fake accessible repo: + ```rust + pub(crate) async fn get_github_repo( + ..., + Path((owner, name)): Path<(String, String)>, + ) -> Response { + (StatusCode::OK, Json(serde_json::json!({ + "owner": owner, + "name": name, + "accessible": true, + "default_branch": "main", + "private": false, + "permissions": { "pull": true, "push": true, "admin": false }, + }))).into_response() + } + ``` + +**Diagnostics demo:** +- return an all-passing report: + ```rust + pub(crate) async fn run_diagnostics(...) -> Response { + (StatusCode::OK, Json(serde_json::json!({ + "version": fabro_util::version::FABRO_VERSION, + "sections": [ + { + "title": "Credentials", + "checks": [ + { "name": "LLM Providers", "status": "pass", "summary": "Anthropic, OpenAI configured", "details": [], "remediation": null }, + { "name": "GitHub App", "status": "pass", "summary": "JWT signing OK", "details": [], "remediation": null }, + { "name": "Sandbox", "status": "pass", "summary": "Daytona reachable", "details": [], "remediation": null }, + { "name": "Brave Search", "status": "pass", "summary": "API key configured", "details": [], "remediation": null }, + ] + }, + { + "title": "System", + "checks": [ + { "name": "dot", "status": "pass", "summary": "dot 12.1.2", "details": [], "remediation": null }, + ] + }, + { + "title": "Configuration", + "checks": [ + { "name": "Crypto", "status": "pass", "summary": "All keys valid", "details": [], "remediation": null }, + ] + }, + ] + }))).into_response() + } + ``` + +Wire all demo handlers in `demo_routes()`: +```rust +.route("/secrets", get(demo::list_secrets)) +.route("/secrets/{name}", put(demo::set_secret).delete(demo::delete_secret)) +.route("/repos/github/{owner}/{name}", get(demo::get_github_repo)) +.route("/health/diagnostics", post(demo::run_diagnostics)) +``` + +## Implementation Order + +``` +1 Health version (no deps, small) +2 Secret store + adapters (no deps) +3 Secret CRUD API (depends on 2) +4 Shared server target plumbing (parallel with 2-3) +5 Secret CLI migration (depends on 3, 4) +6 Provider login migration (depends on 3, 4) +7 Install migration (depends on 3) +8 Repo check API (depends on 2) +9 Repo init migration (depends on 4, 8) +10 Diagnostics API (depends on 2) +11 Doctor CLI migration (depends on 1, 4, 10) +``` + +Steps 1, 2, and 4 can start in parallel. Steps 5 and 6 can run in parallel once 3 and 4 are done. + +## Resolved Questions + +1. **Secret store path**: `/secrets.json` under the active server data dir. Credentials are owned by the server instance, not by a global shared file. + +2. **Migration from existing `.env`**: no auto-import. Hard break. Users must re-enter credentials via `fabro provider login` or `fabro secret set`, and the CLI/server should emit a temporary warning when they detect a legacy `.env`. + +3. **GitHub App credentials for repo check**: non-secret config (`app_id`, `slug`) goes in server settings (not mixed with secrets). Secret values (`GITHUB_APP_PRIVATE_KEY`) go in the secret store. The repo check handler reads `app_id`/`slug` from `Settings` and `GITHUB_APP_PRIVATE_KEY` from `SecretStore`. + +4. **Diagnostics: `check_api` and `check_web`**: dropped. The CLI's ability to call the diagnostics endpoint *is* the API connectivity check — if the server is unreachable, the CLI gets a connection error before any diagnostics run. No circular self-check needed. The one retained local CLI check is user config / legacy `.env`. + +5. **`node` dependency**: dropped from diagnostics. The web app is an SPA served by the Rust server; `node` is a build-time dependency only, not needed at server runtime. + +6. **`dot` dependency**: moves server-side. `openssl` dependency: dropped (being removed soon). + +7. **Server targeting**: server-canonical admin commands use `--server ` and `[server].target`, where `` is either an HTTP(S) base URL or an absolute Unix socket path. + +8. **`fabro install` targeting**: `install` is local-only and writes config plus secrets for the local server host. If the server was already running, `install` prints that a restart is required for startup-time features to pick up new secrets. diff --git a/docs/plans/2026-04-06-cli-config-socket-storage-separation-plan.md b/docs/plans/2026-04-06-cli-config-socket-storage-separation-plan.md new file mode 100644 index 000000000..5e24ba8e2 --- /dev/null +++ b/docs/plans/2026-04-06-cli-config-socket-storage-separation-plan.md @@ -0,0 +1,231 @@ +# CLI Config, Socket, And Storage Separation + +## Summary +Separate machine config, server target, and server storage so the CLI no longer conflates: + +- config path: `~/.fabro/settings.toml` +- default socket target: `~/.fabro/fabro.sock` +- default storage dir: `~/.fabro/storage` + +Normal user-facing commands should always talk to a server target. A Unix socket target may auto-start the daemon. An HTTP target may not. Serverless/direct-storage command behavior should be removed from normal commands in this pass. + +## Scope Boundaries +In scope: +- add `FABRO_CONFIG` support for machine settings loading +- default the server target to `~/.fabro/fabro.sock` +- default server storage to `~/.fabro/storage` +- decouple socket-path resolution from storage-dir resolution +- keep daemon auto-start only for Unix socket targets +- remove normal-command fallback to direct storage-based targeting +- keep `fabro server *`, hidden `fabro __runner`, and `fabro install` as storage-owning commands +- keep user-facing `--server` on server-targeted commands +- remove user-facing `--storage-dir` from server-targeted commands + +Out of scope: +- `fabro exec` +- `fabro system df` +- `fabro system prune` +- `fabro store dump` +- new remote maintenance endpoints for deferred commands + +## Problem Frame +The current CLI still mixes together two different ideas: + +- a local daemon reached over a Unix socket +- serverless behavior where the CLI uses `storage_dir` as the command target + +That has produced the wrong defaults and the wrong abstractions: + +- the socket path is currently derived from `storage_dir` +- many commands still model targeting as "server or storage dir" +- normal commands can still fall back to a storage-driven local connection shape +- autostart is keyed off the local/storage connection path instead of the actual server target type + +The intended model is simpler: + +- normal commands always resolve a server target +- the default server target is a Unix socket in `~/.fabro` +- Unix socket targets may auto-start a daemon +- HTTP targets may not auto-start a daemon +- storage is server-owned runtime state, not the primary targeting mechanism for normal commands + +## Key Decisions +- `FABRO_CONFIG` selects the active machine settings file. + - For server lifecycle commands and daemon auto-start, precedence is: explicit `--config` where supported, then `FABRO_CONFIG`, then `~/.fabro/settings.toml`. + - Normal user-facing commands do not gain a new `--config` flag in this pass; they resolve settings from `FABRO_CONFIG` or the default path. +- `FABRO_SERVER` selects the effective server target for normal commands. + - It accepts either an absolute Unix socket path or an `http(s)` URL. + - If unset, use `settings.server.target`. + - If that is unset, default to `~/.fabro/fabro.sock`. +- Server-targeted commands keep `--server` as the standard one-off override. + - `FABRO_SERVER` remains the env-var equivalent. +- `FABRO_STORAGE_DIR` is no longer a normal command-targeting mechanism. + - It remains an override for storage-owning commands only. + - Precedence for storage-owning commands: explicit `--storage-dir` where still supported, then `FABRO_STORAGE_DIR`, then `settings.storage_dir`, then `~/.fabro/storage`. +- `settings.server.target` remains the durable place to configure the machine's server target. +- `settings.storage_dir` remains the durable place to configure where the local server stores data. +- `fabro server start` keeps `--config` and `--bind`. + - Its default bind is `~/.fabro/fabro.sock`, not `/fabro.sock`. +- `fabro server stop` and `fabro server status` remain storage-owning commands and continue to resolve the local server instance from storage-owned records. +- `fabro settings` is a local config-inspection command, not a server-targeted command. + - It keeps its current local settings-resolution behavior in this pass. +- `fabro exec` is unchanged in this pass. + +## Command Classification +### Storage-owning commands +These commands continue to resolve and use local storage directly: + +- `fabro server start` +- `fabro server stop` +- `fabro server status` +- hidden `fabro server __serve` +- hidden `fabro run __runner` +- `fabro install` + +### Local config-inspection commands +These commands stay outside the server-targeting cleanup in this pass: + +- `fabro settings` + +### Server-targeted commands +These commands should resolve a `ServerTarget` only and should not use storage-dir fallback semantics: + +- `fabro run` +- `fabro create` +- `fabro preflight` +- `fabro validate` +- `fabro graph` +- `fabro model list` +- `fabro model test` +- `fabro doctor` +- `fabro repo init` +- `fabro provider login` +- `fabro secret list` +- `fabro secret rm` +- `fabro secret set` +- `fabro ps` +- `fabro rm` +- `fabro inspect` +- `fabro run start` +- `fabro run attach` +- `fabro run logs` +- `fabro run resume` +- `fabro run rewind` +- `fabro run fork` +- `fabro run wait` +- hidden `fabro run diff` +- `fabro artifact list` +- `fabro artifact cp` +- `fabro sandbox cp` +- `fabro sandbox preview` +- `fabro sandbox ssh` +- `fabro pr create` +- `fabro pr list` +- `fabro pr view` +- `fabro pr merge` +- `fabro pr close` + +### Deferred local-maintenance commands +These remain unchanged in this pass: + +- `fabro system df` +- `fabro system prune` +- `fabro store dump` + +These deferred commands continue to use `StorageDirArgs` and the existing hybrid `ServerRunLookup::connect(storage_dir)` path in this pass, and should keep working against the new default storage dir without being reclassified as server-targeted commands yet. + +## Implementation Changes +### 1. Shared settings and path resolution +- Add a shared helper for the active settings path used by CLI and server code. + - This helper must honor `FABRO_CONFIG`. +- Add a shared helper for the default socket path: `~/.fabro/fabro.sock`. +- Change the default storage dir helper to return `~/.fabro/storage`. +- Stop deriving the socket path from `storage_dir`. + +### 2. Target resolution model +- Refactor CLI target resolution so normal commands resolve a `ServerTarget`, not a "local vs target" union. +- Remove `ServerConnection::Local` as a normal command-routing concept. +- Split helpers into two categories: + - server-target resolution for normal commands + - storage-dir resolution for storage-owning commands +- Keep TLS handling attached to `HttpUrl` targets exactly as today. + +### 3. Connection and auto-start behavior +- Update the server client helpers so they accept or derive a `ServerTarget`. +- If the resolved target is `UnixSocket(path)`: + - attempt to connect to that socket + - if unavailable, auto-start the daemon + - auto-start the daemon bound to that exact socket path +- If the resolved target is `HttpUrl(url)`: + - attempt to connect once + - if unavailable, fail with a clean reachability error + - do not auto-start anything +- Auto-start must pass the active config path through to the spawned server with `--config`. +- The auto-start helper should take the resolved active config path, resolved Unix socket path, and resolved local storage dir as explicit inputs. + - It should not rediscover config via environment variables or reload settings internally during daemon launch. +- Auto-start may also pass the resolved storage dir for the local server process, but only as runtime/server lifecycle plumbing, not as the command target abstraction. +- Any active-server record lookup used by `server stop`, `server status`, `install`, or daemon auto-start should temporarily fall back to the legacy implicit storage root `~/.fabro` when no explicit storage location is provided and no record exists under the new default `~/.fabro/storage`. + - This is only to find already-running daemons started before the default-storage change. + +### 4. CLI arg surface cleanup +- Keep user-facing `--server` on all server-targeted commands listed above. +- Remove user-facing `--storage-dir` from all server-targeted commands listed above. +- Remove the `storage_dir_explicit` conflict plumbing for those commands. +- Remove the custom `--server` / `--storage-dir` conflict detection in `main.rs` once no supported command still accepts both flags together. +- Keep `--storage-dir` only on storage-owning commands in this pass. +- Keep `--config` only where already appropriate for server lifecycle. +- Update help text and parser tests so normal commands still advertise `--server` but no longer imply that storage-dir is a general targeting control. + +### 5. Run/create local run-dir handling +- `run` / `create` currently thread a synthesized local run dir through the result object to print asset paths after completion. +- Preserve that behavior only when the effective target is the machine's local Unix socket and the effective local storage dir is known. +- For HTTP targets, do not synthesize a local run dir. +- This should be derived from "effective target is local socket" plus resolved storage dir, not from a `ServerConnection::Local` enum variant. + +### 6. Docs and user-visible messaging +- Rewrite docs and examples so: + - normal commands use config-driven target resolution by default + - temporary overrides use `FABRO_SERVER=... fabro ...` + - config-file overrides use `FABRO_CONFIG=... fabro ...` +- Update language to avoid "local mode" as a user-facing concept. + - Use "Unix socket target" or "HTTP target". + - Use "serverless" only for the behavior being removed. +- Make `doctor` and `settings` print: + - active config path + - effective server target + - effective storage dir + - any active env-var overrides + +## Test Plan +- Unit tests for path and precedence helpers: + - active config path honors `FABRO_CONFIG` + - default socket path is `~/.fabro/fabro.sock` + - default storage dir is `~/.fabro/storage` + - `FABRO_SERVER` overrides `settings.server.target` + - `FABRO_STORAGE_DIR` overrides `settings.storage_dir` for storage-owning commands only + - `FABRO_CONFIG=/custom/path/settings.toml` loads that file's contents and the resolved `server.target` / `storage_dir` from that file actually take effect +- Unit tests for target resolution: + - normal commands default to a Unix socket target when nothing is configured + - normal commands no longer resolve a storage-dir fallback connection + - HTTP targets never route into autostart code +- Integration tests for daemon behavior: + - `fabro server start` defaults to binding `~/.fabro/fabro.sock` + - auto-start for socket-targeted commands binds the requested socket, not `/fabro.sock` + - auto-start passes the active config path through to the daemon + - HTTP-targeted commands fail cleanly when unreachable + - `server stop` / `server status` still find an already-running daemon whose record lives under the legacy implicit storage root +- Parser/help tests: + - server-targeted commands still accept `--server` + - server-targeted commands no longer accept `--storage-dir` + - storage-owning commands still accept `--storage-dir` where intended + - `fabro settings` keeps its existing local settings override surface in this pass +- Workflow behavior tests: + - `run`, `create`, `model`, `doctor`, `ps`, and `secret` work via the default socket target + - local Unix socket runs still print local asset/run-dir info when appropriate + - HTTP-targeted runs do not print synthesized local run-dir paths + +## Assumptions +- This is a hard cut for CLI targeting semantics. No deprecation period for removed normal-command flags. +- `FABRO_SERVER` is the single override for user-facing command targeting; no separate `FABRO_SOCKET` env var is added. +- `server.target` remains the canonical durable target field in `settings.toml`. +- Deferred commands will be handled in a later pass rather than forced into this refactor. diff --git a/docs/plans/2026-04-06-object-backed-artifact-uploads.md b/docs/plans/2026-04-06-object-backed-artifact-uploads.md new file mode 100644 index 000000000..f11a7e503 --- /dev/null +++ b/docs/plans/2026-04-06-object-backed-artifact-uploads.md @@ -0,0 +1,73 @@ +# Object-Backed Artifact Uploads + +## Summary +- Make `ArtifactStore` the durable source of truth for all stage artifacts, backed by a configurable object store that can be local filesystem or S3. +- Keep the server as the only durable artifact writer. Worker subprocesses upload artifacts to the server over the existing stage-artifacts POST route; they do not write `ArtifactStore` directly and do not use the run scratch directory as IPC. +- Ship both upload modes on `POST /api/v1/runs/{id}/stages/{stageId}/artifacts`: + - `application/octet-stream` for single-file upload + - `multipart/form-data` for batch upload on the same path +- Default artifact-upload failure policy is non-fatal: after bounded retries, the worker emits a warning notice and the run continues. +- Multipart uploads are a strict wire format: the `manifest` part must arrive first so the server can validate the batch before accepting file bytes. + +## Key Changes +- Add artifact storage configuration separate from the main run-store path: + - default backend: local object store rooted under the existing storage directory + - optional backend: S3 with bucket, region, prefix, optional endpoint override, and path-style toggle +- Refactor server startup so `ArtifactStore` is constructed from artifact-storage config instead of being hardwired to `LocalFileSystem` in `serve.rs`. +- Extend `ArtifactStore` with streaming writes: + - add `put_stream(...)` that writes directly to the configured object store using `object_store::buffered::BufWriter` + - use the same deterministic object key layout as today so retries are idempotent +- Replace the buffered server artifact upload handler with a streaming implementation: + - `application/octet-stream`: requires `filename` query param, streams request body into `ArtifactStore` + - `multipart/form-data`: requires a JSON `manifest` part first, followed by file parts; the manifest is the canonical source of artifact paths and optional checksums/content types +- Add multipart request types to the OpenAPI spec: + - `ArtifactBatchUploadManifest` + - `ArtifactBatchUploadEntry` + - one entry per file part, keyed by part name and relative artifact path +- Validate both octet-stream `filename` query params and multipart manifest paths with the existing relative-path rules; reject traversal, empty segments, duplicate manifest paths, duplicate part names, missing parts, and unexpected parts. +- Compute and verify `sha256` during upload when provided by the client. If omitted, accept the upload without checksum enforcement. Native backend checksum features such as S3-specific checksum headers are optional optimizations, not part of the v1 contract. +- Enforce explicit server-side limits: + - maximum single artifact size + - maximum artifacts per multipart request + - maximum total multipart request bytes + - reject uploads that exceed these limits before durable commit when possible, and abort active multipart writes when the limit is crossed mid-stream +- For multipart requests, return non-2xx on the first failed file and leave already-written objects in place. Retries are safe because object keys are deterministic; v1 does not attempt cross-object rollback. +- Treat concurrent uploads to the same `{run_id, stage_id, path}` as idempotent-safe retries. Deterministic object keys mean duplicate concurrent uploads may race, but the final durable object must be equivalent regardless of which writer wins. +- Update worker subprocess behavior: + - after a stage captures artifacts locally, the worker uploads them to the server over the internal artifact route + - single-file uploads may use raw octet-stream; batch upload should use multipart when multiple artifacts exist for the stage + - the worker emits `artifact.captured` only after the server confirms durable upload + - on repeated upload failure, the worker emits a warning-style run notice and continues +- Update read paths so `ArtifactStore` is the only source for list/download; no run-scratch fallback is required. +- Extend the worker spawn contract so the server provides: + - internal server address or Unix-socket target + - a short-lived bearer token scoped to artifact upload routes for that run +- Accept that interrupted multipart uploads may leave orphaned objects in v1. Record this as operational debt and add a later cleanup pass or age-based GC policy for abandoned artifact objects. + +## Public Interfaces +- `POST /api/v1/runs/{id}/stages/{stageId}/artifacts` accepts both `application/octet-stream` and `multipart/form-data`. +- Multipart wire format: + - one `manifest` JSON part first + - one file part per manifest entry + - manifest fields: `part`, `path`, optional `sha256`, optional `expected_bytes`, optional `content_type` +- Settings gain artifact object-store configuration, with local as the default and S3 as an explicit opt-in backend. + +## Test Plan +- Server integration: single-file octet-stream upload stores objects in local object store and returns `204`. +- Server integration: multipart batch upload with manifest stores every artifact under the expected object keys and returns `204`. +- Validation: invalid artifact path, invalid octet-stream `filename`, duplicate path, duplicate part name, missing file part, unexpected file part, malformed manifest, and manifest-not-first all return `400`. +- Integrity: checksum mismatch returns error and does not mark the artifact as captured. +- Limits: oversized single artifact, oversized batch byte total, and too many multipart entries all fail with the configured limit response and abort the active upload. +- Retry behavior: partial multipart failure can be retried safely and produces the correct final artifact set. +- Concurrency: concurrent uploads to the same run/stage/path converge safely to one correct durable object. +- Read path: list/download returns artifacts only from `ArtifactStore`. +- Worker integration: worker uploads captured artifacts through the server route and emits `artifact.captured` only after success. +- Failure behavior: repeated upload failure emits a warning notice and the run completes without failing. +- Backend coverage: large artifact upload works against an S3-compatible backend such as MinIO and uses streaming/multipart object-store writes without buffering the full file in memory. + +## Assumptions +- Scope is limited to `ArtifactStore` stage artifacts. Large run blobs written by `RunDatabase::write_blob()` are unchanged. +- Runs are expected to have durable artifacts in object storage; scratch copies are only local cache/state. +- The server remains the sole durable artifact writer and owns all artifact object-store credentials. +- Non-fatal artifact-upload failure is the chosen default for v1; if strict durability becomes required later, it can be added as a separate policy change. +- The artifact object key remains scoped by run id, stage id, and relative artifact path, so same-key retries are naturally idempotent. diff --git a/docs/plans/2026-04-06-settings-command-server-local-merge-plan.md b/docs/plans/2026-04-06-settings-command-server-local-merge-plan.md new file mode 100644 index 000000000..7ce0c2c89 --- /dev/null +++ b/docs/plans/2026-04-06-settings-command-server-local-merge-plan.md @@ -0,0 +1,147 @@ +# Settings Command Server/Local Merge Plan + +## Summary +Refactor `fabro settings` so it answers “what settings will actually be used?” instead of only dumping locally merged config. + +The command contract becomes: + +- `fabro settings` + - show the effective merged settings for the selected server target plus local config +- `fabro settings --local` + - show only locally resolved settings, with no server call +- `fabro settings WORKFLOW` + - show the effective merged settings for that workflow after server defaults are applied +- `fabro settings --local WORKFLOW` + - show the local-only merged settings for that workflow, with no server call + +This plan is intentionally separate from the broader config/socket/storage cleanup. It can land independently as long as the command reuses the current shared target-resolution behavior that exists at implementation time. + +## Problem Frame +The current `fabro settings` command is purely local. It resolves project/workflow config plus local machine settings and prints the result in YAML or JSON. + +That is no longer the most useful answer for users, because workflows run on a server and the final settings are not purely local. The server already applies its own defaults and runtime adjustments during manifest preparation, so the current output is only a partial picture. + +The goal of this pass is to make `fabro settings` answer two distinct questions clearly: + +- what would the CLI resolve locally without talking to a server? +- what settings will actually be used when this workflow runs on the selected server? + +## Key Decisions +- `fabro settings` becomes server-targeted by default. + - It should fetch server settings and merge them with local config using the same merge logic the server uses for real runs. +- `fabro settings --local` skips the server entirely and preserves the current local inspection behavior. +- `fabro settings` keeps the optional positional `WORKFLOW`. + - With no workflow argument, it shows the effective baseline settings for the current repo/config context. + - With a workflow argument, it shows the effective run settings for that workflow. +- `fabro settings` should support `--server` as the normal one-off server-target override. +- `fabro settings --local` conflicts with `--server`. +- `fabro settings` should no longer take `--storage-dir`. + - Storage targeting is not the purpose of this command. +- Output format stays the same: + - default: YAML `Settings` + - `--json`: JSON `Settings` +- The command should return only the resolved `Settings` object, not extra metadata fields. + - Effective target/config-path diagnostics belong in `doctor` or other output, not in the `settings` payload itself. +- Bare `fabro settings` should use the same server-target resolution behavior as other server-targeted commands at the time this plan lands. + - If the resolved target is a Unix socket, it may use the normal socket/autostart path. + - If the resolved target is an HTTP target and the server is unreachable, the command should fail clearly. + - It should not silently fall back to `--local`. +- The `/api/v1/settings` endpoint should return the full effective runtime `Settings` object from the server. + - The shared merge helper remains responsible for precedence and for preserving the existing distinction between full server defaults and local-daemon-only overrides. + +## Implementation Changes +### 1. CLI surface +- Change `SettingsArgs` in [`lib/crates/fabro-cli/src/args.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-cli/src/args.rs) to: + - add `ServerTargetArgs` + - add `--local` + - keep optional `WORKFLOW` + - remove `StorageDirArgs` +- Update help text and parser tests in [`lib/crates/fabro-cli/tests/it/cmd/config.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-cli/tests/it/cmd/config.rs) accordingly. + +### 2. Shared merge logic +- Extract the server/default merge logic currently embedded in [`lib/crates/fabro-server/src/run_manifest.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-server/src/run_manifest.rs) into a shared helper in `fabro-config` that both the server and CLI can call. +- The helper should live in `fabro-config` because both `fabro-cli` and `fabro-server` already depend on it, while `fabro-cli` should not depend on `fabro-server` merge internals. +- The shared helper should accept: + - the local config layers already resolved by the CLI side + - server settings fetched from the target server + - a mode flag matching current manifest preparation semantics +- The helper must preserve the current distinction between: + - normal remote/server merge behavior + - local-daemon merge behavior +- The helper must make the precedence explicit: + - `fabro settings`: `project + user + server_defaults` + - `fabro settings WORKFLOW`: `workflow + project + user + server_defaults` + - `fabro settings --local`: `project + user` + - `fabro settings --local WORKFLOW`: `workflow + project + user` +- In server-targeted mode, the helper must apply `strip_server_owned_fields` to local layers (workflow, project, user) before combining with server defaults, matching `prepare_manifest_with_mode` semantics. Without this, fields like `exec` or `server` set in a local workflow config would override the server's values, which doesn't match actual run behavior. +- The helper should continue to apply the same server-side rules that exist today: + - normal mode uses the full `server_defaults_layer()` + - local-daemon mode uses `local_daemon_server_overrides_layer()` + - any required post-resolution overrides, such as forcing `storage_dir` from the active server settings, remain in the shared helper rather than being reimplemented by the CLI +- The CLI determines the mode from the resolved `ServerTarget` type: `ServerTarget::UnixSocket` → local-daemon mode, `ServerTarget::HttpUrl` → remote mode. +- The shared helper's scope is layer combination + resolution + post-resolution overrides. `prepare_manifest_with_mode` continues to handle manifest-specific work (parsing workflow bundles, building `args_layer` from `ManifestArgs`, applying `manifest.goal`) and delegates to the shared helper for the combine/resolve step. +- The server should be switched to use the shared helper so `fabro settings` cannot drift from actual run semantics. + +### 3. Server settings source +- Implement the real `/api/v1/settings` route in [`lib/crates/fabro-server/src/server.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-server/src/server.rs), matching the existing OpenAPI contract in [`docs/api-reference/fabro-api.yaml`](/Users/bhelmkamp/p/fabro-sh/fabro/docs/api-reference/fabro-api.yaml). +- The route should return the server’s current effective runtime settings from `AppState`, not just a raw disk parse. +- The route should return the full runtime `Settings` object, not a reduced server-owned subset. +- The CLI settings command should call this route when `--local` is not set. + +### 4. Command behavior +- In [`lib/crates/fabro-cli/src/commands/config/mod.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-cli/src/commands/config/mod.rs): + - preserve the existing local merge path for `--local` + - add a server-targeted path for the default behavior +- Local-only resolution should keep the current semantics: + - no workflow: project config from cwd + local settings + - workflow: workflow config + project config + local settings +- Server-targeted resolution should: + - resolve the server target using the normal shared target-resolution helpers + - fetch server settings from `/api/v1/settings` + - build the same local layers the command already knows how to build + - combine them with fetched server settings using the shared merge helper +- If the server is unreachable: + - Unix socket targets should follow the same socket/autostart behavior normal server-targeted commands use at the time this plan lands + - HTTP targets should fail clearly + - the command should not fall back to local-only mode unless the user explicitly asked for `--local` +- For `WORKFLOW`, the command should not call `preflight` or create a run. + - It should compute and print the resolved settings only. + +### 5. Separation from broader targeting cleanup +- This plan should not block on the larger config/socket/storage refactor. +- If the broader refactor lands first, `fabro settings` should reuse the new target-resolution helpers. +- If it lands first, `fabro settings` should reuse the current `--server` / configured server-target behavior and keep its implementation scoped to this command. +- This plan does not change `exec`, `system df`, `system prune`, or `store dump`. + +## Test Plan +- CLI parser/help coverage in [`lib/crates/fabro-cli/tests/it/cmd/config.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-cli/tests/it/cmd/config.rs): + - `fabro settings --help` shows `--local` and `--server` + - `fabro settings --local --server ...` is rejected + - `--storage-dir` is no longer accepted +- Local behavior tests: + - `fabro settings --local` preserves current merged local output + - `fabro settings --local WORKFLOW` resolves workflow + project + local settings only +- Server-targeted behavior tests: + - `fabro settings` fetches server settings and merges them with local config + - `fabro settings WORKFLOW` merges workflow + project + local + server settings + - `fabro settings --server http://...` uses the explicit target + - socket-targeted settings resolution behaves the same way normal commands do at the time this plan lands + - unreachable HTTP targets fail clearly and do not fall back to local-only output +- Shared merge helper tests: + - `prepare_manifest_with_mode()` delegates to the shared helper for the merge/defaults path + - helper tests directly cover the layer precedence for: + - `project + user` + - `workflow + project + user` + - `project + user + server_defaults` + - `workflow + project + user + server_defaults` + - local-daemon merge semantics remain distinct from remote-server merge semantics where that distinction already exists + - server-owned fields in local layers (workflow, project, user) are stripped before merging in server-targeted mode, matching `prepare_manifest_with_mode` behavior +- Server route tests: + - real `/api/v1/settings` returns the structured settings shape from the OpenAPI contract + - route reflects effective runtime settings, including active storage-dir/runtime overrides + +## Assumptions +- Returning the raw `Settings` payload is sufficient; no new wrapper response is needed for the CLI command. +- Reusing the existing `/api/v1/settings` contract is preferable to adding a second settings-resolution endpoint in this pass. +- `fabro settings WORKFLOW` may perform local workflow/project discovery exactly as the command does today; the only new remote input is the selected server’s settings. +- This pass is command-focused and does not attempt to solve all settings introspection use cases elsewhere in the API. diff --git a/docs/plans/2026-04-06-subprocess-run-workers-signal-control-plan.md b/docs/plans/2026-04-06-subprocess-run-workers-signal-control-plan.md new file mode 100644 index 000000000..b456225bd --- /dev/null +++ b/docs/plans/2026-04-06-subprocess-run-workers-signal-control-plan.md @@ -0,0 +1,237 @@ +# Subprocess Run Workers And Signal Control + +## Summary +- Move workflow execution out of `fabro server` into one hidden worker subprocess per run. +- Use Unix signals for lifecycle control, but keep the server as the sole durable writer for run events, projections, and summaries. +- Stream canonical worker events back to the server over the worker stdout pipe so the event log, SSE, and API status stay coherent without multi-process append races. + +## Scope Boundaries +In scope: +- replace in-process run execution with supervised worker subprocesses +- repurpose hidden `fabro __runner` into hidden `fabro __run-worker` +- use signals for `cancel`, `pause`, and `unpause` +- make the server the only writer to a run's durable event stream +- define a worker spawn contract, stdout/stderr contract, and control priority rules +- add request and effect events for run control +- phase delivery so subprocess cancel lands before pause and procline polish + +Out of scope: +- non-Unix parity for worker supervision +- crash-time worker reattachment in the first delivery +- changing the current public control routes away from `/runs/{id}/cancel`, `/pause`, and `/unpause` +- changing terminal cancellation away from durable `status=failed` with `status_reason=cancelled` + +## Problem Frame +The intended architecture is server-supervised subprocess workers, not server-local async tasks. The current plan also assumed both server and worker could append to the same run event stream, but the current `RunDatabase` writer is process-local: sequence allocation, event cache, and watch fanout are held in per-process memory. That makes multi-process writes and cross-process `watch_events_from()` unsafe as a foundation for this refactor. + +The revised plan therefore needs to solve four things explicitly: + +- control delivery without shared in-process primitives +- durable event ordering without multi-process writers +- server observation of worker state transitions +- clear priority and override rules when multiple control requests arrive + +## Key Decisions +- Hidden worker command + - Rename hidden `fabro __runner` to hidden `fabro __run-worker`. + - `__run-worker` executes a single run locally and does not call back into the server API. +- Single-writer rule + - The server is the only process that appends durable run events, updates run projections, and drives SSE. + - The worker must not open a write-capable `RunDatabase`. + - The worker may open the run store read-only for manifest and checkpoint reads only. +- Worker-to-server event path + - Worker stdout is reserved for newline-delimited canonical `RunEvent` JSON objects. + - The worker builds canonical events once, using the existing event model, and writes them to stdout. + - The server reads stdout, validates each event, appends it to the run store, and fans it out to SSE and any in-process listeners. + - This replaces the current assumption that the worker writes directly to the run store. +- Worker stderr and logs + - Worker stderr is not part of the event stream. + - The server captures worker stderr, writes it to a per-run log file under the run scratch directory, and mirrors lines into server tracing with the run id attached. +- Worker spawn contract + - The server spawns `fabro __run-worker --run-id --mode --storage-dir `. + - If the active config path is required to reproduce local runtime behavior, pass `--config ` as well. + - The worker does not need a server address because it does not talk HTTP to the server. +- Control delivery + - `cancel` sends `SIGTERM` to the worker process. + - After the grace timeout, the server sends `SIGKILL` to the worker process group. + - `pause` sends `SIGUSR1`. + - `unpause` sends `SIGUSR2`. + - Do not use `SIGSTOP` / `SIGCONT` for API pause and unpause. +- Control priority and conflict rules + - Priority is `cancel > pause > unpause`. + - `pending_control` remains a single value and later accepted requests overwrite lower-priority pending requests. + - `cancel` is accepted from `submitted`, `queued`, `starting`, `running`, or `paused`. + - A `cancel` request overwrites a pending `pause` or `unpause`. + - `pause` is accepted only when observed status is `running` and `pending_control` is `null`. + - `unpause` is accepted only when observed status is `paused` and `pending_control` is `null`. + - A lower-priority request while a higher-priority request is pending returns `409`. + - If `pause` is pending and `cancel` arrives before the worker reaches a safe point, the worker must skip `run.paused` and terminate through the normal cancel path. + - If the run is already paused and `cancel` arrives, the worker must exit the pause wait and cancel immediately. +- Signal handling in the worker + - `SIGTERM` requests cooperative cancellation. + - `SIGUSR1` requests cooperative pause. + - `SIGUSR2` requests cooperative unpause. + - Pause takes effect only at safe points such as between stages, before retry sleeps, before entering or resuming human waits, and at existing cancellation checkpoints. +- Event naming + - Use `run.cancel.requested`, `run.pause.requested`, and `run.unpause.requested` for accepted control requests. + - Use `run.paused` and `run.unpaused` for observed worker transitions. + - Keep terminal cancellation on `run.failed` with `reason=cancelled`. + - Avoid `run.resumed` because `resume` already means resume-from-checkpoint elsewhere in the system. +- Restart behavior in the first delivery + - Do not attempt PID-based worker reattachment in the first delivery. + - On graceful server shutdown, terminate active workers before exit. + - On server startup, any non-terminal run with stale worker metadata from a prior server process is marked interrupted or terminated and its worker metadata is cleared. + - Robust crash-time reattachment is deferred to a later phase and must include process identity verification before any signal delivery. +- API shape + - Keep the current control routes and verbs. + - Extend `RunStatusResponse` with `status_reason` and `pending_control`. + - Extend durable `StoreRunSummary` with `pending_control`. + - Control endpoints return the current observed status plus `pending_control`; they do not report the requested action as completed until the worker emits the corresponding effect event. +- Process titles + - Server titles: + - `fabro server boot` + - `fabro server unix:/path/to/socket` + - `fabro server tcp:127.0.0.1:3000` + - `fabro server stopping` + - Worker titles use the existing 12-character short run-id convention: + - `fabro start` + - `fabro resume` + - `fabro init` + - `fabro running` + - `fabro waiting` + - `fabro paused` + - `fabro cancelling` + - `fabro succeeded` + - `fabro failed` + - `fabro cancelled` + +## Delivery Plan +### Phase 1: Subprocess execution and signal cancel +- Spawn one worker subprocess per started run. +- Make the server the sole event-store writer. +- Stream canonical worker events over stdout into the server. +- Route worker stderr into per-run log files and server tracing. +- Implement signal-based `cancel` only. +- Do not support crash-time worker reattachment in this phase. + +### Phase 2: Control request events and API status enrichment +- Add `run.cancel.requested` and `pending_control`. +- Extend `RunStatusResponse` and `StoreRunSummary` with `status_reason` and `pending_control`. +- Update status projections so accepted cancel requests surface immediately without pretending the run has already terminated. + +### Phase 3: Pause and unpause +- Add `SIGUSR1` and `SIGUSR2` handling. +- Add `run.pause.requested`, `run.unpause.requested`, `run.paused`, and `run.unpaused`. +- Implement the priority and overwrite rules defined above. + +### Phase 4: Procline polish and optional crash-time recovery +- Add title helpers and short-id process titles. +- If crash-time worker recovery is still wanted, design it as a separate pass with explicit worker identity verification before any PID-based signaling. + +## Implementation Changes +### 1. Server supervision and worker pipes +- Replace the current `tokio::spawn(execute_run(...))` path in `lib/crates/fabro-server/src/server.rs` with worker subprocess spawning. +- Introduce a server-side supervisor record for live runs that stores: + - observed status + - created time + - local error text + - worker PID + - worker PGID + - worker mode (`start` or `resume`) + - current `pending_control` + - handles for worker stdout and stderr tasks +- On worker spawn: + - set process-group isolation + - capture stdout and stderr + - start one task that parses stdout into canonical `RunEvent`s and appends them to the run store + - start one task that drains stderr into a per-run log file and tracing + - set observed status to `starting` +- On worker exit: + - if the worker already produced a terminal run event, clear live worker metadata only + - if no terminal run event was appended, append a terminal failure with `reason=terminated` + +### 2. Workflow engine event sink refactor +- Refactor run execution so the worker path no longer depends on direct run-store writes from inside `fabro-workflow`. +- Introduce a run-event sink abstraction used by workflow execution, retro, pull-request creation, and any remaining bypass paths that currently append directly to the run store. +- For worker execution, the sink serializes canonical `RunEvent` JSON lines to stdout. +- Preserve the current event-strategy rule that the canonical `RunEvent` is built exactly once and reused for all downstream sinks. + +### 3. Worker execution and safe-point control +- Rewrite `lib/crates/fabro-cli/src/commands/run/runner.rs` into the `__run-worker` entrypoint that: + - loads the run manifest and checkpoint state read-only from storage + - executes `operations::start` or `operations::resume` + - owns the workflow `Emitter` + - translates OS signals into local cooperative control flags +- At safe points: + - if cancel is requested, terminate through the existing cancellation path + - if pause is requested and cancel is not pending, emit `run.paused`, block until unpause or cancel, then emit `run.unpaused` when execution continues +- Make cancel override a paused state immediately. + +### 4. Event model and projection updates +- Extend `fabro-workflow` event types and `fabro-types` `EventBody` with: + - `run.cancel.requested` + - `run.pause.requested` + - `run.unpause.requested` + - `run.paused` + - `run.unpaused` +- Keep request events server-originated and effect events worker-originated. +- Update `lib/crates/fabro-store/src/run_state.rs` so projections: + - track `pending_control` + - do not let request events overwrite observed status + - apply `run.paused` and `run.unpaused` to observed status + - continue projecting terminal cancellation as `status=failed` and `status_reason=cancelled` +- Extend durable `RunSummary` with `pending_control`. + +### 5. API and client updates +- Update `docs/api-reference/fabro-api.yaml` with: + - a new `RunControlAction` schema using `cancel`, `pause`, and `unpause` + - `pending_control` on `RunStatusResponse` + - `status_reason` on `RunStatusResponse` + - `pending_control` on `StoreRunSummary` +- Define control endpoint behavior as: + - validate whether the action is currently allowed + - apply the priority and overwrite rules above + - append the corresponding request event + - update `pending_control` + - send the signal if a live worker exists + - return the current observed status response +- Regenerate both generated API clients after the OpenAPI change. + +### 6. Process title cleanup +- Keep using `fabro_proc::title_init()` and `fabro_proc::title_set()`. +- Add helpers for server title updates by bind and lifecycle phase, and worker title updates by short run id and worker phase. +- Keep procline verification lightweight; do not build brittle exact-string end-to-end assertions around process titles. + +## Test Plan +- Single-writer and event-path tests + - worker execution path opens the run store read-only only + - worker stdout emits valid newline-delimited canonical `RunEvent` payloads + - server appends worker-streamed events in order and SSE reflects the appended stream + - worker stderr is captured into the per-run log file +- Cancel tests + - starting a queued run spawns a worker subprocess and records PID and PGID + - `POST /runs/{id}/cancel` appends `run.cancel.requested`, sets `pending_control=cancel`, sends `SIGTERM`, and later converges to durable `failed` with `status_reason=cancelled` + - cancelling a submitted or queued run reaches durable `failed/cancelled` without spawning a worker + - an unresponsive worker is escalated from worker `SIGTERM` to process-group `SIGKILL` +- Pause and unpause tests + - `POST /runs/{id}/pause` on a running worker appends `run.pause.requested`, sets `pending_control=pause`, and later projects `paused` + - `POST /runs/{id}/unpause` on a paused worker appends `run.unpause.requested`, sets `pending_control=unpause`, and later projects `running` + - pause followed by cancel before a safe point never produces `run.paused` + - cancel while paused exits the pause wait and converges to durable `failed/cancelled` + - lower-priority actions while a higher-priority action is pending return `409` and append no request event +- Startup behavior tests + - graceful server shutdown terminates active workers + - startup clears stale worker metadata and marks prior non-terminal runs interrupted or terminated +- Verification commands + - `cargo nextest run -p fabro-server` + - `cargo nextest run -p fabro-workflow` + - `cargo nextest run -p fabro-store` + - `cargo nextest run -p fabro-cli` + - `cargo fmt --check --all` + - `cargo clippy --workspace -- -D warnings` + +## Assumptions +- This refactor is Unix-first and may explicitly reject or defer non-Unix worker supervision behavior. +- The first delivery optimizes for correct subprocess supervision and durable event ordering, not crash-time worker survival across server restarts. +- `pause` and `unpause` are cooperative safe-point transitions, not immediate OS-level stop and continue. +- The short run id remains the first 12 characters of the ULID, matching current CLI presentation. diff --git a/docs/plans/2026-04-07-fix-attach-terminal-authoritative-stream-plan.md b/docs/plans/2026-04-07-fix-attach-terminal-authoritative-stream-plan.md new file mode 100644 index 000000000..a319307c8 --- /dev/null +++ b/docs/plans/2026-04-07-fix-attach-terminal-authoritative-stream-plan.md @@ -0,0 +1,204 @@ +--- +title: "fix: make attach streams terminal-authoritative" +type: fix +status: active +date: 2026-04-07 +--- + +# fix: make attach streams terminal-authoritative + +## Overview + +Remove `ATTACH_FINAL_STATUS_GRACE` by making `GET /api/v1/runs/{id}/attach` the authoritative source of terminal completion for `fabro attach`. + +After this change: + +- the server streams ordered events starting at `since_seq` +- if a terminal event is reached, the server emits it and closes the stream immediately +- the CLI exits as soon as it sees `run.completed` or `run.failed` +- the CLI no longer waits for quiet time or polls run state after attach completes + +## Problem Frame + +Current behavior is split across the server and CLI in a way that creates a completion race: + +- the server treats `/attach` as a live-only event tail and returns `410 Gone` when the run is no longer live +- the CLI lists historical events first, then opens `/attach?since_seq=next_seq` +- if the run finishes between those two steps, the CLI can miss the terminal event and has to rely on `ATTACH_FINAL_STATUS_GRACE` +- even when the CLI does receive a terminal event, the server-side live stream does not close on that event, so the CLI waits for a grace period before deciding the stream is done + +The desired contract is simpler: `attach` should trust the stream. If the stream ends before a terminal event, that is an error, not a case for client-side repair logic. + +## Requirements Trace + +- R1. `GET /api/v1/runs/{id}/attach` must serve ordered events beginning at `since_seq`, whether the run is still live or already terminal. +- R2. If the stream reaches `run.completed` or `run.failed`, the server must emit that event and close the stream immediately. +- R3. `fabro attach` must derive completion and exit status from streamed events, not from grace periods or follow-up state polling. +- R4. `GET /api/v1/runs/{id}/attach` must stop returning `410 Gone` for completed runs. +- R5. The existing bare-attach API behavior when `since_seq` is omitted remains unchanged: the endpoint starts at the current tail, so attaching after a completed run with no unread events may produce an empty stream that closes immediately. +- R6. The CLI must not interpret R5 as a protocol success path. `fabro attach` still short-circuits terminal runs via its initial state-and-history replay, so its premature-EOF error handling applies only to the live-stream path entered after the initial terminal check. +- R7. The run-specific SSE attach stream must send keepalives during idle periods so long-running quiet stages do not surface as transport EOFs. +- R8. Interview handling and Ctrl-C detach behavior remain unchanged. + +## Key Technical Decisions + +- The server owns the completion contract. + The CLI stays simple and exits immediately on terminal events. + +- Premature EOF is a protocol error. + If the live attach stream ends before a terminal event is observed, `fabro attach` exits non-zero with a clear error instead of performing a repair lookup. This does not conflict with R5 because the CLI does not enter the live-stream path for already-terminal runs. + +- `/attach` becomes replay-capable for completed runs. + The endpoint no longer uses “run is live” as a gate for whether unread events can be served. + +- No new durable storage primitive is required, but the live-watch helper must be hardened. + `open_run_reader()` already returns the shared active `RunDatabase` for live runs, but the current `watch_events_from()` handoff is not strong enough for this contract because it snapshots recent events before subscribing. The implementation must make the replay-to-watch transition seq-complete. + +- Keepalive is in scope for run-specific attach. + The new fail-fast EOF contract is only acceptable if the run-specific SSE stream gets the same idle keepalive treatment as the existing global attach endpoint. + +## Public API / Interface Changes + +- Update `docs/api-reference/fabro-api.yaml` for `GET /api/v1/runs/{id}/attach`: + - change the description from “live run” SSE to “ordered event stream starting at `since_seq`, replaying persisted events and continuing with live updates while the run remains active” + - remove the `410 Run is not live on this server` response + +- Regenerate generated clients after the spec update: + - `cargo build -p fabro-api` + - `cd lib/packages/fabro-api-client && bun run generate` + +- Remove the CLI’s `RunAttachStreamError::Gone` branch from the Rust server client. + +## Implementation Units + +### [ ] Unit 1: Make server attach replay terminal-safe + +**Goal** + +Change the server attach endpoint so it can always deliver unread terminal events and self-close on terminal completion. + +**Files** + +- `lib/crates/fabro-server/src/server.rs` +- `lib/crates/fabro-store/src/slate/run_store.rs` +- `docs/api-reference/fabro-api.yaml` + +**Approach** + +- Replace the current “live run only” guard in `attach_run_events()`. +- Open the run reader first and return `404` only when the run truly does not exist. +- Compute `start_seq` exactly as today. +- Replay persisted events from `start_seq` using `list_events_from_with_limit` in bounded batches, converting them through the existing `sse_event_from_store()` helper. +- Detect terminal events during replay. If replay emits `run.completed` or `run.failed`, close the SSE stream immediately after that event. +- Before using live watch for attach, harden `watch_events_from()` so it is seq-complete for handoff: + - subscribe to `event_tx` before snapshotting `recent_events` + - snapshot and emit cached events at or after the requested seq + - then drain broadcast events, discarding any seq lower than the next expected seq + This closes the snapshot-to-subscribe gap that exists in the current helper. +- If replay reaches the current tail without hitting a terminal event and the run is still active, switch to the hardened `watch_events_from(next_seq)` on the shared `RunDatabase`. +- In the live phase, stream events until the first terminal event, then close immediately. +- Add `.keep_alive(KeepAlive::default())` to the run-specific SSE response so idle stages do not turn into transport EOFs. + +**Patterns to follow** + +- Existing event serialization helpers in `lib/crates/fabro-server/src/server.rs` +- Existing event listing behavior in `list_run_events()` +- Global attach keepalive behavior in `lib/crates/fabro-server/src/server.rs` +- Updated `watch_events_from()` semantics in `lib/crates/fabro-store/src/slate/run_store.rs` + +**Test scenarios** + +- Attach to a live run and confirm the stream includes stage events, then a terminal event, then EOF. +- Attach after the run has already completed with `since_seq` before the terminal event and confirm replay includes the terminal event, then EOF. +- Attach after completion with `since_seq` after the last event and confirm the stream returns `200` and closes cleanly with no events. +- Attach during the race where the run completes after the client reads history but before it opens `/attach`, and confirm the terminal event is still delivered. + Use a barrier or oneshot to hold terminal completion until after the history read returns and release it before the `/attach` request starts, so the race is deterministic rather than sleep-based. +- Attach to a nonexistent run and confirm `404` remains unchanged. +- Attach to a run with a deliberately quiet stage and confirm the SSE response stays open via keepalive until later events arrive. + +**Verification** + +- Server tests prove `200` is returned for completed runs with unread events. +- Server tests prove the stream terminates immediately after a terminal event. + +### [ ] Unit 2: Simplify CLI attach around terminal events + +**Goal** + +Remove timer-based completion handling from `fabro attach` and make the command trust streamed terminal events. + +**Files** + +- `lib/crates/fabro-cli/src/commands/run/attach.rs` +- `lib/crates/fabro-cli/src/server_client.rs` + +**Approach** + +- Delete `ATTACH_FINAL_STATUS_GRACE`. +- Delete `determine_exit_code_with_server()`. +- Remove `RunAttachStreamError::Gone` and its special handling. +- Keep the initial replay path: if the initial event list or run state already shows the run is terminal, replay and exit as today. +- In the live attach path, emit events and return immediately when `event_exit_code()` sees `run.completed` or `run.failed`. +- If the live attach stream ends before a terminal event is observed, return a non-zero protocol error instead of polling server state. +- Document in code that this EOF rule applies only after the command has already ruled out the R5 completed-run replay case via the initial terminal check. +- Leave interview prompting and Ctrl-C behavior unchanged. + +**Patterns to follow** + +- Existing `event_exit_code()` extraction in `lib/crates/fabro-cli/src/commands/run/attach.rs` +- Existing replay behavior in `replay_run_with_client()` + +**Test scenarios** + +- Successful attach on a live run exits `0` immediately after `run.completed`. +- Failed attach on a live run exits `1` immediately after `run.failed`. +- Completed run replay still exits with the correct code without opening a live stream. +- Premature EOF before any terminal event produces a non-zero exit and clear error text. + +**Verification** + +- CLI tests no longer depend on grace-period timing. +- No attach code path polls run state after a live stream finishes. + +### [ ] Unit 3: Align tests, demo behavior, and generated API artifacts + +**Goal** + +Update repo expectations so they match the new terminal-authoritative attach contract. + +**Files** + +- `lib/crates/fabro-server/tests/it/scenario/run_completion.rs` +- `lib/crates/fabro-server/tests/it/scenario/sse.rs` +- `lib/crates/fabro-cli/tests/it/cmd/attach.rs` +- `lib/crates/fabro-server/src/demo/mod.rs` +- generated Rust and TypeScript API artifacts + +**Approach** + +- Replace current `200 or 410` attach assertions with `200`-only expectations where applicable. +- Update the server unit/integration test near the current `StatusCode::GONE` assertion to verify replay-and-close semantics instead. +- Update the demo attach stub to return a short SSE response that ends cleanly, rather than `410`. +- Regenerate Rust and TypeScript clients after the OpenAPI change. + +**Test scenarios** + +- Server scenario tests verify completed-run attach no longer returns `410`. +- CLI mock-server attach tests verify the command exits from streamed terminal events rather than fallback logic. +- Demo-mode attach still behaves coherently for callers expecting an attach response. + +**Verification** + +- Spec, generated clients, and tests all describe the same `attach` contract. + +## Test Plan + +- `cargo nextest run -p fabro-server` +- `cargo nextest run -p fabro-cli` +- Target the attach-specific server and CLI tests first while iterating, then run the crate suites before landing. + +## Assumptions + +- `fabro logs` and `FOLLOW_TERMINAL_GRACE` are out of scope for this change. +- Every valid terminal run should emit a persisted terminal event (`run.completed` or `run.failed`); any gap found during implementation should be treated as a server bug to fix, not a reason to reintroduce client grace timing. +- The user preference for attach simplicity is authoritative: premature EOF is an error, not a repairable condition. diff --git a/docs/plans/2026-04-07-global-cas-blob-refs-plan.md b/docs/plans/2026-04-07-global-cas-blob-refs-plan.md new file mode 100644 index 000000000..417a0f2e8 --- /dev/null +++ b/docs/plans/2026-04-07-global-cas-blob-refs-plan.md @@ -0,0 +1,209 @@ +# Global CAS Blob Refs Plan + +## Summary + +Replace durable offload pointers with global content-addressed blob refs and keep file paths as an execution-only concern. + +- Automatic large-value offload should persist `blob://sha256/` refs instead of `file://...` paths. +- Blob bytes should be stored in a global CAS namespace rather than under per-run keys. +- Handlers and preamble generation should continue to see file references, but those files should be materialized only in the execution-local environment. +- Host scratch blob cache files should stop being part of the durable contract. +- Garbage collection is explicitly deferred in this pass. + +## Problem Frame + +Large context values are currently offloaded by writing the serialized JSON bytes to the run store, materializing a host-side cache file, and replacing the original value with a `file://` pointer to that file. + +That shape creates the wrong durable boundary: + +- persisted checkpoints and checkpoint-completed events contain host- or sandbox-specific file paths instead of stable storage references +- resume seeds those path strings back into runtime state verbatim +- remote sandbox sync rewrites durable context into sandbox-local `file://` paths +- fork semantics are awkward because the same logical content becomes tied to a specific run and a specific materialized file path + +The durable source of truth should be the blob bytes addressed by content hash. File paths should only exist as a temporary execution detail for agents and command handlers. + +## Key Decisions + +- Use `blob://sha256/` as the only new durable blob reference format. + - `RunBlobId` remains the existing SHA-256 hex content hash type in this pass. + - Durable run state, checkpoints, and checkpoint-completed event payloads should persist only plain JSON values and `blob://` refs. + +- Make blob storage global CAS instead of run-scoped. + - Internally, blob bytes move from per-run keys to global keys such as `blobs#sha256#`. + - Existing run-scoped server endpoints can remain as the API surface for now, but they should read and write the global CAS store under the hood. + +- Keep blob handling invisible to the model. + - The model should continue to receive file references in prompts and preambles. + - `blob://` is a backend durability protocol, not a model-facing protocol. + +- Materialize blobs only in execution-local views. + - Before handler execution and before preamble construction, resolve `blob://` refs into local files for the active sandbox. + - For remote sandboxes, materialize under `{working_directory}/.fabro/blobs/.json`. + - For local execution, materialize under a run-local ephemeral runtime directory such as `runtime/blobs/.json`. + +- Do not persist execution-local `file://` refs back into durable state. + - Managed materialized blob file refs must be normalized back to `blob://sha256/` before context snapshots are emitted or checkpointed. + +- Preserve compatibility with older runs. + - Read paths should continue to recognize legacy blob-backed `file://.../.json` values. + - New writes should use only the `blob://` form. + +- Leave stage artifacts unchanged. + - This change applies only to offloaded run blobs. + - `ArtifactStore` remains the durable system for captured stage artifacts. + +- Defer GC. + - New blob writes are append-only in this pass. + - No mark-and-sweep, refcounting, or retention enforcement is included here. + +## Implementation Changes + +### 1. Blob Storage And Blob Ref Helpers + +- Keep `RunBlobId` unchanged in `lib/crates/fabro-types/src/run_blob_id.rs`. +- Add a shared blob-ref helper module in `fabro-workflow` or `fabro-types` that: + - formats `blob://sha256/` + - parses `blob://sha256/` + - recognizes legacy blob-backed `file://.../.json` + - extracts blob ids from managed materialized blob file paths +- Change `fabro-store` blob key construction from `blobs#{run_id}#{blob_id}` to a global key layout such as `blobs#sha256#`. +- Update `RunDatabase::write_blob` and `RunDatabase::read_blob` to operate on the global CAS namespace. +- Remove blob enumeration from the main architecture path. `list_blobs` should not be part of new feature work; if retained temporarily, it should be treated as legacy/debug-only. + +### 2. Automatic Offload + +- In `lib/crates/fabro-workflow/src/artifact.rs`, change `offload_large_values` so that it: + - serializes the JSON value + - writes the bytes to CAS through the existing run-store handle + - replaces the value with `blob://sha256/` + - does not write a host-side cache file +- Remove the current assumption that `cache/artifacts/values/{blob_id}.json` is part of the durable contract. +- Keep the offload threshold unchanged at 100KB in this pass. + +### 3. Execution-Time Materialization + +- Replace `sync_artifacts_to_env` with an execution-time blob materialization flow that handles both: + - new `blob://` refs + - existing explicit or legacy `file://` refs +- Split this into two responsibilities: + - blob resolution and materialization for managed blob refs + - existing file-copy behavior for explicit `file://` refs that are not blob-backed +- Materialization behavior: + - read the blob bytes from CAS + - write the JSON bytes into a sandbox-usable file path + - return a rewritten execution-local value using `file://` +- Managed materialized paths should use a deterministic layout based on blob id so repeated refs dedupe naturally within an execution. + +### 4. Context And Durable Snapshot Boundaries + +- Introduce a clear split between: + - durable context values + - execution-local resolved context values +- Before handler execution, create a resolved execution view where `blob://` refs are rewritten to materialized `file://` refs. +- After handler execution, normalize any managed materialized blob file refs in handler-produced context changes back to `blob://sha256/`. +- Compatibility reads should normalize legacy blob-backed `file://.../.json` values to `blob://sha256/` in memory before they enter new durable snapshots. +- Update checkpoint creation and checkpoint-completed event emission so they snapshot only durable values, never execution-local materialized paths. +- Treat `current.preamble` as runtime-only derived state and exclude it from persisted context snapshots. This prevents preamble strings containing execution-local file paths from leaking into checkpoints or event payloads. + +### 5. Preamble And Handler Execution + +- Keep blobs invisible to the model. +- Before fidelity builds `current.preamble`, resolve completed-stage outcome values and current context into an execution-local view that contains file refs, not blob refs. +- `build_preamble` should continue to work with file references and should not mention `blob://` or “blobs” in user/model-facing output. +- Prompt and agent handlers should continue to consume `context.preamble()` and file references exactly as they do now. +- The only new behavior for handlers should be that the file refs they receive come from execution-time materialization rather than from durable checkpoint state. + +### 6. Resume And Fork Behavior + +- Resume should seed durable `blob://` values from checkpoint state and let the next execution hop materialize them as needed. +- Legacy checkpoints containing blob-backed `file://.../.json` values should be normalized on read so resumed runs persist the new `blob://` form on the next checkpoint. +- Fork should copy checkpoint and run state without copying blob payloads. +- Child runs should retain the same `blob://sha256/` refs as the source run. + +### 7. CLI And Export Behavior + +- Update CLI final-output rendering so when `response.*` is a blob ref it resolves the blob through the run-store read path before printing markdown. +- Keep explicit non-blob file refs as plain file references in CLI output. +- Refactor `store dump` to become reference-driven for blobs: + - scan exported JSON structures for blob refs + - fetch only referenced blobs + - hydrate them inline in exported JSON + - stop emitting a top-level `blobs/` directory +- Preserve the current export layout for run metadata, nodes, retro output, checkpoints, events, and stage artifacts. + +### 8. Server And API Surface + +- Keep the existing run-scoped blob routes: + - `POST /api/v1/runs/{id}/blobs` + - `GET /api/v1/runs/{id}/blobs/{blobId}` +- Change their implementation to use global CAS storage internally. +- Do not add public blob enumeration or global blob-fetch routes in this pass. +- Do not add GC or blob-membership verification to the API contract in this pass. + +## Test Plan + +### Blob Ref Helpers + +- parse and format `blob://sha256/` +- recognize legacy blob-backed `file://.../.json` +- reject ordinary non-blob `file://` refs +- normalize managed materialized blob file refs back to blob refs + +### Offload And Persistence + +- large values are replaced with `blob://sha256/` +- offload writes the blob bytes to CAS +- offload no longer creates a host scratch cache file +- small values remain inline +- checkpoint and checkpoint-completed payloads persist `blob://` refs, not `file://` +- `current.preamble` is excluded from persisted context snapshots + +### Execution Materialization + +- local execution materializes `blob://` refs to local runtime files +- remote execution materializes `blob://` refs to sandbox files under `.fabro/blobs/` +- preamble generation receives file refs and does not expose `blob://` +- handlers receive file refs and can read them normally +- explicit non-blob `file://` refs keep their existing remote-copy behavior + +### Normalization And Compatibility + +- handler-produced managed materialized blob file refs are normalized back to `blob://` before checkpointing +- legacy blob-backed `file://.../.json` values are normalized to `blob://` on resume +- ordinary explicit `file://` refs are preserved as file refs +- resumed runs re-checkpoint using only the new `blob://` form + +### Fork, CLI, And Export + +- forked runs reuse the same `blob://sha256/` refs with no blob copy +- CLI final-output rendering resolves blob-backed final responses +- `store dump` hydrates referenced blobs inline +- `store dump` emits no top-level `blobs/` directory + +## Important Files + +- `lib/crates/fabro-workflow/src/artifact.rs` +- `lib/crates/fabro-workflow/src/lifecycle/artifact.rs` +- `lib/crates/fabro-workflow/src/lifecycle/fidelity.rs` +- `lib/crates/fabro-workflow/src/node_handler.rs` +- `lib/crates/fabro-workflow/src/handler/llm/preamble.rs` +- `lib/crates/fabro-workflow/src/pipeline/execute.rs` +- `lib/crates/fabro-workflow/src/records/checkpoint.rs` +- `lib/crates/fabro-workflow/src/lifecycle/event.rs` +- `lib/crates/fabro-store/src/keys.rs` +- `lib/crates/fabro-store/src/slate/run_store.rs` +- `lib/crates/fabro-server/src/server.rs` +- `lib/crates/fabro-cli/src/server_client.rs` +- `lib/crates/fabro-cli/src/commands/run/output.rs` +- `lib/crates/fabro-workflow/src/run_dump.rs` +- `docs/execution/context.mdx` +- `docs/agents/outputs.mdx` + +## Assumptions And Defaults + +- The durable blob ref format for this pass is `blob://sha256/`. +- `RunBlobId` remains the current type name even though blobs are no longer run-scoped. +- Global CAS uses the existing durable key-value store rather than introducing a new blob backend. +- Existing run-scoped blob HTTP routes remain the only supported transport surface in this pass. +- Blob lifecycle management and GC are explicitly deferred. diff --git a/docs/plans/2026-04-07-interview-control-channel-and-server-slack-plan.md b/docs/plans/2026-04-07-interview-control-channel-and-server-slack-plan.md new file mode 100644 index 000000000..b83494c22 --- /dev/null +++ b/docs/plans/2026-04-07-interview-control-channel-and-server-slack-plan.md @@ -0,0 +1,390 @@ +--- +title: "feat: unify interview handling around projected questions, worker stdin, and server-owned Slack" +type: feat +status: active +date: 2026-04-07 +--- + +# feat: unify interview handling around projected questions, worker stdin, and server-owned Slack + +## Overview + +Replace the current split interview architecture with one canonical model: + +- pending interviews live in the durable run projection +- answers enter through the server +- active runs receive accepted answers through a live control sink +- Slack becomes a server-owned delivery surface, not a special in-memory interviewer path + +This refactor removes `FileInterviewer` and `WebInterviewer`, keeps the existing HTTP question and answer routes as the canonical external contract, and uses worker `stdin` JSONL for server-to-worker answer delivery. + +## Problem Frame + +Interview handling is currently split across two transport-specific implementations: + +- subprocess runs use `FileInterviewer`, with `interview_request.json` and `interview_response.json` scratch files +- the in-process server override path uses `WebInterviewer`, with pending questions and answer waiters held only in memory + +That split leaks into the server: + +- `GET /runs/{id}/questions` branches between `WebInterviewer.pending_questions()` and the request scratch file +- `POST /runs/{id}/questions/{qid}/answer` branches between `WebInterviewer.submit_answer()` and the response scratch file +- Slack is built on the same special `WebInterviewer` path instead of the canonical server question API + +The result is one feature implemented twice, with two incompatible sources of truth for pending questions and two different answer delivery mechanisms. + +The desired architecture is simpler: + +- the durable run event stream and run projection are authoritative for pending interviews +- the server owns answer validation and first-answer-wins behavior +- subprocess workers receive accepted answers over `stdin`, which also establishes the future control plane for steering +- Slack is just another server-owned client of the canonical answer path + +## Scope Boundaries + +In scope: + +- add stable question ids and persist pending interviews in the run projection +- replace scratch-file answer transport with worker `stdin` JSONL +- replace `WebInterviewer` with the same brokered answer model used by subprocess runs +- move Slack onto a single global Socket Mode listener inside `fabro-server` +- keep the existing HTTP question and answer routes as the canonical external API +- expand the question API payload to expose the information needed by CLI and Slack + +Out of scope: + +- worker-side steering behavior beyond reserving the `stdin` protocol shape for it +- server restart rehydration of Slack delivery state +- a separate Slack bridge process +- signed Slack tokens or tamper-evident action payloads +- any new public answer route or worker-side HTTP contract + +## Requirements Trace + +- R1. Pending interview state must be derived from durable run events and exposed from the run projection, not from scratch files or live interviewer objects. +- R2. Every interview question must have a stable `question_id` that survives through events, HTTP APIs, Slack payloads, and worker answer delivery. +- R3. `GET /runs/{id}/questions` must read only from canonical projected pending interview state. +- R4. `POST /runs/{id}/questions/{qid}/answer` must remain the canonical answer ingress for web, CLI, and Slack. +- R5. For subprocess runs, the server must deliver accepted answers to the worker over `stdin` as versioned JSONL. +- R6. For the in-process `registry_factory_override` path, the server must still support interview-driven tests without `WebInterviewer`. +- R7. `FileInterviewer` and `WebInterviewer` must be removed from production use and then deleted. +- R8. Slack must be owned by `fabro-server` as a single global Socket Mode listener, not by per-run workers and not by a separate bridge process. +- R9. Slack delivery state may be memory-only. After server restart, stale Slack prompts may be ignored and resumed runs may post fresh prompts under the new server config. +- R10. Slack payloads must carry `run_id` and `qid`, and Slack answers must route through the same canonical server answer handler as HTTP. +- R11. Multiple-choice and multi-select answers must remain structured end-to-end and must not be flattened to text in the Slack path. +- R12. Accepted answers must use first-answer-wins semantics across concurrent HTTP and Slack submissions. + +## Key Decisions + +- `Question` gains a first-class `id: String`. + - Generate it in the human handler as a ULID string. + - Do not hide it in `metadata`. + - Do not add a new `QuestionId` wrapper type in this pass. + +- `interview.started` becomes the authoritative pending-question event. + - Keep `question` as the human-readable text field. + - Add `question_id`, `stage`, `question_type`, `options`, `allow_freeform`, `timeout_seconds`, and `context_display`. + +- `interview.completed` and `interview.timeout` gain `question_id`. + +- Add `interview.interrupted`. + - Carry `question_id`, `question`, `stage`, `reason`, and `duration_ms`. + - Use it when the interviewer returns `Interrupted`, while `Skipped` flows through `interview.completed`, so pending interview cleanup stays event-driven. + +- `RunProjection` becomes the only source of truth for pending interviews. + - Add `pending_interviews: BTreeMap`. + - Insert on `interview.started`. + - Remove on `interview.completed`, `interview.timeout`, `interview.interrupted`, `run.rewound`, and terminal run events. + +- Keep the existing answer route contract. + - `GET /runs/{id}/questions` and `POST /runs/{id}/questions/{qid}/answer` remain the public surface. + - `SubmitAnswerRequest` already supports freeform, single-select, and multi-select and does not need to change. + - Expand `ApiQuestion` with `stage`, `timeout_seconds`, and `context_display`. + +- Server-to-worker answer delivery uses `stdin` JSONL, versioned from day one. + - Use one JSON object per line. + - Implement only `interview.answer` in this pass. + - Reserve the envelope for future steering, but do not implement steering behavior yet. + +- Use a broker-backed interviewer abstraction for both runtime paths. + - Replace `FileInterviewer` and `WebInterviewer` with an internal `InterviewBroker` plus `ControlInterviewer`. + - For subprocess runs, the broker is fed by parsed `stdin` control messages. + - For the in-process override path, the broker is fed directly by the server’s canonical answer submission service. + +- Slack is a server-owned delivery surface, not a worker concern. + - Start one Socket Mode listener inside `fabro-server` when both Slack tokens and `slack.default_channel` are configured. + - Have that service consume the server's existing global run-event broadcast, the same fanout fed by `forward_run_events_to_global(...)` for both subprocess and in-process runs. + - Do not read from SSE endpoints or introduce a second event source. + - Keep Slack message metadata and thread routing in server memory only. + - Do not replay or restore Slack posts after restart. + +- Slack action payloads use plain JSON in `value`. + - Carry at least `run_id`, `qid`, and answer metadata. + - Keep `action_id` structural rather than encoding identifiers into it. + - Validate all incoming Slack answers against the current projected pending interview before accepting them. + +- `ControlInterviewer` is the runtime `Interviewer` implementation. + - `inform()` remains a no-op. + - `ask_multiple()` continues to use the trait default, so multiple concurrent `ask()` calls must work correctly when keyed by `qid`. + +- `InterviewBroker` owns only live waiter state. + - It does not own canonical pending-question state; `RunProjection.pending_interviews` does. + - Its server/transport injection surface is `submit(qid, answer) -> Result<(), SubmitError>`. + - Unknown, already-resolved, or duplicate `qid`s are rejected by the broker. + +- Event replay must stay backward compatible. + - New interview event fields must deserialize with defaults so old `progress.jsonl` entries still replay. + - Historical `interview.started` events that lack `question_id` must not populate `pending_interviews`. + - Historical runs continue to replay normally, but projection-backed pending interview reconstruction is only guaranteed for runs created after this change. + +- The old file-claim reattach window is intentionally removed. + - Client detach and reattach no longer matter because the pending question is durable in the projection and can be fetched again later. + - Only loss of the live server-worker control channel is treated as fatal for the waiting interview. + +- First-answer-wins is enforced in memory before transport delivery. + - Projection lookup proves the question is still pending. + - A per-run acceptance guard must then claim `qid` exactly once before any answer is sent to the worker. + - If transport delivery fails, that claim is released so a later submission can retry. + - If transport delivery succeeds, the claim stays until the interview lifecycle event clears the pending question. + +## Recommended Delivery Order + +Recommended sequencing: + +- Phase A: steps 1 and 2 together + - lock the question id, event shape, replay compatibility, projection model, and API question shape first + +- Phase B: steps 3, 4, and 5 together + - implement `InterviewBroker`, `ControlInterviewer`, canonical answer submission, subprocess `stdin` transport, in-process override migration, and CLI attach adjustments in one pass + - steps 3 and 4 should land together because they both restructure the live answer transport in `server.rs` + +- Phase C: step 6 + - migrate Slack after the canonical pending-question model and answer submission path are stable + +- Phase D: step 7 + - remove dead implementations, examples, and outdated docs only after the new path is covered by tests + +Parallelizable work after Phase A is in place: + +- worker-side `ControlInterviewer` and JSONL parsing +- CLI `ApiQuestion` conversion updates +- Slack payload and parsing refactor + +## Implementation Changes + +### 1. Canonical question ids and interview events + +Update the workflow and event model so the pending interview can be reconstructed without a live transport object. + +- Modify `lib/crates/fabro-interview/src/lib.rs`: + - add `Question.id` + - keep `metadata` and `default` as internal-only fields + - export the new broker-backed interviewer implementation + +- Modify `lib/crates/fabro-workflow/src/handler/human.rs`: + - generate `question.id` + - emit the richer `interview.started` + - emit `interview.interrupted` for `Interrupted` + - emit `interview.completed` for `Skipped` + - emit `question_id` on `interview.completed` and `interview.timeout` + +- Modify `lib/crates/fabro-workflow/src/event.rs` and `lib/crates/fabro-types/src/run_event/misc.rs`: + - extend the interview event variants and payload props to carry the full pending-question surface + - keep event names stable except for adding `interview.interrupted` + +### 2. Run projection and API question surface + +Update the store projection and public question API to surface the canonical pending interview state. + +- Modify `lib/crates/fabro-store/src/run_state.rs`: + - add `pending_interviews` + - add `PendingInterviewRecord` + - apply insert and cleanup rules for all interview lifecycle events + +- Modify `docs/api-reference/fabro-api.yaml`: + - expand `ApiQuestion` with `stage`, `timeout_seconds`, and `context_display` + - leave `SubmitAnswerRequest` unchanged + +- Regenerate generated API clients: + - Rust via `cargo build -p fabro-api` + - TypeScript via `cd lib/packages/fabro-api-client && bun run generate` + +### 3. Replace file transport with worker `stdin` JSONL + +Replace scratch-file answer delivery with a live control channel into the worker. + +- Add `InterviewBroker` and `ControlInterviewer` in `lib/crates/fabro-interview/src/lib.rs` and supporting modules: + - `ControlInterviewer` implements `Interviewer` + - `ControlInterviewer::ask(question)` registers a oneshot waiter under `question.id` and awaits broker delivery + - `InterviewBroker` owns only live waiter state, such as `HashMap>` + - `InterviewBroker::submit(qid, answer)` resolves a waiter exactly once and returns a typed error for unknown or already-resolved questions + - `inform()` remains a no-op + - `ask_multiple()` continues to use the trait default and is supported by allowing concurrent per-`qid` waiters + +- Modify `lib/crates/fabro-server/src/server.rs`: + - spawn workers with `stdin(Stdio::piped())` + - store a per-run live answer transport instead of a `WebInterviewer` + - use a bounded per-run `mpsc::Sender` feeding a dedicated JSONL pump into worker stdin + - enqueue control messages with a short timeout; if enqueue times out or the channel is closed, clear any acceptance claim and return a transient server error rather than hanging the request + - run a JSONL pump that writes accepted answers into the worker stdin pipe + - replace the current `get_questions` and `submit_answer` branches with one projection-backed question query plus one internal answer-submission service + - implement canonical answer submission in this order: + - load the pending question from the projection + - validate and build the `Answer` + - acquire the per-run acceptance guard and claim `qid` exactly once + - submit the answer to the live transport + - release the claim on transport failure + - keep the claim until interview completion, timeout, or abort clears the pending question + - reject a second concurrent submission with `409` once the acceptance guard says the question was already claimed + +- Modify `lib/crates/fabro-cli/src/commands/run/runner.rs`: + - replace `FileInterviewer` with `ControlInterviewer` + - start a `stdin` reader task that parses versioned JSONL `WorkerControl` messages + - resolve broker waiters by `qid` + - treat unexpected control-channel closure as interviewer abort/failure rather than hanging forever + - keep a dedicated stdin reader task running for the life of the worker so answer messages do not block behind unrelated stage execution + +- Define a wire-specific answer payload. + - Do not serialize internal `Answer` directly on the wire. + - Use an explicit `kind` shape such as `yes`, `no`, `text`, `selected`, and `multi_selected`. + +- Remove scratch-file interview transport. + - delete the request and response file helpers from the server + - stop creating or reading `interview_request.json` and `interview_response.json` for server-managed runs + - intentionally remove the old claim-file reattach window because pending questions are now projection-backed and re-fetchable after client reconnect + +### 4. Replace `WebInterviewer` in the in-process override path + +Keep the test override path, but move it onto the same architecture as subprocess runs. + +- Modify `lib/crates/fabro-server/src/server.rs`: + - replace `ManagedRun.interviewer` with a live answer transport enum such as: + - subprocess `stdin` control sender + - in-process broker handle + - keep `create_app_state_with_registry_factory(...)` and `execute_run_in_process(...)` + - pass a broker-backed `Arc` into the override registry instead of `WebInterviewer` + +- Keep the external behavior unchanged for tests. + - `GET /runs/{id}/questions` still lists pending questions + - `POST /runs/{id}/questions/{qid}/answer` still satisfies waiting in-process interview gates + - the difference is that both now go through the projection and canonical submission service + +### 5. Keep CLI attach on the canonical server question path + +Do not make attach parse the event payload into the prompt directly in this pass. + +- Keep `lib/crates/fabro-cli/src/commands/run/attach.rs` event-driven behavior: + - `interview.started` remains the trigger + - attach fetches pending questions from the server + - attach prompts locally with `ConsoleInterviewer` + - attach submits answers through the existing server client + +- Update CLI question conversion for any new `ApiQuestion` fields that should be shown, especially `context_display`. + +### 6. Move Slack onto server-owned canonical answer handling + +Replace the current `WebInterviewer`-centric Slack path with a server-owned integration. + +- Add server-side Slack wiring in `lib/crates/fabro-server/src/server.rs`: + - create a single Slack service on startup when both tokens and `slack.default_channel` are configured + - subscribe it to the existing `state.global_event_tx` broadcast and filter `interview.started`, `interview.completed`, `interview.timeout`, and `interview.interrupted` + - post a Slack message for each fresh pending interview + - use completion, timeout, and abort events for best-effort Slack message updates while the in-memory message metadata still exists + +- Refactor `lib/crates/fabro-slack`: + - remove `WebInterviewer` dependencies from the Socket Mode connection path + - change parsed interactions to carry `run_id` and `qid` + - use structural `action_id` values and JSON `value` payloads + - preserve structured multiple-choice and multi-select answers + - change freeform thread routing from `thread_ts -> question_id` to `thread_ts -> (run_id, qid)` + +- Keep Slack delivery state memory-only inside the server. + - store `(run_id, qid) -> posted message metadata` + - store `thread_ts -> (run_id, qid)` + - after restart, do not rehydrate these maps + - reject or ignore stale Slack interactions that no longer match a live pending interview + +- Route Slack answers through the canonical internal answer-submission service. + - Slack must not call interviewer objects directly + - if the answer is accepted, update the original Slack message when metadata is still present + +### 7. Remove obsolete interview implementations and docs + +After replacement coverage is in place: + +- delete `lib/crates/fabro-interview/src/file.rs` +- delete `lib/crates/fabro-interview/src/web.rs` +- remove their re-exports from `lib/crates/fabro-interview/src/lib.rs` +- delete or rewrite `lib/crates/fabro-slack/examples/slack_e2e.rs` +- update `docs/integrations/slack.mdx` so it describes the new server-owned projected-question architecture instead of the old web interviewer model + +## Public Interface Changes + +- `ApiQuestion` adds: + - `stage` + - `timeout_seconds` + - `context_display` + +- `SubmitAnswerRequest` does not change. + +- Run events change as follows: + - `interview.started` carries the full pending-question payload + - `interview.completed` gains `question_id` + - `interview.timeout` gains `question_id` + - `interview.interrupted` is new + +- Slack interactive payloads change from `question_id`-only routing to explicit `run_id + qid` routing in the action value payload. + +## Test Plan + +- `fabro-store` + - add projection tests for pending interview insert and cleanup + - cover `completed`, `timeout`, `interrupted`, rewind, and terminal run cleanup + +- `fabro-workflow` + - add handler tests that verify: + - generated `question.id` + - richer `interview.started` + - `interview.interrupted` on `Interrupted` + - `interview.completed` on `Skipped` + - correlated `question_id` on completion and timeout + - old interview events without the new fields still deserialize and replay safely + +- `fabro-interview` + - add broker tests for: + - waiter registration by `qid` + - answer delivery + - unknown `qid` + - duplicate answer handling + - control-channel closure behavior + +- `fabro-server` + - add tests that verify: + - `GET /runs/{id}/questions` is projection-backed only + - `POST /runs/{id}/questions/{qid}/answer` works for both subprocess and in-process runs + - first-answer-wins returns success once and `409` thereafter + - no interview scratch files are created for server-managed runs + - `registry_factory_override` still supports interview-driven tests without `WebInterviewer` + - a full-loop integration test covers: + - server starts a run with a human gate + - worker emits `interview.started` + - pending question appears in `GET /runs/{id}/questions` + - `POST /runs/{id}/questions/{qid}/answer` succeeds + - the server delivers the answer over worker `stdin` + - the workflow continues to terminal completion + +- `fabro-slack` + - add tests that verify: + - button payloads carry `run_id` and `qid` + - freeform thread routing uses `(run_id, qid)` + - multi-select remains structured + - stale interactions are rejected after the pending interview is gone + - Slack answers use the same canonical answer-submission service as HTTP + +## Assumptions + +- `Question.metadata` and `Question.default` remain internal runtime fields and are not exposed in `ApiQuestion`. +- `context_display` is the only contextual prompt payload exposed externally in this pass. +- Slack posts only when both Slack tokens and `slack.default_channel` are configured. +- Slack delivery metadata is intentionally ephemeral and may be lost on server restart. +- `stdin` JSONL is the long-term worker control plane; this pass only implements interview answers, not steering behavior. diff --git a/docs/plans/2026-04-07-run-manifest-blobs-and-bundle-removal-plan.md b/docs/plans/2026-04-07-run-manifest-blobs-and-bundle-removal-plan.md new file mode 100644 index 000000000..b9ea84eef --- /dev/null +++ b/docs/plans/2026-04-07-run-manifest-blobs-and-bundle-removal-plan.md @@ -0,0 +1,121 @@ +# Run Manifest Blobs And Bundle Removal Plan + +## Summary +Persist run-definition inputs in CAS-backed blob storage and stop treating scratch as a durable source of workflow definition state. + +This pass stores two durable blob-backed payloads for `/runs` creates: + +- the raw submitted `RunManifest` on `run.created` +- the smaller accepted internal run definition on `run.submitted` + +It removes `workflow_bundle.json` from scratch and updates start/resume to load the accepted definition from the run store instead. + +## Key Decisions +- Keep the current event sequence. + - Do not add a separate `run.accepted` event. + - `run.created` carries the raw submitted-manifest blob ref. + - `run.submitted` carries the accepted-definition blob ref. +- Store the submitted manifest as the exact request-body bytes. + - Motivation: preserve the exact wire submission for audit/debugging, not just the parsed semantic content. + - Consequence: semantically equivalent JSON bodies with different whitespace or field ordering will produce different blob IDs. +- Treat this as greenfield work. + - Do not add compatibility fallbacks for older event shapes, older runs, or older scratch layouts. + - Do not preserve `workflow_bundle.json` reads or writes behind a migration shim. +- Keep `workflow_source` inline on `run.created`. + - Cheap projection reads and existing graph-source consumers should continue to work without hydrating a blob. +- Rename `StoredWorkflowBundle` to `AcceptedRunDefinition` and make it the durable accepted-definition payload. + - Keep the existing data shape (`workflow_path`, `workflows`) and add a `version` field. + - Delete the file I/O helpers instead of introducing a second identical type with conversion boilerplate. +- Treat the accepted-definition pointer as part of run status state. + - Because `run.submitted` is reused by rewind, every `run.submitted` emission must carry the accepted-definition blob ref from run state. + +## Implementation Changes +### 1. Event and projection types +- Update [`lib/crates/fabro-types/src/run_event/run.rs`](/Users/bhelmkamp/p/fabro-sh/fabro-2/lib/crates/fabro-types/src/run_event/run.rs): + - add `submitted_manifest_blob: Option` to `RunCreatedProps` + - replace `RunSubmitted(RunStatusTransitionProps)` with `RunSubmitted(RunSubmittedProps)` + - define `RunSubmittedProps { reason: Option, accepted_definition_blob: Option }` +- Update [`lib/crates/fabro-types/src/run_event/mod.rs`](/Users/bhelmkamp/p/fabro-sh/fabro-2/lib/crates/fabro-types/src/run_event/mod.rs) and event-name plumbing to use the new `RunSubmittedProps`. +- Update [`lib/crates/fabro-types/src/run.rs`](/Users/bhelmkamp/p/fabro-sh/fabro-2/lib/crates/fabro-types/src/run.rs) `RunRecord` with: + - `submitted_manifest_blob: Option` + - `accepted_definition_blob: Option` +- Update [`lib/crates/fabro-store/src/run_state.rs`](/Users/bhelmkamp/p/fabro-sh/fabro-2/lib/crates/fabro-store/src/run_state.rs): + - `run.created` seeds `submitted_manifest_blob` + - `run.submitted` updates run status and overwrites `accepted_definition_blob` + - `graph_source` continues to come from inline `workflow_source` + +### 2. Durable run-definition blob payloads +- Update [`lib/crates/fabro-workflow/src/workflow_bundle.rs`](/Users/bhelmkamp/p/fabro-sh/fabro-2/lib/crates/fabro-workflow/src/workflow_bundle.rs): + - rename `StoredWorkflowBundle` to `AcceptedRunDefinition` + - add a `version` field + - remove `load_from_run_dir()` and any `workflow_bundle.json` file I/O helpers +- Keep `BundledWorkflow` and `WorkflowBundle` as runtime types used by execution and child-workflow resolution. +- Keep the current constructor/runtime helper surface where useful, but do not add a parallel accepted-definition type or a redundant conversion layer. + +### 3. Server create path and blob persistence +- Update [`lib/crates/fabro-server/src/server.rs`](/Users/bhelmkamp/p/fabro-sh/fabro-2/lib/crates/fabro-server/src/server.rs) `POST /runs`: + - read the raw request body bytes + - deserialize `RunManifest` from those bytes + - pass both the typed manifest and the original bytes into workflow creation because this pass intentionally stores the exact submitted JSON bytes +- Update [`lib/crates/fabro-workflow/src/operations/create.rs`](/Users/bhelmkamp/p/fabro-sh/fabro-2/lib/crates/fabro-workflow/src/operations/create.rs): + - extend `CreateRunInput` with optional raw submitted-manifest bytes + - after opening the run store, write the raw manifest bytes to CAS when present + - derive the accepted definition from `workflow_path` + `workflow_bundle` and write it to CAS + - remove the `persist_workflow_bundle()` call and delete the helper + - append `run.created` with `submitted_manifest_blob` + - append `run.submitted` with `accepted_definition_blob` +- All normal `/runs` creates, including CLI `fabro run` through the server route, should write both blobs. +- Direct low-level `CreateRunInput` callers that bypass manifests may omit `submitted_manifest_blob`. + - If they still provide `workflow_path` + `workflow_bundle`, they should still get an `accepted_definition_blob`. + - Only callers that provide neither manifest bytes nor a bundled workflow definition may leave both refs `None`. + +### 4. Start, resume, and rewind +- Update [`lib/crates/fabro-workflow/src/operations/start.rs`](/Users/bhelmkamp/p/fabro-sh/fabro-2/lib/crates/fabro-workflow/src/operations/start.rs): + - stop reading `workflow_bundle.json` from `persisted.run_dir()` + - load `state.run.accepted_definition_blob` + - fetch the accepted-definition bytes through `RunStoreHandle` + - deserialize the accepted definition and reconstruct `workflow_path` / `workflow_bundle` +- Leave `WorkflowInput::Path` behavior unchanged for truly non-bundled runs that never had an accepted-definition blob. +- Update [`lib/crates/fabro-cli/src/commands/run/rewind.rs`](/Users/bhelmkamp/p/fabro-sh/fabro-2/lib/crates/fabro-cli/src/commands/run/rewind.rs) so the re-emitted `run.submitted` event includes the current `accepted_definition_blob` from run state. +- Keep manager-loop and parallel child-workflow execution unchanged once `EngineServices.workflow_bundle` is hydrated from the accepted-definition blob. + +### 5. Output and documentation cleanup +- Update CLI JSON/event rendering paths that special-case empty `run.submitted` properties so they tolerate structured `RunSubmittedProps`: + - [`lib/crates/fabro-cli/src/commands/run/logs.rs`](/Users/bhelmkamp/p/fabro-sh/fabro-2/lib/crates/fabro-cli/src/commands/run/logs.rs) + - [`lib/crates/fabro-cli/src/commands/run/attach.rs`](/Users/bhelmkamp/p/fabro-sh/fabro-2/lib/crates/fabro-cli/src/commands/run/attach.rs) +- Remove `workflow_bundle.json` from [`docs/reference/run-directory.mdx`](/Users/bhelmkamp/p/fabro-sh/fabro-2/docs/reference/run-directory.mdx). +- Update any tests or docs that mention `StoredWorkflowBundle` or scratch-based workflow bundle persistence. + +## Public Interface Changes +- Internal event payload shape changes: + - `run.created` gains `submitted_manifest_blob` + - `run.submitted` now emits structured properties with `reason` and `accepted_definition_blob` +- Internal run-state shape changes: + - `RunProjection.run` gains `submitted_manifest_blob` and `accepted_definition_blob` +- No public `/runs` request-shape change is intended. +- No new HTTP routes are required; existing run-scoped blob read/write APIs remain the storage surface. + +## Test Plan +- Add create-path coverage in [`lib/crates/fabro-workflow/src/operations/create.rs`](/Users/bhelmkamp/p/fabro-sh/fabro-2/lib/crates/fabro-workflow/src/operations/create.rs): + - manifest-backed create writes both blobs + - raw submitted-manifest bytes round-trip exactly through CAS + - first event is `run.created` with `submitted_manifest_blob` + - second event is `run.submitted` with `accepted_definition_blob` + - no `workflow_bundle.json` file is written +- Add start/resume coverage in [`lib/crates/fabro-workflow/src/operations/start.rs`](/Users/bhelmkamp/p/fabro-sh/fabro-2/lib/crates/fabro-workflow/src/operations/start.rs): + - accepted-definition blob hydrates `workflow_bundle` + - bundled imports/prompts/child workflows still resolve after original source files are removed +- Add projection/event coverage in [`lib/crates/fabro-store/src/run_state.rs`](/Users/bhelmkamp/p/fabro-sh/fabro-2/lib/crates/fabro-store/src/run_state.rs) and event serde tests: + - `run.created` stores `submitted_manifest_blob` + - `run.submitted` updates `accepted_definition_blob` + - serialized/deserialized `RunSubmittedProps` round-trips cleanly +- Add rewind coverage in [`lib/crates/fabro-cli/src/commands/run/rewind.rs`](/Users/bhelmkamp/p/fabro-sh/fabro-2/lib/crates/fabro-cli/src/commands/run/rewind.rs): + - rewind re-emits `run.submitted` with the current accepted-definition blob +- Update CLI attach/log integration tests so `run.submitted` JSON includes structured properties and still renders correctly. + +## Assumptions +- Greenfield strictness is preferred over compatibility padding. +- Orphaned manifest/definition blobs are acceptable in this pass; no blob GC work is included. + - CAS deduplication limits duplicate growth because identical payloads share blob storage. +- The raw submitted-manifest blob is stored exactly as received over HTTP, not by reserializing a typed struct. +- The accepted definition is the only durable source used to reconstruct bundled workflow execution state after creation. diff --git a/docs/plans/2026-04-07-run-state-projection-consolidation-plan.md b/docs/plans/2026-04-07-run-state-projection-consolidation-plan.md new file mode 100644 index 000000000..1d2303670 --- /dev/null +++ b/docs/plans/2026-04-07-run-state-projection-consolidation-plan.md @@ -0,0 +1,66 @@ +# Run State Projection Consolidation Plan + +## Summary +Consolidate the duplicated CLI and CLI-test run-state projection structs into the canonical read-model types owned by `fabro-store`. + +This pass keeps `RunProjection` and `NodeState` in `fabro-store`, does not move them into `fabro-types`, and does not touch workflow events. The goal is to make the store projection the single source of truth for the `/api/v1/runs/{id}/state` response and for CLI-side consumption of that response. + +## Key Decisions +- `fabro_store::RunProjection` and `fabro_store::NodeState` become the only production definitions. +- The server response shape remains strict. + - Do not add backward-compat aliases, migration shims, or permissive serde fallbacks. + - If the server and CLI drift, the build or tests should fail. +- `RunProjection` remains a store-owned read model in this pass. + - Do not move it into `fabro-types`. +- The existing store projection helper surface is the shared API: + - `node()` + - `iter_nodes()` + - `is_empty()` + - `list_node_visits()` + +## Implementation Changes +### 1. Make the store projection reusable as the client model +- Update [`lib/crates/fabro-store/src/run_state.rs`](/Users/bhelmkamp/p/fabro-sh/fabro-2/lib/crates/fabro-store/src/run_state.rs) so `RunProjection` and `NodeState` derive `serde::Deserialize` in addition to their current derives. +- Keep the field layout unchanged unless deserialization requires a minimal adjustment for the existing JSON shape. +- Keep the current helper methods on `RunProjection` as the public access surface for downstream crates. + +### 2. Remove the duplicated CLI runtime projection +- Delete the local `RunProjection` and `NodeState` definitions from [`lib/crates/fabro-cli/src/server_client.rs`](/Users/bhelmkamp/p/fabro-sh/fabro-2/lib/crates/fabro-cli/src/server_client.rs). +- Import `fabro_store::RunProjection` instead. +- Keep `get_run_state()` structurally simple: + - fetch `/api/v1/runs/{id}/state` + - deserialize the response into the shared store projection type +- Do not recreate projection helper methods in the CLI. + +### 3. Remove the duplicated CLI test projection +- Delete the mirrored `RunProjection` and `NodeState` definitions from [`lib/crates/fabro-cli/tests/it/cmd/support.rs`](/Users/bhelmkamp/p/fabro-sh/fabro-2/lib/crates/fabro-cli/tests/it/cmd/support.rs). +- Reuse `fabro_store::RunProjection` in test helpers that fetch `/api/v1/runs/{id}/state`. +- Rewrite any test call sites that depend on direct `nodes` map access to use the shared projection API instead: + - `iter_nodes()` + - `node()` + - `list_node_visits()` + +### 4. Keep the server boundary unchanged +- Leave [`lib/crates/fabro-server/src/server.rs`](/Users/bhelmkamp/p/fabro-sh/fabro-2/lib/crates/fabro-server/src/server.rs) behavior unchanged for `GET /api/v1/runs/{id}/state`. +- The endpoint already returns the store projection directly; this pass only removes downstream duplication. + +## Public Interface Changes +- No HTTP API shape change is intended. +- No `fabro-types` change is intended. +- The effective shared contract becomes explicit: + - `/api/v1/runs/{id}/state` is represented by `fabro_store::RunProjection` + - the CLI no longer maintains a private mirror type for that payload + +## Test Plan +- Add a `fabro-store` serde test that deserializes a representative run-state JSON payload into `RunProjection`, including stage-id string keys such as `build@2`. +- Add a `fabro-store` round-trip test that serializes and deserializes `RunProjection` and verifies: + - `node()` returns the expected node state + - `list_node_visits()` returns the expected visit list +- Run `cargo nextest run -p fabro-store`. +- Run `cargo nextest run -p fabro-cli`. +- Confirm existing CLI integration coverage that hits `/api/v1/runs/{id}/state` still passes with the shared projection type. + +## Assumptions +- This plan covers only the projection-consolidation pass we discussed, not event cleanup or broader run-state refactors. +- Greenfield strictness is preferred over compatibility padding. +- `fabro-store` is the correct ownership boundary for this read model in the current architecture. diff --git a/docs/plans/2026-04-07-store-dump-server-owned-export-plan.md b/docs/plans/2026-04-07-store-dump-server-owned-export-plan.md new file mode 100644 index 000000000..bf6664111 --- /dev/null +++ b/docs/plans/2026-04-07-store-dump-server-owned-export-plan.md @@ -0,0 +1,235 @@ +# Store Dump Server-Owned Export Plan + +## Summary + +Align `fabro store dump` with the intended architecture by making it a pure server-backed export command. + +- The CLI should resolve runs from server summaries only. +- It should fetch hydrated run state, hydrated event history, and artifacts over HTTP. +- It should stop creating a temporary `Database`. +- It should stop reading local scratch directories or local storage/object-store paths. +- It can keep the current export-style output layout on disk, except blob storage should stay transparent: + - top-level metadata files + - `nodes/**` + - `retro/**` + - `events.jsonl` + - `checkpoints/**` + - `artifacts/**` + +This is a debugging export, not a strict snapshot mechanism. Best-effort consistency is acceptable. + +## Problem Frame + +`fabro store dump` is still implemented as a local store reconstruction flow: + +- it resolves the run through local-storage-aware helpers +- it fetches events over HTTP but replays them into an in-memory `fabro_store::Database` +- it reads artifacts from local storage directly +- it assumes the CLI can see the same storage and scratch directories as the server + +That is now the wrong boundary. The server should own store access; the CLI should only export server-provided run data to disk. + +## Key Decisions + +- `store dump` becomes server-only. + - Use `ServerTargetArgs`, not `StorageDirArgs`. + - Resolve selectors through `ServerSummaryLookup`, not `ServerRunLookup`. + - Do not scan local scratch directories or orphan runs. + +- Keep the current export layout. + - It is fine that the command still writes `events.jsonl`, `checkpoints/`, and `artifacts/`. + - It should not write a `blobs/` directory. + - Blob storage is an internal offload mechanism, not part of the user-facing export model. + +- Do not add blob enumeration. + - Blob-backed values should be hydrated server-side before the CLI receives run state or events. + - The CLI should not need to understand blob pointer conventions. + - The exported files should look as if offloading had never happened. + +- Best-effort race handling is acceptable. + - If a blob or artifact is listed/referenced but disappears before download, skip it and continue. + - Fail on transport or server errors other than `404`. + +- Event pagination should use the server's `meta.has_more` contract. + - Do not stop paging based on "returned fewer than page size". + - The client should retain and use pagination metadata from the API response. + +- Artifact downloads should be concurrent with a fixed upper bound. + - Use a bounded concurrency strategy such as `JoinSet` or `FuturesUnordered` with a small limit. + - Recommended default: `8` concurrent artifact downloads. + +## Implementation Changes + +### 1. Convert `store dump` to standard server targeting + +In `fabro-cli`: + +- change `StoreDumpArgs` to flatten `ServerTargetArgs` +- remove `StorageDirArgs` from this command +- update help text, docs, and snapshots to show `--server` / `FABRO_SERVER` + +Run resolution should follow the same contract as other server-backed inspection commands: + +1. explicit `--server` +2. configured `[server].target` +3. default local server instance if no server target is configured + +The command should no longer derive behavior from a local storage dir. + +### 2. Resolve the run from server summaries only + +- replace `ServerRunLookup` usage with `ServerSummaryLookup` +- resolve `` from server-provided summaries only +- remove any dependence on local scratch-path scanning during selector resolution + +This ensures `store dump` works even when the server runs on a different host. + +### 3. Add server/API support for paginated hydrated reads + +The CLI path depends on the server returning enough information to page correctly and to keep blob handling transparent. + +Add or update the server/API contract for: + +- `GET /runs/{id}/state?hydrate_blobs=true` +- `GET /runs/{id}/events?...&hydrate_blobs=true` + +Follow the existing OpenAPI-first workflow for these API changes: + +- update `docs/api-reference/fabro-api.yaml` +- rebuild Rust API types/client via `cargo build -p fabro-api` +- regenerate the TypeScript client in `lib/packages/fabro-api-client` + +Also update the CLI client shape for event listing so pagination metadata is preserved instead of discarded: + +- add a new paginated event-list helper for `store dump` that returns both `data` and `meta.has_more` +- keep the existing `list_run_events(...) -> Vec` convenience method in place for current callers unless there is a strong reason to migrate them in the same change +- `store dump` must consume the metadata-bearing form + +The existing artifact APIs are already sufficient for this plan: + +- `list_run_artifacts(run_id)` +- `download_stage_artifact(run_id, stage_id, filename)` + +No new artifact endpoint work is required here. + +### 4. Add server-side blob hydration for state and events + +Blob storage is intended to be transparent. `store dump` should not detect, enumerate, or hydrate blob pointers in the CLI. + +Recommended API shape: + +- add `hydrate_blobs=true` as an optional query parameter on both endpoints +- when omitted or `false`, preserve current behavior +- when `true`, the server resolves blob-backed references before serializing the response +- reflect those query parameters in the OpenAPI schema and generated clients + +Hydration scope: + +- all blob-backed values inside `RunProjection` +- all blob-backed values inside returned event payloads +- this includes values that ultimately flow into exported checkpoint JSON because checkpoints come from `RunProjection.checkpoints` + +Hydration mechanism: + +- implement a shared server-side JSON hydrator that walks `serde_json::Value`, detects blob-backed pointer values, reads the referenced blobs from the run store, and replaces the pointer string with the parsed JSON payload +- reuse the same helper for both state and event responses so blob resolution rules stay identical +- keep the helper server-owned rather than attaching it only to `RunProjection`, because event payloads need the same treatment + +If a referenced blob cannot be resolved during hydrated fetch: + +- treat `404` as a race-tolerant miss +- preserve the original pointer value in the hydrated response +- fail on non-`404` server/store errors + +This keeps blob knowledge server-owned, which matches the architecture this plan is trying to enforce. + +### 5. Refactor `RunDump` to accept fetched export data instead of store handles + +`RunDump::store_export` currently assumes: + +- a `RunDatabase` +- an `ArtifactStore` +- store enumeration for blobs and artifacts + +Refactor this into a store-agnostic export builder with a concrete constructor: + +```rust +RunDump::from_export( + state: &RunProjection, + events: &[EventEnvelope], + artifacts: &HashMap<(StageId, String), Bytes>, +) -> Result +``` + +Notes: + +- `state` is already hydrated +- `events` are already hydrated +- blobs are not a separate constructor parameter +- `artifacts` are the only binary payloads the CLI still fetches explicitly + +The new builder should preserve the current file layout and validation rules: + +- top-level metadata files from `RunProjection` +- node files under `nodes//visit-/` +- `retro/prompt.md` and `retro/response.md` +- `events.jsonl` +- `checkpoints/.json` sourced directly from `RunProjection.checkpoints` +- `artifacts/nodes//visit-/` + +Keep the existing staged-directory write behavior: + +- reject non-empty output dirs +- write into a temp dir under the output parent +- rename into place when complete + +### 6. Replace local store reconstruction with HTTP-backed export collection + +In `dump_command`, fetch the export inputs directly from the server: + +- hydrated current run state via `get_run_state(run_id, hydrate_blobs = true)` +- full hydrated event history via paginated `list_run_events(run_id, since_seq, limit, hydrate_blobs = true)` + - use an explicit page size of `1000` + - continue paging based on `meta.has_more` +- run artifacts via `list_run_artifacts(run_id)` +- artifact contents via `download_stage_artifact(run_id, stage_id, filename)` using bounded concurrency + +Do not: + +- create a `Database` +- call `rebuild_run_store` +- open a local `ArtifactStore` +- read anything under local `storage/` or scratch paths + +### 7. Leave shared event-rebuild helpers for other commands + +`rebuild_run_store` is still used by other commands such as `fork`, `rewind`, and `pr create`. + +- remove it from `store dump` +- do not delete it in this change unless those other commands are migrated too + +This plan is specific to aligning `store dump` with server-owned export. + +## Test Plan + +- update the `store dump --help` snapshot for the CLI arg change from `--storage-dir` to `--server` +- re-enable the disabled `store dump` integration coverage and make it exercise the server-backed path only +- add a regression test for a run with more than 100 events to prove pagination exports the full `events.jsonl` +- add a regression test for a run with exactly `1000` events to prove the client performs the additional page fetch and stops on `has_more = false`, not on page-size heuristics +- add server/API coverage for hydrated fetches: + - hydrated `get_run_state(..., hydrate_blobs = true)` replaces blob-backed pointers with original JSON values + - hydrated `list_run_events(..., hydrate_blobs = true)` replaces blob-backed pointers in event payloads + - blob `404` during hydration preserves the original pointer value +- add coverage that no `blobs/` directory is emitted +- add `RunDump` coverage for the new store-agnostic constructor to verify the exported file layout remains unchanged for representative state/events/blobs/artifacts +- keep or restore the non-empty output-dir rejection test +- add a race-tolerance test where a referenced artifact returns `404` and the export completes without that artifact file +- add coverage that artifact downloads run through the bounded-concurrency path without changing output order or file paths + +## Assumptions and Defaults + +- `store dump` should use the standard server-target contract, not `--storage-dir` +- the current export file layout is intentionally preserved +- blob handling is transparent and server-owned; there is no `blobs/` export directory and no need for a blob-list API +- best-effort consistency is sufficient because this command exists for debugging and inspection +- the server remains the only component allowed to access the underlying run store in this architecture diff --git a/docs/plans/2026-04-07-worker-http-only-run-store-migration-plan.md b/docs/plans/2026-04-07-worker-http-only-run-store-migration-plan.md new file mode 100644 index 000000000..ac884a717 --- /dev/null +++ b/docs/plans/2026-04-07-worker-http-only-run-store-migration-plan.md @@ -0,0 +1,109 @@ +# Complete Worker HTTP-Only Run Store Migration + +## Summary +- Finish the architecture change by removing all `RunDatabase` / SlateDB usage from the detached `fabro __run-worker` path. +- Keep the server as the only SlateDB owner and single writer. +- Do this with one run-scoped internal runtime abstraction used by workflow execution, plus two implementations: + - local adapter for server-side execution and tests + - HTTP-backed adapter for detached workers +- Let the HTTP-backed worker maintain a write-through in-memory mirror of acknowledged events and projection state so repeated worker-side reads do not turn into unnecessary HTTP round-trips. +- Reuse existing server endpoints; no OpenAPI or route changes are required for this migration. + +## Internal Interface Changes +- Introduce a small run-scoped async runtime-facing store interface in `fabro-workflow` for the operations the executor actually needs: + - `load_state() -> RunProjection` + - `list_events() -> Vec` + - `append_run_event(&RunEvent) -> ()` + - `write_blob(&[u8]) -> RunBlobId` + - `read_blob(&RunBlobId) -> Option` +- Change workflow execution plumbing to depend on that interface instead of `RunDatabase`: + - `StartServices` + - `EngineServices` + - the pipeline structs and options that currently carry `RunDatabase` + - helpers that currently hard-code persisted-load, retro, and finalize store reads +- Replace the `RunEventSink::store(RunDatabase)` special case with a backend-based writer so event emission no longer assumes a local SlateDB handle. +- Keep the interface scoped to a single run so methods do not need a `RunId` parameter. + +## Implementation Changes +### 1. Runtime backend abstraction +- Add a runtime store backend trait in `fabro-workflow` and migrate the worker-facing execution path to depend on it instead of `RunDatabase`. +- Keep the interface narrow and asynchronous, covering only the worker-side behaviors that still depend on store access: + - load run projection / run record / graph source + - list events + - append run events + - write blobs + - read blobs +- Allow the HTTP-backed implementation to keep a write-through in-memory mirror of run state and events, but only update that mirror after the server has acknowledged the write. The server remains canonical; the worker cache is a derived mirror for read efficiency only. +- Define failure policy up front: + - apply bounded retries to transient HTTP failures + - if retries exhaust on a required read or write, fail the worker run with a clear fatal error + - do not continue executing after the worker loses the ability to read or write canonical run state + +### 2. Local adapter for server execution +- Add a local adapter in `fabro-workflow` that wraps `RunDatabase`. +- Keep server-side execution behavior unchanged: + - the server still opens the durable `RunDatabase` + - the server passes the local adapter into workflow execution + - the server remains the only SlateDB owner and single writer in production execution +- Keep existing unit and integration tests that rely on in-process `RunDatabase` semantics working through this adapter. + +### 3. HTTP-backed adapter for detached workers +- Add an HTTP-backed adapter in `fabro-cli` on top of `ServerStoreClient`. +- Extend `ServerStoreClient` with the missing worker-side helpers already supported by the server API: + - write run blob + - read run blob + - get checkpoint only if a migrated path needs a direct checkpoint call rather than `load_state()` +- The detached worker should use this adapter for all run-state reads and event/blob writes. +- Seed the adapter from the server once at worker startup, then keep its in-memory mirror in sync from acknowledged appends and explicit refetches when needed. + +### 4. Migrate confirmed worker-path reads off `RunDatabase` +- Move the confirmed detached-worker call sites to the new backend before deleting any local store construction: + - startup validation and persisted-run loading + - resume checkpoint loading + - retro state and event reads + - finalize conclusion building and metadata-finalize reads + - artifact blob offload and any worker-path blob reads + - git metadata checkpoint and finalize reads that currently load state from the local store +- Treat this as a worker-path refactor, not a whole-repo purge of `RunDatabase`. +- Explicitly out of scope for this migration: + - server supervisor code + - server routes and server-local run execution + - `store dump` and other separate server-funneling work + +### 5. Remove local worker store usage +- After the worker-path reads above are migrated, delete the local seeded in-memory store path from `lib/crates/fabro-cli/src/commands/run/runner.rs`: + - remove local `Database::new(InMemory, ..., flush_interval)` construction + - remove seeding via `list_run_events()` into a local `RunDatabase` + - remove the worker-side `RunEventSink::fanout([store, callback])` +- After this change, detached workers send events directly to the server over HTTP and never construct or open any `fabro_store::Database`. + +## Test Plan +- Add unit coverage for the new local adapter covering: + - state loading + - event append + - blob write behavior + - blob read behavior +- Add focused unit coverage for the HTTP-backed adapter covering: + - write-through projection and event cache updates after acknowledged appends + - bounded retry behavior + - fatal failure when required HTTP reads or writes keep failing +- Add focused CLI and worker tests proving detached execution still works for: + - start + - resume + - cancel / ctrl-c + - human gate handling + - large context value blob offload +- Add a regression test that would have failed under the old design: + - detached worker event delivery is no longer paced by a local worker-side store append + - `dry_run_simple` no longer shows the `~100ms` per-event cadence caused by the worker’s local store path +- Add a regression test that the detached worker path no longer constructs a local `fabro_store::Database`. +- Keep existing server execution tests green to prove the local adapter preserved current semantics. + +## Assumptions and Defaults +- No public HTTP API changes are needed for this migration; existing run state, event, checkpoint, blob, and artifact routes are sufficient. +- The runtime backend is scoped to a single run, matching detached-worker execution semantics. +- The HTTP-backed worker cache is a derived mirror updated only after successful server acknowledgements; it does not make the worker a second source of truth. +- While a detached worker is executing, it is assumed to be the sole emitter of run events for that run; if server-originated events are later added to the live run stream, the cache model will need an explicit invalidation or subscription mechanism. +- The detached worker must have zero direct SlateDB access after this change. +- The server remains the only component allowed to own a `RunDatabase` in production execution. +- This should land as one coherent migration, not as a partial compatibility phase, because the current mixed model is both architecturally wrong and performance-visible. diff --git a/docs/plans/2026-04-08-001-feat-fabro-uninstall-command-plan.md b/docs/plans/2026-04-08-001-feat-fabro-uninstall-command-plan.md new file mode 100644 index 000000000..0262a8f1c --- /dev/null +++ b/docs/plans/2026-04-08-001-feat-fabro-uninstall-command-plan.md @@ -0,0 +1,339 @@ +--- +title: "feat: Add `fabro uninstall` command" +type: feat +status: completed +date: 2026-04-08 +origin: docs/ideation/2026-04-08-fabro-uninstall-ideation.md +deepened: 2026-04-08 +--- + +# feat: Add `fabro uninstall` command + +## Overview + +Add a `fabro uninstall` top-level CLI command that reverses the effects of `fabro install` and `install.sh`. The command stops a running server, removes the `~/.fabro/` directory tree, cleans shell config PATH entries, and handles binary self-removal. Defaults to dry-run (preview) mode, requiring `--yes` to execute. + +## Problem Frame + +There is no supported way to uninstall Fabro. Users must manually `rm -rf ~/.fabro`, hunt for stale PATH entries in their shell configs, and figure out how to stop the server. This creates friction, erodes trust, and leaves detritus after uninstall. + +## Requirements Trace + +- R1. Remove the `~/.fabro/` directory and all contents +- R2. Stop a running server before removing files +- R3. Remove `# fabro` PATH lines from shell configs (.zshrc, .bashrc, .bash_profile, .config/fish/config.fish) +- R4. Default to dry-run with size reporting; require `--yes` to execute +- R5. Delete the binary when installed via `install.sh` to `~/.fabro/bin/`; print a hint otherwise +- R6. Support `--json` output for scriptability + +## Scope Boundaries + +- No selective/component uninstall (users can back up manually) +- No GitHub App deregistration via API +- No export/backup archive feature +- No telemetry farewell event +- No per-repo cascade cleanup (repos can be cleaned individually via `fabro repo deinit`) + +## Context & Research + +### Relevant Code and Patterns + +- `lib/crates/fabro-cli/src/commands/install.rs` — what `fabro install` creates (settings.toml, certs/, secrets.json) +- `apps/marketing/public/install.sh` — what the shell installer creates (binary at `~/.fabro/bin/fabro`, PATH lines with `# fabro` sentinel) +- `lib/crates/fabro-cli/src/commands/server/stop.rs` — `execute(storage_dir, timeout)`: SIGTERM, poll, SIGKILL, record+socket cleanup +- `lib/crates/fabro-cli/src/commands/server/record.rs` — `active_server_record_details()` for detecting running server +- `lib/crates/fabro-cli/src/commands/system/prune.rs` — dry-run-by-default pattern with `--yes`, size reporting via `format_size()` +- `lib/crates/fabro-cli/src/commands/repo/deinit.rs` — cleanup command pattern: green checkmarks, NotFound tolerance, `Vec` return, `--json` support +- `lib/crates/fabro-util/src/home.rs` — `Home` struct with all path accessors (`root()`, `certs_dir()`, `storage_dir()`, etc.) +- `lib/crates/fabro-config/src/storage.rs` — `Storage` struct with storage path accessors +- `lib/crates/fabro-cli/src/args.rs` — `Commands` enum for command registration, `Commands::name()` for telemetry +- `lib/crates/fabro-cli/src/commands/upgrade.rs` — `std::env::current_exe()?.canonicalize()?` for binary path detection +- `lib/crates/fabro-config/src/user_config.rs` — `load_settings()` for resolving effective configuration including non-default `storage_dir` + +### Institutional Learnings + +No `docs/solutions/` directory exists. No prior learnings on install/uninstall patterns. + +## Key Technical Decisions + +- **Top-level command, not subcommand of `system`**: Matches `install`/`upgrade` placement. Users will search for `fabro uninstall` — discoverability matters. (see origin: docs/ideation/2026-04-08-fabro-uninstall-ideation.md) +- **`--yes` confirmation model (not `--dry-run`)**: Matches `system prune` pattern — destructive operations default to preview. The flag name `--yes` is already established in the codebase. +- **Use `Home::from_env()` as the canonical root**: Respects `$FABRO_HOME` for non-default installations. Never hardcode `~/.fabro/`. +- **Shell config cleanup uses exact `# fabro` sentinel match**: The `install.sh` script writes `# fabro` as a marker comment before the PATH export. Match with `line.trim() == "# fabro"` (exact match, not substring) to avoid deleting unrelated lines like `# fabro workflow helper`. After matching the sentinel, validate that the following line matches an expected PATH pattern (`export PATH=` or `fish_add_path`) before removing it — this prevents accidentally deleting an innocent line if the file was manually edited. +- **Binary path resolved during inventory, not after deletion**: `std::env::current_exe()?.canonicalize()?` must be called during the inventory phase (Unit 2) and stored. On macOS, `canonicalize()` fails with `NotFound` after the file is deleted by `remove_dir_all`. The stored path is used later by Unit 5. +- **Binary self-deletion is the final step**: On Unix, unlinking a running binary works (the inode stays alive until the process exits). Must be absolutely last since nothing can run after the binary is gone. +- **Do not call `stop::execute()` without a guard**: `server::stop::execute()` calls `std::process::exit(1)` when no server is running. The uninstall command must check `record::active_server_record_details()` first and only call `stop::execute()` when a server is confirmed running. +- **Resolve `storage_dir` through settings, not just `Home`**: If `settings.toml` configures a non-default `storage_dir`, the server record lives at that custom path. Load effective settings via `user_config::load_settings()` during inventory to resolve the actual `storage_dir`. +- **Compute-once inventory**: The inventory is built once (Unit 2) and consumed by all subsequent units. No step should re-detect artifacts during execution — the directory may already be partially deleted. +- **Exit code policy**: Exit 0 only if all critical steps (server stop, directory removal) succeeded. Exit 1 if any critical step failed. Shell config cleanup and binary handling failures warn but do not affect the exit code. +- **Synchronous implementation**: Server stop logic (`stop::execute`) is synchronous. The uninstall function itself should be async (matching the dispatch pattern in main.rs) but can call sync helpers. + +## Open Questions + +### Resolved During Planning + +- **Should uninstall clean up shell configs that `fabro install` (Rust) didn't create?** Yes — `install.sh` creates them, and the user experience of "uninstall" should reverse the full installation, not just the Rust command's portion. +- **What if the server won't stop?** Follow the existing SIGTERM→SIGKILL pattern from `server/stop.rs`. If SIGKILL fails (shouldn't happen on Unix), warn and continue with removal. + +- **Should the dry-run preview show active workflow run count?** Yes — if the server is running with in-flight workflows, stopping it will terminate them. The preview should report the count so users can make an informed decision. The run count can be obtained from the server API if available, or noted as "server running (active runs unknown)" if the API is unreachable. +- **What about `$ZDOTDIR` mismatch between install and uninstall time?** If the user's `$ZDOTDIR` was set differently at install time versus uninstall time, the uninstall will look in the wrong file. This is an inherent limitation — document it but do not try to solve it. + +### Deferred to Implementation + +- **Fish shell syntax differences**: `fish_add_path` vs `export PATH=` — the removal logic needs shell-specific handling. Determine exact patterns during implementation. + +## Implementation Units + +- [x] **Unit 1: Command registration and skeleton** + +**Goal:** Register `fabro uninstall` as a top-level CLI command with args parsing. + +**Requirements:** R4, R6 + +**Dependencies:** None + +**Files:** +- Modify: `lib/crates/fabro-cli/src/args.rs` +- Modify: `lib/crates/fabro-cli/src/commands/mod.rs` +- Modify: `lib/crates/fabro-cli/src/main.rs` +- Create: `lib/crates/fabro-cli/src/commands/uninstall.rs` + +**Approach:** +- Add `UninstallArgs` struct with `--yes` bool field (clap attribute: `#[arg(long)]`) +- Add `Uninstall(UninstallArgs)` variant to `Commands` enum with doc comment `/// Uninstall Fabro from this machine` +- Add `Self::Uninstall(_) => "uninstall"` to `Commands::name()` +- Add `pub(crate) mod uninstall;` to `commands/mod.rs` +- Add dispatch arm in main.rs calling `commands::uninstall::run_uninstall(&args, &globals).await?` +- Skeleton `run_uninstall` that prints "not yet implemented" and returns Ok + +**Patterns to follow:** +- `InstallArgs` struct and `Commands::Install` variant in `args.rs` +- Dispatch pattern in `main.rs` (line ~176) +- Module declaration pattern in `commands/mod.rs` + +**Test scenarios:** +- Happy path: `fabro uninstall --help` outputs usage text including `--yes` flag description +- Happy path: `fabro uninstall` (no args) parses successfully and runs the skeleton + +**Verification:** +- `cargo build -p fabro-cli` succeeds +- `fabro uninstall --help` shows the expected usage + +--- + +- [x] **Unit 2: Inventory and dry-run preview** + +**Goal:** Discover all Fabro artifacts on the system, compute sizes, and display a preview manifest. When `--yes` is not passed, this is the complete behavior. + +**Requirements:** R1, R2, R3, R4, R5, R6 + +**Dependencies:** Unit 1 + +**Files:** +- Modify: `lib/crates/fabro-cli/src/commands/uninstall.rs` + +**Approach:** +- Use `Home::from_env()` to resolve the root directory +- Check if `Home::root()` exists; if not, print "Fabro is not installed" and exit 0 (skip settings loading entirely) +- If home exists, attempt to load effective settings via `user_config::load_settings()` to resolve actual `storage_dir`. If settings loading fails (e.g., settings.toml missing or corrupt), fall back to `Home::from_env().storage_dir()` — this handles partial installs and repeated uninstall attempts +- Build an inventory struct containing all information needed by subsequent units: + - `home_root`: resolved Home root path + - `storage_dir`: resolved storage directory path + - `home_exists`: whether the home directory exists + - `home_size`: total size of home directory (recursive walk) + - `server_running`: whether a server is detected via `record::active_server_record_details(&storage_dir)` + - `shell_configs`: list of shell config file paths that contain the `# fabro` sentinel (exact match: `line.trim() == "# fabro"`) + - `binary_path`: resolved path from `std::env::current_exe()?.canonicalize()?` (must be resolved NOW, before any deletion) + - `binary_is_managed`: whether `binary_path` starts with `home_root` +- Shell config files to scan: `$ZDOTDIR/.zshrc` or `~/.zshrc`, `~/.bashrc`, `~/.bash_profile`, `~/.config/fish/config.fish` +- In dry-run mode (no `--yes`): print each item that would be removed with its size, including active run warning if server is running, then print `"Pass --yes to confirm."` summary +- Support `--json` output: serialize inventory as JSON to stdout +- Follow the `system prune` output style for human-readable format + +**Patterns to follow:** +- `system prune` dry-run output format ("would delete: ...", "N item(s) would be deleted (X freed)") +- `format_size()` for human-readable byte formatting +- `console::Style` / `Styles::detect_stderr()` for colored output +- `GlobalArgs.json` check for JSON vs human output + +**Test scenarios:** +- Happy path: preview with populated `~/.fabro/` lists all directories and files with sizes +- Happy path: preview with `--json` outputs structured JSON to stdout +- Edge case: `~/.fabro/` does not exist — prints "Fabro is not installed" and exits cleanly +- Edge case: shell config files exist but contain no fabro lines — omitted from preview +- Edge case: binary is not in `~/.fabro/bin/` — preview notes it must be removed manually +- Happy path: running server detected — preview notes it will be stopped and warns about active runs +- Edge case: `current_exe()` or `canonicalize()` fails — inventory stores `None` for binary path, warns in preview + +**Verification:** +- `fabro uninstall` (no `--yes`) prints a complete manifest and does NOT delete anything +- `fabro uninstall --json` outputs valid JSON with inventory details +- Preview accurately reflects what exists on disk + +--- + +- [x] **Unit 3: Server shutdown and directory removal** + +**Goal:** When `--yes` is passed, stop a running server and remove `~/.fabro/` and all contents. + +**Requirements:** R1, R2 + +**Dependencies:** Unit 2 + +**Files:** +- Modify: `lib/crates/fabro-cli/src/commands/uninstall.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/uninstall.rs` + +**Approach:** +- Use the inventory's `server_running` and `storage_dir` fields (computed in Unit 2) +- **Critical**: Only call `server::stop::execute()` when `inventory.server_running` is true. `stop::execute()` calls `std::process::exit(1)` when no server is found — calling it unconditionally would terminate the process before any cleanup happens. +- If running, call `server::stop::execute(&inventory.storage_dir, Duration::from_secs(5))` +- If not running, skip with no error +- **Safety guardrail before deletion**: Validate `inventory.home_root` is reasonable before calling `remove_dir_all`. Refuse to proceed if the resolved path is a filesystem root (`/`), the user's home directory (`$HOME`), or does not contain an expected marker file (e.g., `settings.toml` or `certs/`). This prevents catastrophic data loss from a misconfigured `$FABRO_HOME`. +- After validation, remove `inventory.home_root` with `std::fs::remove_dir_all` +- If `storage_dir` differs from the default and is outside `home_root`, validate it contains Fabro artifacts (e.g., `secrets.json` or `store/`) before removing +- Handle `ErrorKind::NotFound` gracefully (already uninstalled) +- Report each step with green checkmark output following `deinit.rs` pattern +- The `fabro.sock` socket file is inside `~/.fabro/` so it's removed as part of the directory deletion + +**Patterns to follow:** +- `server::stop::execute()` for server shutdown (call ONLY when server is confirmed running) +- `record::active_server_record_details()` for server detection (already done in inventory) +- `deinit.rs` checkmark output pattern +- `RunScratch::remove()` for NotFound tolerance + +**Test scenarios:** +- Happy path: with `--yes` and no running server, `~/.fabro/` is removed successfully +- Happy path: with `--yes` and running server, server is stopped before removal +- Edge case: `~/.fabro/` does not exist — reports "nothing to remove" without error +- Error path: server stop fails (SIGKILL also fails) — warns and continues with removal +- Error path: `$FABRO_HOME` set to `/` — refuses to delete, prints error +- Error path: `$FABRO_HOME` set to `$HOME` — refuses to delete, prints error +- Error path: `$FABRO_HOME` points to a directory without Fabro marker files — refuses to delete +- Integration: after removal, `~/.fabro/` directory does not exist on disk + +**Verification:** +- `fabro uninstall --yes` removes `~/.fabro/` completely +- A running server is stopped before file removal +- No orphaned processes remain after uninstall + +--- + +- [x] **Unit 4: Shell config cleanup** + +**Goal:** Remove PATH lines that `install.sh` added to shell configuration files, using the `# fabro` sentinel comment. + +**Requirements:** R3 + +**Dependencies:** Unit 2 (uses shell config detection from inventory) + +**Files:** +- Modify: `lib/crates/fabro-cli/src/commands/uninstall.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/uninstall.rs` + +**Approach:** +- Use the inventory's `shell_configs` list (detected in Unit 2) +- For each shell config file in the list: + - Read the file contents + - Find lines where `line.trim() == "# fabro"` (exact match, not substring — avoids matching `# fabro workflow helper`) + - Validate that the following line matches an expected PATH pattern before removing it: + - zsh/bash: following line starts with `export PATH=` + - fish: following line starts with `fish_add_path` + - If the following line does NOT match, remove only the sentinel line (defensive — the file was manually edited) + - Remove the sentinel line AND the validated following line + - Write the modified contents back + - Handle edge cases: sentinel at end of file, multiple sentinels, sentinel with no following line +- Report each modified file with the deinit checkmark pattern +- If a shell config file is not found or has no fabro lines, skip silently + +**Patterns to follow:** +- `deinit.rs` reporting pattern (green checkmarks per item) +- **Atomic write pattern**: Write modified content to a temporary file in the same directory, then `std::fs::rename()` over the original (atomic on POSIX). This prevents a truncated/corrupt dotfile if the process crashes mid-write. Check `std::fs::symlink_metadata()` before modifying — if the file is a symlink, follow it (matching `std::fs::read_to_string` behavior) but note this in output so users with dotfile managers are aware. + +**Test scenarios:** +- Happy path: `.zshrc` with `# fabro` + PATH line — both lines removed, rest of file intact +- Happy path: `.bashrc` with `# fabro` + PATH line — both lines removed +- Happy path: `.bash_profile` with `# fabro` + PATH line — both lines removed +- Happy path: `config.fish` with `# fabro` + `fish_add_path` — both lines removed +- Edge case: shell config exists but has no `# fabro` line — file is not modified +- Edge case: shell config file does not exist — no error +- Edge case: `# fabro` is the last line in the file (no following PATH line) — only sentinel line removed +- Edge case: multiple `# fabro` blocks in the same file — all are removed +- Edge case: `# fabro` followed by an unrelated line (not PATH export) — only sentinel removed, following line preserved +- Edge case: line contains `# fabro` as substring (`# fabro-related`) — not matched, file unchanged +- Integration: after cleanup, opening a new shell does not add fabro to PATH + +**Verification:** +- Shell config files have fabro lines removed without corrupting other content +- Files without fabro lines are not modified (mtime unchanged) + +--- + +- [x] **Unit 5: Binary status reporting and final output** + +**Goal:** Report whether the binary was already removed (managed install) or print a removal hint (external install). Print final summary. + +**Requirements:** R5 + +**Dependencies:** Unit 3 (must run after directory removal) + +**Files:** +- Modify: `lib/crates/fabro-cli/src/commands/uninstall.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/uninstall.rs` + +**Approach:** +- Use the inventory's pre-resolved `binary_path` and `binary_is_managed` fields (resolved in Unit 2 before any deletion — `canonicalize()` fails on macOS after the file is deleted) +- If `binary_is_managed` is true: the binary was inside `~/.fabro/bin/` and was already removed by Unit 3's `remove_dir_all` — report as removed +- If `binary_is_managed` is false: print a tailored hint + - Check if path contains `/Cellar/` (Homebrew): "run `brew uninstall fabro`" + - Check if path contains `.cargo/bin/` (cargo): "run `cargo uninstall fabro`" + - Otherwise: "The fabro binary at {path} must be removed manually." +- If `binary_path` is `None` (resolution failed in Unit 2): warn and skip +- Print final summary: "Fabro has been uninstalled." (bold, to stderr) +- For `--json` output: include `binary_removed` and `binary_hint` fields + +**Patterns to follow:** +- `deinit.rs` final summary line pattern (bold text) +- Package manager path detection is heuristic — keep it simple + +**Test scenarios:** +- Happy path: binary was in `~/.fabro/bin/` and was already removed by directory deletion — reports binary removed +- Happy path: binary is in `/opt/homebrew/Cellar/` — prints "run `brew uninstall fabro`" hint +- Happy path: binary is in `~/.cargo/bin/` — prints "run `cargo uninstall fabro`" hint +- Edge case: binary path detection fails (`current_exe()` error) — warns but does not fail the overall uninstall +- Happy path: final summary message is printed after all steps complete + +**Verification:** +- When binary was installed via `install.sh`, it is deleted or confirmed deleted +- When binary was installed via other means, a clear removal instruction is printed +- `fabro uninstall --yes --json` outputs valid JSON including inventory, execution results, and binary status +- The command exits successfully after all steps + +## System-Wide Impact + +- **Interaction graph:** The uninstall command interacts with: `server::stop` (server lifecycle), `Home`/`Storage` (path resolution), shell config files (external to fabro), and the running binary itself. No callbacks, middleware, or observers are affected. +- **Error propagation:** Each step warns on failure but continues. Critical steps (server stop, directory removal) affect the exit code (exit 1 on failure). Non-critical steps (shell config, binary) warn only. A partial uninstall is better than aborting on the first error. +- **State lifecycle risks:** Stopping the server terminates in-flight workflow runs — the dry-run preview warns about this. Mitigated by mandatory server stop as the first step (prevents filesystem corruption) and by defaulting to dry-run (gives user a chance to see the warning before committing). +- **API surface parity:** No API endpoint equivalent needed — uninstall is a local-only operation. +- **Unchanged invariants:** `fabro install`, `fabro server`, `fabro repo deinit`, and `system prune` are not modified by this plan. The uninstall command reuses their code but does not change their behavior. + +## Risks & Dependencies + +| Risk | Mitigation | +|------|------------| +| Shell config surgery corrupts user's dotfiles | Use sentinel-based detection (not regex on PATH content). Only remove exact `# fabro` + next line. Write tests with realistic file content. | +| Binary self-deletion race condition | Delete binary as the absolute last step. On Unix, unlink of a running binary is safe (inode persists until process exits). | +| `$FABRO_HOME` set to dangerous path (`/`, `$HOME`) | Safety guardrail: validate resolved path is not a root or home dir, and contains expected marker files, before `remove_dir_all`. | +| Server stop timeout blocks uninstall | Use a short timeout (5s). SIGKILL as fallback. Continue with removal even if stop fails. | +| `stop::execute()` exits process when no server found | Guard with `active_server_record_details()` check; never call `stop::execute()` unconditionally. | +| `canonicalize()` fails after file deletion on macOS | Resolve binary path during inventory phase (Unit 2), before any deletion occurs. | +| Non-default `storage_dir` in settings.toml | Load settings during inventory to resolve actual storage_dir; don't assume it's inside `~/.fabro/`. | +| `$ZDOTDIR` differs between install and uninstall time | Known limitation — document it. Uninstall uses current `$ZDOTDIR`. | +| Partial `remove_dir_all` failure (locked files on macOS) | Warn and report which files remain. Exit 1. User can retry or manually remove. | + +## Sources & References + +- **Origin document:** [docs/ideation/2026-04-08-fabro-uninstall-ideation.md](docs/ideation/2026-04-08-fabro-uninstall-ideation.md) +- Related code: `lib/crates/fabro-cli/src/commands/install.rs`, `lib/crates/fabro-cli/src/commands/server/stop.rs`, `lib/crates/fabro-cli/src/commands/system/prune.rs`, `lib/crates/fabro-cli/src/commands/repo/deinit.rs` +- Related code: `lib/crates/fabro-util/src/home.rs`, `lib/crates/fabro-config/src/storage.rs` +- Related code: `apps/marketing/public/install.sh` diff --git a/docs/plans/2026-04-08-cli-services-command-context-refactor-plan.md b/docs/plans/2026-04-08-cli-services-command-context-refactor-plan.md new file mode 100644 index 000000000..02c988dfb --- /dev/null +++ b/docs/plans/2026-04-08-cli-services-command-context-refactor-plan.md @@ -0,0 +1,160 @@ +# CLI CommandContext And Server Access Refactor Plan + +## Summary +Refactor `fabro-cli` around an invocation-scoped `CommandContext` that centralizes local settings loading and server connection setup for user-facing server-backed commands. This is a greenfield codebase with no backward-compatibility constraints, so the refactor should be done in one full pass for the in-scope command surface rather than preserving a long-lived mixed model. Keep config-layer composition command-local where commands genuinely need it, keep the generated `fabro_api::Client` as the main HTTP interface, and reuse the existing `ServerStoreClient` type instead of layering a second handwritten endpoint facade on top of it. + +## Public Types And Interfaces +- Add eager, invocation-scoped `CommandContext`: + - Holds `cwd`, `base_config_path`, `machine_settings`, `server_mode`, and a cached server client cell. + - `cwd` is the invocation working directory and replaces repeated inline `std::env::current_dir()` lookups in migrated commands such as `preflight`, `validate`, `graph`, `fabro settings`, and workflow-oriented run creation paths. + - `base_config_path` is the local settings file path chosen by `--config`, `FABRO_CONFIG`, or the default path. + - `machine_settings` is the result of the existing local settings loaders for the command: + - base settings for commands using `load_settings()` + - base settings plus storage-dir override for commands using `load_settings_with_storage_dir(...)` + - `machine_settings` does not include workflow or project config layers. + - Do not store `ConfigLayer` on `CommandContext`. +- Keep config-layer composition command-local: + - `fabro settings` continues to build `EffectiveSettingsLayers` with its existing helpers. + - workflow and manifest code continues to use `ConfigLayer::for_workflow(...)`, `discover_project_config(...)`, and existing workflow resolution structs where individual layers matter. + - `CommandContext` should not attempt to reconstruct or cache workflow/project config layers. +- Use two explicit server access modes that match the real connection paths in the codebase: + - `ServerMode::None` + - `ServerMode::ByTarget { target_override: Option }` + - `ServerMode::ByStorageDir { target_override: Option, storage_dir_override: Option }` + - `ByTarget` maps to the current `connect_server_only(...)` behavior. + - `ByTarget` also covers the current `ServerSummaryLookup::connect(...)` resolution path used by run, pr, runs, and artifact lookup commands. + - `ByStorageDir` maps to the current `connect_server_backed_api_client_with_storage_dir(...)` behavior when a real storage-dir-aware connection mode is needed. + - current callers of `connect_server_backed_api_client(...)` migrate to `ByTarget` in this refactor because their existing `None` storage-dir path collapses to the same local settings load as `connect_server_only(...)`. + - Do not collapse these two behaviors into one variant with optional target and storage fields. +- Reuse existing target concepts instead of introducing a second target enum: + - keep `ServerTargetArgs`, `ServerConnectionArgs`, and the resolved `ServerTarget` model already used by `user_config` and `server_client` + - do not introduce `ServerTargetInput` in this pass +- Keep `ServerStoreClient`: + - do not rename it during the same refactor + - add narrow accessors as needed for: + - the generated `fabro_api::Client` + - raw `reqwest::Client` + - `base_url` + - this preserves the current `exec` adapter path without adding a second server session type +- `CommandContext::server().await?`: + - available only for `ServerMode::ByTarget` and `ServerMode::ByStorageDir` + - returns `Arc` + - caches the first successful connection in a `OnceCell` + - does not cache failures; a later retry should attempt a fresh connection + - `ByTarget` performs current target-based resolution and Unix-socket auto-start behavior + - `ByStorageDir` performs current storage-dir-backed daemon resolution and startup behavior + - to make both modes uniform, refactor the current storage-dir-backed path, which now returns a bare `fabro_api::Client`, to construct a `ServerStoreClient` first and expose the generated client through an accessor + - migrated connection logic must use `machine_settings` and `base_config_path` already loaded on `CommandContext`; it should stop re-calling `load_settings()` and `load_settings_with_storage_dir(...)` inside `server_client.rs` + - HTTP and HTTPS targets never auto-start +- Keep the existing run-summary lookup pattern, but separate lookup construction from connection: + - add `ServerSummaryLookup::from_client(client: Arc) -> Result` + - make the existing `ServerSummaryLookup::connect(...)` a compatibility wrapper during migration, then remove direct call sites from migrated commands + - migrated commands that currently do `ServerSummaryLookup::connect(...)` should instead do `ServerSummaryLookup::from_client(ctx.server().await?)` + - this keeps summary listing, sorting, and selector resolution behavior intact while moving connection ownership into `CommandContext` + +## Implementation Changes +- Keep `main` bootstrap ordering intact: + - continue loading enough local settings before tracing init to determine log level and upgrade-check behavior + - `CommandContext` begins after tracing is initialized; it does not replace that earlier bootstrap phase +- Do not introduce `Services` in this pass: + - the current review surfaced that a `Services` wrapper does not pull enough independent process-scoped concerns to justify the extra indirection + - if a later refactor reveals multiple real process-scoped services, add that separately +- Keep style construction local for now: + - several current command paths still rely on `&'static Styles` + - this refactor should not add a style ownership change on top of settings/server wiring cleanup +- Make `map_api_error` deduplication a firm deliverable: + - migrated commands should reuse the shared `server_client::map_api_error` + - remove remaining verbatim local copies in the in-scope command surface +- Implement in this order: + - Step 1: add `CommandContext`, `ServerMode`, `ctx.server()` caching semantics, convert the storage-dir-backed connect path to construct `ServerStoreClient`, add `ServerSummaryLookup::from_client(...)`, and thread preloaded `machine_settings` / `base_config_path` into server resolution so migrated commands stop re-loading settings inside connection helpers + - Step 2: migrate the workflow-oriented server commands: + - the main `run` command in [`commands/run/command.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-cli/src/commands/run/command.rs) + - `run create` + - `run start` + - `run attach` + - `run diff` + - `run logs` + - `run preview` + - `run ssh` + - `run resume` + - `run rewind` + - `run fork` + - `run wait` + - `run cp` + - include the existing internal create → start → attach chain where multiple settings loads and server connects exist today + - `preflight` + - `validate` + - `graph` + - Step 3: migrate the remaining user-facing commands that use target-based resolution or resolved-target lookup: + - `model` + - `secret` + - `provider login` + - `repo init` + - `doctor` + - `pr` (treat separately inside this step because it mixes settings for app ID and `ServerSummaryLookup::connect`) + - `runs` + - `artifact` + - these commands should use `ServerMode::ByTarget` after migration, even when they currently call `connect_server_backed_api_client(...)`, because their existing `None` storage-dir path collapses to the same local settings load as `connect_server_only(...)` + - Step 4: migrate the user-facing commands that genuinely resolve through a storage-dir-aware server mode: + - `system info` + - `system df` + - `system events` + - `system prune` + - these commands should use `ServerMode::ByStorageDir` + - Step 5: adapt `fabro settings` to use `CommandContext` only for base local settings inputs while keeping its existing layer-building and effective-settings logic + - Step 6: remove obsolete helper entrypoints from migrated call sites and reduce `server_client.rs` to the minimal shared surface still needed by explicit out-of-scope and internal commands: + - migrated commands should stop calling `connect_server_only(...)`, `connect_server_backed_api_client(...)`, `connect_server_backed_api_client_with_storage_dir(...)`, and `ServerSummaryLookup::connect(...)` directly + - migrated commands that need run lookup/resolve behavior should use `ServerSummaryLookup::from_client(ctx.server().await?)` + - keep `connect_server(...)`, `connect_api_client(...)`, and `connect_server_target_direct(...)` only for direct storage-dir or direct-target flows that remain explicit out-of-scope or internal +- Stage dependencies: + - Steps 2, 3, and 4 all depend on Step 1 + - Step 5 depends on Step 1 but not on the migration of other command groups + - Step 6 happens only after the other steps are complete +- Explicitly out of scope: + - `exec` + - `server` lifecycle commands + - hidden `run worker` + - `store dump` + - `workflow` + - `parse` + - `repo deinit` + - `install` + - `sandbox` + - `upgrade` + - hidden analytics and panic upload commands + - direct storage-dir run lookup flows, including `ServerRunLookup`, remain unchanged in this pass +- Cleanup target after the pass: + - the remaining old helper surface should exist only for those explicitly out-of-scope or internal commands + - migrated user-facing commands should no longer call settings loaders or top-level server connect helpers directly + +## Test Plan +- New unit tests for `CommandContext` construction: + - base config path precedence remains `--config` > `FABRO_CONFIG` > default path (`$FABRO_HOME/settings.toml` if `FABRO_HOME` is set, else `$HOME/.fabro/settings.toml`) + - missing default base config path is allowed; missing explicit config path still errors + - `machine_settings` reflect only the command's existing local settings load path: + - base settings for target-based commands + - base settings plus storage-dir override for storage-backed commands +- New unit tests for server access modes: + - `ServerMode::ByTarget` matches current target-based resolution + - `ServerMode::ByStorageDir` matches current storage-dir-backed resolution + - Unix-socket targets may auto-start; HTTP and HTTPS targets never auto-start + - `ctx.server()` caches a successful client and retries after failures +- Existing regression coverage that must keep passing for config-layer commands: + - workflow and manifest code still layer workflow and project config exactly as before + - `fabro settings` local, daemon, and remote effective-settings modes remain unchanged +- Existing regression coverage that must keep passing for special cases: + - `exec` with no explicit server target still runs directly against providers + - `exec` with an explicit server target still constructs the server-backed adapter path correctly using `ServerStoreClient` accessors +- Existing integration coverage that must keep passing for migrated command groups: + - Step 2: the main `run` command, `run create`, `run start`, `run attach`, `run diff`, `run logs`, `run preview`, `run ssh`, `run resume`, `run rewind`, `run fork`, `run wait`, `run cp`, `preflight`, `validate`, and `graph` + - Step 3: representative `model`, `secret`, `provider login`, `repo init`, `doctor`, `pr`, `artifact`, and `runs` commands + - Step 4: representative `system info`, `system df`, `system events`, and `system prune` commands + - Step 5: `fabro settings` base-settings-input path adopted from `CommandContext` while layer-building and effective-settings logic stay unchanged + +## Assumptions And Defaults +- This is a greenfield codebase with no production compatibility constraints, so one full-pass refactor across the in-scope user-facing command surface is acceptable. +- `CommandContext` is for shared local settings loading and server access only; it is not a universal repository for every config layer or every command concern. +- Commands that need workflow/project layering continue to compute those layers locally from the existing config helpers. +- `ServerStoreClient` remains the shared server connection type in this pass and may gain accessors, but not a second handwritten endpoint layer. +- `exec` remains outside the main abstraction because it still has a real direct-provider fallback mode that is different from normal server-backed commands. +- "Resolved path" means the same path shape produced by current helpers: expanded and made absolute where the helpers already do so, but not canonicalized in a way that would require the file to exist. diff --git a/docs/plans/2026-04-08-fabro-spa-asset-crate-plan.md b/docs/plans/2026-04-08-fabro-spa-asset-crate-plan.md new file mode 100644 index 000000000..17cc544f2 --- /dev/null +++ b/docs/plans/2026-04-08-fabro-spa-asset-crate-plan.md @@ -0,0 +1,134 @@ +# Fabro SPA Asset Crate Plan + +## Summary +Create a new internal crate, `fabro-spa`, as the runtime source of truth for the production web bundle. The crate will contain committed built assets and expose a narrow asset API backed by `rust-embed` with compression and include/exclude filtering. This removes Bun/Node from Cargo builds, release builds, and runtime, while preserving the current low-complexity local workflow: `fabro server start` plus `cd apps/fabro-web && bun run dev`, with browser refreshes picking up rebuilt files. + +## Key Decisions +- Commit the production bundle in `lib/crates/fabro-spa/assets`. + - This is the explicit tradeoff for keeping Bun out of Cargo builds, release builds, and runtime. A CI-only asset build would keep the release path coupled to Bun, which is the dependency boundary this plan is trying to remove. + - Repo growth is accepted in exchange for a fully self-contained Rust release artifact. Mitigations in this plan are: no sourcemaps, generated-asset `.gitattributes`, a `15 MB` committed-asset size budget, and a separate `5 MB` embedded-payload budget. +- Keep Bun as an authoring/build-time tool only. + - Do not add Bun to Rust CI or release builds. + - Do not add HMR or a separate browser dev server in this pass. +- `fabro-spa` returns plain file bytes, not content-encoded payloads. + - `rust-embed` compression is an implementation detail inside the crate. + - The `fabro-server` caller should continue to receive normal file bytes for MIME detection and response body construction. +- Define refresh-script idempotency as: running the refresh script twice on unchanged frontend source produces no git diff under `lib/crates/fabro-spa/assets`. +- Keep debug disk fallback anchored to `env!("CARGO_MANIFEST_DIR")`, matching the current server behavior. + - Do not make path resolution depend on cwd. + - Do not add a new env var override in this iteration. + +## Implementation Changes +### 1. Package the production SPA in `fabro-spa` +- Add `lib/crates/fabro-spa` with a committed `assets/` directory populated from `apps/fabro-web/dist`. +- In `fabro-spa`, use `rust-embed` with: + - `compression` + - `include-exclude` + - `deterministic-timestamps` if needed to keep embedded metadata stable across identical rebuilds +- Exclude sourcemaps in two places: + - the refresh script must not copy any `*.map` files into `fabro-spa/assets` + - the embed definition must also exclude `*.map` and `**/*.map` +- Keep the crate API narrow and owned by `fabro-spa`. + - Expose `get(path: &str) -> Option` where `AssetBytes` is a crate-local wrapper around the underlying bytes. + - `AssetBytes` should provide access to the normal file bytes only; it must not expose `rust-embed` types or compression details. + - Do not add `iter()` in v1. If tests need enumeration, add a dedicated test-only helper later. + +### 2. Move `fabro-server` to consume `fabro-spa` +- Replace the direct `RustEmbed` usage in `lib/crates/fabro-server/src/static_files.rs` with calls into `fabro-spa`. +- Preserve existing server behavior: + - SPA fallback to `index.html` + - MIME detection in the server + - current cache-control behavior for hashed assets vs entry/root assets +- Preserve low-friction local development by keeping a debug-only disk override. + - In debug builds, first check `apps/fabro-web/dist/` on disk using a path derived from `env!("CARGO_MANIFEST_DIR")`. + - If the file exists, serve it directly. + - Otherwise fall back to the embedded `fabro-spa` asset. +- Remove the current Bun-based release build hook in `lib/crates/fabro-server/build.rs`. +- Remove the direct `rust-embed` dependency from `fabro-server` once the embed lives entirely in `fabro-spa`. + +### 3. Keep the current Bun authoring/build path +- Keep `apps/fabro-web` as the authoring app and keep the existing custom Bun production build script. +- Keep `bun run dev` as the watch-and-rebuild command, with no HMR or browser dev server added in this pass. +- The local workflow remains: + - run `fabro server start` + - run `cd apps/fabro-web && bun run dev` + - refresh the browser manually after rebuilds +- Do not add a Rust-side dev proxy, a frontend HMR server, or a bundler migration in this iteration. + +### 4. Add an explicit asset refresh workflow +- Add a repo-level refresh command, `scripts/refresh-fabro-spa.sh`. +- The script should: + - run the existing web production build in `apps/fabro-web` + - fully replace `lib/crates/fabro-spa/assets` rather than incrementally syncing it + - copy only shippable files from `dist` + - exclude all sourcemaps +- Treat git state as the source of idempotency truth. + - Two consecutive runs on unchanged frontend source must leave no git diff under `lib/crates/fabro-spa/assets`. + - Timestamps in the filesystem do not matter; only committed file content and paths matter. +- Make this script the only supported way to update committed SPA assets. + +### 5. Add repo hygiene and CI guardrails +- Add `.gitattributes` entries for `lib/crates/fabro-spa/assets/**`. + - Mark the directory as generated for repository tooling. + - Suppress noisy diffs for minified hashed bundles. + - Do not use Git LFS in v1 unless the committed asset size proves unmanageable. +- Add a size budget check for `lib/crates/fabro-spa/assets`. + - Fail CI if total committed asset size exceeds `15 MB` without an explicit budget update. + - This budget is intentionally above the current no-sourcemap bundle size, which is about `12.3 MB` in the current branch, so the initial implementation passes with modest headroom. +- Add a separate embedded-payload budget check for the release binary. + - Fail CI or release verification if the estimated compressed `fabro-spa` asset payload exceeds `5 MB` without an explicit budget update. + - This budget is intentionally above the current estimated compressed payload, which is about `2.8 MB` in the current branch, so the initial implementation passes with meaningful headroom. + - Include a release-build smoke check so accidental asset over-inclusion is visible before merge. +- Keep drift verification in Bun-capable CI, not Rust CI. + - Update the TypeScript workflow to run the refresh script and fail if it leaves a diff under `lib/crates/fabro-spa/assets`. + - Expand that workflow's path coverage to include both `apps/fabro-web/**` and `lib/crates/fabro-spa/**`. + - Treat this freshness job as the authoritative stale-asset check for frontend changes. +- Leave the Rust/release workflows Bun-free. + +### 6. Update docs around the new split +- Update docs that currently say `bun run dev` serves the app on port `5173`; they should instead describe the watch-build plus manual-refresh flow against `fabro server start`. +- Update architecture/deployment docs that imply `fabro-server` builds frontend assets during release. +- Document the new refresh step for contributors who change `apps/fabro-web`. + +## Interfaces And Workflow Changes +- New internal crate: `lib/crates/fabro-spa` +- Removed implicit build coupling: `cargo build` and release builds must no longer invoke Bun +- No HTTP API changes +- No new public CLI flags or env vars in this iteration +- New developer-maintained artifact boundary: + - `apps/fabro-web` remains source code + - `lib/crates/fabro-spa/assets` becomes committed runtime bundle output + +## Test Plan +- Add server tests covering: + - embedded asset serving when no disk bundle is present + - debug disk override when `apps/fabro-web/dist` contains a rebuilt file + - SPA fallback to `index.html` for unknown client routes + - immutable cache headers for hashed assets + - no-cache headers for entry/root assets + - `.map` files are not served +- Add refresh-script verification covering: + - running the refresh script twice without source changes produces no git diff + - asset replacement strips sourcemaps reliably + - the committed asset directory stays under the `15 MB` size budget +- Add release-build verification covering: + - `cargo build --release` succeeds without Bun installed + - the estimated compressed embedded asset payload stays under the `5 MB` budget + - the resulting binary size is recorded during CI/manual verification so the measured end-to-end delta is visible +- Run: + - `cd apps/fabro-web && bun run build` + - the refresh script + - `cargo nextest run -p fabro-server` + - `cargo build --release` +- Manual smoke check: + - start the Rust server + - run `cd apps/fabro-web && bun run dev` + - change a UI file + - confirm the rebuilt asset is served after a browser refresh + +## Assumptions +- The production bundle is intentionally committed to `lib/crates/fabro-spa/assets` to preserve Bun-free Cargo and release builds. +- Sourcemaps are not shipped in the binary and are not copied into the asset crate. +- Local web development accepts manual refresh; no HMR/dev server is part of this plan. +- Bun remains a frontend authoring/build-time dependency only; it is removed from runtime, Cargo build, and release requirements. +- A future bundler migration or HMR setup is explicitly deferred rather than partially introduced here. diff --git a/docs/plans/2026-04-08-optional-web-ui-server-plan.md b/docs/plans/2026-04-08-optional-web-ui-server-plan.md new file mode 100644 index 000000000..1d97e9c7e --- /dev/null +++ b/docs/plans/2026-04-08-optional-web-ui-server-plan.md @@ -0,0 +1,97 @@ +# Optional Web UI for `fabro server` Plan + +## Summary +Make the Fabro server able to run in two surfaces: + +- API-only +- API + web UI + +The always-on surface is `/health` plus the machine API under `/api/v1`. The web surface includes the embedded SPA fallback, browser auth routes under `/auth/*`, and the browser-session/setup/demo helper endpoints that currently live under `/api/v1` in `web_auth::api_routes()`: + +- `/api/v1/auth/me` +- `/api/v1/setup/register` +- `/api/v1/setup/status` +- `/api/v1/demo/toggle` + +When the web UI is disabled, all of those web-surface routes return `404`, even when they live under `/api/v1`. API-only mode is a routing change only; it does not remove the embedded SPA from the binary. + +Operator control should use both config and CLI: + +- Add `web.enabled` to server settings +- Add `--web` / `--no-web` startup overrides +- Precedence: CLI override > config > default +- Default: web UI enabled + +## Key Changes +### Config and CLI surface +- Extend the existing `[web]` config section with `enabled`. +- Add `enabled: Option` to the config model and `enabled: bool` to resolved settings, defaulting to `true`. +- Add mutually exclusive `--web` and `--no-web` flags to server startup args. +- Define merge behavior explicitly: combine `WebConfig` sources first with last-non-`None` wins for `enabled`, then materialize resolved `WebSettings.enabled`, then apply the CLI override last. +- Apply the flags in the same resolved-settings pass that already handles other serve-time overrides. +- Update help text and docs to describe the new toggle and its precedence. + +### Router composition +- Refactor server router construction so the machine API, web surface, and health surface are composed separately. +- Keep these always mounted: + - machine API routes under `/api/v1`, excluding the routes currently provided by `web_auth::api_routes()` + - `/health` +- Treat these as web-only routes, even when they live under `/api/v1`: + - `/api/v1/auth/me` + - `/api/v1/setup/register` + - `/api/v1/setup/status` + - `/api/v1/demo/toggle` + - `/auth/*` + - SPA/static fallback for non-API `GET`/`HEAD` +- The current SPA is served from the fallback closure, not a mounted router. In API-only mode, that fallback closure must return `404` for all non-API, non-health routes instead of serving the SPA. +- In API-only mode, disable the browser-oriented demo/session behavior as well: + - `/api/v1/demo/toggle` returns `404` + - cookie-driven and `X-Fabro-Demo` header demo dispatch do not route requests into the auth-disabled demo router + - requests use only the normal machine API router plus `/health` +- When the web UI is enabled, preserve current browser session, setup, and demo-cookie behavior. + +### Internal interface changes +- Thread a resolved `web_enabled` boolean into router construction. +- Prefer making this explicit in the server boundary, for example by extending `build_router(...)` with a surface/options argument, rather than re-reading settings inside the router. +- `build_router(state, auth_mode)` has a broad test blast radius. Minimize churn by introducing a small `RouterOptions`-style parameter or helper wrapper so tests that do not care about web surface toggling can keep using the default-enabled path. +- Keep the decision centralized in startup/resolution code so tests can build routers deterministically. + +## Test Plan +- Router tests: + - web enabled: `GET /` serves SPA + - web enabled: `/auth/*` remains available + - web enabled: `/api/v1/auth/me`, `/api/v1/setup/status`, and `/api/v1/demo/toggle` keep their current behavior + - web disabled: `GET /` returns `404` + - web disabled: client routes like `/runs/abc` return `404` + - web disabled: `/auth/*` returns `404` + - web disabled: `/api/v1/auth/me`, `/api/v1/setup/register`, `/api/v1/setup/status`, and `/api/v1/demo/toggle` return `404` + - web disabled: representative machine API endpoints still work + - web disabled: cookie-driven or `X-Fabro-Demo` requests do not enter auth-disabled demo dispatch + - `/health` still works in both modes +- CLI/config tests: + - default startup serves web UI + - config `web.enabled = false` disables the UI + - `--web` overrides config-disabled to enable + - `--no-web` overrides config-enabled/default to disable + - help snapshots include `--web` and `--no-web` +- Regression coverage: + - existing source-map/static routing tests still pass in enabled mode + - existing server lifecycle tests still pass with default behavior unchanged + +## Docs and User-Facing Behavior +- Update CLI docs for `fabro server start` to describe `--web` / `--no-web`. +- Update server configuration docs to document `[web].enabled`. +- Update deploy/architecture docs so “server mode” no longer implies the web UI is always present. +- Call out that “web UI disabled” means: + - no `/auth/*` + - no web-session/setup/demo helper endpoints under `/api/v1` + - no SPA fallback at `/` or client routes + - machine API and `/health` only + - embedded SPA assets are still compiled into the binary; this is not a build-time exclusion + +## Assumptions and Defaults +- Default remains web UI enabled. +- CLI flags are `--web` and `--no-web`. +- CLI override precedence is standard: CLI > config > default. +- Disabling the web UI is strictly an HTTP-surface change; it does not disable workflow execution, machine API behavior, or server-owned background services. +- No OpenAPI/API schema changes are needed, since this only changes route availability and classification within the existing server. diff --git a/docs/plans/2026-04-08-production-web-ui-test-plan.md b/docs/plans/2026-04-08-production-web-ui-test-plan.md new file mode 100644 index 000000000..98556eb7d --- /dev/null +++ b/docs/plans/2026-04-08-production-web-ui-test-plan.md @@ -0,0 +1,269 @@ +# Production Web UI Test Plan + +## Harness requirements + +### 1. Rust HTTP integration harness (existing) + +- **What it does:** Sends HTTP requests to the fabro server via `tower::ServiceExt::oneshot()` against an in-memory `AppState`. No network, no boot overhead. +- **What it exposes:** Full request/response cycle including headers, status codes, JSON bodies. Direct mutation of `AppState` (e.g., setting run status) for precondition setup. +- **Estimated complexity:** Already exists (`lib/crates/fabro-server/tests/it/helpers.rs`). Provides `test_app_state()`, `build_router()`, `body_json()`, `create_and_start_run()`, `api()`, etc. +- **Which tests depend on it:** Tests 1-6 + +### 2. Playwright browser harness (new -- must be built) + +- **What it does:** Launches a real browser against a running fabro server that serves the built SPA. Exercises the full stack: React app fetching real HTTP endpoints, rendering real DOM, cookies for demo mode. +- **What it exposes:** Page navigation, DOM element inspection, cookie management, screenshot capture, click/interaction simulation. +- **Estimated complexity:** Moderate. Requires: + 1. Install `@playwright/test` as a dev dependency in `apps/fabro-web` + 2. Create `apps/fabro-web/tests/playwright.config.ts` with `webServer` config that builds the SPA and starts the fabro server with `AuthMode::Disabled` and in-memory store + 3. The server already serves the built SPA when `FABRO_STATIC_DIR` is configured. Auth disabled mode returns a synthetic user from `/auth/me`. +- **Which tests depend on it:** Tests 7-14 + +### 3. TypeScript unit test harness (existing) + +- **What it does:** Runs bun tests for pure TypeScript functions and React component logic. +- **What it exposes:** Direct function calls, React `renderToString` for context providers. +- **Estimated complexity:** Already exists (`bun test`). +- **Which tests depend on it:** Tests 15-18 + +--- + +## Test plan + +### Test 1: Demo `/boards/runs` returns RunListItem-shaped data + +- **Name:** Requesting the runs board in demo mode returns run list items with repository, title, workflow, and board column status +- **Type:** integration +- **Disposition:** new +- **Harness:** Rust HTTP integration harness +- **Preconditions:** Server initialized with `AuthMode::Disabled`. Demo header `X-Fabro-Demo: 1` set on request. +- **Actions:** `GET /api/v1/boards/runs` with `X-Fabro-Demo: 1` header +- **Expected outcome:** HTTP 200. Response body has `data` array. First item has string `id`, object `repository` (with `name`), string `title`, object `workflow` (with `slug`), string `status` that is one of `"working"`, `"pending"`, `"review"`, `"merge"`, and string `created_at`. Source of truth: OpenAPI spec `PaginatedRunList` schema and plan Decision 2. +- **Interactions:** Demo data module (`demo/mod.rs` `runs::list_items()`) + +### Test 2: Demo `GET /runs/{id}` returns StoreRunSummary shape (not RunStatusResponse) + +- **Name:** Requesting a single run in demo mode returns the StoreRunSummary shape with run_id, goal, and workflow fields instead of the old RunStatusResponse shape +- **Type:** integration +- **Disposition:** new +- **Harness:** Rust HTTP integration harness +- **Preconditions:** Server initialized with `AuthMode::Disabled`. Demo header set. Run ID "run-1" exists in demo data. +- **Actions:** `GET /api/v1/runs/run-1` with `X-Fabro-Demo: 1` header +- **Expected outcome:** HTTP 200. Body has `run_id` (string), `goal` (string), `workflow_slug` (string), `workflow_name` (string), `host_repo_path` (string), `status` (string), `duration_ms` (number or null). Body does NOT have `queue_position` field. Source of truth: OpenAPI spec `StoreRunSummary` schema and plan Decision 3. +- **Interactions:** Demo data module + +### Test 3: Demo `GET /runs/{id}` returns 404 for unknown run + +- **Name:** Requesting a nonexistent run in demo mode returns 404 +- **Type:** boundary +- **Disposition:** new +- **Harness:** Rust HTTP integration harness +- **Preconditions:** Server initialized with `AuthMode::Disabled`. Demo header set. +- **Actions:** `GET /api/v1/runs/nonexistent-run-id` with `X-Fabro-Demo: 1` header +- **Expected outcome:** HTTP 404. Source of truth: OpenAPI spec 404 error response. +- **Interactions:** Demo data module + +### Test 4: Real `/boards/runs` returns RunListItem shape with board columns + +- **Name:** Requesting the runs board in production mode returns enriched run list items with board column statuses instead of lifecycle statuses +- **Type:** integration +- **Disposition:** new +- **Harness:** Rust HTTP integration harness +- **Preconditions:** Server initialized with `AuthMode::Disabled`, no demo header. A run created and started, then set to `RunStatus::Running` in state. +- **Actions:** `GET /api/v1/boards/runs` (no demo header) +- **Expected outcome:** HTTP 200. Response has `data` array. The created run appears with string `id`, string `title`, object `repository` (with `name`), object `workflow` (with `slug`), string `status` equal to `"working"` (the board column mapping of `Running`), and string `created_at`. Source of truth: OpenAPI spec `PaginatedRunList` schema and plan Decision 1 (Running -> "working"). +- **Interactions:** `state.runs` mutex, `state.store.list_runs()` + +### Test 5: Real `/boards/runs` excludes non-board statuses + +- **Name:** Runs with statuses that don't map to board columns (Submitted, Queued, Starting, Failed, Cancelled) are excluded from the board response +- **Type:** boundary +- **Disposition:** new +- **Harness:** Rust HTTP integration harness +- **Preconditions:** Server initialized. A run created and started, then set to `RunStatus::Failed`. +- **Actions:** `GET /api/v1/boards/runs` (no demo header) +- **Expected outcome:** HTTP 200. The failed run does NOT appear in the `data` array. Source of truth: Plan Decision 1 status mapping -- Failed is excluded. +- **Interactions:** `state.runs` mutex + +### Test 6: Real `/boards/runs` maps Paused to "pending" and Completed to "merge" + +- **Name:** Board column mapping correctly translates Paused to pending and Completed to merge +- **Type:** integration +- **Disposition:** new +- **Harness:** Rust HTTP integration harness +- **Preconditions:** Server initialized. Two runs created: one set to `RunStatus::Paused`, one set to `RunStatus::Completed`. +- **Actions:** `GET /api/v1/boards/runs` (no demo header) +- **Expected outcome:** HTTP 200. Paused run has `status: "pending"`. Completed run has `status: "merge"`. Source of truth: Plan Decision 1 status mapping. +- **Interactions:** `state.runs` mutex, `state.store.list_runs()` + +### Test 7: Runs board loads without errors in production mode (browser) + +- **Name:** Navigating to the runs board in production mode renders a page without error messages +- **Type:** scenario +- **Disposition:** new +- **Harness:** Playwright browser harness +- **Preconditions:** Fabro server running with auth disabled and in-memory store. SPA built and served. No demo cookie set. +- **Actions:** Navigate to `/runs`. +- **Expected outcome:** Page loads. Body does not contain "Unauthorized" or "500" or "error" (case-insensitive check excluding expected UI text). Screenshot captured as artifact at `test-results/runs-prod.png`. Source of truth: User request ("make it production grade" -- the runs board is the primary view and must render). +- **Interactions:** React Router loader -> `apiFetch("/boards/runs")` -> server real `list_board_runs` handler + +### Test 8: Navigation hides Workflows and Insights in production mode (browser) + +- **Name:** The sidebar/header navigation does not show Workflows or Insights links when not in demo mode +- **Type:** scenario +- **Disposition:** new +- **Harness:** Playwright browser harness +- **Preconditions:** Server running, no demo cookie. +- **Actions:** Navigate to `/runs`. Inspect the `nav` element. +- **Expected outcome:** `nav` element does NOT contain text "Workflows". `nav` element does NOT contain text "Insights". `nav` element DOES contain text "Runs". Source of truth: Plan Decision 4 and user request ("remove the corresponding UI from fabro web for now"). +- **Interactions:** App shell loader -> `getAuthMe()` -> `demoMode: false` -> `getVisibleNavigation(false)` filters out demo-only items + +### Test 9: Settings page loads in production mode (browser) + +- **Name:** The settings page loads successfully in production mode since /settings is implemented in the real server +- **Type:** scenario +- **Disposition:** new +- **Harness:** Playwright browser harness +- **Preconditions:** Server running, no demo cookie. +- **Actions:** Navigate to `/settings`. +- **Expected outcome:** Page loads without "Not implemented" or "500" error text. Screenshot captured at `test-results/settings-prod.png`. Source of truth: Real server route table has `get(get_server_settings)` for `/settings`. +- **Interactions:** Settings route loader -> `apiJson("/settings")` -> real `get_server_settings` handler + +### Test 10: Demo mode runs board shows demo data (browser) + +- **Name:** Navigating to the runs board with the demo cookie set renders demo run data +- **Type:** scenario +- **Disposition:** new +- **Harness:** Playwright browser harness +- **Preconditions:** Server running. `fabro-demo=1` cookie set for localhost. +- **Actions:** Navigate to `/runs`. +- **Expected outcome:** Page loads without error. Body content is not empty/blank (confirms demo data rendered). Screenshot captured at `test-results/runs-demo.png`. Source of truth: Plan Decision 2 (demo `/boards/runs` returns demo run list items). +- **Interactions:** Demo cookie -> server `X-Fabro-Demo` middleware -> demo `list_board_runs` -> demo run data + +### Test 11: Navigation shows all items in demo mode (browser) + +- **Name:** The navigation shows Workflows, Runs, and Insights links when in demo mode +- **Type:** scenario +- **Disposition:** new +- **Harness:** Playwright browser harness +- **Preconditions:** Server running. Demo cookie set. +- **Actions:** Navigate to `/runs`. Inspect `nav` element. +- **Expected outcome:** `nav` contains "Workflows", "Runs", and "Insights". Source of truth: Plan Decision 4 -- all nav items visible in demo mode. +- **Interactions:** App shell loader -> `getAuthMe()` -> `demoMode: true` -> `getVisibleNavigation(true)` includes all items + +### Test 12: Workflows page loads in demo mode (browser) + +- **Name:** The workflows page renders successfully when in demo mode +- **Type:** scenario +- **Disposition:** new +- **Harness:** Playwright browser harness +- **Preconditions:** Server running. Demo cookie set. +- **Actions:** Navigate to `/workflows`. +- **Expected outcome:** Page loads without "error" or "500" text. Screenshot captured at `test-results/workflows-demo.png`. Source of truth: Demo routes include workflow handlers. +- **Interactions:** Workflows route loader -> demo workflow handlers + +### Test 13: Toggling demo mode changes navigation (browser) + +- **Name:** Clicking the demo toggle button switches between production and demo navigation +- **Type:** scenario +- **Disposition:** new +- **Harness:** Playwright browser harness +- **Preconditions:** Server running. No demo cookie initially. +- **Actions:** + 1. Navigate to `/runs` + 2. Verify nav does NOT contain "Workflows" + 3. Click the beaker button (demo toggle -- `button[title*="demo"], button[title*="Demo"]`) + 4. Wait for page to revalidate + 5. Verify nav now DOES contain "Workflows" +- **Expected outcome:** Before toggle: no "Workflows" in nav. After toggle: "Workflows" appears in nav. Screenshot captured at `test-results/after-toggle-demo.png`. Source of truth: Plan Decision 4 -- `toggleDemoMode()` posts to `/api/v1/demo/toggle`, sets cookie, revalidates, `demoMode` changes, nav re-renders. +- **Interactions:** Demo toggle button -> `POST /demo/toggle` -> cookie set -> revalidate -> `getAuthMe()` returns `demoMode: true` -> `getVisibleNavigation(true)` + +### Test 14: Run detail page loads in production mode (browser) + +- **Name:** Navigating to a run detail page in production mode renders without crashing, even when no runs exist +- **Type:** scenario +- **Disposition:** new +- **Harness:** Playwright browser harness +- **Preconditions:** Server running with in-memory store, no demo cookie, no runs created. +- **Actions:** Navigate to `/runs/nonexistent-id`. +- **Expected outcome:** Page renders (may show "Run not found" message). Does NOT crash with unhandled error or blank page. Source of truth: Plan Decision 6 -- run-detail loader uses `/runs/{id}` which returns 404, and component handles `run: null`. +- **Interactions:** Run detail loader -> `apiJson("/runs/nonexistent-id")` -> 404 -> graceful error handling + +### Test 15: `mapRunSummaryToRunItem` correctly maps StoreRunSummary fields to RunItem + +- **Name:** The mapping function converts server run summary response fields to the UI's RunItem shape +- **Type:** unit +- **Disposition:** new +- **Harness:** TypeScript unit test harness (bun test) +- **Preconditions:** None (pure function). +- **Actions:** Call `mapRunSummaryToRunItem()` with a complete `RunSummaryResponse` object containing `run_id`, `goal`, `workflow_slug`, `host_repo_path`, `duration_ms`. +- **Expected outcome:** Returns `RunItem` with `id` equal to `run_id`, `title` equal to `goal`, `workflow` equal to `workflow_slug`, `repo` equal to last segment of `host_repo_path`, `elapsed` formatted from `duration_ms`. Source of truth: Plan Decision 6 field mapping specification. +- **Interactions:** `formatElapsedSecs()` from `lib/format.ts` + +### Test 16: `mapRunSummaryToRunItem` handles null optional fields + +- **Name:** The mapping function provides sensible defaults when optional fields are null +- **Type:** boundary +- **Disposition:** new +- **Harness:** TypeScript unit test harness (bun test) +- **Preconditions:** None (pure function). +- **Actions:** Call `mapRunSummaryToRunItem()` with all optional fields set to null (`goal: null`, `workflow_slug: null`, `host_repo_path: null`, `duration_ms: null`). +- **Expected outcome:** Returns `RunItem` with `title: "Untitled run"`, `workflow: "unknown"`, `repo: "unknown"`, `elapsed: undefined`. Source of truth: Plan Task 4 step 3 default values. +- **Interactions:** None + +### Test 17: `DemoModeProvider` provides demo mode value to children + +- **Name:** The React context correctly propagates the demo mode boolean to consumer components +- **Type:** unit +- **Disposition:** new +- **Harness:** TypeScript unit test harness (bun test) +- **Preconditions:** None. +- **Actions:** Render `` using `renderToString`. `TestConsumer` reads `useDemoMode()`. +- **Expected outcome:** Rendered HTML contains `data-demo="true"` and text "demo". Source of truth: Plan Decision 7 -- `DemoModeProvider` wraps `Outlet` with context value. +- **Interactions:** React context system + +### Test 18: `getVisibleNavigation` filters demo-only items in production mode + +- **Name:** The navigation filtering function excludes demo-only navigation items when not in demo mode +- **Type:** unit +- **Disposition:** new +- **Harness:** TypeScript unit test harness (bun test) +- **Preconditions:** None (pure function). +- **Actions:** Call `getVisibleNavigation(false)` and `getVisibleNavigation(true)`. +- **Expected outcome:** `getVisibleNavigation(false)` returns items that include "Runs" but NOT "Workflows" or "Insights". `getVisibleNavigation(true)` returns items that include "Runs", "Workflows", and "Insights". Source of truth: Plan Decision 4 -- Workflows and Insights are `demoOnly: true`. +- **Interactions:** None + +--- + +## Coverage summary + +### Covered areas + +| Area | Tests | Coverage approach | +|---|---|---| +| Demo `/boards/runs` endpoint (new) | 1, 10 | Server integration + browser | +| Demo `GET /runs/{id}` shape fix | 2, 3 | Server integration | +| Real `/boards/runs` RunListItem enrichment | 4, 5, 6 | Server integration | +| Real `/boards/runs` board column mapping | 4, 5, 6 | Server integration | +| Run detail loader using `/runs/{id}` | 14, 15, 16 | Browser + unit | +| Demo mode toggle | 13 | Browser | +| Navigation filtering by demo mode | 8, 11, 18 | Browser + unit | +| DemoModeProvider context | 17 | Unit | +| mapRunSummaryToRunItem | 15, 16 | Unit | +| Runs board in production mode | 7 | Browser | +| Settings page in production mode | 9 | Browser | +| Workflows page in demo mode | 12 | Browser | + +### Explicitly excluded (per agreed strategy) + +| Area | Reason | Risk | +|---|---|---| +| Run stages tab rendering | Hidden in production mode; demo-only route. Unchanged code. | Low -- existing demo data path untouched. | +| Run files tab | Always hidden (endpoint does not exist). Tab removed from UI. | None. | +| Insights page deep interaction | Demo-only route. Unchanged code. Out of scope per "remove UI for removed functionality." | Low. | +| Workflow detail/diagram/runs pages | Demo-only routes. Static data, unchanged. | Low. | +| Run overview graph rendering (Graphviz) | Depends on `@viz-js/viz` WASM, hard to test in headless browser. Loader resilience tested via browser smoke. | Medium -- if WASM fails to load, graph won't render, but the page will still load with "No workflow graph available" fallback. | +| Run billing tab | Unchanged, already works in both modes. | None. | +| SSE event streaming | Unchanged, not part of this task. | None. | +| Performance benchmarks | Low performance risk -- no new hot paths, board query is bounded by in-memory run count. | Low. | +| Existing server test regressions from `/boards/runs` shape change | Existing tests that assert `status_reason`/`pending_control` from `/boards/runs` will need updating. Covered by implementation plan Task 3 Step 5, verified by running full `cargo nextest run -p fabro-server`. | Medium -- if missed, existing tests fail, caught immediately by CI. | diff --git a/docs/plans/2026-04-08-production-web-ui.md b/docs/plans/2026-04-08-production-web-ui.md new file mode 100644 index 000000000..3e9c9e7b9 --- /dev/null +++ b/docs/plans/2026-04-08-production-web-ui.md @@ -0,0 +1,1161 @@ +# Production Web UI Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use trycycle-executing to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Wire the fabro web UI to the real fabro server via HTTP, retain a demo mode toggle, and remove UI for server features that are not implemented in real mode. + +**Architecture:** The web UI already calls the fabro server's `/api/v1/` endpoints via `apiFetch`/`apiJson` helpers. Demo mode is toggled per-request via a `fabro-demo` cookie that the server middleware converts to an `X-Fabro-Demo: 1` header, dispatching to separate demo vs real route sets. The core work is: (1) fix the real `/boards/runs` handler to return the `RunListItem` shape the UI expects, (2) make the UI conditionally hide navigation and routes for features not implemented in real mode (workflows, insights, run files), (3) add a demo `/boards/runs` handler so demo mode also works through this endpoint, and (4) fix the demo `get_run_status` to return `StoreRunSummary` shape matching the OpenAPI spec so the run-detail loader works in both modes. + +**Tech Stack:** Rust (Axum server), TypeScript (React 19 + React Router + Vite), Playwright (browser tests) + +--- + +## Key architectural decisions + +### Decision 1: Fix real `/boards/runs` to return `RunListItem` shape + +The OpenAPI spec declares `/boards/runs` returns `PaginatedRunList` containing `RunListItem` objects. However, the real `list_board_runs` handler currently returns `RunStatusResponse` objects (id, status, error, queue_position, created_at). The UI's runs board, run-detail, and run-overview loaders all consume `/boards/runs` and expect `RunListItem` fields (repository, title, workflow, status as `BoardColumn`, pull_request, timings, sandbox, question). + +**Decision:** Enrich the real `list_board_runs` handler to return `RunListItem`-shaped data by pulling `goal` (as title), `workflow_slug`/`workflow_name`, `host_repo_path` (as repository name), `duration_ms` (as timing), and `total_usd_micros` from `RunSummary`. Map `RunStatus` lifecycle values to `BoardColumn` values: `Running` -> `"working"`, `Paused` -> `"pending"`, `Completed` -> `"merge"`, everything else (`Submitted`, `Queued`, `Starting`, `Failed`, `Cancelled`) -> excluded from the board (they are not actionable board items). + +**Justification:** This aligns the real handler with the OpenAPI spec and avoids bifurcating the UI's data layer into two incompatible response shapes. The store already has the needed fields. Fields not available from the store (pull_request, sandbox, checks, question) are left `null`/absent -- the UI already handles their optionality with `?.` chains. + +**Impact on existing tests:** Several existing integration tests (e.g., `cancel_run_sets_status_reason`, `cancel_run_overwrites_pending_pause_request`, queue position tests) assert on `status_reason` and `pending_control` fields from `/boards/runs` responses. These fields exist on `RunStatusResponse` but not on `RunListItem`. These tests use `/boards/runs` as a secondary assertion to verify server state -- they should be updated to assert those fields via `/runs/{id}` (which returns `StoreRunSummary` containing both fields) instead, then assert the board-specific shape from `/boards/runs`. + +### Decision 2: Add `/boards/runs` to demo routes + +The demo routes currently have `/runs` but NOT `/boards/runs`. The UI exclusively calls `/boards/runs` for the runs board. Since the server dispatches to demo vs real routes based on the `X-Fabro-Demo` header, and both route sets need `/boards/runs`, add it to demo routes. + +**Decision:** Add a `demo::list_board_runs` handler that returns the same `RunListItem` data the existing `demo::list_runs` returns, but under the `/boards/runs` path. + +### Decision 3: Fix demo `get_run_status` to return `StoreRunSummary` shape + +The OpenAPI spec says `GET /runs/{id}` returns `StoreRunSummary` (with fields `run_id`, `goal`, `workflow_slug`, `workflow_name`, `host_repo_path`, `status`, `duration_ms`, etc.). The real handler correctly returns `RunSummary` (the Rust type that maps to `StoreRunSummary`). But the demo handler returns `RunStatusResponse` (with fields `id`, `status`, `error`, `queue_position`, `created_at`) -- a completely different shape that violates the spec. + +**Decision:** Change `demo::get_run_status` to return `StoreRunSummary`-shaped JSON by constructing it from the matching `RunListItem` in the demo data. This makes the run-detail loader work identically in both demo and real modes. + +### Decision 4: Conditionally hide unimplemented features based on demo mode + +The real server returns `not_implemented` (501) for: `/workflows`, `/workflows/{name}`, `/workflows/{name}/runs`, `/insights/*`, `/runs/{id}/stages`, `/runs/{id}/stages/{stageId}/turns`, `/runs/{id}/settings`. + +The "Files Changed" tab calls `/runs/{id}/files` which does not exist in either demo or real routes -- it has no server endpoint at all. + +**Decision:** The `auth/me` response already includes `demoMode: boolean`. Use this flag in the UI to: +- Hide the "Workflows" and "Insights" nav items when not in demo mode +- Remove the "Stages" and "Settings" tabs from the run detail view when not in demo mode (keep Overview, Graph, Billing which all use real endpoints) +- Remove the "Files Changed" tab always (the endpoint does not exist in either mode) +- Redirect away from workflow/insight routes when not in demo mode + +This avoids showing users broken pages. The routes remain in the router for demo mode. + +**Justification:** Per user instruction: "for functionality that has been removed from fabro server, remove the corresponding UI from fabro web for now." Using `demoMode` from the existing auth response is the simplest mechanism -- no new API call needed. + +### Decision 5: Run overview graceful degradation + +The run-overview loader fetches both `/runs/{id}/stages` (501 in real mode) and `/boards/runs`. It also tries to fetch `/workflows/{name}` for the graph dot source. + +**Decision:** Make the run-overview loader resilient: catch 501 errors from `/runs/{id}/stages` and return an empty stages list. Remove the `/boards/runs` and `/workflows/{name}` fetches entirely -- the overview doesn't need the workflow slug (it got it just to fetch the graph dot, but that's redundant with the Graph tab), and the graph dot source is only useful for Graphviz rendering which the Graph tab already handles. + +### Decision 6: Run detail loader -- use `/runs/{id}` instead of searching `/boards/runs` + +The run-detail loader currently fetches ALL board runs via `/boards/runs` and finds the run by ID. This is wasteful and won't scale. + +**Decision:** Change run-detail loader to fetch `/runs/{id}` directly. The real handler returns `RunSummary` (which contains `run_id`, `goal`, `workflow_slug`, `workflow_name`, `host_repo_path`, `status`, `duration_ms`). Map this to the same shape the component expects. After fixing the demo handler (Decision 3), the demo `/runs/{id}` also returns `StoreRunSummary`-shaped data, so the same mapping works in both modes. + +### Decision 7: Propagate `demoMode` via React context + +Currently `demoMode` is only available in the app-shell loader data. Child routes need it to conditionally render features. + +**Decision:** The app-shell already passes `demoMode` from `getAuthMe()`. Add a `DemoModeProvider` React context so child components can access it via `useDemoMode()`. + +### Decision 8: Workflow-definition uses hardcoded static data + +`workflow-definition.tsx` imports `workflowData` from `workflow-detail.tsx` and reads from the static record by name, ignoring the loader data. This is a demo-only artifact. + +**Decision:** Since workflows are demo-only for now, this is acceptable. No change needed -- the route is only accessible in demo mode. + +--- + +## File structure + +### Files to modify + +- `lib/crates/fabro-server/src/server.rs` -- Enrich real `list_board_runs` to return `RunListItem` shape; no changes to routes +- `lib/crates/fabro-server/src/demo/mod.rs` -- Add `list_board_runs` handler reusing existing run data; fix `get_run_status` to return `StoreRunSummary` shape +- `apps/fabro-web/app/layouts/app-shell.tsx` -- Conditionally hide nav items based on `demoMode`; export demo mode via context +- `apps/fabro-web/app/lib/demo-mode.tsx` -- New file: `DemoModeProvider` context and `useDemoMode()` hook +- `apps/fabro-web/app/routes/runs.tsx` -- Use `/boards/runs` (already does); no structural changes needed +- `apps/fabro-web/app/routes/run-detail.tsx` -- Change loader to use `/runs/{id}` instead of searching `/boards/runs`; conditionally hide tabs; always hide "Files Changed" +- `apps/fabro-web/app/routes/run-overview.tsx` -- Make loader resilient to 501 from stages endpoint; remove `/boards/runs` dependency +- `apps/fabro-web/app/routes/run-stages.tsx` -- No loader changes; route hidden in non-demo mode +- `apps/fabro-web/app/routes/run-graph.tsx` -- Make loader resilient to 501 from stages endpoint; use `/runs/{id}/graph` (real, works) +- `apps/fabro-web/app/routes/run-settings.tsx` -- No loader changes; route hidden in non-demo mode +- `apps/fabro-web/app/routes/run-files.tsx` -- No loader changes; tab always hidden (endpoint doesn't exist) +- `apps/fabro-web/app/routes/run-billing.tsx` -- No changes; uses `/runs/{id}/billing` which is implemented in real mode +- `apps/fabro-web/app/routes/workflows.tsx` -- No changes; route hidden in non-demo mode +- `apps/fabro-web/app/routes/workflow-detail.tsx` -- No changes; route hidden in non-demo mode +- `apps/fabro-web/app/routes/insights.tsx` -- No changes; route hidden in non-demo mode +- `apps/fabro-web/app/routes/settings.tsx` -- No changes; uses `/settings` which is implemented in real mode +- `apps/fabro-web/app/data/runs.ts` -- Add `mapRunSummaryToRunItem()` for mapping `/runs/{id}` response +- `apps/fabro-web/app/api.ts` -- Add `apiJsonOrNull()` helper for graceful 501 handling + +### Files to create + +- `apps/fabro-web/app/lib/demo-mode.tsx` -- DemoModeProvider and useDemoMode hook +- `apps/fabro-web/tests/playwright.config.ts` -- Playwright configuration +- `apps/fabro-web/tests/browser/smoke.test.ts` -- Browser smoke tests + +--- + +## Task 1: Add `/boards/runs` to demo routes + +**Files:** +- Modify: `lib/crates/fabro-server/src/demo/mod.rs` +- Modify: `lib/crates/fabro-server/src/server.rs:847-923` (demo_routes function) + +- [ ] **Step 1: Write failing test** + +Add a Rust integration test that sends `GET /api/v1/boards/runs` with the `X-Fabro-Demo: 1` header and expects a 200 response with `data` array containing `RunListItem`-shaped objects (having `id`, `repository`, `title`, `workflow`, `status`, `created_at` fields). + +```rust +// In lib/crates/fabro-server/src/server.rs tests section +#[tokio::test] +async fn demo_boards_runs_returns_run_list_items() { + let state = create_app_state(); + let app = build_router(state, AuthMode::Disabled); + let req = Request::builder() + .method("GET") + .uri("/api/v1/boards/runs") + .header("X-Fabro-Demo", "1") + .body(Body::empty()) + .unwrap(); + let response = app.oneshot(req).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = body_json(response.into_body()).await; + let data = body["data"].as_array().expect("data should be array"); + assert!(!data.is_empty(), "demo should return runs"); + let first = &data[0]; + assert!(first["id"].is_string()); + assert!(first["repository"].is_object()); + assert!(first["title"].is_string()); + assert!(first["workflow"].is_object()); + assert!(first["status"].is_string()); + assert!(first["created_at"].is_string()); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /Users/bhelmkamp/p/fabro-sh/fabro-3/.worktrees/production-web-ui && cargo nextest run -p fabro-server -- demo_boards_runs_returns_run_list_items` +Expected: FAIL (404 or route not found because `/boards/runs` is not in demo routes) + +- [ ] **Step 3: Implement demo `/boards/runs` handler** + +In `demo/mod.rs`, add a `list_board_runs` function that delegates to the existing `list_runs` logic (which already returns `RunListItem`-shaped data): + +```rust +pub(crate) async fn list_board_runs( + auth: AuthenticatedService, + state: State>, + pagination: Query, +) -> Response { + list_runs(auth, state, pagination).await +} +``` + +In `server.rs` `demo_routes()`, add the route: + +```rust +.route("/boards/runs", get(demo::list_board_runs)) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd /Users/bhelmkamp/p/fabro-sh/fabro-3/.worktrees/production-web-ui && cargo nextest run -p fabro-server -- demo_boards_runs_returns_run_list_items` +Expected: PASS + +- [ ] **Step 5: Refactor and verify** + +Run full server test suite to check for regressions: +Run: `cd /Users/bhelmkamp/p/fabro-sh/fabro-3/.worktrees/production-web-ui && ulimit -n 4096 && cargo nextest run -p fabro-server` +Expected: all PASS + +- [ ] **Step 6: Commit** + +```bash +git add lib/crates/fabro-server/src/demo/mod.rs lib/crates/fabro-server/src/server.rs +git commit -m "feat(server): add /boards/runs to demo routes" +``` + +--- + +## Task 2: Fix demo `get_run_status` to return `StoreRunSummary` shape + +**Files:** +- Modify: `lib/crates/fabro-server/src/demo/mod.rs:173-194` (get_run_status function) + +The demo `get_run_status` currently returns `RunStatusResponse` (with `id`, `status`, `error`, `queue_position`, `created_at`). The OpenAPI spec says `GET /runs/{id}` returns `StoreRunSummary` (with `run_id`, `goal`, `workflow_slug`, `workflow_name`, `host_repo_path`, `status`, `duration_ms`, etc.). The real handler already returns the correct shape. The demo must match so the UI can use a single mapping function. + +- [ ] **Step 1: Write failing test** + +```rust +#[tokio::test] +async fn demo_get_run_returns_store_run_summary_shape() { + let state = create_app_state(); + let app = build_router(state, AuthMode::Disabled); + let req = Request::builder() + .method("GET") + .uri("/api/v1/runs/run-1") + .header("X-Fabro-Demo", "1") + .body(Body::empty()) + .unwrap(); + let response = app.oneshot(req).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = body_json(response.into_body()).await; + // Should have StoreRunSummary fields, not RunStatusResponse fields + assert!(body["run_id"].is_string(), "should have run_id field"); + assert!(body["goal"].is_string(), "should have goal field"); + assert!(body["workflow_slug"].is_string(), "should have workflow_slug field"); + // Should NOT have RunStatusResponse-only fields + assert!(body["queue_position"].is_null(), "should not have queue_position"); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /Users/bhelmkamp/p/fabro-sh/fabro-3/.worktrees/production-web-ui && cargo nextest run -p fabro-server -- demo_get_run_returns_store_run_summary_shape` +Expected: FAIL (currently returns `id` not `run_id`, has `queue_position`, lacks `goal`/`workflow_slug`) + +- [ ] **Step 3: Rewrite demo `get_run_status` to return `StoreRunSummary` shape** + +Replace the handler body to construct a `StoreRunSummary`-shaped JSON response from the matching `RunListItem`: + +```rust +pub(crate) async fn get_run_status( + _auth: AuthenticatedService, + State(_state): State>, + Path(id): Path, +) -> Response { + match runs::list_items().into_iter().find(|r| r.id == id) { + Some(item) => { + let elapsed_ms = item.timings.as_ref().map(|t| (t.elapsed_secs * 1000.0) as u64); + ( + StatusCode::OK, + Json(json!({ + "run_id": item.id, + "goal": item.title, + "workflow_slug": item.workflow.slug, + "workflow_name": item.workflow.slug, + "host_repo_path": format!("/demo/{}", item.repository.name), + "labels": {}, + "start_time": item.created_at.to_rfc3339(), + "status": "running", + "status_reason": null, + "pending_control": null, + "duration_ms": elapsed_ms, + "total_usd_micros": null, + })), + ) + .into_response() + } + None => ApiError::not_found("Run not found.").into_response(), + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd /Users/bhelmkamp/p/fabro-sh/fabro-3/.worktrees/production-web-ui && cargo nextest run -p fabro-server -- demo_get_run_returns_store_run_summary_shape` +Expected: PASS + +- [ ] **Step 5: Refactor and verify** + +Run full server test suite: +Run: `cd /Users/bhelmkamp/p/fabro-sh/fabro-3/.worktrees/production-web-ui && ulimit -n 4096 && cargo nextest run -p fabro-server` +Expected: all PASS + +- [ ] **Step 6: Commit** + +```bash +git add lib/crates/fabro-server/src/demo/mod.rs +git commit -m "fix(server): demo get_run_status returns StoreRunSummary shape matching OpenAPI spec" +``` + +--- + +## Task 3: Enrich real `/boards/runs` to return `RunListItem` shape + +**Files:** +- Modify: `lib/crates/fabro-server/src/server.rs:2017-2083` (list_board_runs function) + +- [ ] **Step 1: Write failing test** + +Add a Rust integration test that creates a run, starts it, then calls `GET /api/v1/boards/runs` (without demo header) and expects `RunListItem`-shaped objects with `repository`, `title`, `workflow`, and `status` as a `BoardColumn` value. + +```rust +#[tokio::test] +async fn boards_runs_returns_run_list_items_with_board_columns() { + let state = create_app_state(); + let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let run_id = create_and_start_run(&app, MINIMAL_DOT).await; + + // Set run to running so it appears on the board + { + let id = run_id.parse::().unwrap(); + let mut runs = state.runs.lock().expect("runs lock poisoned"); + let managed_run = runs.get_mut(&id).expect("run should exist"); + managed_run.status = RunStatus::Running; + } + + let req = Request::builder() + .method("GET") + .uri(api("/boards/runs")) + .body(Body::empty()) + .unwrap(); + let response = app.oneshot(req).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = body_json(response.into_body()).await; + let data = body["data"].as_array().expect("data should be array"); + let item = data.iter() + .find(|i| i["id"].as_str() == Some(&run_id)) + .expect("run should be in board"); + // Should have RunListItem fields + assert!(item["title"].is_string()); + assert!(item["repository"].is_object()); + assert!(item["workflow"].is_object()); + // Status should be a board column, not a lifecycle status + let status = item["status"].as_str().unwrap(); + assert!( + ["working", "pending", "review", "merge"].contains(&status), + "status should be a board column, got: {status}" + ); + assert!(item["created_at"].is_string()); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /Users/bhelmkamp/p/fabro-sh/fabro-3/.worktrees/production-web-ui && cargo nextest run -p fabro-server -- boards_runs_returns_run_list_items_with_board_columns` +Expected: FAIL (current handler returns RunStatusResponse shape without title/repository/workflow, and status is lifecycle not board column) + +- [ ] **Step 3: Rewrite `list_board_runs` to return enriched `RunListItem` data** + +Replace the `list_board_runs` handler body with logic that: +1. Collects live run data from `state.runs` (id, status, created_at) +2. Fetches `RunSummary` data from `state.store.list_runs()` +3. Maps `RunStatus` to `BoardColumn`: + - `Running` -> `"working"` + - `Paused` -> `"pending"` + - `Completed` -> `"merge"` + - All others (`Submitted`, `Queued`, `Starting`, `Failed`, `Cancelled`) -> excluded from board +4. Constructs `RunListItem`-shaped JSON for each included run: + +```rust +async fn list_board_runs( + _auth: AuthenticatedService, + State(state): State>, + Query(pagination): Query, +) -> Response { + let live_runs: HashMap)> = { + let runs = state.runs.lock().expect("runs lock poisoned"); + runs.iter() + .map(|(id, mr)| (*id, (mr.status, mr.created_at))) + .collect() + }; + let summaries = match state + .store + .list_runs(&fabro_store::ListRunsQuery::default()) + .await + { + Ok(runs) => runs + .into_iter() + .map(|s| (s.run_id, s)) + .collect::>(), + Err(err) => { + return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) + .into_response(); + } + }; + + fn board_column(status: RunStatus) -> Option<&'static str> { + match status { + RunStatus::Running => Some("working"), + RunStatus::Paused => Some("pending"), + RunStatus::Completed => Some("merge"), + _ => None, + } + } + + let all_items: Vec = live_runs + .iter() + .filter_map(|(id, (status, created_at))| { + let column = board_column(*status)?; + let summary = summaries.get(id); + let title = summary + .and_then(|s| s.goal.as_deref()) + .unwrap_or("Untitled run"); + let workflow_slug = summary + .and_then(|s| s.workflow_slug.as_deref()) + .unwrap_or("unknown"); + let workflow_name = summary + .and_then(|s| s.workflow_name.as_deref()) + .unwrap_or(workflow_slug); + let repo_name = summary + .and_then(|s| s.host_repo_path.as_deref()) + .and_then(|p| p.rsplit('/').next()) + .unwrap_or("unknown"); + let elapsed_secs = summary + .and_then(|s| s.duration_ms) + .map(|ms| ms as f64 / 1000.0); + Some(json!({ + "id": id.to_string(), + "title": title, + "repository": { "name": repo_name }, + "workflow": { "slug": workflow_slug, "name": workflow_name }, + "status": column, + "created_at": created_at.to_rfc3339(), + "timings": elapsed_secs.map(|s| json!({ "elapsed_secs": s })), + })) + }) + .collect(); + + let limit = pagination.limit.clamp(1, 100) as usize; + let offset = pagination.offset as usize; + let page: Vec<_> = all_items.into_iter().skip(offset).take(limit + 1).collect(); + let has_more = page.len() > limit; + let data: Vec<_> = page.into_iter().take(limit).collect(); + ( + StatusCode::OK, + Json(json!({ "data": data, "meta": { "has_more": has_more } })), + ) + .into_response() +} +``` + +Note: the exact types and imports will need to be adjusted based on what is in scope. The handler already has access to `HashMap`, `RunId`, etc. from the existing module scope. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd /Users/bhelmkamp/p/fabro-sh/fabro-3/.worktrees/production-web-ui && cargo nextest run -p fabro-server -- boards_runs_returns_run_list_items_with_board_columns` +Expected: PASS + +- [ ] **Step 5: Refactor and verify** + +Some existing tests assert on `status_reason` and `pending_control` fields from `/boards/runs` responses (e.g., tests for cancel and pause flows). These fields no longer exist in the `RunListItem` shape. Update those tests to assert `status_reason`/`pending_control` via `GET /runs/{id}` (which returns `StoreRunSummary` containing both fields) instead. The `/boards/runs` assertions in those tests should be updated to check for the new `RunListItem` fields or removed if redundant. + +Run: `cd /Users/bhelmkamp/p/fabro-sh/fabro-3/.worktrees/production-web-ui && ulimit -n 4096 && cargo nextest run -p fabro-server` +Expected: all PASS + +- [ ] **Step 6: Commit** + +```bash +git add lib/crates/fabro-server/src/server.rs +git commit -m "feat(server): enrich /boards/runs to return RunListItem shape with board columns" +``` + +--- + +## Task 4: Fix run-detail loader to use `/runs/{id}` directly + +**Files:** +- Modify: `apps/fabro-web/app/routes/run-detail.tsx:19-30` +- Modify: `apps/fabro-web/app/data/runs.ts` + +- [ ] **Step 1: Write failing test** + +Add a TypeScript test in `apps/fabro-web/app/data/runs.test.ts` that tests a new `mapRunSummaryToRunItem()` function which maps the `/runs/{id}` response shape (a `StoreRunSummary` with `run_id`, `goal`, `workflow_slug`, `workflow_name`, `host_repo_path`, `status`, `duration_ms`) to the `RunItem` shape. + +```typescript +import { describe, expect, test } from "bun:test"; +import { mapRunSummaryToRunItem } from "./runs"; + +describe("mapRunSummaryToRunItem", () => { + test("maps store run summary to RunItem", () => { + const summary = { + run_id: "01ABC", + goal: "Fix the build", + workflow_slug: "fix_build", + workflow_name: "Fix Build", + host_repo_path: "/home/user/myrepo", + status: "running", + duration_ms: 65000, + total_usd_micros: 500000, + labels: {}, + start_time: "2026-04-08T12:00:00Z", + status_reason: null, + pending_control: null, + }; + const item = mapRunSummaryToRunItem(summary); + expect(item.id).toBe("01ABC"); + expect(item.title).toBe("Fix the build"); + expect(item.workflow).toBe("fix_build"); + expect(item.repo).toBe("myrepo"); + expect(item.elapsed).toBeDefined(); + }); + + test("handles missing optional fields", () => { + const summary = { + run_id: "01DEF", + goal: null, + workflow_slug: null, + workflow_name: null, + host_repo_path: null, + status: "submitted", + duration_ms: null, + total_usd_micros: null, + labels: {}, + start_time: null, + status_reason: null, + pending_control: null, + }; + const item = mapRunSummaryToRunItem(summary); + expect(item.id).toBe("01DEF"); + expect(item.title).toBe("Untitled run"); + expect(item.workflow).toBe("unknown"); + expect(item.repo).toBe("unknown"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /Users/bhelmkamp/p/fabro-sh/fabro-3/.worktrees/production-web-ui/apps/fabro-web && bun test app/data/runs.test.ts` +Expected: FAIL (mapRunSummaryToRunItem does not exist yet) + +- [ ] **Step 3: Implement `mapRunSummaryToRunItem` and update run-detail loader** + +In `apps/fabro-web/app/data/runs.ts`, add: + +```typescript +export interface RunSummaryResponse { + run_id: string; + goal: string | null; + workflow_slug: string | null; + workflow_name: string | null; + host_repo_path: string | null; + status: string | null; + status_reason: string | null; + pending_control: string | null; + duration_ms: number | null; + total_usd_micros: number | null; + labels: Record; + start_time: string | null; +} + +export function mapRunSummaryToRunItem(summary: RunSummaryResponse): RunItem { + const repoPath = summary.host_repo_path ?? ""; + const repoName = repoPath.split("/").pop() || "unknown"; + return { + id: summary.run_id, + repo: repoName, + title: summary.goal ?? "Untitled run", + workflow: summary.workflow_slug ?? "unknown", + elapsed: summary.duration_ms != null + ? formatElapsedSecs(summary.duration_ms / 1000) + : undefined, + }; +} +``` + +In `apps/fabro-web/app/routes/run-detail.tsx`, change the loader: + +```typescript +import { RunSummaryResponse, mapRunSummaryToRunItem, columnNames } from "../data/runs"; +import type { ColumnStatus } from "../data/runs"; +import { apiJson } from "../api"; + +export async function loader({ request, params }: any) { + const summary = await apiJson(`/runs/${params.id}`, { request }); + const item = mapRunSummaryToRunItem(summary); + const statusMap: Record = { + running: "working", + paused: "pending", + completed: "merge", + }; + const status = statusMap[summary.status ?? ""] ?? "working"; + return { + run: { + ...item, + status, + statusLabel: columnNames[status] ?? summary.status ?? "Unknown", + }, + }; +} +``` + +Remove the `PaginatedRunList` import and the find-by-id logic. Remove the `mapRunListItem` import if no longer needed here. + +Note: This works for both real and demo modes because Task 2 ensures the demo `get_run_status` returns `StoreRunSummary`-shaped data with the same fields (`run_id`, `goal`, `workflow_slug`, `host_repo_path`, `duration_ms`, etc.). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd /Users/bhelmkamp/p/fabro-sh/fabro-3/.worktrees/production-web-ui/apps/fabro-web && bun test app/data/runs.test.ts` +Expected: PASS + +- [ ] **Step 5: Refactor and verify** + +Run typecheck and all tests: +Run: `cd /Users/bhelmkamp/p/fabro-sh/fabro-3/.worktrees/production-web-ui/apps/fabro-web && bun run typecheck && bun test` +Expected: all PASS + +- [ ] **Step 6: Commit** + +```bash +git add apps/fabro-web/app/data/runs.ts apps/fabro-web/app/data/runs.test.ts apps/fabro-web/app/routes/run-detail.tsx +git commit -m "feat(web): use /runs/{id} directly in run-detail loader instead of searching /boards/runs" +``` + +--- + +## Task 5: Create `useDemoMode()` hook and `DemoModeProvider` context + +**Files:** +- Create: `apps/fabro-web/app/lib/demo-mode.tsx` +- Modify: `apps/fabro-web/app/layouts/app-shell.tsx` + +- [ ] **Step 1: Write failing test** + +Create `apps/fabro-web/app/lib/demo-mode.test.tsx`: + +```typescript +import { describe, expect, test } from "bun:test"; +import { renderToString } from "react-dom/server"; +import { DemoModeProvider, useDemoMode } from "./demo-mode"; + +function TestConsumer() { + const demoMode = useDemoMode(); + return {demoMode ? "demo" : "prod"}; +} + +describe("DemoModeProvider", () => { + test("provides demo mode value to children", () => { + const html = renderToString( + + + , + ); + expect(html).toContain("demo"); + expect(html).toContain('data-demo="true"'); + }); + + test("defaults to false", () => { + const html = renderToString( + + + , + ); + expect(html).toContain("prod"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /Users/bhelmkamp/p/fabro-sh/fabro-3/.worktrees/production-web-ui/apps/fabro-web && bun test app/lib/demo-mode.test.tsx` +Expected: FAIL (module not found) + +- [ ] **Step 3: Implement DemoModeProvider and useDemoMode** + +Create `apps/fabro-web/app/lib/demo-mode.tsx`: + +```tsx +import { createContext, useContext } from "react"; + +const DemoModeContext = createContext(false); + +export function DemoModeProvider({ + value, + children, +}: { + value: boolean; + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} + +export function useDemoMode(): boolean { + return useContext(DemoModeContext); +} +``` + +In `app-shell.tsx`, wrap the `` with `DemoModeProvider`: + +```tsx +import { DemoModeProvider } from "../lib/demo-mode"; + +// In the component body, wrap the content: + + {/* existing header and main content */} + +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd /Users/bhelmkamp/p/fabro-sh/fabro-3/.worktrees/production-web-ui/apps/fabro-web && bun test app/lib/demo-mode.test.tsx` +Expected: PASS + +- [ ] **Step 5: Refactor and verify** + +Run: `cd /Users/bhelmkamp/p/fabro-sh/fabro-3/.worktrees/production-web-ui/apps/fabro-web && bun run typecheck && bun test` +Expected: all PASS + +- [ ] **Step 6: Commit** + +```bash +git add apps/fabro-web/app/lib/demo-mode.tsx apps/fabro-web/app/lib/demo-mode.test.tsx apps/fabro-web/app/layouts/app-shell.tsx +git commit -m "feat(web): add DemoModeProvider context and useDemoMode hook" +``` + +--- + +## Task 6: Conditionally hide nav items and routes based on demo mode + +**Files:** +- Modify: `apps/fabro-web/app/layouts/app-shell.tsx` +- Modify: `apps/fabro-web/app/routes/run-detail.tsx` + +- [ ] **Step 1: Write failing test** + +This is a visual behavior change. We will verify with the Playwright browser test in Task 9. For now, write a unit test verifying the navigation filtering logic. + +Create `apps/fabro-web/app/layouts/app-shell.test.tsx`: + +```typescript +import { describe, expect, test } from "bun:test"; + +// Test the navigation filtering logic extracted as a pure function +import { getVisibleNavigation } from "./app-shell"; + +describe("getVisibleNavigation", () => { + test("shows all nav items in demo mode", () => { + const items = getVisibleNavigation(true); + const names = items.map((i) => i.name); + expect(names).toContain("Workflows"); + expect(names).toContain("Runs"); + expect(names).toContain("Insights"); + }); + + test("hides Workflows and Insights in production mode", () => { + const items = getVisibleNavigation(false); + const names = items.map((i) => i.name); + expect(names).not.toContain("Workflows"); + expect(names).not.toContain("Insights"); + expect(names).toContain("Runs"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /Users/bhelmkamp/p/fabro-sh/fabro-3/.worktrees/production-web-ui/apps/fabro-web && bun test app/layouts/app-shell.test.tsx` +Expected: FAIL (getVisibleNavigation not exported) + +- [ ] **Step 3: Extract navigation filtering and conditionally hide items** + +In `app-shell.tsx`: + +1. Export the navigation array and a filtering function: + +```typescript +const allNavigation = [ + { name: "Workflows", href: "/workflows", icon: RectangleStackIcon, demoOnly: true }, + { name: "Runs", href: "/runs", icon: PlayIcon, demoOnly: false }, + { name: "Insights", href: "/insights", icon: ChartBarIcon, demoOnly: true }, +]; + +export function getVisibleNavigation(demoMode: boolean) { + return allNavigation.filter((item) => !item.demoOnly || demoMode); +} +``` + +2. In the component, use `getVisibleNavigation(demoMode)` instead of the static `navigation` array. + +In `run-detail.tsx`, conditionally filter the tabs array based on demo mode. Remove "Stages" and "Settings" tabs when not in demo mode. Remove "Files Changed" tab always (the `/runs/{id}/files` endpoint does not exist in either mode). Keep "Overview", "Graph", and "Billing": + +```typescript +import { useDemoMode } from "../lib/demo-mode"; + +// Define all tabs +const allTabs = [ + { name: "Overview", path: "", count: null, demoOnly: false, broken: false }, + { name: "Stages", path: "/stages/detect-drift", count: null, demoOnly: true, broken: false }, + { name: "Files Changed", path: "/files", count: null, demoOnly: false, broken: true }, + { name: "Graph", path: "/graph", count: null, demoOnly: false, broken: false }, + { name: "Billing", path: "/billing", count: null, demoOnly: false, broken: false }, +]; + +// In component: +const demoMode = useDemoMode(); +const visibleTabs = allTabs.filter((t) => !t.broken && (!t.demoOnly || demoMode)); +``` + +Note: The original tabs array has "Overview", "Stages", "Files Changed", "Billing" -- it does not include "Graph". Add "Graph" to the tabs since the graph tab route exists and works in real mode. The "Settings" tab is not listed in the current tabs array (the route exists but has no tab link), so it is already effectively hidden. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd /Users/bhelmkamp/p/fabro-sh/fabro-3/.worktrees/production-web-ui/apps/fabro-web && bun test app/layouts/app-shell.test.tsx` +Expected: PASS + +- [ ] **Step 5: Refactor and verify** + +Run: `cd /Users/bhelmkamp/p/fabro-sh/fabro-3/.worktrees/production-web-ui/apps/fabro-web && bun run typecheck && bun test` +Expected: all PASS + +- [ ] **Step 6: Commit** + +```bash +git add apps/fabro-web/app/layouts/app-shell.tsx apps/fabro-web/app/layouts/app-shell.test.tsx apps/fabro-web/app/routes/run-detail.tsx +git commit -m "feat(web): hide Workflows, Insights nav and demo-only run tabs in production mode" +``` + +--- + +## Task 7: Add `apiJsonOrNull` helper and make run-overview/run-graph loaders resilient + +**Files:** +- Modify: `apps/fabro-web/app/api.ts` +- Modify: `apps/fabro-web/app/routes/run-overview.tsx` +- Modify: `apps/fabro-web/app/routes/run-graph.tsx` + +- [ ] **Step 1: Write failing test** + +Create `apps/fabro-web/app/api.test.ts`: + +```typescript +import { describe, expect, test } from "bun:test"; + +// We test the logic of apiJsonOrNull which returns null on 501 +// Since we can't mock fetch easily, test the extraction function +import { isNotImplemented } from "./api"; + +describe("isNotImplemented", () => { + test("returns true for 501 status", () => { + expect(isNotImplemented(501)).toBe(true); + }); + + test("returns false for 200 status", () => { + expect(isNotImplemented(200)).toBe(false); + }); + + test("returns false for 404 status", () => { + expect(isNotImplemented(404)).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /Users/bhelmkamp/p/fabro-sh/fabro-3/.worktrees/production-web-ui/apps/fabro-web && bun test app/api.test.ts` +Expected: FAIL (isNotImplemented not exported) + +- [ ] **Step 3: Implement `apiJsonOrNull` and `isNotImplemented`, update loaders** + +In `apps/fabro-web/app/api.ts`, add: + +```typescript +export function isNotImplemented(status: number): boolean { + return status === 501; +} + +export async function apiJsonOrNull(path: string, options?: ApiOptions): Promise { + const response = await apiFetch(path, options); + if (isNotImplemented(response.status)) { + return null; + } + if (!response.ok) { + throw new Response(null, { status: response.status, statusText: response.statusText }); + } + return response.json() as Promise; +} +``` + +In `run-overview.tsx`, simplify the loader to only fetch stages (gracefully) and set `graphDot` to null: + +```typescript +import { apiJsonOrNull } from "../api"; + +export async function loader({ request, params }: any) { + const stagesResult = await apiJsonOrNull( + `/runs/${params.id}/stages`, + { request }, + ); + const stages: Stage[] = (stagesResult?.data ?? []).map((s) => ({ + id: s.id, + name: s.name, + status: s.status as StageStatus, + duration: s.duration_secs != null ? formatDurationSecs(s.duration_secs) : "--", + })); + return { stages, graphDot: null }; +} +``` + +Remove the imports for `PaginatedRunList`, `WorkflowDetailResponse`, and `apiJson` (if no longer needed). Keep `apiJsonOrNull`. + +In `run-graph.tsx`, use `apiJsonOrNull` for stages: + +```typescript +import { apiJsonOrNull } from "../api"; + +export async function loader({ request, params }: any) { + const [stagesResult, graphRes] = await Promise.all([ + apiJsonOrNull(`/runs/${params.id}/stages`, { request }), + apiFetch(`/runs/${params.id}/graph`, { request }), + ]); + const stages: Stage[] = (stagesResult?.data ?? []).map((s) => ({ + id: s.id, + name: s.name, + dotId: s.dot_id ?? s.id, + status: s.status as StageStatus, + duration: s.duration_secs != null ? formatDurationSecs(s.duration_secs) : "--", + })); + const graphSvg = graphRes.ok ? await graphRes.text() : null; + return { stages, graphSvg }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd /Users/bhelmkamp/p/fabro-sh/fabro-3/.worktrees/production-web-ui/apps/fabro-web && bun test app/api.test.ts` +Expected: PASS + +- [ ] **Step 5: Refactor and verify** + +Run: `cd /Users/bhelmkamp/p/fabro-sh/fabro-3/.worktrees/production-web-ui/apps/fabro-web && bun run typecheck && bun test` +Expected: all PASS + +- [ ] **Step 6: Commit** + +```bash +git add apps/fabro-web/app/api.ts apps/fabro-web/app/api.test.ts apps/fabro-web/app/routes/run-overview.tsx apps/fabro-web/app/routes/run-graph.tsx +git commit -m "feat(web): add apiJsonOrNull for graceful 501 handling in run-overview and run-graph" +``` + +--- + +## Task 8: Set up Playwright and write browser smoke tests + +**Files:** +- Create: `apps/fabro-web/tests/playwright.config.ts` +- Create: `apps/fabro-web/tests/browser/smoke.test.ts` +- Modify: `apps/fabro-web/package.json` (add test:browser script) + +- [ ] **Step 1: Install Playwright and configure** + +```bash +cd /Users/bhelmkamp/p/fabro-sh/fabro-3/.worktrees/production-web-ui/apps/fabro-web +bun add -d @playwright/test +``` + +Create `apps/fabro-web/tests/playwright.config.ts`: + +```typescript +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/browser", + timeout: 30000, + use: { + baseURL: "http://localhost:8080", + screenshot: "only-on-failure", + }, + webServer: { + command: "cd ../.. && FABRO_TEST_IN_MEMORY_STORE=1 cargo run -p fabro-cli -- server foreground --bind 127.0.0.1:8080", + port: 8080, + reuseExistingServer: true, + timeout: 120000, + }, +}); +``` + +Note: The exact server start command may need adjustment. The fabro server serves the built SPA via static file handler when `FABRO_STATIC_DIR` points to the built web app. The test should build the web app first, then start the server with auth disabled. Check the `ServeArgs` in `serve.rs` and the CLI subcommand in `commands/server/` for the correct invocation. If `server foreground` does not work, try `fabro serve` or adjust. The key settings are: +- `FABRO_TEST_IN_MEMORY_STORE=1` for an ephemeral store +- Auth mode defaults to `AuthMode::Disabled` when no auth configuration is present +- The static file handler serves from the configured static directory + +- [ ] **Step 2: Write browser smoke tests** + +Create `apps/fabro-web/tests/browser/smoke.test.ts`: + +```typescript +import { test, expect } from "@playwright/test"; + +test.describe("Production mode (no demo header)", () => { + test("runs board loads without errors", async ({ page }) => { + await page.goto("/runs"); + // Should not show error page + await expect(page.locator("body")).not.toContainText("Unauthorized"); + // Take screenshot for visual verification + await page.screenshot({ path: "test-results/runs-prod.png" }); + }); + + test("settings page loads", async ({ page }) => { + await page.goto("/settings"); + await expect(page.locator("body")).not.toContainText("Not implemented"); + await page.screenshot({ path: "test-results/settings-prod.png" }); + }); + + test("navigation does not show Workflows in production mode", async ({ page }) => { + await page.goto("/runs"); + const nav = page.locator("nav"); + await expect(nav).not.toContainText("Workflows"); + await expect(nav).not.toContainText("Insights"); + await expect(nav).toContainText("Runs"); + }); +}); + +test.describe("Demo mode (with demo cookie)", () => { + test.beforeEach(async ({ context }) => { + await context.addCookies([{ + name: "fabro-demo", + value: "1", + domain: "localhost", + path: "/", + }]); + }); + + test("runs board loads with demo data", async ({ page }) => { + await page.goto("/runs"); + // Demo mode should show run cards + await expect(page.locator("body")).not.toContainText("error"); + await page.screenshot({ path: "test-results/runs-demo.png" }); + }); + + test("navigation shows all items in demo mode", async ({ page }) => { + await page.goto("/runs"); + const nav = page.locator("nav"); + await expect(nav).toContainText("Workflows"); + await expect(nav).toContainText("Runs"); + await expect(nav).toContainText("Insights"); + }); + + test("workflows page loads in demo mode", async ({ page }) => { + await page.goto("/workflows"); + await expect(page.locator("body")).not.toContainText("error"); + await page.screenshot({ path: "test-results/workflows-demo.png" }); + }); + + test("insights page loads in demo mode", async ({ page }) => { + await page.goto("/insights"); + await expect(page.locator("body")).not.toContainText("error"); + await page.screenshot({ path: "test-results/insights-demo.png" }); + }); +}); + +test.describe("Demo mode toggle", () => { + test("toggling demo mode changes navigation", async ({ page }) => { + // Start in prod mode + await page.goto("/runs"); + const nav = page.locator("nav"); + await expect(nav).not.toContainText("Workflows"); + + // Toggle demo mode on via the beaker button + const demoToggle = page.locator('button[title*="demo"], button[title*="Demo"]'); + await demoToggle.click(); + + // Wait for revalidation + await page.waitForTimeout(1000); + await expect(nav).toContainText("Workflows"); + await page.screenshot({ path: "test-results/after-toggle-demo.png" }); + }); +}); +``` + +- [ ] **Step 3: Add test script to package.json** + +In `apps/fabro-web/package.json`, add: + +```json +"test:browser": "bunx playwright test --config tests/playwright.config.ts" +``` + +- [ ] **Step 4: Build and run browser tests** + +First build the web app so the server can serve it: +```bash +cd /Users/bhelmkamp/p/fabro-sh/fabro-3/.worktrees/production-web-ui/apps/fabro-web && bun run build +``` + +Then run the browser tests (this requires the fabro server to be running or the webServer config to start it): +```bash +cd /Users/bhelmkamp/p/fabro-sh/fabro-3/.worktrees/production-web-ui/apps/fabro-web && bun run test:browser +``` + +Expected: Tests may fail on first run due to the server startup command or auth configuration. Iterate on the Playwright config's `webServer.command` until the server starts correctly with auth disabled and serves the built SPA. The server's `AuthMode::Disabled` skips authentication, and `getAuthMe()` should still return a response (it returns a disabled-mode user). If `getAuthMe()` returns 401 in disabled mode, that indicates the server auth isn't properly disabled -- check the environment variables and CLI flags. + +- [ ] **Step 5: Refactor and verify** + +Run all tests including browser: +```bash +cd /Users/bhelmkamp/p/fabro-sh/fabro-3/.worktrees/production-web-ui/apps/fabro-web && bun run typecheck && bun test && bun run test:browser +``` +Expected: all PASS + +- [ ] **Step 6: Commit** + +```bash +git add apps/fabro-web/tests/ apps/fabro-web/package.json +git commit -m "test(web): add Playwright browser smoke tests for production and demo mode" +``` + +--- + +## Task 9: Final integration verification + +- [ ] **Step 1: Run all Rust tests** + +```bash +cd /Users/bhelmkamp/p/fabro-sh/fabro-3/.worktrees/production-web-ui && ulimit -n 4096 && cargo nextest run -p fabro-server +``` +Expected: all PASS + +- [ ] **Step 2: Run all TypeScript tests** + +```bash +cd /Users/bhelmkamp/p/fabro-sh/fabro-3/.worktrees/production-web-ui/apps/fabro-web && bun run typecheck && bun test +``` +Expected: all PASS + +- [ ] **Step 3: Build production web app** + +```bash +cd /Users/bhelmkamp/p/fabro-sh/fabro-3/.worktrees/production-web-ui/apps/fabro-web && bun run build +``` +Expected: Build succeeds with no errors + +- [ ] **Step 4: Run browser tests** + +```bash +cd /Users/bhelmkamp/p/fabro-sh/fabro-3/.worktrees/production-web-ui/apps/fabro-web && bun run test:browser +``` +Expected: all PASS + +- [ ] **Step 5: Commit any remaining changes** + +```bash +git add -A +git commit -m "chore: final integration cleanup for production web UI" +``` + +--- + +## Summary of changes by endpoint + +| Endpoint | Real mode | Demo mode | UI behavior | +|---|---|---|---| +| `/boards/runs` | Enriched to return `RunListItem` with board columns | New handler delegates to `list_runs` | Runs board works in both modes | +| `/runs/{id}` | Returns `RunSummary` (already works) | Fixed to return `StoreRunSummary` shape (was returning `RunStatusResponse`) | Run detail uses this directly | +| `/runs/{id}/graph` | Returns SVG (already works) | Returns SVG | Graph tab works in both modes | +| `/runs/{id}/billing` | Returns billing (already works) | Returns billing | Billing tab works in both modes | +| `/runs/{id}/stages` | Returns 501 | Returns demo stages | Graceful null in real mode; full data in demo | +| `/runs/{id}/stages/{stageId}/turns` | Returns 501 | Returns demo turns | Tab hidden in real mode | +| `/runs/{id}/settings` | Returns 501 | Returns demo settings | Tab hidden in real mode | +| `/runs/{id}/files` | Does not exist | Does not exist | Tab always hidden (endpoint missing in both modes) | +| `/workflows` | Returns 501 | Returns demo workflows | Nav hidden in real mode | +| `/workflows/{name}` | Returns 501 | Returns demo detail | Nav hidden in real mode | +| `/workflows/{name}/runs` | Returns 501 | Returns demo runs | Nav hidden in real mode | +| `/insights/*` | Returns 501 | Returns demo data | Nav hidden in real mode | +| `/settings` | Returns settings (works) | Returns demo settings | Works in both modes | diff --git a/docs/plans/2026-04-08-settings-toml-redesign-implementation-plan.md b/docs/plans/2026-04-08-settings-toml-redesign-implementation-plan.md new file mode 100644 index 000000000..c20894061 --- /dev/null +++ b/docs/plans/2026-04-08-settings-toml-redesign-implementation-plan.md @@ -0,0 +1,334 @@ +# Settings TOML Redesign Implementation Plan + +## Summary + +Use `docs/brainstorms/2026-04-08-settings-toml-redesign-requirements.md` as the source of truth and land this as a hard cut: replace the flat and organic config schema everywhere, update all loaders and consumers to the new namespaced model, and regenerate all outward-facing examples and contracts in the same change. + +Fabro is still greenfield. This plan intentionally optimizes for the best steady-state code rather than backwards compatibility: + +- one user-facing schema, not old and new in parallel +- one hard-cut contract update for config files and settings payloads +- no user-facing compatibility layer + +This can still land as one cohesive PR. The staged sequence below is an internal implementation order so the work stays mechanically sane while the refactor is in flight. + +This refactor is centered on four seams: + +- schema and parsing in `lib/crates/fabro-types/src/settings/` and `lib/crates/fabro-config/src/config.rs` +- layering and trust-boundary resolution in `lib/crates/fabro-config/src/effective_settings.rs` +- CLI, workflow, agent, MCP, sandbox, and server consumers across the Rust workspace +- public contracts in `docs/api-reference/fabro-api.yaml`, generated clients, generated config files, and `apps/fabro-web` + +## Public Types And Interfaces + +- Replace the flat `fabro_types::Settings` shape with a resolved namespaced settings tree matching the redesign: + - `_version` + - `project` + - `workflow` + - `run` + - `cli` + - `server` + - `features` +- Replace the current `ConfigLayer` shape with a sparse namespaced parse tree. A temporary in-repo bridge between old and new internal types is acceptable only to keep intermediate stages compiling; it must not become a user-visible compatibility layer and must be deleted by the end of the cut. +- Treat `cli.*` and `server.*` as schema-valid everywhere but runtime-consumed only from local `settings.toml` plus explicit process-local overrides. +- Replace legacy flat run sections and fields with namespaced equivalents, including: + - `goal` and `working_dir` under `[run]` + - `vars` to `[run.inputs]` + - `labels` to `project.metadata`, `workflow.metadata`, and `run.metadata` + - `llm` to `[run.model]` + - `setup` to `[run.prepare]` + - `mcp_servers` to `[run.agent.mcps.]` or `[cli.exec.agent.mcps.]`, depending on the consumer + - `exec` to `[cli.exec]` + - flat server sections to `[server.*]` +- Treat `vars -> run.inputs` as a behavioral change, not just a rename. `run.inputs` intentionally replaces the inherited map wholesale rather than merging by key. +- Replace legacy project shape `[fabro].root` with `[project].directory`. +- Replace hook merge identity from effective-name semantics to optional explicit `id`, while keeping `name` human-facing only. +- Replace string-command hook and launcher shorthand with one execution-language rule: + - `script = "..."` for shell-evaluated commands + - `command = ["..."]` for argv launches + - mutually exclusive +- Treat `script` and `command` fields as trusted executable config. Repo-scoped config using these fields executes with the consuming process privileges. Env interpolation inside `script` is raw string substitution, not shell-escaped templating. +- Replace old MCP shapes with agent-scoped MCPs: + - `[run.agent.mcps.]` + - `[cli.exec.agent.mcps.]` +- Keep `SecretStore` and provider ambient auth as the credential sources for secrets. The redesigned config should describe selectors and non-secret knobs, not become a general secret transport. +- Keep `/api/v1/settings` as the endpoint path, but replace broad `Settings` serialization with an explicit public DTO. The hard cut is the schema and payload shape, not the path name. + +## Resolved Deferred Questions + +- `run.scm` first pass: + - core fields are `provider`, `owner`, and `repository` + - provider-specific capability leaves live under `[run.scm.]` + - branch and PR behavior stay out of `run.scm` in this cut and remain on `[run.pull_request]` or runtime context +- object-store envelope first pass: + - provider-neutral envelope fields are `provider` and optional `prefix` + - provider-specific tables live under `[server.artifacts.]` and `[server.slatedb.]` + - `local` uses `root`, defaulting to `server.storage.root` when omitted + - `s3` carries bucket and region plus optional endpoint and path-style settings + - provider credentials come from `SecretStore`, `${env.NAME}`, or ambient provider auth rather than first-pass secret fields in TOML +- MCP surface first pass: + - common fields are `enabled`, `type`, `startup_timeout`, and `tool_timeout` + - `startup_timeout` and `tool_timeout` use the shared duration type from the value-language helpers + - `type = "http"` uses `url` plus optional `headers` + - `type = "stdio"` requires exactly one of `script` or `command` and may include `env` + - `type = "sandbox"` requires exactly one of `script` or `command`, requires `port` as an integer, and may include `env` +- notification route surface first pass: + - route envelope fields are `enabled`, `provider`, and `events` + - provider-specific destination fields live under `[run.notifications..]` + - first-pass chat destinations for Slack, Discord, and Teams use `channel` +- duration parser first pass: + - one shared parser accepts a single unit suffix per value: `ms`, `s`, `m`, `h`, or `d` + - composed values like `1h30m` are not supported in first pass; use the smallest needed unit instead + - one shared canonical renderer prints human-readable durations in the same single-unit form +- size parser first pass: + - one shared parser accepts bare integers plus `B`, `KB`, `MB`, `GB`, `TB`, and `KiB`, `MiB`, `GiB`, `TiB` + - `KB`, `MB`, `GB`, and `TB` are decimal (powers of 1000); `KiB`, `MiB`, `GiB`, and `TiB` are binary (powers of 1024) + - bare values default to `GB` + - fractional values are not supported in first pass + - one shared canonical renderer prints human-readable sizes using the largest decimal unit that represents the value as an integer multiple + - config-language parsing stays permissive; provider layers remain responsible for stricter admissible-value validation such as Daytona-specific CPU and memory limits +- object-store `provider` field is a closed enum. First-pass variants are `local` and `s3`. Unknown providers hard-fail against the schema rather than passing through as opaque strings. +- `SecretStore` access is not referenced from user TOML in first pass. Consumers read secrets via existing server-side `SecretStore` code paths; the config schema does not introduce a `${secret.NAME}` interpolation form. If TOML-level secret references become necessary later, they are a separate schema bump. + +## Implementation Changes + +### 1. Replace the config parse tree and resolved types + +- Introduce a new namespaced parse tree for `_version`, `project`, `workflow`, `run`, `cli`, `server`, and `features`; do not alias old field names forward. +- Redesign `fabro_types::Settings` to match the new resolved schema rather than preserving the old flat representation internally. +- Treat strict unknown-key handling as a parse-architecture change, not just a derive tweak. The loader must validate against the full union schema before consumer-specific filtering and must surface targeted rename hints for legacy keys. +- Add explicit `_version` handling before deeper validation: + - missing defaults to `1` + - legacy `version` hard-fails with a rename hint + - unsupported higher versions hard-fail with an upgrade hint +- Stage the new value-language helpers explicitly instead of bundling them into one opaque parser rewrite: + - one shared duration type and parser for config-facing time values + - one shared size type and parser for memory and disk values + - one model-reference parser for `run.model.fallbacks` + - one interpolation representation for `${env.NAME}` tokens, including substring interpolation and multiple tokens per string + - one splice-capable string-array helper for the exact `"..."` semantics in the requirements doc +- Implement the resolved first-pass shapes from the previous section directly in the parse tree and resolved settings types rather than leaving them to implementer choice. +- Redesign run model types to cover: + - `run.metadata` + - `run.inputs` + - `run.model` + - `run.git` + - `run.prepare.steps` + - `run.execution` + - `run.checkpoint` + - `run.sandbox` + - `run.notifications.` + - `run.interviews` + - `run.agent` + - `run.agent.mcps.` + - `run.hooks` + - `run.scm` + - `run.scm.` + - `run.pull_request` + - `run.artifacts` +- Redesign CLI types to cover: + - `cli.target` + - `cli.target.tls` + - `cli.auth` + - `cli.exec` + - `cli.exec.model` + - `cli.exec.agent` + - `cli.exec.agent.mcps.` + - `cli.output` + - `cli.updates` + - `cli.logging` +- Redesign server types to cover: + - `server.listen` + - `server.listen.tls` + - `server.api` + - `server.web` + - `server.auth.api` + - `server.auth.web.providers.` + - `server.storage` + - `server.artifacts` + - `server.slatedb` + - `server.scheduler` + - `server.logging` + - `server.integrations.` +- Keep provider-neutral envelopes and provider-specific nested tables where the requirements already locked them: + - sandbox + - notifications + - interviews + - object stores + - SCM provider leaves +- Keep model config intentionally provider-neutral and implement the fallback grammar exactly as specified in the requirements doc. + +### 2. Narrow merge changes to the paths whose behavior actually changes + +- Keep `Combine` as the default layering mechanism where it still matches the requirements. Add explicit custom merge only for paths whose behavior changes. +- Encode the merge matrix from the requirements doc directly in code, with custom logic only for: + - replace-by-default maps like `run.inputs`, `project.metadata`, `workflow.metadata`, and `run.metadata` + - sticky merge-by-key maps like `run.sandbox.env` + - splice-aware string arrays + - whole-list replacement for `run.prepare.steps` + - field-merge keyed objects like notifications, MCPs, and web-auth providers + - ordered hook merging by optional `id` +- Make splice-capable arrays explicit in the implementation rather than shape-driven. In the first pass, the only splice-capable array paths are: + - `run.model.fallbacks` + - `run.notifications..events` +- Treat `"..."` in all non-splice arrays as a hard error rather than data or a silent no-op. +- Keep inactive provider and strategy subtables inert when the selected provider changes; validate and consume only the selected subtree. +- Move env interpolation out of the current sandbox-only whole-value resolver and into a post-layering resolution pass that runs only on consumed string fields. +- If any `${env.NAME}` token in a consumed string fails to resolve, fail the entire field with an error that identifies both the unresolved token and the config path. +- Track interpolation provenance so env-sourced resolved values can be redacted consistently in outward-facing serialization, not just in the CLI. +- Keep hook ordering stable: + - `id`-matched replacement happens in place + - anonymous hooks from higher-precedence files append after the fully merged inherited hook list + - duplicate `id` values in one file hard-fail + +### 3. Rebuild resolution, trust boundaries, and safe serialization + +- Rework `EffectiveSettingsLayers` and `resolve_settings()` so owner-specific domains are consumed only from `~/.fabro/settings.toml` plus flags and env overrides. +- Remove the current “merge everything, then strip server-owned fields” model. Build shared layered domains and owner-specific domains separately from the start. +- Preserve today’s `exec` routing behavior: + - configured CLI target defaults affect commands that use server targeting + - `fabro exec` still requires explicit `--server` +- Make the default server auth posture explicit and fail-closed: + - if `server.auth` is absent or resolves to no enabled API or web auth configuration, normal server startup must refuse to start + - demo and test helpers may continue to inject explicit insecure settings where needed, but insecure startup must be opt-in rather than accidental +- Settings API exposure: + - replace raw resolved settings serialization with explicit public DTOs + - two distinct exposure scopes, each with its own DTO: + - scope 1: `/api/v1/settings` (server configuration view) + - first-pass allow-list: + - `server.api.url` + - `server.web.enabled` + - `server.web.url` + - enabled state for `server.auth.web.providers.*` + - non-secret `server.scheduler` values + - denies everything else, including all `project.*`, `workflow.*`, `run.*`, `cli.*`, and any `server.*` path not explicitly allowed (notably `server.listen`, `server.listen.tls.*`, `server.auth.api`, `server.integrations.*`, `server.artifacts*`, `server.slatedb*`, local secret-store paths, and any env-resolved secret values) + - scope 2: `/api/v1/runs/:id/settings` and run-settings snapshots exposed via API (run configuration view) + - allows the resolved `run.*` tree so the frontend run-settings page and equivalent consumers can render it + - denies: + - any resolved string value tagged as `${env.NAME}`-sourced (via the interpolation provenance tracking) + - provider-credential fields under `run.notifications.*.` even when not env-sourced + - env values under `run.agent.mcps.*.env` that were env-interpolated + - any field explicitly marked sensitive in its type (for example, tokens or keys) + - also denies all `project.*`, `workflow.*`, `cli.*`, and `server.*`; these are not part of a run-configuration view +- Apply the matching exposure scope and redaction rules consistently across all outward-facing settings renderers: + - `fabro settings` uses the server scope for server-facing rendering and the run scope for run-facing rendering + - `/api/v1/settings` uses the server scope + - `/api/v1/runs/:id/settings` and any API-exposed run-settings snapshots use the run scope + - logs and emitted settings-like debug output use whichever scope matches the payload kind +- Trust model: + - `script` and `command` fields in repo-scoped config are trusted executable config and should be reviewed like code + - those fields execute with the consuming process privileges; the config system does not sandbox them + - `${env.NAME}` interpolation inside `script` is raw substitution, not shell quoting or shell-safe templating +- Keep command-local override layering separate from machine settings loading: + - `run`, `preflight`, and manifest code still build layered run defaults + - `exec` still loads machine CLI defaults directly + - `settings` still assembles effective layers deliberately +- Classify server settings as startup-only vs live-reloadable in the first pass: + - live-reloadable: + - `server.logging` + - `server.scheduler` + - startup-only: + - `server.listen` + - `server.listen.tls` + - `server.api` + - `server.web` + - `server.auth` + - `server.storage` + - `server.artifacts` + - `server.slatedb` + - `server.integrations` +- Update server runtime application logic to stop assuming old flat fields like `storage_dir`, `artifact_storage`, `api`, and `web`. +- Make the persisted-settings decision explicit: old run-settings snapshots and local dev state are not guaranteed to survive the hard cut. Tests, fixtures, and generated examples should be rewritten; no snapshot migration layer is planned. + +### 4. Migrate all consumers, scaffolds, and contracts + +- Update CLI overrides, run manifest building, workflow discovery, project discovery, and remote and local-daemon settings application to the new schema. +- Update all crates that currently consume settings or config layers, not just the CLI and server entrypoints. At minimum this includes: + - `fabro-cli` + - `fabro-server` + - `fabro-workflow` + - `fabro-agent` + - `fabro-mcp` + - sandbox-facing config consumers + - hook execution consumers + - test helpers in `fabro-test` +- Update server start and foreground command flows to read and apply the new server config shape. +- Update `SecretStore` integration points so server and installer flows continue to source secrets out of band while the new config shape only carries non-secret selectors and toggles. +- Update scaffolding and installers so generated `settings.toml`, `fabro.toml`, and `workflow.toml` use `_version` and the new namespaced sections. +- Update install-time config writers to stop editing legacy `[git]`, `[web]`, `[api]`, and similar flat sections. +- Update the server `/api/v1/settings` response and any run-settings snapshot payloads to the new allow-listed resolved shape, then regenerate Rust and TypeScript clients from OpenAPI. +- Update `apps/fabro-web` and any generated TypeScript consumers to the new settings contract. The live `/settings` and `/runs/:id/settings` routes currently `JSON.stringify` the full response, so they remain shape-agnostic, but the static `workflowData` fallback in `apps/fabro-web/app/routes/workflow-detail.tsx` uses the old schema shape and must be rewritten against the new `RunSettings` type. +- Update docs and examples in `docs/reference/`, especially: + - `user-configuration.mdx` + - `cli.mdx` + - any other config examples that currently show `[llm]`, `[exec]`, `[server]`, `[sandbox]`, `[fabro]`, or `version = 1` +- Update installer, repo-init, and workflow-create generated content so no new files are emitted in the old schema after the cutover lands. + +## Sequencing + +Implement in these internal compile-preserving stages: + +1. Add the new value-language helpers and namespaced sparse parse structs alongside the current code so the repo still builds while parser architecture is being introduced. +2. Add the new resolved settings tree plus a temporary internal bridge between old and new types so callers can migrate incrementally without freezing the repo in an unbuildable state. +3. Switch parsing and layering to the new schema, strict validation, merge behavior, trust boundaries, and env interpolation. This is where legacy user config starts hard-failing. +4. Migrate consumers crate by crate: + - `fabro-cli` + - `fabro-server` + - `fabro-workflow` + - `fabro-agent` + - `fabro-mcp` + - hook, sandbox, and test-helper consumers +5. Update `/api/v1/settings`, OpenAPI, generated clients, `apps/fabro-web`, scaffolds, installers, and docs to the new contract. +6. Remove the old flat settings types, the temporary bridge, legacy fixtures, and any now-dead merge logic. + +This remains a hard cut. These stages describe implementation order, not a staged user rollout. + +## Test Plan + +- Add parser and unit coverage for: + - `_version` defaulting and failure modes + - representative hard failures for legacy keys and unknown keys + - model fallback token parsing and ambiguity errors + - duration and size parsing + - substring and multi-token `${env.NAME}` interpolation + - splice-array rules on allowed paths + - hard failure for `"..."` on non-splice paths + - hook `id` replacement and anonymous append ordering +- Add layering and resolution coverage for: + - `run.inputs` replace semantics + - `run.sandbox.env` sticky merge semantics + - keyed object merge and disable behavior + - owner-specific trust boundaries for `cli.*` and `server.*` + - inactive provider subtables remaining inert + - default server auth fail-closed behavior when `server.auth` is absent +- Add serialization and exposure coverage for: + - `fabro settings` redaction + - `/api/v1/settings` allow-list behavior + - exclusion of TLS paths, auth internals, object-store credentials, and env-resolved secrets + - any API-exposed run-settings snapshot redaction behavior +- Add behavior coverage for: + - `project.directory`-based workflow discovery + - `run.inputs` replace semantics + - hook identity via explicit `id` +- Update CLI integration tests in: + - `lib/crates/fabro-cli/tests/it/cmd/config.rs` + - `lib/crates/fabro-cli/tests/it/cmd/exec.rs` + - `lib/crates/fabro-cli/tests/it/cmd/repo_init.rs` + - `lib/crates/fabro-cli/tests/it/cmd/workflow_create.rs` +- Update server and API coverage for: + - `/api/v1/settings` + - startup-only vs live-reloadable server settings + - run settings snapshots + - any tests assuming old flat server settings fields +- Update frontend and generated-client expectations after the OpenAPI change. +- Update doc examples and snapshot tests that assert generated config files or `fabro settings` output. + +## Assumptions And Defaults + +- Hard cut only: one user-facing schema, no compatibility aliases, and no user-facing compatibility layer. +- A temporary internal bridge between old and new settings types is acceptable only to keep intermediate stages compiling and must be removed before the work is done. +- `run.inputs` replaces inherited values wholesale; `run.sandbox.env` remains merge-by-key and sticky. +- `cli.*` and `server.*` remain schema-valid in all files but are runtime-inert outside local `settings.toml`. +- Provider-specific subtables coexist inertly; only the selected provider or strategy subtree is validated and consumed. +- Object-store and integration credentials continue to come from `SecretStore`, `${env.NAME}`, or ambient provider auth rather than new first-pass secret fields in TOML. +- `/api/v1/settings` remains the endpoint path, but its payload shape becomes a new allow-listed public contract. diff --git a/docs/plans/2026-04-09-settings-toml-redesign-handoff-2.md b/docs/plans/2026-04-09-settings-toml-redesign-handoff-2.md new file mode 100644 index 000000000..eb31b743e --- /dev/null +++ b/docs/plans/2026-04-09-settings-toml-redesign-handoff-2.md @@ -0,0 +1,572 @@ +--- +date: 2026-04-09 +status: active +topic: settings-toml-redesign +predecessor: docs/plans/2026-04-09-settings-toml-redesign-handoff.md +--- + +# Settings TOML Redesign — Handoff 2 (post Stage 6.1–6.5 landing) + +## TL;DR + +Stages 6.1, 6.2, and 6.4 of the Stage 6 follow-up landed cleanly on `main`. +Stages 6.3 and 6.5 are **partially** complete — they each hit a concrete +blocker that requires Stage 6.6 to be done first. Stage 6.6 (OpenAPI DTO +rewrite + fabro-web) is **not started**. + +The workspace builds clean, all 3,756 tests pass, `cargo clippy --workspace +-- -D warnings` and `cargo fmt --check --all` are green. There are known +runtime behavior changes on the `/api/v1/settings` endpoint (see "Known +wire-contract mismatches" below) that will affect fabro-web until 6.6 +lands. + +The main work that remains is: + +1. **Finish Stage 6.6** — rewrite `docs/api-reference/fabro-api.yaml`, + regenerate the Rust progenitor and TypeScript Axios clients, update + `fabro-web/app/routes/workflow-detail.tsx`, and rewrite the server's + `/api/v1/settings` + `/api/v1/runs/:id/settings` handlers to build + allow-list DTOs from the v2 tree without bridging. +2. **Unblock Stage 6.3** — 6.6 removes the last reader of the legacy + flat `Settings` struct (the progenitor-generated `api::types::ServerSettings` + conversion path). Once that's gone, the whole `fabro_types::settings::{hook, + mcp, project, run, sandbox, server, user}` module tree plus + `fabro_types::combine::Combine` can be deleted. +3. **Unblock Stage 6.5** — 6.3's deletion removes the filename collisions + that currently prevent flattening `settings/v2/*.rs` up to `settings/*.rs`. +4. **Revisit scoped TODOs** — see "Scoped TODOs" below. + +## Source documents + +Read these, in this order: + +1. **Requirements (authoritative)** — + [`docs/brainstorms/2026-04-08-settings-toml-redesign-requirements.md`](../brainstorms/2026-04-08-settings-toml-redesign-requirements.md). + Source of truth for the v2 schema, merge matrix (R22 / R30 / R71 etc.), + trust boundaries, disable semantics. Refer to requirement numbers when + making schema decisions. + +2. **Original implementation plan** — + [`docs/plans/2026-04-08-settings-toml-redesign-implementation-plan.md`](./2026-04-08-settings-toml-redesign-implementation-plan.md). + +3. **Stage 6 handoff (predecessor to this doc)** — + [`docs/plans/2026-04-09-settings-toml-redesign-handoff.md`](./2026-04-09-settings-toml-redesign-handoff.md). + This is the doc I worked from. It has the per-stage scope, the file + maps, gotchas, and open design questions. **Still current** for the + remaining work — read it before touching Stage 6.6. + +## Commit trail (landed on main, most recent first) + +``` +ace24c410 refactor(types): stage 6.5 promote v2 types to settings top level +a3fd3b002 refactor(config): stage 6.4 delete fabro-config re-export shims +34a481cd4 refactor(settings): stage 6.3 delete dead Settings helpers + v2 install TOML +ea206e0e4 feat(settings): stage 6.2 delete bridge_to_old seam +52c295cf7 test(settings): update fabro-cli test suite for v2 settings shape +dc856d088 feat(settings): stage 6.1 consumer migration builds workspace-wide +5d9aad85a wip(settings): stage 6.1 consumer migration (broken build) +842ab71eb feat(types): expose bridge helpers and expand v2 accessors +3f32bdb87 feat(types): add SettingsFile convenience accessors +``` + +Total: 81 files changed, +3,718 / −2,423 lines (net +1,295). + +Note: commit `5d9aad85a` was an explicit broken-build WIP checkpoint +the user approved mid-session; `dc856d088` fixes the build. Subsequent +commits are individually test-green. + +## Current-state map (what's in the tree now) + +``` +lib/crates/fabro-types/src/settings/ +├── mod.rs +│ ├── legacy `Settings` struct (flat, _still present_ — see 6.3 status) +│ ├── legacy type re-exports from hook/mcp/project/run/sandbox/server/user +│ └── NEW: pub use v2::{SettingsFile, InterpString, Duration, ...} ← 6.5 +│ +├── hook.rs / mcp.rs / project.rs / run.rs / sandbox.rs / server.rs / user.rs +│ └── LEGACY runtime type definitions, still used (see below) +│ +└── v2/ + ├── mod.rs — module root; no more `bridge_to_old` re-export + ├── tree.rs — SettingsFile top-level + ├── version.rs + ├── project.rs / workflow.rs / run.rs / cli.rs / server.rs / features.rs + ├── duration.rs / size.rs / model_ref.rs / interp.rs / splice_array.rs + ├── accessors.rs — NEW in 6.1 prep; ~35 flat-view accessors on SettingsFile + └── to_runtime.rs — NEW in 6.2; narrow v2→runtime-type helpers + (bridge_sandbox, bridge_mcp_entry, bridge_hook, + bridge_pull_request, bridge_worktree_mode, etc.) + REPLACES the deleted bridge.rs file +``` + +``` +lib/crates/fabro-config/src/ +├── lib.rs — crate root; NEW: top-level `resolve_storage_dir(&SettingsFile)` helper +├── config.rs — ConfigLayer newtype; NO MORE `.resolve()` / TryFrom<...> for Settings +├── merge.rs — v2 merge matrix, unchanged +├── effective_settings.rs — rewritten: returns SettingsFile, v2 merge for server defaults +├── project.rs — resolve_working_directory takes &SettingsFile +├── run.rs — workflow loaders only (parse_run_config / load_run_config / resolve_graph_path) +├── user.rs — machine settings loader + path helpers, no type re-exports +├── home.rs / storage.rs / legacy_env.rs — unchanged +│ +└── DELETED in 6.4: + hook.rs, mcp.rs, sandbox.rs, server.rs +``` + +## Stage-by-stage status + +### 6.1 — Migrate consumers off flat `Settings` ✅ **COMPLETE** + +Every production read site in `fabro-workflow`, `fabro-server`, +`fabro-cli`, and `fabro-config` reads from `SettingsFile` or walks v2 +subtrees via `settings::v2::accessors`. `RunRecord.settings`, +`RunCreatedProps.settings`, `RunOptions.settings`, `CreateRunInput.settings`, +`ValidateInput.settings`, `ResolveWorkflowInput.settings`, +`ResolvedWorkflow.settings`, `AppState.settings`, and +`CommandContext::machine_settings` are all `SettingsFile`-typed. + +Where the `bridge_to_old`-style conversion to a legacy runtime type was +still needed (e.g., `fabro_types::settings::sandbox::SandboxSettings` +for `fabro-sandbox`, `fabro_types::settings::mcp::McpServerEntry` for +`fabro-mcp`, `fabro_types::settings::hook::HookDefinition` for +`fabro-hooks`), the new narrow helpers in +`fabro_types::settings::v2::to_runtime` build them from single v2 +subtrees. Consumers call these explicitly at the point of use. + +### 6.2 — Delete `bridge_to_old` seam ✅ **COMPLETE** + +`lib/crates/fabro-types/src/settings/v2/bridge.rs` (818 LOC) is deleted. +`ConfigLayer::resolve`, `TryFrom for Settings`, and +`TryFrom<&ConfigLayer> for Settings` are deleted. The full-tree +conversion from a v2 `SettingsFile` to a legacy flat `Settings` no +longer exists anywhere in the codebase. + +The narrow runtime-type helpers that the bridge exported as public +functions moved to `fabro_types::settings::v2::to_runtime` and are +scoped per runtime type (one helper per runtime struct, not one +all-in-one converter). They survive until Stage 6.3 deletes the +runtime type targets. + +### 6.3 — Delete legacy flat `Settings` types ⚠️ **PARTIAL (blocked on 6.6)** + +**What landed** (`34a481cd4`): +- Every inherent helper method on the legacy `Settings` struct + (`app_id`, `slug`, `client_id`, `git_author`, `sandbox_settings`, + `setup_settings`, `setup_commands`, `setup_timeout_ms`, + `preserve_sandbox_enabled`, `github_permissions`, `mcp_server_entries`, + `verbose_enabled`, `prevent_idle_sleep_enabled`, `upgrade_check_enabled`, + `dry_run_enabled`, `auto_approve_enabled`, `no_retro_enabled`, + `storage_dir`, `slack_settings`) is deleted. Callers migrated to the + `SettingsFile` accessors with identical names. +- `fabro-cli/src/commands/install.rs::merge_server_settings` now + writes v2 TOML (with `[server.{api,listen.tls,web,auth.api.{jwt,mtls}, + auth.web}]` stanzas). Its tests parse the output through + `ConfigLayer::parse` and assert v2 fields. + +**What did NOT land** (blocked on 6.6): +- The `Settings` struct itself is **still alive** in + `lib/crates/fabro-types/src/settings/mod.rs`. +- All seven legacy runtime type modules (`hook.rs`, `mcp.rs`, + `project.rs`, `run.rs`, `sandbox.rs`, `server.rs`, `user.rs`) are + **still alive** and used by runtime crates. +- The `Combine` trait in `lib/crates/fabro-types/src/combine.rs` is + **still alive** (only used by the legacy type `#[derive(Combine)]` + attributes). +- The `fabro-macros` crate's `Combine` derive macro is **still alive**. + +**Why it's blocked on 6.6**: the progenitor-generated OpenAPI client +in `lib/crates/fabro-api` deserializes `/api/v1/settings` responses +into `api::types::ServerSettings`, which `fabro-cli/src/server_client.rs:: +retrieve_server_settings()` converts to `fabro_types::Settings` via +`convert_type`. That conversion is the only remaining reader of the +flat `Settings` shape in production code. Stage 6.6 rewrites the +OpenAPI spec so the client returns a v2 DTO and this conversion path +goes away. + +**Remaining readers of the legacy `Settings` struct**: +| File | Use | +|---|---| +| `lib/crates/fabro-cli/src/server_client.rs:282` | `retrieve_server_settings` return type | +| `lib/crates/fabro-cli/src/commands/config/mod.rs:93` | `legacy_settings_to_v2` shim (takes `&fabro_types::Settings`) | +| `lib/crates/fabro-cli/src/commands/install.rs` | gone (tests rewritten) | +| `lib/crates/fabro-server/src/demo/mod.rs:1328, 1525` | demo route payloads | +| `lib/crates/fabro-server/src/lib.rs:20` | `pub use fabro_types::Settings;` re-export | +| `lib/crates/fabro-server/src/web_auth.rs:691` | test (or removed — double-check) | +| `lib/crates/fabro-types/src/settings/mod.rs` | definition | + +**Remaining readers of legacy runtime types** (imported via +`fabro_types::settings::{hook,mcp,sandbox,server,user,run}`): +| Consumer crate | Types it imports | +|---|---| +| `fabro-hooks` | `HookDefinition`, `HookEvent`, `HookSettings`, `HookType`, `TlsMode` | +| `fabro-mcp` | `McpServerEntry`, `McpServerSettings`, `McpTransport`, timeouts | +| `fabro-sandbox` | `SandboxSettings`, `DaytonaSettings`, `DaytonaSnapshotSettings`, `DaytonaNetwork`, `LocalSandboxSettings`, `WorktreeMode`, `DockerfileSource` | +| `fabro-checkpoint` | `GitAuthorSettings` (plus the v2 `GitAuthorLayer` via new `From` impl) | +| `fabro-workflow` | `PullRequestSettings`, `MergeStrategy`, `WorktreeMode` | +| `fabro-server` | `ApiSettings`, `TlsSettings`, `ApiAuthStrategy`, `GitSettings`, plus `ServerSettings` for the CLI target | +| `fabro-cli` | `ClientTlsSettings`, `OutputFormat`, `PermissionLevel`, `ExecSettings`, `ServerSettings` | +| `fabro-agent` | `OutputFormat`, `PermissionLevel` (for `AgentArgs`) | + +### 6.4 — Delete `fabro-config` re-export shims ✅ **COMPLETE** + +Files deleted from `lib/crates/fabro-config/src/`: +- `hook.rs`, `mcp.rs`, `sandbox.rs`, `server.rs` (pure pass-throughs) + +Files shrunk: +- `run.rs` — lost the type re-export block and the dead `resolve_env_refs` + helper. Still exports `parse_run_config` / `load_run_config` / + `resolve_graph_path` (used by fabro-cli and fabro-server). +- `user.rs` — lost the runtime type re-export block. Still exports path + helpers, `load_settings_config`, `active_settings_path`, etc. + +`resolve_storage_dir` moved from `fabro-config/src/server.rs` (deleted) +to the crate root in `fabro-config/src/lib.rs`. It takes `&SettingsFile` +now. + +All ~20 consumer crates updated to import runtime types directly from +`fabro_types::settings::{hook,mcp,sandbox,server,user,run}` instead of +`fabro_config::{hook,mcp,sandbox,server,user,run}`. The legacy import +paths no longer compile. + +### 6.5 — Flatten `settings::v2::*` → `settings::*` ⚠️ **PARTIAL (blocked on 6.3)** + +**What landed** (`ace24c410`): +Top-level re-exports of the v2 public surface at `fabro_types::settings`. +Consumers can now write: + +```rust +use fabro_types::settings::{SettingsFile, InterpString, Duration, ...}; +``` + +Covers `{CURRENT_VERSION, CliLayer, Duration, FeaturesLayer, InterpString, +ModelRef, ParseDurationError, ParseError, ParseModelRefError, +ParseSizeError, ProjectLayer, Provenance, ResolveEnvError, Resolved, +ResolvedModelRef, RunLayer, SchemaVersion, ServerLayer, SettingsFile, +Size, SpliceArray, SpliceArrayError, VersionError, WorkflowLayer, +parse_settings_file, validate_version}`. + +**What did NOT land**: +Actually moving the v2/*.rs files up to settings/*.rs. This is blocked +because the v2 submodule filenames (`project.rs`, `run.rs`, `server.rs`, +`cli.rs`) collide with the surviving legacy runtime type files with the +same names. Once Stage 6.3 deletes the legacy files, a trivial follow-up +commit can: + +1. `git mv lib/crates/fabro-types/src/settings/v2/*.rs lib/crates/fabro-types/src/settings/` +2. Delete `lib/crates/fabro-types/src/settings/v2/mod.rs` +3. Update `lib/crates/fabro-types/src/settings/mod.rs` to replace + `pub mod v2;` + the `pub use v2::{...}` block with direct + `pub mod ;` declarations and a `pub use ...::*` re-export pass. +4. Search-and-replace `::v2::` to nothing across the workspace. +5. Update the accessors module and the `to_runtime` module to drop + `super::` / `crate::` adjustments. + +### 6.6 — Rewrite OpenAPI contracts and fabro-web DTOs ⏳ **NOT STARTED** + +See the predecessor doc's Stage 6.6 section for the full scope. Key +points and anything I've learned since: + +**Files to rewrite**: +- `docs/api-reference/fabro-api.yaml`: + - Replace the `ServerSettings` schema (~lines 4238–4364 in the + untouched version) with an explicit allow-list DTO that maps + cleanly onto `SettingsFile`. See the handoff predecessor doc for + the field allow-list guidance (R16 / R52 / R53 constraints). + - Replace the `RunSettings` schema (~lines 3995–4032) similarly. +- Regenerate clients: + - Rust progenitor: `cargo build -p fabro-api` (auto-runs `build.rs`). + - TypeScript: `cd lib/packages/fabro-api-client && bun run generate`. +- `apps/fabro-web/app/routes/workflow-detail.tsx` — rewrite the static + `workflowData` literal to match the new DTO. +- `lib/crates/fabro-server/src/server.rs::get_server_settings` + (around line 1062 — **note: this function was already edited in + Stage 6.2** and now serializes the full v2 `SettingsFile` as JSON via + `serde_json::to_value(&settings)` with `strip_nulls`. That's a + temporary workaround, not the final state — see "Known wire-contract + mismatches" below). Stage 6.6 replaces it with explicit allow-list + DTO construction from the v2 tree. +- `lib/crates/fabro-server/src/server.rs` `/api/v1/runs/:id/settings` + handler — still returns `not_implemented` in the real router. +- `lib/crates/fabro-server/src/demo/mod.rs` — demo routes still emit + legacy Settings shapes. Either migrate to v2 or keep them as the + "legacy demo" path. + +**Known wire-contract mismatches** (will affect fabro-web until 6.6 lands): +1. **`/api/v1/settings` response shape drift**. The server now emits the + v2 `SettingsFile` JSON (e.g., `server.storage.root`, + `run.execution.mode`, `cli.output.verbosity`) directly. The OpenAPI + spec still declares the legacy `ServerSettings` schema (flat + `storage_dir`, `dry_run`, `verbose`). Any client that relies on the + spec will see missing fields or mis-typed values. The browser client + is the main consumer; fabro-cli's `retrieve_server_settings` still + goes through the progenitor client and round-trips through the old + JSON shape — it will break on any v2 field the old schema doesn't + declare. +2. **`openapi_conformance` test**. Still passes because it asserts + progenitor types match the YAML — but both sides are now stale + relative to what the server actually emits. Stage 6.6 should + rewrite this test or update it to cover the new DTOs. + +## Scoped TODOs (stopgap code that needs revisiting) + +Each of these is a deliberate short-term hack with a pointer to where +it should land eventually. They're also marked in-line with +`// Stage 6.x ...` comments. + +### TODO-1: `legacy_settings_to_v2` shim in fabro-cli +**File**: `lib/crates/fabro-cli/src/commands/config/mod.rs:91` +**What**: Reverse mapping from `fabro_types::Settings` → `SettingsFile`. +Covers `server.storage.root`, `server.scheduler.max_concurrent_runs`, +`server.integrations.github.{app_id, client_id, slug}`, +`server.integrations.slack.default_channel`, `run.model.{provider, name}`, +`run.inputs`, and `cli.output.verbosity`. Does **not** cover most other +fields. +**Why**: `server_client::retrieve_server_settings` returns the legacy +shape because the OpenAPI spec hasn't been rewritten. +**Delete when**: Stage 6.6 rewrites the OpenAPI spec and the progenitor +client returns v2 natively. + +### TODO-2: `build_legacy_api_settings` in fabro-server +**File**: `lib/crates/fabro-server/src/serve.rs:91` +**What**: Projects the v2 `server.auth.api.{jwt,mtls}` + `server.listen.tls` +subtrees onto the legacy `ApiSettings` struct that the existing +`resolve_auth_mode_with_lookup` function still expects. +**Why**: The auth resolver in `jwt_auth.rs` hasn't been migrated to v2 +yet. The v2 structure is different enough (no single `authentication_strategies` +enum list; `jwt` and `mtls` are separate subtables with per-strategy +`enabled` booleans) that a rewrite is warranted. +**Delete when**: Stage 6.6 replaces `resolve_auth_mode_with_lookup` with +a v2-aware resolver and deletes the legacy `ApiSettings` type. + +### TODO-3: `get_server_settings` emits raw v2 JSON +**File**: `lib/crates/fabro-server/src/server.rs:1063` +**What**: The `/api/v1/settings` handler now serializes the full v2 +`SettingsFile` as JSON with `strip_nulls` instead of building a +`ServerSettings` DTO. The spec still declares the old DTO. +**Why**: Bridge deletion left no way to produce the old shape without +re-introducing `bridge_to_old`. +**Fix when**: Stage 6.6 rewrites the OpenAPI spec and builds an explicit +allow-list DTO from v2 subtrees. Per R16/R52/R53 in the requirements doc: +- **Allow**: `server.api.url`, `server.web.enabled`, `server.web.url`, + per-provider enabled state under `server.auth.web.providers.*`, + non-secret `server.scheduler` values. +- **Deny**: `server.listen.*`, `server.listen.tls.*`, `server.auth.api`, + `server.integrations.*`, `server.artifacts*`, `server.slatedb*`, any + local `SecretStore` paths, any `InterpString` value whose + `Provenance::EnvSourced` is set. + +### TODO-4: `web_auth.rs` register flow +**File**: `lib/crates/fabro-server/src/web_auth.rs:496-659` +**What**: `setup_register` mutates a v2 TOML document via the new +`merge_settings_keys` helper (which now writes v2 top-level stanzas +under `[server.{web,auth,integrations.github}]`), writes it to disk, +then re-parses it with `ConfigLayer::load` and swaps it into +`state.settings`. +**Why**: Previously the function wrote legacy v1 TOML (top-level +`[web]`/`[api]`/`[git]`) that the v2 parser would reject. It had to be +rewritten to stay functional. +**Still TODO**: Stage 6.6 should decide whether the register flow +belongs in the server at all, or whether the web UI should drive it +directly via the HTTP API and a /api/v1/setup endpoint. The current +implementation is a hand-rolled TOML writer and loses comments / +formatting on round-trip. + +### TODO-5: `check_crypto` in diagnostics walks v2 listen TLS +**File**: `lib/crates/fabro-server/src/diagnostics.rs:469-574` +**What**: Reads `server.auth.api.{jwt,mtls}.enabled` and +`server.listen.tls.{cert,key,ca}` directly from `SettingsFile`. +**Why**: Migrated off the bridge. Works, but the error messages +reference v2 field paths (e.g., "mTLS configured but +[server.listen.tls] is missing"); the `doctor` command hints may need +updating for consistency. +**Fix when**: Opportunistic, no blocker. + +### TODO-6: Retain-or-delete dead `Combine` trait +**Files**: +- `lib/crates/fabro-types/src/combine.rs` +- `lib/crates/fabro-macros/src/lib.rs` (the `Combine` derive) +- Every `#[derive(crate::Combine)]` / `#[derive(Combine)]` on legacy + types in `fabro-types/src/settings/{run,sandbox,server,user}.rs` + + manual impls in `fabro-types/src/settings/mcp.rs`. +**What**: The trait is only used by legacy types for cross-layer +merging that v2's `combine_files` function replaced. Nothing external +calls `.combine()` on a legacy type. +**Delete when**: Stage 6.3 deletes the legacy types. The `Combine` +trait, its derive macro, and the `combine.rs` file all go with them. + +### TODO-7: Fallback chain bug preserved +**File**: `lib/crates/fabro-workflow/src/operations/start.rs:491-525` +**What**: `resolve_fallback_chain` groups all v2 `ModelRef` entries under +the empty-string provider key when building the legacy `HashMap>` that `Catalog::build_fallback_chain` expects. Since +`build_fallback_chain` looks up by `Provider::as_str()` (e.g., +`"anthropic"`), this **always returns an empty chain**. This preserves +the pre-migration behavior exactly. +**Fix when**: The model registry work in the requirements doc lands +(open question #4 in the predecessor handoff). A proper fix groups +fallbacks by actual provider and resolves bare `ModelRef::Bare` tokens +against the catalog. + +### TODO-8: V2 doesn't model `goal_file` +**File**: `lib/crates/fabro-workflow/src/operations/source.rs:150-160` +**What**: V2 has `run.goal` (an `InterpString`) but no separate +`run.goal_file`. The legacy CLI `--goal-file` flag can't be expressed +in v2. The `resolve_goal_override` helper comments on this. +**Fix when**: Either add a `run.goal_file` subfield to the v2 schema +(requires a requirements update), or route file-based goals through +the workflow-manifest layer the way the server-side flow already does. + +### TODO-9: Server settings inherent methods gone but struct serializes legacy field set +**File**: `lib/crates/fabro-types/src/settings/mod.rs:77-146` +**What**: The `Settings` struct still has ~30 fields (`llm`, `sandbox`, +`setup`, `checkpoint`, `hooks`, `mcp_servers`, `github`, `slack`, `api`, +`web`, `features`, `log`, `git`, `fabro`, `storage_dir`, `verbose`, +`prevent_idle_sleep`, `upgrade_check`, `dry_run`, `auto_approve`, +`no_retro`, `max_concurrent_runs`, `artifact_storage`, `exec`, etc.). +These are all dead weight except for the OpenAPI response path and +the demo routes. +**Delete when**: Stage 6.6 rewrites the OpenAPI spec. + +### TODO-10: Demo routes still emit legacy shape +**File**: `lib/crates/fabro-server/src/demo/mod.rs:1327-1560` +**What**: Two big `fabro_types::Settings { ... }` literal constructions +that feed demo mode responses. The demo path isn't wired into the +production API surface (goes through `demo::get_run_settings`). +**Fix when**: Either rewrite as v2 `SettingsFile` literals in Stage 6.6, +or delete the demo path entirely if it's no longer used by fabro-web. + +### TODO-11: `fabro-cli/tests/it/cmd/config.rs` has an unused `Settings` import +**File**: `lib/crates/fabro-cli/tests/it/cmd/config.rs:4` +**What**: `use fabro_types::Settings;` is leftover from an earlier +migration step. If clippy is happy with it (via re-export?), it's +harmless; otherwise remove it. +**Check**: `cargo clippy -p fabro-cli --tests -- -D warnings`. + +### TODO-12: Unused `settings_file` binding after `drop(settings)` +**File**: `lib/crates/fabro-server/src/web_auth.rs:557-570` +**What**: I re-parse the file after writing it and swap into state. +The `settings_file` local binding is the pre-edit snapshot; it's no +longer used. Double-check the function compiles without a warning and +drop the local if it's dead. + +## Scoped open design questions (from the predecessor doc, still open) + +1. **Should `ConfigLayer::resolve(self) -> Settings` survive in any form?** + — It's gone. The natural rename (`into_file(self) -> SettingsFile`) + isn't needed because `From for SettingsFile` already + exists. Consumers call `.into()`. **Decided: no rename.** + +2. **Post-layering env interpolation resolution pass**. Still not + implemented. `InterpString::resolve` is called at read time by each + consumer that needs a concrete string. Stage 6.6's allow-list DTO + construction will need provenance-aware redaction; the missing pass + means each DTO builder has to do its own `.resolve(|name| + std::env::var(name).ok())` + provenance check. The requirements doc + R79–R81 still specifies a centralized pass under + `fabro-config/src/interp_pass.rs`. + +3. **Fail-closed server auth posture**. Still not wired into + `fabro-server/src/server.rs` startup. R52/R53 requires that if + `server.auth` is absent or resolves to no enabled API / web + strategies, normal startup refuses to run, with demo and test + helpers opting in explicitly to insecure startup. Stage 6.6 is the + natural place — the allow-list DTO construction for + `/api/v1/settings` must know the enabled auth strategies, which + overlaps with the startup posture check. + +4. **Runtime `ModelRegistry` for `ModelRef::resolve`**. Still unimplemented. + `fabro_types::settings::v2::model_ref::ModelRef::resolve` takes a + `&dyn ModelRegistry` and errors on ambiguous bare tokens. There's + no runtime implementation against `fabro-model::Catalog`. See TODO-7 + above. + +5. **`run.scm.` subtree depth**. Still minimal — only + `run.scm.github` exists as a placeholder unit struct. Add real + fields when the first SCM-specific leaf lands. + +6. **`flatten` + `HashMap` + `deny_unknown_fields`**. Don't try to + flatten a HashMap under `deny_unknown_fields`. It doesn't work in + serde. Enumerate known providers explicitly (as v2 already does for + `NotificationRouteLayer`, `InterviewsLayer`, etc.). + +## Running verification + +```bash +# full gate — must stay green after every incremental commit +cargo fmt --check --all +cargo build --workspace +cargo clippy --workspace -- -D warnings +ulimit -n 4096 && cargo nextest run --workspace + +# web assets (when touching fabro-web): +cd apps/fabro-web && bun run typecheck && bun test && bun run build + +# API spec conformance: +cargo nextest run -p fabro-server --test it openapi_conformance +``` + +Current status on `main`: all of the above are green. + +## Success criteria for finishing Stage 6 + +Pulled from the predecessor handoff, updated for what remains: + +- [ ] `git grep 'fabro_types::Settings\b'` returns zero hits outside + the legacy type file that's about to be deleted. + **Current: ~9 hits remain — see TODO-1 / TODO-9 / TODO-10.** +- [x] `git grep 'bridge_to_old'` returns zero hits. + **Done in 6.2.** +- [ ] `lib/crates/fabro-types/src/settings/v2/` no longer exists as + a subdirectory — its contents are promoted to `settings/*`. + **Blocked on 6.3; top-level re-exports landed in 6.5.** +- [ ] `lib/crates/fabro-types/src/combine.rs` is deleted. + **Blocked on 6.3.** +- [ ] `lib/crates/fabro-config/src/{hook,mcp,sandbox,server,run,user}.rs` + are either deleted or reduced to thin re-export shells. + **hook/mcp/sandbox/server: deleted. run/user: reduced to the + helper functions they still own.** +- [ ] `docs/api-reference/fabro-api.yaml` `ServerSettings` and + `RunSettings` schemas are explicit allow-list DTOs. + **Not started (6.6).** +- [ ] `lib/packages/fabro-api-client` and the Rust progenitor client + are regenerated from the new spec. + **Not started (6.6).** +- [ ] `apps/fabro-web/app/routes/workflow-detail.tsx` `workflowData` + literal matches the new `RunSettings` DTO. + **Not started (6.6).** +- [x] The `cargo fmt` / `cargo build` / `cargo clippy -D warnings` / + `cargo nextest run --workspace` / `bun run typecheck` / `bun test` + / `bun run build` gates all stay green. + **Rust side: green. Frontend: unverified — the new `/api/v1/settings` + JSON shape may break fabro-web at runtime. Verify before merging + any frontend release.** + +## Starting points for the next engineer + +1. **Read the predecessor handoff end-to-end** — it has the scope, + gotchas, and open design questions. +2. **Run the test suite locally** to confirm the starting state + (`ulimit -n 4096 && cargo nextest run --workspace`). Expected: + 3,756 passed / 0 failed / 182 skipped. +3. **Verify the wire-contract drift** before touching anything: + ```bash + cargo run -p fabro-cli -- server start # in one terminal + curl -s http://localhost:3000/api/v1/settings | jq '.' + ``` + You should see the v2 `SettingsFile` shape (`server.storage.root`, + `run.execution.mode`, etc.), not the legacy flat shape. This is + the state that 6.6 needs to reconcile with the OpenAPI spec. +4. **Start 6.6 by drafting the new `ServerSettings` DTO** in the + OpenAPI yaml. Use the R16 allow-list from the requirements doc + as the starting point. Don't try to be exhaustive — a narrower + first cut is easier to review. +5. **Generate clients, update `get_server_settings` and + `get_run_settings` to build the DTO explicitly**, and only then + touch fabro-web. The backend change should be testable in isolation + before anything in the frontend moves. +6. **After 6.6 lands**, deleting the legacy `Settings` types in 6.3 + + flattening the v2 directory in 6.5 becomes mechanical. + +Good luck. diff --git a/docs/plans/2026-04-09-settings-toml-redesign-handoff-3.md b/docs/plans/2026-04-09-settings-toml-redesign-handoff-3.md new file mode 100644 index 000000000..f277a9d55 --- /dev/null +++ b/docs/plans/2026-04-09-settings-toml-redesign-handoff-3.md @@ -0,0 +1,364 @@ +--- +date: 2026-04-09 +status: active +topic: settings-toml-redesign +predecessor: docs/plans/2026-04-09-settings-toml-redesign-handoff-2.md +--- + +# Settings TOML Redesign — Handoff 3 (post Stage 6.6 + 6.3b partial) + +## TL;DR + +Stage 6.6 (OpenAPI DTO rewrite + server handlers + CLI migration + +fabro-web literals + demo routes) landed cleanly on `main`, and Stage +6.3b's first pass — **deleting the legacy flat `fabro_types::Settings` +struct itself** — also landed. The legacy flat view is dead code +everywhere in production. + +What remains is the *runtime type module cleanup*: the 7 files under +`lib/crates/fabro-types/src/settings/{hook,mcp,project,run,sandbox, +server,user}.rs` are still alive and consumed by 8 downstream crates. +These modules are what blocks Stage 6.5b (flatten `settings/v2/*.rs` +up to `settings/*.rs`). The blockers are filename collisions and ~33 +import statements scattered across the workspace. + +3,758 workspace tests pass. `cargo fmt --check --all` and +`cargo clippy --workspace -- -D warnings` are clean. `bun run +typecheck`, `bun test`, and `bun run build` for `apps/fabro-web` are +green. + +Main work remaining: + +1. **Finish Stage 6.3b** — migrate the 8 consumer crates off the + runtime type modules, then delete those 7 files plus the + `Combine` trait + derive macro. +2. **Stage 6.5b** — trivial once 6.3b finishes: `git mv + lib/crates/fabro-types/src/settings/v2/*.rs + lib/crates/fabro-types/src/settings/` and sweep `::v2::` out of + the workspace. +3. **Stage 6.6g** — rewrite `fabro-server` auth resolver for v2 + (TODO-2 from handoff-2). +4. **Stage 6.6j** — review `setup_register` TOML writer in + `web_auth.rs` (TODO-4 from handoff-2). +5. Remaining scoped TODOs (TODO-5, 7, 8, 11, 12 from handoff-2). + +## Source documents + +Read these, in this order: + +1. **Requirements (authoritative)** — + [`docs/brainstorms/2026-04-08-settings-toml-redesign-requirements.md`](../brainstorms/2026-04-08-settings-toml-redesign-requirements.md). +2. **Original implementation plan** — + [`docs/plans/2026-04-08-settings-toml-redesign-implementation-plan.md`](./2026-04-08-settings-toml-redesign-implementation-plan.md). +3. **Stage 6 handoff (predecessor 1)** — + [`docs/plans/2026-04-09-settings-toml-redesign-handoff.md`](./2026-04-09-settings-toml-redesign-handoff.md). +4. **Stage 6 handoff 2 (immediate predecessor)** — + [`docs/plans/2026-04-09-settings-toml-redesign-handoff-2.md`](./2026-04-09-settings-toml-redesign-handoff-2.md). + Full per-stage file maps and scoped TODOs; most content still + applies. + +## Commit trail (landed on main in this session, most recent first) + +``` +4a40c73b7 refactor(settings): stage 6.3b delete legacy flat Settings struct +65a9fd137 refactor(fabro-web): stage 6.6 rewrite workflowData literal to v2 shape +f5b9f82a2 feat(settings): stage 6.6 wire server + CLI to v2 SettingsFile DTO +7c8448ece refactor(api): stage 6.6 collapse settings DTOs to freeform v2 shape +``` + +Net effect: about −3,500 / +500 lines across the four commits. + +## Stage-by-stage status + +### 6.6 — OpenAPI DTO rewrite + fabro-web ✅ **COMPLETE (for the in-scope parts)** + +**What landed** (`7c8448ece` + `f5b9f82a2` + `65a9fd137`): + +- `docs/api-reference/fabro-api.yaml`: + - Replaces `ServerSettings` with a `type: object, + additionalProperties: true` freeform schema pointing at the v2 + `SettingsFile` docs. + - Replaces `RunSettings` similarly. + - Deletes the 20+ orphaned supporting schemas that only those two + referenced (`LlmSettings`, `SandboxSettings`, `HookDefinition`, + `WebSettings`, `ApiSettings`, `TlsSettings`, `GitSettings`, + `AuthSettings`, `Features`, `LogSettings`, `CheckpointSettings`, + `PullRequestSettings`, `ArtifactsSettings`, `McpServerEntry`, + `GitHubSettings`, `DaytonaSettings`, `LocalSandboxSettings`, + `DaytonaSnapshotSettings`, `SetupSettings`, `GitAuthorSettings`, + `WebhookSettings`). +- Regenerates the Rust progenitor client — `RunSettings` and + `ServerSettings` are now `#[serde(transparent)]` newtype wrappers + over `serde_json::Map`. +- Regenerates the TypeScript Axios client — the orphan + `run-settings.ts`, `server-settings.ts`, and 30+ nested model files + are deleted; the API methods inline the freeform type + as `{ [key: string]: any; }`. +- `fabro-server/src/settings_view.rs` (**new module**, ~220 LOC + including tests): `redact_for_api(&SettingsFile) -> SettingsFile` + drops `server.listen.*`, `server.auth.api.jwt.{issuer,audience}`, + `server.auth.api.mtls.ca`, and + `server.auth.web.providers.github.client_secret`. 5 unit tests + cover each drop case plus a `preserves_run_cli_project_and_features` + smoke test. +- `fabro-server/src/server.rs::get_server_settings` — now calls + `settings_view::redact_for_api` before serializing. +- `fabro-server/src/server.rs::get_run_settings` — **new** real + handler (was previously `not_implemented`) that opens the run + reader, reads the persisted `RunRecord.settings`, redacts, and + emits JSON. The demo route still points at `demo::get_run_settings`, + which was also rewritten. +- `fabro-cli/src/server_client.rs::retrieve_server_settings` — now + returns `SettingsFile` directly (not the legacy `Settings`). The + body is decoded from the progenitor `types::ServerSettings` + transparent newtype via `serde_json::from_value::(...)`. +- `fabro-cli/src/commands/config/mod.rs::legacy_settings_to_v2` — + **deleted** (TODO-1 from handoff-2 resolved). `merged_config` + passes the v2 file straight into + `effective_settings::resolve_settings`. +- `fabro-cli/tests/it/cmd/config.rs` — rewrites + `server_settings_fixture` to build a v2 `SettingsFile` via + `ConfigLayer::parse` instead of the legacy flat TOML shape. +- `fabro-web` — defines local `type ServerSettings = + Record` and `type RunSettings = Record` aliases in `settings.tsx` / `workflow-api.ts` since the + generated client no longer exports named model types. The UI only + `JSON.stringify`s these payloads. The static `workflowData` + literal in `workflow-detail.tsx` is rewritten to v2 shape + (`_version`, `run.goal`, `run.inputs`, `run.model`, `run.sandbox`, + `run.prepare.steps`, with `"120s"` / `"8GB"` / `"10GB"` string + forms). +- `fabro-server/src/demo/mod.rs` — the two demo settings fixtures + (`runs::settings()` and `settings::server_settings()`) are + rewritten as `serde_json::json!(...)` literals in v2 shape + (TODO-10 from handoff-2 resolved). + +**Known remaining wire-contract concerns**: + +1. `openapi_conformance::server_settings_keys_match_openapi_spec` + was **deleted** in 6.3b because the new freeform-object schema + has no `properties` to diff against. `all_spec_routes_are_routable` + remains. +2. `bun run dev` / browser sanity check against a real running + server is still unverified — the new wire shape should work + because fabro-web only stringifies it, but this should be smoke- + tested before the next frontend release. + +### 6.6g — Rewrite auth resolver for v2 ⏳ **NOT STARTED** + +TODO-2 from handoff-2 still stands: + +**File**: `lib/crates/fabro-server/src/serve.rs:91` — the +`build_legacy_api_settings` stopgap builds a legacy +`fabro_types::settings::server::ApiSettings` from the v2 +`server.auth.api.{jwt,mtls}` + `server.listen.tls` subtrees so that +`resolve_auth_mode_with_lookup` in `jwt_auth.rs` still works. + +**Fix**: rewrite `resolve_auth_mode_with_lookup` to read +`SettingsFile` directly, delete `build_legacy_api_settings`, and +drop the `fabro_types::settings::server::{ApiSettings, +ApiAuthStrategy, TlsSettings}` imports from `serve.rs` / `jwt_auth.rs` +/ `tls.rs`. + +### 6.6j — setup_register review ⏳ **NOT STARTED** + +TODO-4 from handoff-2 still stands: + +**File**: `lib/crates/fabro-server/src/web_auth.rs:496-659`. The +`setup_register` function hand-rolls a v2 TOML document and writes +it to disk. It works but loses comments / formatting on round-trip. +Plus TODO-12: double-check and drop any dead `settings_file` local +binding after the `drop(settings)` write-and-reparse dance at +`web_auth.rs:557-570`. + +### 6.3b — Delete legacy flat `Settings` types ⚠️ **PARTIAL** + +**What landed in this session** (`4a40c73b7`): + +- `fabro_types::Settings` struct itself: **deleted** from + `lib/crates/fabro-types/src/settings/mod.rs`. All ~65 fields gone. +- `fabro_types::Settings` re-export from `fabro_types/src/lib.rs:56`: + **deleted**. +- `fabro_types::settings::Settings` usage in + `fabro-server/src/lib.rs::server_config` module: re-export + **deleted**. The `fabro_types::settings::server::*` pass-through + is still there because downstream code still imports from it. +- `fabro-server/src/demo/mod.rs` — the two demo settings literals + (runs::settings + settings::server_settings) were rewritten as + v2 `serde_json::json!` literals (6.6i, simultaneously). +- `fabro-server/tests/it/openapi_conformance.rs` — deleted the + `server_settings_keys_match_openapi_spec` test that built a + fully-populated legacy `Settings` to diff against the spec. Kept + `all_spec_routes_are_routable`. +- `fabro-store/src/run_state.rs` — test fixture switched from + `Settings::default()` to `SettingsFile::default()`. +- `fabro-types/src/run_event/mod.rs` — two `RunCreated` round-trip + tests switched from `Settings::default()` to + `SettingsFile::default()`. +- `fabro-workflow/tests/it/integration.rs` — the two + `hook_toml_*_parsing` tests that decoded top-level `[[hooks]]` into + a legacy `Settings` were **deleted**. Those test the legacy parse + path which had already been removed in Stage 6.1; the coverage + moves to `fabro-types::settings::v2::tree::tests`. + +**What did NOT land** (deferred to Stage 6.3c): + +The 7 runtime type modules under +`lib/crates/fabro-types/src/settings/` are still alive: + +- `hook.rs` — `HookDefinition`, `HookEvent`, `HookSettings`, + `HookType`, `TlsMode` +- `mcp.rs` — `McpServerEntry`, `McpServerSettings`, `McpTransport`, + `default_startup_timeout_secs`, `default_tool_timeout_secs` +- `project.rs` — `ProjectSettings` +- `run.rs` — `ArtifactsSettings`, `CheckpointSettings`, `GitHubSettings`, + `LlmSettings`, `MergeStrategy`, `PullRequestSettings`, `SetupSettings` +- `sandbox.rs` — `DaytonaNetwork`, `DaytonaSettings`, + `DaytonaSnapshotSettings`, `DockerfileSource`, `LocalSandboxSettings`, + `SandboxSettings`, `WorktreeMode` +- `server.rs` — `ApiAuthStrategy`, `ApiSettings`, + `ArtifactStorageBackend`, `ArtifactStorageSettings`, `AuthProvider`, + `AuthSettings`, `FeaturesSettings`, `GitAuthorSettings`, + `GitProvider`, `GitSettings`, `LogSettings`, `SlackSettings`, + `TlsSettings`, `WebSettings`, `WebhookSettings`, `WebhookStrategy` +- `user.rs` — `ClientTlsSettings`, `ExecSettings`, `OutputFormat`, + `PermissionLevel`, `ServerSettings` + +Plus `fabro-types/src/combine.rs` (the `Combine` trait) and the +`fabro-macros` `Combine` derive macro that only these modules use. + +These are blocked on migrating the 8 consumer crates that import +them. See "Consumer migration map" below. + +### 6.5b — Flatten `settings::v2::*` → `settings::*` ⏳ **STILL BLOCKED ON 6.3b** + +No change from handoff-2. When 6.3b finishes deleting the runtime +type modules, this becomes a trivial `git mv` + search-and-replace +pass. The file-name collisions to resolve are `project.rs`, `run.rs`, +`server.rs`, `cli.rs` — each exists in both `settings/` and +`settings/v2/`. + +## Consumer migration map (for finishing 6.3b) + +| Crate | Legacy types it still imports | Suggested destination | +|---|---|---| +| `fabro-agent` | `OutputFormat`, `PermissionLevel` from `settings::user` | Promote into `fabro-agent` itself — they're CLI/exec concerns. Or point at `settings::v2::cli::OutputFormat` / `v2::run::AgentPermissions` if shapes match. | +| `fabro-checkpoint` | `GitAuthorSettings` from `settings::server` | Promote into `fabro-checkpoint` or read directly from `v2::run::GitAuthorLayer` at the call site. | +| `fabro-hooks` | `HookDefinition`, `HookEvent`, `HookSettings`, `HookType`, `TlsMode` | Promote all of them into `fabro-hooks`. They are runtime behavior types (has `resolved_hook_type()` / `runs_in_sandbox()` methods), not parse-tree types, so they belong in the consumer crate. | +| `fabro-mcp` | `McpServerEntry`, `McpServerSettings`, `McpTransport`, `default_startup_timeout_secs`, `default_tool_timeout_secs` | Promote into `fabro-mcp`. Convert from v2 `run.agent.mcps.*` or `cli.exec.agent.mcps.*` at the call site. | +| `fabro-sandbox` | `SandboxSettings`, `DaytonaSettings`, `DaytonaSnapshotSettings`, `DaytonaNetwork`, `LocalSandboxSettings`, `WorktreeMode`, `DockerfileSource` | Already re-exported as `fabro_sandbox::daytona::*` with renames. Promote the source into `fabro-sandbox` directly and drop the re-export path. | +| `fabro-checkpoint` | `GitAuthorSettings` | Same as above. | +| `fabro-workflow` | `PullRequestSettings`, `MergeStrategy`, `WorktreeMode` | `MergeStrategy` and `WorktreeMode` have identical v2 equivalents in `v2::run` — point at them directly. `PullRequestSettings` should move into `fabro-workflow`. | +| `fabro-server` | `ApiSettings`, `TlsSettings`, `ApiAuthStrategy`, `GitSettings`, `ServerSettings` (as `UserServerSettings`), `GitHubSettings`, `WebSettings`, `AuthSettings`, `GitAuthorSettings`, `WebhookSettings`, `LogSettings`, `FeaturesSettings` | Part of Stage 6.6g — the auth resolver rewrite needs to walk `v2::server::auth` directly; likewise the TLS handling in `tls.rs`. Other types may just need to move into `fabro-server`. | +| `fabro-cli` | `ClientTlsSettings`, `OutputFormat`, `PermissionLevel`, `ExecSettings`, `ServerSettings` (as `UserServerSettings`) | Promote `ClientTlsSettings` / `ExecSettings` into `fabro-cli`. `OutputFormat` / `PermissionLevel` / `ServerSettings` are shared with `fabro-agent` — decide whether they belong in `fabro-agent` and re-export, or in a new shared crate. | + +**Total import sites to rewrite**: about 33 `use` statements and +roughly that many call-sites, across ~15 files in 8 crates. Each +individual migration is small; the aggregate is the bulk of the +remaining 6.3b work. + +### Combine trait + +After the consumer migration: + +1. `lib/crates/fabro-types/src/combine.rs` — delete. +2. `lib/crates/fabro-macros/src/lib.rs::Combine` derive — delete. +3. `fabro-macros` crate becomes empty or can go away entirely if + there are no other derives in it. + +## Scoped TODOs (handoff-2 status update) + +| TODO | Subject | Status | +|---|---|---| +| TODO-1 | `legacy_settings_to_v2` shim in fabro-cli | ✅ **Deleted** in `f5b9f82a2` | +| TODO-2 | `build_legacy_api_settings` in fabro-server | ⏳ Still open (6.6g) | +| TODO-3 | `get_server_settings` emits raw v2 JSON | ✅ **Fixed** in `f5b9f82a2`. Handler now calls `settings_view::redact_for_api` | +| TODO-4 | `web_auth.rs` register flow | ⏳ Still open (6.6j) | +| TODO-5 | `check_crypto` in diagnostics | ⏳ Opportunistic, unchanged | +| TODO-6 | Dead `Combine` trait | ⏳ Still blocked on consumer migration | +| TODO-7 | Fallback chain bug preserved | ⏳ Unchanged — waiting on model registry work | +| TODO-8 | V2 doesn't model `goal_file` | ⏳ Unchanged — requirements decision needed | +| TODO-9 | Server settings inherent methods gone | ✅ **Fixed** — Settings struct is deleted entirely in 6.3b | +| TODO-10 | Demo routes still emit legacy shape | ✅ **Fixed** in `4a40c73b7`. Demo fixtures rewritten as v2 JSON | +| TODO-11 | Unused `Settings` import in `config.rs` tests | ✅ **Fixed** in `f5b9f82a2`. Test file rewritten to use `SettingsFile` | +| TODO-12 | Unused `settings_file` binding in `web_auth.rs` | ⏳ Still open (rolls up into 6.6j) | + +## Running verification + +```bash +# Rust side — should stay green after every incremental commit +cargo fmt --check --all +cargo build --workspace +cargo clippy --workspace -- -D warnings +ulimit -n 4096 && cargo nextest run --workspace + +# Web side — should stay green when touching fabro-web +cd apps/fabro-web && bun run typecheck && bun test && bun run build + +# API spec conformance — single test remaining +cargo nextest run -p fabro-server --test it openapi_conformance +``` + +Expected as of `4a40c73b7`: 3,758 tests pass / 0 fail / 182 skipped. + +## Success criteria for finishing Stage 6 + +Updated from handoff-2: + +- [x] `git grep 'fabro_types::Settings\b'` returns zero hits outside + the comment in the conformance test. + **Done in `4a40c73b7`.** +- [x] `git grep 'bridge_to_old'` returns zero hits. +- [ ] `lib/crates/fabro-types/src/settings/v2/` no longer exists + as a subdirectory. **Blocked on finishing 6.3b.** +- [ ] `lib/crates/fabro-types/src/combine.rs` is deleted. + **Blocked on finishing 6.3b.** +- [x] `lib/crates/fabro-config/src/{hook,mcp,sandbox,server,run,user}.rs` + deleted or reduced to thin helpers. + **Done in Stage 6.4.** +- [x] `docs/api-reference/fabro-api.yaml` `ServerSettings` and + `RunSettings` schemas are not the legacy flat shape. + **Done in `7c8448ece`** (freeform objects pointing at the v2 + SettingsFile Rust type). +- [x] `lib/packages/fabro-api-client` and the Rust progenitor client + are regenerated. + **Done in `7c8448ece`.** +- [x] `apps/fabro-web/app/routes/workflow-detail.tsx` `workflowData` + literal matches the new shape. + **Done in `65a9fd137`.** +- [x] `cargo fmt` / `cargo build` / `cargo clippy -D warnings` / + `cargo nextest run --workspace` / `bun run typecheck` / + `bun test` / `bun run build` gates all green. + **Verified after each commit.** + +## Starting points for the next engineer + +1. **Read this doc and handoff-2 in full** — the consumer migration + map above is the bulk of the remaining work and rewards careful + per-crate thinking. +2. **Run the test suite locally** to confirm the starting state + (`ulimit -n 4096 && cargo nextest run --workspace`). Expected: + 3,758 passed / 0 failed / 182 skipped. +3. **Pick the smallest consumer first** (suggested order: + `fabro-checkpoint` → `fabro-agent` → `fabro-workflow` → + `fabro-hooks` → `fabro-mcp` → `fabro-sandbox` → `fabro-cli` → + `fabro-server`). For each: + a. Move the types into the consumer crate with `git mv` or hand + relocation. + b. Update the consumer's public API to own them. + c. Rewrite the consumer's `From<&SettingsFile>` / construction + path to build from v2 subtrees directly. + d. Delete the corresponding runtime type file in `fabro-types`. + e. Verify `cargo build --workspace`, `cargo clippy --workspace + -- -D warnings`, and the relevant nextest subset stay green + before moving to the next crate. +4. **After the last consumer migrates**, delete `Combine` (trait, + derive, crate file). +5. **Stage 6.5b** is a one-commit follow-up: `git mv v2/*.rs up`, + drop the `::v2::` paths, done. +6. **Stage 6.6g and 6.6j** are independent of the above and can be + sequenced whenever; 6.6g pairs naturally with the `fabro-server` + consumer migration because both touch `jwt_auth.rs` / `serve.rs` / + `tls.rs`. + +Good luck. diff --git a/docs/plans/2026-04-09-settings-toml-redesign-handoff-4.md b/docs/plans/2026-04-09-settings-toml-redesign-handoff-4.md new file mode 100644 index 000000000..17610bb3f --- /dev/null +++ b/docs/plans/2026-04-09-settings-toml-redesign-handoff-4.md @@ -0,0 +1,265 @@ +--- +date: 2026-04-09 +status: complete +topic: settings-toml-redesign +predecessor: docs/plans/2026-04-09-settings-toml-redesign-handoff-3.md +--- + +# Settings TOML Redesign — Handoff 4 (Stage 6 complete) + +## TL;DR + +**Stage 6 is done.** Every substage from 6.1 through 6.6j is +complete. The legacy flat `Settings` parse tree, its `Combine`-driven +layering, the `bridge_to_old` seam, the seven runtime type modules, +and the transitional `v2/` subdirectory are all deleted. The +`fabro_types::settings` module is now flat and v2-native. + +3,758 workspace tests pass. `cargo fmt --check --all`, +`cargo clippy --workspace -- -D warnings`, and +`cd apps/fabro-web && bun run typecheck && bun test && bun run build` +are all green. + +There is no remaining Stage 6 work to hand off. Any follow-ups from +here are *new* decisions (see "Deferred / new work" below). + +## What landed in this wrap-up session + +Fifteen commits on `main` on top of handoff-3's starting point: + +``` +c625747e0 refactor(settings): stage 6.5b sweep ::v2:: prefix out of consumers +d82d167f0 refactor(settings): stage 6.6g rewrite auth resolver for v2 +15b799fb3 refactor(settings): stage 6.3b + 6.5b finish — delete last legacy server types and flatten v2/ +3ac7ab903 refactor(settings): stage 6.3b shrink server runtime types + delete Combine +7f9640aac refactor(settings): stage 6.3b promote run runtime types + delete to_runtime +6df8bbeb3 refactor(settings): stage 6.3b promote sandbox runtime types into fabro-sandbox +38dacb874 refactor(settings): stage 6.3b promote mcp runtime types into fabro-mcp +2016c8e94 refactor(settings): stage 6.3b promote hook + project runtime types +db45511ff refactor(settings): stage 6.3b promote user runtime types into consumers +``` + +Each commit is small, test-green, and self-contained. The migration +walked one consumer crate at a time through the consumer migration +map from handoff-3. + +### Stage 6.3b complete — runtime type module tree deletion + +Every consumer that used to import from +`fabro_types::settings::{hook, mcp, project, run, sandbox, server, +user}` now owns its runtime types locally: + +| Old module | Runtime types moved to | +|---|---| +| `hook.rs` | `fabro-hooks/src/config.rs` | +| `mcp.rs` | `fabro-mcp/src/config.rs` | +| `sandbox.rs` | `fabro-sandbox/src/config.rs` | +| `run.rs` → `PullRequestSettings`, `MergeStrategy`, `ArtifactsSettings` | `fabro-workflow/src/config.rs` | +| `user.rs` → `OutputFormat`, `PermissionLevel` | `fabro-agent/src/cli.rs` | +| `user.rs` → `ClientTlsSettings` | `fabro-cli/src/user_config.rs` | +| `server.rs` → `ApiAuthStrategy`, `ApiSettings`, `TlsSettings` | `fabro-server/src/jwt_auth.rs` (temporarily; see 6.6g) | +| `project.rs` | deleted (`ProjectSettings` was dead) | + +Dead types deleted outright (no consumers remained): + +- From `run.rs`: `LlmSettings`, `SetupSettings`, `CheckpointSettings`, + `GitHubSettings`. +- From `user.rs`: `ExecSettings`, legacy `ServerSettings`. +- From `server.rs`: `AuthProvider`, `AuthSettings`, `GitProvider`, + `GitSettings`, `GitAuthorSettings`, `WebSettings`, `WebhookSettings`, + `WebhookStrategy`, `SlackSettings`, `FeaturesSettings`, + `LogSettings`, `ArtifactStorageBackend`, `ArtifactStorageSettings`. + +Narrow v2→runtime bridge helpers that used to live in +`fabro-types::settings::v2::to_runtime` moved alongside their target +types: + +- `bridge_hook` → `fabro_hooks::config::bridge_hook` +- `bridge_mcp_entry` / `bridge_mcps` → `fabro_mcp::config::*` +- `bridge_sandbox` / `bridge_worktree_mode` → `fabro_sandbox::config::*` +- `bridge_pull_request` / `bridge_merge_strategy` / `bridge_run_artifacts` + → `fabro_workflow::config::*` + +`fabro-types/src/settings/v2/to_runtime.rs` is deleted. + +### `Combine` trait machinery deleted + +- `lib/crates/fabro-types/src/combine.rs` — deleted. +- `pub mod combine;` / `pub use fabro_macros::Combine;` removed from + `fabro-types/src/lib.rs`. +- `#[proc_macro_derive(Combine)]` and its `syn::{Data, DeriveInput, + Fields}` imports removed from `fabro-macros/src/lib.rs`. The + `e2e_test` attribute macro is untouched. + +### Stage 6.5b complete — v2 directory flatten + +- `git mv lib/crates/fabro-types/src/settings/v2/*.rs + lib/crates/fabro-types/src/settings/` +- `lib/crates/fabro-types/src/settings/v2/` — deleted. +- `settings/mod.rs` absorbs the old `v2/mod.rs` declarations and + re-exports (accessors, cli, duration, features, interp, model_ref, + project, run, server, size, splice_array, tree, version, workflow). +- A final workspace sweep rewrote every + `fabro_types::settings::v2::*` import path to + `fabro_types::settings::*` — 53 files, 10 crates. +- The transitional `pub mod v2 { pub use super::*; }` alias is also + deleted; there is no `::v2::` namespace anywhere. + +### Stage 6.6g complete — auth resolver v2-native + +- `resolve_auth_mode_with_lookup` rewritten to take `&SettingsFile` + directly and walk + `settings.server.auth.api.{jwt,mtls}` + + `settings.server.auth.web.allowed_usernames` + + `settings.server.listen.tls`. +- Strategy presence uses the "subtree present unless `enabled = false`" + semantics from R52. +- The `ApiSettings` and `ApiAuthStrategy` shim types and the + `build_legacy_api_settings` helper in `serve.rs` are **deleted** + (~60 LOC). +- `TlsSettings` survives as a local helper in + `fabro-server/src/jwt_auth.rs` with a + `TlsSettings::from_settings(&SettingsFile)` constructor that + projects `server.listen.tls` into the resolved triple. It's only + used by `tls.rs`'s rustls builder and the mTLS integration test. +- `serve.rs`'s bootstrap now calls the new resolver directly. + +## Final status of every Stage 6 substage + +| Substage | Status | +|---|---| +| 6.1 — Migrate consumers off flat `Settings` | ✅ COMPLETE (predecessor session) | +| 6.2 — Delete `bridge_to_old` seam | ✅ COMPLETE (predecessor session) | +| 6.3 — Delete legacy flat `Settings` helpers | ✅ COMPLETE (predecessor session) | +| 6.3b — Delete `Settings` struct + 7 runtime type modules | ✅ **COMPLETE** | +| 6.4 — Delete `fabro-config` re-export shims | ✅ COMPLETE (predecessor session) | +| 6.5 — Promote v2 types to top-level `settings::*` re-exports | ✅ COMPLETE (predecessor session) | +| 6.5b — Flatten `settings/v2/*.rs` → `settings/*.rs` | ✅ **COMPLETE** | +| 6.6a/b — Design allow-list DTOs in OpenAPI | ✅ COMPLETE (this session, prior) | +| 6.6c — Regenerate Rust + TS clients | ✅ COMPLETE (this session, prior) | +| 6.6d — Rewrite `get_server_settings` with redaction | ✅ COMPLETE (this session, prior) | +| 6.6e — Rewrite `get_run_settings` handler | ✅ COMPLETE (this session, prior) | +| 6.6f — Migrate `retrieve_server_settings` in fabro-cli | ✅ COMPLETE (this session, prior) | +| 6.6g — Rewrite auth resolver for v2 | ✅ **COMPLETE** | +| 6.6h — Update fabro-web `workflow-detail.tsx` DTO literal | ✅ COMPLETE (this session, prior) | +| 6.6i — Migrate demo routes to v2 | ✅ COMPLETE (this session, prior) | +| 6.6j — Rewrite `setup_register` web_auth flow | ✅ **COMPLETE** (was already v2-writing after predecessor session; TODO-12's dead `settings_file` binding turned out to not exist anymore) | + +## Scoped TODO status (from handoff-2) + +| TODO | Subject | Final status | +|---|---|---| +| TODO-1 | `legacy_settings_to_v2` shim in fabro-cli | ✅ Deleted | +| TODO-2 | `build_legacy_api_settings` in fabro-server | ✅ Deleted (6.6g) | +| TODO-3 | `get_server_settings` emits raw v2 JSON | ✅ Replaced with redacted DTO | +| TODO-4 | `web_auth.rs` register flow rewrite | ⚠️ **Partial** — the hand-rolled TOML writer now emits v2 shape and is tested. Comment/formatting preservation on round-trip is a nice-to-have left for a follow-up pass; see "Deferred / new work" | +| TODO-5 | `check_crypto` in diagnostics | ✅ Walks v2 listen TLS (predecessor session) | +| TODO-6 | Dead `Combine` trait | ✅ Deleted | +| TODO-7 | Fallback chain bug preserved | ⚠️ **Unchanged** — still preserves pre-migration behavior; needs the runtime `ModelRegistry` implementation | +| TODO-8 | V2 doesn't model `goal_file` | ⚠️ **Unchanged** — needs requirements-level decision | +| TODO-9 | Legacy Settings struct dead weight | ✅ Deleted (6.3b) | +| TODO-10 | Demo routes still emit legacy shape | ✅ Rewritten as v2 JSON | +| TODO-11 | Unused `Settings` import in config tests | ✅ Removed | +| TODO-12 | Unused `settings_file` binding in web_auth.rs | ✅ No such binding exists (already cleaned up) | + +## Deferred / new work + +These are *not* Stage 6 items. They are open questions or new +improvements that came up during the work and are worth considering +separately. + +1. **`setup_register` comment-preserving TOML writes (ex-TODO-4).** + The current hand-rolled writer uses `toml` + `toml::to_string_pretty` + which loses comments and formatting on round-trip. A fix would use + `toml_edit::DocumentMut` (new workspace dependency). Alternatively, + the whole GitHub App registration flow might be better driven from + fabro-web as a dedicated `/api/v1/setup` endpoint instead of living + in `setup_register`. + +2. **Runtime `ModelRegistry` for `ModelRef::resolve` (ex-TODO-7).** + `fabro_types::settings::model_ref::ModelRef::resolve` still takes + a `&dyn ModelRegistry` and errors on ambiguous bare tokens. There's + no runtime implementation against `fabro-model::Catalog`, so the + `resolve_fallback_chain` helper in + `fabro-workflow/src/operations/start.rs` still groups all fallbacks + under the empty-string provider key and never matches. This + preserves pre-migration behavior exactly but isn't the correct + fallback behavior. Open question from predecessor handoff #4. + +3. **`run.goal_file` schema support (ex-TODO-8).** V2 has `run.goal` + as an `InterpString` but no separate `run.goal_file`. The legacy + CLI `--goal-file` flag can't be expressed in v2. Either add a + `run.goal_file` subfield (requirements update) or route file-based + goals through the workflow-manifest layer. + +4. **Fail-closed server auth posture (open question #3).** The + requirements doc R52/R53 specifies that startup should refuse to + run if `server.auth` is absent or resolves to no enabled API/web + strategies, with demo and test helpers opting in explicitly. The + current `resolve_auth_mode_with_lookup` just logs a warning and + builds an `AuthMode::Strategies(empty)`. A follow-up can tighten + this — the hook point is already clean now that 6.6g landed. + +5. **Post-layering env interpolation resolution pass (open question + #2).** `InterpString::resolve` is still called at read time by + each consumer that needs a concrete string. The requirements doc + R79–R81 specifies a centralized pass under + `fabro-config/src/interp_pass.rs` that runs once after layering. + Not implemented in any handoff so far. + +6. **OpenAPI freeform settings DTO vs formal allow-list DTO.** Stage + 6.6a/b chose to declare `ServerSettings` and `RunSettings` as + `type: object, additionalProperties: true` freeform objects in the + OpenAPI spec, pointing at the Rust `SettingsFile` type for the + shape. This loses client-side type safety in TypeScript (the + generated client returns `{ [key: string]: any }`). A follow-up + could formalize the full v2 `SettingsFile` tree in OpenAPI yaml + (tedious but not hard), or keep the loose shape and provide a + hand-written TypeScript type declaration in + `@qltysh/fabro-api-client` as a convenience. + +7. **`TlsSettings` in `fabro-server/src/jwt_auth.rs`.** This 3-field + struct is the last legacy-shaped leftover. It's technically owned + by the right crate now, but putting it in `jwt_auth.rs` is a + historical artifact — a dedicated `fabro-server/src/tls_config.rs` + module would be a more natural home. Pure cleanup, no urgency. + +## Running verification + +```bash +cargo fmt --check --all +cargo build --workspace +cargo clippy --workspace -- -D warnings +ulimit -n 4096 && cargo nextest run --workspace + +cd apps/fabro-web && bun run typecheck && bun test && bun run build +``` + +All green as of `c625747e0` on `main`: 3,758 tests passed / 0 failed +/ 182 skipped. + +## Success criteria for Stage 6 (all resolved) + +- [x] `git grep 'fabro_types::Settings\b'` returns zero hits. +- [x] `git grep 'bridge_to_old'` returns zero hits. +- [x] `lib/crates/fabro-types/src/settings/v2/` no longer exists as + a subdirectory. +- [x] `lib/crates/fabro-types/src/combine.rs` is deleted. +- [x] `lib/crates/fabro-types/src/settings/{hook,mcp,project,run, + sandbox,user}.rs` legacy runtime modules — deleted. + `settings/server.rs` now exists as the *v2* server layer file + (promoted from `v2/server.rs` in 6.5b). +- [x] `lib/crates/fabro-config/src/{hook,mcp,sandbox,server,run,user}.rs` + deleted or reduced to helpers. +- [x] `docs/api-reference/fabro-api.yaml` `ServerSettings` and + `RunSettings` schemas are not the legacy flat shape. +- [x] `lib/packages/fabro-api-client` and the Rust progenitor client + are regenerated. +- [x] `apps/fabro-web/app/routes/workflow-detail.tsx` `workflowData` + literal matches the new shape. +- [x] `cargo fmt` / `cargo build` / `cargo clippy -D warnings` / + `cargo nextest run --workspace` / `bun run typecheck` / + `bun test` / `bun run build` gates all green. + +Stage 6 is closed. Next work should be driven by the deferred items +list above or by new requirements. diff --git a/docs/plans/2026-04-09-settings-toml-redesign-handoff.md b/docs/plans/2026-04-09-settings-toml-redesign-handoff.md new file mode 100644 index 000000000..4a4206125 --- /dev/null +++ b/docs/plans/2026-04-09-settings-toml-redesign-handoff.md @@ -0,0 +1,608 @@ +--- +date: 2026-04-09 +status: active +topic: settings-toml-redesign +predecessor: docs/plans/2026-04-08-settings-toml-redesign-implementation-plan.md +--- + +# Settings TOML Redesign — Handoff to Stage 6 Follow-up + +## TL;DR + +Stages 1–5 of the settings TOML redesign landed on `main` across 13 commits. The +user-facing hard cut is complete: every Fabro config file now parses against +the v2 namespaced schema, legacy top-level keys hard-fail with targeted rename +hints, the merge matrix is implemented per the normative requirements doc, +trust boundaries work across all three resolution modes, all scaffolds and +docs are migrated, and the workspace is 100% tests-green (**3,760 passed / 0 +failed**), clippy-clean, and correctly formatted. + +The remaining work is **Stage 6: delete the legacy flat `Settings` shape and +the transitional `bridge_to_old` seam**, plus the OpenAPI + generated clients ++ fabro-web DTO rewrite that was explicitly deferred from Stage 5. This +document is everything you need to continue the work in a fresh session. + +## Source documents + +Read these before starting, in order: + +1. **Requirements (authoritative)** — + [`docs/brainstorms/2026-04-08-settings-toml-redesign-requirements.md`](./../brainstorms/2026-04-08-settings-toml-redesign-requirements.md). + This is the source of truth for the v2 schema, merge matrix, trust + boundaries, and disable semantics. Refer to requirement numbers (R1–R90) + when you change schema rules so decisions stay traceable. + +2. **Original implementation plan** — + [`docs/plans/2026-04-08-settings-toml-redesign-implementation-plan.md`](./2026-04-08-settings-toml-redesign-implementation-plan.md). + This is the 6-stage sequence and the scope of what needs to land. Stage 6 + in that document is the list of things this handoff still owes. + +3. **Representative canonical example** — the `representative_full_tree_parses` + test in + [`lib/crates/fabro-types/src/settings/v2/tree.rs`](../../lib/crates/fabro-types/src/settings/v2/tree.rs#L295-L422). + If you want a feel for how the whole v2 schema fits together, read this + fixture before anything else. + +## Current-state map + +### What the tree looks like at handoff + +``` +lib/crates/fabro-types/src/settings/ +├── mod.rs — transitional seam; hosts legacy flat Settings + +│ module comment explaining the deletion plan +├── v2/ — authoritative v2 schema (Stages 1–2 output) +│ ├── mod.rs — module root; re-exports +│ ├── tree.rs — SettingsFile top-level; parse_settings_file(), +│ │ ParseError with rename-hint table +│ ├── version.rs — _version pre-validation +│ ├── project.rs — ProjectLayer +│ ├── workflow.rs — WorkflowLayer +│ ├── run.rs — RunLayer + all run subtree types (536 LOC) +│ ├── cli.rs — CliLayer + cli subtree types +│ ├── server.rs — ServerLayer + server subtree types +│ ├── features.rs — FeaturesLayer +│ ├── duration.rs — Duration value-language helper +│ ├── size.rs — Size value-language helper +│ ├── model_ref.rs — ModelRef + ambiguity resolution +│ ├── interp.rs — InterpString with provenance tagging +│ ├── splice_array.rs — SpliceArray "..." marker +│ └── bridge.rs — TRANSITIONAL: bridge_to_old(&SettingsFile)->Settings +│ (~820 LOC — this is the thing Stage 6 deletes) +├── hook.rs, mcp.rs, project.rs, run.rs, sandbox.rs, server.rs, user.rs +│ — LEGACY flat type definitions. Delete in Stage 6. +└── (combine trait is in ../combine.rs — also legacy, also deletes) + +lib/crates/fabro-config/ +├── lib.rs — module tree (note: combine.rs + settings.rs +│ deleted in Stage 6 initial cleanup) +├── config.rs — ConfigLayer newtype over SettingsFile; exposes +│ ::parse/::load/::combine/::resolve/::as_v2 +├── merge.rs — v2 merge matrix implementation (683 LOC, +│ covers every row of the normative table) +├── effective_settings.rs — EffectiveSettingsLayers + resolve_settings +│ with LocalOnly/RemoteServer/LocalDaemon modes +│ and trust-boundary stripping +├── project.rs — workflow discovery + resolve_fabro_root +├── user.rs — load_settings_config + legacy file warnings +├── run.rs — parse_run_config + resolve_env_refs helper + +│ re-export shim of resolved run types +├── sandbox.rs, server.rs, — THIN re-export shims. Stage 6 deletes these +│ hook.rs, mcp.rs once consumers stop importing through them. +├── storage.rs — unrelated; stays +├── home.rs — 1-line Home re-export +└── legacy_env.rs — 12-line legacy env var helper +``` + +### Dependency chain to understand + +``` +TOML file + │ + ▼ parse_settings_file() (fabro-types/src/settings/v2/tree.rs) +SettingsFile (v2) + │ + ▼ combine_files() (fabro-config/src/merge.rs) +SettingsFile (v2, merged) + │ + ▼ bridge_to_old() (fabro-types/src/settings/v2/bridge.rs) +Settings (legacy flat) + │ + ▼ every consumer that reads (~84 call sites across 15 files) + settings.llm, settings.vars, settings.sandbox, ... +``` + +The **bridge is the only producer of the legacy flat `Settings` shape**. +Removing it requires every reader to consume `SettingsFile` directly. + +## Commit log (Stages 1–5 landed on `main`) + +``` +c6d515be4 fix(lint): clean up fabro-config test clippy warnings +31db613aa docs(config): point new code at ConfigLayer::as_v2 rather than the bridge +3dd3c7bf8 refactor(config): delete unused legacy shim modules, document transitional seam +dba10e5e9 docs: migrate reference and guide examples to v2 config shape +b57248236 test(migration): land final Stage 4 fixes — 100% workspace tests green +2fc85282b fix(effective_settings): keep cli/server stanzas from user settings.toml +a6047250c fix(lint): clippy cleanup for Stage 3/4 consumer migration +f4a79b896 test(cli): migrate remaining config/exec/create fixtures to v2 +f467bd23c fix(bridge): use hook command shorthand to avoid duplicate serde key +eabbca649 feat(tests): migrate fabro-cli fixtures and repo fabro.toml to v2 +a0eec6aee feat(config): switch parser and layering to v2 schema +bb228643e feat(types): flesh out v2 subtrees and add legacy bridge +288e73321 feat(types): add settings v2 parse tree scaffolding +``` + +Total: 76 files changed, +6,413 / -2,151 lines. + +## Stage 6 work breakdown + +Stage 6 has **six independent subtasks**. Each subtask can land as its own PR +on top of `main` — they have a natural dependency order but can be paused +between steps because the transitional bridge keeps the workspace building at +every intermediate state. + +### 6.1 — Migrate consumer read sites from flat `Settings` to v2 `SettingsFile` + +**Scope**: ~84 field-access sites across 15 files (grep below). + +**Files to touch** (ordered easy → hard): + +``` +lib/crates/fabro-workflow/src/run_options.rs — 5 sites, mostly behind accessor methods +lib/crates/fabro-workflow/src/operations/source.rs — 3 sites +lib/crates/fabro-workflow/src/operations/create.rs — ~8 sites, touches LLM mutation +lib/crates/fabro-workflow/src/operations/start.rs — ~10 sites, touches setup/hooks/llm +lib/crates/fabro-cli/src/commands/run/runner.rs — a few sites +lib/crates/fabro-cli/src/commands/exec.rs — a few sites +lib/crates/fabro-cli/src/manifest_builder.rs — 2 sites (goal, goal_file) +lib/crates/fabro-server/src/run_manifest.rs — ~11 sites in handlers + tests +lib/crates/fabro-server/src/server.rs — ~14 sites (biggest file) +lib/crates/fabro-server/src/web_auth.rs — ~20 sites (git settings heavy) +lib/crates/fabro-server/src/serve.rs — a few sites +lib/crates/fabro-config/src/effective_settings.rs — apply_server_defaults copies every field +lib/crates/fabro-config/src/project.rs — resolve_working_directory reads settings.work_dir +lib/crates/fabro-cli/tests/it/cmd/create.rs — 7 sites in assertions +lib/crates/fabro-cli/tests/it/cmd/runner.rs — 4 sites in assertions +``` + +Exact grep: + +```bash +grep -rn 'settings\.llm\|settings\.vars\|settings\.sandbox\|settings\.setup\|settings\.hooks\|settings\.checkpoint\|settings\.pull_request\|settings\.mcp_servers\|settings\.artifacts\|settings\.git\|settings\.exec\|settings\.fabro\|settings\.goal\|settings\.work_dir\|settings\.labels\|settings\.github' lib/crates --include='*.rs' +``` + +**Migration pattern** (before → after): + +```rust +// BEFORE (legacy flat) +let model = settings.llm.as_ref().and_then(|llm| llm.model.clone()); +let provider = settings.llm.as_ref().and_then(|llm| llm.provider.clone()); +``` + +```rust +// AFTER (v2 via ConfigLayer::as_v2()) +let model = layer + .as_v2() + .run + .as_ref() + .and_then(|r| r.model.as_ref()) + .and_then(|m| m.name.as_ref()) + .map(InterpString::as_source); +``` + +**Recommended sequence**: + +1. **Start with receive-side accessor methods** on `ConfigLayer` and + `RunOptions`. For every flat field that consumers read, add an accessor + method that walks the v2 tree. Land these additively (no caller changes + yet). Example: + ```rust + impl ConfigLayer { + pub fn run_model_name(&self) -> Option { + self.file.run.as_ref() + .and_then(|r| r.model.as_ref()) + .and_then(|m| m.name.as_ref()) + .map(InterpString::as_source) + } + } + ``` +2. **Migrate one caller at a time**, file-by-file, smallest first. After each + file: `cargo build -p ` + `cargo nextest run -p ` before + moving on. Do not try to cover 15 files at once — incremental commits. +3. **Delete the flat-field helper methods on `Settings`** as nothing reads + them. They are in + [`lib/crates/fabro-types/src/settings/mod.rs`](../../lib/crates/fabro-types/src/settings/mod.rs#L111-L196): + `app_id()`, `slug()`, `client_id()`, `git_author()`, `sandbox_settings()`, + `setup_settings()`, `setup_commands()`, `setup_timeout_ms()`, + `preserve_sandbox_enabled()`, `github_permissions()`, + `mcp_server_entries()`, `verbose_enabled()`, `prevent_idle_sleep_enabled()`, + `upgrade_check_enabled()`, `dry_run_enabled()`, `auto_approve_enabled()`, + `no_retro_enabled()`, `storage_dir()`, `slack_settings()`. These will + cascade compiler errors into callers that you can then migrate. + +**Gotchas**: + +- **`settings.vars` vs v2 `run.inputs`**: v2 replaces wholesale (R22). If a + consumer was relying on `vars` merging across layers, its behavior was + ambiguous before and is now explicit — it sees whichever layer set `inputs` + last. Check tests after migration. +- **`settings.setup.commands` vs v2 `run.prepare.steps`**: v2 replaces the + whole ordered list (R30). Several tests were re-asserted in Stage 4; + similar audits will be needed for any newly-migrated code path. +- **`settings.work_dir`** is the bridge output of `run.working_dir` + (`InterpString`). When consumers want the raw string, call + `InterpString::as_source()`. When they want an env-resolved value, call + `InterpString::resolve(|name| std::env::var(name).ok())` — the v2 + interpolation pass is not yet wired into the default resolve path. +- **`settings.github.permissions`** maps to + `server.integrations.github.permissions` in v2, which means it lives in + the owner-specific domain and is stripped from fabro.toml / workflow.toml + layers per R16. Consumers in `fabro-workflow` that read it will need to + either lift the read to a call site that has access to the server-local + layer, or accept that workflow-level config cannot ask for GitHub token + permissions. Flag this as an open design question if you hit it. + +### 6.2 — Delete the `bridge_to_old` seam + +**Files**: +- `lib/crates/fabro-types/src/settings/v2/bridge.rs` (818 LOC) — delete + entirely. +- `lib/crates/fabro-types/src/settings/v2/mod.rs` — drop the + `pub mod bridge;` and `pub use bridge::bridge_to_old;` lines. +- `lib/crates/fabro-config/src/config.rs` — delete the + `TryFrom for Settings` and `TryFrom<&ConfigLayer> for Settings` + impls, the `bridge_to_old` import, and change `ConfigLayer::resolve(self) -> + Settings` to `ConfigLayer::into_file(self) -> SettingsFile` (or just + encourage `From for SettingsFile` which already exists). + +**Prerequisite**: 6.1 must be complete — there must be zero readers of flat +`Settings` left. `git grep 'fabro_types::Settings\b'` should return nothing +outside of the legacy type definitions themselves. + +**Known consumer of `bridge_to_old`**: only `ConfigLayer::resolve` in +[`lib/crates/fabro-config/src/config.rs`](../../lib/crates/fabro-config/src/config.rs#L133-L140). +No external callers. This is the last thing to unwire before the bridge can +be deleted. + +### 6.3 — Delete the legacy flat types + +**Files to delete** (and remove from `mod.rs` re-export lists): + +``` +lib/crates/fabro-types/src/settings/mod.rs — Settings struct, impls, tests +lib/crates/fabro-types/src/settings/hook.rs — HookDefinition, HookEvent, HookType, HookSettings, TlsMode +lib/crates/fabro-types/src/settings/mcp.rs — McpServerEntry, McpServerSettings, McpTransport +lib/crates/fabro-types/src/settings/project.rs — ProjectSettings +lib/crates/fabro-types/src/settings/run.rs — LlmSettings, SetupSettings, CheckpointSettings, + PullRequestSettings, ArtifactsSettings, + GitHubSettings, MergeStrategy +lib/crates/fabro-types/src/settings/sandbox.rs — SandboxSettings, DaytonaSettings, DaytonaSnapshotSettings, + LocalSandboxSettings, DaytonaNetwork, WorktreeMode, + DockerfileSource +lib/crates/fabro-types/src/settings/server.rs — ApiSettings, WebSettings, GitSettings, GitAuthorSettings, + AuthSettings, AuthProvider, ApiAuthStrategy, TlsSettings, + WebhookSettings, WebhookStrategy, GitProvider, + FeaturesSettings, LogSettings, SlackSettings, + ArtifactStorageSettings, ArtifactStorageBackend +lib/crates/fabro-types/src/settings/user.rs — ClientTlsSettings, ExecSettings, OutputFormat, + PermissionLevel, ServerSettings +lib/crates/fabro-types/src/combine.rs — Combine trait (unused after deletes above) +lib/crates/fabro-macros/src/lib.rs — keep `#[derive(Combine)]` if any non-legacy use; + otherwise delete the derive macro entry +``` + +**Prerequisite**: 6.2 must be complete (bridge deleted). + +**Dependency chain**: `Combine` is used _only_ by legacy flat type derives +today. Search with +```bash +grep -rn '#\[derive(.*Combine\|impl Combine\|fabro_types::combine\|fabro_types::Combine' lib/crates --include='*.rs' +``` +If the only hits are inside `fabro-types/src/settings/*.rs` legacy files, the +trait + derive are safe to delete in the same PR. + +### 6.4 — Delete the `fabro-config` re-export shims + +**Files** (all are 1–62 LOC thin pass-throughs): + +``` +lib/crates/fabro-config/src/hook.rs — re-exports fabro_types::settings::hook::* +lib/crates/fabro-config/src/mcp.rs — re-exports fabro_types::settings::mcp::* +lib/crates/fabro-config/src/sandbox.rs — re-exports fabro_types::settings::sandbox::* +lib/crates/fabro-config/src/server.rs — re-exports fabro_types::settings::server::* + resolve_storage_dir() +lib/crates/fabro-config/src/user.rs — re-exports fabro_types::settings::user::* + path helpers +lib/crates/fabro-config/src/run.rs — re-exports fabro_types::settings::run::* + + parse_run_config + resolve_env_refs + resolve_graph_path +``` + +**Before deleting**, migrate callers off them. The callers are listed in the +file-level commit `3dd3c7bf8` — summary: `fabro-hooks`, `fabro-mcp`, +`fabro-sandbox`, `fabro-agent`, `fabro-cli`, `fabro-server`, +`fabro-workflow`, plus a handful of test files import via +`fabro_config::::...` paths. Each should import directly from +`fabro_types::settings::v2::...` once the legacy types are gone. + +**Retain**: +- `fabro-config/src/run.rs` **`resolve_graph_path()`** — still used, not + legacy. Move it to `fabro-config/src/project.rs` or `fabro-config/src/lib.rs`. +- `fabro-config/src/run.rs` **`parse_run_config()`** — still used by + `fabro-server/src/run_manifest.rs` and `fabro-cli/src/manifest_builder.rs`. + It's already a thin `ConfigLayer::parse` wrapper. Either keep it as a + top-level function in `fabro-config/src/lib.rs` or inline at call sites. +- `fabro-config/src/run.rs` **`resolve_env_refs()`** — the legacy minimal env + resolver. Once consumers use `InterpString::resolve` directly, delete. +- `fabro-config/src/user.rs` **path helpers** (`default_settings_path`, + `default_socket_path`, `active_settings_path`, legacy path helpers, + `load_settings_config`) — still used by CLI commands. Move them to + `fabro-config/src/lib.rs` or a new `fabro-config/src/paths.rs`. + +### 6.5 — Flatten `settings::v2::*` → `settings::*` + +Once Stages 6.3 + 6.4 are done and `fabro-types/src/settings/` only contains +the old `v2/` directory plus a mostly-empty `mod.rs`, rename everything to +be the primary namespace: + +``` +fabro-types/src/settings/ +├── mod.rs (re-exports direct from subdirs, no more v2 prefix) +├── tree.rs +├── version.rs +├── project.rs +├── workflow.rs +├── run.rs +├── cli.rs +├── server.rs +├── features.rs +├── duration.rs +├── size.rs +├── model_ref.rs +├── interp.rs +└── splice_array.rs +``` + +Rewrite imports across the workspace — `use fabro_types::settings::v2::...` +becomes `use fabro_types::settings::...`. + +**Recommendation**: one big mechanical commit with just the rename; do not +mix with behavior changes. + +### 6.6 — Rewrite OpenAPI contracts + regenerate clients + fix fabro-web + +This is the piece that was explicitly deferred from Stage 5 because the +current bridge-backed `/api/v1/settings` response still works against the +existing `ServerSettings` schema. Owning the explicit allow-list DTOs is the +end-state the plan calls for (requirements doc "Validation Boundary" + +implementation plan Stage 5). + +**Files to rewrite**: + +- `docs/api-reference/fabro-api.yaml` — replace the current flat + `ServerSettings` schema (lines ~4238–4364) and `RunSettings` schema + (lines ~3995–4032) with explicit allow-list DTOs. The allow-lists are + spelled out in the implementation plan under "Rebuild resolution, trust + boundaries, and safe serialization": + - **`/api/v1/settings` (server scope)**: allow only `server.api.url`, + `server.web.enabled`, `server.web.url`, per-provider enabled state for + `server.auth.web.providers.*`, and non-secret `server.scheduler` values. + Deny everything else — notably `server.listen.*`, `server.listen.tls.*`, + `server.auth.api`, `server.integrations.*`, `server.artifacts*`, + `server.slatedb*`, any local SecretStore paths, and any env-resolved + values tagged via `InterpString` provenance. + - **`/api/v1/runs/{id}/settings` (run scope)**: allow the resolved `run.*` + tree. Deny: any `InterpString` value whose resolution provenance shows + it was sourced from `${env.NAME}`, provider-credential fields under + `run.notifications.*.`, env values under + `run.agent.mcps.*.env` that were env-interpolated, and any field + explicitly marked sensitive. Deny all `project.*`, `workflow.*`, + `cli.*`, and `server.*` — they're not part of a run view. +- **Then regenerate**: + - Rust progenitor client: `cargo build -p fabro-api` (auto-runs via + `build.rs`). + - TypeScript client: `cd lib/packages/fabro-api-client && bun run generate`. +- **Update fabro-web**: + - `apps/fabro-web/app/routes/workflow-detail.tsx` has a static + `workflowData` literal (lines 18+) typed as `RunSettings`. Rewrite each + entry to match the new run-scope DTO shape. The live `/settings` and + `/runs/:id/settings` routes use `JSON.stringify` and are shape-agnostic — + they don't need code changes, just the type alignment that falls out of + the client regen. +- **Update server handlers**: + - `lib/crates/fabro-server/src/server.rs` `get_server_settings` (around + line 1062) currently serializes the flat Settings into the legacy + `ServerSettings` shape via `serde_json::to_value` and `strip_nulls`. + Rewrite to build the new allow-list DTO explicitly from + `state.settings` — it must _not_ use `serde_json::to_value` on the full + Settings, otherwise the allow-list is leaky. There is a redaction + helper path in `fabro-types/src/settings/v2/interp.rs` + (`Provenance::EnvSourced`) — consult it when you build the run-scope DTO. + - `/api/v1/runs/:id/settings` currently returns `not_implemented` in the + real (non-demo) router (grep `server.rs:1012`). The run-scope DTO rebuild + is the same mechanical shape as the server-scope one, just different + fields. The demo router wires `demo::get_run_settings` around + `server.rs:934` — don't confuse the two during migration. + +**Provenance redaction helper you'll need**: + +`InterpString::resolve` returns a `Resolved { value, provenance }`. When +`provenance == Provenance::EnvSourced`, the caller knows the field came +from an env var and must redact it before serializing into the run-scope +DTO. If you find yourself building the same `Resolved` → DTO conversion in +multiple handlers, pull it into `fabro-types/src/settings/v2/redact.rs` as +a new helper module. + +## Verification recipe (run on every incremental step) + +```bash +# full gate — must stay green between every sub-step +cargo fmt --check --all +cargo build --workspace +cargo clippy --workspace -- -D warnings +ulimit -n 4096 && cargo nextest run --workspace +cd apps/fabro-web && bun run typecheck && bun test && cd - + +# sanity: no legacy top-level TOML keys remain in real config files +git grep -n '^version = 1' -- docs/ lib/ apps/ fabro/ test/ | \ + grep -v 'changelog\|_version' + +# sanity: after Stage 6.2 the bridge should have no callers +git grep 'bridge_to_old' lib/ +``` + +## Testing gotchas I hit + +These are lessons learned during Stages 1–5. Save yourself the pain. + +1. **`fabro-cli` integration tests use a shared CLI test daemon under + parallel nextest load**. Raise the shell FD limit and cap threads: + ```bash + ulimit -n 4096 + cargo nextest run -p fabro-cli --no-fail-fast --test-threads=4 + ``` + macOS inherited sessions default to `ulimit -n 256`, which surfaces as + misleading EMFILE test timeouts. + +2. **Insta snapshots** — when you update a snapshot, check the pending + diffs before bulk-accepting. `cargo insta pending-snapshots` lists + what's about to change. `cargo insta accept` accepts everything; + `cargo insta accept --snapshot ` accepts one at a time. During + Stage 4 we chose bulk accept for the run / attach JSON snapshots after + confirming the only diffs were `server.target` + `_version` leakage + (which we then filtered out explicitly in the per-test filter code). + +3. **Hook shorthand vs `#[serde(flatten)]`** — the legacy + `HookDefinition` struct has `command: Option` and + `#[serde(flatten)] hook_type: Option`, and `HookType::Command` + _also_ has a `command: String` field. Setting both + `hook_type = Some(HookType::Command { command: ... })` and trying to + serialize (or round-trip through YAML) produces a duplicate `command` + key and fails deserialization. The bridge emits script/command hooks + via the shorthand (`HookDefinition.command`) and leaves + `HookDefinition.hook_type` as `None` to work around this. See commit + `f467bd23c`. + +4. **`fabro-test` managed settings marker** — the helper writes a + `# fabro-test managed storage_dir` comment as the first line of + injected settings.toml files. Functions that read the file (like + `settings_storage_dir` for `isolated_server`) must detect the marker + and treat the managed storage root as _not_ user-explicit, or + `isolated_server` will pick up the shared storage dir and the test + will fail with `assertion left != right` on the storage dir. See + commit `b57248236`. + +5. **`effective_settings::apply_server_defaults`** copies the **full** + server-side Settings shape (llm, sandbox, setup, checkpoint, + pull_request, artifacts, hooks, mcp_servers, github, slack, fabro) + into the resolved CLI settings in RemoteServer / LocalDaemon modes. + This is intentional — it matches the pre-Stage-3 behavior and makes + `fabro-server::server::tests::start_run_persists_full_settings_snapshot` + work. If you refactor the bridge during Stage 6, make sure the + equivalent propagation lands in whatever replaces it. + +6. **User layer trust boundary**: `effective_settings` strips `cli` and + `server` from the `workflow.toml` and `fabro.toml` layers in + RemoteServer / LocalDaemon modes, but **the user layer + (`~/.fabro/settings.toml`) is never stripped** — owner-specific + domains are only legal there. If you're tempted to strip them + uniformly, re-read R16 and commit `2fc85282b`. + +7. **Clippy test warnings**: `cargo clippy --workspace --tests -- -D warnings` + has two pre-existing issues in `fabro-interview/src/control.rs` + (absolute paths for `tokio::task::yield_now`). They're unrelated to + the settings refactor — leave them alone or fix them in a tiny + side-quest PR. The workspace-level (non-tests) clippy is already + green. + +## Open design questions for you to decide + +1. **Should `ConfigLayer::resolve(self) -> Settings` survive in any form?** + The natural rename is `into_file(self) -> SettingsFile`, but many + callers genuinely want a "final resolved view" that has applied env + interpolation, applied defaults, etc. Decide whether that's an + explicit `ResolvedSettings` type (new, v2-shaped) or whether it's + just `SettingsFile` with a contract that consumers resolve + `InterpString` themselves at read time. + +2. **Post-layering env interpolation resolution pass** — the original + plan calls for a pass in `fabro-config/src/interp_pass.rs` that + resolves every `InterpString` in the merged `SettingsFile` using + provenance tagging. I left this undone because `InterpString::resolve` + is adequate for the bridge output. Stage 6 is the right moment to + build the proper pass so the DTOs in Stage 6.6 can rely on + provenance. Requirements R79–R81 and the "Validation Boundary" + section of the requirements doc cover the rules. + +3. **Fail-closed server auth posture** — R52/R53 + "Default server + auth posture" in the plan say that if `server.auth` is absent or + resolves to no enabled API / web auth strategies, normal server + startup must refuse to start, with demo and test helpers free to + opt in to insecure startup. I did not wire this into + `fabro-server/src/server.rs`. Decide when it should land — doing it + in the same PR as Stage 6.6 keeps auth-related changes together. + +4. **`runtime.rs` model-ref ambiguity registry** — `ModelRef::resolve` + takes a `&dyn ModelRegistry` and errors on ambiguous bare tokens. + There's no runtime implementation of `ModelRegistry` yet. Decide + whether to implement it against `fabro-model::Catalog` in Stage 6, + or leave model-ref resolution as a consumption-time concern the + model selector already handles. + +5. **`run.scm.` subtree depth** — only `run.scm.github` is + defined as a unit struct placeholder right now. Requirements R64 says + "provider-specific details live in provider-specific nested tables". + When the first real SCM provider leaf lands, add fields under + `v2::run::ScmGitHubLayer` and mirror the pattern for future + providers. + +6. **`flatten` + `HashMap` + `deny_unknown_fields`** does NOT work + together in serde. Every time you think "I can just flatten a + HashMap here for provider-specific fields," resist. Use an + enumerated list of known-provider subfields instead (that's why + `RunSandboxLayer`, `NotificationRouteLayer`, `InterviewsLayer`, + `RunScmLayer`, `ServerIntegrationsLayer`, etc. have explicit + `github`/`slack`/`discord`/`teams`/`local`/`s3` fields). Adding a new + provider means adding a new field. + +## Repo conventions you'll hit + +- **Rust import style** (from `CLAUDE.md`): types imported by name, + functions via parent module, no glob imports in production code + except in test modules. The v2 schema code follows this throughout. +- **Shell quoting in sandbox code**: always `shell_quote()` / + `shlex::try_quote`. Don't hand-roll `.replace('\'', "'\\''")`. +- **Commits**: conventional style. Incremental commits per logical + unit. Do not force-push. Do not amend. Stage 1–5 commits are the + model. +- **Tests**: match existing patterns in each crate. `insta` for + snapshots. `e2e_test` attribute for dual-mode tests. Use + `fabro_test::test_http_client()` rather than `reqwest::Client::new()` + for local HTTP in tests (macOS proxy discovery overhead). + +## Success criteria for Stage 6 + +The refactor is **done** when: + +- [ ] `git grep 'fabro_types::Settings\b'` returns zero hits outside of + the legacy type file that's about to be deleted. +- [ ] `git grep 'bridge_to_old'` returns zero hits. +- [ ] `lib/crates/fabro-types/src/settings/v2/` no longer exists as a + subdirectory — its contents are promoted to `settings/*`. +- [ ] `lib/crates/fabro-types/src/combine.rs` is deleted (the trait + only existed to serve legacy flat types). +- [ ] `lib/crates/fabro-config/src/{hook,mcp,sandbox,server,run,user}.rs` + are either deleted or reduced to a thin `pub use ...::v2::...` + re-export shell, depending on your preference for the external + surface. +- [ ] `docs/api-reference/fabro-api.yaml` `ServerSettings` and + `RunSettings` schemas are explicit allow-list DTOs, not reflections + of the flat legacy shape. +- [ ] `lib/packages/fabro-api-client` and the Rust progenitor client are + regenerated from the new spec. +- [ ] `apps/fabro-web/app/routes/workflow-detail.tsx` `workflowData` + literal matches the new `RunSettings` DTO. +- [ ] The `cargo fmt` / `cargo build` / `cargo clippy -D warnings` / + `cargo nextest run --workspace` / `bun run typecheck` / `bun test` + / `bun run build` gates all stay green. + +Good luck! The hard cut is behind you — Stage 6 is mechanical from +here. diff --git a/docs/reference/architecture.mdx b/docs/reference/architecture.mdx index c26e8c61f..df9e90456 100644 --- a/docs/reference/architecture.mdx +++ b/docs/reference/architecture.mdx @@ -28,11 +28,11 @@ CLI mode is ideal for: fabro server start ``` -`fabro server start` starts an HTTP server (default `127.0.0.1:3000`) backed by SQLite for run persistence. Runs are submitted via the REST API and executed asynchronously. +`fabro server start` starts an HTTP server (default `127.0.0.1:3000`) with persistent run storage. Runs are submitted via the REST API and executed asynchronously. ### Configuration -The server reads `~/.fabro/server.toml` for default settings (model, sandbox, variables, authentication). This file is live-reloaded — changes take effect within seconds without restarting the server. +The server reads `~/.fabro/settings.toml` for default settings (model, sandbox, variables, authentication). This file is live-reloaded — changes take effect within seconds without restarting the server. Key server config options: @@ -61,7 +61,7 @@ In API mode, human-in-the-loop questions are served over HTTP instead of termina ### Authentication -API mode supports two authentication strategies, configurable in `server.toml`: +API mode supports two authentication strategies, configurable in `settings.toml`: - **JWT** — EdDSA-signed tokens (used by the web UI) - **mTLS** — Mutual TLS with client certificates (used for service-to-service communication) @@ -76,7 +76,7 @@ The web UI is a React app (`apps/fabro-web`) that connects to the API server. St ```bash fabro server start # API on port 3000 -cd apps/fabro-web && bun run dev # Web UI on port 5173 +cd apps/fabro-web && bun run dev # rebuilds web assets on change; refresh the browser ``` The UI provides: diff --git a/docs/reference/cli.mdx b/docs/reference/cli.mdx index aed773d69..60b5e3025 100644 --- a/docs/reference/cli.mdx +++ b/docs/reference/cli.mdx @@ -9,31 +9,46 @@ These flags apply to all subcommands: | Flag | Description | |---|---| +| `--json` | Output machine-readable JSON when the command supports it | | `--debug` | Enable DEBUG-level logging (default is INFO) | | `--no-upgrade-check` | Skip the automatic background upgrade check | -| `--storage-dir ` | Storage directory for local run data (default: `~/.fabro`). Implies standalone mode. | -| `--server-url ` | Fabro API server URL (overrides `server.base_url` from `user.toml`). Implies server mode. | +| `--quiet` | Suppress non-essential output | +| `--verbose` | Enable verbose output | | `-h, --help` | Print help | | `-V, --version` | Print version | -Fabro loads environment variables from `~/.fabro/.env`. +Connection-target flags like `--storage-dir` and `--server` are command-specific, not global. Fabro no longer auto-loads `~/.fabro/.env`; persist server-owned credentials with `fabro provider login` / `fabro secret set`, or provide environment variables in the invoking shell. ## Configuration -CLI defaults can be set in `~/.fabro/user.toml` so you don't have to pass common flags every time: +CLI defaults can be set in `~/.fabro/settings.toml` so you don't have to pass common flags every time: -```toml title="user.toml" -[exec] +```toml title="settings.toml" +_version = 1 + +[cli.exec.model] provider = "anthropic" -model = "claude-opus-4-6" -permissions = "read-write" -output_format = "text" +name = "claude-opus-4-6" -[llm] -model = "claude-sonnet-4-5" +[cli.exec.agent] +permissions = "read-write" + +[cli.output] +format = "text" + +[run.model] +name = "claude-sonnet-4-5" + +[cli.target] +type = "http" +url = "https://fabro.example.com:3000/api/v1" ``` -CLI flags always override `user.toml` values, which override hardcoded defaults. +`[cli.exec]` config applies to `fabro exec`. `[run.model]` sets the default workflow model/provider for commands like `fabro run` and `fabro preflight`. `[cli.target]` stores connection info for commands that can target a remote Fabro server. + +`fabro model` uses `[cli.target]` by default when no explicit `--storage-dir` is passed. `fabro exec` remains a local session unless you pass `--server`, even if `[cli.target]` is configured. + +CLI flags always override `settings.toml` values, which override hardcoded defaults. --- @@ -47,7 +62,7 @@ fabro settings demo fabro settings run.toml ``` -With no argument, Fabro prints the merged ambient defaults from `~/.fabro/user.toml` and the nearest `fabro.toml`. +With no argument, Fabro prints the merged ambient defaults from `~/.fabro/settings.toml` and the nearest `.fabro/project.toml`. When you pass a workflow name or path: @@ -71,7 +86,7 @@ fabro run run.toml | Argument / Flag | Description | |---|---| -| `` | Path to a `.fabro` workflow file, `.toml` task config, or workflow name (resolved from `fabro/workflows/` in the project, then `~/.fabro/workflows/`). | +| `` | Path to a `.fabro` workflow file, `.toml` task config, or workflow name (resolved from `.fabro/workflows/` in the project, then `~/.fabro/workflows/`). | | `--dry-run` | Execute with a simulated LLM backend | | `--auto-approve` | Auto-approve all human gates | | `--model ` | Override default LLM model | @@ -79,7 +94,7 @@ fabro run run.toml | `-v, --verbose` | Enable verbose output | | `--sandbox ` | Sandbox for agent tools: `local`, `docker`, or `daytona` | | `--label ` | Attach a label to this run (repeatable) | -| `--goal ` | Override the workflow goal (exposed as `$goal` in prompts) | +| `--goal ` | Override the workflow goal (available as `{{ goal }}` in prompts) | | `--goal-file ` | Read the goal from a file instead of inline text | | `--no-retro` | Skip retro generation after the run | | `--preserve-sandbox` | Keep the sandbox alive after the run finishes (for debugging) | @@ -97,7 +112,7 @@ fabro preflight run.toml | Argument / Flag | Description | |---|---| | `` | Path to a `.fabro` workflow file, `.toml` task config, or workflow name. | -| `--goal ` | Override the workflow goal (exposed as `$goal` in prompts) | +| `--goal ` | Override the workflow goal (available as `{{ goal }}` in prompts) | | `--goal-file ` | Read the goal from a file instead of inline text | | `--model ` | Override default LLM model | | `--provider ` | Override default LLM provider | @@ -190,7 +205,7 @@ The table shows run ID, status, workflow name, goal, and timing. | `--before ` | Only show runs started before this date (YYYY-MM-DD prefix match) | | `--workflow ` | Filter by workflow name (substring match) | | `--label ` | Filter by label (repeatable, AND semantics) | -| `--orphans` | Include orphan directories (no `run.json`) | +| `--orphans` | Include orphan directories (no matching durable run) | | `--json` | Output as JSON | | `-q, --quiet` | Only display full run IDs, one per line (no headers or footers). Takes precedence over `--json`. | @@ -209,22 +224,45 @@ fabro rm my-workflow --force | `...` | Run IDs or workflow names to remove (required, repeatable) | | `-f, --force` | Force removal of active runs | +## `fabro system info` + +Show server runtime information including version, uptime, and run counts. + +```bash +fabro system info +fabro system info --json +``` + +## `fabro system events` + +Stream run events from the server in real time. + +```bash +fabro system events +fabro system events --run-id abc123 +``` + +| Flag | Description | +|---|---| +| `--run-id ` | Filter by run ID (repeatable) | + ## `fabro system prune` Delete old workflow runs. Dry-run by default — pass `--yes` to actually delete. ```bash fabro system prune --before 2026-01-01 -fabro system prune --before 2026-01-01 --yes +fabro system prune --older-than 7d --yes fabro system prune --orphans --yes ``` | Flag | Description | |---|---| | `--before ` | Only prune runs started before this date (YYYY-MM-DD prefix match) | +| `--older-than ` | Only prune runs older than this duration (e.g. `24h`, `7d`). Default when no explicit filters are set: `24h` | | `--workflow ` | Filter by workflow name (substring match) | | `--label ` | Filter by label (repeatable, AND semantics) | -| `--orphans` | Include orphan directories (no `run.json`) | +| `--orphans` | Include orphan directories (no matching durable run) | | `--yes` | Actually delete (default is dry-run) | --- @@ -255,44 +293,6 @@ Permission levels control which tools are auto-approved: `read-only` allows read --- -## `fabro llm prompt` - -Send a one-shot prompt to an LLM. Accepts a prompt as an argument, via stdin, or both (stdin is prepended). - -```bash -fabro llm prompt "Explain quicksort in one paragraph" -echo "Summarize this:" | fabro llm prompt -fabro llm prompt "Translate to French" -m claude-sonnet-4-5 -o temperature=0.3 -fabro llm prompt -S '{"type":"object","properties":{"name":{"type":"string"}}}' "Extract the name from: John Smith" -``` - -| Argument / Flag | Description | -|---|---| -| `[PROMPT]` | The prompt text (also accepts stdin) | -| `-m, --model ` | Model to use | -| `-s, --system ` | System prompt | -| `--no-stream` | Do not stream output | -| `-u, --usage` | Show token usage | -| `-S, --schema ` | JSON schema for structured output (inline JSON string) | -| `-o, --option ` | Generation options: `temperature`, `max_tokens`, `top_p`, or provider-specific keys | - -## `fabro llm chat` - -Start an interactive multi-turn chat session. In server mode, the session is backed by the Fabro server's session endpoints. - -```bash -fabro llm chat -fabro llm chat -m claude-opus-4-6 -s "You are a helpful coding assistant" -fabro llm chat --server-url http://localhost:3000/api/v1 -``` - -| Flag | Description | -|---|---| -| `-m, --model ` | Model to use | -| `-s, --system ` | System prompt | - ---- - ## `fabro model list` List available LLM models from the built-in catalog. Running `fabro model` with no subcommand also lists models. @@ -301,7 +301,7 @@ List available LLM models from the built-in catalog. Running `fabro model` with fabro model list fabro model list -p anthropic fabro model list -q sonnet -fabro model list --server-url http://localhost:3000/api/v1 +fabro model list --server http://localhost:3000/api/v1 ``` | Flag | Description | @@ -329,28 +329,59 @@ fabro model test -m claude-sonnet-4-5 ## `fabro server start` -Start the HTTP API server that exposes the [REST API](/api-reference) for launching and managing workflow runs. +Start the Fabro server daemon. By default, the server launches as a background process listening on a Unix socket. Use `--foreground` for the previous blocking behavior. ```bash -fabro server start -fabro server start --port 8080 --host 0.0.0.0 +fabro server start # background daemon on Unix socket +fabro server start --bind 127.0.0.1 # TCP on 32276, or random port if 32276 is busy +fabro server start --bind 127.0.0.1:8080 # TCP on a specific port +fabro server start --no-web # API and /health only +fabro server start --foreground # blocking foreground mode fabro server start --sandbox daytona --max-concurrent-runs 4 ``` | Flag | Description | Default | |---|---|---| -| `--port ` | Port to listen on | `3000` | -| `--host ` | Host address to bind to | `127.0.0.1` | +| `--bind ` | Address to bind: `IP` or `IP:port` for TCP, or a path for Unix socket | `~/.fabro/fabro.sock` | +| `--web` | Enable the embedded web UI, browser auth routes, and web-only helper endpoints | Enabled | +| `--no-web` | Disable the embedded web UI, browser auth routes, and web-only helper endpoints | Disabled | +| `--foreground` | Run in the foreground instead of daemonizing | — | | `--model ` | Override default LLM model | — | | `--provider ` | Override default LLM provider | — | | `--dry-run` | Execute with simulated LLM backend | — | | `--sandbox ` | Sandbox for agent tools: `local`, `docker`, or `daytona` | — | | `--max-concurrent-runs ` | Maximum number of concurrent run executions | — | -| `--config ` | Path to server config file | `~/.fabro/server.toml` | +| `--config ` | Path to server config file | `~/.fabro/settings.toml` | Demo mode is per-request: send the `X-Fabro-Demo: 1` header to get static demo data with auth disabled. -If no LLM provider API keys are configured, the server automatically falls back to dry-run mode. +When `--no-web` is set, the server still exposes the machine API under `/api/v1` and `/health`, but it returns `404` for `/`, `/auth/*`, SPA client routes, and the web-only helper endpoints under `/api/v1`. + +## `fabro server stop` + +Stop the running server daemon. Sends SIGTERM and waits for graceful shutdown, escalating to SIGKILL after the timeout. + +```bash +fabro server stop +fabro server stop --timeout 30 +``` + +| Flag | Description | Default | +|---|---|---| +| `--timeout ` | Seconds to wait for graceful shutdown before SIGKILL | `10` | + +## `fabro server status` + +Show whether the server daemon is running, along with PID, bind address, and uptime. + +```bash +fabro server status +fabro server status --json +``` + +| Flag | Description | +|---|---| +| `--json` | Output as JSON | --- @@ -374,7 +405,7 @@ Run IDs support prefix matching — you can use the first few characters instead ## `fabro pr` -Manage GitHub pull requests created by workflow runs. Requires a [GitHub App](/integrations/github) to be configured. +Manage GitHub pull requests created by workflow runs. Requires GitHub access to be configured via the default `gh_cli` strategy or a [GitHub App](/integrations/github). ### `fabro pr create` @@ -390,7 +421,7 @@ fabro pr create --model claude-opus-4-6 | `` | Run ID or prefix (required) | | `--model ` | LLM model for generating the PR description | -The run must have completed successfully (or with partial success) and have a `final.patch` with changes. +The run must have completed successfully (or with partial success) and have a stored diff with changes. ### `fabro pr list` @@ -475,24 +506,6 @@ fabro graph run.toml --format svg | `-o, --output ` | Output file path. Defaults to stdout. | | `-d, --direction ` | Graph direction: `lr` or `tb`. If omitted, uses the Graphviz file's own `rankdir`. | -## `fabro skill install` - -Install the built-in `fabro-create-workflow` skill for AI assistants (Claude Code, Codex). The skill teaches AI assistants Fabro's Graphviz syntax, node types, and run configuration format. - -```bash -# Install into the current project (.claude/skills/ or .agents/skills/) -fabro skill install --for project --dir claude - -# Install for all projects (user-level, ~/.claude/skills/) -fabro skill install --for user --dir claude -``` - -| Flag | Description | -|---|---| -| `--for ` | `user` (default) — installs to `~//skills/`. `project` — installs to `.//skills/`. | -| `--dir ` | Directory convention: `claude` (`.claude/skills/`) or `agents` (`.agents/skills/`). Required. | -| `--force` | Overwrite an existing installation without prompting. | - ## `fabro rewind` Rewind a workflow run to an earlier checkpoint. This resets both the run branch and metadata branch refs so that `fabro resume` continues from the target checkpoint. @@ -602,7 +615,7 @@ fabro workflow create my-workflow --goal "Run the CI pipeline" | `` | Name of the workflow (required) | | `-g, --goal ` | Goal description for the workflow | -Requires a `fabro.toml` project config in the current directory or a parent. +Requires a `.fabro/project.toml` project config in the current directory or a parent. --- @@ -635,22 +648,22 @@ fabro parse workflow.fabro ## `fabro repo init` -Initialize a new Fabro project in the current git repository. Creates a `fabro.toml` project config and a sample `hello` workflow. +Initialize a new Fabro project in the current git repository. Creates a `.fabro/project.toml` project config and a sample `hello` workflow. ```bash fabro repo init ``` The command must be run inside a git repository. It creates: -- `fabro.toml` — project configuration with comments and a link to docs -- `fabro/workflows/hello/workflow.fabro` — a simple greeting workflow -- `fabro/workflows/hello/workflow.toml` — run config for the hello workflow +- `.fabro/project.toml` — project configuration with comments and a link to docs +- `.fabro/workflows/hello/workflow.fabro` — a simple greeting workflow +- `.fabro/workflows/hello/workflow.toml` — run config for the hello workflow -After creating files, it checks whether the GitHub App is installed for the repository. If the app is not installed and the repository owner differs from the app owner, it warns that the app may need to be [made public](/integrations/github#github-app-is-private-but-this-repo-belongs-to-a-different-owner) first. +After creating files, it checks whether GitHub access is available for the repository. In GitHub App mode, if the app is not installed and the repository owner differs from the app owner, it warns that the app may need to be [made public](/integrations/github#github-app-is-private-but-this-repo-belongs-to-a-different-owner) first. ## `fabro repo deinit` -Remove Fabro from a project by deleting `fabro.toml` and the `fabro/` directory. Fails with an error if the project is not initialized. +Remove Fabro from a project by deleting the `.fabro/` project directory. Fails with an error if the project is not initialized. ```bash fabro repo deinit @@ -658,20 +671,17 @@ fabro repo deinit ## `fabro diff` -Show the diff from a workflow run. Displays the `final.patch` for completed runs, or connects to the sandbox for a live diff from in-progress runs. +Show the diff from a workflow run. Reads the stored diff for completed runs. ```bash fabro diff fabro diff --node work -fabro diff --stat ``` | Argument / Flag | Description | |---|---| | `` | Run ID or prefix (required) | | `--node ` | Show diff for a specific node instead of the full run | -| `--stat` | Show diffstat instead of full patch (live diffs only) | -| `--shortstat` | Show only files-changed/insertions/deletions summary (live diffs only) | Output is colorized when writing to a terminal. @@ -717,18 +727,17 @@ Without `--signed`, the command prints the URL, token, and a `curl` example. See ## `fabro doctor` -Check environment and integration health. Verifies system dependencies, API keys, and optional services. Probes live services (LLM providers, sandbox, GitHub App) by default. +Check environment and integration health. `fabro doctor` always performs live server-backed diagnostics and keeps only local user-config and legacy `.env` checks on the CLI side. ```bash fabro doctor fabro doctor -v -fabro doctor --dry-run +fabro doctor --server https://fabro.example.com:3000/api/v1 ``` | Flag | Description | |---|---| | `-v, --verbose` | Show detailed information for each check | -| `--dry-run` | Skip live service probes (LLM, sandbox, API, web, Brave Search) | ## `fabro upgrade` @@ -746,16 +755,16 @@ fabro upgrade --version 0.6.0 | `--force` | Upgrade even if already on the target version | | `--dry-run` | Preview what would happen without making changes | -Fabro refuses to downgrade unless you specify an explicit `--version`. A daily background check notifies you when a new version is available — disable it with `upgrade_check = false` in [`user.toml`](/reference/user-configuration#upgrade_check) or the `--no-upgrade-check` global flag. +Fabro refuses to downgrade unless you specify an explicit `--version`. A daily background check notifies you when a new version is available — disable it with `upgrade_check = false` in [`settings.toml`](/reference/user-configuration#upgrade_check) or the `--no-upgrade-check` global flag. -## `fabro asset list` +## `fabro artifact list` -List assets (screenshots, test reports, traces) collected from a workflow run. +List artifacts (screenshots, test reports, traces) collected from a workflow run. ```bash -fabro asset list -fabro asset list --node verify --json -fabro asset list --node verify --retry 2 +fabro artifact list +fabro artifact list --node verify --json +fabro artifact list --node verify --retry 2 ``` | Argument / Flag | Description | @@ -765,15 +774,15 @@ fabro asset list --node verify --retry 2 | `--retry ` | Filter to assets from a specific retry attempt | | `--json` | Output as JSON | -## `fabro asset cp` +## `fabro artifact cp` -Copy assets from a workflow run to the local filesystem. +Copy artifacts from a workflow run to the local filesystem. ```bash -fabro asset cp ./output # all assets, flat -fabro asset cp ./output --tree # preserve directory structure -fabro asset cp :report.html ./output # specific file -fabro asset cp :report.html ./output --node verify --retry 2 +fabro artifact cp ./output # all assets, flat +fabro artifact cp ./output --tree # preserve directory structure +fabro artifact cp :report.html ./output # specific file +fabro artifact cp :report.html ./output --node verify --retry 2 ``` | Argument / Flag | Description | @@ -799,7 +808,7 @@ fabro provider login --provider anthropic |---|---| | `--provider ` | LLM provider to authenticate with (required) | -For OpenAI, this launches a browser-based OAuth PKCE flow with an automatic fallback to manual API key entry. All other providers prompt for an API key with validation. Credentials are merged non-destructively into `~/.fabro/.env`. +For OpenAI, this launches a browser-based OAuth PKCE flow with an automatic fallback to manual API key entry. All other providers prompt for an API key with validation. Credentials are saved to the connected Fabro server's secret store. ## `fabro install` @@ -812,13 +821,13 @@ fabro install --web-url https://fabro.example.com | Flag | Description | Default | |---|---|---| -| `--web-url ` | Web UI base URL for OAuth callback endpoints | `http://localhost:5173` | +| `--web-url ` | Web UI base URL for OAuth callback endpoints | `http://localhost:3000` | --- ## `fabro secret set` -Store a secret in `~/.fabro/.env`. +Store or update a server-owned secret on the connected Fabro server. ```bash fabro secret set ANTHROPIC_API_KEY sk-ant-... @@ -829,34 +838,17 @@ fabro secret set ANTHROPIC_API_KEY sk-ant-... | `` | Name of the secret (required) | | `` | Value to store (required) | -## `fabro secret get` - -Print the value of a secret from `~/.fabro/.env`. - -```bash -fabro secret get ANTHROPIC_API_KEY -``` - -| Argument | Description | -|---|---| -| `` | Name of the secret (required) | - ## `fabro secret list` -List secret names stored in `~/.fabro/.env`. +List server-owned secret names. Values are never returned after storage. ```bash fabro secret list -fabro secret list --show-values ``` -| Flag | Description | -|---|---| -| `--show-values` | Print values alongside keys | - ## `fabro secret rm` -Remove a secret from `~/.fabro/.env`. +Remove a server-owned secret from the connected Fabro server. ```bash fabro secret rm ANTHROPIC_API_KEY diff --git a/docs/reference/dot-language.mdx b/docs/reference/dot-language.mdx index bfdcfdfdf..62d329ca6 100644 --- a/docs/reference/dot-language.mdx +++ b/docs/reference/dot-language.mdx @@ -377,7 +377,7 @@ digraph ImplementFeature { exit [shape=Msquare, label="Exit"] // Planning phase - plan [label="Plan", shape=tab, prompt="Create a detailed implementation plan for: $goal"] + plan [label="Plan", shape=tab, prompt="Create a detailed implementation plan for: {{ goal }}"] // Human approval approve [shape=hexagon, label="Approve Plan"] diff --git a/docs/reference/run-directory.mdx b/docs/reference/run-directory.mdx index dcbd6ac72..6bab5df6a 100644 --- a/docs/reference/run-directory.mdx +++ b/docs/reference/run-directory.mdx @@ -4,81 +4,45 @@ description: "Structure of Fabro's per-run directory" --- - The run directory structure and file formats described here are internal implementation details and subject to change without notice. Do not build tooling that relies on them. + The run directory structure and file formats described here are internal implementation details and subject to change without notice. Do not build tooling that relies on them. The authoritative source of run state is the event-sourced run store; the scratch directory is now mostly local runtime state and caches. -Each `fabro run` invocation creates a timestamped directory under `~/.fabro/runs/`: +Each `fabro run` invocation creates a timestamped directory under `~/.fabro/storage/scratch/`: ``` -~/.fabro/runs/20260307-01JQXYZ123ABC456DEF789/ +~/.fabro/storage/scratch/20260307-01JQXYZ123ABC456DEF789/ ``` -The naming format is `YYYYMMDD-{run_id}`, where `run_id` is the ULID assigned to the run. You can override the base storage directory with the global `--storage-dir` flag (the runs directory will be `/runs/`). +The naming format is `YYYYMMDD-{run_id}`, where `run_id` is the ULID assigned to the run. You can override the base storage directory with the global `--storage-dir` flag (the scratch directory will be `/scratch/`). ## Root-level files | File | Format | When written | Description | |---|---|---|---| -| `run.json` | JSON | Run create | Run metadata — `run_id`, `created_at`, `config` (resolved configuration), `graph` (Graph), `workflow_slug`, `working_directory`, `host_repo_path`, `base_branch`, `labels` | -| `start.json` | JSON | Run start | Start metadata — `run_id`, `start_time`, `run_branch`, `base_sha` | -| `workflow.fabro` | Graphviz | Run create | Copy of the original workflow graph when the raw DOT source is available | +| `workflow_bundle.json` | JSON | Run create | Bundled workflow input used to restart the run without re-reading the original workflow files. Includes the root workflow path plus bundled child workflow sources and inline files. | | `run.pid` | Text | Legacy only | Legacy process ID file from older runs. Current detached launches use launcher records instead, and current attach/resume no longer read `run.pid`. | -| `workflow.toml` | TOML | Run create | Copy of the original workflow file (only when the workflow is defined in TOML) | -| `progress.jsonl` | JSONL | Continuous | Event stream — one JSON object per line for every significant event (stage starts, completions, tool calls, retries, etc.). See [Observability](/execution/observability) for the full event catalog. | -| `live.json` | JSON | Continuous | Current execution state snapshot, overwritten on each event. Used for live monitoring. | -| `checkpoint.json` | JSON | After each node | Crash recovery state — `current_node`, `completed_nodes`, `node_retries`, `context_values`, `node_outcomes`, `next_node_id`, `git_commit_sha`, failure signatures. See [Checkpoints](/execution/checkpoints). | -| `conclusion.json` | JSON | Run end | Final result — `status`, `duration_ms`, `failure_reason`, `final_git_commit_sha`. Only present when the run completes (not for crashed or interrupted runs). | -| `final.patch` | Diff | Run end | Git diff from `base_sha` to final HEAD. Only present in git checkpoint mode. | -| `retro.json` | JSON | Run end | Post-run retrospective analysis — `smoothness_rating`, `learnings`, `friction_points`, `stages`. Omitted if `--no-retro` is passed. See [Retros](/execution/retros). | -| `cli.log` | Text | Continuous | Per-run tracing log. Contains the same tracing output as the daily log file, scoped to this run. | -## `nodes/` subdirectory +## Local-only directories -Each node execution writes artifacts into `nodes/{node_id}/`. When a node is retried, subsequent visits use `nodes/{node_id}-visit_{N}/` (where N starts at 2). +These paths are local runtime state and caches, not the canonical run record. -Every node gets a `status.json` after completion containing `status`, `notes`, `failure_reason`, and `timestamp`. The remaining files depend on the handler type: +- **`worktree/`** — When running in worktree mode, Fabro creates a Git worktree here as the working directory for agents and commands. +- **`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. -**Agent and prompt nodes:** +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. -| File | Description | -|---|---| -| `prompt.md` | The full prompt sent to the LLM | -| `response.md` | The LLM's response text | -| `status.json` | Execution status with routing outcome | +## Reconstructed and export-only layouts -**Command nodes:** +Some file names you may have seen in older runs or older docs still exist in reconstructed metadata branches or `fabro store dump` exports: -| File | Description | -|---|---| -| `script_invocation.json` | Command metadata — `command`, `language`, `timeout_ms` | -| `stdout.log` | Standard output | -| `stderr.log` | Standard error | -| `script_timing.json` | Timing info — `duration_ms`, `exit_code`, `timed_out` | - -**Nodes with git checkpointing:** - -| File | Description | -|---|---| -| `diff.patch` | Git diff of changes made during this stage | - -**Manager loop nodes:** - -Manager nodes that run sub-workflows write a nested `child/` directory containing a full run structure (run.json, start.json, checkpoint, nodes, etc.). - -## Other directories - -**`worktree/`** — When running in git checkpoint mode, Fabro creates a Git worktree here as the working directory for agents and commands. - -**`runtime/`** — Local-only runtime files, including interview IPC files used by detached runs and `fabro attach`. - -**`cache/`** — Local filesystem cache for file-backed artifacts and captured test assets: - -- `cache/artifacts/values/` — large context values offloaded from checkpoints -- `cache/artifacts/assets/` — captured test artifacts organized by node and retry +- `run.json`, `start.json`, and `checkpoint.json` on metadata branches for rewind and fork +- `run.json`, `start.json`, `checkpoint.json`, `conclusion.json`, `retro.json`, and `events.jsonl` in `fabro store dump` output +- Per-node prompt, response, status, stdout, and stderr files in `fabro store dump` output and metadata rebuilds ## Browsing runs -Use `fabro ps` to scan the runs directory and display a table of all runs with their status, workflow name, and timestamps. Pass `--json` for machine-readable output. +Use `fabro ps` to scan the scratch directory and display a table of all runs with their status, workflow name, and timestamps. Pass `--json` for machine-readable output. ```bash fabro ps @@ -89,62 +53,26 @@ fabro ps --filter workflow=my-workflow ## Full directory tree ``` -~/.fabro/runs/ +~/.fabro/storage/scratch/ ├── 20260307-01JQXYZ123ABC456DEF789/ # One directory per run -│ ├── run.json -│ ├── start.json -│ ├── workflow.fabro +│ ├── workflow_bundle.json │ ├── run.pid # Legacy only; older runs may contain this -│ ├── workflow.toml -│ ├── progress.jsonl -│ ├── live.json -│ ├── checkpoint.json -│ ├── conclusion.json -│ ├── final.patch -│ ├── retro.json -│ ├── cli.log │ ├── runtime/ -│ │ ├── interview_request.json -│ │ ├── interview_response.json -│ │ └── interview_request.claim +│ │ └── blobs/ +│ │ └── 01JT5Y3KJ0N5S9E1Y7YFBR2G4D.json │ ├── cache/ │ │ └── artifacts/ -│ │ ├── values/ -│ │ │ ├── response.plan.json -│ │ │ └── command.output.json -│ │ └── assets/ +│ │ └── files/ │ │ └── test/ │ │ └── retry_1/ │ │ ├── test-results/ │ │ │ └── screenshot.png -│ │ └── manifest.json │ ├── nodes/ -│ │ ├── plan/ -│ │ │ ├── prompt.md -│ │ │ ├── response.md -│ │ │ └── status.json -│ │ ├── work/ -│ │ │ ├── prompt.md -│ │ │ ├── response.md -│ │ │ ├── status.json -│ │ │ └── diff.patch -│ │ ├── work-visit_2/ # Retry of "work" node -│ │ │ ├── prompt.md -│ │ │ ├── response.md -│ │ │ ├── status.json -│ │ │ └── diff.patch -│ │ ├── test/ -│ │ │ ├── script_invocation.json -│ │ │ ├── stdout.log -│ │ │ ├── stderr.log -│ │ │ ├── script_timing.json -│ │ │ └── status.json │ │ └── manager/ -│ │ ├── status.json │ │ └── child/ -│ │ ├── run.json -│ │ ├── start.json -│ │ ├── checkpoint.json -│ │ └── nodes/ +│ │ ├── workflow_bundle.json +│ │ ├── runtime/ +│ │ ├── cache/ +│ │ └── worktree/ │ └── worktree/ # Git worktree (git checkpoint mode) ``` diff --git a/docs/reference/sdk.mdx b/docs/reference/sdk.mdx index 76d8bc302..e44b7a9fe 100644 --- a/docs/reference/sdk.mdx +++ b/docs/reference/sdk.mdx @@ -79,7 +79,7 @@ pub fn new( | `initialize().await` | Discovers project docs, skills, and MCP servers. Call before `process_input`. | | `process_input(input).await` | Sends user input and runs the agent loop until the model stops or a limit is hit. | | `close()` | Ends the session and emits `SessionEnded`. | -| `abort()` | Cancels the current `process_input` call. | +| `interrupt()` | Cancels the current `process_input` call. | | `cancel_token()` | Returns a `CancellationToken` for external cancellation. | **Inspection:** @@ -111,7 +111,7 @@ All fields are public. Key settings with their defaults: | `enable_context_compaction` | `true` | Automatically summarize old turns when approaching the context window limit. | | `compaction_threshold_percent` | `80` | Context window usage percentage that triggers compaction. | | `max_subagent_depth` | `1` | Maximum nesting depth for sub-agents. | -| `wall_clock_timeout` | `None` | Hard timeout for `process_input`. Triggers `AbortReason::WallClockTimeout`. | +| `wall_clock_timeout` | `None` | Hard timeout for `process_input`. Triggers `InterruptReason::WallClockTimeout`. | | `tool_hooks` | `None` | Pre/post hooks around tool execution (see [Tool hooks](#tool-hooks)). | | `mcp_servers` | `[]` | MCP server configurations to connect on startup. | | `skill_dirs` | `None` | Directories to discover `SKILL.md` files. `None` uses convention defaults. | @@ -287,7 +287,7 @@ All fallible `Session` methods return `Result`: | `SessionClosed` | `process_input` was called on a closed session. | | `InvalidState(String)` | The session is in an unexpected state. | | `ToolExecution(String)` | A tool execution failed. | -| `Aborted(AbortReason)` | The session was cancelled (`Cancelled`) or timed out (`WallClockTimeout`). | +| `Interrupted(InterruptReason)` | The session was cancelled (`Cancelled`) or timed out (`WallClockTimeout`). | --- @@ -757,7 +757,7 @@ match result { } Err(SdkError::RequestTimeout { message, .. }) => println!("Timeout: {message}"), Err(SdkError::Network { message, .. }) => println!("Network: {message}"), - Err(SdkError::Abort { message }) => println!("Cancelled: {message}"), + Err(SdkError::Interrupt { message }) => println!("Cancelled: {message}"), Err(e) => println!("Other: {e}"), Ok(_) => {} } @@ -817,7 +817,7 @@ Retry only fires when `error.retryable()` returns `true` and respects `Retry-Aft ### Cancellation -Pass a `CancellationToken` to abort long-running generation: +Pass a `CancellationToken` to interrupt long-running generation: ```rust use tokio_util::sync::CancellationToken; @@ -836,7 +836,7 @@ let result = generate( .prompt("Write a novel") .abort_signal(token) ).await; -// Returns SdkError::Abort if cancelled +// Returns SdkError::Interrupt if cancelled ``` ### Provider adapters diff --git a/docs/reference/user-configuration.mdx b/docs/reference/user-configuration.mdx index bd27dc48f..56c4f913d 100644 --- a/docs/reference/user-configuration.mdx +++ b/docs/reference/user-configuration.mdx @@ -1,107 +1,183 @@ --- -title: "User Configuration" -description: "Configure default user settings for Fabro with user.toml" +title: "Settings Configuration" +description: "Configure CLI and shared machine defaults with settings.toml" --- -Fabro loads user defaults from `~/.fabro/user.toml` so you don't have to pass common flags every time. The file is optional — if it doesn't exist, built-in defaults are used. +Fabro loads machine defaults from `~/.fabro/settings.toml`. The file is optional. If it does not exist, Fabro falls back to built-in defaults. + +On a same-machine setup, the CLI and server both read this file. On a remote setup, each machine has its own `settings.toml` and reads the sections relevant to that process. + + +Legacy `cli.toml`, `user.toml`, and `server.toml` are ignored with a warning. Rename them to `settings.toml`. + ## File location -The default path is `~/.fabro/user.toml`. Fabro silently skips loading if the file is missing. +The default path is `~/.fabro/settings.toml`. + +Use `fabro server start --config /path/to/settings.toml` if the server should read a different file. + +## Schema version + +Every Fabro config file must declare its schema version with a top-level `_version` key: + +```toml title="settings.toml" +_version = 1 +``` + +Files that omit `_version` are treated as version `1`. The legacy top-level `version` key is no longer accepted and raises a targeted rename hint. + +## Who reads what + +`settings.toml` uses the same schema as `.fabro/project.toml` and `workflow.toml`, but each process only reads the fields it understands. The top-level schema is strictly namespaced — the only allowed domains are `[project]`, `[workflow]`, `[run]`, `[cli]`, `[server]`, and `[features]`. + +| Scope | Examples | +|---|---| +| CLI-only | `[cli.target]`, `[cli.auth]`, `[cli.exec]`, `[cli.output]`, `[cli.updates]`, `[cli.logging]` | +| Shared run defaults | `[run.model]`, `[run.sandbox]`, `[run.checkpoint]`, `[run.inputs]`, `[run.prepare]`, `[run.pull_request]`, `[run.hooks]`, `[run.agent.mcps]` | +| Server-only | `[server.listen]`, `[server.api]`, `[server.web]`, `[server.auth]`, `[server.storage]`, `[server.artifacts]`, `[server.slatedb]`, `[server.scheduler]`, `[server.logging]`, `[server.integrations]` | + +`[cli.*]` and `[server.*]` stanzas are owner-specific: they are only consumed from `~/.fabro/settings.toml` (plus process-local flags and env overrides). The same stanzas in `.fabro/project.toml` or `workflow.toml` remain schema-valid but runtime-inert. + +See [Server Configuration](/administration/server-configuration) for the server-owned sections. ## Precedence -CLI flags always take the highest priority: +Shared layered domains (`[project]`, `[workflow]`, `[run]`, `[features]`) use this override order: 1. **CLI flags** — always win -2. **`user.toml`** — used when no flag is provided -3. **Built-in defaults** — used when neither flag nor config is set +2. **Environment overrides** — Fabro-defined override channels +3. **`workflow.toml`** — per-workflow overrides +4. **`.fabro/project.toml`** — project defaults +5. **`~/.fabro/settings.toml`** — machine defaults +6. **Built-in defaults** + +Owner-specific domains (`[cli.*]`, `[server.*]`) use a narrower trust boundary — only CLI flags, env overrides, `~/.fabro/settings.toml`, and built-in defaults apply. ## Full example -```toml title="user.toml" -verbose = true -upgrade_check = true -mode = "server" +```toml title="settings.toml" +_version = 1 -[server] -base_url = "https://fabro.example.com:3000/api/v1" +[cli.target] +type = "http" +url = "https://fabro.example.com:3000/api/v1" -[server.tls] +[cli.target.tls] cert = "~/.fabro/tls/client.crt" key = "~/.fabro/tls/client.key" ca = "~/.fabro/tls/ca.crt" -[exec] +[cli.exec] +prevent_idle_sleep = true + +[cli.exec.model] provider = "anthropic" -model = "claude-opus-4-6" +name = "claude-opus-4-6" + +[cli.exec.agent] permissions = "read-write" -output_format = "text" -[llm] -model = "claude-sonnet-4-5" +[cli.output] +format = "text" +verbosity = "normal" -[log] +[cli.updates] +check = true + +[cli.logging] level = "info" -[git.author] +[run.model] +name = "claude-sonnet-4-5" + +[run.git.author] name = "fabro-bot" email = "fabro-bot@company.com" -[pull_request] +[run.pull_request] enabled = true -[mcp_servers.filesystem] +[run.agent.mcps.filesystem] type = "stdio" command = ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/workspace"] -startup_timeout_secs = 15 -tool_timeout_secs = 90 +startup_timeout = "15s" +tool_timeout = "90s" -[mcp_servers.filesystem.env] +[run.agent.mcps.filesystem.env] NODE_ENV = "production" -[mcp_servers.sentry] +[run.agent.mcps.sentry] type = "http" url = "https://mcp.sentry.dev/mcp" -[mcp_servers.sentry.headers] +[run.agent.mcps.sentry.headers] Authorization = "Bearer sk-xxx" ``` -All fields are optional. You can include just the sections and keys you want to override. +All fields are optional. Include only the sections and keys you want to override. On a same-machine install, this same file can also include server sections such as `[server.web]` and `[server.api]`. -## `upgrade_check` +## `[cli.updates]` Controls whether Fabro runs a daily background check for new releases. The check runs during `run`, `exec`, `init`, and `install` commands and prints a notice to stderr when a newer version is available. -| Value | Description | -|---|---| -| `true` | Check for new releases (default) | -| `false` | Disable automatic upgrade checks | +```toml title="settings.toml" +[cli.updates] +check = true +``` + +| Key | Value | Description | +|---|---|---| +| `check` | `true` | Check for new releases (default) | +| `check` | `false` | Disable automatic upgrade checks | The `--no-upgrade-check` CLI flag overrides this for a single invocation. See [`fabro upgrade`](/reference/cli#fabro-upgrade) for manual upgrades. -## `verbose` +## `[cli.output]` -Enable verbose output by default for `fabro run start` and `fabro doctor`, without passing `-v` every time. +Generic CLI output defaults. -| Value | Description | -|---|---| -| `true` | Verbose output on by default | -| `false` | Normal output (default) | +```toml title="settings.toml" +[cli.output] +format = "text" +verbosity = "verbose" +``` + +| Key | Values | Default | +|---|---|---| +| `format` | `"text"`, `"json"` | `"text"` | +| `verbosity` | `"quiet"`, `"normal"`, `"verbose"` | `"normal"` | The `-v` / `--verbose` CLI flag always takes effect regardless of this setting. -## `[exec]` section +## `[cli.exec]` section Defaults for `fabro exec` sessions. +```toml title="settings.toml" +[cli.exec] +prevent_idle_sleep = true + +[cli.exec.model] +provider = "anthropic" +name = "claude-opus-4-6" + +[cli.exec.agent] +permissions = "read-write" +``` + +`[cli.exec.model]` selects the default LLM for exec: + +| Key | Description | Values | +|---|---|---| +| `provider` | LLM provider | `"anthropic"`, `"openai"`, `"gemini"`, etc. | +| `name` | Model name | Any model ID from `fabro model list` | + +`[cli.exec.agent]` controls agent behavior during exec: + | Key | Description | Values | Default | |---|---|---|---| -| `provider` | LLM provider | `"anthropic"`, `"openai"`, `"gemini"`, etc. | `"anthropic"` | -| `model` | Model name | Any model ID from `fabro model list` | Per provider | | `permissions` | Tool permission level | `"read-only"`, `"read-write"`, `"full"` | `"read-write"` | -| `output_format` | Output format | `"text"`, `"json"` | `"text"` | ### Permission levels @@ -111,70 +187,91 @@ Defaults for `fabro exec` sessions. Tools outside the permission level are interactively prompted (if a TTY is present) or denied (with `--auto-approve`). -### Output formats +## `[run.model]` section -- **`text`** — human-readable terminal output -- **`json`** — NDJSON event stream +Defaults for workflow model selection in commands like `fabro run` and `fabro preflight`. -## `[llm]` section - -Defaults for `fabro llm prompt` and `fabro llm chat`. +```toml title="settings.toml" +[run.model] +provider = "anthropic" +name = "claude-sonnet-4-5" +fallbacks = ["openai", "gpt-5.4", "gemini/gemini-flash"] +``` | Key | Description | Values | Default | |---|---|---|---| -| `model` | Model name | Any model ID from `fabro model list` | Per provider | +| `name` | Model name | Any model ID from `fabro model list` | Per provider | +| `provider` | Provider name | `"anthropic"`, `"openai"`, `"gemini"`, etc. | Auto-inferred from model/catalog | +| `fallbacks` | Ordered list of fallback model references | bare provider, bare alias, or `provider/model` | `[]` | -The `[llm]` section only sets the default model. Use `[exec]` to configure provider, permissions, and output format for `fabro exec`. +Use `[cli.exec.model]` to configure provider and model for `fabro exec`. Use `[run.model]` for workflow-oriented defaults. -## `[log]` section +## `[cli.logging]` section -Configure the default log level. Precedence: `FABRO_LOG` env var > `--debug` flag > `[log]` level > `"info"`. +Configure the default CLI log level. Precedence: `FABRO_LOG` env var > `--debug` flag > `[cli.logging].level` > `"info"`. -| Key | Description | Values | Default | -|---|---|---|---| -| `level` | Log level | `"error"`, `"warn"`, `"info"`, `"debug"`, `"trace"` | `"info"` | +```toml title="settings.toml" +[cli.logging] +level = "info" +``` -## `[git]` section +| Key | Values | Default | +|---|---|---| +| `level` | `"error"`, `"warn"`, `"info"`, `"debug"`, `"trace"` | `"info"` | -### `[git.author]` +Server-side logging is a separate namespace at `[server.logging]`. -Customize the git author identity used for checkpoint commits. Overrides the server default when set. +## `[run.git.author]` + +Customize the git author identity used for checkpoint commits. + +```toml title="settings.toml" +[run.git.author] +name = "fabro-bot" +email = "fabro-bot@company.com" +``` | Key | Description | Default | |---|---|---| | `name` | Git author name | `"fabro"` | | `email` | Git author email | `"fabro@local"` | -## `mode` +## `[cli.target]` section -Controls the default execution mode when neither `--storage-dir` nor `--server-url` is passed. +Connection info for commands that target a remote Fabro server. -| Value | Description | -|---|---| -| `"standalone"` | Execute locally (default) | -| `"server"` | Delegate to an Fabro API server | - -For a single invocation, `--storage-dir` implies standalone mode and `--server-url` implies server mode. - -## `[server]` section - -Configuration for server mode. - -| Key | Description | Default | -|---|---|---| -| `base_url` | Server URL | `"http://localhost:3000/api/v1"` | - -Passing `--server-url` implies server mode and overrides `server.base_url`: - -```bash -fabro --server-url https://fabro.example.com:3000/api/v1 model list +```toml title="settings.toml" +[cli.target] +type = "http" +url = "https://fabro.example.com:3000/api/v1" ``` -### `[server.tls]` section +| Key | Description | +|---|---| +| `type` | `"http"` or `"unix"` — explicit transport selection | +| `url` | Required for `type = "http"` — the API base URL | +| `path` | Required for `type = "unix"` — the absolute Unix socket path | -Optional mTLS configuration for authenticating with the server. When present, the CLI presents a client certificate during the TLS handshake. +`fabro model` uses `[cli.target]` by default when no explicit `--storage-dir` is passed. An explicit `--server` flag overrides the configured target: + +```bash +fabro model list --server https://fabro.example.com:3000/api/v1 +``` + +`fabro exec` does not automatically use `[cli.target]`. It only routes model traffic through a Fabro server when you pass `--server` for that invocation. + +### `[cli.target.tls]` section + +Optional mTLS configuration for authenticating with an HTTP target. When present, the CLI presents a client certificate during the TLS handshake. + +```toml title="settings.toml" +[cli.target.tls] +cert = "~/.fabro/tls/client.crt" +key = "~/.fabro/tls/client.key" +ca = "~/.fabro/tls/ca.crt" +``` | Key | Description | |---|---| @@ -182,46 +279,42 @@ Optional mTLS configuration for authenticating with the server. When present, th | `key` | Path to client private key PEM file | | `ca` | Path to CA certificate PEM file (to verify the server) | -Paths support `~/` expansion. Example: +Paths support `~/` expansion. -```toml title="user.toml" -[server.tls] -cert = "~/.fabro/tls/client.crt" -key = "~/.fabro/tls/client.key" -ca = "~/.fabro/tls/ca.crt" -``` +## `[run.pull_request]` -## `[pull_request]` +Enable auto-PR globally so workflows open a GitHub pull request on successful completion. -Enable auto-PR globally so workflows open a GitHub pull request on successful completion — even when running with a `.fabro` file instead of a `run.toml`. - -```toml title="user.toml" -[pull_request] +```toml title="settings.toml" +[run.pull_request] enabled = true ``` | Key | Description | Default | |---|---|---| | `enabled` | Automatically create a PR after successful runs | `false` | +| `draft` | Open the PR as a draft | `true` | +| `auto_merge` | Enable GitHub auto-merge on the created PR (implies `draft = false`) | `false` | +| `merge_strategy` | One of `"squash"`, `"merge"`, `"rebase"` | `"squash"` | -Precedence: `run.toml` > `fabro.toml` (project config) > `user.toml` > `server.toml` > built-in default (`false`). +Precedence: `workflow.toml` > `.fabro/project.toml` > `~/.fabro/settings.toml` > built-in default (`false`). -## `[mcp_servers]` section +## `[run.agent.mcps]` section -Configure [MCP servers](/agents/mcp) to connect to during `fabro exec` sessions. Each server is a named TOML table under `[mcp_servers]`. MCP servers can also be configured per-workflow in [run config TOML](/execution/run-configuration#mcp_servers). +Configure [MCP servers](/agents/mcp) to connect to during agent-driven runs. Each server is a named TOML table under `[run.agent.mcps]`. For `fabro exec`-only MCPs, use `[cli.exec.agent.mcps.*]` with the same shape. ### Stdio transport Spawn a local process and communicate over stdin/stdout: -```toml title="user.toml" -[mcp_servers.filesystem] +```toml title="settings.toml" +[run.agent.mcps.filesystem] type = "stdio" command = ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/workspace"] -startup_timeout_secs = 15 -tool_timeout_secs = 90 +startup_timeout = "15s" +tool_timeout = "90s" -[mcp_servers.filesystem.env] +[run.agent.mcps.filesystem.env] NODE_ENV = "production" ``` @@ -230,19 +323,19 @@ NODE_ENV = "production" | `type` | Must be `"stdio"` | — | | `command` | Array: executable + arguments | — | | `env` | Additional environment variables for the child process | `{}` | -| `startup_timeout_secs` | Max seconds for the MCP handshake | `10` | -| `tool_timeout_secs` | Max seconds for a single tool call | `60` | +| `startup_timeout` | Max duration for the MCP handshake (e.g. `"10s"`, `"30s"`) | `"10s"` | +| `tool_timeout` | Max duration for a single tool call (e.g. `"60s"`, `"2m"`) | `"60s"` | ### HTTP transport Connect to a remote MCP server over Streamable HTTP: -```toml title="user.toml" -[mcp_servers.sentry] +```toml title="settings.toml" +[run.agent.mcps.sentry] type = "http" url = "https://mcp.sentry.dev/mcp" -[mcp_servers.sentry.headers] +[run.agent.mcps.sentry.headers] Authorization = "Bearer sk-xxx" ``` @@ -250,21 +343,21 @@ Authorization = "Bearer sk-xxx" |---|---|---| | `type` | Must be `"http"` | — | | `url` | The MCP server endpoint URL | — | -| `headers` | Optional HTTP headers (e.g., for authentication) | `{}` | -| `startup_timeout_secs` | Max seconds for the MCP handshake | `10` | -| `tool_timeout_secs` | Max seconds for a single tool call | `60` | +| `headers` | Optional HTTP headers (for example, for authentication) | `{}` | +| `startup_timeout` | Max duration for the MCP handshake | `"10s"` | +| `tool_timeout` | Max duration for a single tool call | `"60s"` | ### Sandbox transport -Run an MCP server inside the workflow's sandbox and connect via preview URL. Only available with remote sandbox providers ([Daytona](/integrations/daytona)) that support port previews. Typically configured in [run config TOML](/execution/run-configuration#mcp_servers) rather than `user.toml`. +Run an MCP server inside the workflow's sandbox and connect via preview URL. Only available with remote sandbox providers ([Daytona](/integrations/daytona)) that support port previews. Typically configured in `workflow.toml` rather than `settings.toml`: -```toml title="run.toml" -[mcp_servers.playwright] +```toml title="workflow.toml" +[run.agent.mcps.playwright] type = "sandbox" command = ["npx", "@playwright/mcp@latest", "--port", "3100", "--headless"] port = 3100 -startup_timeout_secs = 60 -tool_timeout_secs = 120 +startup_timeout = "60s" +tool_timeout = "2m" ``` | Key | Description | Default | @@ -273,7 +366,7 @@ tool_timeout_secs = 120 | `command` | Array: the command to run inside the sandbox | — | | `port` | Port the server listens on inside the sandbox | — | | `env` | Additional environment variables for the server process | `{}` | -| `startup_timeout_secs` | Max seconds for startup + MCP handshake | `10` | -| `tool_timeout_secs` | Max seconds for a single tool call | `60` | +| `startup_timeout` | Max duration for startup + MCP handshake | `"10s"` | +| `tool_timeout` | Max duration for a single tool call | `"60s"` | See [MCP — Sandbox transport](/agents/mcp#sandbox) for how Fabro launches and connects to sandbox MCP servers. diff --git a/docs/workflows/variables.mdx b/docs/workflows/variables.mdx index 345375ec3..2680d5e37 100644 --- a/docs/workflows/variables.mdx +++ b/docs/workflows/variables.mdx @@ -1,76 +1,103 @@ --- title: "Variables" -description: "Using variables in workflows" +description: "Using templates in workflows" --- -Fabro supports `$variable` placeholders that let you parameterize workflows without editing the Graphviz file. +Fabro uses `{{ ... }}` templates for workflow strings and prompts. -## Run config variables +## Template context -Define variables in the `[vars]` section of a run config TOML file: +Workflow and prompt templates can reference: + +| Expression | Resolves to | +|---|---| +| `{{ goal }}` | The workflow goal | +| `{{ inputs.name }}` | A value from `[run.inputs]` | + +Environment variables are **not** available in workflow or prompt templates. Use `{{ env.NAME }}` only in config strings and HTTP hook headers. + +## Run config inputs + +Define typed inputs in `[run.inputs]`: ```toml title="run.toml" -version = 1 -goal = "Run tests for $repo_name" +_version = 1 + +[workflow] graph = "check.fabro" -[vars] +[run] +goal = "Run repository checks" + +[run.inputs] repo_name = "fabro" repo_url = "https://github.com/fabro-sh/fabro" language = "rust" ``` -These variables are expanded into the Graphviz source **before** the graph is parsed. You can use `$variable` anywhere in the Graphviz file — goals, prompts, labels, scripts, or any other attribute: +These values are available throughout the workflow as `{{ inputs.* }}`: ```dot title="check.fabro" digraph Check { - graph [goal="Run tests for $repo_name"] + graph [goal="Run tests for {{ inputs.repo_name }}"] start [shape=Mdiamond, label="Start"] exit [shape=Msquare, label="Exit"] - clone [label="Clone", shape=parallelogram, script="git clone $repo_url repo"] - test [label="Test", prompt="Run the $language test suite in the repo/ directory."] + clone [label="Clone", shape=parallelogram, script="git clone {{ inputs.repo_url }} repo"] + test [label="Test", prompt="Run the {{ inputs.language }} test suite in the repo/ directory."] start -> clone -> test -> exit } ``` -When launched with `fabro run run.toml`, Fabro replaces `$repo_name`, `$repo_url`, and `$language` with their values before parsing the graph. +## `goal` -### Undefined variables - -If a `$variable` in the Graphviz file has no matching entry in `[vars]`, Fabro raises an error. This catches typos early — a misspelled `$langauge` fails immediately rather than passing a literal `$langauge` to the LLM. - -### Escaping `$` - -To include a literal `$` in the output, write `$$`: - -```dot -test [prompt="The env var is $$HOME"] -``` - -This produces `The env var is $HOME` without treating `$HOME` as a variable reference. A bare `$` not followed by an identifier character (e.g. `costs $5`) does not need escaping. - -## The `$goal` variable - -Inside agent and prompt node prompts, Fabro automatically expands `$goal` to the workflow's `goal` attribute. This happens at runtime, after graph parsing: +Agent and prompt nodes also receive the workflow goal at runtime: ```dot title="example.fabro" digraph Example { graph [goal="Implement the login feature"] - plan [label="Plan", prompt="Create a plan for: $goal"] + plan [label="Plan", prompt="Create a plan for: {{ goal }}"] } ``` -The plan node's prompt becomes `"Create a plan for: Implement the login feature"`. +That prompt becomes `Create a plan for: Implement the login feature`. -## Variable merging +## Expansion timing -When using server-level run defaults alongside a run config TOML, variables are merged. Task config vars override default vars when keys collide: +Fabro expands templates in multiple passes: + +1. Before DOT parsing, `{{ inputs.* }}` can parameterize structural parts of the graph, including imported `.fabro` files. +2. After parsing, all string graph, node, and edge attributes are rendered again with the real `{ goal, inputs }` context. +3. Agent and prompt handlers do a final runtime render pass as a safety net. + +`{{ goal }}` is preserved through the pre-parse step so it can be resolved later. That means goal-dependent MiniJinja control flow such as `{% if goal %}` is not useful in structural pre-parse templates. + +## Undefined variables + +Fabro uses strict undefined-variable handling. If a workflow template references an unknown value such as `{{ inputs.langauge }}`, validation fails instead of passing the literal text through to the model. + +## Escaping + +To emit literal template syntax, use MiniJinja escaping: + +```dot +test [prompt="{% raw %}{{ goal }}{% endraw %}"] +``` + +You can also emit literal braces with expressions such as `{{ '{{' }}` when needed. + +## Input merging + +`[run.inputs]` intentionally replaces the inherited map wholesale rather than merging by key. Whichever layer has the highest precedence and sets `[run.inputs]` wins its entire map. | Source | Priority | |---|---| -| Run config TOML `[vars]` | Highest — wins on collision | -| Server defaults `[vars]` | Lowest — provides fallback values | +| CLI flags (`-V key=value`, repeated) | Highest | +| `workflow.toml` `[run.inputs]` | | +| `.fabro/project.toml` `[run.inputs]` | | +| `~/.fabro/settings.toml` `[run.inputs]` | Lowest | + +If you need per-key overrides on top of inherited defaults, set each input explicitly in the winning layer. diff --git a/fabro/workflows/gh-triage/workflow.toml b/fabro/workflows/gh-triage/workflow.toml deleted file mode 100644 index 3e0093cb8..000000000 --- a/fabro/workflows/gh-triage/workflow.toml +++ /dev/null @@ -1,4 +0,0 @@ -version = 1 - -[github] -permissions = { pull_requests = "read", issues = "read" } diff --git a/fabro/workflows/implement-issue/workflow.toml b/fabro/workflows/implement-issue/workflow.toml deleted file mode 100644 index 0ec3f59de..000000000 --- a/fabro/workflows/implement-issue/workflow.toml +++ /dev/null @@ -1,4 +0,0 @@ -version = 1 - -[github] -permissions = { issues = "read", pull_requests = "write" } diff --git a/fabro/workflows/implement-plan/workflow.toml b/fabro/workflows/implement-plan/workflow.toml deleted file mode 100644 index 2ebc2a05d..000000000 --- a/fabro/workflows/implement-plan/workflow.toml +++ /dev/null @@ -1 +0,0 @@ -version = 1 \ No newline at end of file diff --git a/fabro/workflows/smoke/workflow.toml b/fabro/workflows/smoke/workflow.toml deleted file mode 100644 index d9914dfa6..000000000 --- a/fabro/workflows/smoke/workflow.toml +++ /dev/null @@ -1 +0,0 @@ -version = 1 diff --git a/files-internal/testing-strategy.md b/files-internal/testing-strategy.md index fcf2cc67b..264930629 100644 --- a/files-internal/testing-strategy.md +++ b/files-internal/testing-strategy.md @@ -113,7 +113,7 @@ Allowed setup: - checked-in workflow fixtures - temp `.fabro` workflow files -- temp `workflow.toml` and `fabro.toml` +- temp `workflow.toml` and `.fabro/project.toml` - temp git repositories - temp user config and environment variables - invoking commands to create runs, checkpoints, branches, and persisted state @@ -209,7 +209,7 @@ Use the test helpers that reinforce the rules above. ### `TestContext` -Use `TestContext` for CLI integration tests so each test gets isolated home, storage, and temp directories. +Use `TestContext` for CLI integration tests so each test gets isolated home and temp directories, with storage shared per nextest run or per test process depending on the harness mode. Prefer helpers like: @@ -227,6 +227,7 @@ Shared integration-test helpers may: - normalize output - compact structured events - poll for stable command-created conditions +- centralize localhost HTTP client construction Shared integration-test helpers should not: @@ -234,6 +235,20 @@ Shared integration-test helpers should not: - write runtime files the engine is supposed to own - hide broad scenario setup behind opaque helper functions +### Local HTTP clients + +When test code talks to a local server or twin over HTTP, always create the client through a shared test helper that calls `.no_proxy()`. + +Do not open-code localhost clients with: + +- `reqwest::Client::new()` +- bare `Client::builder().build()` +- `reqwest::get(...)` + +Use a crate-local helper or a shared helper such as `fabro_test::test_http_client()` instead. + +This rule exists because macOS proxy discovery adds hidden startup overhead to repeated reqwest client creation. The result looks like random nextest timeouts even when the server under test is only talking to `127.0.0.1`. + ### Fixtures Prefer checked-in fixtures when they express a reusable workflow or scenario shape. diff --git a/installer/fabro.rb.template b/installer/fabro.rb.template new file mode 100644 index 000000000..46ea04817 --- /dev/null +++ b/installer/fabro.rb.template @@ -0,0 +1,32 @@ +class Fabro < Formula + desc "Unified CLI for the Fabro AI framework" + homepage "https://fabro.sh" + license "MIT" + version "{{VERSION}}" + + if OS.mac? + if Hardware::CPU.arm? + url "https://github.com/fabro-sh/fabro/releases/download/v{{VERSION}}/fabro-aarch64-apple-darwin.tar.gz" + sha256 "{{SHA_AARCH64_DARWIN}}" + end + end + + if OS.linux? + if Hardware::CPU.intel? + url "https://github.com/fabro-sh/fabro/releases/download/v{{VERSION}}/fabro-x86_64-unknown-linux-gnu.tar.gz" + sha256 "{{SHA_X86_64_LINUX}}" + end + if Hardware::CPU.arm? + url "https://github.com/fabro-sh/fabro/releases/download/v{{VERSION}}/fabro-aarch64-unknown-linux-gnu.tar.gz" + sha256 "{{SHA_AARCH64_LINUX}}" + end + end + + def install + bin.install "fabro" + end + + test do + assert_match version.to_s, shell_output("#{bin}/fabro --version") + end +end diff --git a/lib/crates/fabro-agent/Cargo.toml b/lib/crates/fabro-agent/Cargo.toml index 2657bd3f3..4fbe1079c 100644 --- a/lib/crates/fabro-agent/Cargo.toml +++ b/lib/crates/fabro-agent/Cargo.toml @@ -25,11 +25,13 @@ workspace = true clap.workspace = true anyhow.workspace = true fabro-config = { path = "../fabro-config", features = ["clap"] } +fabro-types = { path = "../fabro-types" } fabro-llm = { path = "../fabro-llm" } fabro-model = { path = "../fabro-model" } fabro-mcp = { path = "../fabro-mcp" } fabro-sandbox = { path = "../fabro-sandbox" } fabro-util = { path = "../fabro-util" } +fabro-http.workspace = true thiserror.workspace = true serde.workspace = true serde_json.workspace = true @@ -39,7 +41,6 @@ futures.workspace = true async-trait.workspace = true jsonschema.workspace = true chrono.workspace = true -reqwest.workspace = true tokio-util.workspace = true tracing.workspace = true dirs = "6" diff --git a/lib/crates/fabro-agent/README.md b/lib/crates/fabro-agent/README.md index 7b1442e97..4c25523d9 100644 --- a/lib/crates/fabro-agent/README.md +++ b/lib/crates/fabro-agent/README.md @@ -10,7 +10,7 @@ The crate is organized around a central `Session` that drives an agentic loop: 2. The session builds a `Request` with system prompt, history, and tools 3. An LLM generates a response (text and/or tool calls) via `unified-llm` 4. Tool calls are executed through a `ToolRegistry` against a `Sandbox` -5. Results are recorded and the loop continues until the LLM responds with text only (natural completion), a turn limit is reached, or the session is aborted +5. Results are recorded and the loop continues until the LLM responds with text only (natural completion), a turn limit is reached, or the session is interrupted ``` User Input @@ -39,12 +39,12 @@ User Input ### Key Components -- **`Session`** -- Manages the full agentic loop: LLM calls, tool execution, steering, follow-ups, abort handling, and event emission. +- **`Session`** -- Manages the full agentic loop: LLM calls, tool execution, steering, follow-ups, interrupt handling, and event emission. - **`AgentProfile`** (trait) -- Defines how to build system prompts, which tools to register, and what capabilities a provider supports. Ships with `AnthropicProfile`, `OpenAiProfile`, and `GeminiProfile`. - **`Sandbox`** (trait) -- Abstracts filesystem, shell, grep, and glob operations. `LocalSandbox` provides a real implementation; the trait enables sandboxing and testing. - **`ToolRegistry`** -- Maps tool names to definitions and async executor functions. Tools are registered per-profile. - **`History`** -- Ordered list of `Turn` variants (`User`, `Assistant`, `ToolResults`, `System`, `Steering`) that converts to LLM messages. -- **`EventEmitter`** -- Broadcasts `SessionEvent`s (tool calls, text, errors, warnings) over a `tokio::sync::broadcast` channel for UI or logging. +- **`Emitter`** -- Broadcasts `SessionEvent`s (tool calls, text, errors, warnings) over a `tokio::sync::broadcast` channel for UI or logging. - **`SubAgentManager`** -- Spawns child `Session`s on background tasks for delegated work, with depth limits. - **`SessionConfig`** -- Tunable parameters: max turns, tool round limits, command timeouts, loop detection, output truncation limits, and user instructions. @@ -166,7 +166,7 @@ session.steer("Focus on the root cause, not symptoms".into()); session.follow_up("Now run the test suite to verify".into()); ``` -### Abort +### Interrupt Cancel a running session from another thread: diff --git a/lib/crates/fabro-agent/src/agent_profile.rs b/lib/crates/fabro-agent/src/agent_profile.rs index de869e34c..cc9c1e1d4 100644 --- a/lib/crates/fabro-agent/src/agent_profile.rs +++ b/lib/crates/fabro-agent/src/agent_profile.rs @@ -1,3 +1,9 @@ +use std::sync::Arc; + +use fabro_llm::types::ToolDefinition; +use fabro_model::{Catalog, Provider}; +use tokio::sync::Mutex; + use crate::profiles::EnvContext; use crate::sandbox::Sandbox; use crate::skills::Skill; @@ -6,10 +12,6 @@ use crate::subagent::{ make_spawn_agent_tool, make_wait_tool, }; use crate::tool_registry::ToolRegistry; -use fabro_llm::types::ToolDefinition; -use fabro_model::{Catalog, Provider}; -use std::sync::Arc; -use tokio::sync::Mutex; pub trait AgentProfile: Send + Sync { fn provider(&self) -> Provider; @@ -63,9 +65,10 @@ pub trait AgentProfile: Send + Sync { #[cfg(test)] mod tests { + use fabro_model::Provider; + use super::*; use crate::test_support::{MockSandbox, TestProfile}; - use fabro_model::Provider; #[test] fn profile_provider_and_model() { diff --git a/lib/crates/fabro-agent/src/cli.rs b/lib/crates/fabro-agent/src/cli.rs index b5fdb06a7..bb43dfe05 100644 --- a/lib/crates/fabro-agent/src/cli.rs +++ b/lib/crates/fabro-agent/src/cli.rs @@ -1,27 +1,28 @@ -use crate::config::{ToolApprovalAdapter, ToolApprovalFn, ToolHookCallback}; -use crate::error::AbortReason; -use crate::tools::WebFetchSummarizer; -use crate::truncation; -use crate::{ - AgentEvent, AgentProfile, AnthropicProfile, GeminiProfile, LocalSandbox, OpenAiProfile, - Sandbox, Session, SessionConfig, Turn, - subagent::{SessionFactory, SubAgentManager}, -}; -use clap::{Args, Parser}; -use fabro_llm::client::Client; -use fabro_llm::error::SdkError; -use fabro_llm::middleware::{Middleware, NextFn, NextStreamFn}; -use fabro_llm::provider::StreamEventStream; -use fabro_llm::types::{Request, Response}; -use fabro_mcp::config::McpServerConfig; -use fabro_model::{Catalog, ModelRef, Provider}; -use fabro_util::terminal::Styles; use std::io::{IsTerminal, Write}; use std::path::PathBuf; use std::sync::{Arc, Mutex}; + +use clap::{Args, Parser}; +use fabro_llm::Error as LlmError; +use fabro_llm::client::Client; +use fabro_llm::middleware::{Middleware, NextFn, NextStreamFn}; +use fabro_llm::provider::StreamEventStream; +use fabro_llm::types::{Request, Response}; +use fabro_mcp::config::McpServerSettings; +use fabro_model::{Catalog, ModelHandle, Provider}; +use fabro_util::terminal::Styles; use tokio::signal; use tokio::sync::Mutex as AsyncMutex; +use crate::config::{ToolApprovalAdapter, ToolApprovalFn, ToolHookCallback}; +use crate::error::InterruptReason; +use crate::subagent::{SessionFactory, SubAgentManager}; +use crate::tools::WebFetchSummarizer; +use crate::{ + AgentEvent, AgentProfile, AnthropicProfile, GeminiProfile, LocalSandbox, OpenAiProfile, + Sandbox, Session, SessionOptions, Turn, truncation, +}; + /// Public arguments for the agent command, usable from an external CLI. #[derive(Args)] pub struct AgentArgs { @@ -68,10 +69,29 @@ struct Cli { args: AgentArgs, } -pub use fabro_config::user::{OutputFormat, PermissionLevel}; +/// Output format for the `fabro exec` / agent CLI. +#[derive( + Clone, Copy, Debug, PartialEq, Eq, serde::Deserialize, serde::Serialize, clap::ValueEnum, +)] +#[serde(rename_all = "kebab-case")] +pub enum OutputFormat { + Text, + Json, +} + +/// Agent tool permission level. +#[derive( + Clone, Copy, Debug, PartialEq, Eq, serde::Deserialize, serde::Serialize, clap::ValueEnum, +)] +#[serde(rename_all = "kebab-case")] +pub enum PermissionLevel { + ReadOnly, + ReadWrite, + Full, +} impl AgentArgs { - /// Fill `None` fields from user.toml values, then hardcoded defaults. + /// Fill `None` fields from settings.toml values, then hardcoded defaults. pub fn apply_cli_defaults( &mut self, provider: Option<&str>, @@ -166,8 +186,8 @@ fn build_tool_approval( }) } -fn summarizer_model_id(provider: Provider) -> ModelRef { - ModelRef::ByName { +fn summarizer_model_id(provider: Provider) -> ModelHandle { + ModelHandle::ByName { provider, model: match provider { Provider::OpenAi | Provider::OpenAiCompatible => "gpt-4o-mini", @@ -257,7 +277,7 @@ fn print_summary(session: &Session, styles: &Styles) { { turn_count += 1; tool_call_count += tool_calls.len(); - total_tokens += usage.total_tokens; + total_tokens += usage.total_tokens(); } } let token_str = if total_tokens >= 1_000_000 { @@ -283,7 +303,7 @@ struct DebugMiddleware { #[async_trait::async_trait] impl Middleware for DebugMiddleware { #[allow(clippy::print_stderr)] - async fn handle_complete(&self, request: Request, next: NextFn) -> Result { + async fn handle_complete(&self, request: Request, next: NextFn) -> Result { let s = self.styles; eprintln!( "{}", @@ -303,7 +323,7 @@ impl Middleware for DebugMiddleware { response.finish_reason, response.usage.input_tokens, response.usage.output_tokens, - response.usage.total_tokens, + response.usage.total_tokens(), )), ); Ok(response) @@ -313,7 +333,7 @@ impl Middleware for DebugMiddleware { &self, request: Request, next: NextStreamFn, - ) -> Result { + ) -> Result { next(request).await } } @@ -326,7 +346,7 @@ struct VerboseMiddleware { #[async_trait::async_trait] impl Middleware for VerboseMiddleware { #[allow(clippy::print_stderr)] - async fn handle_complete(&self, request: Request, next: NextFn) -> Result { + async fn handle_complete(&self, request: Request, next: NextFn) -> Result { let s = self.styles; eprintln!( "{}\n{}", @@ -348,14 +368,14 @@ impl Middleware for VerboseMiddleware { &self, request: Request, next: NextStreamFn, - ) -> Result { + ) -> Result { next(request).await } } pub async fn run_with_args( args: AgentArgs, - mcp_servers: Vec, + mcp_servers: Vec, ) -> anyhow::Result<()> { run_with_args_and_client(args, None, mcp_servers).await } @@ -364,9 +384,10 @@ pub async fn run_with_args( pub async fn run_with_args_and_client( args: AgentArgs, llm_client: Option, - mcp_servers: Vec, + mcp_servers: Vec, ) -> anyhow::Result<()> { - // Resolve color support once, leak to get 'static lifetime for use across threads + // Resolve color support once, leak to get 'static lifetime for use across + // threads let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); // Parse provider string to enum early for compile-time safety @@ -420,11 +441,11 @@ pub async fn run_with_args_and_client( let tool_approval = build_tool_approval(permissions, is_interactive, styles); let tool_hooks: Arc = Arc::new(ToolApprovalAdapter(tool_approval)); - let config = SessionConfig { + let config = SessionOptions { tool_hooks: Some(tool_hooks.clone()), skill_dirs: args.skills_dir.map(|d| vec![d]), mcp_servers, - ..SessionConfig::default() + ..SessionOptions::default() }; // Register subagent tools @@ -464,9 +485,9 @@ pub async fn run_with_args_and_client( factory_client.clone(), child_profile, Arc::clone(&factory_env), - SessionConfig { + SessionOptions { tool_hooks: factory_hooks.clone(), - ..SessionConfig::default() + ..SessionOptions::default() }, None, ) @@ -490,15 +511,15 @@ pub async fn run_with_args_and_client( // SIGINT handler let cancel_token = session.cancel_token(); - let abort_reason = session.abort_reason_handle(); + let interrupt_reason = session.interrupt_reason_handle(); tokio::spawn(async move { signal::ctrl_c().await.ok(); { - let mut guard = abort_reason + let mut guard = interrupt_reason .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); if guard.is_none() { - *guard = Some(AbortReason::Cancelled); + *guard = Some(InterruptReason::Cancelled); } } cancel_token.cancel(); @@ -652,10 +673,11 @@ pub async fn run() -> anyhow::Result<()> { #[cfg(test)] mod tests { - use super::*; use fabro_model::Provider; use serde_json::json; + use super::*; + static NO_COLOR: std::sync::LazyLock = std::sync::LazyLock::new(|| Styles::new(false)); // tool_category tests diff --git a/lib/crates/fabro-agent/src/compaction.rs b/lib/crates/fabro-agent/src/compaction.rs index 14d630aaa..30f1a383a 100644 --- a/lib/crates/fabro-agent/src/compaction.rs +++ b/lib/crates/fabro-agent/src/compaction.rs @@ -1,25 +1,26 @@ use std::fmt::Write; -use crate::agent_profile::AgentProfile; -use crate::error::AgentError; -use crate::event::EventEmitter; -use crate::file_tracker::FileTracker; -use crate::history::History; -use crate::truncation; -use crate::types::{AgentEvent, Turn}; use fabro_llm::client::Client; use fabro_llm::types::{Message, Request}; use tracing::debug; +use crate::agent_profile::AgentProfile; +use crate::error::Error; +use crate::event::Emitter; +use crate::file_tracker::FileTracker; +use crate::history::History; +use crate::truncation; +use crate::types::{AgentEvent, Turn}; + /// Check whether the context window usage exceeds the configured threshold. -/// Emits a `Warning` event with kind `"context_window"` when over the threshold. -/// Returns `true` if the threshold is exceeded. +/// Emits a `Warning` event with kind `"context_window"` when over the +/// threshold. Returns `true` if the threshold is exceeded. pub fn check_context_usage( system_prompt: &str, history: &History, provider_profile: &dyn AgentProfile, threshold_percent: usize, - emitter: &EventEmitter, + emitter: &Emitter, session_id: &str, ) -> bool { let estimated_tokens = estimate_token_count(system_prompt, history); @@ -27,28 +28,26 @@ pub fn check_context_usage( let threshold = context_window * threshold_percent / 100; if estimated_tokens > threshold { - emitter.emit( - session_id.to_owned(), - AgentEvent::Warning { - kind: "context_window".into(), - message: format!( - "Context window usage: {}%", - estimated_tokens * 100 / context_window - ), - details: serde_json::json!({ - "estimated_tokens": estimated_tokens, - "context_window_size": context_window, - "usage_percent": estimated_tokens * 100 / context_window, - }), - }, - ); + emitter.emit(session_id.to_owned(), AgentEvent::Warning { + kind: "context_window".into(), + message: format!( + "Context window usage: {}%", + estimated_tokens * 100 / context_window + ), + details: serde_json::json!({ + "estimated_tokens": estimated_tokens, + "context_window_size": context_window, + "usage_percent": estimated_tokens * 100 / context_window, + }), + }); true } else { false } } -/// Compact the conversation history by summarizing older turns via a non-streaming LLM call. +/// Compact the conversation history by summarizing older turns via a +/// non-streaming LLM call. #[allow(clippy::too_many_arguments)] pub async fn compact_context( history: &mut History, @@ -57,20 +56,17 @@ pub async fn compact_context( system_prompt: &str, file_tracker: &FileTracker, preserve_count: usize, - emitter: &EventEmitter, + emitter: &Emitter, session_id: &str, -) -> Result<(), AgentError> { +) -> Result<(), Error> { let estimated_tokens = estimate_token_count(system_prompt, history); let context_window = provider_profile.context_window_size(); let original_turn_count = history.turns().len(); - emitter.emit( - session_id.to_owned(), - AgentEvent::CompactionStarted { - estimated_tokens, - context_window_size: context_window, - }, - ); + emitter.emit(session_id.to_owned(), AgentEvent::CompactionStarted { + estimated_tokens, + context_window_size: context_window, + }); // Determine turns to summarize if original_turn_count <= preserve_count { @@ -106,31 +102,31 @@ function names, error messages, and exact values. Omit pleasantries and conversa ); let summary_request = Request { - model: provider_profile.model().to_string(), - messages: vec![ + model: provider_profile.model().to_string(), + messages: vec![ Message::system(summarization_prompt), Message::user(format!( "Here is the conversation to summarize:\n\n{rendered}" )), ], - provider: Some(provider_profile.provider().as_str().to_string()), - tools: None, - tool_choice: None, - response_format: None, - temperature: Some(0.0), - top_p: None, - max_tokens: Some(4096), - stop_sequences: None, + provider: Some(provider_profile.provider().as_str().to_string()), + tools: None, + tool_choice: None, + response_format: None, + temperature: Some(0.0), + top_p: None, + max_tokens: Some(4096), + stop_sequences: None, reasoning_effort: None, - speed: None, - metadata: None, + speed: None, + metadata: None, provider_options: None, }; let response = llm_client .complete(&summary_request) .await - .map_err(AgentError::Llm)?; + .map_err(Error::Llm)?; let summary_text = response.text(); debug!( @@ -145,21 +141,18 @@ Build on their progress — do not repeat completed steps.\n\n{summary_text}" history.compact(preserve_count, summary_content); - emitter.emit( - session_id.to_owned(), - AgentEvent::CompactionCompleted { - original_turn_count, - preserved_turn_count: preserve_count, - summary_token_estimate, - tracked_file_count: file_tracker.file_count(), - }, - ); + emitter.emit(session_id.to_owned(), AgentEvent::CompactionCompleted { + original_turn_count, + preserved_turn_count: preserve_count, + summary_token_estimate, + tracked_file_count: file_tracker.file_count(), + }); Ok(()) } -/// Estimate the total token count of the system prompt and conversation history. -/// Uses a rough heuristic of ~4 characters per token. +/// Estimate the total token count of the system prompt and conversation +/// history. Uses a rough heuristic of ~4 characters per token. pub fn estimate_token_count(system_prompt: &str, history: &History) -> usize { let mut total_chars = system_prompt.len(); @@ -194,7 +187,8 @@ pub fn estimate_token_count(system_prompt: &str, history: &History) -> usize { total_chars / 4 // rough estimate: ~4 chars per token } -/// Render conversation turns into a human-readable summary format for the compaction LLM call. +/// Render conversation turns into a human-readable summary format for the +/// compaction LLM call. pub fn render_turns_for_summary(turns: &[Turn]) -> String { let mut out = String::new(); for turn in turns { @@ -250,40 +244,42 @@ pub fn render_turns_for_summary(turns: &[Turn]) -> String { #[cfg(test)] mod tests { + use std::time::SystemTime; + + use fabro_llm::types::{TokenCounts, ToolCall, ToolResult}; + use super::*; - use crate::event::EventEmitter; + use crate::event::Emitter; use crate::history::History; use crate::test_support::TestProfile; use crate::tool_registry::ToolRegistry; use crate::types::Turn; - use fabro_llm::types::{ToolCall, ToolResult, Usage}; - use std::time::SystemTime; #[test] fn render_turns_produces_labeled_text() { let turns = vec![ Turn::User { - content: "Hello".into(), + content: "Hello".into(), timestamp: SystemTime::now(), }, Turn::Assistant { - content: "Let me check".into(), - tool_calls: vec![ToolCall::new( + content: "Let me check".into(), + tool_calls: vec![ToolCall::new( "c1", "read_file", serde_json::json!({"path": "foo.rs"}), )], provider_parts: vec![], - usage: Box::new(Usage::default()), - response_id: "resp_1".into(), - timestamp: SystemTime::now(), + usage: Box::new(TokenCounts::default()), + response_id: "resp_1".into(), + timestamp: SystemTime::now(), }, Turn::ToolResults { - results: vec![ToolResult { - tool_call_id: "c1".into(), - content: serde_json::json!("file contents here"), - is_error: false, - image_data: None, + results: vec![ToolResult { + tool_call_id: "c1".into(), + content: serde_json::json!("file contents here"), + is_error: false, + image_data: None, image_media_type: None, }], timestamp: SystemTime::now(), @@ -302,11 +298,11 @@ mod tests { fn render_turns_truncates_long_tool_output() { let long_output = "x".repeat(1000); let turns = vec![Turn::ToolResults { - results: vec![ToolResult { - tool_call_id: "c1".into(), - content: serde_json::json!(long_output), - is_error: false, - image_data: None, + results: vec![ToolResult { + tool_call_id: "c1".into(), + content: serde_json::json!(long_output), + is_error: false, + image_data: None, image_media_type: None, }], timestamp: SystemTime::now(), @@ -321,7 +317,7 @@ mod tests { fn estimate_token_count_basic() { let mut history = History::default(); history.push(Turn::User { - content: "Hello world".into(), // 11 chars + content: "Hello world".into(), // 11 chars timestamp: SystemTime::now(), }); // system_prompt = "test" (4 chars) + 11 chars = 15 chars / 4 = 3 tokens @@ -331,7 +327,7 @@ mod tests { #[test] fn check_context_usage_below_threshold() { let history = History::default(); - let emitter = EventEmitter::new(); + let emitter = Emitter::new(); let profile = TestProfile::new(); // Empty history, huge context window => well below threshold let over = check_context_usage("short", &history, &profile, 80, &emitter, "sess"); @@ -343,10 +339,10 @@ mod tests { let mut history = History::default(); // Push enough content to exceed a tiny context window history.push(Turn::User { - content: "x".repeat(1000), + content: "x".repeat(1000), timestamp: SystemTime::now(), }); - let emitter = EventEmitter::new(); + let emitter = Emitter::new(); let mut rx = emitter.subscribe(); // TestProfile has context_window=200_000 by default; use a small one let profile = TestProfile::with_context_window(ToolRegistry::new(), 100); diff --git a/lib/crates/fabro-agent/src/config.rs b/lib/crates/fabro-agent/src/config.rs index af80f7e25..6e2f626b6 100644 --- a/lib/crates/fabro-agent/src/config.rs +++ b/lib/crates/fabro-agent/src/config.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use std::time::Duration; use fabro_llm::types::ReasoningEffort; -use fabro_mcp::config::McpServerConfig; +use fabro_mcp::config::McpServerSettings; /// Callback invoked before each tool execution. Return `Ok(())` to allow, /// `Err(message)` to deny with the given message. @@ -59,7 +59,7 @@ impl ToolHookCallback for ToolApprovalAdapter { } #[derive(Clone)] -pub struct SessionConfig { +pub struct SessionOptions { pub max_turns: usize, pub max_tool_rounds_per_input: usize, pub default_command_timeout_ms: u64, @@ -81,18 +81,19 @@ pub struct SessionConfig { pub enable_context_compaction: bool, pub compaction_threshold_percent: usize, pub compaction_preserve_turns: usize, - /// Skill directories. `None` = use convention defaults, `Some(dirs)` = use these instead. + /// Skill directories. `None` = use convention defaults, `Some(dirs)` = use + /// these instead. pub skill_dirs: Option>, /// MCP server configurations to connect to on session startup. - pub mcp_servers: Vec, + pub mcp_servers: Vec, /// Wall-clock timeout for the entire `process_input` call. /// When set, the session's cancel token is triggered after this duration. pub wall_clock_timeout: Option, } -impl std::fmt::Debug for SessionConfig { +impl std::fmt::Debug for SessionOptions { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("SessionConfig") + f.debug_struct("SessionOptions") .field("max_turns", &self.max_turns) .field("max_tool_rounds_per_input", &self.max_tool_rounds_per_input) .field( @@ -127,7 +128,7 @@ impl std::fmt::Debug for SessionConfig { } } -impl Default for SessionConfig { +impl Default for SessionOptions { fn default() -> Self { Self { max_turns: 0, @@ -161,7 +162,7 @@ mod tests { #[test] fn default_config_values() { - let config = SessionConfig::default(); + let config = SessionOptions::default(); assert_eq!(config.max_turns, 0); assert_eq!(config.max_tool_rounds_per_input, 0); assert_eq!(config.default_command_timeout_ms, 10_000); @@ -179,7 +180,7 @@ mod tests { #[test] fn default_config_has_compaction_enabled() { - let config = SessionConfig::default(); + let config = SessionOptions::default(); assert!(config.enable_context_compaction); assert_eq!(config.compaction_threshold_percent, 80); assert_eq!(config.compaction_preserve_turns, 6); @@ -187,7 +188,7 @@ mod tests { #[test] fn config_with_custom_values() { - let config = SessionConfig { + let config = SessionOptions { max_turns: 50, reasoning_effort: Some(ReasoningEffort::High), ..Default::default() @@ -215,12 +216,9 @@ mod tests { let approval: ToolApprovalFn = Arc::new(|_name, _args| Err("denied".to_string())); let adapter = ToolApprovalAdapter(approval); let decision = adapter.pre_tool_use("shell", &serde_json::json!({})).await; - assert_eq!( - decision, - ToolHookDecision::Block { - reason: "denied".to_string() - } - ); + assert_eq!(decision, ToolHookDecision::Block { + reason: "denied".to_string(), + }); } #[tokio::test] diff --git a/lib/crates/fabro-agent/src/docker_sandbox.rs b/lib/crates/fabro-agent/src/docker_sandbox.rs index d7635f528..2c3f4f257 100644 --- a/lib/crates/fabro-agent/src/docker_sandbox.rs +++ b/lib/crates/fabro-agent/src/docker_sandbox.rs @@ -1,2 +1,2 @@ // Re-export from fabro-sandbox -pub use fabro_sandbox::docker::{DockerSandbox, DockerSandboxConfig}; +pub use fabro_sandbox::docker::{DockerSandbox, DockerSandboxOptions}; diff --git a/lib/crates/fabro-agent/src/error.rs b/lib/crates/fabro-agent/src/error.rs index 24f2b72d6..be5bbf05e 100644 --- a/lib/crates/fabro-agent/src/error.rs +++ b/lib/crates/fabro-agent/src/error.rs @@ -1,14 +1,14 @@ -use fabro_llm::error::SdkError; +use fabro_llm::Error as LlmError; -/// Why a session was aborted. +/// Why a session was interrupted. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "snake_case")] -pub enum AbortReason { +pub enum InterruptReason { WallClockTimeout, Cancelled, } -impl std::fmt::Display for AbortReason { +impl std::fmt::Display for InterruptReason { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::WallClockTimeout => write!(f, "wall clock timeout"), @@ -19,9 +19,9 @@ impl std::fmt::Display for AbortReason { #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, thiserror::Error)] #[serde(tag = "type", content = "data", rename_all = "snake_case")] -pub enum AgentError { +pub enum Error { #[error("LLM error: {0}")] - Llm(#[from] SdkError), + Llm(#[from] LlmError), #[error("Session is closed")] SessionClosed, @@ -32,116 +32,119 @@ pub enum AgentError { #[error("Tool execution error: {0}")] ToolExecution(String), - #[error("Aborted: {0}")] - Aborted(AbortReason), + #[error("Interrupted: {0}")] + Interrupted(InterruptReason), } +pub type Result = std::result::Result; + #[cfg(test)] mod tests { + use fabro_llm::{ProviderErrorDetail, ProviderErrorKind}; + use super::*; - use fabro_llm::error::{ProviderErrorDetail, ProviderErrorKind}; #[test] fn agent_error_from_sdk_error() { - let sdk_err = SdkError::Network { + let sdk_err = LlmError::Network { message: "connection refused".into(), - source: None, + source: None, }; - let agent_err = AgentError::from(sdk_err); - assert!(matches!(agent_err, AgentError::Llm(_))); + let agent_err = Error::from(sdk_err); + assert!(matches!(agent_err, Error::Llm(_))); assert!(agent_err.to_string().contains("connection refused")); } #[test] fn session_closed_display() { - let err = AgentError::SessionClosed; + let err = Error::SessionClosed; assert_eq!(err.to_string(), "Session is closed"); } #[test] fn invalid_state_display() { - let err = AgentError::InvalidState("bad state".into()); + let err = Error::InvalidState("bad state".into()); assert_eq!(err.to_string(), "Invalid state: bad state"); } #[test] fn tool_execution_display() { - let err = AgentError::ToolExecution("command failed".into()); + let err = Error::ToolExecution("command failed".into()); assert_eq!(err.to_string(), "Tool execution error: command failed"); } #[test] - fn aborted_display() { - let err = AgentError::Aborted(AbortReason::Cancelled); - assert_eq!(err.to_string(), "Aborted: cancelled"); + fn interrupted_display() { + let err = Error::Interrupted(InterruptReason::Cancelled); + assert_eq!(err.to_string(), "Interrupted: cancelled"); } #[test] - fn aborted_wall_clock_timeout_display() { - let err = AgentError::Aborted(AbortReason::WallClockTimeout); - assert_eq!(err.to_string(), "Aborted: wall clock timeout"); + fn interrupted_wall_clock_timeout_display() { + let err = Error::Interrupted(InterruptReason::WallClockTimeout); + assert_eq!(err.to_string(), "Interrupted: wall clock timeout"); } // --- Serde roundtrip tests --- #[test] fn serde_roundtrip_llm_network() { - let err = AgentError::Llm(SdkError::Network { + let err = Error::Llm(LlmError::Network { message: "connection refused".into(), - source: None, + source: None, }); let json = serde_json::to_string(&err).unwrap(); - let deserialized: AgentError = serde_json::from_str(&json).unwrap(); + let deserialized: Error = serde_json::from_str(&json).unwrap(); assert_eq!(err.to_string(), deserialized.to_string()); } #[test] fn serde_roundtrip_llm_provider() { - let err = AgentError::Llm(SdkError::Provider { - kind: ProviderErrorKind::RateLimit, + let err = Error::Llm(LlmError::Provider { + kind: ProviderErrorKind::RateLimit, detail: Box::new(ProviderErrorDetail { - message: "too fast".into(), - provider: "openai".into(), + message: "too fast".into(), + provider: "openai".into(), status_code: Some(429), - error_code: None, + error_code: None, retry_after: Some(2.0), - raw: None, + raw: None, }), }); let json = serde_json::to_string(&err).unwrap(); - let deserialized: AgentError = serde_json::from_str(&json).unwrap(); + let deserialized: Error = serde_json::from_str(&json).unwrap(); assert_eq!(err.to_string(), deserialized.to_string()); } #[test] fn serde_roundtrip_session_closed() { - let err = AgentError::SessionClosed; + let err = Error::SessionClosed; let json = serde_json::to_string(&err).unwrap(); - let deserialized: AgentError = serde_json::from_str(&json).unwrap(); + let deserialized: Error = serde_json::from_str(&json).unwrap(); assert_eq!(err.to_string(), deserialized.to_string()); } #[test] fn serde_roundtrip_invalid_state() { - let err = AgentError::InvalidState("bad".into()); + let err = Error::InvalidState("bad".into()); let json = serde_json::to_string(&err).unwrap(); - let deserialized: AgentError = serde_json::from_str(&json).unwrap(); + let deserialized: Error = serde_json::from_str(&json).unwrap(); assert_eq!(err.to_string(), deserialized.to_string()); } #[test] fn serde_roundtrip_tool_execution() { - let err = AgentError::ToolExecution("cmd failed".into()); + let err = Error::ToolExecution("cmd failed".into()); let json = serde_json::to_string(&err).unwrap(); - let deserialized: AgentError = serde_json::from_str(&json).unwrap(); + let deserialized: Error = serde_json::from_str(&json).unwrap(); assert_eq!(err.to_string(), deserialized.to_string()); } #[test] - fn serde_roundtrip_aborted() { - let err = AgentError::Aborted(AbortReason::Cancelled); + fn serde_roundtrip_interrupted() { + let err = Error::Interrupted(InterruptReason::Cancelled); let json = serde_json::to_string(&err).unwrap(); - let deserialized: AgentError = serde_json::from_str(&json).unwrap(); + let deserialized: Error = serde_json::from_str(&json).unwrap(); assert_eq!(err.to_string(), deserialized.to_string()); } @@ -149,15 +152,15 @@ mod tests { #[test] fn clone_all_variants() { - let errors: Vec = vec![ - AgentError::Llm(SdkError::Network { + let errors: Vec = vec![ + Error::Llm(LlmError::Network { message: "refused".into(), - source: None, + source: None, }), - AgentError::SessionClosed, - AgentError::InvalidState("reason".into()), - AgentError::ToolExecution("reason".into()), - AgentError::Aborted(AbortReason::Cancelled), + Error::SessionClosed, + Error::InvalidState("reason".into()), + Error::ToolExecution("reason".into()), + Error::Interrupted(InterruptReason::Cancelled), ]; for err in &errors { assert_eq!(err.to_string(), err.clone().to_string()); @@ -168,9 +171,9 @@ mod tests { #[test] fn serde_tag_format_llm() { - let err = AgentError::Llm(SdkError::Network { + let err = Error::Llm(LlmError::Network { message: "refused".into(), - source: None, + source: None, }); let json = serde_json::to_string(&err).unwrap(); let v: serde_json::Value = serde_json::from_str(&json).unwrap(); @@ -179,7 +182,7 @@ mod tests { #[test] fn serde_tag_format_session_closed() { - let err = AgentError::SessionClosed; + let err = Error::SessionClosed; let json = serde_json::to_string(&err).unwrap(); let v: serde_json::Value = serde_json::from_str(&json).unwrap(); assert_eq!(v["type"], "session_closed"); @@ -187,7 +190,7 @@ mod tests { #[test] fn serde_tag_format_invalid_state() { - let err = AgentError::InvalidState("x".into()); + let err = Error::InvalidState("x".into()); let json = serde_json::to_string(&err).unwrap(); let v: serde_json::Value = serde_json::from_str(&json).unwrap(); assert_eq!(v["type"], "invalid_state"); @@ -195,18 +198,18 @@ mod tests { #[test] fn serde_tag_format_tool_execution() { - let err = AgentError::ToolExecution("x".into()); + let err = Error::ToolExecution("x".into()); let json = serde_json::to_string(&err).unwrap(); let v: serde_json::Value = serde_json::from_str(&json).unwrap(); assert_eq!(v["type"], "tool_execution"); } #[test] - fn serde_tag_format_aborted() { - let err = AgentError::Aborted(AbortReason::WallClockTimeout); + fn serde_tag_format_interrupted() { + let err = Error::Interrupted(InterruptReason::WallClockTimeout); let json = serde_json::to_string(&err).unwrap(); let v: serde_json::Value = serde_json::from_str(&json).unwrap(); - assert_eq!(v["type"], "aborted"); + assert_eq!(v["type"], "interrupted"); assert_eq!(v["data"], "wall_clock_timeout"); } } diff --git a/lib/crates/fabro-agent/src/event.rs b/lib/crates/fabro-agent/src/event.rs index 60dbbe9e7..c2495f25a 100644 --- a/lib/crates/fabro-agent/src/event.rs +++ b/lib/crates/fabro-agent/src/event.rs @@ -1,13 +1,15 @@ -use crate::types::{AgentEvent, SessionEvent}; use std::time::SystemTime; + use tokio::sync::broadcast; +use crate::types::{AgentEvent, SessionEvent}; + #[derive(Clone)] -pub struct EventEmitter { +pub struct Emitter { sender: broadcast::Sender, } -impl EventEmitter { +impl Emitter { #[must_use] pub fn new() -> Self { let (sender, _) = broadcast::channel(1024); @@ -36,7 +38,7 @@ impl EventEmitter { } } -impl Default for EventEmitter { +impl Default for Emitter { fn default() -> Self { Self::new() } @@ -45,32 +47,35 @@ impl Default for EventEmitter { #[cfg(test)] mod tests { use super::*; - use crate::error::AgentError; + use crate::error::Error; #[tokio::test] async fn emit_and_receive_event() { - let emitter = EventEmitter::new(); + let emitter = Emitter::new(); let mut receiver = emitter.subscribe(); - emitter.emit("sess-1".into(), AgentEvent::SessionStarted); + emitter.emit("sess-1".into(), AgentEvent::SessionStarted { + provider: Some("anthropic".into()), + model: Some("claude-opus".into()), + }); let event = receiver.recv().await.unwrap(); - assert!(matches!(event.event, AgentEvent::SessionStarted)); + assert!(matches!(event.event, AgentEvent::SessionStarted { + provider: Some(_), + model: Some(_), + })); assert_eq!(event.session_id, "sess-1"); assert_eq!(event.parent_session_id, None); } #[tokio::test] async fn emit_with_data() { - let emitter = EventEmitter::new(); + let emitter = Emitter::new(); let mut receiver = emitter.subscribe(); - emitter.emit( - "sess-2".into(), - AgentEvent::Error { - error: AgentError::ToolExecution("something went wrong".into()), - }, - ); + emitter.emit("sess-2".into(), AgentEvent::Error { + error: Error::ToolExecution("something went wrong".into()), + }); let event = receiver.recv().await.unwrap(); assert!( @@ -81,7 +86,7 @@ mod tests { #[tokio::test] async fn multiple_subscribers() { - let emitter = EventEmitter::new(); + let emitter = Emitter::new(); let mut rx1 = emitter.subscribe(); let mut rx2 = emitter.subscribe(); @@ -99,36 +104,39 @@ mod tests { #[test] fn emit_without_subscribers_does_not_panic() { - let emitter = EventEmitter::new(); - emitter.emit( - "sess-4".into(), - AgentEvent::Error { - error: AgentError::ToolExecution("test".into()), - }, - ); + let emitter = Emitter::new(); + emitter.emit("sess-4".into(), AgentEvent::Error { + error: Error::ToolExecution("test".into()), + }); } #[test] fn default_creates_emitter() { - let emitter = EventEmitter::default(); + let emitter = Emitter::default(); let _rx = emitter.subscribe(); } #[tokio::test] async fn forward_preserves_session_ids() { - let emitter = EventEmitter::new(); + let emitter = Emitter::new(); let mut receiver = emitter.subscribe(); emitter.forward(SessionEvent { - event: AgentEvent::SessionStarted, - timestamp: SystemTime::now(), - session_id: "child".into(), + event: AgentEvent::SessionStarted { + provider: Some("anthropic".into()), + model: Some("claude-opus".into()), + }, + timestamp: SystemTime::now(), + session_id: "child".into(), parent_session_id: Some("parent".into()), }); let event = receiver.recv().await.unwrap(); assert_eq!(event.session_id, "child"); assert_eq!(event.parent_session_id.as_deref(), Some("parent")); - assert!(matches!(event.event, AgentEvent::SessionStarted)); + assert!(matches!(event.event, AgentEvent::SessionStarted { + provider: Some(_), + model: Some(_), + })); } } diff --git a/lib/crates/fabro-agent/src/file_tracker.rs b/lib/crates/fabro-agent/src/file_tracker.rs index ed8cc04fd..10d6e274b 100644 --- a/lib/crates/fabro-agent/src/file_tracker.rs +++ b/lib/crates/fabro-agent/src/file_tracker.rs @@ -5,9 +5,9 @@ use fabro_llm::types::{ToolCall, ToolResult}; #[derive(Debug, Clone, Copy, Default)] struct FileOps { - read: bool, + read: bool, written: bool, - edited: bool, + edited: bool, } #[derive(Debug, Default)] diff --git a/lib/crates/fabro-agent/src/history.rs b/lib/crates/fabro-agent/src/history.rs index 7ed9f928f..74593dbc2 100644 --- a/lib/crates/fabro-agent/src/history.rs +++ b/lib/crates/fabro-agent/src/history.rs @@ -1,6 +1,7 @@ -use crate::types::Turn; use fabro_llm::types::{ContentPart, Message, Role}; +use crate::types::Turn; + #[derive(Debug, Clone, Default)] pub struct History { turns: Vec, @@ -25,7 +26,7 @@ impl History { let extracted_user_messages = extract_recent_user_messages(discarded, COMPACTION_USER_MESSAGE_TOKEN_BUDGET); self.turns.push(Turn::System { - content: summary, + content: summary, timestamp: std::time::SystemTime::now(), }); self.turns.extend(extracted_user_messages); @@ -33,10 +34,11 @@ impl History { self.strip_opaque_provider_items(); } - /// Remove provider-specific opaque items that are no longer valid after compaction. - /// OpenAI reasoning and message items are opaque round-trip data tied to specific API - /// responses; after compaction replaces their surrounding context with a summary, they - /// serve no purpose and can violate API constraints (reasoning must be followed by its + /// Remove provider-specific opaque items that are no longer valid after + /// compaction. OpenAI reasoning and message items are opaque round-trip + /// data tied to specific API responses; after compaction replaces their + /// surrounding context with a summary, they serve no purpose and can + /// violate API constraints (reasoning must be followed by its /// output, identified by the message item's `id`). fn strip_opaque_provider_items(&mut self) { for turn in &mut self.turns { @@ -70,9 +72,9 @@ impl History { parts.push(ContentPart::ToolCall(tc.clone())); } Message { - role: Role::Assistant, - content: parts, - name: None, + role: Role::Assistant, + content: parts, + name: None, tool_call_id: None, } } @@ -92,9 +94,9 @@ impl History { } Turn::System { content, .. } => Message::system(content), Turn::Steering { content, .. } => Message { - role: Role::User, - content: vec![ContentPart::text(content)], - name: None, + role: Role::User, + content: vec![ContentPart::text(content)], + name: None, tool_call_id: None, }, }) @@ -102,7 +104,8 @@ impl History { } } -/// Maximum token budget for user messages extracted from discarded turns during compaction. +/// Maximum token budget for user messages extracted from discarded turns during +/// compaction. const COMPACTION_USER_MESSAGE_TOKEN_BUDGET: usize = 20_000; /// Walk discarded turns in reverse, collecting `Turn::User` variants up to @@ -135,16 +138,18 @@ fn extract_recent_user_messages(discarded: Vec, token_budget: usize) -> Ve #[cfg(test)] mod tests { - use super::*; - use fabro_llm::types::{ThinkingData, ToolCall, ToolResult, Usage}; use std::time::SystemTime; + use fabro_llm::types::{ThinkingData, TokenCounts, ToolCall, ToolResult}; + + use super::*; + #[test] fn compact_replaces_old_turns_with_summary() { let mut history = History::default(); for i in 0..8 { history.push(Turn::User { - content: format!("msg {i}"), + content: format!("msg {i}"), timestamp: SystemTime::now(), }); } @@ -158,7 +163,7 @@ mod tests { let mut history = History::default(); for i in 0..3 { history.push(Turn::User { - content: format!("msg {i}"), + content: format!("msg {i}"), timestamp: SystemTime::now(), }); } @@ -171,7 +176,7 @@ mod tests { let mut history = History::default(); for i in 0..8 { history.push(Turn::User { - content: format!("msg {i}"), + content: format!("msg {i}"), timestamp: SystemTime::now(), }); } @@ -194,7 +199,7 @@ mod tests { let mut history = History::default(); for i in 0..6 { history.push(Turn::User { - content: format!("msg {i}"), + content: format!("msg {i}"), timestamp: SystemTime::now(), }); } @@ -215,7 +220,7 @@ mod tests { fn user_turn_maps_to_user_message() { let mut history = History::default(); history.push(Turn::User { - content: "Hello".into(), + content: "Hello".into(), timestamp: SystemTime::now(), }); let messages = history.convert_to_messages(); @@ -228,12 +233,12 @@ mod tests { fn assistant_turn_maps_to_assistant_message() { let mut history = History::default(); history.push(Turn::Assistant { - content: "Hi there".into(), - tool_calls: vec![], + content: "Hi there".into(), + tool_calls: vec![], provider_parts: vec![], - usage: Box::new(Usage::default()), - response_id: "resp_1".into(), - timestamp: SystemTime::now(), + usage: Box::new(TokenCounts::default()), + response_id: "resp_1".into(), + timestamp: SystemTime::now(), }); let messages = history.convert_to_messages(); assert_eq!(messages.len(), 1); @@ -246,12 +251,12 @@ mod tests { let mut history = History::default(); let tc = ToolCall::new("call_1", "read_file", serde_json::json!({"path": "foo.rs"})); history.push(Turn::Assistant { - content: "Let me read that".into(), - tool_calls: vec![tc], + content: "Let me read that".into(), + tool_calls: vec![tc], provider_parts: vec![], - usage: Box::new(Usage::default()), - response_id: "resp_2".into(), - timestamp: SystemTime::now(), + usage: Box::new(TokenCounts::default()), + response_id: "resp_2".into(), + timestamp: SystemTime::now(), }); let messages = history.convert_to_messages(); assert_eq!(messages[0].role, Role::Assistant); @@ -267,17 +272,17 @@ mod tests { fn assistant_turn_with_reasoning_in_provider_parts() { let mut history = History::default(); let thinking = ContentPart::Thinking(ThinkingData { - text: "Let me think about this...".into(), + text: "Let me think about this...".into(), signature: None, - redacted: false, + redacted: false, }); history.push(Turn::Assistant { - content: "The answer is 42".into(), - tool_calls: vec![], + content: "The answer is 42".into(), + tool_calls: vec![], provider_parts: vec![thinking], - usage: Box::new(Usage::default()), - response_id: "resp_3".into(), - timestamp: SystemTime::now(), + usage: Box::new(TokenCounts::default()), + response_id: "resp_3".into(), + timestamp: SystemTime::now(), }); let messages = history.convert_to_messages(); let thinking_parts: Vec<_> = messages[0] @@ -292,17 +297,17 @@ mod tests { fn thinking_with_signature_preserved_via_provider_parts() { let mut history = History::default(); let thinking = ContentPart::Thinking(ThinkingData { - text: "Let me think...".into(), + text: "Let me think...".into(), signature: Some("sig_abc123".into()), - redacted: false, + redacted: false, }); history.push(Turn::Assistant { - content: "The answer".into(), - tool_calls: vec![], + content: "The answer".into(), + tool_calls: vec![], provider_parts: vec![thinking], - usage: Box::new(Usage::default()), - response_id: "resp_4".into(), - timestamp: SystemTime::now(), + usage: Box::new(TokenCounts::default()), + response_id: "resp_4".into(), + timestamp: SystemTime::now(), }); let messages = history.convert_to_messages(); let thinking_parts: Vec<_> = messages[0] @@ -328,12 +333,12 @@ mod tests { }; let tc = ToolCall::new("call_1", "search", serde_json::json!({})); history.push(Turn::Assistant { - content: String::new(), - tool_calls: vec![tc], + content: String::new(), + tool_calls: vec![tc], provider_parts: vec![reasoning_item], - usage: Box::new(Usage::default()), - response_id: "resp_1".into(), - timestamp: SystemTime::now(), + usage: Box::new(TokenCounts::default()), + response_id: "resp_1".into(), + timestamp: SystemTime::now(), }); let messages = history.convert_to_messages(); assert_eq!(messages.len(), 1); @@ -349,7 +354,7 @@ mod tests { let mut history = History::default(); let result = ToolResult::success("call_1", serde_json::json!("file contents here")); history.push(Turn::ToolResults { - results: vec![result], + results: vec![result], timestamp: SystemTime::now(), }); let messages = history.convert_to_messages(); @@ -362,7 +367,7 @@ mod tests { fn system_turn_maps_to_system_message() { let mut history = History::default(); history.push(Turn::System { - content: "You are a coding assistant".into(), + content: "You are a coding assistant".into(), timestamp: SystemTime::now(), }); let messages = history.convert_to_messages(); @@ -375,7 +380,7 @@ mod tests { fn steering_turn_maps_to_user_message() { let mut history = History::default(); history.push(Turn::Steering { - content: "Focus on the main task".into(), + content: "Focus on the main task".into(), timestamp: SystemTime::now(), }); let messages = history.convert_to_messages(); @@ -389,17 +394,17 @@ mod tests { let mut history = History::default(); assert_eq!(history.turns().len(), 0); history.push(Turn::User { - content: "First".into(), + content: "First".into(), timestamp: SystemTime::now(), }); assert_eq!(history.turns().len(), 1); history.push(Turn::Assistant { - content: "Second".into(), - tool_calls: vec![], + content: "Second".into(), + tool_calls: vec![], provider_parts: vec![], - usage: Box::new(Usage::default()), - response_id: "resp_1".into(), - timestamp: SystemTime::now(), + usage: Box::new(TokenCounts::default()), + response_id: "resp_1".into(), + timestamp: SystemTime::now(), }); assert_eq!(history.turns().len(), 2); } @@ -408,32 +413,31 @@ mod tests { fn round_trip_preserves_content() { let mut history = History::default(); history.push(Turn::User { - content: "Hello".into(), + content: "Hello".into(), timestamp: SystemTime::now(), }); history.push(Turn::Assistant { - content: "Hi".into(), - tool_calls: vec![ToolCall::new( + content: "Hi".into(), + tool_calls: vec![ToolCall::new( "c1", "shell", serde_json::json!({"cmd": "ls"}), )], provider_parts: vec![ContentPart::Thinking(ThinkingData { - text: "thinking...".into(), + text: "thinking...".into(), signature: None, - redacted: false, + redacted: false, })], - usage: Box::new(Usage { + usage: Box::new(TokenCounts { input_tokens: 10, output_tokens: 5, - total_tokens: 15, ..Default::default() }), - response_id: "resp_1".into(), - timestamp: SystemTime::now(), + response_id: "resp_1".into(), + timestamp: SystemTime::now(), }); history.push(Turn::ToolResults { - results: vec![ToolResult::success( + results: vec![ToolResult::success( "c1", serde_json::json!("file1.rs\nfile2.rs"), )], @@ -451,11 +455,11 @@ mod tests { fn compact_strips_openai_reasoning_from_preserved_turns() { let mut history = History::default(); history.push(Turn::User { - content: "old msg".into(), + content: "old msg".into(), timestamp: SystemTime::now(), }); history.push(Turn::User { - content: "recent msg".into(), + content: "recent msg".into(), timestamp: SystemTime::now(), }); let reasoning = ContentPart::Other { @@ -464,17 +468,18 @@ mod tests { }; let tc = ToolCall::new("call_1", "search", serde_json::json!({})); history.push(Turn::Assistant { - content: "response".into(), - tool_calls: vec![tc], + content: "response".into(), + tool_calls: vec![tc], provider_parts: vec![reasoning], - usage: Box::new(Usage::default()), - response_id: "resp_1".into(), - timestamp: SystemTime::now(), + usage: Box::new(TokenCounts::default()), + response_id: "resp_1".into(), + timestamp: SystemTime::now(), }); history.compact(2, "Summary".into()); - // Layout: summary, extracted User("old msg"), preserved User("recent msg"), preserved Assistant + // Layout: summary, extracted User("old msg"), preserved User("recent msg"), + // preserved Assistant let assistant_turn = &history.turns()[3]; if let Turn::Assistant { provider_parts, @@ -498,30 +503,31 @@ mod tests { fn compact_preserves_anthropic_thinking_blocks() { let mut history = History::default(); history.push(Turn::User { - content: "old msg".into(), + content: "old msg".into(), timestamp: SystemTime::now(), }); history.push(Turn::User { - content: "recent msg".into(), + content: "recent msg".into(), timestamp: SystemTime::now(), }); let thinking = ContentPart::Thinking(ThinkingData { - text: "deep thought".into(), + text: "deep thought".into(), signature: Some("sig_xyz".into()), - redacted: false, + redacted: false, }); history.push(Turn::Assistant { - content: "answer".into(), - tool_calls: vec![], + content: "answer".into(), + tool_calls: vec![], provider_parts: vec![thinking], - usage: Box::new(Usage::default()), - response_id: "resp_1".into(), - timestamp: SystemTime::now(), + usage: Box::new(TokenCounts::default()), + response_id: "resp_1".into(), + timestamp: SystemTime::now(), }); history.compact(2, "Summary".into()); - // Layout: summary, extracted User("old msg"), preserved User("recent msg"), preserved Assistant + // Layout: summary, extracted User("old msg"), preserved User("recent msg"), + // preserved Assistant let assistant_turn = &history.turns()[3]; if let Turn::Assistant { provider_parts, .. } = assistant_turn { assert_eq!( @@ -539,21 +545,21 @@ mod tests { fn compact_strips_reasoning_from_all_preserved_assistant_turns() { let mut history = History::default(); history.push(Turn::User { - content: "old msg".into(), + content: "old msg".into(), timestamp: SystemTime::now(), }); // Two assistant turns that will both be preserved for i in 0..2 { history.push(Turn::Assistant { - content: format!("response {i}"), - tool_calls: vec![], + content: format!("response {i}"), + tool_calls: vec![], provider_parts: vec![ContentPart::Other { kind: ContentPart::OPENAI_REASONING.into(), data: serde_json::json!({"type": "reasoning", "id": format!("rs_{i}")}), }], - usage: Box::new(Usage::default()), - response_id: format!("resp_{i}"), - timestamp: SystemTime::now(), + usage: Box::new(TokenCounts::default()), + response_id: format!("resp_{i}"), + timestamp: SystemTime::now(), }); } @@ -573,19 +579,19 @@ mod tests { fn extract_recent_user_messages_collects_in_chronological_order() { let turns = vec![ Turn::User { - content: "first".into(), + content: "first".into(), timestamp: SystemTime::now(), }, Turn::Assistant { - content: "reply".into(), - tool_calls: vec![], + content: "reply".into(), + tool_calls: vec![], provider_parts: vec![], - usage: Box::new(Usage::default()), - response_id: "r1".into(), - timestamp: SystemTime::now(), + usage: Box::new(TokenCounts::default()), + response_id: "r1".into(), + timestamp: SystemTime::now(), }, Turn::User { - content: "second".into(), + content: "second".into(), timestamp: SystemTime::now(), }, ]; @@ -599,15 +605,16 @@ mod tests { fn extract_recent_user_messages_respects_token_budget() { let turns = vec![ Turn::User { - content: "a".repeat(100), + content: "a".repeat(100), timestamp: SystemTime::now(), }, Turn::User { - content: "b".repeat(100), + content: "b".repeat(100), timestamp: SystemTime::now(), }, ]; - // Budget of 30 tokens = 120 chars; second message (100 chars) fits, first would exceed + // Budget of 30 tokens = 120 chars; second message (100 chars) fits, first would + // exceed let extracted = extract_recent_user_messages(turns, 30); assert_eq!(extracted.len(), 1); assert!(matches!(&extracted[0], Turn::User { content, .. } if content.starts_with('b'))); @@ -617,19 +624,19 @@ mod tests { fn compact_extracts_only_user_turns_from_discarded() { let mut history = History::default(); history.push(Turn::User { - content: "user msg".into(), + content: "user msg".into(), timestamp: SystemTime::now(), }); history.push(Turn::Assistant { - content: "assistant msg".into(), - tool_calls: vec![], + content: "assistant msg".into(), + tool_calls: vec![], provider_parts: vec![], - usage: Box::new(Usage::default()), - response_id: "r1".into(), - timestamp: SystemTime::now(), + usage: Box::new(TokenCounts::default()), + response_id: "r1".into(), + timestamp: SystemTime::now(), }); history.push(Turn::User { - content: "preserved".into(), + content: "preserved".into(), timestamp: SystemTime::now(), }); diff --git a/lib/crates/fabro-agent/src/lib.rs b/lib/crates/fabro-agent/src/lib.rs index 67ab43544..68839f033 100644 --- a/lib/crates/fabro-agent/src/lib.rs +++ b/lib/crates/fabro-agent/src/lib.rs @@ -27,12 +27,12 @@ pub mod types; pub mod v4a_patch; pub use agent_profile::AgentProfile; -pub use config::{SessionConfig, ToolApprovalAdapter, ToolHookCallback, ToolHookDecision}; +pub use config::{SessionOptions, ToolApprovalAdapter, ToolHookCallback, ToolHookDecision}; #[cfg(feature = "docker")] -pub use docker_sandbox::{DockerSandbox, DockerSandboxConfig}; -pub use error::{AbortReason, AgentError}; -pub use event::EventEmitter; -pub use fabro_mcp::config::McpServerConfig; +pub use docker_sandbox::{DockerSandbox, DockerSandboxOptions}; +pub use error::{Error, InterruptReason, Result}; +pub use event::Emitter; +pub use fabro_mcp::config::McpServerSettings; pub use history::History; pub use local_sandbox::LocalSandbox; pub use loop_detection::detect_loop; @@ -40,8 +40,8 @@ pub use memory::discover_memory; pub use profiles::{AnthropicProfile, EnvContext, GeminiProfile, OpenAiProfile}; pub use read_before_write_sandbox::ReadBeforeWriteSandbox; pub use sandbox::{ - DirEntry, ExecResult, GrepOptions, Sandbox, SandboxEvent, SandboxEventCallback, WorktreeConfig, - WorktreeEvent, WorktreeEventCallback, WorktreeSandbox, format_lines_numbered, shell_quote, + DirEntry, ExecResult, GrepOptions, Sandbox, SandboxEvent, SandboxEventCallback, WorktreeEvent, + WorktreeEventCallback, WorktreeOptions, WorktreeSandbox, format_lines_numbered, shell_quote, }; pub use session::Session; pub use skills::Skill; diff --git a/lib/crates/fabro-agent/src/loop_detection.rs b/lib/crates/fabro-agent/src/loop_detection.rs index e00940139..926bd2a3c 100644 --- a/lib/crates/fabro-agent/src/loop_detection.rs +++ b/lib/crates/fabro-agent/src/loop_detection.rs @@ -1,8 +1,9 @@ -use crate::history::History; -use crate::types::Turn; use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; +use crate::history::History; +use crate::types::Turn; + fn tool_call_signature(name: &str, arguments: &serde_json::Value) -> u64 { let mut hasher = DefaultHasher::new(); name.hash(&mut hasher); @@ -23,7 +24,8 @@ fn extract_signatures_from_assistant(turn: &Turn) -> Vec { #[must_use] pub fn detect_loop(history: &History, window_size: usize) -> bool { - // Extract tool call signatures from the last N assistant turns that have tool calls + // Extract tool call signatures from the last N assistant turns that have tool + // calls let turns = history.turns(); let mut signatures: Vec = Vec::new(); @@ -93,18 +95,20 @@ fn is_repeating_pattern(signatures: &[u64], pattern_len: usize) -> bool { #[cfg(test)] mod tests { - use super::*; - use fabro_llm::types::{ToolCall, Usage}; use std::time::SystemTime; + use fabro_llm::types::{TokenCounts, ToolCall}; + + use super::*; + fn assistant_with_tool(name: &str, args: serde_json::Value) -> Turn { Turn::Assistant { - content: String::new(), - tool_calls: vec![ToolCall::new("call_1", name, args)], + content: String::new(), + tool_calls: vec![ToolCall::new("call_1", name, args)], provider_parts: vec![], - usage: Box::new(Usage::default()), - response_id: "resp".into(), - timestamp: SystemTime::now(), + usage: Box::new(TokenCounts::default()), + response_id: "resp".into(), + timestamp: SystemTime::now(), } } @@ -262,15 +266,15 @@ mod tests { fn user_turns_are_ignored() { let mut history = History::default(); history.push(Turn::User { - content: "hello".into(), + content: "hello".into(), timestamp: SystemTime::now(), }); history.push(Turn::User { - content: "hello".into(), + content: "hello".into(), timestamp: SystemTime::now(), }); history.push(Turn::User { - content: "hello".into(), + content: "hello".into(), timestamp: SystemTime::now(), }); assert!(!detect_loop(&history, 10)); diff --git a/lib/crates/fabro-agent/src/mcp_integration.rs b/lib/crates/fabro-agent/src/mcp_integration.rs index 33c97d173..65d8a3fef 100644 --- a/lib/crates/fabro-agent/src/mcp_integration.rs +++ b/lib/crates/fabro-agent/src/mcp_integration.rs @@ -5,7 +5,8 @@ use fabro_mcp::connection_manager::{McpConnectionManager, call_result_to_string} use crate::tool_registry::RegisteredTool; -/// Create `RegisteredTool` instances for every tool exposed by connected MCP servers. +/// Create `RegisteredTool` instances for every tool exposed by connected MCP +/// servers. pub fn make_mcp_tools(manager: &Arc) -> Vec { manager .all_tools() @@ -17,11 +18,11 @@ pub fn make_mcp_tools(manager: &Arc) -> Vec) -> Vec McpServerConfig { + fn test_server_config() -> McpServerSettings { let test_server = format!( "{}/../fabro-mcp/tests/test_mcp_server.py", env!("CARGO_MANIFEST_DIR") ); - McpServerConfig { - name: "test-echo".into(), - transport: McpTransport::Stdio { + McpServerSettings { + name: "test-echo".into(), + transport: McpTransport::Stdio { command: vec!["python3".into(), test_server], - env: HashMap::new(), + env: HashMap::new(), }, startup_timeout_secs: 10, - tool_timeout_secs: 30, + tool_timeout_secs: 30, } } diff --git a/lib/crates/fabro-agent/src/memory.rs b/lib/crates/fabro-agent/src/memory.rs index 4a46d4f44..8c5554537 100644 --- a/lib/crates/fabro-agent/src/memory.rs +++ b/lib/crates/fabro-agent/src/memory.rs @@ -1,8 +1,10 @@ -use crate::sandbox::Sandbox; -use fabro_model::Provider; use std::collections::HashSet; + +use fabro_model::Provider; use tracing::{debug, info, warn}; +use crate::sandbox::Sandbox; + const BUDGET_BYTES: usize = 32768; pub async fn discover_memory( @@ -112,11 +114,12 @@ fn truncate_to_budget(content: &str, budget: usize) -> String { #[cfg(test)] mod tests { + use std::collections::HashMap; + use std::sync::Arc; + use super::*; use crate::sandbox::Sandbox; use crate::test_support::MockSandbox; - use std::collections::HashMap; - use std::sync::Arc; #[tokio::test] async fn discovers_agents_md() { diff --git a/lib/crates/fabro-agent/src/profiles/anthropic.rs b/lib/crates/fabro-agent/src/profiles/anthropic.rs index 3f44aa01c..7992e6eb7 100644 --- a/lib/crates/fabro-agent/src/profiles/anthropic.rs +++ b/lib/crates/fabro-agent/src/profiles/anthropic.rs @@ -1,14 +1,13 @@ +use fabro_model::Provider; + +use super::EnvContext; use crate::agent_profile::AgentProfile; -use crate::config::SessionConfig; -use crate::profiles::BaseProfile; -use crate::profiles::assemble_system_prompt; +use crate::config::SessionOptions; +use crate::profiles::{BaseProfile, assemble_system_prompt}; use crate::sandbox::Sandbox; use crate::skills::Skill; use crate::tool_registry::ToolRegistry; use crate::tools::{WebFetchSummarizer, make_edit_file_tool, register_core_tools}; -use fabro_model::Provider; - -use super::EnvContext; pub struct AnthropicProfile { base: BaseProfile, @@ -25,9 +24,9 @@ impl AnthropicProfile { model: impl Into, summarizer: Option, ) -> Self { - let config = SessionConfig { + let config = SessionOptions { default_command_timeout_ms: 120_000, - ..SessionConfig::default() + ..SessionOptions::default() }; let mut registry = ToolRegistry::new(); @@ -166,11 +165,13 @@ in the project. Keep changes minimal and focused on the task."; #[cfg(test)] mod tests { + use std::sync::Arc; + + use tokio::sync::Mutex as AsyncMutex; + use super::*; use crate::subagent::{SessionFactory, SubAgentManager}; use crate::test_support::MockSandbox; - use std::sync::Arc; - use tokio::sync::Mutex as AsyncMutex; #[test] fn anthropic_profile_identity() { @@ -250,12 +251,12 @@ mod tests { let profile = AnthropicProfile::new("claude-opus-4-6"); let env = MockSandbox::linux(); let ctx = EnvContext { - git_branch: Some("feature-branch".into()), - is_git_repo: true, - current_date: "2026-02-20".into(), - model: "claude-opus-4-6".into(), - knowledge_cutoff: "May 2025".into(), - git_status_short: None, + git_branch: Some("feature-branch".into()), + is_git_repo: true, + current_date: "2026-02-20".into(), + model: "claude-opus-4-6".into(), + knowledge_cutoff: "May 2025".into(), + git_status_short: None, git_recent_commits: None, }; let prompt = profile.build_system_prompt(&env, &ctx, &[], None, &[]); diff --git a/lib/crates/fabro-agent/src/profiles/gemini.rs b/lib/crates/fabro-agent/src/profiles/gemini.rs index 91691d402..85e75f1ea 100644 --- a/lib/crates/fabro-agent/src/profiles/gemini.rs +++ b/lib/crates/fabro-agent/src/profiles/gemini.rs @@ -1,7 +1,9 @@ +use fabro_model::Provider; + +use super::EnvContext; use crate::agent_profile::AgentProfile; -use crate::config::SessionConfig; -use crate::profiles::BaseProfile; -use crate::profiles::assemble_system_prompt; +use crate::config::SessionOptions; +use crate::profiles::{BaseProfile, assemble_system_prompt}; use crate::sandbox::Sandbox; use crate::skills::Skill; use crate::tool_registry::ToolRegistry; @@ -9,9 +11,6 @@ use crate::tools::{ WebFetchSummarizer, make_edit_file_tool, make_list_dir_tool, make_read_many_files_tool, register_core_tools, }; -use fabro_model::Provider; - -use super::EnvContext; pub struct GeminiProfile { base: BaseProfile, @@ -28,7 +27,7 @@ impl GeminiProfile { model: impl Into, summarizer: Option, ) -> Self { - let config = SessionConfig::default(); + let config = SessionOptions::default(); let mut registry = ToolRegistry::new(); register_core_tools(&mut registry, &config, summarizer); @@ -201,11 +200,13 @@ in the project."; #[cfg(test)] mod tests { + use std::sync::Arc; + + use tokio::sync::Mutex as AsyncMutex; + use super::*; use crate::subagent::{SessionFactory, SubAgentManager}; use crate::test_support::MockSandbox; - use std::sync::Arc; - use tokio::sync::Mutex as AsyncMutex; #[test] fn gemini_profile_identity() { diff --git a/lib/crates/fabro-agent/src/profiles/mod.rs b/lib/crates/fabro-agent/src/profiles/mod.rs index 742099aa0..717e22914 100644 --- a/lib/crates/fabro-agent/src/profiles/mod.rs +++ b/lib/crates/fabro-agent/src/profiles/mod.rs @@ -3,40 +3,42 @@ pub mod gemini; pub mod openai; pub use anthropic::AnthropicProfile; +use fabro_model::Provider; pub use gemini::GeminiProfile; pub use openai::OpenAiProfile; use crate::sandbox::Sandbox; use crate::skills::{Skill, format_skills_prompt_section}; use crate::tool_registry::ToolRegistry; -use fabro_model::Provider; /// Common fields shared by all provider profiles. /// -/// Each concrete profile embeds this struct and delegates `provider()`, `model()`, -/// `tool_registry()`, and `tool_registry_mut()` to it. +/// Each concrete profile embeds this struct and delegates `provider()`, +/// `model()`, `tool_registry()`, and `tool_registry_mut()` to it. pub struct BaseProfile { pub provider: Provider, - pub model: String, + pub model: String, pub registry: ToolRegistry, } /// Additional context for building environment blocks #[derive(Default)] pub struct EnvContext { - pub git_branch: Option, - pub is_git_repo: bool, - pub current_date: String, - pub model: String, - pub knowledge_cutoff: String, - pub git_status_short: Option, + pub git_branch: Option, + pub is_git_repo: bool, + pub current_date: String, + pub model: String, + pub knowledge_cutoff: String, + pub git_status_short: Option, pub git_recent_commits: Option, } -/// Assembles a complete system prompt from a core prompt template and standard sections. +/// Assembles a complete system prompt from a core prompt template and standard +/// sections. /// -/// The `core_prompt` should contain `{env_block}` as a placeholder where the environment -/// context block will be inserted. Project docs and user instructions are appended at the end. +/// The `core_prompt` should contain `{env_block}` as a placeholder where the +/// environment context block will be inserted. Project docs and user +/// instructions are appended at the end. #[must_use] pub fn assemble_system_prompt( core_prompt: &str, @@ -131,12 +133,12 @@ mod tests { fn env_context_block_with_extra_context() { let env = MockSandbox::linux(); let ctx = EnvContext { - git_branch: Some("main".into()), - is_git_repo: true, - current_date: "2026-02-20".into(), - model: "claude-opus-4-6".into(), - knowledge_cutoff: "May 2025".into(), - git_status_short: None, + git_branch: Some("main".into()), + is_git_repo: true, + current_date: "2026-02-20".into(), + model: "claude-opus-4-6".into(), + knowledge_cutoff: "May 2025".into(), + git_status_short: None, git_recent_commits: None, }; let block = build_env_context_block_with(&env, &ctx); diff --git a/lib/crates/fabro-agent/src/profiles/openai.rs b/lib/crates/fabro-agent/src/profiles/openai.rs index c3f71f2a7..83e7a5949 100644 --- a/lib/crates/fabro-agent/src/profiles/openai.rs +++ b/lib/crates/fabro-agent/src/profiles/openai.rs @@ -1,15 +1,14 @@ +use fabro_model::Provider; + +use super::EnvContext; use crate::agent_profile::AgentProfile; -use crate::config::SessionConfig; -use crate::profiles::BaseProfile; -use crate::profiles::assemble_system_prompt; +use crate::config::SessionOptions; +use crate::profiles::{BaseProfile, assemble_system_prompt}; use crate::sandbox::Sandbox; use crate::skills::Skill; use crate::tool_registry::ToolRegistry; use crate::tools::{WebFetchSummarizer, register_core_tools}; use crate::v4a_patch::make_apply_patch_tool; -use fabro_model::Provider; - -use super::EnvContext; pub struct OpenAiProfile { base: BaseProfile, @@ -26,7 +25,7 @@ impl OpenAiProfile { model: impl Into, summarizer: Option, ) -> Self { - let config = SessionConfig::default(); + let config = SessionOptions::default(); let mut registry = ToolRegistry::new(); register_core_tools(&mut registry, &config, summarizer); @@ -199,11 +198,13 @@ in the project."); #[cfg(test)] mod tests { + use std::sync::Arc; + + use tokio::sync::Mutex as AsyncMutex; + use super::*; use crate::subagent::{SessionFactory, SubAgentManager}; use crate::test_support::MockSandbox; - use std::sync::Arc; - use tokio::sync::Mutex as AsyncMutex; #[test] fn openai_profile_identity() { diff --git a/lib/crates/fabro-agent/src/sandbox.rs b/lib/crates/fabro-agent/src/sandbox.rs index 9efecd005..6df954184 100644 --- a/lib/crates/fabro-agent/src/sandbox.rs +++ b/lib/crates/fabro-agent/src/sandbox.rs @@ -1,9 +1,8 @@ // Re-export all sandbox types from fabro-sandbox. -pub use fabro_sandbox::{ - DirEntry, ExecResult, GrepOptions, Sandbox, SandboxEvent, SandboxEventCallback, WorktreeConfig, - WorktreeEvent, WorktreeEventCallback, WorktreeSandbox, format_lines_numbered, shell_quote, -}; - // Re-export the delegate_sandbox! macro at crate root so existing // `crate::delegate_sandbox!` invocations continue to work. -pub use fabro_sandbox::delegate_sandbox; +pub use fabro_sandbox::{ + DirEntry, ExecResult, GrepOptions, Sandbox, SandboxEvent, SandboxEventCallback, WorktreeEvent, + WorktreeEventCallback, WorktreeOptions, WorktreeSandbox, delegate_sandbox, + format_lines_numbered, shell_quote, +}; diff --git a/lib/crates/fabro-agent/src/session.rs b/lib/crates/fabro-agent/src/session.rs index d01979e56..f50eec2ba 100644 --- a/lib/crates/fabro-agent/src/session.rs +++ b/lib/crates/fabro-agent/src/session.rs @@ -1,8 +1,28 @@ +use std::collections::{HashMap, VecDeque}; +use std::sync::{Arc, Mutex}; +use std::time::SystemTime; + +use fabro_llm::client::Client; +use fabro_llm::error::ProviderErrorKind; +use fabro_llm::generate::StreamAccumulator; +use fabro_llm::provider::StreamEventStream; +use fabro_llm::types::{ + ContentPart, Message, ReasoningEffort, Request, RetryPolicy, StreamEvent, ToolChoice, +}; +use fabro_llm::{Error as LlmError, retry}; +use fabro_mcp::config::{McpServerSettings, McpTransport}; +use fabro_mcp::connection_manager::McpConnectionManager; +use futures::StreamExt; +use tokio::sync::{Mutex as AsyncMutex, broadcast}; +use tokio::time; +use tokio_util::sync::CancellationToken; +use tracing::{debug, info, warn}; + use crate::agent_profile::AgentProfile; use crate::compaction::{check_context_usage, compact_context}; -use crate::config::SessionConfig; -use crate::error::{AbortReason, AgentError}; -use crate::event::EventEmitter; +use crate::config::SessionOptions; +use crate::error::{Error, InterruptReason}; +use crate::event::Emitter; use crate::file_tracker::FileTracker; use crate::history::History; use crate::loop_detection::detect_loop; @@ -16,44 +36,26 @@ use crate::skills::{ use crate::subagent::{SubAgentCallbackEvent, SubAgentEventCallback, SubAgentManager}; use crate::tool_execution::execute_tool_calls; use crate::types::{AgentEvent, SessionEvent, SessionState, Turn}; -use fabro_llm::client::Client; -use fabro_llm::error::{ProviderErrorKind, SdkError}; -use fabro_llm::generate::StreamAccumulator; -use fabro_llm::provider::StreamEventStream; -use fabro_llm::retry; -use fabro_llm::types::{ - ContentPart, Message, ReasoningEffort, Request, RetryPolicy, StreamEvent, ToolChoice, -}; -use fabro_mcp::config::{McpServerConfig, McpTransport}; -use fabro_mcp::connection_manager::McpConnectionManager; -use futures::StreamExt; -use std::collections::{HashMap, VecDeque}; -use std::sync::{Arc, Mutex}; -use std::time::SystemTime; -use tokio::sync::{Mutex as AsyncMutex, broadcast}; -use tokio::time; -use tokio_util::sync::CancellationToken; -use tracing::{debug, info, warn}; pub struct Session { - id: String, - config: SessionConfig, - history: History, - event_emitter: EventEmitter, - state: SessionState, - llm_client: Client, + id: String, + config: SessionOptions, + history: History, + event_emitter: Emitter, + state: SessionState, + llm_client: Client, provider_profile: Arc, - sandbox: Arc, - steering_queue: Arc>>, - followup_queue: Arc>>, - cancel_token: CancellationToken, - abort_reason: Arc>>, - memory: Vec, - env_context: EnvContext, - skills: Vec, - system_prompt: String, - file_tracker: FileTracker, - tool_env: Option>, + sandbox: Arc, + steering_queue: Arc>>, + followup_queue: Arc>>, + cancel_token: CancellationToken, + interrupt_reason: Arc>>, + memory: Vec, + env_context: EnvContext, + skills: Vec, + system_prompt: String, + file_tracker: FileTracker, + tool_env: Option>, subagent_manager: Option>>, } @@ -63,14 +65,14 @@ impl Session { llm_client: Client, provider_profile: Arc, sandbox: Arc, - config: SessionConfig, + config: SessionOptions, subagent_manager: Option>>, ) -> Self { Self { id: uuid::Uuid::new_v4().to_string(), config, history: History::default(), - event_emitter: EventEmitter::new(), + event_emitter: Emitter::new(), state: SessionState::Idle, llm_client, provider_profile, @@ -78,7 +80,7 @@ impl Session { steering_queue: Arc::new(Mutex::new(VecDeque::new())), followup_queue: Arc::new(Mutex::new(VecDeque::new())), cancel_token: CancellationToken::new(), - abort_reason: Arc::new(Mutex::new(None)), + interrupt_reason: Arc::new(Mutex::new(None)), memory: Vec::new(), env_context: EnvContext::default(), skills: Vec::new(), @@ -98,11 +100,14 @@ impl Session { &self.id } - /// Initialize session by discovering project docs and capturing environment context. - /// Call before `process_input`. + /// Initialize session by discovering project docs and capturing environment + /// context. Call before `process_input`. pub async fn initialize(&mut self) { self.event_emitter - .emit(self.id.clone(), AgentEvent::SessionStarted); + .emit(self.id.clone(), AgentEvent::SessionStarted { + provider: Some(self.provider_profile.provider().to_string()), + model: Some(self.provider_profile.model().to_string()), + }); let doc_root = self .config @@ -121,8 +126,9 @@ impl Session { let skill_dirs = if let Some(dirs) = &self.config.skill_dirs { dirs.clone() } else { - let home = dirs::home_dir().map(|p| p.to_string_lossy().to_string()); - default_skill_dirs(home.as_deref(), self.config.git_root.as_deref()) + let skills_dir = fabro_util::Home::from_env().skills_dir(); + let skills_str = skills_dir.to_string_lossy().to_string(); + default_skill_dirs(Some(&skills_str), self.config.git_root.as_deref()) }; self.skills = discover_skills(self.sandbox.as_ref(), &skill_dirs).await; debug!(skill_count = self.skills.len(), "Skills discovered"); @@ -149,22 +155,18 @@ impl Session { for (server_name, result) in &results { match result { Ok(tool_count) => { - self.event_emitter.emit( - self.id.clone(), - AgentEvent::McpServerReady { + self.event_emitter + .emit(self.id.clone(), AgentEvent::McpServerReady { server_name: server_name.clone(), - tool_count: *tool_count, - }, - ); + tool_count: *tool_count, + }); } Err(e) => { - self.event_emitter.emit( - self.id.clone(), - AgentEvent::McpServerFailed { + self.event_emitter + .emit(self.id.clone(), AgentEvent::McpServerFailed { server_name: server_name.clone(), - error: e.to_string(), - }, - ); + error: e.to_string(), + }); } } } @@ -196,9 +198,10 @@ impl Session { ); } - /// Resolve `McpTransport::Sandbox` configs by starting the MCP server inside the - /// sandbox and rewriting the transport to `Http` with the sandbox's preview URL. - async fn resolve_sandbox_mcp_servers(&self) -> Vec { + /// Resolve `McpTransport::Sandbox` configs by starting the MCP server + /// inside the sandbox and rewriting the transport to `Http` with the + /// sandbox's preview URL. + async fn resolve_sandbox_mcp_servers(&self) -> Vec { let mut resolved = Vec::with_capacity(self.config.mcp_servers.len()); for config in &self.config.mcp_servers { @@ -212,11 +215,11 @@ impl Session { url = %url, "Sandbox MCP server started, connecting via HTTP" ); - resolved.push(McpServerConfig { - name: config.name.clone(), - transport: McpTransport::Http { url, headers }, + resolved.push(McpServerSettings { + name: config.name.clone(), + transport: McpTransport::Http { url, headers }, startup_timeout_secs: config.startup_timeout_secs, - tool_timeout_secs: config.tool_timeout_secs, + tool_timeout_secs: config.tool_timeout_secs, }); } Err(e) => { @@ -225,13 +228,11 @@ impl Session { error = %e, "Failed to start sandbox MCP server" ); - self.event_emitter.emit( - self.id.clone(), - AgentEvent::McpServerFailed { + self.event_emitter + .emit(self.id.clone(), AgentEvent::McpServerFailed { server_name: config.name.clone(), - error: e, - }, - ); + error: e, + }); } } } @@ -242,7 +243,8 @@ impl Session { resolved } - /// Start an MCP server inside the sandbox and return (url, headers) for HTTP connection. + /// Start an MCP server inside the sandbox and return (url, headers) for + /// HTTP connection. async fn start_sandbox_mcp_server( &self, command: &[String], @@ -294,7 +296,8 @@ impl Session { )); } - // Get the preview URL for the port, or fall back to localhost for local sandboxes + // Get the preview URL for the port, or fall back to localhost for local + // sandboxes if let Some(url_and_headers) = sandbox.get_preview_url(port).await? { Ok(url_and_headers) } else { @@ -380,20 +383,20 @@ impl Session { .push_back(message); } - pub fn abort(&self) { - self.set_abort_reason(AbortReason::Cancelled); + pub fn interrupt(&self) { + self.set_interrupt_reason(InterruptReason::Cancelled); self.cancel_token.cancel(); } - /// Returns a handle that can set the abort reason from another task. + /// Returns a handle that can set the interrupt reason from another task. #[must_use] - pub fn abort_reason_handle(&self) -> Arc>> { - self.abort_reason.clone() + pub fn interrupt_reason_handle(&self) -> Arc>> { + self.interrupt_reason.clone() } - fn set_abort_reason(&self, reason: AbortReason) { + fn set_interrupt_reason(&self, reason: InterruptReason) { let mut guard = self - .abort_reason + .interrupt_reason .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); if guard.is_none() { @@ -401,27 +404,24 @@ impl Session { } } - fn aborted_error(&self) -> AgentError { + fn interrupted_error(&self) -> Error { let reason = self - .abort_reason + .interrupt_reason .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .clone() - .unwrap_or(AbortReason::Cancelled); - AgentError::Aborted(reason) + .unwrap_or(InterruptReason::Cancelled); + Error::Interrupted(reason) } - fn emit_llm_error(&mut self, err: SdkError) -> AgentError { - self.event_emitter.emit( - self.id.clone(), - AgentEvent::Error { - error: AgentError::Llm(err.clone()), - }, - ); + fn emit_llm_error(&mut self, err: LlmError) -> Error { + self.event_emitter.emit(self.id.clone(), AgentEvent::Error { + error: Error::Llm(err.clone()), + }); if is_auth_error(&err) { self.transition(SessionState::Closed); } - AgentError::Llm(err) + Error::Llm(err) } async fn open_stream_with_retry( @@ -429,7 +429,7 @@ impl Session { client: &Client, request: &Request, retry_policy: &RetryPolicy, - ) -> Result { + ) -> Result { let stream_result = retry::retry(retry_policy, || { let client = client.clone(); let request = request.clone(); @@ -458,8 +458,8 @@ impl Session { self.cancel_token.clone() } - /// Build a callback that forwards sub-agent lifecycle and child session events - /// through this session's emitter. + /// Build a callback that forwards sub-agent lifecycle and child session + /// events through this session's emitter. #[must_use] pub fn sub_agent_event_callback(&self) -> SubAgentEventCallback { let emitter = self.event_emitter.clone(); @@ -488,7 +488,7 @@ impl Session { /// - Thinking → Closed (emits SessionEnded) /// - Executing → Closed (emits SessionEnded) /// - Idle → Closed (emits SessionEnded) - /// - any → Closed (abort/error — emits SessionEnded) + /// - any → Closed (interrupt/error — emits SessionEnded) fn transition(&mut self, to: SessionState) { let from = self.state; if from == to { @@ -556,15 +556,15 @@ impl Session { &self.file_tracker } - pub async fn process_input(&mut self, input: &str) -> Result<(), AgentError> { + pub async fn process_input(&mut self, input: &str) -> Result<(), Error> { if self.state == SessionState::Closed { - return Err(AgentError::SessionClosed); + return Err(Error::SessionClosed); } // Spawn wall-clock timeout task if configured let timer_handle = self.config.wall_clock_timeout.map(|duration| { let token = self.cancel_token.clone(); - let reason_handle = self.abort_reason.clone(); + let reason_handle = self.interrupt_reason.clone(); tokio::spawn(async move { time::sleep(duration).await; { @@ -572,7 +572,7 @@ impl Session { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); if guard.is_none() { - *guard = Some(AbortReason::WallClockTimeout); + *guard = Some(InterruptReason::WallClockTimeout); } } token.cancel(); @@ -597,7 +597,7 @@ impl Session { } } - // Abort the timer so it doesn't fire after we're done + // Stop the timer so it doesn't fire after we're done. if let Some(handle) = timer_handle { handle.abort(); } @@ -610,11 +610,11 @@ impl Session { result } - async fn run_single_input(&mut self, input: &str) -> Result<(), AgentError> { + async fn run_single_input(&mut self, input: &str) -> Result<(), Error> { const STREAM_CONSUME_RETRIES: usize = 3; if self.state == SessionState::Closed { - return Err(AgentError::SessionClosed); + return Err(Error::SessionClosed); } self.transition(SessionState::Thinking); @@ -622,33 +622,29 @@ impl Session { // Expand skill references in input let expanded = if self.skills.is_empty() { ExpandedInput { - text: input.to_string(), + text: input.to_string(), skill_name: None, } } else { - expand_skill(&self.skills, input).map_err(AgentError::InvalidState)? + expand_skill(&self.skills, input).map_err(Error::InvalidState)? }; if let Some(ref name) = expanded.skill_name { - self.event_emitter.emit( - self.id.clone(), - AgentEvent::SkillExpanded { + self.event_emitter + .emit(self.id.clone(), AgentEvent::SkillExpanded { skill_name: name.clone(), - }, - ); + }); } let expanded_input = expanded.text; // Append user turn and emit event self.history.push(Turn::User { - content: expanded_input.clone(), + content: expanded_input.clone(), timestamp: SystemTime::now(), }); - self.event_emitter.emit( - self.id.clone(), - AgentEvent::UserInput { + self.event_emitter + .emit(self.id.clone(), AgentEvent::UserInput { text: expanded_input.clone(), - }, - ); + }); // Drain steering queue before first LLM call self.drain_steering(); @@ -660,30 +656,26 @@ impl Session { if self.config.max_tool_rounds_per_input > 0 && round_count >= self.config.max_tool_rounds_per_input { - self.event_emitter.emit( - self.id.clone(), - AgentEvent::TurnLimitReached { + self.event_emitter + .emit(self.id.clone(), AgentEvent::TurnLimitReached { max_turns: self.config.max_tool_rounds_per_input, - }, - ); + }); break; } // Check max_turns if self.config.max_turns > 0 && self.history.turns().len() >= self.config.max_turns { - self.event_emitter.emit( - self.id.clone(), - AgentEvent::TurnLimitReached { + self.event_emitter + .emit(self.id.clone(), AgentEvent::TurnLimitReached { max_turns: self.config.max_turns, - }, - ); + }); break; } // Check cancellation if self.cancel_token.is_cancelled() { self.close(); - return Err(self.aborted_error()); + return Err(self.interrupted_error()); } // Pre-turn compaction: trim context before building the request @@ -704,16 +696,13 @@ impl Session { let retry_policy = RetryPolicy { max_retries: 3, on_retry: Some(std::sync::Arc::new(move |err, attempt, delay| { - retry_emitter.emit( - retry_session_id.clone(), - AgentEvent::LlmRetry { - provider: retry_provider.clone(), - model: retry_model.clone(), - attempt: attempt as usize, - delay_secs: delay.as_secs_f64(), - error: err.clone(), - }, - ); + retry_emitter.emit(retry_session_id.clone(), AgentEvent::LlmRetry { + provider: retry_provider.clone(), + model: retry_model.clone(), + attempt: attempt as usize, + delay_secs: delay.as_secs_f64(), + error: err.clone(), + }); })), ..Default::default() }; @@ -769,12 +758,12 @@ impl Session { } } - // If aborted during streaming, drop the stream to cancel the HTTP + // If interrupted during streaming, drop the stream to cancel the HTTP // connection, then close the session before returning. if self.cancel_token.is_cancelled() { drop(event_stream); self.close(); - return Err(self.aborted_error()); + return Err(self.interrupted_error()); } if let Some(resp) = accumulator.response().cloned() { @@ -793,7 +782,7 @@ impl Session { self.event_emitter.emit( self.id.clone(), AgentEvent::AssistantOutputReplace { - text: String::new(), + text: String::new(), reasoning: None, }, ); @@ -805,9 +794,9 @@ impl Session { } let Some(response) = response else { - return Err(self.emit_llm_error(SdkError::Stream { + return Err(self.emit_llm_error(LlmError::Stream { message: "Stream ended without a Finish event (after retries)".into(), - source: None, + source: None, })); }; @@ -833,15 +822,13 @@ impl Session { }); // Emit AssistantMessage with enriched data from the response - self.event_emitter.emit( - self.id.clone(), - AgentEvent::AssistantMessage { - text: text.clone(), - model: response.model.clone(), - usage: response.usage.clone(), + self.event_emitter + .emit(self.id.clone(), AgentEvent::AssistantMessage { + text: text.clone(), + model: response.model.clone(), + usage: response.usage.clone(), tool_call_count: tool_calls.len(), - }, - ); + }); // Post-response compaction: trim context after appending assistant turn self.compact_if_needed().await; @@ -880,7 +867,7 @@ impl Session { timestamp: SystemTime::now(), }); self.close(); - return Err(self.aborted_error()); + return Err(self.interrupted_error()); } // Record tool results turn @@ -931,12 +918,9 @@ impl Session { ) .await { - self.event_emitter.emit( - self.id.clone(), - AgentEvent::Error { - error: AgentError::InvalidState(format!("Context compaction failed: {e}")), - }, - ); + self.event_emitter.emit(self.id.clone(), AgentEvent::Error { + error: Error::InvalidState(format!("Context compaction failed: {e}")), + }); } } } @@ -951,7 +935,7 @@ impl Session { for msg in messages { let text = msg.clone(); self.history.push(Turn::Steering { - content: msg, + content: msg, timestamp: SystemTime::now(), }); self.event_emitter @@ -996,7 +980,7 @@ impl Session { } } -const fn is_auth_error(err: &SdkError) -> bool { +const fn is_auth_error(err: &LlmError) -> bool { matches!( err.provider_kind(), Some(ProviderErrorKind::Authentication | ProviderErrorKind::AccessDenied) @@ -1005,29 +989,31 @@ const fn is_auth_error(err: &SdkError) -> bool { #[cfg(test)] mod tests { - use super::*; - use crate::config::ToolApprovalAdapter; - use crate::subagent::SubAgentStatus; - use crate::test_support::*; - use crate::tool_registry::{RegisteredTool, ToolRegistry}; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use fabro_llm::error::{ProviderErrorDetail, ProviderErrorKind}; use fabro_llm::provider::{ProviderAdapter, StreamEventStream}; use fabro_llm::types::{ ContentPart, ReasoningEffort, Request, Response, Role, StreamEvent, ToolDefinition, }; use futures::stream; - use std::sync::Arc; - use std::sync::atomic::{AtomicUsize, Ordering}; + + use super::*; + use crate::config::ToolApprovalAdapter; + use crate::subagent::SubAgentStatus; + use crate::test_support::*; + use crate::tool_registry::{RegisteredTool, ToolRegistry}; #[derive(Clone)] enum ScriptedStreamCall { Response(Box), - Events(Vec>), - Error(SdkError), + Events(Vec>), + Error(LlmError), } struct ScriptedStreamProvider { - calls: Vec, + calls: Vec, call_index: AtomicUsize, } @@ -1043,7 +1029,7 @@ mod tests { } } - fn events_for_response(response: Response) -> Vec> { + fn events_for_response(response: Response) -> Vec> { let mut events = Vec::new(); let text = response.text(); if !text.is_empty() { @@ -1073,14 +1059,14 @@ mod tests { "mock" } - async fn complete(&self, _request: &Request) -> Result { - Err(SdkError::Configuration { + async fn complete(&self, _request: &Request) -> Result { + Err(LlmError::Configuration { message: "ScriptedStreamProvider does not implement complete()".into(), - source: None, + source: None, }) } - async fn stream(&self, _request: &Request) -> Result { + async fn stream(&self, _request: &Request) -> Result { let idx = self.call_index.fetch_add(1, Ordering::SeqCst); let scripted = if idx < self.calls.len() { self.calls[idx].clone() @@ -1113,7 +1099,7 @@ mod tests { client, profile, env, - SessionConfig::default(), + SessionOptions::default(), subagent_manager, ) } @@ -1180,7 +1166,7 @@ mod tests { tool_call_response("echo", "call_3", serde_json::json!({"text": "c"})), ]; - let config = SessionConfig { + let config = SessionOptions { max_tool_rounds_per_input: 2, enable_loop_detection: false, ..Default::default() @@ -1203,7 +1189,7 @@ mod tests { text_response("should not reach"), ]; - let config = SessionConfig { + let config = SessionOptions { max_turns: 3, ..Default::default() }; @@ -1280,7 +1266,7 @@ mod tests { assert!( events .iter() - .any(|e| matches!(e.event, AgentEvent::SessionStarted)) + .any(|e| matches!(e.event, AgentEvent::SessionStarted { .. })) ); assert!( events @@ -1393,7 +1379,7 @@ mod tests { text_response("Done"), ]; - let config = SessionConfig { + let config = SessionOptions { enable_loop_detection: true, loop_detection_window: 3, ..Default::default() @@ -1430,18 +1416,18 @@ mod tests { tool_call_response("echo", "call_2", serde_json::json!({"text": "b"})), ]; - let config = SessionConfig { + let config = SessionOptions { enable_loop_detection: false, ..Default::default() }; let mut session = make_session_with_tools_and_config(responses, registry, config).await; - // Set abort before processing - session.abort(); + // Set interrupt before processing + session.interrupt(); let result = session.process_input("Do something").await; - // Should return Aborted error and transition to Closed - assert!(matches!(result, Err(AgentError::Aborted(_)))); + // Should return Interrupted error and transition to Closed + assert!(matches!(result, Err(Error::Interrupted(_)))); assert_eq!(session.state(), SessionState::Closed); // Should have stopped immediately: User turn only, no LLM call @@ -1458,11 +1444,11 @@ mod tests { // Tool that cancels the token when executed let abort_tool = RegisteredTool { definition: ToolDefinition { - name: "set_abort".into(), - description: "Sets abort flag".into(), - parameters: serde_json::json!({"type": "object"}), + name: "set_abort".into(), + description: "Sets interrupt flag".into(), + parameters: serde_json::json!({"type": "object"}), }, - executor: Arc::new(move |_args, _ctx| { + executor: Arc::new(move |_args, _ctx| { let token = cancel_token_for_tool.clone(); Box::pin(async move { token.cancel(); @@ -1483,7 +1469,7 @@ mod tests { let client = make_client(provider).await; let profile = Arc::new(TestProfile::with_tools(registry)); let env = Arc::new(MockSandbox::default()); - let config = SessionConfig { + let config = SessionOptions { enable_loop_detection: false, ..Default::default() }; @@ -1494,8 +1480,8 @@ mod tests { let result = session.process_input("Do something").await; - // Should return Aborted error and transition to Closed - assert!(matches!(result, Err(AgentError::Aborted(_)))); + // Should return Interrupted error and transition to Closed + assert!(matches!(result, Err(Error::Interrupted(_)))); assert_eq!(session.state(), SessionState::Closed); // Should have processed: User + Assistant(tool_call) + ToolResults = 3 turns @@ -1510,19 +1496,19 @@ mod tests { #[tokio::test] async fn auth_error_closes_session() { let error_provider = Arc::new(MockErrorProvider { - error: SdkError::Provider { - kind: ProviderErrorKind::Authentication, + error: LlmError::Provider { + kind: ProviderErrorKind::Authentication, detail: Box::new(ProviderErrorDetail::new("invalid api key", "mock")), }, }); let client = make_client(error_provider).await; let profile = Arc::new(TestProfile::new()); let env = Arc::new(MockSandbox::default()); - let mut session = Session::new(client, profile, env, SessionConfig::default(), None); + let mut session = Session::new(client, profile, env, SessionOptions::default(), None); let result = session.process_input("Hello").await; assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), AgentError::Llm(_))); + assert!(matches!(result.unwrap_err(), Error::Llm(_))); assert_eq!(session.state(), SessionState::Closed); } @@ -1554,7 +1540,7 @@ mod tests { let result = session.process_input("Hello").await; assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), AgentError::SessionClosed)); + assert!(matches!(result.unwrap_err(), Error::SessionClosed)); } #[tokio::test] @@ -1564,7 +1550,7 @@ mod tests { let mut rx = session.subscribe(); let result = session.process_input("Hello").await; - assert!(matches!(result, Err(AgentError::SessionClosed))); + assert!(matches!(result, Err(Error::SessionClosed))); // No SessionStarted event should have been emitted let mut events = Vec::new(); @@ -1574,7 +1560,7 @@ mod tests { assert!( !events .iter() - .any(|e| matches!(e.event, AgentEvent::SessionStarted)), + .any(|e| matches!(e.event, AgentEvent::SessionStarted { .. })), "SessionStarted should not be emitted for a closed session" ); } @@ -1597,7 +1583,7 @@ mod tests { let client = make_client(provider).await; let profile = Arc::new(TestProfile::with_tools(registry)); let env = Arc::new(MockSandbox::default()); - let mut session = Session::new(client, profile, env, SessionConfig::default(), None); + let mut session = Session::new(client, profile, env, SessionOptions::default(), None); let mut rx = session.subscribe(); session.process_input("Use echo three times").await.unwrap(); @@ -1648,7 +1634,7 @@ mod tests { let registry = ToolRegistry::new(); let profile = Arc::new(TestProfile::with_context_window(registry, 100)); let env = Arc::new(MockSandbox::default()); - let mut session = Session::new(client, profile, env, SessionConfig::default(), None); + let mut session = Session::new(client, profile, env, SessionOptions::default(), None); let mut rx = session.subscribe(); session.process_input(&large_input).await.unwrap(); @@ -1670,7 +1656,7 @@ mod tests { let client = make_client(provider as Arc).await; let profile = Arc::new(TestProfile::new()); let env = Arc::new(MockSandbox::default()); - let mut session = Session::new(client, profile, env, SessionConfig::default(), None); + let mut session = Session::new(client, profile, env, SessionOptions::default(), None); // Default reasoning_effort is None session.set_reasoning_effort(Some(ReasoningEffort::High)); @@ -1693,7 +1679,7 @@ mod tests { // Large context window so short input stays well under 80% let profile = Arc::new(TestProfile::with_context_window(registry, 200_000)); let env = Arc::new(MockSandbox::default()); - let mut session = Session::new(client, profile, env, SessionConfig::default(), None); + let mut session = Session::new(client, profile, env, SessionOptions::default(), None); let mut rx = session.subscribe(); session.process_input("Hi").await.unwrap(); @@ -1712,9 +1698,9 @@ mod tests { let mut registry = ToolRegistry::new(); registry.register(RegisteredTool { definition: ToolDefinition { - name: "strict_tool".into(), + name: "strict_tool".into(), description: "Tool with required params".into(), - parameters: serde_json::json!({ + parameters: serde_json::json!({ "type": "object", "properties": { "text": {"type": "string"} @@ -1722,7 +1708,7 @@ mod tests { "required": ["text"] }), }, - executor: Arc::new(|_args, _ctx| { + executor: Arc::new(|_args, _ctx| { Box::pin(async move { Ok("should not reach".to_string()) }) }), }); @@ -1753,9 +1739,9 @@ mod tests { let mut registry = ToolRegistry::new(); registry.register(RegisteredTool { definition: ToolDefinition { - name: "strict_tool".into(), + name: "strict_tool".into(), description: "Tool with required params".into(), - parameters: serde_json::json!({ + parameters: serde_json::json!({ "type": "object", "properties": { "text": {"type": "string"} @@ -1763,7 +1749,7 @@ mod tests { "required": ["text"] }), }, - executor: Arc::new(|_args, _ctx| { + executor: Arc::new(|_args, _ctx| { Box::pin(async move { Ok("tool executed".to_string()) }) }), }); @@ -1803,14 +1789,15 @@ mod tests { let mut session_start_count = 0; let mut session_end_count = 0; while let Ok(event) = rx.try_recv() { - if matches!(event.event, AgentEvent::SessionStarted) { + if matches!(event.event, AgentEvent::SessionStarted { .. }) { session_start_count += 1; } if matches!(event.event, AgentEvent::SessionEnded) { session_end_count += 1; } } - // SessionStarted is emitted once during initialize(), SessionEnded once during close() + // SessionStarted is emitted once during initialize(), SessionEnded once during + // close() assert_eq!(session_start_count, 1); assert_eq!(session_end_count, 1); } @@ -1822,7 +1809,7 @@ mod tests { let client = make_client(provider as Arc).await; let profile = Arc::new(TestProfile::new()); let env = Arc::new(MockSandbox::default()); - let config = SessionConfig { + let config = SessionOptions { user_instructions: Some("Always use TDD".into()), ..Default::default() }; @@ -1850,7 +1837,7 @@ mod tests { let client = make_client(provider as Arc).await; let profile = Arc::new(TestProfile::new()); let env = Arc::new(MockSandbox::default()); - let mut session = Session::new(client, profile, env, SessionConfig::default(), None); + let mut session = Session::new(client, profile, env, SessionOptions::default(), None); // Intentionally skip initialize(): system prompt remains empty. session.process_input("test").await.unwrap(); @@ -1882,7 +1869,7 @@ mod tests { text_response("OK after denial"), ]; - let config = SessionConfig { + let config = SessionOptions { tool_hooks: Some(Arc::new(ToolApprovalAdapter(Arc::new(|_name, _args| { Err("denied by policy".to_string()) })))), @@ -1923,7 +1910,7 @@ mod tests { text_response("Done"), ]; - let config = SessionConfig { + let config = SessionOptions { tool_hooks: Some(Arc::new(ToolApprovalAdapter(Arc::new(|_name, _args| { Ok(()) })))), @@ -1959,7 +1946,7 @@ mod tests { text_response("Done"), ]; - let config = SessionConfig { + let config = SessionOptions { tool_hooks: Some(Arc::new(ToolApprovalAdapter(Arc::new( move |name, args| { *captured_clone.lock().unwrap() = Some((name.to_string(), args.clone())); @@ -1990,7 +1977,7 @@ mod tests { text_response("Done"), ]; - let config = SessionConfig { + let config = SessionOptions { tool_hooks: None, ..Default::default() }; @@ -2021,7 +2008,7 @@ mod tests { text_response("Done"), ]; - let config = SessionConfig { + let config = SessionOptions { tool_hooks: Some(Arc::new(ToolApprovalAdapter(Arc::new(|_name, _args| { Err("not allowed".to_string()) })))), @@ -2074,21 +2061,18 @@ mod tests { async fn stream_mid_stream_error() { let provider = Arc::new(MockMidStreamErrorProvider { partial_text: "partial".into(), - error: SdkError::Stream { + error: LlmError::Stream { message: "connection reset".into(), - source: None, + source: None, }, }); let client = make_client(provider as Arc).await; let profile = Arc::new(TestProfile::new()); let env = Arc::new(MockSandbox::default()); - let mut session = Session::new(client, profile, env, SessionConfig::default(), None); + let mut session = Session::new(client, profile, env, SessionOptions::default(), None); let result = session.process_input("Hello").await; - assert!(matches!( - result, - Err(AgentError::Llm(SdkError::Stream { .. })) - )); + assert!(matches!(result, Err(Error::Llm(LlmError::Stream { .. })))); } #[tokio::test] @@ -2162,22 +2146,19 @@ mod tests { } } - assert_eq!( - observed, - vec![ - "start".to_string(), - "delta:Hel".to_string(), - "replace::None".to_string(), - "delta:Hello".to_string(), - "message:Hello".to_string(), - ] - ); + assert_eq!(observed, vec![ + "start".to_string(), + "delta:Hel".to_string(), + "replace::None".to_string(), + "delta:Hello".to_string(), + "message:Hello".to_string(), + ]); } #[tokio::test] async fn retry_open_auth_error_emits_error_and_closes_session() { - let auth_error = SdkError::Provider { - kind: ProviderErrorKind::Authentication, + let auth_error = LlmError::Provider { + kind: ProviderErrorKind::Authentication, detail: Box::new(ProviderErrorDetail { status_code: Some(401), ..ProviderErrorDetail::new("bad key", "mock") @@ -2193,7 +2174,7 @@ mod tests { let result = session.process_input("Hello").await; assert!(matches!( result, - Err(AgentError::Llm(SdkError::Provider { + Err(Error::Llm(LlmError::Provider { kind: ProviderErrorKind::Authentication, .. })) @@ -2215,7 +2196,7 @@ mod tests { observed.push("error".to_string()); found_auth_error_event = matches!( error, - AgentError::Llm(SdkError::Provider { + Error::Llm(LlmError::Provider { kind: ProviderErrorKind::Authentication, .. }) @@ -2226,22 +2207,20 @@ mod tests { } } - assert_eq!( - observed, - vec![ - "start".to_string(), - "delta:Hel".to_string(), - "replace::None".to_string(), - "error".to_string(), - ] - ); + assert_eq!(observed, vec![ + "start".to_string(), + "delta:Hel".to_string(), + "replace::None".to_string(), + "error".to_string(), + ]); assert!(found_auth_error_event, "expected auth error event"); } #[tokio::test] async fn compaction_triggered_when_over_threshold() { // Tiny context window to trigger compaction - // Responses: [0] conversation response (stream), [1] summarization (complete), [2] unused fallback + // Responses: [0] conversation response (stream), [1] summarization (complete), + // [2] unused fallback let responses = vec![ text_response("OK"), text_response("Here is the summary of the conversation so far."), @@ -2255,7 +2234,7 @@ mod tests { let registry = ToolRegistry::new(); let profile = Arc::new(TestProfile::with_context_window(registry, 100)); let env = Arc::new(MockSandbox::default()); - let config = SessionConfig { + let config = SessionOptions { enable_context_compaction: true, compaction_preserve_turns: 1, ..Default::default() @@ -2298,7 +2277,7 @@ mod tests { let registry = ToolRegistry::new(); let profile = Arc::new(TestProfile::with_context_window(registry, 100)); let env = Arc::new(MockSandbox::default()); - let config = SessionConfig { + let config = SessionOptions { enable_context_compaction: false, ..Default::default() }; @@ -2321,11 +2300,12 @@ mod tests { #[tokio::test] async fn compaction_failure_is_non_fatal() { - // Response [0] = conversation response (stream), [1] will be used for summarization (complete) but we - // need it to error. We'll use a special provider that errors on complete() but succeeds on stream(). + // Response [0] = conversation response (stream), [1] will be used for + // summarization (complete) but we need it to error. We'll use a special + // provider that errors on complete() but succeeds on stream(). struct StreamOnlyProvider { - responses: Vec, + responses: Vec, call_index: AtomicUsize, } @@ -2335,14 +2315,14 @@ mod tests { "mock" } - async fn complete(&self, _request: &Request) -> Result { - Err(SdkError::Stream { + async fn complete(&self, _request: &Request) -> Result { + Err(LlmError::Stream { message: "summarization failed".into(), - source: None, + source: None, }) } - async fn stream(&self, _request: &Request) -> Result { + async fn stream(&self, _request: &Request) -> Result { let idx = self.call_index.fetch_add(1, Ordering::SeqCst); let response = if idx < self.responses.len() { self.responses[idx].clone() @@ -2350,7 +2330,7 @@ mod tests { self.responses[self.responses.len() - 1].clone() }; // Reuse response_to_stream helper from test_support - let mut events: Vec> = Vec::new(); + let mut events: Vec> = Vec::new(); let text = response.text(); if !text.is_empty() { events.push(Ok(StreamEvent::text_delta(text, None))); @@ -2382,7 +2362,7 @@ mod tests { let registry = ToolRegistry::new(); let profile = Arc::new(TestProfile::with_context_window(registry, 100)); let env = Arc::new(MockSandbox::default()); - let config = SessionConfig { + let config = SessionOptions { enable_context_compaction: true, compaction_preserve_turns: 1, ..Default::default() @@ -2412,14 +2392,15 @@ mod tests { #[tokio::test] async fn compaction_includes_structured_prompt_and_file_tracking() { - use crate::tool_registry::RegisteredTool; use fabro_llm::types::ToolDefinition; + use crate::tool_registry::RegisteredTool; + // Provider that captures complete() requests (compaction) while returning // canned responses for stream() calls. struct CompactionCapturingProvider { - stream_responses: Vec, - stream_index: AtomicUsize, + stream_responses: Vec, + stream_index: AtomicUsize, captured_complete: Mutex>, } @@ -2429,12 +2410,12 @@ mod tests { "mock" } - async fn complete(&self, request: &Request) -> Result { + async fn complete(&self, request: &Request) -> Result { *self.captured_complete.lock().unwrap() = Some(request.clone()); Ok(text_response("## Goal\nSummary goes here.")) } - async fn stream(&self, _request: &Request) -> Result { + async fn stream(&self, _request: &Request) -> Result { let idx = self.stream_index.fetch_add(1, Ordering::SeqCst); let response = if idx < self.stream_responses.len() { self.stream_responses[idx].clone() @@ -2448,11 +2429,11 @@ mod tests { // read_file tool that always succeeds let read_tool = RegisteredTool { definition: ToolDefinition { - name: "read_file".into(), + name: "read_file".into(), description: "Read a file".into(), - parameters: serde_json::json!({"type": "object", "properties": {"file_path": {"type": "string"}}}), + parameters: serde_json::json!({"type": "object", "properties": {"file_path": {"type": "string"}}}), }, - executor: Arc::new(|_args, _ctx| { + executor: Arc::new(|_args, _ctx| { Box::pin(async move { Ok("file contents".to_string()) }) }), }; @@ -2486,7 +2467,7 @@ mod tests { // Tiny context window to force compaction let profile = Arc::new(TestProfile::with_context_window(registry, 100)); let env = Arc::new(MockSandbox::default()); - let config = SessionConfig { + let config = SessionOptions { enable_context_compaction: true, compaction_preserve_turns: 1, ..Default::default() @@ -2504,7 +2485,8 @@ mod tests { "read_file should be tracked" ); - // Second call with large input: context is well over threshold, compaction triggers + // Second call with large input: context is well over threshold, compaction + // triggers let large_input = "x".repeat(400); session.process_input(&large_input).await.unwrap(); @@ -2550,22 +2532,23 @@ mod tests { #[tokio::test] async fn mcp_end_to_end_tool_call() { - use fabro_mcp::config::{McpServerConfig, McpTransport}; use std::collections::HashMap; + use fabro_mcp::config::{McpServerSettings, McpTransport}; + let test_server = format!( "{}/../fabro-mcp/tests/test_mcp_server.py", env!("CARGO_MANIFEST_DIR") ); - let config = SessionConfig { - mcp_servers: vec![McpServerConfig { - name: "test-echo".into(), - transport: McpTransport::Stdio { + let config = SessionOptions { + mcp_servers: vec![McpServerSettings { + name: "test-echo".into(), + transport: McpTransport::Stdio { command: vec!["python3".into(), test_server], - env: HashMap::new(), + env: HashMap::new(), }, startup_timeout_secs: 10, - tool_timeout_secs: 30, + tool_timeout_secs: 30, }], enable_loop_detection: false, ..Default::default() @@ -2671,11 +2654,11 @@ mod tests { // Register a tool that loops until the cancel token fires let slow_tool = RegisteredTool { definition: ToolDefinition { - name: "slow_tool".into(), + name: "slow_tool".into(), description: "Waits until cancelled".into(), - parameters: serde_json::json!({"type": "object"}), + parameters: serde_json::json!({"type": "object"}), }, - executor: Arc::new(|_args, ctx| { + executor: Arc::new(|_args, ctx| { Box::pin(async move { ctx.cancel.cancelled().await; Ok("cancelled".to_string()) @@ -2691,7 +2674,7 @@ mod tests { text_response("Should not reach this"), ]; - let config = SessionConfig { + let config = SessionOptions { wall_clock_timeout: Some(std::time::Duration::from_millis(10)), enable_loop_detection: false, ..Default::default() @@ -2703,9 +2686,9 @@ mod tests { assert!( matches!( result, - Err(AgentError::Aborted(AbortReason::WallClockTimeout)) + Err(Error::Interrupted(InterruptReason::WallClockTimeout)) ), - "expected Aborted(WallClockTimeout), got {result:?}" + "expected Interrupted(WallClockTimeout), got {result:?}" ); assert_eq!(session.state(), SessionState::Closed); } @@ -2714,7 +2697,7 @@ mod tests { async fn wall_clock_timeout_does_not_fire_when_session_completes_in_time() { let responses = vec![text_response("Fast response")]; - let config = SessionConfig { + let config = SessionOptions { wall_clock_timeout: Some(std::time::Duration::from_secs(10)), ..Default::default() }; diff --git a/lib/crates/fabro-agent/src/skills.rs b/lib/crates/fabro-agent/src/skills.rs index 062140721..a4d00226e 100644 --- a/lib/crates/fabro-agent/src/skills.rs +++ b/lib/crates/fabro-agent/src/skills.rs @@ -1,14 +1,16 @@ +use std::sync::Arc; + +use fabro_llm::types::ToolDefinition; + use crate::sandbox::Sandbox; use crate::tool_registry::RegisteredTool; use crate::tools::required_str; -use fabro_llm::types::ToolDefinition; -use std::sync::Arc; #[derive(Debug, Clone)] pub struct Skill { - pub name: String, + pub name: String, pub description: String, - pub template: String, + pub template: String, } pub fn parse_skill(content: &str) -> Result { @@ -46,21 +48,23 @@ pub fn parse_skill(content: &str) -> Result { }) } -/// A detected skill reference in user input: the name and byte range of the `/name` token. +/// A detected skill reference in user input: the name and byte range of the +/// `/name` token. struct SkillMatch { - name: String, + name: String, /// Byte offset of the `/` character start: usize, /// Byte offset just past the skill name - end: usize, + end: usize, } fn is_skill_name_char(c: char) -> bool { c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-' } -/// Find all `/skill-name` tokens in input where the `/` is preceded by whitespace (or -/// start-of-string) and the name is followed by whitespace (or end-of-string). +/// Find all `/skill-name` tokens in input where the `/` is preceded by +/// whitespace (or start-of-string) and the name is followed by whitespace (or +/// end-of-string). fn find_skill_references(input: &str) -> Vec { let mut results = Vec::new(); let bytes = input.as_bytes(); @@ -93,9 +97,9 @@ fn find_skill_references(input: &str) -> Vec { let followed_by_boundary = j >= len || bytes[j].is_ascii_whitespace(); if followed_by_boundary { results.push(SkillMatch { - name: input[name_start..j].to_string(), + name: input[name_start..j].to_string(), start: i, - end: j, + end: j, }); } @@ -110,7 +114,7 @@ fn find_skill_references(input: &str) -> Vec { #[derive(Debug)] pub struct ExpandedInput { - pub text: String, + pub text: String, pub skill_name: Option, } @@ -119,7 +123,7 @@ pub fn expand_skill(skills: &[Skill], input: &str) -> Result Result>) -> RegisteredTool { RegisteredTool { definition: ToolDefinition { - name: "use_skill".into(), + name: "use_skill".into(), description: "Load a skill's instructions by name. Call this when the user's \ request matches an available skill." .into(), - parameters: serde_json::json!({ + parameters: serde_json::json!({ "type": "object", "properties": { "skill_name": { @@ -170,7 +174,7 @@ pub fn make_use_skill_tool(skills: Arc>) -> RegisteredTool { "required": ["skill_name"] }), }, - executor: Arc::new(move |args, _ctx| { + executor: Arc::new(move |args, _ctx| { let skills = skills.clone(); Box::pin(async move { let name = required_str(&args, "skill_name")?; @@ -205,11 +209,11 @@ pub fn format_skills_prompt_section(skills: &[Skill]) -> String { lines.join("\n") } -pub fn default_skill_dirs(home_dir: Option<&str>, git_root: Option<&str>) -> Vec { +pub fn default_skill_dirs(fabro_skills_dir: Option<&str>, git_root: Option<&str>) -> Vec { let mut dirs = Vec::new(); - if let Some(home) = home_dir { - dirs.push(format!("{home}/.fabro/skills")); + if let Some(skills_dir) = fabro_skills_dir { + dirs.push(skills_dir.to_string()); } if let Some(root) = git_root { @@ -247,12 +251,14 @@ pub async fn discover_skills(env: &dyn Sandbox, dirs: &[String]) -> Vec { #[cfg(test)] mod tests { + use std::collections::HashMap; + + use tokio_util::sync::CancellationToken; + use super::*; use crate::sandbox::Sandbox; use crate::test_support::MockSandbox; use crate::tool_registry::ToolContext; - use std::collections::HashMap; - use tokio_util::sync::CancellationToken; // --- parse_skill tests --- @@ -337,14 +343,14 @@ name: trimmed fn test_skills() -> Vec { vec![ Skill { - name: "commit".into(), + name: "commit".into(), description: "Create a commit".into(), - template: "Review changes and commit.\n\n{{user_input}}".into(), + template: "Review changes and commit.\n\n{{user_input}}".into(), }, Skill { - name: "test".into(), + name: "test".into(), description: "Run tests".into(), - template: "Run the test suite.".into(), + template: "Run the test suite.".into(), }, ] } @@ -517,20 +523,17 @@ name: trimmed #[test] fn default_dirs_with_git_root() { - let dirs = default_skill_dirs(Some("/home/user"), Some("/repo")); - assert_eq!( - dirs, - vec![ - "/home/user/.fabro/skills", - "/repo/.fabro/skills", - "/repo/skills", - ] - ); + let dirs = default_skill_dirs(Some("/home/user/.fabro/skills"), Some("/repo")); + assert_eq!(dirs, vec![ + "/home/user/.fabro/skills", + "/repo/.fabro/skills", + "/repo/skills", + ]); } #[test] fn default_dirs_without_git_root() { - let dirs = default_skill_dirs(Some("/home/user"), None); + let dirs = default_skill_dirs(Some("/home/user/.fabro/skills"), None); assert_eq!(dirs, vec!["/home/user/.fabro/skills"]); } diff --git a/lib/crates/fabro-agent/src/subagent.rs b/lib/crates/fabro-agent/src/subagent.rs index 92d5c25d5..a27cde76b 100644 --- a/lib/crates/fabro-agent/src/subagent.rs +++ b/lib/crates/fabro-agent/src/subagent.rs @@ -1,14 +1,16 @@ -use crate::error::AgentError; +use std::collections::{HashMap, VecDeque}; +use std::sync::{Arc, Mutex}; + +use fabro_llm::types::ToolDefinition; +use tokio::sync::Mutex as AsyncMutex; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; + +use crate::error::Error; use crate::session::Session; use crate::tool_registry::RegisteredTool; use crate::tools::required_str; use crate::types::{AgentEvent, SessionEvent, Turn}; -use fabro_llm::types::ToolDefinition; -use std::collections::{HashMap, VecDeque}; -use std::sync::{Arc, Mutex}; -use tokio::sync::Mutex as AsyncMutex; -use tokio::task::JoinHandle; -use tokio_util::sync::CancellationToken; pub type SessionFactory = Arc Session + Send + Sync>; @@ -22,29 +24,29 @@ pub type SubAgentEventCallback = Arc), + Finished(Result), Closed, } pub struct SubAgent { - task: Option>>, + task: Option>>, followup_queue: Arc>>, - cancel_token: CancellationToken, - depth: usize, - status: SubAgentStatus, + cancel_token: CancellationToken, + depth: usize, + status: SubAgentStatus, } pub struct SubAgentManager { - agents: HashMap, - max_depth: usize, + agents: HashMap, + max_depth: usize, event_callback: Option, } @@ -73,9 +75,9 @@ impl SubAgentManager { mut session: Session, task_prompt: String, depth: usize, - ) -> Result { + ) -> Result { if depth >= self.max_depth { - return Err(AgentError::InvalidState(format!( + return Err(Error::InvalidState(format!( "Maximum subagent depth ({}) reached", self.max_depth ))); @@ -92,18 +94,14 @@ impl SubAgentManager { tokio::spawn(async move { while let Ok(event) = rx.recv().await { // Skip streaming / noise events - if matches!( - &event.event, - AgentEvent::TextDelta { .. } - | AgentEvent::AssistantOutputReplace { .. } - | AgentEvent::ReasoningDelta { .. } - | AgentEvent::ToolCallOutputDelta { .. } - | AgentEvent::AssistantTextStart - | AgentEvent::SessionStarted - | AgentEvent::SessionEnded - | AgentEvent::ProcessingEnd - | AgentEvent::SkillExpanded { .. } - ) { + if event.event.is_streaming_noise() + || matches!( + &event.event, + AgentEvent::SessionStarted { .. } + | AgentEvent::SessionEnded + | AgentEvent::ProcessingEnd + ) + { continue; } cb(SubAgentCallbackEvent::Forwarded(event)); @@ -121,35 +119,32 @@ impl SubAgentManager { _ => None, }); Ok(SubAgentResult { - output: last_text.unwrap_or_default(), - success: true, + output: last_text.unwrap_or_default(), + success: true, turns_used: turns.len(), }) }); - self.agents.insert( - agent_id.clone(), - SubAgent { - task: Some(task), - followup_queue, - cancel_token, - depth: depth + 1, - status: SubAgentStatus::Running, - }, - ); + self.agents.insert(agent_id.clone(), SubAgent { + task: Some(task), + followup_queue, + cancel_token, + depth: depth + 1, + status: SubAgentStatus::Running, + }); self.emit_event(AgentEvent::SubAgentSpawned { agent_id: agent_id.clone(), - depth: depth + 1, - task: task_prompt, + depth: depth + 1, + task: task_prompt, }); Ok(agent_id) } - pub fn send_input(&self, agent_id: &str, message: &str) -> Result<(), AgentError> { + pub fn send_input(&self, agent_id: &str, message: &str) -> Result<(), Error> { let agent = self.agents.get(agent_id).ok_or_else(|| { - AgentError::InvalidState(format!( + Error::InvalidState(format!( "No agent found with id: {agent_id} (it was never spawned)" )) })?; @@ -157,7 +152,7 @@ impl SubAgentManager { match agent.status { SubAgentStatus::Running => {} _ => { - return Err(AgentError::InvalidState(format!( + return Err(Error::InvalidState(format!( "Agent {agent_id} is not running" ))); } @@ -172,12 +167,12 @@ impl SubAgentManager { Ok(()) } - pub async fn wait(&mut self, agent_id: &str) -> Result { + pub async fn wait(&mut self, agent_id: &str) -> Result { // Phase 1: Check existence and current status let agent = self.agents.get(agent_id); let depth = match agent { None => { - return Err(AgentError::InvalidState(format!( + return Err(Error::InvalidState(format!( "No agent found with id: {agent_id} (it was never spawned)" ))); } @@ -186,7 +181,7 @@ impl SubAgentManager { match &self.agents[agent_id].status { SubAgentStatus::Closed => { - return Err(AgentError::InvalidState(format!( + return Err(Error::InvalidState(format!( "Agent {agent_id} has been closed" ))); } @@ -203,16 +198,12 @@ impl SubAgentManager { .unwrap() .task .take() - .ok_or_else(|| { - AgentError::InvalidState(format!("Agent {agent_id} has no running task")) - })?; + .ok_or_else(|| Error::InvalidState(format!("Agent {agent_id} has no running task")))?; // Phase 3: Await the task (no borrow held) let task_result = match join_handle.await { Ok(result) => result, - Err(e) => Err(AgentError::InvalidState(format!( - "Agent task panicked: {e}" - ))), + Err(e) => Err(Error::InvalidState(format!("Agent task panicked: {e}"))), }; // Phase 4: Emit event @@ -244,16 +235,16 @@ impl SubAgentManager { } } - pub fn close(&mut self, agent_id: &str) -> Result<(), AgentError> { + pub fn close(&mut self, agent_id: &str) -> Result<(), Error> { let agent = self.agents.get_mut(agent_id).ok_or_else(|| { - AgentError::InvalidState(format!( + Error::InvalidState(format!( "No agent found with id: {agent_id} (it was never spawned)" )) })?; match agent.status { SubAgentStatus::Closed => { - return Err(AgentError::InvalidState(format!( + return Err(Error::InvalidState(format!( "Agent {agent_id} is already closed" ))); } @@ -312,9 +303,9 @@ pub fn make_spawn_agent_tool( ) -> RegisteredTool { RegisteredTool { definition: ToolDefinition { - name: "spawn_agent".into(), + name: "spawn_agent".into(), description: "Spawn a subagent to work on a delegated task".into(), - parameters: serde_json::json!({ + parameters: serde_json::json!({ "type": "object", "properties": { "task": { @@ -337,7 +328,7 @@ pub fn make_spawn_agent_tool( "required": ["task"] }), }, - executor: Arc::new(move |args, _ctx| { + executor: Arc::new(move |args, _ctx| { let manager = manager.clone(); let session_factory = session_factory.clone(); Box::pin(async move { @@ -351,7 +342,8 @@ pub fn make_spawn_agent_tool( // Note: working_dir and model require session factory changes to wire through let mut session = session_factory(); - // Default subagent max_turns is 0 (unlimited) per spec (overridable via parameter) + // Default subagent max_turns is 0 (unlimited) per spec (overridable via + // parameter) session.set_max_turns(max_turns.unwrap_or(0)); let mut mgr = manager.lock().await; mgr.spawn(session, task.to_string(), current_depth) @@ -364,9 +356,9 @@ pub fn make_spawn_agent_tool( pub fn make_send_input_tool(manager: Arc>) -> RegisteredTool { RegisteredTool { definition: ToolDefinition { - name: "send_input".into(), + name: "send_input".into(), description: "Send a follow-up message to a running subagent".into(), - parameters: serde_json::json!({ + parameters: serde_json::json!({ "type": "object", "properties": { "agent_id": { @@ -381,7 +373,7 @@ pub fn make_send_input_tool(manager: Arc>) -> Regist "required": ["agent_id", "message"] }), }, - executor: Arc::new(move |args, _ctx| { + executor: Arc::new(move |args, _ctx| { let manager = manager.clone(); Box::pin(async move { let agent_id = required_str(&args, "agent_id")?; @@ -399,9 +391,9 @@ pub fn make_send_input_tool(manager: Arc>) -> Regist pub fn make_wait_tool(manager: Arc>) -> RegisteredTool { RegisteredTool { definition: ToolDefinition { - name: "wait".into(), + name: "wait".into(), description: "Wait for a subagent to complete and return its result".into(), - parameters: serde_json::json!({ + parameters: serde_json::json!({ "type": "object", "properties": { "agent_id": { @@ -412,7 +404,7 @@ pub fn make_wait_tool(manager: Arc>) -> RegisteredTo "required": ["agent_id"] }), }, - executor: Arc::new(move |args, _ctx| { + executor: Arc::new(move |args, _ctx| { let manager = manager.clone(); Box::pin(async move { let agent_id = required_str(&args, "agent_id")?; @@ -431,9 +423,9 @@ pub fn make_wait_tool(manager: Arc>) -> RegisteredTo pub fn make_close_agent_tool(manager: Arc>) -> RegisteredTool { RegisteredTool { definition: ToolDefinition { - name: "close_agent".into(), + name: "close_agent".into(), description: "Close a running subagent".into(), - parameters: serde_json::json!({ + parameters: serde_json::json!({ "type": "object", "properties": { "agent_id": { @@ -444,7 +436,7 @@ pub fn make_close_agent_tool(manager: Arc>) -> Regis "required": ["agent_id"] }), }, - executor: Arc::new(move |args, _ctx| { + executor: Arc::new(move |args, _ctx| { let manager = manager.clone(); Box::pin(async move { let agent_id = required_str(&args, "agent_id")?; @@ -459,13 +451,14 @@ pub fn make_close_agent_tool(manager: Arc>) -> Regis #[cfg(test)] mod tests { - use super::*; - use crate::config::SessionConfig; - use crate::test_support::*; use fabro_llm::provider::ProviderAdapter; use fabro_llm::types::Role; use tokio::time; + use super::*; + use crate::config::SessionOptions; + use crate::test_support::*; + // --- Tests --- #[test] @@ -495,7 +488,7 @@ mod tests { let client = make_client(provider as Arc).await; let profile = Arc::new(TestProfile::new()); let env = Arc::new(MockSandbox::default()); - let session = Session::new(client, profile, env, SessionConfig::default(), None); + let session = Session::new(client, profile, env, SessionOptions::default(), None); let agent_id = manager.spawn(session, "Do something".into(), 0).unwrap(); let _ = manager.wait(&agent_id).await.unwrap(); @@ -722,15 +715,21 @@ mod tests { let mut rx = parent.subscribe(); callback(SubAgentCallbackEvent::Forwarded(SessionEvent { - event: AgentEvent::SessionStarted, - timestamp: std::time::SystemTime::now(), - session_id: "child".into(), + event: AgentEvent::SessionStarted { + provider: Some("anthropic".into()), + model: Some("claude-opus".into()), + }, + timestamp: std::time::SystemTime::now(), + session_id: "child".into(), parent_session_id: None, })); callback(SubAgentCallbackEvent::Forwarded(SessionEvent { - event: AgentEvent::SessionStarted, - timestamp: std::time::SystemTime::now(), - session_id: "grandchild".into(), + event: AgentEvent::SessionStarted { + provider: Some("anthropic".into()), + model: Some("claude-opus".into()), + }, + timestamp: std::time::SystemTime::now(), + session_id: "grandchild".into(), parent_session_id: Some("child".into()), })); @@ -748,7 +747,7 @@ mod tests { let manager = SubAgentManager::new(3); manager.emit_event(AgentEvent::SubAgentClosed { agent_id: "x".into(), - depth: 0, + depth: 0, }); } diff --git a/lib/crates/fabro-agent/src/test_support.rs b/lib/crates/fabro-agent/src/test_support.rs index 95d2f832a..d7284bc11 100644 --- a/lib/crates/fabro-agent/src/test_support.rs +++ b/lib/crates/fabro-agent/src/test_support.rs @@ -1,34 +1,37 @@ +use std::collections::HashMap; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use fabro_llm::Error as LlmError; +use fabro_llm::client::Client; +use fabro_llm::provider::{ProviderAdapter, StreamEventStream}; +use fabro_llm::types::{ + ContentPart, FinishReason, Message, Request, Response, StreamEvent, TokenCounts, +}; +use fabro_model::Provider; pub use fabro_sandbox::test_support::{MockSandbox, MutableMockSandbox}; +use futures::stream; use crate::agent_profile::AgentProfile; -use crate::config::SessionConfig; +use crate::config::SessionOptions; use crate::profiles::EnvContext; use crate::sandbox::*; use crate::session::Session; use crate::skills::{Skill, format_skills_prompt_section}; use crate::tool_registry::{RegisteredTool, ToolRegistry}; -use async_trait::async_trait; -use fabro_llm::client::Client; -use fabro_llm::error::SdkError; -use fabro_llm::provider::{ProviderAdapter, StreamEventStream}; -use fabro_llm::types::{ContentPart, FinishReason, Message, Request, Response, StreamEvent, Usage}; -use fabro_model::Provider; -use futures::stream; -use std::collections::HashMap; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::{Arc, Mutex}; // --- TestProfile --- pub struct TestProfile { - pub registry: ToolRegistry, + pub registry: ToolRegistry, pub context_window: usize, } impl TestProfile { pub fn new() -> Self { Self { - registry: ToolRegistry::new(), + registry: ToolRegistry::new(), context_window: 200_000, } } @@ -95,7 +98,7 @@ impl AgentProfile for TestProfile { // --- MockLlmProvider --- pub struct MockLlmProvider { - pub responses: Vec, + pub responses: Vec, pub call_index: AtomicUsize, } @@ -114,7 +117,7 @@ impl ProviderAdapter for MockLlmProvider { "mock" } - async fn complete(&self, _request: &Request) -> Result { + async fn complete(&self, _request: &Request) -> Result { let idx = self.call_index.fetch_add(1, Ordering::SeqCst); if idx < self.responses.len() { Ok(self.responses[idx].clone()) @@ -123,7 +126,7 @@ impl ProviderAdapter for MockLlmProvider { } } - async fn stream(&self, _request: &Request) -> Result { + async fn stream(&self, _request: &Request) -> Result { let idx = self.call_index.fetch_add(1, Ordering::SeqCst); let response = if idx < self.responses.len() { self.responses[idx].clone() @@ -136,7 +139,7 @@ impl ProviderAdapter for MockLlmProvider { /// Convert a canned `Response` into a `StreamEventStream` for mock streaming. pub fn response_to_stream(response: Response) -> StreamEventStream { - let mut events: Vec> = Vec::new(); + let mut events: Vec> = Vec::new(); // Emit text deltas for text content let text = response.text(); @@ -167,27 +170,27 @@ pub fn response_to_stream(response: Response) -> StreamEventStream { pub fn text_response(text: &str) -> Response { Response { - id: format!("resp_{text}"), - model: "mock-model".into(), - provider: "mock".into(), - message: Message::assistant(text), + id: format!("resp_{text}"), + model: "mock-model".into(), + provider: "mock".into(), + message: Message::assistant(text), finish_reason: FinishReason::Stop, - usage: Usage { + usage: TokenCounts { input_tokens: 10, output_tokens: 5, - total_tokens: 15, ..Default::default() }, - raw: None, - warnings: vec![], - rate_limit: None, + raw: None, + warnings: vec![], + rate_limit: None, } } pub async fn make_client(provider: Arc) -> Client { let mut providers = HashMap::new(); providers.insert(provider.name().to_string(), provider.clone()); - // Also register under "anthropic" so TestProfile (Provider::Anthropic) routes correctly + // Also register under "anthropic" so TestProfile (Provider::Anthropic) routes + // correctly providers.insert("anthropic".to_string(), provider); Client::new(providers, Some("mock".into()), vec![]) } @@ -197,7 +200,7 @@ pub async fn make_session(responses: Vec) -> Session { let client = make_client(provider).await; let profile = Arc::new(TestProfile::new()); let env = Arc::new(MockSandbox::default()); - Session::new(client, profile, env, SessionConfig::default(), None) + Session::new(client, profile, env, SessionOptions::default(), None) } pub async fn make_session_with_tools(responses: Vec, registry: ToolRegistry) -> Session { @@ -205,10 +208,10 @@ pub async fn make_session_with_tools(responses: Vec, registry: ToolReg let client = make_client(provider).await; let profile = Arc::new(TestProfile::with_tools(registry)); let env = Arc::new(MockSandbox::default()); - Session::new(client, profile, env, SessionConfig::default(), None) + Session::new(client, profile, env, SessionOptions::default(), None) } -pub async fn make_session_with_config(responses: Vec, config: SessionConfig) -> Session { +pub async fn make_session_with_config(responses: Vec, config: SessionOptions) -> Session { let provider = Arc::new(MockLlmProvider::new(responses)); let client = make_client(provider).await; let profile = Arc::new(TestProfile::new()); @@ -219,7 +222,7 @@ pub async fn make_session_with_config(responses: Vec, config: SessionC pub async fn make_session_with_tools_and_config( responses: Vec, registry: ToolRegistry, - config: SessionConfig, + config: SessionOptions, ) -> Session { let provider = Arc::new(MockLlmProvider::new(responses)); let client = make_client(provider).await; @@ -235,28 +238,27 @@ pub fn tool_call_response( ) -> Response { use fabro_llm::types::{ContentPart, Role, ToolCall}; Response { - id: format!("resp_{tool_call_id}"), - model: "mock-model".into(), - provider: "mock".into(), - message: Message { - role: Role::Assistant, - content: vec![ + id: format!("resp_{tool_call_id}"), + model: "mock-model".into(), + provider: "mock".into(), + message: Message { + role: Role::Assistant, + content: vec![ ContentPart::text("Let me use a tool."), ContentPart::ToolCall(ToolCall::new(tool_call_id, tool_name, args)), ], - name: None, + name: None, tool_call_id: None, }, finish_reason: FinishReason::ToolCalls, - usage: Usage { + usage: TokenCounts { input_tokens: 10, output_tokens: 5, - total_tokens: 15, ..Default::default() }, - raw: None, - warnings: vec![], - rate_limit: None, + raw: None, + warnings: vec![], + rate_limit: None, } } @@ -264,11 +266,11 @@ pub fn make_echo_tool() -> RegisteredTool { use fabro_llm::types::ToolDefinition; RegisteredTool { definition: ToolDefinition { - name: "echo".into(), + name: "echo".into(), description: "Echoes the input".into(), - parameters: serde_json::json!({"type": "object", "properties": {"text": {"type": "string"}}}), + parameters: serde_json::json!({"type": "object", "properties": {"text": {"type": "string"}}}), }, - executor: Arc::new(|args, _ctx| { + executor: Arc::new(|args, _ctx| { Box::pin(async move { let text = args .get("text") @@ -284,11 +286,11 @@ pub fn make_error_tool() -> RegisteredTool { use fabro_llm::types::ToolDefinition; RegisteredTool { definition: ToolDefinition { - name: "fail_tool".into(), + name: "fail_tool".into(), description: "Always fails".into(), - parameters: serde_json::json!({"type": "object"}), + parameters: serde_json::json!({"type": "object"}), }, - executor: Arc::new(|_args, _ctx| { + executor: Arc::new(|_args, _ctx| { Box::pin(async move { Err("tool execution failed".to_string()) }) }), } @@ -297,7 +299,7 @@ pub fn make_error_tool() -> RegisteredTool { // --- MockErrorProvider --- pub struct MockErrorProvider { - pub error: SdkError, + pub error: LlmError, } #[async_trait] @@ -306,11 +308,11 @@ impl ProviderAdapter for MockErrorProvider { "mock" } - async fn complete(&self, _request: &Request) -> Result { + async fn complete(&self, _request: &Request) -> Result { Err(self.error.clone()) } - async fn stream(&self, _request: &Request) -> Result { + async fn stream(&self, _request: &Request) -> Result { Err(self.error.clone()) } } @@ -336,7 +338,7 @@ impl ProviderAdapter for CapturingLlmProvider { "mock" } - async fn complete(&self, request: &Request) -> Result { + async fn complete(&self, request: &Request) -> Result { *self .captured_request .lock() @@ -344,7 +346,7 @@ impl ProviderAdapter for CapturingLlmProvider { Ok(text_response("captured")) } - async fn stream(&self, request: &Request) -> Result { + async fn stream(&self, request: &Request) -> Result { *self .captured_request .lock() @@ -358,7 +360,7 @@ impl ProviderAdapter for CapturingLlmProvider { /// A mock provider that yields some text deltas then an error mid-stream. pub struct MockMidStreamErrorProvider { pub partial_text: String, - pub error: SdkError, + pub error: LlmError, } #[async_trait] @@ -367,12 +369,12 @@ impl ProviderAdapter for MockMidStreamErrorProvider { "mock" } - async fn complete(&self, _request: &Request) -> Result { + async fn complete(&self, _request: &Request) -> Result { Err(self.error.clone()) } - async fn stream(&self, _request: &Request) -> Result { - let events: Vec> = vec![ + async fn stream(&self, _request: &Request) -> Result { + let events: Vec> = vec![ Ok(StreamEvent::text_delta(self.partial_text.clone(), None)), Err(self.error.clone()), ]; @@ -391,24 +393,23 @@ pub fn multi_tool_call_response(calls: Vec<(&str, &str, serde_json::Value)>) -> ))); } Response { - id: "resp_multi".into(), - model: "mock-model".into(), - provider: "mock".into(), - message: Message { + id: "resp_multi".into(), + model: "mock-model".into(), + provider: "mock".into(), + message: Message { role: Role::Assistant, content, name: None, tool_call_id: None, }, finish_reason: FinishReason::ToolCalls, - usage: Usage { + usage: TokenCounts { input_tokens: 10, output_tokens: 5, - total_tokens: 15, ..Default::default() }, - raw: None, - warnings: vec![], - rate_limit: None, + raw: None, + warnings: vec![], + rate_limit: None, } } diff --git a/lib/crates/fabro-agent/src/tool_execution.rs b/lib/crates/fabro-agent/src/tool_execution.rs index b79184625..31bcfbf1a 100644 --- a/lib/crates/fabro-agent/src/tool_execution.rs +++ b/lib/crates/fabro-agent/src/tool_execution.rs @@ -1,17 +1,20 @@ -use crate::config::{SessionConfig, ToolHookCallback, ToolHookDecision}; -use crate::event::EventEmitter; +use std::collections::HashMap; +use std::sync::Arc; + +use fabro_llm::types::{ToolCall, ToolResult}; +use futures::future; +use tokio_util::sync::CancellationToken; +use tracing::debug; + +use crate::config::{SessionOptions, ToolHookCallback, ToolHookDecision}; +use crate::event::Emitter; use crate::sandbox::Sandbox; use crate::tool_registry::{RegisteredTool, ToolContext, ToolRegistry}; use crate::truncation::truncate_tool_output; use crate::types::AgentEvent; -use fabro_llm::types::{ToolCall, ToolResult}; -use futures::future; -use std::collections::HashMap; -use std::sync::Arc; -use tokio_util::sync::CancellationToken; -use tracing::debug; -/// Execute tool calls, choosing parallel or sequential based on `parallel` flag. +/// Execute tool calls, choosing parallel or sequential based on `parallel` +/// flag. #[allow(clippy::too_many_arguments)] pub async fn execute_tool_calls( tool_calls: &[ToolCall], @@ -20,8 +23,8 @@ pub async fn execute_tool_calls( env: Arc, tool_hooks: Option<&Arc>, cancel_token: &CancellationToken, - config: &SessionConfig, - emitter: &EventEmitter, + config: &SessionOptions, + emitter: &Emitter, session_id: &str, tool_env: Option<&HashMap>, ) -> Vec { @@ -61,8 +64,8 @@ async fn execute_tool_calls_sequential( env: Arc, tool_hooks: Option<&Arc>, cancel_token: &CancellationToken, - config: &SessionConfig, - emitter: &EventEmitter, + config: &SessionOptions, + emitter: &Emitter, session_id: &str, tool_env: Option<&HashMap>, ) -> Vec { @@ -97,8 +100,8 @@ async fn execute_tool_calls_parallel( env: Arc, tool_hooks: Option<&Arc>, cancel_token: &CancellationToken, - config: &SessionConfig, - emitter: &EventEmitter, + config: &SessionOptions, + emitter: &Emitter, session_id: &str, tool_env: Option<&HashMap>, ) -> Vec { @@ -144,8 +147,8 @@ pub async fn execute_and_emit_one_tool( env: Arc, tool_hooks: Option<&Arc>, cancel_token: CancellationToken, - config: &SessionConfig, - emitter: &EventEmitter, + config: &SessionOptions, + emitter: &Emitter, session_id: &str, tool_env: Option<&HashMap>, ) -> ToolResult { @@ -163,7 +166,8 @@ pub async fn execute_and_emit_one_tool( .await } -/// Execute a single tool call with event emission, using a pre-looked-up tool reference. +/// Execute a single tool call with event emission, using a pre-looked-up tool +/// reference. #[allow(clippy::too_many_arguments)] async fn execute_and_emit_one_tool_with_lookup( tc: &ToolCall, @@ -171,19 +175,16 @@ async fn execute_and_emit_one_tool_with_lookup( env: Arc, tool_hooks: Option<&Arc>, cancel_token: CancellationToken, - config: &SessionConfig, - emitter: &EventEmitter, + config: &SessionOptions, + emitter: &Emitter, session_id: &str, tool_env: Option<&HashMap>, ) -> ToolResult { - emitter.emit( - session_id.to_owned(), - AgentEvent::ToolCallStarted { - tool_name: tc.name.clone(), - tool_call_id: tc.id.clone(), - arguments: tc.arguments.clone(), - }, - ); + emitter.emit(session_id.to_owned(), AgentEvent::ToolCallStarted { + tool_name: tc.name.clone(), + tool_call_id: tc.id.clone(), + arguments: tc.arguments.clone(), + }); // Pre-tool-use hook if let Some(hooks) = tool_hooks { @@ -196,21 +197,15 @@ async fn execute_and_emit_one_tool_with_lookup( if let ToolHookDecision::Block { reason } = decision { let result = ToolResult::error(&tc.id, &reason); - emitter.emit( - session_id.to_owned(), - AgentEvent::ToolCallOutputDelta { - delta: result.content.to_string(), - }, - ); - emitter.emit( - session_id.to_owned(), - AgentEvent::ToolCallCompleted { - tool_name: tc.name.clone(), - tool_call_id: tc.id.clone(), - output: result.content.clone(), - is_error: true, - }, - ); + emitter.emit(session_id.to_owned(), AgentEvent::ToolCallOutputDelta { + delta: result.content.to_string(), + }); + emitter.emit(session_id.to_owned(), AgentEvent::ToolCallCompleted { + tool_name: tc.name.clone(), + tool_call_id: tc.id.clone(), + output: result.content.clone(), + is_error: true, + }); return truncate_tool_result(&result, &tc.name, config); } @@ -218,22 +213,16 @@ async fn execute_and_emit_one_tool_with_lookup( let result = execute_one_tool(tc, registered_tool, env, cancel_token, tool_env).await; - emitter.emit( - session_id.to_owned(), - AgentEvent::ToolCallOutputDelta { - delta: result.content.to_string(), - }, - ); + emitter.emit(session_id.to_owned(), AgentEvent::ToolCallOutputDelta { + delta: result.content.to_string(), + }); - emitter.emit( - session_id.to_owned(), - AgentEvent::ToolCallCompleted { - tool_name: tc.name.clone(), - tool_call_id: tc.id.clone(), - output: result.content.clone(), - is_error: result.is_error, - }, - ); + emitter.emit(session_id.to_owned(), AgentEvent::ToolCallCompleted { + tool_name: tc.name.clone(), + tool_call_id: tc.id.clone(), + output: result.content.clone(), + is_error: result.is_error, + }); // Post-tool-use hooks if let Some(hooks) = tool_hooks { @@ -294,7 +283,7 @@ async fn execute_one_tool( fn truncate_tool_result( result: &ToolResult, tool_name: &str, - config: &SessionConfig, + config: &SessionOptions, ) -> ToolResult { let truncated_content = match &result.content { serde_json::Value::String(s) => { @@ -304,10 +293,10 @@ fn truncate_tool_result( }; ToolResult { - tool_call_id: result.tool_call_id.clone(), - content: truncated_content, - is_error: result.is_error, - image_data: result.image_data.clone(), + tool_call_id: result.tool_call_id.clone(), + content: truncated_content, + is_error: result.is_error, + image_data: result.image_data.clone(), image_media_type: result.image_media_type.clone(), } } @@ -343,9 +332,13 @@ pub fn validate_tool_args( #[cfg(test)] mod tests { + use std::sync::Mutex; + + use fabro_llm::types::{ToolCall, ToolDefinition}; + use super::*; use crate::config::{ToolHookCallback, ToolHookDecision}; - use crate::event::EventEmitter; + use crate::event::Emitter; use crate::local_sandbox::LocalSandbox; use crate::read_before_write_sandbox::ReadBeforeWriteSandbox; use crate::test_support::MutableMockSandbox; @@ -353,15 +346,13 @@ mod tests { use crate::tools::{ make_edit_file_tool, make_grep_tool, make_read_file_tool, make_write_file_tool, }; - use fabro_llm::types::{ToolCall, ToolDefinition}; - use std::sync::Mutex; fn make_echo_tool() -> RegisteredTool { RegisteredTool { definition: ToolDefinition { - name: "echo".to_string(), + name: "echo".to_string(), description: "Echo input".to_string(), - parameters: serde_json::json!({ + parameters: serde_json::json!({ "type": "object", "properties": { "text": {"type": "string"} @@ -369,7 +360,7 @@ mod tests { "required": ["text"] }), }, - executor: Arc::new(|args: serde_json::Value, _ctx: ToolContext| { + executor: Arc::new(|args: serde_json::Value, _ctx: ToolContext| { Box::pin(async move { let text = args["text"].as_str().unwrap_or("").to_string(); Ok(format!("echo: {text}")) @@ -381,11 +372,11 @@ mod tests { fn make_fail_tool() -> RegisteredTool { RegisteredTool { definition: ToolDefinition { - name: "fail_tool".to_string(), + name: "fail_tool".to_string(), description: "Always fails".to_string(), - parameters: serde_json::json!({}), + parameters: serde_json::json!({}), }, - executor: Arc::new(|_args: serde_json::Value, _ctx: ToolContext| { + executor: Arc::new(|_args: serde_json::Value, _ctx: ToolContext| { Box::pin(async move { Err("tool failed".to_string()) }) }), } @@ -393,26 +384,26 @@ mod tests { fn make_tool_call(name: &str, id: &str, args: serde_json::Value) -> ToolCall { ToolCall { - id: id.to_string(), - name: name.to_string(), - tool_type: "function".to_string(), - arguments: args, - raw_arguments: None, + id: id.to_string(), + name: name.to_string(), + tool_type: "function".to_string(), + arguments: args, + raw_arguments: None, provider_metadata: None, } } struct MockHookCallback { - pre_decision: ToolHookDecision, - post_calls: Arc>>, + pre_decision: ToolHookDecision, + post_calls: Arc>>, post_failure_calls: Arc>>, } impl MockHookCallback { fn new(decision: ToolHookDecision) -> Self { Self { - pre_decision: decision, - post_calls: Arc::new(Mutex::new(Vec::new())), + pre_decision: decision, + post_calls: Arc::new(Mutex::new(Vec::new())), post_failure_calls: Arc::new(Mutex::new(Vec::new())), } } @@ -460,8 +451,8 @@ mod tests { })); let tc = make_tool_call("echo", "call_1", serde_json::json!({"text": "hello"})); - let emitter = EventEmitter::new(); - let config = SessionConfig::default(); + let emitter = Emitter::new(); + let config = SessionOptions::default(); let result = execute_and_emit_one_tool( &tc, @@ -490,8 +481,8 @@ mod tests { Arc::new(MockHookCallback::new(ToolHookDecision::Proceed)); let tc = make_tool_call("echo", "call_1", serde_json::json!({"text": "hello"})); - let emitter = EventEmitter::new(); - let config = SessionConfig::default(); + let emitter = Emitter::new(); + let config = SessionOptions::default(); let result = execute_and_emit_one_tool( &tc, @@ -520,8 +511,8 @@ mod tests { let hooks: Arc = mock.clone(); let tc = make_tool_call("echo", "call_1", serde_json::json!({"text": "hello"})); - let emitter = EventEmitter::new(); - let config = SessionConfig::default(); + let emitter = Emitter::new(); + let config = SessionOptions::default(); execute_and_emit_one_tool( &tc, @@ -555,8 +546,8 @@ mod tests { let hooks: Arc = mock.clone(); let tc = make_tool_call("fail_tool", "call_1", serde_json::json!({})); - let emitter = EventEmitter::new(); - let config = SessionConfig::default(); + let emitter = Emitter::new(); + let config = SessionOptions::default(); execute_and_emit_one_tool( &tc, @@ -587,8 +578,8 @@ mod tests { registry.register(make_echo_tool()); let tc = make_tool_call("echo", "call_1", serde_json::json!({"text": "hello"})); - let emitter = EventEmitter::new(); - let config = SessionConfig::default(); + let emitter = Emitter::new(); + let config = SessionOptions::default(); let result = execute_and_emit_one_tool( &tc, @@ -627,8 +618,8 @@ mod tests { "call_1", serde_json::json!({"file_path": "a.ts", "content": "new"}), ); - let emitter = EventEmitter::new(); - let config = SessionConfig::default(); + let emitter = Emitter::new(); + let config = SessionOptions::default(); let result = execute_and_emit_one_tool( &tc, @@ -654,8 +645,8 @@ mod tests { registry.register(make_write_file_tool()); let sandbox = make_guarded_sandbox(HashMap::from([("a.ts".into(), "content".into())])); - let emitter = EventEmitter::new(); - let config = SessionConfig::default(); + let emitter = Emitter::new(); + let config = SessionOptions::default(); // First read the file let read_tc = make_tool_call( @@ -706,8 +697,8 @@ mod tests { registry.register(make_write_file_tool()); let sandbox = make_guarded_sandbox(HashMap::from([("a.ts".into(), "content".into())])); - let emitter = EventEmitter::new(); - let config = SessionConfig::default(); + let emitter = Emitter::new(); + let config = SessionOptions::default(); // Grep matching a.ts let grep_tc = make_tool_call("grep", "call_1", serde_json::json!({"pattern": "content"})); @@ -758,8 +749,8 @@ mod tests { "call_1", serde_json::json!({"file_path": "a.ts", "old_string": "content", "new_string": "updated"}), ); - let emitter = EventEmitter::new(); - let config = SessionConfig::default(); + let emitter = Emitter::new(); + let config = SessionOptions::default(); let result = execute_and_emit_one_tool( &tc, @@ -789,8 +780,8 @@ mod tests { "call_1", serde_json::json!({"file_path": "new.ts", "content": "hello"}), ); - let emitter = EventEmitter::new(); - let config = SessionConfig::default(); + let emitter = Emitter::new(); + let config = SessionOptions::default(); let result = execute_and_emit_one_tool( &tc, diff --git a/lib/crates/fabro-agent/src/tool_registry.rs b/lib/crates/fabro-agent/src/tool_registry.rs index 79809a406..cc0984249 100644 --- a/lib/crates/fabro-agent/src/tool_registry.rs +++ b/lib/crates/fabro-agent/src/tool_registry.rs @@ -1,14 +1,16 @@ -use crate::sandbox::Sandbox; -use fabro_llm::types::ToolDefinition; use std::collections::HashMap; use std::future::Future; use std::pin::Pin; use std::sync::Arc; + +use fabro_llm::types::ToolDefinition; use tokio_util::sync::CancellationToken; +use crate::sandbox::Sandbox; + pub struct ToolContext { - pub env: Arc, - pub cancel: CancellationToken, + pub env: Arc, + pub cancel: CancellationToken, pub tool_env: Option>, } @@ -24,7 +26,7 @@ pub type ToolExecutor = Arc< #[derive(Clone)] pub struct RegisteredTool { pub definition: ToolDefinition, - pub executor: ToolExecutor, + pub executor: ToolExecutor, } pub struct ToolRegistry { @@ -78,11 +80,11 @@ mod tests { fn make_tool(name: &str) -> RegisteredTool { RegisteredTool { definition: ToolDefinition { - name: name.into(), + name: name.into(), description: format!("Tool {name}"), - parameters: serde_json::json!({"type": "object"}), + parameters: serde_json::json!({"type": "object"}), }, - executor: Arc::new(|_args, _ctx| Box::pin(async { Ok("ok".into()) })), + executor: Arc::new(|_args, _ctx| Box::pin(async { Ok("ok".into()) })), } } @@ -122,19 +124,19 @@ mod tests { let mut registry = ToolRegistry::new(); registry.register(RegisteredTool { definition: ToolDefinition { - name: "tool_a".into(), + name: "tool_a".into(), description: "version 1".into(), - parameters: serde_json::json!({}), + parameters: serde_json::json!({}), }, - executor: Arc::new(|_args, _ctx| Box::pin(async { Ok("v1".into()) })), + executor: Arc::new(|_args, _ctx| Box::pin(async { Ok("v1".into()) })), }); registry.register(RegisteredTool { definition: ToolDefinition { - name: "tool_a".into(), + name: "tool_a".into(), description: "version 2".into(), - parameters: serde_json::json!({}), + parameters: serde_json::json!({}), }, - executor: Arc::new(|_args, _ctx| Box::pin(async { Ok("v2".into()) })), + executor: Arc::new(|_args, _ctx| Box::pin(async { Ok("v2".into()) })), }); let tool = registry.get("tool_a").unwrap(); diff --git a/lib/crates/fabro-agent/src/tools.rs b/lib/crates/fabro-agent/src/tools.rs index a78c92bf3..c3b1a8bc5 100644 --- a/lib/crates/fabro-agent/src/tools.rs +++ b/lib/crates/fabro-agent/src/tools.rs @@ -1,20 +1,22 @@ -use crate::config::SessionConfig; -use crate::sandbox::GrepOptions; -use crate::tool_registry::{RegisteredTool, ToolRegistry}; -use fabro_llm::client::Client; -use fabro_llm::types::{Message, Request, ToolDefinition}; -use fabro_model::ModelRef; use std::borrow::Cow; use std::fmt::Write; use std::sync::Arc; +use fabro_llm::client::Client; +use fabro_llm::types::{Message, Request, ToolDefinition}; +use fabro_model::ModelHandle; + +use crate::config::SessionOptions; +use crate::sandbox::GrepOptions; +use crate::tool_registry::{RegisteredTool, ToolRegistry}; + const MAX_WEB_FETCH_BYTES: usize = 100 * 1024; /// Configuration for the optional LLM-based summarizer used by `web_fetch`. #[derive(Clone)] pub struct WebFetchSummarizer { - pub client: Client, - pub model_id: ModelRef, + pub client: Client, + pub model_id: ModelHandle, } /// Returns true if the input looks like it contains HTML markup. @@ -40,15 +42,15 @@ fn html_to_markdown(text: &str) -> String { converter.convert(text).unwrap_or_else(|_| text.to_string()) } -/// Registers the core tools shared by all provider profiles: `read_file`, `write_file`, -/// `shell`, `grep`, `glob`, `web_search`, and `web_fetch`. +/// Registers the core tools shared by all provider profiles: `read_file`, +/// `write_file`, `shell`, `grep`, `glob`, `web_search`, and `web_fetch`. /// -/// The shell tool uses `config` to set its default and max timeouts. Pass a custom -/// `SessionConfig` (e.g. with a longer `default_command_timeout_ms`) for providers -/// that need non-default shell behavior. +/// The shell tool uses `config` to set its default and max timeouts. Pass a +/// custom `SessionOptions` (e.g. with a longer `default_command_timeout_ms`) +/// for providers that need non-default shell behavior. pub fn register_core_tools( registry: &mut ToolRegistry, - config: &SessionConfig, + config: &SessionOptions, summarizer: Option, ) { registry.register(make_read_file_tool()); @@ -70,9 +72,9 @@ pub(crate) fn required_str<'a>(args: &'a serde_json::Value, key: &str) -> Result pub fn make_read_file_tool() -> RegisteredTool { RegisteredTool { definition: ToolDefinition { - name: "read_file".into(), + name: "read_file".into(), description: "Read the contents of a file".into(), - parameters: serde_json::json!({ + parameters: serde_json::json!({ "type": "object", "properties": { "file_path": {"type": "string", "description": "Absolute path to the file"}, @@ -82,7 +84,7 @@ pub fn make_read_file_tool() -> RegisteredTool { "required": ["file_path"] }), }, - executor: Arc::new(|args, ctx| { + executor: Arc::new(|args, ctx| { Box::pin(async move { let file_path = required_str(&args, "file_path")?; let offset = args.get("offset").and_then(serde_json::Value::as_u64); @@ -106,9 +108,9 @@ pub fn make_read_file_tool() -> RegisteredTool { pub fn make_write_file_tool() -> RegisteredTool { RegisteredTool { definition: ToolDefinition { - name: "write_file".into(), + name: "write_file".into(), description: "Write content to a file".into(), - parameters: serde_json::json!({ + parameters: serde_json::json!({ "type": "object", "properties": { "file_path": {"type": "string", "description": "Absolute path to the file"}, @@ -117,7 +119,7 @@ pub fn make_write_file_tool() -> RegisteredTool { "required": ["file_path", "content"] }), }, - executor: Arc::new(|args, ctx| { + executor: Arc::new(|args, ctx| { Box::pin(async move { let file_path = required_str(&args, "file_path")?; let content = required_str(&args, "content")?; @@ -133,9 +135,9 @@ pub fn make_write_file_tool() -> RegisteredTool { pub fn make_edit_file_tool() -> RegisteredTool { RegisteredTool { definition: ToolDefinition { - name: "edit_file".into(), + name: "edit_file".into(), description: "Edit a file by replacing a string".into(), - parameters: serde_json::json!({ + parameters: serde_json::json!({ "type": "object", "properties": { "file_path": {"type": "string", "description": "Absolute path to the file"}, @@ -146,7 +148,7 @@ pub fn make_edit_file_tool() -> RegisteredTool { "required": ["file_path", "old_string", "new_string"] }), }, - executor: Arc::new(|args, ctx| { + executor: Arc::new(|args, ctx| { Box::pin(async move { let file_path = required_str(&args, "file_path")?; let old_string = required_str(&args, "old_string")?; @@ -190,18 +192,18 @@ pub fn make_edit_file_tool() -> RegisteredTool { #[must_use] pub fn make_shell_tool() -> RegisteredTool { - make_shell_tool_with_config(&SessionConfig::default()) + make_shell_tool_with_config(&SessionOptions::default()) } #[must_use] -pub fn make_shell_tool_with_config(config: &SessionConfig) -> RegisteredTool { +pub fn make_shell_tool_with_config(config: &SessionOptions) -> RegisteredTool { let default_timeout = config.default_command_timeout_ms; let max_timeout = config.max_command_timeout_ms; RegisteredTool { definition: ToolDefinition { - name: "shell".into(), + name: "shell".into(), description: "Execute a shell command".into(), - parameters: serde_json::json!({ + parameters: serde_json::json!({ "type": "object", "properties": { "command": {"type": "string", "description": "The shell command to execute"}, @@ -211,7 +213,7 @@ pub fn make_shell_tool_with_config(config: &SessionConfig) -> RegisteredTool { "required": ["command"] }), }, - executor: Arc::new(move |args, ctx| { + executor: Arc::new(move |args, ctx| { Box::pin(async move { let command = required_str(&args, "command")?; let timeout_ms = args @@ -257,9 +259,9 @@ pub fn make_shell_tool_with_config(config: &SessionConfig) -> RegisteredTool { pub fn make_grep_tool() -> RegisteredTool { RegisteredTool { definition: ToolDefinition { - name: "grep".into(), + name: "grep".into(), description: "Search file contents with a regex pattern".into(), - parameters: serde_json::json!({ + parameters: serde_json::json!({ "type": "object", "properties": { "pattern": {"type": "string", "description": "Regex pattern to search for"}, @@ -271,7 +273,7 @@ pub fn make_grep_tool() -> RegisteredTool { "required": ["pattern"] }), }, - executor: Arc::new(|args, ctx| { + executor: Arc::new(|args, ctx| { Box::pin(async move { let pattern = required_str(&args, "pattern")?; let path = args @@ -314,9 +316,9 @@ pub fn make_grep_tool() -> RegisteredTool { pub fn make_glob_tool() -> RegisteredTool { RegisteredTool { definition: ToolDefinition { - name: "glob".into(), + name: "glob".into(), description: "Find files matching a glob pattern".into(), - parameters: serde_json::json!({ + parameters: serde_json::json!({ "type": "object", "properties": { "pattern": {"type": "string", "description": "Glob pattern to match files"}, @@ -325,7 +327,7 @@ pub fn make_glob_tool() -> RegisteredTool { "required": ["pattern"] }), }, - executor: Arc::new(|args, ctx| { + executor: Arc::new(|args, ctx| { Box::pin(async move { let pattern = required_str(&args, "pattern")?; let path = args.get("path").and_then(serde_json::Value::as_str); @@ -341,9 +343,9 @@ pub fn make_glob_tool() -> RegisteredTool { pub(crate) fn make_read_many_files_tool() -> RegisteredTool { RegisteredTool { definition: ToolDefinition { - name: "read_many_files".into(), + name: "read_many_files".into(), description: "Read multiple files at once".into(), - parameters: serde_json::json!({ + parameters: serde_json::json!({ "type": "object", "properties": { "paths": { @@ -355,7 +357,7 @@ pub(crate) fn make_read_many_files_tool() -> RegisteredTool { "required": ["paths"] }), }, - executor: Arc::new(|args, ctx| { + executor: Arc::new(|args, ctx| { Box::pin(async move { let paths = args["paths"] .as_array() @@ -386,9 +388,9 @@ pub(crate) fn make_read_many_files_tool() -> RegisteredTool { pub(crate) fn make_list_dir_tool() -> RegisteredTool { RegisteredTool { definition: ToolDefinition { - name: "list_dir".into(), + name: "list_dir".into(), description: "List directory contents with depth control".into(), - parameters: serde_json::json!({ + parameters: serde_json::json!({ "type": "object", "properties": { "path": {"type": "string", "description": "Directory path to list"}, @@ -397,7 +399,7 @@ pub(crate) fn make_list_dir_tool() -> RegisteredTool { "required": ["path"] }), }, - executor: Arc::new(|args, ctx| { + executor: Arc::new(|args, ctx| { Box::pin(async move { let path = required_str(&args, "path")?; let depth = args @@ -465,13 +467,13 @@ pub(crate) fn make_web_search_tool() -> RegisteredTool { fn make_web_search_tool_with_api_key(api_key: Option) -> RegisteredTool { use std::sync::OnceLock; - static CLIENT: OnceLock = OnceLock::new(); + static CLIENT: OnceLock = OnceLock::new(); RegisteredTool { definition: ToolDefinition { - name: "web_search".into(), + name: "web_search".into(), description: "Search the web using Brave Search".into(), - parameters: serde_json::json!({ + parameters: serde_json::json!({ "type": "object", "properties": { "query": {"type": "string", "description": "Search query"}, @@ -480,8 +482,7 @@ fn make_web_search_tool_with_api_key(api_key: Option) -> RegisteredTool "required": ["query"] }), }, - executor: Arc::new(move |args, _ctx| { - let client = CLIENT.get_or_init(reqwest::Client::new).clone(); + executor: Arc::new(move |args, _ctx| { let api_key = api_key.clone(); Box::pin(async move { let api_key = api_key.ok_or_else(|| { @@ -489,6 +490,11 @@ fn make_web_search_tool_with_api_key(api_key: Option) -> RegisteredTool })?; let query = required_str(&args, "query")?; + let client = CLIENT + .get_or_init(|| { + fabro_http::http_client().expect("Brave Search HTTP client should build") + }) + .clone(); let count = args .get("max_results") .and_then(serde_json::Value::as_u64) @@ -616,13 +622,15 @@ pub(crate) fn make_web_fetch_tool(summarizer: Option) -> Reg #[cfg(test)] mod tests { + use std::collections::HashMap; + + use fabro_llm::provider::ProviderAdapter; + use tokio_util::sync::CancellationToken; + use super::*; use crate::sandbox::*; use crate::test_support::MockSandbox; use crate::tool_registry::ToolContext; - use fabro_llm::provider::ProviderAdapter; - use std::collections::HashMap; - use tokio_util::sync::CancellationToken; #[tokio::test] async fn read_file_returns_content() { @@ -634,14 +642,11 @@ mod tests { apply_read_offset_limit: true, ..Default::default() }); - let result = (tool.executor)( - serde_json::json!({"file_path": "/test.txt"}), - ToolContext { - env, - cancel: CancellationToken::new(), - tool_env: None, - }, - ) + let result = (tool.executor)(serde_json::json!({"file_path": "/test.txt"}), ToolContext { + env, + cancel: CancellationToken::new(), + tool_env: None, + }) .await; assert_eq!(result.unwrap(), " 1 | hello\n 2 | world"); } @@ -679,8 +684,8 @@ mod tests { let result = (tool.executor)( serde_json::json!({"file_path": "/out.txt", "content": "hello"}), ToolContext { - env: env_clone, - cancel: CancellationToken::new(), + env: env_clone, + cancel: CancellationToken::new(), tool_env: None, }, ) @@ -709,8 +714,8 @@ mod tests { "new_string": "goodbye" }), ToolContext { - env: env_clone, - cancel: CancellationToken::new(), + env: env_clone, + cancel: CancellationToken::new(), tool_env: None, }, ) @@ -791,8 +796,8 @@ mod tests { "replace_all": true }), ToolContext { - env: env_clone, - cancel: CancellationToken::new(), + env: env_clone, + cancel: CancellationToken::new(), tool_env: None, }, ) @@ -808,22 +813,19 @@ mod tests { let tool = make_shell_tool(); let env: Arc = Arc::new(MockSandbox { exec_result: ExecResult { - stdout: "hello".into(), - stderr: String::new(), - exit_code: 0, - timed_out: false, + stdout: "hello".into(), + stderr: String::new(), + exit_code: 0, + timed_out: false, duration_ms: 10, }, ..Default::default() }); - let result = (tool.executor)( - serde_json::json!({"command": "echo hello"}), - ToolContext { - env, - cancel: CancellationToken::new(), - tool_env: None, - }, - ) + let result = (tool.executor)(serde_json::json!({"command": "echo hello"}), ToolContext { + env, + cancel: CancellationToken::new(), + tool_env: None, + }) .await; let output = result.unwrap(); assert!(output.contains("Exit code: 0")); @@ -838,8 +840,8 @@ mod tests { let _result = (tool.executor)( serde_json::json!({"command": "sleep 1", "timeout_ms": 5000}), ToolContext { - env: env_clone, - cancel: CancellationToken::new(), + env: env_clone, + cancel: CancellationToken::new(), tool_env: None, }, ) @@ -852,22 +854,19 @@ mod tests { let tool = make_shell_tool(); let env: Arc = Arc::new(MockSandbox { exec_result: ExecResult { - stdout: String::new(), - stderr: "error".into(), - exit_code: 1, - timed_out: false, + stdout: String::new(), + stderr: "error".into(), + exit_code: 1, + timed_out: false, duration_ms: 10, }, ..Default::default() }); - let result = (tool.executor)( - serde_json::json!({"command": "false"}), - ToolContext { - env, - cancel: CancellationToken::new(), - tool_env: None, - }, - ) + let result = (tool.executor)(serde_json::json!({"command": "false"}), ToolContext { + env, + cancel: CancellationToken::new(), + tool_env: None, + }) .await; let output = result.unwrap(); assert!(output.contains("Exit code: 1")); @@ -879,22 +878,19 @@ mod tests { let tool = make_shell_tool(); let env: Arc = Arc::new(MockSandbox { exec_result: ExecResult { - stdout: String::new(), - stderr: String::new(), - exit_code: -1, - timed_out: true, + stdout: String::new(), + stderr: String::new(), + exit_code: -1, + timed_out: true, duration_ms: 10000, }, ..Default::default() }); - let result = (tool.executor)( - serde_json::json!({"command": "sleep 100"}), - ToolContext { - env, - cancel: CancellationToken::new(), - tool_env: None, - }, - ) + let result = (tool.executor)(serde_json::json!({"command": "sleep 100"}), ToolContext { + env, + cancel: CancellationToken::new(), + tool_env: None, + }) .await; let output = result.unwrap(); assert!(output.starts_with("Command timed out.\n")); @@ -910,8 +906,8 @@ mod tests { let _result = (tool.executor)( serde_json::json!({"command": "echo $MY_KEY"}), ToolContext { - env: env_clone, - cancel: CancellationToken::new(), + env: env_clone, + cancel: CancellationToken::new(), tool_env: Some(tool_env.clone()), }, ) @@ -925,14 +921,11 @@ mod tests { let tool = make_shell_tool(); let env = Arc::new(MockSandbox::default()); let env_clone: Arc = env.clone(); - let _result = (tool.executor)( - serde_json::json!({"command": "echo hello"}), - ToolContext { - env: env_clone, - cancel: CancellationToken::new(), - tool_env: None, - }, - ) + let _result = (tool.executor)(serde_json::json!({"command": "echo hello"}), ToolContext { + env: env_clone, + cancel: CancellationToken::new(), + tool_env: None, + }) .await; let captured = env.captured_env_vars.lock().unwrap().clone(); assert_eq!(captured, None); @@ -943,10 +936,10 @@ mod tests { let tool = make_web_fetch_tool(None); let env = Arc::new(MockSandbox { exec_result: ExecResult { - stdout: "fetched content".into(), - stderr: String::new(), - exit_code: 0, - timed_out: false, + stdout: "fetched content".into(), + stderr: String::new(), + exit_code: 0, + timed_out: false, duration_ms: 100, }, ..Default::default() @@ -957,8 +950,8 @@ mod tests { let _result = (tool.executor)( serde_json::json!({"url": "https://example.com"}), ToolContext { - env: env_clone, - cancel: CancellationToken::new(), + env: env_clone, + cancel: CancellationToken::new(), tool_env: Some(tool_env.clone()), }, ) @@ -977,14 +970,11 @@ mod tests { ], ..Default::default() }); - let result = (tool.executor)( - serde_json::json!({"pattern": "fn"}), - ToolContext { - env, - cancel: CancellationToken::new(), - tool_env: None, - }, - ) + let result = (tool.executor)(serde_json::json!({"pattern": "fn"}), ToolContext { + env, + cancel: CancellationToken::new(), + tool_env: None, + }) .await; let output = result.unwrap(); assert!(output.contains("src/main.rs:10:fn main()")); @@ -998,14 +988,11 @@ mod tests { glob_results: vec!["src/main.rs".into(), "src/lib.rs".into()], ..Default::default() }); - let result = (tool.executor)( - serde_json::json!({"pattern": "src/**/*.rs"}), - ToolContext { - env, - cancel: CancellationToken::new(), - tool_env: None, - }, - ) + let result = (tool.executor)(serde_json::json!({"pattern": "src/**/*.rs"}), ToolContext { + env, + cancel: CancellationToken::new(), + tool_env: None, + }) .await; let output = result.unwrap(); assert!(output.contains("src/main.rs")); @@ -1016,14 +1003,11 @@ mod tests { async fn web_search_missing_api_key_returns_error() { let tool = make_web_search_tool_with_api_key(None); let env: Arc = Arc::new(MockSandbox::default()); - let result = (tool.executor)( - serde_json::json!({"query": "test"}), - ToolContext { - env, - cancel: CancellationToken::new(), - tool_env: None, - }, - ) + let result = (tool.executor)(serde_json::json!({"query": "test"}), ToolContext { + env, + cancel: CancellationToken::new(), + tool_env: None, + }) .await; let err = result.unwrap_err(); assert!( @@ -1036,14 +1020,11 @@ mod tests { async fn web_search_missing_query_returns_error() { let tool = make_web_search_tool_with_api_key(Some("fake-key".into())); let env: Arc = Arc::new(MockSandbox::default()); - let result = (tool.executor)( - serde_json::json!({}), - ToolContext { - env, - cancel: CancellationToken::new(), - tool_env: None, - }, - ) + let result = (tool.executor)(serde_json::json!({}), ToolContext { + env, + cancel: CancellationToken::new(), + tool_env: None, + }) .await; let err = result.unwrap_err(); assert!( @@ -1080,10 +1061,10 @@ mod tests { let tool = make_web_fetch_tool(None); let env = Arc::new(MockSandbox { exec_result: ExecResult { - stdout: "

hello

".into(), - stderr: String::new(), - exit_code: 0, - timed_out: false, + stdout: "

hello

".into(), + stderr: String::new(), + exit_code: 0, + timed_out: false, duration_ms: 100, }, ..Default::default() @@ -1092,8 +1073,8 @@ mod tests { let result = (tool.executor)( serde_json::json!({"url": "https://example.com"}), ToolContext { - env: env_clone, - cancel: CancellationToken::new(), + env: env_clone, + cancel: CancellationToken::new(), tool_env: None, }, ) @@ -1150,8 +1131,8 @@ mod tests { let _result = (tool.executor)( serde_json::json!({"url": "https://example.com", "timeout_ms": 15000}), ToolContext { - env: env_clone, - cancel: CancellationToken::new(), + env: env_clone, + cancel: CancellationToken::new(), tool_env: None, }, ) @@ -1172,8 +1153,8 @@ mod tests { let _result = (tool.executor)( serde_json::json!({"url": "https://example.com", "timeout_ms": 120_000}), ToolContext { - env: env_clone, - cancel: CancellationToken::new(), + env: env_clone, + cancel: CancellationToken::new(), tool_env: None, }, ) @@ -1192,10 +1173,10 @@ mod tests { let tool = make_web_fetch_tool(None); let env: Arc = Arc::new(MockSandbox { exec_result: ExecResult { - stdout: large_content, - stderr: String::new(), - exit_code: 0, - timed_out: false, + stdout: large_content, + stderr: String::new(), + exit_code: 0, + timed_out: false, duration_ms: 100, }, ..Default::default() @@ -1219,10 +1200,10 @@ mod tests { let tool = make_web_fetch_tool(None); let env: Arc = Arc::new(MockSandbox { exec_result: ExecResult { - stdout: String::new(), - stderr: "curl: (6) Could not resolve host".into(), - exit_code: 6, - timed_out: false, + stdout: String::new(), + stderr: "curl: (6) Could not resolve host".into(), + exit_code: 6, + timed_out: false, duration_ms: 100, }, ..Default::default() @@ -1257,19 +1238,20 @@ mod tests { let client = make_client(provider).await; let summarizer = WebFetchSummarizer { client, - model_id: ModelRef::ByName { + model_id: ModelHandle::ByName { provider: fabro_model::Provider::Anthropic, - model: "mock-model".to_string(), + model: "mock-model".to_string(), }, }; let tool = make_web_fetch_tool(Some(summarizer)); let env: Arc = Arc::new(MockSandbox { exec_result: ExecResult { - stdout: "

Lots of content about Rust...

".into(), - stderr: String::new(), - exit_code: 0, - timed_out: false, + stdout: "

Lots of content about Rust...

" + .into(), + stderr: String::new(), + exit_code: 0, + timed_out: false, duration_ms: 100, }, ..Default::default() @@ -1295,11 +1277,12 @@ mod tests { let tool = make_web_fetch_tool(None); let env: Arc = Arc::new(MockSandbox { exec_result: ExecResult { - stdout: "

Rust is a systems programming language.

" - .into(), - stderr: String::new(), - exit_code: 0, - timed_out: false, + stdout: + "

Rust is a systems programming language.

" + .into(), + stderr: String::new(), + exit_code: 0, + timed_out: false, duration_ms: 100, }, ..Default::default() @@ -1326,13 +1309,15 @@ mod tests { #[tokio::test] async fn web_fetch_summarizer_routes_to_specified_provider() { + use fabro_llm::Error as LlmError; + use fabro_llm::error::{ProviderErrorDetail, ProviderErrorKind}; + use crate::test_support::{MockErrorProvider, MockLlmProvider, text_response}; - use fabro_llm::error::{ProviderErrorDetail, ProviderErrorKind, SdkError}; // "other_provider" is the default — it rejects all requests. let default_provider: Arc = Arc::new(MockErrorProvider { - error: SdkError::Provider { - kind: ProviderErrorKind::NotFound, + error: LlmError::Provider { + kind: ProviderErrorKind::NotFound, detail: Box::new(ProviderErrorDetail::new( "model not found", "other_provider", @@ -1347,25 +1332,26 @@ mod tests { let mut providers = HashMap::new(); providers.insert("other_provider".to_string(), default_provider); - // Register under "anthropic" so ModelRef { provider: Anthropic, .. } routes here + // Register under "anthropic" so ModelRef { provider: Anthropic, .. } routes + // here providers.insert("anthropic".to_string(), target_provider); let client = Client::new(providers, Some("other_provider".into()), vec![]); let summarizer = WebFetchSummarizer { client, - model_id: ModelRef::ByName { + model_id: ModelHandle::ByName { provider: fabro_model::Provider::Anthropic, - model: "target-model".to_string(), + model: "target-model".to_string(), }, }; let tool = make_web_fetch_tool(Some(summarizer)); let env: Arc = Arc::new(MockSandbox { exec_result: ExecResult { - stdout: "

Page content

".into(), - stderr: String::new(), - exit_code: 0, - timed_out: false, + stdout: "

Page content

".into(), + stderr: String::new(), + exit_code: 0, + timed_out: false, duration_ms: 100, }, ..Default::default() @@ -1448,14 +1434,11 @@ mod tests { // read_file tool should mark the file as agent-read let tool = make_read_file_tool(); - (tool.executor)( - serde_json::json!({"file_path": "a.ts"}), - ToolContext { - env: Arc::clone(&env), - cancel: CancellationToken::new(), - tool_env: None, - }, - ) + (tool.executor)(serde_json::json!({"file_path": "a.ts"}), ToolContext { + env: Arc::clone(&env), + cancel: CancellationToken::new(), + tool_env: None, + }) .await .unwrap(); @@ -1480,14 +1463,11 @@ mod tests { // grep tool should mark matched files as agent-read let tool = make_grep_tool(); - (tool.executor)( - serde_json::json!({"pattern": "content"}), - ToolContext { - env: Arc::clone(&env), - cancel: CancellationToken::new(), - tool_env: None, - }, - ) + (tool.executor)(serde_json::json!({"pattern": "content"}), ToolContext { + env: Arc::clone(&env), + cancel: CancellationToken::new(), + tool_env: None, + }) .await .unwrap(); diff --git a/lib/crates/fabro-agent/src/truncation.rs b/lib/crates/fabro-agent/src/truncation.rs index 8ed70ec7c..78cfa1152 100644 --- a/lib/crates/fabro-agent/src/truncation.rs +++ b/lib/crates/fabro-agent/src/truncation.rs @@ -1,4 +1,4 @@ -use crate::config::SessionConfig; +use crate::config::SessionOptions; /// Round a byte index down to the nearest UTF-8 char boundary. /// Stable equivalent of `str::floor_char_boundary` (nightly-only). @@ -99,7 +99,7 @@ pub fn truncate_lines(output: &str, max_lines: usize) -> String { } #[must_use] -pub fn truncate_tool_output(output: &str, tool_name: &str, config: &SessionConfig) -> String { +pub fn truncate_tool_output(output: &str, tool_name: &str, config: &SessionOptions) -> String { let mode = default_truncation_mode(tool_name); // Char truncation first @@ -180,7 +180,7 @@ mod tests { // Create an output that is large in chars and many lines let long_line = "x".repeat(50_000); let output = format!("{long_line}\n{long_line}"); - let config = SessionConfig::default(); + let config = SessionOptions::default(); let result = truncate_tool_output(&output, "shell", &config); // Should have been char-truncated first (30k limit for shell) assert!(result.len() < output.len()); @@ -189,7 +189,7 @@ mod tests { #[test] fn config_override_char_limit() { let output = "x".repeat(5000); - let mut config = SessionConfig::default(); + let mut config = SessionOptions::default(); config.tool_output_limits.insert("my_tool".into(), 100); let result = truncate_tool_output(&output, "my_tool", &config); assert!(result.len() < output.len()); @@ -200,7 +200,7 @@ mod tests { fn config_override_line_limit() { let lines: Vec = (1..=100).map(|i| format!("line {i}")).collect(); let output = lines.join("\n"); - let mut config = SessionConfig::default(); + let mut config = SessionOptions::default(); config.tool_line_limits.insert("my_tool".into(), 10); let result = truncate_tool_output(&output, "my_tool", &config); assert!(result.contains("lines omitted")); @@ -209,7 +209,7 @@ mod tests { #[test] fn unknown_tool_no_truncation() { let output = "x".repeat(200); - let config = SessionConfig::default(); + let config = SessionOptions::default(); let result = truncate_tool_output(&output, "unknown_tool", &config); assert_eq!(result, output); } diff --git a/lib/crates/fabro-agent/src/types.rs b/lib/crates/fabro-agent/src/types.rs index d6fe2fb6a..965c98ee6 100644 --- a/lib/crates/fabro-agent/src/types.rs +++ b/lib/crates/fabro-agent/src/types.rs @@ -1,14 +1,17 @@ -use crate::error::AgentError; -use fabro_llm::error::SdkError; -use fabro_llm::types::{ContentPart, ThinkingData, ToolCall, ToolResult, Usage}; -use serde::{Deserialize, Serialize}; use std::time::SystemTime; +use fabro_llm::Error as LlmError; +use fabro_llm::types::{ContentPart, ThinkingData, TokenCounts, ToolCall, ToolResult}; +use serde::{Deserialize, Serialize}; + +use crate::error::Error; + mod system_time_iso8601 { + use std::time::SystemTime; + use chrono::{DateTime, SecondsFormat, Utc}; use serde::de::Error as DeError; use serde::{self, Deserialize, Deserializer, Serializer}; - use std::time::SystemTime; pub(super) fn serialize(time: &SystemTime, serializer: S) -> Result where @@ -31,40 +34,43 @@ mod system_time_iso8601 { #[derive(Debug, Clone)] pub enum Turn { User { - content: String, + content: String, timestamp: SystemTime, }, Assistant { - content: String, - tool_calls: Vec, + content: String, + tool_calls: Vec, /// Provider-specific content parts (e.g. `OpenAI` reasoning items, - /// `Anthropic` thinking blocks with signatures) preserved for round-tripping. - /// Reasoning/thinking text is stored here as `ContentPart::Thinking`. + /// `Anthropic` thinking blocks with signatures) preserved for + /// round-tripping. Reasoning/thinking text is stored here as + /// `ContentPart::Thinking`. provider_parts: Vec, - usage: Box, - response_id: String, - timestamp: SystemTime, + usage: Box, + response_id: String, + timestamp: SystemTime, }, ToolResults { - results: Vec, + results: Vec, timestamp: SystemTime, }, - /// Injected content sent as a system-role message to the LLM (maps to `Role::System`). + /// Injected content sent as a system-role message to the LLM (maps to + /// `Role::System`). System { - content: String, + content: String, timestamp: SystemTime, }, - /// Injected steering content sent as a user-role message to the LLM (maps to `Role::User`). - /// Used to guide the assistant's behavior mid-conversation without appearing as actual user input. + /// Injected steering content sent as a user-role message to the LLM (maps + /// to `Role::User`). Used to guide the assistant's behavior + /// mid-conversation without appearing as actual user input. Steering { - content: String, + content: String, timestamp: SystemTime, }, } impl Turn { - /// Extract the first non-redacted thinking/reasoning text from an `Assistant` turn's - /// `provider_parts`, if any. + /// Extract the first non-redacted thinking/reasoning text from an + /// `Assistant` turn's `provider_parts`, if any. #[must_use] pub fn reasoning_text(&self) -> Option<&str> { let Self::Assistant { provider_parts, .. } = self else { @@ -91,7 +97,12 @@ pub enum SessionState { #[derive(Debug, Clone, Serialize, Deserialize)] pub enum AgentEvent { - SessionStarted, + SessionStarted { + #[serde(default, skip_serializing_if = "Option::is_none")] + provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + model: Option, + }, SessionEnded, ProcessingEnd, UserInput { @@ -100,14 +111,14 @@ pub enum AgentEvent { AssistantTextStart, /// Replaces the current in-progress assistant output buffers. AssistantOutputReplace { - text: String, + text: String, #[serde(default, skip_serializing_if = "Option::is_none")] reasoning: Option, }, AssistantMessage { - text: String, - model: String, - usage: Usage, + text: String, + model: String, + usage: TokenCounts, tool_call_count: usize, }, TextDelta { @@ -117,24 +128,24 @@ pub enum AgentEvent { delta: String, }, ToolCallStarted { - tool_name: String, + tool_name: String, tool_call_id: String, - arguments: serde_json::Value, + arguments: serde_json::Value, }, ToolCallOutputDelta { delta: String, }, ToolCallCompleted { - tool_name: String, + tool_name: String, tool_call_id: String, - output: serde_json::Value, - is_error: bool, + output: serde_json::Value, + is_error: bool, }, Error { - error: AgentError, + error: Error, }, Warning { - kind: String, + kind: String, message: String, details: serde_json::Value, }, @@ -149,58 +160,77 @@ pub enum AgentEvent { text: String, }, CompactionStarted { - estimated_tokens: usize, + estimated_tokens: usize, context_window_size: usize, }, CompactionCompleted { - original_turn_count: usize, - preserved_turn_count: usize, + original_turn_count: usize, + preserved_turn_count: usize, summary_token_estimate: usize, - tracked_file_count: usize, + tracked_file_count: usize, }, LlmRetry { - provider: String, - model: String, - attempt: usize, + provider: String, + model: String, + attempt: usize, delay_secs: f64, - error: SdkError, + error: LlmError, }, SubAgentSpawned { agent_id: String, - depth: usize, - task: String, + depth: usize, + task: String, }, SubAgentCompleted { - agent_id: String, - depth: usize, - success: bool, + agent_id: String, + depth: usize, + success: bool, turns_used: usize, }, SubAgentFailed { agent_id: String, - depth: usize, - error: AgentError, + depth: usize, + error: Error, }, SubAgentClosed { agent_id: String, - depth: usize, + depth: usize, }, McpServerReady { server_name: String, - tool_count: usize, + tool_count: usize, }, McpServerFailed { server_name: String, - error: String, + error: String, }, } impl AgentEvent { + /// Returns `true` for streaming-delta and UI-noise variants that are + /// typically filtered out before forwarding to the workflow event stream. + pub fn is_streaming_noise(&self) -> bool { + matches!( + self, + Self::AssistantTextStart + | Self::AssistantOutputReplace { .. } + | Self::TextDelta { .. } + | Self::ReasoningDelta { .. } + | Self::ToolCallOutputDelta { .. } + | Self::SkillExpanded { .. } + ) + } + pub fn trace(&self, session_id: &str) { use tracing::{debug, error, info, warn}; match self { - Self::SessionStarted => { - info!(session_id, "Agent session started"); + Self::SessionStarted { provider, model } => { + info!( + session_id, + provider = provider.as_deref().unwrap_or(""), + model = model.as_deref().unwrap_or(""), + "Agent session started" + ); } Self::SessionEnded => { info!(session_id, "Agent session ended"); @@ -382,10 +412,10 @@ impl AgentEvent { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SessionEvent { - pub event: AgentEvent, + pub event: AgentEvent, #[serde(with = "system_time_iso8601")] - pub timestamp: SystemTime, - pub session_id: String, + pub timestamp: SystemTime, + pub session_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub parent_session_id: Option, } @@ -397,12 +427,18 @@ mod tests { #[test] fn session_event_construction() { let event = SessionEvent { - event: AgentEvent::SessionStarted, - timestamp: SystemTime::now(), - session_id: "sess_1".into(), + event: AgentEvent::SessionStarted { + provider: Some("anthropic".into()), + model: Some("claude-opus".into()), + }, + timestamp: SystemTime::now(), + session_id: "sess_1".into(), parent_session_id: None, }; - assert!(matches!(event.event, AgentEvent::SessionStarted)); + assert!(matches!(event.event, AgentEvent::SessionStarted { + provider: Some(_), + model: Some(_), + })); assert_eq!(event.session_id, "sess_1"); assert_eq!(event.parent_session_id, None); } @@ -410,30 +446,24 @@ mod tests { #[test] fn compaction_events_constructible() { let started = AgentEvent::CompactionStarted { - estimated_tokens: 5000, + estimated_tokens: 5000, context_window_size: 8000, }; - assert!(matches!( - started, - AgentEvent::CompactionStarted { - estimated_tokens: 5000, - .. - } - )); + assert!(matches!(started, AgentEvent::CompactionStarted { + estimated_tokens: 5000, + .. + })); let completed = AgentEvent::CompactionCompleted { - original_turn_count: 20, - preserved_turn_count: 6, + original_turn_count: 20, + preserved_turn_count: 6, summary_token_estimate: 500, - tracked_file_count: 3, + tracked_file_count: 3, }; - assert!(matches!( - completed, - AgentEvent::CompactionCompleted { - original_turn_count: 20, - .. - } - )); + assert!(matches!(completed, AgentEvent::CompactionCompleted { + original_turn_count: 20, + .. + })); } #[test] @@ -450,39 +480,36 @@ mod tests { fn subagent_spawned_constructible() { let event = AgentEvent::SubAgentSpawned { agent_id: "sa-1".into(), - depth: 1, - task: "list files".into(), + depth: 1, + task: "list files".into(), }; - assert!(matches!( - event, - AgentEvent::SubAgentSpawned { depth: 1, .. } - )); + assert!(matches!(event, AgentEvent::SubAgentSpawned { + depth: 1, + .. + })); } #[test] fn subagent_completed_constructible() { let event = AgentEvent::SubAgentCompleted { - agent_id: "sa-1".into(), - depth: 1, - success: true, + agent_id: "sa-1".into(), + depth: 1, + success: true, turns_used: 5, }; - assert!(matches!( - event, - AgentEvent::SubAgentCompleted { - success: true, - turns_used: 5, - .. - } - )); + assert!(matches!(event, AgentEvent::SubAgentCompleted { + success: true, + turns_used: 5, + .. + })); } #[test] fn subagent_failed_constructible() { let event = AgentEvent::SubAgentFailed { agent_id: "sa-1".into(), - depth: 0, - error: AgentError::ToolExecution("timeout".into()), + depth: 0, + error: Error::ToolExecution("timeout".into()), }; assert!(matches!(event, AgentEvent::SubAgentFailed { depth: 0, .. })); } @@ -491,7 +518,7 @@ mod tests { fn subagent_closed_constructible() { let event = AgentEvent::SubAgentClosed { agent_id: "sa-1".into(), - depth: 2, + depth: 2, }; assert!(matches!(event, AgentEvent::SubAgentClosed { depth: 2, .. })); } @@ -501,23 +528,23 @@ mod tests { let events = vec![ AgentEvent::SubAgentSpawned { agent_id: "sa-1".into(), - depth: 0, - task: "test".into(), + depth: 0, + task: "test".into(), }, AgentEvent::SubAgentCompleted { - agent_id: "sa-1".into(), - depth: 0, - success: true, + agent_id: "sa-1".into(), + depth: 0, + success: true, turns_used: 3, }, AgentEvent::SubAgentFailed { agent_id: "sa-1".into(), - depth: 0, - error: AgentError::ToolExecution("oops".into()), + depth: 0, + error: Error::ToolExecution("oops".into()), }, AgentEvent::SubAgentClosed { agent_id: "sa-1".into(), - depth: 0, + depth: 0, }, ]; let json = serde_json::to_string(&events).unwrap(); @@ -528,9 +555,12 @@ mod tests { #[test] fn session_event_serde_round_trip_without_parent_session_id() { let event = SessionEvent { - event: AgentEvent::SessionStarted, - timestamp: SystemTime::now(), - session_id: "sess_42".into(), + event: AgentEvent::SessionStarted { + provider: Some("anthropic".into()), + model: Some("claude-opus".into()), + }, + timestamp: SystemTime::now(), + session_id: "sess_42".into(), parent_session_id: None, }; let json = serde_json::to_string(&event).unwrap(); @@ -543,15 +573,21 @@ mod tests { let deserialized: SessionEvent = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized.session_id, "sess_42"); assert_eq!(deserialized.parent_session_id, None); - assert!(matches!(deserialized.event, AgentEvent::SessionStarted)); + assert!(matches!(deserialized.event, AgentEvent::SessionStarted { + provider: Some(_), + model: Some(_), + })); } #[test] fn session_event_serde_round_trip_with_parent_session_id() { let event = SessionEvent { - event: AgentEvent::SessionStarted, - timestamp: SystemTime::now(), - session_id: "sess_child".into(), + event: AgentEvent::SessionStarted { + provider: Some("openai".into()), + model: Some("gpt-5.4".into()), + }, + timestamp: SystemTime::now(), + session_id: "sess_child".into(), parent_session_id: Some("sess_parent".into()), }; let json = serde_json::to_string(&event).unwrap(); @@ -570,19 +606,19 @@ mod tests { fn mcp_server_ready_constructible() { let event = AgentEvent::McpServerReady { server_name: "filesystem".into(), - tool_count: 3, + tool_count: 3, }; - assert!(matches!( - event, - AgentEvent::McpServerReady { tool_count: 3, .. } - )); + assert!(matches!(event, AgentEvent::McpServerReady { + tool_count: 3, + .. + })); } #[test] fn mcp_server_failed_constructible() { let event = AgentEvent::McpServerFailed { server_name: "broken".into(), - error: "connection refused".into(), + error: "connection refused".into(), }; assert!( matches!(event, AgentEvent::McpServerFailed { server_name, .. } if server_name == "broken") @@ -594,20 +630,20 @@ mod tests { let events = vec![ AgentEvent::McpServerReady { server_name: "fs".into(), - tool_count: 5, + tool_count: 5, }, AgentEvent::McpServerFailed { server_name: "bad".into(), - error: "timeout".into(), + error: "timeout".into(), }, ]; let json = serde_json::to_string(&events).unwrap(); let deserialized: Vec = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized.len(), 2); - assert!(matches!( - &deserialized[0], - AgentEvent::McpServerReady { tool_count: 5, .. } - )); + assert!(matches!(&deserialized[0], AgentEvent::McpServerReady { + tool_count: 5, + .. + })); assert!(matches!( &deserialized[1], AgentEvent::McpServerFailed { .. } @@ -616,20 +652,17 @@ mod tests { #[test] fn agent_event_assistant_message() { - let usage = Usage { - input_tokens: 100, - output_tokens: 50, - total_tokens: 150, - cache_read_tokens: Some(80), - cache_write_tokens: Some(10), - reasoning_tokens: Some(20), - speed: None, - raw: None, + let usage = TokenCounts { + input_tokens: 100, + output_tokens: 50, + cache_read_tokens: 80, + cache_write_tokens: 10, + reasoning_tokens: 20, }; let event = AgentEvent::AssistantMessage { - text: "Hello".into(), - model: "test-model".into(), - usage: usage.clone(), + text: "Hello".into(), + model: "test-model".into(), + usage: usage.clone(), tool_call_count: 2, }; match &event { @@ -640,8 +673,8 @@ mod tests { } => { assert_eq!(*tool_call_count, 2); assert_eq!(usage.input_tokens, 100); - assert_eq!(usage.cache_read_tokens, Some(80)); - assert_eq!(usage.reasoning_tokens, Some(20)); + assert_eq!(usage.cache_read_tokens, 80); + assert_eq!(usage.reasoning_tokens, 20); } _ => panic!("expected AssistantMessage"), } @@ -650,7 +683,7 @@ mod tests { #[test] fn agent_event_assistant_output_replace_roundtrip() { let event = AgentEvent::AssistantOutputReplace { - text: "Hello again".into(), + text: "Hello again".into(), reasoning: Some("Retrying from scratch".into()), }; let json = serde_json::to_string(&event).unwrap(); @@ -669,9 +702,9 @@ mod tests { #[test] fn error_event_serde_roundtrip_with_agent_error() { let event = AgentEvent::Error { - error: AgentError::Llm(SdkError::Network { + error: Error::Llm(LlmError::Network { message: "refused".into(), - source: None, + source: None, }), }; let json = serde_json::to_string(&event).unwrap(); @@ -688,19 +721,19 @@ mod tests { fn llm_retry_event_carries_sdk_error() { use fabro_llm::error::{ProviderErrorDetail, ProviderErrorKind}; let event = AgentEvent::LlmRetry { - provider: "openai".into(), - model: "gpt-4".into(), - attempt: 1, + provider: "openai".into(), + model: "gpt-4".into(), + attempt: 1, delay_secs: 2.0, - error: SdkError::Provider { - kind: ProviderErrorKind::RateLimit, + error: LlmError::Provider { + kind: ProviderErrorKind::RateLimit, detail: Box::new(ProviderErrorDetail { - message: "too fast".into(), - provider: "openai".into(), + message: "too fast".into(), + provider: "openai".into(), status_code: Some(429), - error_code: None, + error_code: None, retry_after: Some(2.0), - raw: None, + raw: None, }), }, }; @@ -719,8 +752,8 @@ mod tests { fn subagent_failed_carries_agent_error() { let event = AgentEvent::SubAgentFailed { agent_id: "sa-1".into(), - depth: 0, - error: AgentError::ToolExecution("cmd failed".into()), + depth: 0, + error: Error::ToolExecution("cmd failed".into()), }; let json = serde_json::to_string(&event).unwrap(); let deserialized: AgentEvent = serde_json::from_str(&json).unwrap(); @@ -735,11 +768,11 @@ mod tests { #[test] fn error_event_preserves_error_type_through_json() { let event = AgentEvent::Error { - error: AgentError::ToolExecution("cmd failed".into()), + error: Error::ToolExecution("cmd failed".into()), }; let json = serde_json::to_string(&event).unwrap(); let v: serde_json::Value = serde_json::from_str(&json).unwrap(); - // The error field should contain the AgentError's tagged type + // The error field should contain the Error's tagged type assert_eq!(v["Error"]["error"]["type"], "tool_execution"); } @@ -747,7 +780,7 @@ mod tests { fn mcp_server_failed_still_string() { let event = AgentEvent::McpServerFailed { server_name: "broken".into(), - error: "connection refused".into(), + error: "connection refused".into(), }; let json = serde_json::to_string(&event).unwrap(); let deserialized: AgentEvent = serde_json::from_str(&json).unwrap(); diff --git a/lib/crates/fabro-agent/src/v4a_patch.rs b/lib/crates/fabro-agent/src/v4a_patch.rs index 167c6a7ba..eb45971b1 100644 --- a/lib/crates/fabro-agent/src/v4a_patch.rs +++ b/lib/crates/fabro-agent/src/v4a_patch.rs @@ -1,8 +1,10 @@ +use std::sync::Arc; + +use fabro_llm::types::ToolDefinition; + use crate::sandbox::{Sandbox, format_lines_numbered}; use crate::tool_registry::RegisteredTool; use crate::truncation::{TruncationMode, truncate_output}; -use fabro_llm::types::ToolDefinition; -use std::sync::Arc; #[derive(Debug, Clone, PartialEq, Eq)] pub enum Change { @@ -14,23 +16,23 @@ pub enum Change { #[derive(Debug, Clone, PartialEq, Eq)] pub struct Hunk { pub context_line: String, - pub changes: Vec, - pub end_of_file: bool, + pub changes: Vec, + pub end_of_file: bool, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum PatchOperation { Add { - path: String, + path: String, content: String, }, Delete { path: String, }, Update { - path: String, + path: String, new_path: Option, - hunks: Vec, + hunks: Vec, }, } @@ -400,9 +402,9 @@ fn format_patch_error(error: &str, path: &str, content: &str) -> String { pub fn make_apply_patch_tool() -> RegisteredTool { RegisteredTool { definition: ToolDefinition { - name: "apply_patch".into(), + name: "apply_patch".into(), description: "Apply a v4a format patch to modify files".into(), - parameters: serde_json::json!({ + parameters: serde_json::json!({ "type": "object", "properties": { "patch": { @@ -413,7 +415,7 @@ pub fn make_apply_patch_tool() -> RegisteredTool { "required": ["patch"] }), }, - executor: Arc::new(|args, ctx| { + executor: Arc::new(|args, ctx| { Box::pin(async move { let patch_text = args .get("patch") @@ -429,9 +431,10 @@ pub fn make_apply_patch_tool() -> RegisteredTool { #[cfg(test)] mod tests { + use std::collections::HashMap; + use super::*; use crate::test_support::MutableMockSandbox; - use std::collections::HashMap; #[test] fn parse_v4a_add_file() { @@ -445,13 +448,10 @@ mod tests { let ops = parse_v4a_patch(patch).unwrap(); assert_eq!(ops.len(), 1); - assert_eq!( - ops[0], - PatchOperation::Add { - path: "src/new_file.rs".into(), - content: "fn main() {\n println!(\"hello\");\n}".into(), - } - ); + assert_eq!(ops[0], PatchOperation::Add { + path: "src/new_file.rs".into(), + content: "fn main() {\n println!(\"hello\");\n}".into(), + }); } #[test] @@ -463,12 +463,9 @@ mod tests { let ops = parse_v4a_patch(patch).unwrap(); assert_eq!(ops.len(), 1); - assert_eq!( - ops[0], - PatchOperation::Delete { - path: "src/old_file.rs".into(), - } - ); + assert_eq!(ops[0], PatchOperation::Delete { + path: "src/old_file.rs".into(), + }); } #[test] @@ -585,21 +582,21 @@ mod tests { let env = MutableMockSandbox::new(files); let ops = vec![PatchOperation::Update { - path: "src/game.py".into(), + path: "src/game.py".into(), new_path: None, - hunks: vec![ + hunks: vec![ Hunk { context_line: String::new(), - end_of_file: false, - changes: vec![ + end_of_file: false, + changes: vec![ Change::Remove("from src.cards import Suit".into()), Change::Add("from src.cards import Card, Suit".into()), ], }, Hunk { context_line: String::new(), - end_of_file: false, - changes: vec![ + end_of_file: false, + changes: vec![ Change::Remove(" stock: list = field(default_factory=list)".into()), Change::Remove(" waste: list = field(default_factory=list)".into()), Change::Add(" stock: list[Card] = field(default_factory=list)".into()), @@ -718,12 +715,12 @@ mod tests { let env = MutableMockSandbox::new(files); let ops = vec![PatchOperation::Update { - path: "src/lib.rs".into(), + path: "src/lib.rs".into(), new_path: None, - hunks: vec![Hunk { + hunks: vec![Hunk { context_line: String::new(), - end_of_file: false, - changes: vec![ + end_of_file: false, + changes: vec![ Change::Context("fn unchanged() {".into()), Change::Remove(" old_line();".into()), Change::Add(" new_line();".into()), @@ -749,21 +746,21 @@ mod tests { let env = MutableMockSandbox::new(files); let ops = vec![PatchOperation::Update { - path: "src/lib.rs".into(), + path: "src/lib.rs".into(), new_path: None, - hunks: vec![ + hunks: vec![ Hunk { context_line: "def setup():".into(), - end_of_file: false, - changes: vec![ + end_of_file: false, + changes: vec![ Change::Remove(" old_setup()".into()), Change::Add(" new_setup()".into()), ], }, Hunk { context_line: String::new(), - end_of_file: false, - changes: vec![ + end_of_file: false, + changes: vec![ Change::Remove(" old_teardown()".into()), Change::Add(" new_teardown()".into()), ], @@ -785,7 +782,7 @@ mod tests { async fn apply_patch_add_file() { let env = MutableMockSandbox::new(HashMap::new()); let ops = vec![PatchOperation::Add { - path: "src/new.rs".into(), + path: "src/new.rs".into(), content: "fn new() {}".into(), }]; @@ -806,12 +803,12 @@ mod tests { let env = MutableMockSandbox::new(files); let ops = vec![PatchOperation::Update { - path: "src/lib.rs".into(), + path: "src/lib.rs".into(), new_path: None, - hunks: vec![Hunk { + hunks: vec![Hunk { context_line: "fn hello() {".into(), - end_of_file: false, - changes: vec![ + end_of_file: false, + changes: vec![ Change::Remove(" println!(\"old\");".into()), Change::Add(" println!(\"new\");".into()), ], @@ -859,12 +856,12 @@ mod tests { let env = MutableMockSandbox::new(files); let ops = vec![PatchOperation::Update { - path: "src/game.py".into(), + path: "src/game.py".into(), new_path: None, - hunks: vec![Hunk { + hunks: vec![Hunk { context_line: "def nonexistent():".into(), - end_of_file: false, - changes: vec![ + end_of_file: false, + changes: vec![ Change::Remove(" old_body()".into()), Change::Add(" new_body()".into()), ], @@ -885,16 +882,16 @@ mod tests { let hunks = vec![ Hunk { context_line: String::new(), - end_of_file: false, - changes: vec![ + end_of_file: false, + changes: vec![ Change::Remove(" pass".into()), Change::Add(" return 1".into()), ], }, Hunk { context_line: String::new(), - end_of_file: false, - changes: vec![ + end_of_file: false, + changes: vec![ Change::Remove(" pass".into()), Change::Add(" return 2".into()), ], @@ -979,8 +976,8 @@ mod tests { let content = "def foo():\n pass\n\ndef bar():\n pass"; let hunks = vec![Hunk { context_line: String::new(), - end_of_file: true, - changes: vec![ + end_of_file: true, + changes: vec![ Change::Remove(" pass".into()), Change::Add(" return 99".into()), ], @@ -1028,12 +1025,12 @@ mod tests { let env = MutableMockSandbox::new(files); let ops = vec![PatchOperation::Update { - path: "src/old.py".into(), + path: "src/old.py".into(), new_path: Some("src/new.py".into()), - hunks: vec![Hunk { + hunks: vec![Hunk { context_line: "def hello():".into(), - end_of_file: false, - changes: vec![ + end_of_file: false, + changes: vec![ Change::Remove(" pass".into()), Change::Add(" return 1".into()), ], @@ -1060,8 +1057,8 @@ mod tests { let content = " indented\nindented"; let hunks = vec![Hunk { context_line: "indented".into(), - end_of_file: false, - changes: vec![Change::Add("extra".into())], + end_of_file: false, + changes: vec![Change::Add("extra".into())], }]; let result = apply_hunks(content, &hunks).unwrap(); // Should match line 1 (exact), so "extra" inserted after "indented" (line 1) @@ -1073,8 +1070,8 @@ mod tests { let content = "print(\u{201C}hello\u{201D})"; let hunks = vec![Hunk { context_line: "print(\"hello\")".into(), - end_of_file: false, - changes: vec![Change::Add("print(\"world\")".into())], + end_of_file: false, + changes: vec![Change::Add("print(\"world\")".into())], }]; let result = apply_hunks(content, &hunks).unwrap(); // Original line preserved, new line added after @@ -1401,7 +1398,7 @@ def gamma(): #[tokio::test] async fn e2e_through_tool_executor() { - use crate::config::SessionConfig; + use crate::config::SessionOptions; use crate::session::Session; use crate::test_support::{ MockLlmProvider, TestProfile, make_client, text_response, tool_call_response, @@ -1451,8 +1448,13 @@ def farewell(name): let provider = Arc::new(MockLlmProvider::new(responses)); let client = make_client(provider).await; let profile = Arc::new(TestProfile::with_tools(registry)); - let mut session = - Session::new(client, profile, env.clone(), SessionConfig::default(), None); + let mut session = Session::new( + client, + profile, + env.clone(), + SessionOptions::default(), + None, + ); session.initialize().await; session .process_input("Update the greeting functions") diff --git a/lib/crates/fabro-agent/tests/it/parity_matrix.rs b/lib/crates/fabro-agent/tests/it/parity_matrix.rs index 5a3381979..51d3b7311 100644 --- a/lib/crates/fabro-agent/tests/it/parity_matrix.rs +++ b/lib/crates/fabro-agent/tests/it/parity_matrix.rs @@ -6,46 +6,46 @@ use std::sync::Arc; use fabro_agent::subagent::SessionFactory; use fabro_agent::{ AgentProfile, AnthropicProfile, GeminiProfile, LocalSandbox, OpenAiProfile, Session, - SessionConfig, SubAgentManager, WebFetchSummarizer, + SessionOptions, SubAgentManager, WebFetchSummarizer, }; use fabro_llm::client::Client; use fabro_llm::provider::{Provider, ProviderAdapter}; use fabro_llm::providers::OpenAiAdapter; -use fabro_model::ModelRef; +use fabro_model::ModelHandle; use fabro_test::{TwinScenario, TwinScenarios, TwinToolCall, twin_openai}; use tokio::sync::Mutex as AsyncMutex; #[derive(Clone)] -struct OpenAiTwinConfig { +struct OpenAiTwinOptions { base_url: String, - api_key: String, + api_key: String, } -fn summarizer_model_id(provider: Provider) -> ModelRef { +fn summarizer_model_id(provider: Provider) -> ModelHandle { match provider { Provider::OpenAi | Provider::Kimi | Provider::Zai | Provider::Minimax | Provider::Inception - | Provider::OpenAiCompatible => ModelRef::ByName { + | Provider::OpenAiCompatible => ModelHandle::ByName { provider: Provider::OpenAi, - model: "gpt-5.4-mini".to_string(), + model: "gpt-5.4-mini".to_string(), }, - Provider::Gemini => ModelRef::ByName { + Provider::Gemini => ModelHandle::ByName { provider: Provider::Gemini, - model: "gemini-3-flash-preview".to_string(), + model: "gemini-3-flash-preview".to_string(), }, - Provider::Anthropic => ModelRef::ByName { + Provider::Anthropic => ModelHandle::ByName { provider: Provider::Anthropic, - model: "claude-haiku-4-5".to_string(), + model: "claude-haiku-4-5".to_string(), }, } } fn build_summarizer(provider: Provider, client: &Client) -> WebFetchSummarizer { WebFetchSummarizer { - client: client.clone(), + client: client.clone(), model_id: summarizer_model_id(provider), } } @@ -70,13 +70,14 @@ async fn make_session( provider: Provider, model: &str, cwd: &Path, - twin: Option, + twin: Option, ) -> Session { let client = make_client(provider, twin.as_ref()).await; let mut profile = build_profile(provider, model, &client); let env = Arc::new(LocalSandbox::new(cwd.to_path_buf())); - // Register subagent tools so spawn_agent / wait / send_input / close_agent are available + // Register subagent tools so spawn_agent / wait / send_input / close_agent are + // available let manager = Arc::new(AsyncMutex::new(SubAgentManager::new(3))); let factory_client = client.clone(); let factory_model: String = model.to_string(); @@ -110,16 +111,16 @@ async fn make_session( factory_client.clone(), sub_profile, sub_env, - SessionConfig::default(), + SessionOptions::default(), None, ) }); profile.register_subagent_tools(manager, factory, 0); let profile: Arc = Arc::from(profile); - let config = SessionConfig { + let config = SessionOptions { max_turns: 20, - ..SessionConfig::default() + ..SessionOptions::default() }; Session::new(client, profile, env, config, None) } @@ -128,8 +129,8 @@ async fn make_session_with_config( provider: Provider, model: &str, cwd: &Path, - config: SessionConfig, - twin: Option, + config: SessionOptions, + twin: Option, ) -> Session { let client = make_client(provider, twin.as_ref()).await; let profile: Arc = Arc::from(build_profile(provider, model, &client)); @@ -137,15 +138,15 @@ async fn make_session_with_config( Session::new(client, profile, env, config, None) } -async fn make_client(provider: Provider, twin: Option<&OpenAiTwinConfig>) -> Client { +async fn make_client(provider: Provider, twin: Option<&OpenAiTwinOptions>) -> Client { if provider == Provider::OpenAi && fabro_test::TestMode::from_env().is_twin() { - return make_twin_client(twin.expect("openai twin config should be provided")).await; + return make_twin_client(twin.expect("openai twin config should be provided")); } Client::from_env().await.expect("Client::from_env failed") } -async fn make_twin_client(twin: &OpenAiTwinConfig) -> Client { +fn make_twin_client(twin: &OpenAiTwinOptions) -> Client { let adapter: Arc = Arc::new(OpenAiAdapter::new(twin.api_key.clone()).with_base_url(twin.base_url.clone())); let mut providers: HashMap> = HashMap::new(); @@ -174,7 +175,7 @@ macro_rules! openai_twin_provider_test { async fn []() { let tmp = tempfile::tempdir().expect("failed to create tempdir"); let (base_url, api_key) = fabro_test::e2e_openai!(); - let twin = OpenAiTwinConfig { base_url, api_key }; + let twin = OpenAiTwinOptions { base_url, api_key }; if fabro_test::TestMode::from_env().is_twin() { load_openai_twin_scenario(stringify!($scenario), &twin.api_key, tmp.path()) .await; @@ -369,15 +370,18 @@ provider_test!( ); // Scenarios below are only generated for providers where they are supported. -// - multi_step_read_analyze_edit / provider_specific_editing: gpt-4o-mini is too -// weak to reliably apply precise file edits (uses apply_patch, not edit_file). -// - reasoning_effort: gpt-4o-mini doesn't support the reasoning.effort parameter. +// - multi_step_read_analyze_edit / provider_specific_editing: gpt-4o-mini is +// too weak to reliably apply precise file edits (uses apply_patch, not +// edit_file). +// - reasoning_effort: gpt-4o-mini doesn't support the reasoning.effort +// parameter. // - loop_detection: needs custom config, tested separately below. provider_tests!(error_recovery); openai_twin_provider_test!(error_recovery); -// gpt-5-mini is too weak to reliably apply precise file edits (uses apply_patch, not edit_file). +// gpt-5-mini is too weak to reliably apply precise file edits (uses +// apply_patch, not edit_file). macro_rules! non_openai_provider_tests { ($scenario:ident) => { provider_test!( @@ -650,10 +654,10 @@ macro_rules! reasoning_effort_tests { #[fabro_macros::e2e_test($(live($key)),+)] async fn $test_name() { let tmp = tempfile::tempdir().expect("failed to create tempdir"); - let config = SessionConfig { + let config = SessionOptions { max_turns: 20, reasoning_effort: Some(fabro_llm::types::ReasoningEffort::Low), - ..SessionConfig::default() + ..SessionOptions::default() }; let mut session = make_session_with_config($provider, $model, tmp.path(), config, None).await; @@ -672,7 +676,8 @@ reasoning_effort_tests!( anthropic_reasoning_effort, keys = ["ANTHROPIC_API_KEY"] ); -// gpt-5-mini does not support the reasoning.effort parameter, so no OpenAI test. +// gpt-5-mini does not support the reasoning.effort parameter, so no OpenAI +// test. reasoning_effort_tests!( Provider::Gemini, "gemini-3-flash-preview", @@ -728,10 +733,10 @@ macro_rules! loop_detection_tests { #[fabro_macros::e2e_test($(live($key)),+)] async fn $test_name() { let tmp = tempfile::tempdir().expect("failed to create tempdir"); - let config = SessionConfig { + let config = SessionOptions { max_turns: 20, loop_detection_window: 3, - ..SessionConfig::default() + ..SessionOptions::default() }; let mut session = make_session_with_config($provider, $model, tmp.path(), config, None).await; diff --git a/lib/crates/fabro-api-types/build.rs b/lib/crates/fabro-api-types/build.rs deleted file mode 100644 index 5be485f4b..000000000 --- a/lib/crates/fabro-api-types/build.rs +++ /dev/null @@ -1,50 +0,0 @@ -use std::{env, fs, path::Path}; - -use schemars::schema::Schema; -use typify::{TypeSpace, TypeSpaceSettings}; - -fn main() { - let spec_path = Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .parent() - .unwrap() - .join("docs/api-reference/fabro-api.yaml"); - - println!("cargo::rerun-if-changed={}", spec_path.display()); - - let spec_text = fs::read_to_string(&spec_path) - .unwrap_or_else(|e| panic!("failed to read {}: {e}", spec_path.display())); - let spec: serde_json::Value = - serde_yaml::from_str(&spec_text).unwrap_or_else(|e| panic!("failed to parse YAML: {e}")); - - let schemas = spec["components"]["schemas"] - .as_object() - .expect("no components/schemas in spec"); - - let named_schemas: Vec<(String, Schema)> = schemas - .iter() - .map(|(name, value)| { - let schema: Schema = serde_json::from_value(value.clone()) - .unwrap_or_else(|e| panic!("failed to parse schema {name}: {e}")); - (name.clone(), schema) - }) - .collect(); - - let settings = TypeSpaceSettings::default(); - let mut type_space = TypeSpace::new(&settings); - type_space - .add_ref_types(named_schemas) - .expect("failed to add schemas to type space"); - - let token_stream = type_space.to_stream(); - let syntax_tree = - syn::parse2::(token_stream).expect("failed to parse generated tokens"); - let formatted = prettyplease::unparse(&syntax_tree); - - let out_dir = env::var("OUT_DIR").unwrap(); - let out_path = Path::new(&out_dir).join("openapi_types.rs"); - fs::write(&out_path, formatted).expect("failed to write generated types"); -} diff --git a/lib/crates/fabro-api-types/src/lib.rs b/lib/crates/fabro-api-types/src/lib.rs deleted file mode 100644 index ecb7d0772..000000000 --- a/lib/crates/fabro-api-types/src/lib.rs +++ /dev/null @@ -1,5 +0,0 @@ -#[allow(clippy::absolute_paths, clippy::derivable_impls)] -mod generated { - include!(concat!(env!("OUT_DIR"), "/openapi_types.rs")); -} -pub use generated::*; diff --git a/lib/crates/fabro-api-types/Cargo.toml b/lib/crates/fabro-api/Cargo.toml similarity index 69% rename from lib/crates/fabro-api-types/Cargo.toml rename to lib/crates/fabro-api/Cargo.toml index 3542aed9f..98ef6836d 100644 --- a/lib/crates/fabro-api-types/Cargo.toml +++ b/lib/crates/fabro-api/Cargo.toml @@ -1,10 +1,10 @@ [package] -name = "fabro-api-types" +name = "fabro-api" edition.workspace = true version.workspace = true publish = false license.workspace = true -description = "Generated Rust types from the Fabro API OpenAPI spec" +description = "Generated Rust types and HTTP client from the Fabro API OpenAPI spec" [lib] doctest = false @@ -15,13 +15,16 @@ wildcard_imports = "warn" [dependencies] chrono = { workspace = true, features = ["serde"] } +progenitor-client = "0.13" +regress = "0.10" +reqwest.workspace = true serde.workspace = true serde_json.workspace = true uuid = { workspace = true, features = ["serde"] } [build-dependencies] -typify = { version = "0.6", default-features = false } -schemars = "0.8" +openapiv3 = "2" +progenitor = "0.13" serde_json = "1" serde_yaml = "0.9" prettyplease = "0.2" diff --git a/lib/crates/fabro-api/build.rs b/lib/crates/fabro-api/build.rs new file mode 100644 index 000000000..c19164184 --- /dev/null +++ b/lib/crates/fabro-api/build.rs @@ -0,0 +1,172 @@ +use std::path::{Path, PathBuf}; +use std::{env, fs}; + +use progenitor::{GenerationSettings, Generator, InterfaceStyle}; + +/// Recursively convert OpenAPI 3.1 `type: "null"` patterns to 3.0 `nullable: +/// true`. +/// +/// Handles two patterns: +/// - `oneOf: [{...}, {type: "null"}]` → the non-null schema with `nullable: +/// true` +/// - `type: [T1, ..., "null"]` → the remaining types with `nullable: true` +fn patch_nullable(value: &mut serde_json::Value) { + match value { + serde_json::Value::Object(map) => { + // Pattern: oneOf with a {type: "null"} variant + if let Some(one_of) = map.get_mut("oneOf") { + if let Some(variants) = one_of.as_array_mut() { + let null_idx = variants.iter().position(|v| { + v.get("type").and_then(serde_json::Value::as_str) == Some("null") + }); + if let Some(idx) = null_idx { + variants.remove(idx); + if variants.len() == 1 { + // Collapse single-variant oneOf into the schema itself + let mut inner = variants.remove(0); + inner + .as_object_mut() + .unwrap() + .insert("nullable".to_string(), serde_json::Value::Bool(true)); + patch_nullable(&mut inner); + *value = inner; + return; + } + map.insert("nullable".to_string(), serde_json::Value::Bool(true)); + } + } + } + + // Pattern: type array containing "null" + let needs_nullable_from_type = map + .get("type") + .and_then(|v| v.as_array()) + .is_some_and(|arr| arr.iter().any(|v| v.as_str() == Some("null"))); + if needs_nullable_from_type { + if let Some(type_val) = map.get_mut("type") { + if let Some(arr) = type_val.as_array_mut() { + arr.retain(|v| v.as_str() != Some("null")); + if arr.len() == 1 { + *type_val = arr.remove(0); + } + } + } + map.insert("nullable".to_string(), serde_json::Value::Bool(true)); + } + + for v in map.values_mut() { + patch_nullable(v); + } + } + serde_json::Value::Array(arr) => { + for v in arr { + patch_nullable(v); + } + } + _ => {} + } +} + +/// Progenitor currently panics when an operation advertises more than one +/// request-body media type. +/// +/// Keep the source OpenAPI spec accurate for docs, but collapse the +/// generated-client view down to a single preferred media type so code +/// generation can proceed. +fn patch_codegen_request_body_media_types(value: &mut serde_json::Value) { + let Some(paths) = value + .get_mut("paths") + .and_then(serde_json::Value::as_object_mut) + else { + return; + }; + + for path_item in paths.values_mut() { + let Some(item) = path_item.as_object_mut() else { + continue; + }; + + for method in ["get", "put", "post", "delete", "patch"] { + let Some(operation) = item + .get_mut(method) + .and_then(serde_json::Value::as_object_mut) + else { + continue; + }; + let Some(content) = operation + .get_mut("requestBody") + .and_then(|request_body| request_body.get_mut("content")) + .and_then(serde_json::Value::as_object_mut) + else { + continue; + }; + if content.len() <= 1 { + continue; + } + + let preferred = content + .get("application/octet-stream") + .cloned() + .map(|value| ("application/octet-stream".to_string(), value)) + .or_else(|| { + content + .iter() + .next() + .map(|(key, value)| (key.clone(), value.clone())) + }); + if let Some((key, value)) = preferred { + content.clear(); + content.insert(key, value); + } + } + } +} + +fn spec_path_from_manifest_dir(manifest_dir: &Path) -> PathBuf { + manifest_dir + .parent() + .unwrap() + .parent() + .unwrap() + .parent() + .unwrap() + .join("docs/api-reference/fabro-api.yaml") +} + +fn main() { + let manifest_dir = env::var_os("CARGO_MANIFEST_DIR") + .map(PathBuf::from) + .expect("CARGO_MANIFEST_DIR should be set for build scripts"); + let spec_path = spec_path_from_manifest_dir(&manifest_dir); + + println!("cargo::rerun-if-changed={}", spec_path.display()); + + let spec_text = fs::read_to_string(&spec_path) + .unwrap_or_else(|e| panic!("failed to read {}: {e}", spec_path.display())); + let mut spec_value: serde_json::Value = + serde_yaml::from_str(&spec_text).unwrap_or_else(|e| panic!("failed to parse YAML: {e}")); + + // TODO: Remove 3.1→3.0 patch when progenitor supports OpenAPI 3.1. + // Progenitor only supports OpenAPI 3.0.x; our spec uses 3.1.0 but doesn't + // rely on any 3.1-only features that affect codegen. + spec_value["openapi"] = serde_json::Value::String("3.0.3".to_string()); + patch_nullable(&mut spec_value); + patch_codegen_request_body_media_types(&mut spec_value); + + let spec: openapiv3::OpenAPI = + serde_json::from_value(spec_value).expect("failed to deserialize OpenAPI spec"); + + let mut settings = GenerationSettings::default(); + settings.with_interface(InterfaceStyle::Builder); + + let mut generator = Generator::new(&settings); + let tokens = generator + .generate_tokens(&spec) + .expect("failed to generate tokens from OpenAPI spec"); + let syntax_tree = syn::parse2::(tokens).expect("failed to parse generated tokens"); + let formatted = prettyplease::unparse(&syntax_tree); + + let out_dir = env::var("OUT_DIR").unwrap(); + let out_path = Path::new(&out_dir).join("codegen.rs"); + fs::write(&out_path, formatted).expect("failed to write generated code"); +} diff --git a/lib/crates/fabro-api/src/lib.rs b/lib/crates/fabro-api/src/lib.rs new file mode 100644 index 000000000..8c05995fd --- /dev/null +++ b/lib/crates/fabro-api/src/lib.rs @@ -0,0 +1,14 @@ +#[allow( + clippy::absolute_paths, + clippy::all, + clippy::derivable_impls, + clippy::disallowed_methods, + clippy::disallowed_types, + clippy::needless_lifetimes, + unreachable_pub, + unused_imports +)] +mod generated { + include!(concat!(env!("OUT_DIR"), "/codegen.rs")); +} +pub use generated::{Client, types}; diff --git a/lib/crates/fabro-checkpoint/src/author.rs b/lib/crates/fabro-checkpoint/src/author.rs index 857ed5572..d8e364c69 100644 --- a/lib/crates/fabro-checkpoint/src/author.rs +++ b/lib/crates/fabro-checkpoint/src/author.rs @@ -1,18 +1,19 @@ use std::fmt::Write; -use fabro_types::settings::server::GitAuthorSettings; +use fabro_types::settings::InterpString; +use fabro_types::settings::run::{GitAuthorLayer, GitAuthorSettings}; /// Resolved git author identity for checkpoint commits. #[derive(Debug, Clone, PartialEq)] pub struct GitAuthor { - pub name: String, + pub name: String, pub email: String, } impl Default for GitAuthor { fn default() -> Self { Self { - name: "Fabro".into(), + name: "Fabro".into(), email: "noreply@fabro.sh".into(), } } @@ -23,7 +24,7 @@ impl GitAuthor { pub fn from_options(name: Option, email: Option) -> Self { let defaults = Self::default(); Self { - name: name.unwrap_or(defaults.name), + name: name.unwrap_or(defaults.name), email: email.unwrap_or(defaults.email), } } @@ -49,8 +50,20 @@ impl GitAuthor { } } -impl From<&GitAuthorSettings> for GitAuthor { - fn from(value: &GitAuthorSettings) -> Self { - Self::from_options(value.name.clone(), value.email.clone()) +impl From<&GitAuthorLayer> for GitAuthor { + fn from(value: &GitAuthorLayer) -> Self { + Self::from_options( + value.name.as_ref().map(InterpString::as_source), + value.email.as_ref().map(InterpString::as_source), + ) + } +} + +impl From<&GitAuthorSettings> for GitAuthor { + fn from(value: &GitAuthorSettings) -> Self { + Self::from_options( + value.name.as_ref().map(InterpString::as_source), + value.email.as_ref().map(InterpString::as_source), + ) } } diff --git a/lib/crates/fabro-checkpoint/src/branch.rs b/lib/crates/fabro-checkpoint/src/branch.rs index f5adc10a8..52958ee87 100644 --- a/lib/crates/fabro-checkpoint/src/branch.rs +++ b/lib/crates/fabro-checkpoint/src/branch.rs @@ -7,24 +7,26 @@ use crate::git::{FileMode, Store, TreeEntries}; /// Metadata about a commit, returned by `log`. #[derive(Debug)] pub struct CommitInfo { - pub oid: Oid, - pub message: String, - pub author_name: String, + pub oid: Oid, + pub message: String, + pub author_name: String, pub author_email: String, - pub time: git2::Time, + pub time: git2::Time, } /// Key-value storage on a single git branch. Each write creates one commit. -/// The branch's tree grows monotonically — each commit's tree is a superset of the previous. +/// The branch's tree grows monotonically — each commit's tree is a superset of +/// the previous. pub struct BranchStore<'a> { objects: &'a Store, - branch: String, - author: Signature<'static>, + branch: String, + author: Signature<'static>, } impl<'a> BranchStore<'a> { pub fn new(objects: &'a Store, branch: impl Into, author: &Signature<'_>) -> Self { - // Clone to 'static by using Signature::now (author name/email are copied into owned strings) + // Clone to 'static by using Signature::now (author name/email are copied into + // owned strings) let author_static = Signature::now( author.name().unwrap_or("unknown"), author.email().unwrap_or(""), @@ -51,7 +53,8 @@ impl<'a> BranchStore<'a> { Ok(()) } - /// Core read-modify-write: read current tree, let caller mutate, write new commit. + /// Core read-modify-write: read current tree, let caller mutate, write new + /// commit. pub fn write_with( &self, message: &str, @@ -113,7 +116,8 @@ impl<'a> BranchStore<'a> { }) } - /// Read a single file from the latest tree. Returns `None` if branch or path doesn't exist. + /// Read a single file from the latest tree. Returns `None` if branch or + /// path doesn't exist. pub fn read_entry(&self, path: &str) -> Result>> { let Some(commit_oid) = self.objects.resolve_ref(&self.branch)? else { return Ok(None); @@ -218,9 +222,10 @@ pub fn sharded_path(id: &str, prefix_len: usize) -> String { #[cfg(test)] mod tests { + use git2::Repository; + use super::*; use crate::git::FileMode; - use git2::Repository; fn temp_repo() -> (tempfile::TempDir, Store) { let dir = tempfile::TempDir::new().unwrap(); diff --git a/lib/crates/fabro-checkpoint/src/error.rs b/lib/crates/fabro-checkpoint/src/error.rs index fa5df4467..6d59481c7 100644 --- a/lib/crates/fabro-checkpoint/src/error.rs +++ b/lib/crates/fabro-checkpoint/src/error.rs @@ -9,7 +9,7 @@ pub enum Error { #[error("reading file {path}: {source}")] ReadFile { - path: PathBuf, + path: PathBuf, source: std::io::Error, }, diff --git a/lib/crates/fabro-checkpoint/src/git.rs b/lib/crates/fabro-checkpoint/src/git.rs index ce4d1f2cc..30f4eca69 100644 --- a/lib/crates/fabro-checkpoint/src/git.rs +++ b/lib/crates/fabro-checkpoint/src/git.rs @@ -34,14 +34,15 @@ impl FileMode { /// A single entry in a flat tree map. #[derive(Debug, Clone)] pub struct TreeEntry { - pub oid: Oid, + pub oid: Oid, pub filemode: FileMode, } /// A flat, sorted map of paths to tree entries. /// -/// Intermediate representation between reading an existing git tree and writing a new one. -/// Paths use forward slashes and are relative to the tree root (e.g. `"src/main.rs"`). +/// Intermediate representation between reading an existing git tree and writing +/// a new one. Paths use forward slashes and are relative to the tree root (e.g. +/// `"src/main.rs"`). #[derive(Debug, Clone, Default)] pub struct TreeEntries(BTreeMap); @@ -92,7 +93,8 @@ impl TreeEntries { } } -/// Wraps a `git2::Repository` with operations for creating blobs, trees, commits, and refs. +/// Wraps a `git2::Repository` with operations for creating blobs, trees, +/// commits, and refs. pub struct Store { repo: Repository, } @@ -119,10 +121,11 @@ impl Store { } /// Read a file from disk, store as a blob. - /// Returns `(oid, filemode)` where filemode detects the executable bit on unix. + /// Returns `(oid, filemode)` where filemode detects the executable bit on + /// unix. pub fn write_blob_from_file(&self, path: &Path) -> Result<(Oid, FileMode)> { let content = std::fs::read(path).map_err(|e| Error::ReadFile { - path: path.to_path_buf(), + path: path.to_path_buf(), source: e, })?; let mode = detect_filemode(path); @@ -150,8 +153,8 @@ impl Store { Ok(builder.write()?) } - /// Create a commit. Does NOT update any ref — caller does that via `update_ref`. - /// `author` is used for both author and committer fields. + /// Create a commit. Does NOT update any ref — caller does that via + /// `update_ref`. `author` is used for both author and committer fields. pub fn write_commit( &self, tree_oid: Oid, @@ -189,7 +192,8 @@ impl Store { } } - /// Read a blob from the tree of a specific commit. Returns `None` if the path doesn't exist. + /// Read a blob from the tree of a specific commit. Returns `None` if the + /// path doesn't exist. pub fn read_blob_at(&self, commit_oid: Oid, path: &str) -> Result>> { let commit = self.repo.find_commit(commit_oid)?; let tree = commit.tree()?; @@ -246,14 +250,14 @@ fn read_tree_recursive( /// Intermediate structure for building nested git trees from flat paths. struct DirNode { files: BTreeMap, - dirs: BTreeMap, + dirs: BTreeMap, } impl DirNode { fn new() -> Self { Self { files: BTreeMap::new(), - dirs: BTreeMap::new(), + dirs: BTreeMap::new(), } } } diff --git a/lib/crates/fabro-checkpoint/src/metadata.rs b/lib/crates/fabro-checkpoint/src/metadata.rs index e73800870..a25d6f41f 100644 --- a/lib/crates/fabro-checkpoint/src/metadata.rs +++ b/lib/crates/fabro-checkpoint/src/metadata.rs @@ -15,14 +15,14 @@ use crate::git::Store; /// (`fabro/meta/{run_id}`) so that runs can be resumed from git alone. pub struct MetadataStore { repo_path: PathBuf, - author: GitAuthor, + author: GitAuthor, } impl MetadataStore { pub fn new(repo_path: impl Into, author: &GitAuthor) -> Self { Self { repo_path: repo_path.into(), - author: author.clone(), + author: author.clone(), } } @@ -59,7 +59,8 @@ impl MetadataStore { Ok(()) } - /// Write arbitrary files to the metadata branch without overwriting checkpoint.json. + /// Write arbitrary files to the metadata branch without overwriting + /// checkpoint.json. pub fn write_files( &self, run_id: &str, @@ -92,7 +93,8 @@ impl MetadataStore { Ok(oid.to_string()) } - /// Read a single file from the metadata branch. Returns `None` if branch or path doesn't exist. + /// Read a single file from the metadata branch. Returns `None` if branch or + /// path doesn't exist. fn read_file( repo_path: &Path, run_id: &str, @@ -108,7 +110,8 @@ impl MetadataStore { Ok(branch_store.read_entry(path)?) } - /// Read a checkpoint from the metadata branch. Returns `None` if branch or file doesn't exist. + /// Read a checkpoint from the metadata branch. Returns `None` if branch or + /// file doesn't exist. pub fn read_checkpoint( repo_path: &Path, run_id: &str, @@ -126,7 +129,8 @@ impl MetadataStore { } } - /// Read the run record from the metadata branch. Returns `None` if not found. + /// Read the run record from the metadata branch. Returns `None` if not + /// found. pub fn read_run_record( repo_path: &Path, run_id: &str, @@ -144,7 +148,8 @@ impl MetadataStore { } } - /// Read the start record from the metadata branch. Returns `None` if not found. + /// Read the start record from the metadata branch. Returns `None` if not + /// found. pub fn read_start_record( repo_path: &Path, run_id: &str, @@ -174,11 +179,18 @@ impl MetadataStore { #[cfg(test)] mod tests { + #![expect( + clippy::disallowed_methods, + reason = "These unit tests use the real git CLI to validate metadata branch behavior." + )] + use std::collections::HashMap; - use super::*; use chrono::{TimeZone, Utc}; - use fabro_types::{FabroSettings, Graph, fixtures}; + use fabro_types::settings::SettingsLayer; + use fabro_types::{Graph, fixtures}; + + use super::*; /// Create a temporary git repo with an initial commit. fn init_repo(dir: &Path) { @@ -206,14 +218,17 @@ mod tests { fn test_run_record(run_id: fabro_types::RunId) -> RunRecord { RunRecord { run_id, - created_at: Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).single().unwrap(), - settings: FabroSettings::default(), + settings: SettingsLayer::default(), graph: Graph::new("test"), workflow_slug: None, working_directory: PathBuf::from("/tmp"), host_repo_path: None, + repo_origin_url: None, base_branch: None, labels: HashMap::new(), + provenance: None, + manifest_blob: None, + definition_blob: None, } } @@ -354,11 +369,10 @@ mod tests { let checkpoint_json = serde_json::to_vec_pretty(&test_checkpoint("node_a", Vec::new(), None)).unwrap(); store - .write_checkpoint( - &run_id, - &checkpoint_json, - &[("artifacts/response.plan.json", artifact_data.as_slice())], - ) + .write_checkpoint(&run_id, &checkpoint_json, &[( + "artifacts/response.plan.json", + artifact_data.as_slice(), + )]) .unwrap(); let read_back = MetadataStore::read_artifact(dir.path(), &run_id, "response.plan") @@ -419,10 +433,10 @@ mod tests { let run_id = fixtures::RUN_6.to_string(); let store = MetadataStore::new(dir.path(), &GitAuthor::default()); let start_record = StartRecord { - run_id: fixtures::RUN_6, + run_id: fixtures::RUN_6, start_time: Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).single().unwrap(), run_branch: Some("fabro/run/test".to_string()), - base_sha: None, + base_sha: None, }; let bytes = serde_json::to_vec_pretty(&start_record).unwrap(); store.init_run(&run_id, &[("start.json", &bytes)]).unwrap(); diff --git a/lib/crates/fabro-checkpoint/src/trailer.rs b/lib/crates/fabro-checkpoint/src/trailer.rs index 055fd0f88..cd97085af 100644 --- a/lib/crates/fabro-checkpoint/src/trailer.rs +++ b/lib/crates/fabro-checkpoint/src/trailer.rs @@ -2,11 +2,12 @@ use std::fmt::Write; /// A git commit message trailer (key-value pair). pub struct Trailer<'a> { - pub key: &'a str, + pub key: &'a str, pub value: &'a str, } -/// Append a trailer to a commit message, inserting a blank-line separator if needed. +/// Append a trailer to a commit message, inserting a blank-line separator if +/// needed. pub fn append(message: &str, trailer: &Trailer<'_>) -> String { let trailer_line = format!("{}: {}", trailer.key, trailer.value); let trimmed = message.trim_end(); @@ -91,26 +92,20 @@ mod tests { #[test] fn append_to_simple_message() { - let result = append( - "Initial commit", - &Trailer { - key: "My-Checkpoint", - value: "abc123", - }, - ); + let result = append("Initial commit", &Trailer { + key: "My-Checkpoint", + value: "abc123", + }); assert_eq!(result, "Initial commit\n\nMy-Checkpoint: abc123\n"); } #[test] fn append_to_message_with_existing_trailer() { let msg = "Initial commit\n\nSigned-off-by: Alice \n"; - let result = append( - msg, - &Trailer { - key: "My-Checkpoint", - value: "abc123", - }, - ); + let result = append(msg, &Trailer { + key: "My-Checkpoint", + value: "abc123", + }); assert_eq!( result, "Initial commit\n\nSigned-off-by: Alice \nMy-Checkpoint: abc123\n" @@ -120,13 +115,10 @@ mod tests { #[test] fn append_to_message_with_body_no_trailer() { let msg = "Initial commit\n\nThis is a longer description of the change.\n"; - let result = append( - msg, - &Trailer { - key: "My-Checkpoint", - value: "abc123", - }, - ); + let result = append(msg, &Trailer { + key: "My-Checkpoint", + value: "abc123", + }); assert_eq!( result, "Initial commit\n\nThis is a longer description of the change.\n\nMy-Checkpoint: abc123\n" @@ -175,20 +167,16 @@ mod tests { #[test] fn format_message_with_trailers() { - let result = format_message( - "Initial commit", - "", - &[ - Trailer { - key: "Signed-off-by", - value: "Alice", - }, - Trailer { - key: "My-Checkpoint", - value: "abc123", - }, - ], - ); + let result = format_message("Initial commit", "", &[ + Trailer { + key: "Signed-off-by", + value: "Alice", + }, + Trailer { + key: "My-Checkpoint", + value: "abc123", + }, + ]); assert_eq!( result, "Initial commit\n\nSigned-off-by: Alice\nMy-Checkpoint: abc123\n" @@ -197,14 +185,10 @@ mod tests { #[test] fn format_message_with_body_and_trailers() { - let result = format_message( - "Initial commit", - "Description here", - &[Trailer { - key: "My-Checkpoint", - value: "abc123", - }], - ); + let result = format_message("Initial commit", "Description here", &[Trailer { + key: "My-Checkpoint", + value: "abc123", + }]); assert_eq!( result, "Initial commit\n\nDescription here\n\nMy-Checkpoint: abc123\n" diff --git a/lib/crates/fabro-cli/Cargo.toml b/lib/crates/fabro-cli/Cargo.toml index 1c34bb415..d476a3500 100644 --- a/lib/crates/fabro-cli/Cargo.toml +++ b/lib/crates/fabro-cli/Cargo.toml @@ -12,7 +12,6 @@ path = "src/main.rs" [features] default = [] -server = ["dep:fabro-server"] sleep_inhibitor = ["dep:core-foundation"] [lints] @@ -29,18 +28,21 @@ fabro-devcontainer = { path = "../fabro-devcontainer" } fabro-hooks = { path = "../fabro-hooks" } fabro-interview = { path = "../fabro-interview" } fabro-mcp = { path = "../fabro-mcp" } -fabro-proctitle = { path = "../fabro-proctitle" } +fabro-proc = { path = "../fabro-proc" } fabro-retro = { path = "../fabro-retro" } fabro-sandbox = { path = "../fabro-sandbox", features = ["daytona"] } fabro-checkpoint = { path = "../fabro-checkpoint" } fabro-graphviz = { path = "../fabro-graphviz" } fabro-validate = { path = "../fabro-validate" } fabro-workflow = { path = "../fabro-workflow" } -fabro-server = { path = "../fabro-server", optional = true } +fabro-server = { path = "../fabro-server" } +fabro-api = { path = "../fabro-api" } fabro-telemetry = { path = "../fabro-telemetry" } fabro-store = { path = "../fabro-store" } +fabro-vault = { path = "../fabro-vault" } fabro-types = { path = "../fabro-types" } fabro-util = { path = "../fabro-util" } +fabro-http.workspace = true clap.workspace = true clap_complete.workspace = true cli-table.workspace = true @@ -60,7 +62,7 @@ toml.workspace = true futures.workspace = true regex.workspace = true semver.workspace = true -reqwest.workspace = true +progenitor-client = "0.13" async-trait.workspace = true jsonwebtoken.workspace = true base64.workspace = true @@ -81,9 +83,8 @@ sha2.workspace = true shlex = "1" walkdir.workspace = true object_store.workspace = true - -[target.'cfg(unix)'.dependencies] -libc = "0.2" +bytes.workspace = true +tokio-util.workspace = true [target.'cfg(target_os = "macos")'.dependencies] core-foundation = { version = "0.9", optional = true } diff --git a/lib/crates/fabro-cli/build.rs b/lib/crates/fabro-cli/build.rs index 6ec1c3c48..c24a4d15a 100644 --- a/lib/crates/fabro-cli/build.rs +++ b/lib/crates/fabro-cli/build.rs @@ -1,3 +1,7 @@ +#[expect( + clippy::disallowed_methods, + reason = "Build scripts run outside Tokio and need a synchronous git probe for the embedded build SHA." +)] fn main() { println!("cargo:rerun-if-changed=../../../.git/HEAD"); diff --git a/lib/crates/fabro-cli/src/args.rs b/lib/crates/fabro-cli/src/args.rs index 0cf3ca9f8..228e50921 100644 --- a/lib/crates/fabro-cli/src/args.rs +++ b/lib/crates/fabro-cli/src/args.rs @@ -1,10 +1,9 @@ use std::fmt; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use clap::{Args, Subcommand, ValueEnum}; use fabro_agent::cli::AgentArgs; use fabro_graphviz::render::GraphFormat; -use fabro_llm::cli::{ChatArgs, ModelsCommand, PromptArgs}; pub(crate) const LONG_VERSION: &str = concat!( env!("CARGO_PKG_VERSION"), @@ -36,20 +35,6 @@ pub(crate) struct GlobalArgs { /// Enable verbose output #[arg(long, global = true, env = "FABRO_VERBOSE", value_parser = clap::builder::BoolishValueParser::new(), conflicts_with = "quiet")] pub verbose: bool, - - /// Storage directory (default: ~/.fabro) - #[arg(long, global = true, env = "FABRO_STORAGE_DIR")] - pub storage_dir: Option, - - #[cfg(feature = "server")] - /// Server URL (overrides server.base_url from user.toml) - #[arg( - long, - global = true, - env = "FABRO_SERVER_URL", - conflicts_with = "storage_dir" - )] - pub server_url: Option, } impl GlobalArgs { @@ -59,6 +44,45 @@ impl GlobalArgs { } } +#[derive(Args, Debug, Clone, Default)] +pub(crate) struct StorageDirArgs { + /// Local storage directory (default: ~/.fabro/storage) + #[arg(long, env = "FABRO_STORAGE_DIR")] + pub(crate) storage_dir: Option, +} + +impl StorageDirArgs { + pub(crate) fn as_deref(&self) -> Option<&Path> { + self.storage_dir.as_deref() + } + + pub(crate) fn clone_path(&self) -> Option { + self.storage_dir.clone() + } +} + +#[derive(Args, Debug, Clone, Default)] +pub(crate) struct ServerTargetArgs { + /// Fabro server target: http(s) URL or absolute Unix socket path + #[arg(long = "server", env = "FABRO_SERVER")] + pub(crate) server: Option, +} + +impl ServerTargetArgs { + pub(crate) fn as_deref(&self) -> Option<&str> { + self.server.as_deref() + } +} + +#[derive(Args, Debug, Clone, Default)] +pub(crate) struct ServerConnectionArgs { + #[command(flatten)] + pub(crate) storage_dir: StorageDirArgs, + + #[command(flatten)] + pub(crate) target: ServerTargetArgs, +} + #[derive(Debug, Clone, Copy, ValueEnum)] pub(crate) enum CliSandboxProvider { Local, @@ -88,6 +112,9 @@ impl From for CliSandboxProvider { #[derive(Args)] pub(crate) struct RunArgs { + #[command(flatten)] + pub(crate) target: ServerTargetArgs, + /// Path to a .fabro workflow file or .toml task config #[arg(required = true)] pub(crate) workflow: Option, @@ -100,7 +127,7 @@ pub(crate) struct RunArgs { #[arg(long)] pub(crate) auto_approve: bool, - /// Override the workflow goal (exposed as $goal in prompts) + /// Override the workflow goal (available as {{ goal }} in prompts) #[arg(long)] pub(crate) goal: Option, @@ -147,10 +174,13 @@ pub(crate) struct RunArgs { #[derive(Args)] pub(crate) struct PreflightArgs { + #[command(flatten)] + pub(crate) target: ServerTargetArgs, + /// Path to a .fabro workflow file or .toml task config pub(crate) workflow: PathBuf, - /// Override the workflow goal (exposed as $goal in prompts) + /// Override the workflow goal (available as {{ goal }} in prompts) #[arg(long)] pub(crate) goal: Option, @@ -189,13 +219,16 @@ pub(crate) struct RunFilterArgs { #[arg(long = "label", value_name = "KEY=VALUE")] pub(crate) label: Vec, - /// Include orphan directories (no run.json) + /// Include orphan directories (no matching durable run) #[arg(long)] pub(crate) orphans: bool, } #[derive(Args)] pub(crate) struct RunsListArgs { + #[command(flatten)] + pub(crate) server: ServerTargetArgs, + #[command(flatten)] pub(crate) filter: RunFilterArgs, @@ -210,6 +243,9 @@ pub(crate) struct RunsListArgs { #[derive(Args)] pub(crate) struct RunsRemoveArgs { + #[command(flatten)] + pub(crate) server: ServerTargetArgs, + /// Run IDs or workflow names to remove #[arg(required = true)] pub(crate) runs: Vec, @@ -221,17 +257,21 @@ pub(crate) struct RunsRemoveArgs { #[derive(Args)] pub(crate) struct LogsArgs { + #[command(flatten)] + pub(crate) server: ServerTargetArgs, + /// Run ID prefix or workflow name (most recent run) - pub(crate) run: String, + pub(crate) run: String, /// Follow log output #[arg(short, long)] pub(crate) follow: bool, - /// Logs since timestamp or relative (e.g. "42m", "2h", "2026-01-02T13:00:00Z") + /// Logs since timestamp or relative (e.g. "42m", "2h", + /// "2026-01-02T13:00:00Z") #[arg(long)] - pub(crate) since: Option, + pub(crate) since: Option, /// Lines from end (default: all) #[arg(short = 'n', long)] - pub(crate) tail: Option, + pub(crate) tail: Option, /// Formatted colored output with rendered assistant text #[arg(short = 'p', long)] pub(crate) pretty: bool, @@ -239,6 +279,9 @@ pub(crate) struct LogsArgs { #[derive(Args)] pub(crate) struct ValidateArgs { + #[command(flatten)] + pub(crate) target: ServerTargetArgs, + /// Path to the .fabro workflow file pub(crate) workflow: PathBuf, } @@ -286,7 +329,11 @@ impl fmt::Display for GraphOutputFormat { #[derive(Args)] pub(crate) struct GraphArgs { - /// Path to the .fabro workflow file, .toml task config, or project workflow name + #[command(flatten)] + pub(crate) target: ServerTargetArgs, + + /// Path to the .fabro workflow file, .toml task config, or project workflow + /// name pub(crate) workflow: PathBuf, /// Output format @@ -309,33 +356,39 @@ pub(crate) struct ParseArgs { } #[derive(Args)] -pub(crate) struct AssetListArgs { +pub(crate) struct ArtifactListArgs { + #[command(flatten)] + pub(crate) server: ServerTargetArgs, + /// Run ID (or prefix) pub(crate) run_id: String, - /// Filter to assets from a specific node + /// Filter to artifacts from a specific node #[arg(long)] pub(crate) node: Option, - /// Filter to assets from a specific retry attempt + /// Filter to artifacts from a specific retry attempt #[arg(long)] pub(crate) retry: Option, } #[derive(Args)] -pub(crate) struct AssetCpArgs { - /// Source: RUN_ID (all assets) or RUN_ID:path (specific asset) +pub(crate) struct ArtifactCpArgs { + #[command(flatten)] + pub(crate) server: ServerTargetArgs, + + /// Source: RUN_ID (all artifacts) or RUN_ID:path (specific artifact) pub(crate) source: String, /// Destination directory (defaults to current directory) #[arg(default_value = ".")] pub(crate) dest: PathBuf, - /// Filter to assets from a specific node + /// Filter to artifacts from a specific node #[arg(long)] pub(crate) node: Option, - /// Filter to assets from a specific retry attempt + /// Filter to artifacts from a specific retry attempt #[arg(long)] pub(crate) retry: Option, @@ -346,10 +399,13 @@ pub(crate) struct AssetCpArgs { #[derive(Args)] pub(crate) struct CpArgs { + #[command(flatten)] + pub(crate) server: ServerTargetArgs, + /// Source: : or local path - pub(crate) src: String, + pub(crate) src: String, /// Destination: : or local path - pub(crate) dst: String, + pub(crate) dst: String, /// Recurse into directories #[arg(short, long)] pub(crate) recursive: bool, @@ -357,28 +413,34 @@ pub(crate) struct CpArgs { #[derive(Args)] pub(crate) struct PreviewArgs { + #[command(flatten)] + pub(crate) server: ServerTargetArgs, + /// Run ID or prefix - pub(crate) run: String, + pub(crate) run: String, /// Port number - pub(crate) port: u16, + pub(crate) port: u16, /// Generate a signed URL (embeds auth token, no headers needed) #[arg(long)] pub(crate) signed: bool, /// Signed URL expiry in seconds (default 3600, requires --signed) #[arg(long, default_value = "3600", requires = "signed")] - pub(crate) ttl: i32, + pub(crate) ttl: i32, /// Open URL in browser (implies --signed) #[arg(long)] - pub(crate) open: bool, + pub(crate) open: bool, } #[derive(Args)] pub(crate) struct SshArgs { + #[command(flatten)] + pub(crate) server: ServerTargetArgs, + /// Run ID or prefix - pub(crate) run: String, + pub(crate) run: String, /// SSH access expiry in minutes (default 60) #[arg(long, default_value = "60")] - pub(crate) ttl: f64, + pub(crate) ttl: f64, /// Print the SSH command instead of connecting #[arg(long)] pub(crate) print: bool, @@ -386,27 +448,30 @@ pub(crate) struct SshArgs { #[derive(Args)] pub(crate) struct DiffArgs { + #[command(flatten)] + pub(crate) server: ServerTargetArgs, + /// Run ID or prefix - pub(crate) run: String, + pub(crate) run: String, /// Show diff for a specific node #[arg(long)] pub(crate) node: Option, - /// Show diffstat instead of full patch (live diffs only) - #[arg(long)] - pub(crate) stat: bool, - /// Show only files-changed/insertions/deletions summary (live diffs only) - #[arg(long)] - pub(crate) shortstat: bool, } #[derive(Args)] pub(crate) struct InspectArgs { + #[command(flatten)] + pub(crate) server: ServerTargetArgs, + /// Run ID prefix or workflow name (most recent run) pub(crate) run: String, } #[derive(Args)] pub(crate) struct StoreDumpArgs { + #[command(flatten)] + pub(crate) storage_dir: StorageDirArgs, + /// Run ID prefix or workflow name pub(crate) run: String, @@ -416,17 +481,7 @@ pub(crate) struct StoreDumpArgs { } #[derive(Args)] -pub(crate) struct SecretGetArgs { - /// Name of the secret - pub(crate) key: String, -} - -#[derive(Args)] -pub(crate) struct SecretListArgs { - /// Show values alongside keys - #[arg(long)] - pub(crate) show_values: bool, -} +pub(crate) struct SecretListArgs; #[derive(Args)] pub(crate) struct SecretRmArgs { @@ -434,16 +489,29 @@ pub(crate) struct SecretRmArgs { pub(crate) key: String, } +#[derive(Clone, Copy, Debug, ValueEnum)] +pub(crate) enum SecretTypeArg { + Environment, + File, +} + #[derive(Args)] pub(crate) struct SecretSetArgs { /// Name of the secret - pub(crate) key: String, + pub(crate) key: String, /// Value to store - pub(crate) value: String, + pub(crate) value: String, + #[arg(long, value_enum, default_value = "environment")] + pub(crate) r#type: SecretTypeArg, + #[arg(long)] + pub(crate) description: Option, } #[derive(Debug, Args)] pub(crate) struct ResumeArgs { + #[command(flatten)] + pub(crate) server: ServerTargetArgs, + /// Run ID or unambiguous prefix pub(crate) run: String, @@ -454,6 +522,9 @@ pub(crate) struct ResumeArgs { #[derive(Debug, Args)] pub(crate) struct RewindArgs { + #[command(flatten)] + pub(crate) server: ServerTargetArgs, + /// Run ID (or unambiguous prefix) pub(crate) run_id: String, @@ -471,10 +542,14 @@ pub(crate) struct RewindArgs { #[derive(Debug, Args)] pub(crate) struct ForkArgs { + #[command(flatten)] + pub(crate) server: ServerTargetArgs, + /// Run ID (or unambiguous prefix) pub(crate) run_id: String, - /// Target checkpoint: node name, node@visit, or @ordinal (omit to fork from latest) + /// Target checkpoint: node name, node@visit, or @ordinal (omit to fork from + /// latest) pub(crate) target: Option, /// Show the checkpoint timeline instead of forking @@ -488,6 +563,9 @@ pub(crate) struct ForkArgs { #[derive(Args)] pub(crate) struct WaitArgs { + #[command(flatten)] + pub(crate) server: ServerTargetArgs, + /// Run ID prefix or workflow name (most recent run) pub(crate) run: String, @@ -515,17 +593,30 @@ pub(crate) struct WorkflowCreateArgs { #[derive(Args)] pub(crate) struct ProviderLoginArgs { + #[command(flatten)] + pub(crate) target: ServerTargetArgs, + /// LLM provider to authenticate with #[arg(long)] pub(crate) provider: fabro_model::Provider, } +#[derive(Args)] +pub(crate) struct SystemInfoArgs { + #[command(flatten)] + pub(crate) connection: ServerConnectionArgs, +} + #[derive(Args)] pub(crate) struct RunsPruneArgs { + #[command(flatten)] + pub(crate) connection: ServerConnectionArgs, + #[command(flatten)] pub(crate) filter: RunFilterArgs, - /// Only prune runs older than this duration (e.g. 24h, 7d). Default: 24h when no explicit filters are set. + /// Only prune runs older than this duration (e.g. 24h, 7d). Default: 24h + /// when no explicit filters are set. #[arg( long, value_name = "DURATION", @@ -540,58 +631,57 @@ pub(crate) struct RunsPruneArgs { #[derive(Args)] pub(crate) struct DfArgs { + #[command(flatten)] + pub(crate) connection: ServerConnectionArgs, + /// Show per-run breakdown #[arg(short, long)] pub(crate) verbose: bool, } +#[derive(Args)] +pub(crate) struct SystemEventsArgs { + #[command(flatten)] + pub(crate) connection: ServerConnectionArgs, + + /// Filter by run ID (repeatable) + #[arg(long = "run-id")] + pub(crate) run_ids: Vec, +} + #[derive(Args)] pub(crate) struct SettingsArgs { + #[command(flatten)] + pub(crate) target: ServerTargetArgs, + + /// Show only locally resolved settings and skip the server call + #[arg(long, conflicts_with = "server")] + pub(crate) local: bool, + /// Optional workflow name, .fabro path, or .toml run config to overlay pub(crate) workflow: Option, } -#[derive(Clone, ValueEnum)] -pub(crate) enum SkillDir { - Claude, - Agents, -} - -#[derive(Clone, ValueEnum)] -pub(crate) enum SkillScope { - User, - Project, -} - -#[derive(Args)] -pub(crate) struct SkillInstallArgs { - /// Where to install: user-level or project-level - #[arg(long = "for", default_value = "user")] - pub(crate) scope: SkillScope, - - /// Target directory convention - #[arg(long)] - pub(crate) dir: SkillDir, - - /// Overwrite existing skill without prompting - #[arg(long)] - pub(crate) force: bool, -} - #[derive(Args)] pub(crate) struct PrCreateArgs { + #[command(flatten)] + pub(crate) server: ServerTargetArgs, + /// Run ID or prefix pub(crate) run_id: String, /// LLM model for generating PR description #[arg(long)] - pub(crate) model: Option, + pub(crate) model: Option, /// Create PR even if the run status is not success/partial_success #[arg(short, long)] - pub(crate) force: bool, + pub(crate) force: bool, } #[derive(Args)] pub(crate) struct PrListArgs { + #[command(flatten)] + pub(crate) server: ServerTargetArgs, + /// Show all PRs (including closed/merged), not just open #[arg(long)] pub(crate) all: bool, @@ -599,12 +689,18 @@ pub(crate) struct PrListArgs { #[derive(Args)] pub(crate) struct PrViewArgs { + #[command(flatten)] + pub(crate) server: ServerTargetArgs, + /// Run ID or prefix pub(crate) run_id: String, } #[derive(Args)] pub(crate) struct PrMergeArgs { + #[command(flatten)] + pub(crate) server: ServerTargetArgs, + /// Run ID or prefix pub(crate) run_id: String, /// Merge method: merge, squash, or rebase @@ -614,10 +710,101 @@ pub(crate) struct PrMergeArgs { #[derive(Args)] pub(crate) struct PrCloseArgs { + #[command(flatten)] + pub(crate) server: ServerTargetArgs, + /// Run ID or prefix pub(crate) run_id: String, } +#[derive(Args)] +pub(crate) struct StartArgs { + #[command(flatten)] + pub(crate) server: ServerTargetArgs, + + /// Run ID prefix or workflow name + pub(crate) run: String, +} + +#[derive(Args)] +pub(crate) struct AttachArgs { + #[command(flatten)] + pub(crate) server: ServerTargetArgs, + + /// Run ID prefix or workflow name + pub(crate) run: String, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub(crate) enum RunWorkerMode { + Start, + Resume, +} + +#[derive(Args)] +pub(crate) struct RunWorkerArgs { + /// Fabro server target: http(s) URL or absolute Unix socket path + #[arg(long)] + pub(crate) server: String, + + /// Short-lived bearer token for artifact uploads + #[arg(long, hide = true)] + pub(crate) artifact_upload_token: Option, + + /// Run scratch directory + #[arg(long)] + pub(crate) run_dir: PathBuf, + + /// Run ID + #[arg(long)] + pub(crate) run_id: fabro_types::RunId, + + /// Worker mode + #[arg(long, value_enum)] + pub(crate) mode: RunWorkerMode, +} + +#[derive(Args, Debug, Clone, Default)] +pub(crate) struct ModelListArgs { + #[command(flatten)] + pub(crate) target: ServerTargetArgs, + + /// Filter by provider + #[arg(short, long)] + pub(crate) provider: Option, + + /// Search for models matching this string + #[arg(short, long)] + pub(crate) query: Option, +} + +#[derive(Args, Debug, Clone, Default)] +pub(crate) struct ModelTestArgs { + #[command(flatten)] + pub(crate) target: ServerTargetArgs, + + /// Filter by provider + #[arg(short, long)] + pub(crate) provider: Option, + + /// Test a specific model + #[arg(short, long)] + pub(crate) model: Option, + + /// Run a multi-turn tool-use test (catches reasoning round-trip bugs) + #[arg(long)] + pub(crate) deep: bool, +} + +#[derive(Args)] +pub(crate) struct ExecArgs { + #[command(flatten)] + pub(crate) server: ServerTargetArgs, + + #[command(flatten)] + pub(crate) agent: AgentArgs, +} + #[derive(Args)] pub(crate) struct UpgradeArgs { /// Target version (e.g. "0.5.0" or "v0.5.0") @@ -639,29 +826,13 @@ pub(crate) enum RunCommands { Run(RunArgs), /// Create a workflow run (allocate run dir, persist spec) Create(RunArgs), - /// Start a created workflow run (spawn engine process) - Start { - /// Run ID prefix or workflow name - run: String, - }, + /// Start a created workflow run on the server + Start(StartArgs), /// Attach to a running or finished workflow run - Attach { - /// Run ID prefix or workflow name - run: String, - }, - /// Internal: run the engine process (reads run.json from run dir) - #[command(name = "__detached", hide = true)] - Detached { - /// Run directory - #[arg(long)] - run_dir: PathBuf, - /// Launcher metadata path - #[arg(long)] - launcher_path: PathBuf, - /// Resume from checkpoint instead of fresh start - #[arg(long)] - resume: bool, - }, + Attach(AttachArgs), + /// Internal: execute a single workflow run locally + #[command(name = "__run-worker", hide = true)] + RunWorker(RunWorkerArgs), /// Show the diff of changes from a workflow run #[command(hide = true)] Diff(DiffArgs), @@ -682,9 +853,9 @@ impl RunCommands { match self { Self::Run(_) => "run", Self::Create(_) => "create", - Self::Start { .. } => "start", - Self::Attach { .. } => "attach", - Self::Detached { .. } => "__detached", + Self::Start(_) => "start", + Self::Attach(_) => "attach", + Self::RunWorker(_) => "__run-worker", Self::Diff(_) => "diff", Self::Logs(_) => "logs", Self::Resume(_) => "resume", @@ -736,14 +907,20 @@ impl RunsCommands { } } +#[derive(Subcommand)] +pub(crate) enum ModelsCommand { + /// List available models + List(ModelListArgs), + + /// Test model availability by sending a simple prompt + Test(ModelTestArgs), +} + #[derive(Subcommand)] pub(crate) enum Commands { - /// LLM prompt operations - #[command(hide = true)] - Llm(LlmNamespace), /// Run an agentic coding session #[command(hide = true)] - Exec(AgentArgs), + Exec(ExecArgs), #[command(flatten)] RunCmd(RunCommands), /// Validate run configuration without executing @@ -755,8 +932,8 @@ pub(crate) enum Commands { /// Parse a DOT file and print its AST #[command(hide = true)] Parse(ParseArgs), - /// Inspect and copy run assets (screenshots, reports, traces) - Asset(AssetNamespace), + /// Inspect and copy run artifacts (screenshots, reports, traces) + Artifact(ArtifactNamespace), /// Export store-backed run state for debugging Store(StoreNamespace), #[command(flatten)] @@ -767,32 +944,18 @@ pub(crate) enum Commands { command: Option, }, /// Server operations - #[cfg(feature = "server")] Server(ServerNamespace), /// Check environment and integration health - Doctor { - /// Show detailed information for each check - #[arg(short, long)] - verbose: bool, - - /// Skip live service probes (LLM, sandbox, API, web, Brave Search) - #[arg(long)] - dry_run: bool, - }, + Doctor(DoctorArgs), /// Set up the Fabro environment (LLMs, certs, GitHub) - Install { - /// Base URL for the web UI (used for OAuth callback URLs) - #[arg(long, default_value = "http://localhost:5173")] - web_url: String, - }, + Install(InstallArgs), + /// Uninstall Fabro from this machine + Uninstall(UninstallArgs), /// Pull request operations Pr(PrNamespace), - /// Skill management - #[command(hide = true)] - Skill(SkillNamespace), - /// Manage secrets in ~/.fabro/.env + /// Manage server-owned secrets Secret(SecretNamespace), - /// Inspect merged configuration + /// Inspect effective settings Settings(SettingsArgs), /// Workflow operations Workflow(WorkflowNamespace), @@ -827,18 +990,21 @@ pub(crate) enum Commands { /// Path to the JSON event file path: PathBuf, }, + /// Build a panic event and write JSON to stdout (internal testing) + #[cfg(debug_assertions)] + #[command(name = "__test_panic", hide = true)] + TestPanic { + /// Panic message + message: String, + }, } impl Commands { pub(crate) fn name(&self) -> &'static str { match self { - Self::Llm(ns) => match &ns.command { - LlmCommand::Prompt(_) => "llm prompt", - LlmCommand::Chat(_) => "llm chat", - }, - Self::Asset(ns) => match &ns.command { - AssetCommand::List(_) => "asset list", - AssetCommand::Cp(_) => "asset cp", + Self::Artifact(ns) => match &ns.command { + ArtifactCommand::List(_) => "artifact list", + ArtifactCommand::Cp(_) => "artifact cp", }, Self::Store(ns) => match &ns.command { StoreCommand::Dump(_) => "store dump", @@ -851,20 +1017,23 @@ impl Commands { Self::Parse(_) => "parse", Self::RunsCmd(cmd) => cmd.name(), Self::Model { command } => match command { - Some(ModelsCommand::List { .. }) => "model list", - Some(ModelsCommand::Test { .. }) => "model test", + Some(ModelsCommand::List(_)) => "model list", + Some(ModelsCommand::Test(_)) => "model test", None => "model", }, - #[cfg(feature = "server")] Self::Server(ns) => match &ns.command { ServerCommand::Start(_) => "server start", + ServerCommand::Stop(_) => "server stop", + ServerCommand::Status(_) => "server status", + ServerCommand::Serve(_) => "server __serve", }, - Self::Doctor { .. } => "doctor", + Self::Doctor(_) => "doctor", Self::Repo(ns) => match &ns.command { - RepoCommand::Init { .. } => "repo init", + RepoCommand::Init(_) => "repo init", RepoCommand::Deinit => "repo deinit", }, - Self::Install { .. } => "install", + Self::Install(_) => "install", + Self::Uninstall(_) => "uninstall", Self::Pr(ns) => match &ns.command { PrCommand::Create(_) => "pr create", PrCommand::List(_) => "pr list", @@ -873,7 +1042,6 @@ impl Commands { PrCommand::Close(_) => "pr close", }, Self::Secret(ns) => match &ns.command { - SecretCommand::Get(_) => "secret get", SecretCommand::List(_) => "secret list", SecretCommand::Rm(_) => "secret rm", SecretCommand::Set(_) => "secret set", @@ -883,9 +1051,6 @@ impl Commands { WorkflowCommand::List(_) => "workflow list", WorkflowCommand::Create(_) => "workflow create", }, - Self::Skill(ns) => match &ns.command { - SkillCommand::Install(_) => "skill install", - }, Self::Discord => "discord", Self::Docs => "docs", Self::Upgrade(_) => "upgrade", @@ -895,11 +1060,15 @@ impl Commands { Self::Sandbox { command } => command.name(), Self::Completion(_) => "completion", Self::System(ns) => match &ns.command { + SystemCommand::Info(_) => "system info", SystemCommand::Prune(_) => "system prune", SystemCommand::Df(_) => "system df", + SystemCommand::Events(_) => "system events", }, Self::SendAnalytics { .. } => "__send_analytics", Self::SendPanic { .. } => "__send_panic", + #[cfg(debug_assertions)] + Self::TestPanic { .. } => "__test_panic", } } } @@ -925,17 +1094,17 @@ pub(crate) enum PrCommand { } #[derive(Args)] -pub(crate) struct AssetNamespace { +pub(crate) struct ArtifactNamespace { #[command(subcommand)] - pub(crate) command: AssetCommand, + pub(crate) command: ArtifactCommand, } #[derive(Subcommand)] -pub(crate) enum AssetCommand { - /// List assets for a workflow run - List(AssetListArgs), - /// Copy assets from a workflow run - Cp(AssetCpArgs), +pub(crate) enum ArtifactCommand { + /// List artifacts for a workflow run + List(ArtifactListArgs), + /// Copy artifacts from a workflow run + Cp(ArtifactCpArgs), } #[derive(Args)] @@ -952,14 +1121,15 @@ pub(crate) enum StoreCommand { #[derive(Args)] pub(crate) struct SecretNamespace { + #[command(flatten)] + pub(crate) target: ServerTargetArgs, + #[command(subcommand)] pub(crate) command: SecretCommand, } #[derive(Subcommand)] pub(crate) enum SecretCommand { - /// Get a secret value - Get(SecretGetArgs), /// List secret names #[command(alias = "ls")] List(SecretListArgs), @@ -969,18 +1139,71 @@ pub(crate) enum SecretCommand { Set(SecretSetArgs), } -#[cfg(feature = "server")] #[derive(Args)] pub(crate) struct ServerNamespace { #[command(subcommand)] pub(crate) command: ServerCommand, } -#[cfg(feature = "server")] +use fabro_server::serve::ServeArgs; + +#[derive(Args)] +pub(crate) struct ServerStartArgs { + #[command(flatten)] + pub(crate) storage_dir: StorageDirArgs, + + /// Run in the foreground instead of daemonizing + #[arg(long)] + pub(crate) foreground: bool, + + #[command(flatten)] + pub(crate) serve_args: ServeArgs, +} + +#[derive(Args)] +pub(crate) struct ServerStopArgs { + #[command(flatten)] + pub(crate) storage_dir: StorageDirArgs, + + /// Seconds to wait for graceful shutdown before SIGKILL + #[arg(long, default_value = "10")] + pub(crate) timeout: u64, +} + +#[derive(Args)] +pub(crate) struct ServerStatusArgs { + #[command(flatten)] + pub(crate) storage_dir: StorageDirArgs, + + /// Output as JSON + #[arg(long)] + pub(crate) json: bool, +} + +#[derive(Args)] +pub(crate) struct ServerServeArgs { + #[command(flatten)] + pub(crate) storage_dir: StorageDirArgs, + + /// Path to the server record file + #[arg(long)] + pub(crate) record_path: PathBuf, + + #[command(flatten)] + pub(crate) serve_args: ServeArgs, +} + #[derive(Subcommand)] pub(crate) enum ServerCommand { /// Start the HTTP API server - Start(fabro_server::serve::ServeArgs), + Start(ServerStartArgs), + /// Stop the HTTP API server + Stop(ServerStopArgs), + /// Show server status + Status(ServerStatusArgs), + /// Internal: run the server process (spawned by `start`) + #[command(name = "__serve", hide = true)] + Serve(ServerServeArgs), } #[derive(Args)] @@ -991,10 +1214,14 @@ pub(crate) struct SystemNamespace { #[derive(Subcommand)] pub(crate) enum SystemCommand { + /// Show server runtime information + Info(SystemInfoArgs), /// Delete old workflow runs Prune(RunsPruneArgs), /// Show disk usage Df(DfArgs), + /// Stream run events from the server + Events(SystemEventsArgs), } #[derive(Args)] @@ -1020,15 +1247,44 @@ pub(crate) struct RepoNamespace { #[derive(Subcommand)] pub(crate) enum RepoCommand { /// Initialize a new project - Init { - /// Also install the fabro-create-workflow skill - #[arg(long, hide = true)] - skill: bool, - }, - /// Remove fabro.toml and fabro/ directory + Init(RepoInitArgs), + /// Remove .fabro/ project directory Deinit, } +#[derive(Args)] +pub(crate) struct RepoInitArgs { + #[command(flatten)] + pub(crate) target: ServerTargetArgs, +} + +#[derive(Args)] +pub(crate) struct DoctorArgs { + #[command(flatten)] + pub(crate) target: ServerTargetArgs, + + /// Show detailed information for each check + #[arg(short, long)] + pub(crate) verbose: bool, +} + +#[derive(Args)] +pub(crate) struct InstallArgs { + #[command(flatten)] + pub(crate) storage_dir: StorageDirArgs, + + /// Base URL for the web UI (used for OAuth callback URLs) + #[arg(long, default_value = "http://localhost:3000")] + pub(crate) web_url: String, +} + +#[derive(Args)] +pub(crate) struct UninstallArgs { + /// Skip confirmation prompt + #[arg(long)] + pub(crate) yes: bool, +} + #[derive(Args)] pub(crate) struct ProviderNamespace { #[command(subcommand)] @@ -1046,29 +1302,3 @@ pub(crate) struct CompletionArgs { /// Shell to generate completions for pub shell: clap_complete::Shell, } - -#[derive(Args)] -pub(crate) struct LlmNamespace { - #[command(subcommand)] - pub(crate) command: LlmCommand, -} - -#[derive(Subcommand)] -pub(crate) enum LlmCommand { - /// Execute a prompt - Prompt(PromptArgs), - /// Interactive multi-turn chat - Chat(ChatArgs), -} - -#[derive(Args)] -pub(crate) struct SkillNamespace { - #[command(subcommand)] - pub(crate) command: SkillCommand, -} - -#[derive(Subcommand)] -pub(crate) enum SkillCommand { - /// Install a built-in skill - Install(SkillInstallArgs), -} diff --git a/lib/crates/fabro-cli/src/command_context.rs b/lib/crates/fabro-cli/src/command_context.rs new file mode 100644 index 000000000..9ddc24d86 --- /dev/null +++ b/lib/crates/fabro-cli/src/command_context.rs @@ -0,0 +1,127 @@ +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use anyhow::{Context as _, Result, bail}; +use fabro_types::settings::{CliSettings, SettingsLayer}; +use fabro_util::printer::Printer; +use tokio::sync::OnceCell; + +use crate::args::{ServerConnectionArgs, ServerTargetArgs}; +use crate::server_client::ServerStoreClient; +use crate::{server_client, user_config}; + +#[derive(Clone, Debug)] +pub(crate) enum ServerMode { + None, + ByTarget { + target_override: Option, + }, + ByStorageDir { + target_override: Option, + storage_dir_override: Option, + }, +} + +pub(crate) struct CommandContext { + #[allow(dead_code)] + printer: Printer, + cwd: PathBuf, + base_config_path: PathBuf, + machine_settings: SettingsLayer, + cli_settings: CliSettings, + server_mode: ServerMode, + server: OnceCell>, +} + +impl CommandContext { + pub(crate) fn base(printer: Printer) -> Result { + Self::new(printer, ServerMode::None) + } + + pub(crate) fn for_target(args: &ServerTargetArgs, printer: Printer) -> Result { + Self::new(printer, ServerMode::ByTarget { + target_override: args.server.clone(), + }) + } + + pub(crate) fn for_connection(args: &ServerConnectionArgs, printer: Printer) -> Result { + Self::new(printer, ServerMode::ByStorageDir { + target_override: args.target.server.clone(), + storage_dir_override: args.storage_dir.clone_path(), + }) + } + + fn new(printer: Printer, server_mode: ServerMode) -> Result { + let cwd = std::env::current_dir().context("Failed to get current directory")?; + let base_config_path = user_config::active_settings_path(None); + let machine_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 cli_settings = user_config::resolve_cli_settings(&machine_settings)?; + + Ok(Self { + printer, + cwd, + base_config_path, + machine_settings, + cli_settings, + server_mode, + server: OnceCell::new(), + }) + } + + #[allow(dead_code)] + pub(crate) fn printer(&self) -> Printer { + self.printer + } + + pub(crate) fn cwd(&self) -> &Path { + &self.cwd + } + + pub(crate) fn base_config_path(&self) -> &Path { + &self.base_config_path + } + + pub(crate) fn machine_settings(&self) -> &SettingsLayer { + &self.machine_settings + } + + pub(crate) fn cli_settings(&self) -> &CliSettings { + &self.cli_settings + } + + pub(crate) async fn server(&self) -> Result> { + let server_mode = self.server_mode.clone(); + let base_config_path = self.base_config_path.clone(); + let machine_settings = self.machine_settings.clone(); + + let client = self + .server + .get_or_try_init(|| async move { + let target = match server_mode { + ServerMode::None => bail!("This command context does not have server access"), + ServerMode::ByTarget { target_override } + | ServerMode::ByStorageDir { + target_override, .. + } => ServerTargetArgs { + server: target_override, + }, + }; + server_client::connect_server_with_settings( + &target, + &machine_settings, + &base_config_path, + ) + .await + .map(Arc::new) + }) + .await?; + + Ok(Arc::clone(client)) + } +} diff --git a/lib/crates/fabro-cli/src/commands/asset/cp.rs b/lib/crates/fabro-cli/src/commands/artifact/cp.rs similarity index 62% rename from lib/crates/fabro-cli/src/commands/asset/cp.rs rename to lib/crates/fabro-cli/src/commands/artifact/cp.rs index edae3d399..03c633cea 100644 --- a/lib/crates/fabro-cli/src/commands/asset/cp.rs +++ b/lib/crates/fabro-cli/src/commands/artifact/cp.rs @@ -1,29 +1,29 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; -use fabro_config::FabroSettingsExt; -use fabro_store::RuntimeState; -use fabro_workflow::assets::{AssetEntry, scan_assets}; -use fabro_workflow::run_lookup::{resolve_run, runs_base}; +use fabro_util::printer::Printer; -use crate::args::{AssetCpArgs, GlobalArgs}; +use crate::args::{ArtifactCpArgs, GlobalArgs}; +use crate::server_client::ServerStoreClient; use crate::shared::{print_json_pretty, split_run_path}; -use crate::user_config::load_user_settings_with_globals; -pub(super) fn cp_command(args: &AssetCpArgs, globals: &GlobalArgs) -> Result<()> { - let cli_settings = load_user_settings_with_globals(globals)?; - let base = runs_base(&cli_settings.storage_dir()); - let (run_id, asset_path) = parse_source(&args.source); - let run = resolve_run(&base, run_id)?; - let runtime_state = RuntimeState::new(&run.path); - let entries = scan_assets( - &runtime_state.assets_dir(), +pub(super) async fn cp_command( + args: &ArtifactCpArgs, + globals: &GlobalArgs, + printer: Printer, +) -> Result<()> { + let (run_id_selector, asset_path) = parse_source(&args.source); + let (run_id, client, entries) = super::resolve_artifacts( + &args.server, + run_id_selector, args.node.as_deref(), args.retry, - )?; + printer, + ) + .await?; if entries.is_empty() { - bail!("No assets found for this run"); + bail!("No artifacts found for this run"); } std::fs::create_dir_all(&args.dest) @@ -35,15 +35,15 @@ pub(super) fn cp_command(args: &AssetCpArgs, globals: &GlobalArgs) -> Result<()> .filter(|entry| entry.relative_path == path) .collect(); if matching.is_empty() { - bail!("No asset matching path '{path}' found in this run"); + bail!("No artifact matching path '{path}' found in this run"); } if matching.len() > 1 { let candidates: Vec<_> = matching .iter() - .map(|entry| format!("{}:retry_{}", entry.node_slug, entry.retry)) + .map(|entry| format_candidate(entry)) .collect(); bail!( - "Path '{path}' matches multiple assets: {}. Use --node and/or --retry to disambiguate.", + "Path '{path}' matches multiple artifacts: {}. Use --node and/or --retry to disambiguate.", candidates.join(", ") ); } @@ -54,16 +54,7 @@ pub(super) fn cp_command(args: &AssetCpArgs, globals: &GlobalArgs) -> Result<()> .file_name() .unwrap_or_else(|| std::ffi::OsStr::new(&entry.relative_path)), ); - if let Some(parent) = dest_file.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::copy(&entry.absolute_path, &dest_file).with_context(|| { - format!( - "Failed to copy {} to {}", - entry.absolute_path.display(), - dest_file.display() - ) - })?; + write_artifact_file(&client, &run_id, entry, &dest_file).await?; if globals.json { print_json_pretty(&serde_json::json!({ "copied": [{ @@ -72,7 +63,12 @@ pub(super) fn cp_command(args: &AssetCpArgs, globals: &GlobalArgs) -> Result<()> }], }))?; } else { - println!("Copied {} to {}", entry.relative_path, dest_file.display()); + fabro_util::printout!( + printer, + "Copied {} to {}", + entry.relative_path, + dest_file.display() + ); } return Ok(()); } @@ -84,23 +80,15 @@ pub(super) fn cp_command(args: &AssetCpArgs, globals: &GlobalArgs) -> Result<()> .join(format!("retry_{}", entry.retry)) .join(&entry.relative_path); let dest_file = args.dest.join(relative_dest); - if let Some(parent) = dest_file.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::copy(&entry.absolute_path, &dest_file).with_context(|| { - format!( - "Failed to copy {} to {}", - entry.absolute_path.display(), - dest_file.display() - ) - })?; + write_artifact_file(&client, &run_id, entry, &dest_file).await?; copied.push(serde_json::json!({ "relative_path": entry.relative_path, "destination": dest_file.display().to_string(), })); } } else { - let mut by_filename: Vec<(String, &AssetEntry)> = Vec::with_capacity(entries.len()); + let mut by_filename: Vec<(String, &super::ArtifactEntry)> = + Vec::with_capacity(entries.len()); for entry in &entries { let filename = Path::new(&entry.relative_path) .file_name() @@ -120,16 +108,7 @@ pub(super) fn cp_command(args: &AssetCpArgs, globals: &GlobalArgs) -> Result<()> for (filename, entry) in &by_filename { let dest_file = args.dest.join(filename); - if let Some(parent) = dest_file.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::copy(&entry.absolute_path, &dest_file).with_context(|| { - format!( - "Failed to copy {} to {}", - entry.absolute_path.display(), - dest_file.display() - ) - })?; + write_artifact_file(&client, &run_id, entry, &dest_file).await?; copied.push(serde_json::json!({ "relative_path": entry.relative_path, "destination": dest_file.display().to_string(), @@ -140,8 +119,9 @@ pub(super) fn cp_command(args: &AssetCpArgs, globals: &GlobalArgs) -> Result<()> if globals.json { print_json_pretty(&serde_json::json!({ "copied": copied }))?; } else { - println!( - "Copied {} asset(s) to {}", + fabro_util::printout!( + printer, + "Copied {} artifact(s) to {}", entries.len(), args.dest.display() ); @@ -149,6 +129,23 @@ pub(super) fn cp_command(args: &AssetCpArgs, globals: &GlobalArgs) -> Result<()> Ok(()) } +async fn write_artifact_file( + client: &ServerStoreClient, + run_id: &fabro_types::RunId, + entry: &super::ArtifactEntry, + dest_file: &Path, +) -> Result<()> { + if let Some(parent) = dest_file.parent() { + std::fs::create_dir_all(parent)?; + } + let bytes = client + .download_stage_artifact(run_id, &entry.stage_id, &entry.relative_path) + .await?; + std::fs::write(dest_file, bytes) + .with_context(|| format!("Failed to write {}", dest_file.display()))?; + Ok(()) +} + fn parse_source(source: &str) -> (&str, Option<&str>) { match split_run_path(source) { Some((run_id, path)) => (run_id, Some(path)), @@ -156,7 +153,7 @@ fn parse_source(source: &str) -> (&str, Option<&str>) { } } -fn format_candidate(entry: &AssetEntry) -> String { +fn format_candidate(entry: &super::ArtifactEntry) -> String { format!("{}:retry_{}", entry.node_slug, entry.retry) } @@ -194,12 +191,12 @@ mod tests { #[test] fn format_candidate_includes_retry() { - let entry = AssetEntry { - node_slug: "retry_assets".to_string(), - retry: 2, + let entry = super::super::ArtifactEntry { + node_slug: "retry_assets".to_string(), + retry: 2, + stage_id: fabro_types::StageId::new("retry_assets", 2), relative_path: "assets/retry/report.txt".to_string(), - absolute_path: PathBuf::from("/tmp/report.txt"), - size: 6, + size: 6, }; assert_eq!(format_candidate(&entry), "retry_assets:retry_2"); diff --git a/lib/crates/fabro-cli/src/commands/artifact/list.rs b/lib/crates/fabro-cli/src/commands/artifact/list.rs new file mode 100644 index 000000000..24c6d638c --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/artifact/list.rs @@ -0,0 +1,62 @@ +use anyhow::Result; +use fabro_util::printer::Printer; + +use crate::args::{ArtifactListArgs, GlobalArgs}; + +pub(super) async fn list_command( + args: &ArtifactListArgs, + globals: &GlobalArgs, + printer: Printer, +) -> Result<()> { + let (_run_id, _client, entries) = super::resolve_artifacts( + &args.server, + &args.run_id, + args.node.as_deref(), + args.retry, + printer, + ) + .await?; + + if globals.json { + fabro_util::printout!(printer, "{}", serde_json::to_string_pretty(&entries)?); + return Ok(()); + } + + if entries.is_empty() { + fabro_util::printout!(printer, "No artifacts found for this run."); + return Ok(()); + } + + let node_width = entries + .iter() + .map(|entry| entry.node_slug.len()) + .max() + .unwrap_or(4) + .max(4); + let retry_width = entries + .iter() + .map(|entry| entry.retry.to_string().len()) + .max() + .unwrap_or(5) + .max(5); + + fabro_util::printout!( + printer, + "{:retry_width$} PATH", + "NODE", + "RETRY" + ); + for entry in &entries { + fabro_util::printout!( + printer, + "{:retry_width$} {}", + entry.node_slug, + entry.retry, + entry.relative_path + ); + } + fabro_util::printout!(printer, ""); + fabro_util::printout!(printer, "{} artifact(s)", entries.len()); + + Ok(()) +} diff --git a/lib/crates/fabro-cli/src/commands/artifact/mod.rs b/lib/crates/fabro-cli/src/commands/artifact/mod.rs new file mode 100644 index 000000000..df9dc4563 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/artifact/mod.rs @@ -0,0 +1,74 @@ +mod cp; +mod list; + +use anyhow::{Context, Result}; +use fabro_types::{RunId, StageId}; +use fabro_util::printer::Printer; + +use crate::args::{ArtifactCommand, ArtifactNamespace, GlobalArgs, ServerTargetArgs}; +use crate::command_context::CommandContext; +use crate::server_client::ServerStoreClient; +use crate::server_runs::ServerSummaryLookup; + +#[derive(Clone, Debug, serde::Serialize)] +pub(super) struct ArtifactEntry { + #[serde(skip_serializing)] + pub(super) stage_id: StageId, + pub(super) node_slug: String, + pub(super) retry: u32, + pub(super) relative_path: String, + pub(super) size: u64, +} + +pub(super) async fn resolve_artifacts( + server: &ServerTargetArgs, + run_selector: &str, + node: Option<&str>, + retry: Option, + printer: Printer, +) -> Result<(RunId, ServerStoreClient, Vec)> { + let ctx = CommandContext::for_target(server, printer)?; + let lookup = ServerSummaryLookup::from_client(ctx.server().await?).await?; + let run = lookup.resolve(run_selector)?; + let run_id = run.run_id(); + let mut entries = Vec::new(); + for entry in lookup.client().list_run_artifacts(&run_id).await? { + if node.is_some_and(|value| entry.node_slug != value) { + continue; + } + let entry_retry = u32::try_from(entry.retry) + .context("server returned invalid negative artifact retry")?; + if retry.is_some_and(|value| entry_retry != value) { + continue; + } + let size = + u64::try_from(entry.size).context("server returned invalid negative artifact size")?; + entries.push(ArtifactEntry { + stage_id: entry.stage_id.parse()?, + node_slug: entry.node_slug, + retry: entry_retry, + relative_path: entry.relative_path, + size, + }); + } + + entries.sort_by(|a, b| { + a.stage_id + .cmp(&b.stage_id) + .then_with(|| a.relative_path.cmp(&b.relative_path)) + }); + + let client = lookup.client().clone_for_reuse(); + Ok((run_id, client, entries)) +} + +pub(crate) async fn dispatch( + ns: ArtifactNamespace, + globals: &GlobalArgs, + printer: Printer, +) -> Result<()> { + match ns.command { + ArtifactCommand::List(args) => list::list_command(&args, globals, printer).await, + ArtifactCommand::Cp(args) => cp::cp_command(&args, globals, printer).await, + } +} diff --git a/lib/crates/fabro-cli/src/commands/asset/list.rs b/lib/crates/fabro-cli/src/commands/asset/list.rs deleted file mode 100644 index dca6faa45..000000000 --- a/lib/crates/fabro-cli/src/commands/asset/list.rs +++ /dev/null @@ -1,68 +0,0 @@ -use anyhow::Result; -use fabro_config::FabroSettingsExt; -use fabro_store::RuntimeState; -use fabro_workflow::assets::scan_assets; -use fabro_workflow::run_lookup::{resolve_run, runs_base}; - -use crate::args::{AssetListArgs, GlobalArgs}; -use crate::shared::format_size; -use crate::user_config::load_user_settings_with_globals; - -pub(super) fn list_command(args: &AssetListArgs, globals: &GlobalArgs) -> Result<()> { - let cli_settings = load_user_settings_with_globals(globals)?; - let base = runs_base(&cli_settings.storage_dir()); - let run = resolve_run(&base, &args.run_id)?; - let runtime_state = RuntimeState::new(&run.path); - let entries = scan_assets( - &runtime_state.assets_dir(), - args.node.as_deref(), - args.retry, - )?; - - if globals.json { - println!("{}", serde_json::to_string_pretty(&entries)?); - return Ok(()); - } - - if entries.is_empty() { - println!("No assets found for this run."); - return Ok(()); - } - - let node_width = entries - .iter() - .map(|entry| entry.node_slug.len()) - .max() - .unwrap_or(4) - .max(4); - let retry_width = 5; - let size_width = entries - .iter() - .map(|entry| format_size(entry.size).len()) - .max() - .unwrap_or(4) - .max(4); - - println!( - "{:retry_width$} {:>size_width$} PATH", - "NODE", "RETRY", "SIZE" - ); - let total_size: u64 = entries.iter().map(|entry| entry.size).sum(); - for entry in &entries { - println!( - "{:retry_width$} {:>size_width$} {}", - entry.node_slug, - entry.retry, - format_size(entry.size), - entry.relative_path - ); - } - println!(); - println!( - "{} asset(s), {} total", - entries.len(), - format_size(total_size) - ); - - Ok(()) -} diff --git a/lib/crates/fabro-cli/src/commands/asset/mod.rs b/lib/crates/fabro-cli/src/commands/asset/mod.rs deleted file mode 100644 index aae4f1101..000000000 --- a/lib/crates/fabro-cli/src/commands/asset/mod.rs +++ /dev/null @@ -1,13 +0,0 @@ -mod cp; -mod list; - -use anyhow::Result; - -use crate::args::{AssetCommand, AssetNamespace, GlobalArgs}; - -pub(crate) fn dispatch(ns: AssetNamespace, globals: &GlobalArgs) -> Result<()> { - match ns.command { - AssetCommand::List(args) => list::list_command(&args, globals), - AssetCommand::Cp(args) => cp::cp_command(&args, globals), - } -} diff --git a/lib/crates/fabro-cli/src/commands/config/mod.rs b/lib/crates/fabro-cli/src/commands/config/mod.rs index 16db80b36..54f36b757 100644 --- a/lib/crates/fabro-cli/src/commands/config/mod.rs +++ b/lib/crates/fabro-cli/src/commands/config/mod.rs @@ -1,28 +1,99 @@ use std::io::Write; use std::path::Path; +use fabro_config::effective_settings::{EffectiveSettingsLayers, EffectiveSettingsMode}; +use fabro_config::{effective_settings, load_settings_project, project}; +use fabro_types::settings::SettingsLayer; +use fabro_util::printer::Printer; + use crate::args::{GlobalArgs, SettingsArgs}; +use crate::command_context::CommandContext; use crate::shared::print_json_pretty; use crate::user_config; -use fabro_config::{ConfigLayer, FabroSettings}; -fn merged_config(workflow: Option<&Path>, globals: &GlobalArgs) -> anyhow::Result { - let cwd = std::env::current_dir()?; - let base = match workflow { - Some(path) => ConfigLayer::for_workflow(path, &cwd)?, - None => ConfigLayer::project(&cwd)?, +fn config_layers( + ctx: &CommandContext, + workflow: Option<&Path>, +) -> anyhow::Result { + let cwd = ctx.cwd(); + let (workflow_layer, project_layer) = match workflow { + Some(path) => workflow_and_project_layers(path, cwd)?, + None => (SettingsLayer::default(), load_settings_project(cwd)?), }; - let cli = user_config::user_layer_with_globals(globals)?; - - base.combine(cli).resolve() + let user_layer = user_config::settings_layer_with_config_and_storage_dir( + Some(ctx.base_config_path()), + None, + )?; + Ok(EffectiveSettingsLayers::new( + SettingsLayer::default(), + workflow_layer, + project_layer, + user_layer, + )) } -pub(crate) fn execute(args: &SettingsArgs, globals: &GlobalArgs) -> anyhow::Result<()> { - let config = merged_config(args.workflow.as_deref(), globals)?; +fn workflow_and_project_layers( + path: &Path, + cwd: &Path, +) -> anyhow::Result<(SettingsLayer, SettingsLayer)> { + let resolution = project::resolve_workflow_path(path, cwd)?; + if resolution.workflow_config.is_none() && !resolution.resolved_workflow_path.is_file() { + anyhow::bail!( + "Workflow not found: {}", + resolution.resolved_workflow_path.display() + ); + } + + let workflow_layer = resolution.workflow_config.unwrap_or_default(); + let project_layer = project::discover_project_config( + resolution + .resolved_workflow_path + .parent() + .unwrap_or_else(|| Path::new(".")), + )? + .map(|(_, config)| config) + .unwrap_or_default(); + + Ok((workflow_layer, project_layer)) +} + +async fn merged_config(args: &SettingsArgs, printer: Printer) -> anyhow::Result { + let base_ctx = CommandContext::base(printer)?; + let layers = config_layers(&base_ctx, args.workflow.as_deref())?; + if args.local { + return Ok(effective_settings::resolve_settings( + layers, + None, + EffectiveSettingsMode::LocalOnly, + )?); + } + + let ctx = CommandContext::for_target(&args.target, printer)?; + let target = user_config::resolve_server_target(&args.target, ctx.machine_settings())?; + let server_settings = ctx.server().await?.retrieve_server_settings().await?; + let mode = match target { + user_config::ServerTarget::HttpUrl { .. } => EffectiveSettingsMode::RemoteServer, + user_config::ServerTarget::UnixSocket(_) => EffectiveSettingsMode::LocalDaemon, + }; + + Ok(effective_settings::resolve_settings( + layers, + Some(&server_settings), + mode, + )?) +} + +pub(crate) async fn execute( + args: &SettingsArgs, + globals: &GlobalArgs, + printer: Printer, +) -> anyhow::Result<()> { + let config = Box::pin(merged_config(args, printer)).await?; if globals.json { print_json_pretty(&config)?; return Ok(()); } + let mut yaml = serde_yaml::to_string(&config)?; if !yaml.ends_with('\n') { yaml.push('\n'); diff --git a/lib/crates/fabro-cli/src/commands/doctor.rs b/lib/crates/fabro-cli/src/commands/doctor.rs index f3e1959ea..57fe554f8 100644 --- a/lib/crates/fabro-cli/src/commands/doctor.rs +++ b/lib/crates/fabro-cli/src/commands/doctor.rs @@ -1,62 +1,64 @@ use std::path::PathBuf; -#[cfg(feature = "server")] -use std::process::Command; -#[cfg(feature = "server")] use std::sync::LazyLock; -use base64::Engine as _; -use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; -#[cfg(feature = "server")] -use fabro_config::server::{ApiAuthStrategy, AuthProvider}; -use fabro_config::user::{default_user_config_path, legacy_user_config_path}; -use fabro_llm::client::Client as LlmClient; -use fabro_llm::types::{Message, Request}; -use fabro_model::{Catalog, Provider}; +use anyhow::Result; +use fabro_api::types as api_types; +use fabro_config::legacy_env; +use fabro_config::user::{ + active_settings_path, legacy_old_user_config_path, legacy_server_config_path, + legacy_user_config_path, +}; pub(crate) use fabro_util::check_report::{ CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus, }; +use fabro_util::printer::Printer; use fabro_util::terminal::Styles; -use futures::future::join_all; -#[cfg(feature = "server")] +use fabro_util::version::FABRO_VERSION; use regex::Regex; -#[cfg(feature = "server")] use semver::Version; +use tokio::process::Command as TokioCommand; -use crate::args::GlobalArgs; +use crate::args::{DoctorArgs, GlobalArgs}; +use crate::command_context::CommandContext; use crate::shared::print_json_pretty; -use crate::user_config::load_user_settings; -// --------------------------------------------------------------------------- -// System dependency types and parsers (server mode only) -// --------------------------------------------------------------------------- - -#[cfg(feature = "server")] -pub struct DepSpec { - pub name: &'static str, - command: &'static [&'static str], - pub required: bool, +pub(crate) struct DepSpec { + pub name: &'static str, + command: &'static [&'static str], + pub required: bool, pub min_version: Version, - pattern: &'static LazyLock, + pattern: &'static LazyLock, } -#[cfg(feature = "server")] #[derive(Debug, Clone, PartialEq)] -pub enum ProbeOutcome { +pub(crate) enum ProbeOutcome { NotFound, Failed, Ok { version: Option }, } -#[cfg(feature = "server")] static OPENSSL_RE: LazyLock = LazyLock::new(|| Regex::new(r"(?:OpenSSL|LibreSSL)\s+(\d+)\.(\d+)\.(\d+)").unwrap()); -#[cfg(feature = "server")] -static NODE_RE: LazyLock = LazyLock::new(|| Regex::new(r"v(\d+)\.(\d+)\.(\d+)").unwrap()); -#[cfg(feature = "server")] static DOT_RE: LazyLock = LazyLock::new(|| Regex::new(r"graphviz version (\d+)\.(\d+)\.(\d+)").unwrap()); -#[cfg(feature = "server")] +pub(crate) const DEP_SPECS: &[DepSpec] = &[ + DepSpec { + name: "openssl", + command: &["openssl", "version"], + required: true, + min_version: Version::new(3, 0, 0), + pattern: &OPENSSL_RE, + }, + DepSpec { + name: "dot", + command: &["dot", "-V"], + required: false, + min_version: Version::new(2, 0, 0), + pattern: &DOT_RE, + }, +]; + fn parse_version(re: &Regex, output: &str) -> Option { let caps = re.captures(output)?; Some(Version::new( @@ -66,57 +68,31 @@ fn parse_version(re: &Regex, output: &str) -> Option { )) } -#[cfg(feature = "server")] -pub const DEP_SPECS: &[DepSpec] = &[ - DepSpec { - name: "openssl", - command: &["openssl", "version"], - required: true, - min_version: Version::new(3, 0, 0), - pattern: &OPENSSL_RE, - }, - DepSpec { - name: "node", - command: &["node", "--version"], - required: true, - min_version: Version::new(20, 0, 0), - pattern: &NODE_RE, - }, - DepSpec { - name: "dot", - command: &["dot", "-V"], - required: false, - min_version: Version::new(2, 0, 0), - pattern: &DOT_RE, - }, -]; +pub(crate) async fn probe_system_deps() -> Vec { + let mut outcomes = Vec::with_capacity(DEP_SPECS.len()); + for spec in DEP_SPECS { + let result = TokioCommand::new(spec.command[0]) + .args(&spec.command[1..]) + .output() + .await + .ok(); -#[cfg(feature = "server")] -pub fn probe_system_deps() -> Vec { - DEP_SPECS - .iter() - .map(|spec| { - let result = Command::new(spec.command[0]) - .args(&spec.command[1..]) - .output() - .ok(); - - match result { - None => ProbeOutcome::NotFound, - Some(o) if !o.status.success() => ProbeOutcome::Failed, - Some(o) => { - let stdout = String::from_utf8_lossy(&o.stdout); - let stderr = String::from_utf8_lossy(&o.stderr); - let version = parse_version(spec.pattern, &stdout) - .or_else(|| parse_version(spec.pattern, &stderr)); - ProbeOutcome::Ok { version } - } + let outcome = match result { + None => ProbeOutcome::NotFound, + Some(output) if !output.status.success() => ProbeOutcome::Failed, + Some(output) => { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let version = parse_version(spec.pattern, &stdout) + .or_else(|| parse_version(spec.pattern, &stderr)); + ProbeOutcome::Ok { version } } - }) - .collect() + }; + outcomes.push(outcome); + } + outcomes } -#[cfg(feature = "server")] fn dep_issue(name: &str, issue: &str, required: bool) -> (CheckStatus, String) { let severity = if required { "required" } else { "optional" }; let status = if required { @@ -127,8 +103,7 @@ fn dep_issue(name: &str, issue: &str, required: bool) -> (CheckStatus, String) { (status, format!("{name}: {issue} ({severity})")) } -#[cfg(feature = "server")] -pub fn check_system_deps(specs: &[DepSpec], outcomes: &[ProbeOutcome]) -> CheckResult { +pub(crate) fn check_system_deps(specs: &[DepSpec], outcomes: &[ProbeOutcome]) -> CheckResult { let mut details = Vec::new(); let mut worst_status = CheckStatus::Pass; @@ -139,14 +114,16 @@ pub fn check_system_deps(specs: &[DepSpec], outcomes: &[ProbeOutcome]) -> CheckR ProbeOutcome::Ok { version: None } => { (CheckStatus::Pass, format!("{}: version unknown", spec.name)) } - ProbeOutcome::Ok { version: Some(v) } => { - if v < &spec.min_version { + ProbeOutcome::Ok { + version: Some(version), + } => { + if version < &spec.min_version { ( CheckStatus::Warning, - format!("{}: {v} (minimum {})", spec.name, spec.min_version), + format!("{}: {version} (minimum {})", spec.name, spec.min_version), ) } else { - (CheckStatus::Pass, format!("{}: {v}", spec.name)) + (CheckStatus::Pass, format!("{}: {version}", spec.name)) } } }; @@ -175,786 +152,174 @@ pub fn check_system_deps(specs: &[DepSpec], outcomes: &[ProbeOutcome]) -> CheckR } } -// --------------------------------------------------------------------------- -// Check functions (pure, testable) -// --------------------------------------------------------------------------- - -fn apply_live_result( - live_result: Option<&Result<(), String>>, - details: &mut Vec, - remediation_msg: &str, -) -> (CheckStatus, Option) { - match live_result { - Some(Ok(())) => { - details.push(CheckDetail::new("Connectivity: OK".to_string())); - (CheckStatus::Pass, None) - } - Some(Err(e)) => { - details.push(CheckDetail::new(format!("Connectivity: {e}"))); - (CheckStatus::Warning, Some(remediation_msg.to_string())) - } - None => (CheckStatus::Pass, None), - } -} - pub(crate) fn check_config( - user_path: Option, - legacy_path: Option, + settings_path: Option, + legacy_paths: &[PathBuf], ) -> CheckResult { - match (user_path, legacy_path) { - (Some(p), None) => CheckResult { - name: "Configuration".to_string(), - status: CheckStatus::Pass, - summary: p.display().to_string(), - details: vec![CheckDetail::new(format!("Loaded from {}", p.display()))], + match (settings_path, legacy_paths.is_empty()) { + (Some(path), true) => CheckResult { + name: "Configuration".to_string(), + status: CheckStatus::Pass, + summary: path.display().to_string(), + details: vec![CheckDetail::new(format!("Loaded from {}", path.display()))], remediation: None, }, - (Some(p), Some(legacy)) => CheckResult { - name: "Configuration".to_string(), - status: CheckStatus::Warning, - summary: p.display().to_string(), - details: vec![ - CheckDetail::new(format!("Loaded from {}", p.display())), - CheckDetail::new(format!("Ignoring legacy config file {}", legacy.display())), - ], - remediation: Some(format!("Delete or rename {}", legacy.display())), + (Some(path), false) => CheckResult { + name: "Configuration".to_string(), + status: CheckStatus::Warning, + summary: path.display().to_string(), + details: std::iter::once(CheckDetail::new(format!( + "Loaded from {}", + path.display() + ))) + .chain(legacy_paths.iter().map(|legacy| { + CheckDetail::new(format!("Ignoring legacy config file {}", legacy.display())) + })) + .collect(), + remediation: Some("Delete or rename legacy config files".to_string()), }, - (None, Some(legacy)) => CheckResult { - name: "Configuration".to_string(), - status: CheckStatus::Warning, - summary: "legacy config file ignored".to_string(), - details: vec![ - CheckDetail::new(format!("Found legacy config file {}", legacy.display())), - CheckDetail::new("Rename it to ~/.fabro/user.toml".to_string()), - ], - remediation: Some(format!("Rename {} to ~/.fabro/user.toml", legacy.display())), + (None, false) => CheckResult { + name: "Configuration".to_string(), + status: CheckStatus::Warning, + summary: "legacy config files ignored".to_string(), + details: legacy_paths + .iter() + .map(|legacy| { + CheckDetail::new(format!("Found legacy config file {}", legacy.display())) + }) + .chain(std::iter::once(CheckDetail::new( + "Rename one to ~/.fabro/settings.toml or create a new settings.toml" + .to_string(), + ))) + .collect(), + remediation: Some("Create ~/.fabro/settings.toml".to_string()), }, - (None, None) => CheckResult { - name: "Configuration".to_string(), - status: CheckStatus::Warning, - summary: "no user config file found".to_string(), - details: vec![CheckDetail::new( - "Create ~/.fabro/user.toml to configure Fabro".to_string(), + (None, true) => CheckResult { + name: "Configuration".to_string(), + status: CheckStatus::Warning, + summary: "no settings config file found".to_string(), + details: vec![CheckDetail::new( + "Create ~/.fabro/settings.toml to configure Fabro".to_string(), )], - remediation: Some("Create ~/.fabro/user.toml".to_string()), + remediation: Some("Create ~/.fabro/settings.toml".to_string()), }, } } -pub(crate) fn check_llm_providers( - statuses: &[(Provider, bool)], - live_results: Option<&[(Provider, Result<(), String>)]>, -) -> CheckResult { - let count = statuses.iter().filter(|(_, set)| *set).count(); - - let mut details: Vec = statuses - .iter() - .filter(|(_, set)| *set) - .map(|(provider, _)| { - let env_vars = provider.api_key_env_vars().join(" or "); - CheckDetail::new(format!("{provider} ({env_vars}): set")) - }) - .collect(); - - let mut failed_providers: Vec<&Provider> = Vec::new(); - if let Some(results) = live_results { - for (provider, result) in results { - match result { - Ok(()) => details.push(CheckDetail::new(format!("{provider} connectivity: OK",))), - Err(e) => { - failed_providers.push(provider); - details.push(CheckDetail::new(format!("{provider} connectivity: {e}",))); - } - } - } - } - - if count == 0 { - CheckResult { - name: "LLM providers".to_string(), - status: CheckStatus::Error, - summary: "none configured".to_string(), - details, - remediation: Some("Set at least one provider API key".to_string()), - } - } else if !failed_providers.is_empty() { - let names: Vec<_> = failed_providers - .iter() - .map(std::string::ToString::to_string) - .collect(); - CheckResult { - name: "LLM providers".to_string(), - status: CheckStatus::Warning, - summary: format!("{count} configured (connectivity issues)"), - details, - remediation: Some(format!("Connectivity issues with: {}", names.join(", "))), - } - } else { - CheckResult { - name: "LLM providers".to_string(), - status: CheckStatus::Pass, - summary: format!("{count} configured"), - details, - remediation: None, - } - } -} - -pub(crate) fn check_brave_search( - api_key_set: bool, - live_result: Option<&Result<(), String>>, -) -> CheckResult { - let mut details = vec![CheckDetail::new(format!( - "BRAVE_SEARCH_API_KEY is {}", - if api_key_set { "set" } else { "not set" } - ))]; - - let (mut status, mut remediation) = if api_key_set { - (CheckStatus::Pass, None) - } else { - ( - CheckStatus::Warning, - Some("Set BRAVE_SEARCH_API_KEY to enable web search".to_string()), - ) - }; - - if api_key_set { - let (live_status, live_remediation) = apply_live_result( - live_result, - &mut details, - "Check BRAVE_SEARCH_API_KEY and network connectivity", - ); - if live_status == CheckStatus::Warning { - status = live_status; - remediation = live_remediation; - } - } - - let summary = match (api_key_set, live_result) { - (true, Some(Ok(()))) => "API key set, connected".to_string(), - (true, Some(Err(_))) => "API key set, connectivity error".to_string(), - (true, None) => "API key set".to_string(), - (false, _) => "not configured".to_string(), - }; - - CheckResult { - name: "Brave Search".to_string(), - status, - summary, - details, - remediation, - } -} - -pub(crate) struct SandboxStatus { - pub daytona_configured: bool, - pub daytona_probe: Option>, -} - -pub(crate) fn check_sandbox(status: &SandboxStatus) -> CheckResult { - let mut details = Vec::new(); - - match &status.daytona_probe { - Some(Ok(())) => { - details.push(CheckDetail::new( - "Daytona (DAYTONA_API_KEY): available".to_string(), - )); - return CheckResult { - name: "Cloud sandbox".to_string(), - status: CheckStatus::Pass, - summary: "Daytona available".to_string(), - details, - remediation: None, - }; - } - Some(Err(e)) => { - details.push(CheckDetail::new(format!( - "Daytona (DAYTONA_API_KEY): error — {e}", - ))); - return CheckResult { - name: "Cloud sandbox".to_string(), - status: CheckStatus::Error, - summary: format!("Daytona: {e}"), - details, - remediation: Some("Fix sandbox configuration errors".to_string()), - }; - } - None if status.daytona_configured => { - details.push(CheckDetail::new( - "Daytona (DAYTONA_API_KEY): configured".to_string(), - )); - return CheckResult { - name: "Cloud sandbox".to_string(), - status: CheckStatus::Pass, - summary: "Daytona configured".to_string(), - details, - remediation: None, - }; - } - None => { - details.push(CheckDetail::new( - "Daytona (DAYTONA_API_KEY): not configured".to_string(), - )); - } - } - - CheckResult { - name: "Cloud sandbox".to_string(), - status: CheckStatus::Warning, - summary: "no sandbox configured".to_string(), - details, - remediation: Some("Set DAYTONA_API_KEY to enable cloud sandbox execution".to_string()), - } -} - -pub(crate) struct GithubAppStatus { - pub app_id: Option, - pub slug: Option, - pub private_key_set: bool, - /// Result of attempting to sign a JWT with the configured credentials. - /// `None` if app_id or private key is missing. - pub sign_result: Option>, - #[cfg(feature = "server")] - pub client_id: bool, - #[cfg(feature = "server")] - pub client_secret: bool, - #[cfg(feature = "server")] - pub webhook_secret: bool, -} - -impl GithubAppStatus { - fn core_set(&self) -> bool { - self.app_id.is_some() && self.private_key_set - } - - fn none_set(&self) -> bool { - let core_none = self.app_id.is_none() && !self.private_key_set; - #[cfg(feature = "server")] - { - core_none && !self.client_id && !self.client_secret && !self.webhook_secret - } - #[cfg(not(feature = "server"))] - { - core_none - } - } - - #[cfg(feature = "server")] - fn all_set(&self) -> bool { - self.core_set() && self.client_id && self.client_secret && self.webhook_secret - } -} - -pub(crate) fn check_github_app(status: &GithubAppStatus) -> CheckResult { - let mut details: Vec = Vec::new(); - - match (&status.app_id, &status.slug) { - (Some(id), Some(slug)) => details.push(CheckDetail::new(format!("App: {slug} (ID {id})"))), - (Some(id), None) => details.push(CheckDetail::new(format!("App ID: {id}"))), - _ => details.push(CheckDetail::new("git.app_id: not set".to_string())), - } - - details.push(CheckDetail::new(format!( - "GITHUB_APP_PRIVATE_KEY: {}", - if status.private_key_set { - "set" - } else { - "not set" - } - ))); - - #[allow(unused_mut)] - let mut fields: Vec<(&str, bool)> = vec![ - ("git.app_id", status.app_id.is_some()), - ("GITHUB_APP_PRIVATE_KEY", status.private_key_set), - ]; - - #[cfg(feature = "server")] - { - let server_fields: Vec<(&str, bool)> = vec![ - ("git.client_id", status.client_id), - ("GITHUB_APP_CLIENT_SECRET", status.client_secret), - ("GITHUB_APP_WEBHOOK_SECRET", status.webhook_secret), - ]; - for (name, set) in &server_fields { - details.push(CheckDetail::new(format!( - "{name}: {}", - if *set { "set" } else { "not set" } - ))); - } - fields.extend(server_fields); - } - - // Add key validation detail - if let Some(ref result) = status.sign_result { - match result { - Ok(()) => details.push(CheckDetail::new( - "Private key: valid (JWT signing OK)".to_string(), - )), - Err(e) => details.push(CheckDetail::new(format!("Private key: invalid ({e})"))), - } - } - - let has_sign_error = matches!(&status.sign_result, Some(Err(_))); - - if has_sign_error { - let msg = status - .sign_result - .as_ref() - .unwrap() - .as_ref() - .unwrap_err() - .clone(); - return CheckResult { - name: "GitHub App".to_string(), - status: CheckStatus::Error, - summary: "private key invalid".to_string(), - details, - remediation: Some(format!( - "GITHUB_APP_PRIVATE_KEY failed JWT signing: {msg}. \ - Generate a new private key from your GitHub App settings." - )), - }; - } - - if status.none_set() { - return CheckResult { - name: "GitHub App".to_string(), - status: CheckStatus::Warning, - summary: "not configured".to_string(), - details, +fn check_legacy_env(path: Option) -> CheckResult { + match path { + Some(path) => CheckResult { + name: "Legacy .env".to_string(), + status: CheckStatus::Warning, + summary: "legacy secrets file detected".to_string(), + details: vec![CheckDetail::new(format!( + "{} is no longer read by fabro", + path.display() + ))], remediation: Some( - "Configure GitHub App in server.toml and set env vars to enable GitHub integration" + "Re-enter credentials with `fabro provider login` or `fabro secret set`." .to_string(), ), - }; - } - - #[cfg(feature = "server")] - if status.all_set() { - return CheckResult { - name: "GitHub App".to_string(), - status: CheckStatus::Pass, - summary: "fully configured".to_string(), - details, + }, + None => CheckResult { + name: "Legacy .env".to_string(), + status: CheckStatus::Pass, + summary: "not present".to_string(), + details: Vec::new(), remediation: None, - }; - } - - #[cfg(not(feature = "server"))] - if status.core_set() { - return CheckResult { - name: "GitHub App".to_string(), - status: CheckStatus::Pass, - summary: "configured".to_string(), - details, - remediation: None, - }; - } - - let missing: Vec<_> = fields - .iter() - .filter(|(_, set)| !set) - .map(|(name, _)| *name) - .collect(); - CheckResult { - name: "GitHub App".to_string(), - status: CheckStatus::Error, - summary: "partially configured".to_string(), - details, - remediation: Some(format!("Missing: {}", missing.join(", "))), - } -} - -#[cfg(feature = "server")] -pub struct ApiStatus { - pub base_url: String, - pub authentication_strategies: Vec, -} - -#[cfg(feature = "server")] -fn format_auth_strategies(strategies: &[ApiAuthStrategy]) -> String { - strategies - .iter() - .map(|s| match s { - ApiAuthStrategy::Jwt => "jwt", - ApiAuthStrategy::Mtls => "mtls", - }) - .collect::>() - .join(", ") -} - -#[cfg(feature = "server")] -pub fn check_api(status: &ApiStatus, live_result: Option<&Result<(), String>>) -> CheckResult { - let mut details = vec![ - CheckDetail::new(format!("Base URL: {}", status.base_url)), - CheckDetail::new(format!( - "Authentication: {}", - format_auth_strategies(&status.authentication_strategies) - )), - ]; - - let (check_status, remediation) = apply_live_result( - live_result, - &mut details, - "Check that the API server is running and reachable", - ); - - CheckResult { - name: "Fabro API".to_string(), - status: check_status, - summary: status.base_url.clone(), - details, - remediation, - } -} - -#[cfg(feature = "server")] -pub struct WebStatus { - pub url: String, - pub auth_provider: AuthProvider, - pub allowed_usernames_count: usize, -} - -#[cfg(feature = "server")] -fn format_auth_provider(provider: &AuthProvider) -> &'static str { - match provider { - AuthProvider::Github => "github", - AuthProvider::InsecureDisabled => "insecure_disabled", - } -} - -#[cfg(feature = "server")] -pub fn check_web(status: &WebStatus, live_result: Option<&Result<(), String>>) -> CheckResult { - let mut details = vec![ - CheckDetail::new(format!("URL: {}", status.url)), - CheckDetail::new(format!( - "Auth provider: {}", - format_auth_provider(&status.auth_provider) - )), - CheckDetail::new(format!( - "Allowed usernames: {}", - status.allowed_usernames_count - )), - ]; - - let (check_status, remediation) = apply_live_result( - live_result, - &mut details, - "Check that the web app is running and reachable", - ); - - CheckResult { - name: "Fabro Web".to_string(), - status: check_status, - summary: status.url.clone(), - details, - remediation, - } -} - -// --------------------------------------------------------------------------- -// Cryptographic key validation -// --------------------------------------------------------------------------- - -#[cfg(feature = "server")] -pub struct TlsCheckInput { - pub cert_pem: String, - pub key_pem: String, - pub ca_pem: String, -} - -#[cfg(feature = "server")] -pub struct CryptoInput { - pub auth_strategies: Vec, - pub tls_files: Option>, - pub jwt_public_key: Option, - pub jwt_private_key: Option, - pub session_secret: Option, - pub now_epoch: i64, -} - -#[cfg(feature = "server")] -fn decode_pem_value(name: &str, value: &str) -> Result { - if value.starts_with("-----") { - return Ok(value.to_string()); - } - let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, value) - .map_err(|e| format!("{name} is not valid PEM or base64: {e}"))?; - String::from_utf8(bytes).map_err(|e| format!("{name} base64 decoded to invalid UTF-8: {e}")) -} - -#[cfg(feature = "server")] -fn validate_tls_cert(pem: &str, now_epoch: i64) -> Result { - let mut reader = std::io::Cursor::new(pem.as_bytes()); - let certs: Vec<_> = rustls_pemfile::certs(&mut reader) - .collect::, _>>() - .map_err(|e| format!("failed to parse certificate PEM: {e}"))?; - if certs.is_empty() { - return Err("no certificates found in PEM".to_string()); - } - let (_, parsed) = x509_parser::parse_x509_certificate(&certs[0]) - .map_err(|e| format!("failed to parse X.509 certificate: {e}"))?; - let not_after = parsed.validity().not_after.timestamp(); - if not_after <= now_epoch { - return Err("certificate has expired".to_string()); - } - let cn = parsed - .subject() - .iter_common_name() - .next() - .and_then(|cn| cn.as_str().ok()) - .unwrap_or("(no CN)"); - Ok(format!("CN={cn}, valid")) -} - -#[cfg(feature = "server")] -fn validate_tls_private_key(pem: &str) -> Result<(), String> { - let mut reader = std::io::Cursor::new(pem.as_bytes()); - rustls_pemfile::private_key(&mut reader) - .map_err(|e| format!("failed to parse private key PEM: {e}"))? - .ok_or_else(|| "no private key found in PEM".to_string())?; - Ok(()) -} - -#[cfg(feature = "server")] -fn validate_tls_ca(pem: &str) -> Result<(), String> { - let mut reader = std::io::Cursor::new(pem.as_bytes()); - let certs: Vec<_> = rustls_pemfile::certs(&mut reader) - .collect::, _>>() - .map_err(|e| format!("failed to parse CA certificate PEM: {e}"))?; - if certs.is_empty() { - return Err("no CA certificates found in PEM".to_string()); - } - Ok(()) -} - -#[cfg(feature = "server")] -fn validate_session_secret(value: &str) -> Result<(), String> { - if value.len() < 64 { - return Err(format!( - "too short ({} chars, need at least 64 hex chars for 256-bit entropy)", - value.len() - )); - } - if !value.chars().all(|c| c.is_ascii_hexdigit()) { - return Err("contains non-hex characters".to_string()); - } - Ok(()) -} - -#[cfg(feature = "server")] -struct CryptoCheckState { - details: Vec, - errors: Vec, - worst: CheckStatus, -} - -#[cfg(feature = "server")] -impl CryptoCheckState { - fn new() -> Self { - Self { - details: Vec::new(), - errors: Vec::new(), - worst: CheckStatus::Pass, - } - } - - /// Record a validation result. Ok(suffix) becomes "{label}: {suffix}" detail, - /// Err(msg) becomes an error detail and is accumulated for remediation. - fn record(&mut self, label: &str, result: Result) { - match result { - Ok(suffix) => self - .details - .push(CheckDetail::new(format!("{label}: {suffix}"))), - Err(e) => { - self.worst = CheckStatus::Error; - let text = format!("{label}: {e}"); - self.errors.push(text.clone()); - self.details.push(CheckDetail::new(text)); - } - } - } - - fn record_unit(&mut self, label: &str, result: Result<(), String>) { - self.record(label, result.map(|()| "valid".to_string())); - } - - fn push_error(&mut self, text: String) { - self.worst = CheckStatus::Error; - self.errors.push(text.clone()); - self.details.push(CheckDetail::new(text)); - } -} - -#[cfg(feature = "server")] -pub fn check_crypto(input: &CryptoInput) -> CheckResult { - let has_jwt = input.auth_strategies.contains(&ApiAuthStrategy::Jwt); - let has_mtls = input.auth_strategies.contains(&ApiAuthStrategy::Mtls); - - let mut state = CryptoCheckState::new(); - - // mTLS certs - if has_mtls { - match &input.tls_files { - Some(Ok(tls)) => { - state.record( - "TLS cert", - validate_tls_cert(&tls.cert_pem, input.now_epoch) - .map(|info| format!("valid ({info})")), - ); - state.record_unit("TLS key", validate_tls_private_key(&tls.key_pem)); - state.record_unit("TLS CA", validate_tls_ca(&tls.ca_pem)); - } - Some(Err(e)) => state.push_error(format!("TLS files: {e}")), - None => state.push_error("mTLS configured but [api.tls] not set".to_string()), - } - } - - // JWT public key - if has_jwt { - let result = input - .jwt_public_key - .as_deref() - .ok_or_else(|| "JWT configured but FABRO_JWT_PUBLIC_KEY not set".to_string()) - .and_then(|raw| decode_pem_value("FABRO_JWT_PUBLIC_KEY", raw)) - .and_then(|pem| { - jsonwebtoken::DecodingKey::from_ed_pem(pem.as_bytes()) - .map(|_| ()) - .map_err(|e| format!("invalid Ed25519 — {e}")) - }); - state.record_unit("JWT public key", result); - } - - // JWT private key (only when set) - if let Some(raw) = &input.jwt_private_key { - let result = decode_pem_value("FABRO_JWT_PRIVATE_KEY", raw).and_then(|pem| { - jsonwebtoken::EncodingKey::from_ed_pem(pem.as_bytes()) - .map(|_| ()) - .map_err(|e| format!("invalid Ed25519 — {e}")) - }); - state.record_unit("JWT private key", result); - } - - // Session secret (only when set) - if let Some(secret) = &input.session_secret { - state.record_unit("Session secret", validate_session_secret(secret)); - } - - // No auth at all - if !has_jwt && !has_mtls && input.jwt_private_key.is_none() && input.session_secret.is_none() { - return CheckResult { - name: "Cryptographic keys".to_string(), - status: CheckStatus::Warning, - summary: "no authentication configured".to_string(), - details: vec![CheckDetail::new( - "No authentication strategies or keys configured".to_string(), - )], - remediation: Some( - "Configure authentication_strategies in [api] section of server.toml".to_string(), - ), - }; - } - - let summary = match state.worst { - CheckStatus::Pass => "all keys valid".to_string(), - CheckStatus::Warning => "some issues".to_string(), - CheckStatus::Error => "invalid keys found".to_string(), - }; - - CheckResult { - name: "Cryptographic keys".to_string(), - status: state.worst, - summary, - details: state.details, - remediation: if state.errors.is_empty() { - None - } else { - Some(state.errors.join("; ")) }, } } -// --------------------------------------------------------------------------- -// Orchestrator (does real I/O) -// --------------------------------------------------------------------------- - -async fn probe_daytona() -> Option> { - if std::env::var("DAYTONA_API_KEY").is_err() { - return None; - } - Some( - daytona_sdk::Client::new() - .await - .map(|_| ()) - .map_err(|e| e.to_string()), - ) -} - -pub(crate) fn probe_model(provider: Provider) -> String { - Catalog::builtin().probe_for_provider(provider).map_or_else( - || format!("unknown-{}", provider.as_str()), - |m| m.id.clone(), - ) -} - -async fn probe_llm_provider( - client: &LlmClient, - provider: Provider, -) -> (Provider, Result<(), String>) { - let request = Request { - model: probe_model(provider), - messages: vec![Message::user("hi")], - provider: Some(provider.as_str().to_string()), - tools: None, - tool_choice: None, - response_format: None, - temperature: None, - top_p: None, - max_tokens: Some(16), - stop_sequences: None, - reasoning_effort: None, - speed: None, - metadata: None, - provider_options: None, - }; - let result = client - .complete(&request) - .await - .map(|_| ()) - .map_err(|e| e.to_string()); - (provider, result) -} - -async fn probe_brave_search(http: &reqwest::Client) -> Result<(), String> { - let api_key = std::env::var("BRAVE_SEARCH_API_KEY") - .map_err(|_| "BRAVE_SEARCH_API_KEY not set".to_string())?; - let resp = http - .get("https://api.search.brave.com/res/v1/web/search?q=test&count=1") - .header("X-Subscription-Token", api_key) - .send() - .await - .map_err(|e| e.to_string())?; - if resp.status().is_success() { - Ok(()) +fn check_version_parity(server_version: &str) -> CheckResult { + let cli_version = FABRO_VERSION; + if server_version == cli_version { + CheckResult { + name: "Version parity".to_string(), + status: CheckStatus::Pass, + summary: cli_version.to_string(), + details: vec![CheckDetail::new(format!( + "CLI and server are both {cli_version}" + ))], + remediation: None, + } } else { - Err(format!("HTTP {}", resp.status())) + CheckResult { + name: "Version parity".to_string(), + status: CheckStatus::Warning, + summary: format!("CLI {cli_version}, server {server_version}"), + details: vec![CheckDetail::new(format!( + "CLI version {cli_version} does not match server version {server_version}" + ))], + remediation: Some( + "Upgrade or restart components so the CLI and server run the same version." + .to_string(), + ), + } } } -#[cfg(feature = "server")] -async fn probe_url(http: &reqwest::Client, url: &str) -> Result<(), String> { - http.get(url) - .send() - .await - .map(|_| ()) - .map_err(|e| e.to_string()) +fn convert_diagnostics_status(status: api_types::DiagnosticsCheckStatus) -> CheckStatus { + match status { + api_types::DiagnosticsCheckStatus::Pass => CheckStatus::Pass, + api_types::DiagnosticsCheckStatus::Warning => CheckStatus::Warning, + api_types::DiagnosticsCheckStatus::Error => CheckStatus::Error, + } +} + +fn convert_diagnostics_sections(sections: Vec) -> Vec { + sections + .into_iter() + .map(|section| CheckSection { + title: section.title, + checks: section + .checks + .into_iter() + .map(|check| CheckResult { + name: check.name, + status: convert_diagnostics_status(check.status), + summary: check.summary, + details: check + .details + .into_iter() + .map(|detail| CheckDetail { + text: detail.text, + warn: detail.warn, + }) + .collect(), + remediation: check.remediation, + }) + .collect(), + }) + .collect() +} + +fn render_report_text( + report: &CheckReport, + styles: &Styles, + verbose: bool, + max_width: Option, +) -> String { + report.render(styles, verbose, None, max_width) +} + +fn render_report(report: &CheckReport, styles: &Styles, verbose: bool, printer: Printer) { + let term_width = console::Term::stderr().size().1; + { + use std::fmt::Write as _; + let _ = write!( + printer.stdout(), + "{}", + render_report_text(report, styles, verbose, Some(term_width)) + ); + } } pub(crate) async fn run_doctor( + args: &DoctorArgs, verbose: bool, - live: bool, globals: &GlobalArgs, + printer: Printer, ) -> Result { let styles = Styles::detect_stdout(); let spinner = if globals.json { @@ -966,259 +331,159 @@ pub(crate) async fn run_doctor( .expect("valid template") .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", ""]), ); - spinner.set_message("Running checks…"); + spinner.set_message("Running checks..."); spinner.enable_steady_tick(std::time::Duration::from_millis(80)); Some(spinner) }; - // Gather state - let cli_settings = load_user_settings().unwrap_or_default(); - - let user_config_path = default_user_config_path(); - let user_config_exists = user_config_path.as_ref().is_some_and(|p| p.exists()); - let legacy_config_path = legacy_user_config_path(); - let legacy_config_exists = legacy_config_path.as_ref().is_some_and(|p| p.exists()); - - let llm_statuses: Vec<(Provider, bool)> = Provider::ALL - .iter() - .map(|p| (*p, p.has_api_key())) - .collect(); - - let brave_key_set = std::env::var("BRAVE_SEARCH_API_KEY").is_ok(); - - let daytona_configured = std::env::var("DAYTONA_API_KEY").is_ok(); - - #[cfg(feature = "server")] - let server_settings = fabro_config::server::load_server_settings(None).unwrap_or_default(); - - #[cfg(feature = "server")] - let api_status = { - let api = server_settings.api.clone().unwrap_or_default(); - ApiStatus { - base_url: api.base_url.clone(), - authentication_strategies: api.authentication_strategies.clone(), - } + let settings_config_path = active_settings_path(None); + let legacy_config_paths = [ + legacy_user_config_path(), + legacy_old_user_config_path(), + legacy_server_config_path(), + ] + .into_iter() + .flatten() + .filter(|path| path.exists()) + .collect::>(); + let legacy_env_path = { + let p = legacy_env::legacy_env_file_path(); + p.exists().then_some(p) }; - #[cfg(feature = "server")] - let web_status = { - let web = server_settings.web.clone().unwrap_or_default(); - WebStatus { - url: web.url.clone(), - auth_provider: web.auth.provider.clone(), - allowed_usernames_count: web.auth.allowed_usernames.len(), - } - }; - - #[cfg(feature = "server")] - let server_git = server_settings.git.clone().unwrap_or_default(); - - #[cfg(feature = "server")] - let server_api = server_settings.api.clone().unwrap_or_default(); - - #[cfg(feature = "server")] - let server_web = server_settings.web.clone().unwrap_or_default(); - - let git_app_id = cli_settings.app_id().map(str::to_owned); - let private_key_raw = std::env::var("GITHUB_APP_PRIVATE_KEY").ok(); - let sign_result = match (&git_app_id, &private_key_raw) { - (Some(app_id), Some(raw)) => { - let pem = if raw.starts_with("-----") { - Ok(raw.clone()) - } else { - BASE64_STANDARD - .decode(raw) - .map_err(|e| format!("base64 decode failed: {e}")) - .and_then(|bytes| { - String::from_utf8(bytes) - .map_err(|e| format!("decoded key is not valid UTF-8: {e}")) - }) - }; - match pem { - Ok(pem) => Some( - fabro_github::sign_app_jwt(app_id, &pem) - .map(|_| ()) - .map_err(|e| e.clone()), - ), - Err(e) => Some(Err(e)), - } - } - _ => None, - }; - let github_status = GithubAppStatus { - app_id: git_app_id, - slug: cli_settings.slug().map(str::to_owned), - private_key_set: private_key_raw.is_some(), - sign_result, - #[cfg(feature = "server")] - client_id: server_git.client_id.is_some(), - #[cfg(feature = "server")] - client_secret: std::env::var("GITHUB_APP_CLIENT_SECRET").is_ok(), - #[cfg(feature = "server")] - webhook_secret: std::env::var("GITHUB_APP_WEBHOOK_SECRET").is_ok(), - }; - - #[cfg(feature = "server")] - let crypto_input = { - let has_mtls = server_api - .authentication_strategies - .contains(&ApiAuthStrategy::Mtls); - let tls_files = if has_mtls { - server_api.tls.as_ref().map(|tls| { - let read = |p: &std::path::Path| -> Result { - let expanded = fabro_config::expand_tilde(p); - std::fs::read_to_string(&expanded) - .map_err(|e| format!("{}: {e}", expanded.display())) - }; - Ok(TlsCheckInput { - cert_pem: read(&tls.cert)?, - key_pem: read(&tls.key)?, - ca_pem: read(&tls.ca)?, - }) - }) - } else { - None - }; - CryptoInput { - auth_strategies: server_api.authentication_strategies.clone(), - tls_files, - jwt_public_key: std::env::var("FABRO_JWT_PUBLIC_KEY").ok(), - jwt_private_key: std::env::var("FABRO_JWT_PRIVATE_KEY").ok(), - session_secret: std::env::var("SESSION_SECRET").ok(), - now_epoch: chrono::Utc::now().timestamp(), - } - }; - - #[cfg(feature = "server")] - let dep_results = probe_system_deps(); - - // Live probes (only when --live is set) - let sandbox_status; - let llm_live_results: Option)>>; - let brave_live_result: Option>; - #[cfg(feature = "server")] - let api_live_result: Option>; - #[cfg(feature = "server")] - let web_live_result: Option>; - - if live { - let http = reqwest::Client::new(); - - // Build LLM client — may fail if no keys are set - let llm_client = LlmClient::from_env().await.ok(); - - let configured_providers: Vec = llm_statuses - .iter() - .filter(|(_, set)| *set) - .map(|(p, _)| *p) - .collect(); - - let llm_fut = async { - if let Some(client) = &llm_client { - let futures: Vec<_> = configured_providers - .iter() - .map(|p| probe_llm_provider(client, *p)) - .collect(); - Some(join_all(futures).await) - } else { - None - } - }; - - let sandbox_fut = async { - let daytona_probe = probe_daytona().await; - SandboxStatus { - daytona_configured, - daytona_probe, - } - }; - let brave_fut = probe_brave_search(&http); - - #[cfg(feature = "server")] - { - let api_url = format!("{}/runs", server_api.base_url); - let api_fut = probe_url(&http, &api_url); - let web_fut = probe_url(&http, &server_web.url); - - let (sandbox, llm, brave, api, web) = - tokio::join!(sandbox_fut, llm_fut, brave_fut, api_fut, web_fut); - - sandbox_status = sandbox; - llm_live_results = llm; - brave_live_result = Some(brave); - api_live_result = Some(api); - web_live_result = Some(web); - } - - #[cfg(not(feature = "server"))] - { - let (sandbox, llm, brave) = tokio::join!(sandbox_fut, llm_fut, brave_fut); - - sandbox_status = sandbox; - llm_live_results = llm; - brave_live_result = Some(brave); - } - } else { - sandbox_status = SandboxStatus { - daytona_configured, - daytona_probe: None, - }; - llm_live_results = None; - brave_live_result = None; - #[cfg(feature = "server")] - { - api_live_result = None; - web_live_result = None; - } - } - - // Run pure checks - #[allow(unused_mut)] - let mut sections = vec![ - CheckSection { - title: "Required".into(), + let mut report = CheckReport { + title: "Fabro Doctor".to_string(), + sections: vec![CheckSection { + title: "Local".to_string(), checks: vec![ check_config( - if user_config_exists { - user_config_path - } else { - None - }, - if legacy_config_exists { - legacy_config_path - } else { - None - }, + settings_config_path + .exists() + .then_some(settings_config_path), + &legacy_config_paths, ), - check_llm_providers(&llm_statuses, llm_live_results.as_deref()), - check_github_app(&github_status), + check_legacy_env(legacy_env_path), ], - }, - CheckSection { - title: "Optional".into(), - checks: vec![ - check_sandbox(&sandbox_status), - check_brave_search(brave_key_set, brave_live_result.as_ref()), - ], - }, - ]; - - #[cfg(feature = "server")] - sections.push(CheckSection { - title: "Server".into(), - checks: vec![ - check_system_deps(DEP_SPECS, &dep_results), - check_api(&api_status, api_live_result.as_ref()), - check_web(&web_status, web_live_result.as_ref()), - check_crypto(&crypto_input), - ], - }); - - let report = CheckReport { - title: "Fabro Doctor".into(), - sections, + }], }; + let ctx = match CommandContext::for_target(&args.target, printer) { + Ok(ctx) => ctx, + Err(err) => { + report.sections.push(CheckSection { + title: "Server".to_string(), + checks: vec![CheckResult { + name: "Fabro server".to_string(), + status: CheckStatus::Error, + summary: "settings resolution failed".to_string(), + details: vec![CheckDetail::new(err.to_string())], + remediation: Some( + "Fix the local CLI settings or provide `--server`, then run doctor again." + .to_string(), + ), + }], + }); + + if let Some(spinner) = spinner { + spinner.finish_and_clear(); + } + + if globals.json { + print_json_pretty(&report)?; + } else { + render_report(&report, &styles, verbose, printer); + } + return Ok(1); + } + }; + + let server = match ctx.server().await { + Ok(server) => server, + Err(err) => { + report.sections.push(CheckSection { + title: "Server".to_string(), + checks: vec![CheckResult { + name: "Fabro server".to_string(), + status: CheckStatus::Error, + summary: "unreachable".to_string(), + details: vec![CheckDetail::new(err.to_string())], + remediation: Some( + "Start or connect to the server with `--server` and run doctor again." + .to_string(), + ), + }], + }); + + if let Some(spinner) = spinner { + spinner.finish_and_clear(); + } + + if globals.json { + print_json_pretty(&report)?; + } else { + render_report(&report, &styles, verbose, printer); + } + return Ok(1); + } + }; + + let health = match server.api().get_health().send().await { + Ok(response) => response.into_inner(), + Err(err) => { + report.sections.push(CheckSection { + title: "Server".to_string(), + checks: vec![CheckResult { + name: "Fabro server".to_string(), + status: CheckStatus::Error, + summary: "health check failed".to_string(), + details: vec![CheckDetail::new(err.to_string())], + remediation: Some( + "Check that the server is reachable and responding to /health.".to_string(), + ), + }], + }); + + if let Some(spinner) = spinner { + spinner.finish_and_clear(); + } + + if globals.json { + print_json_pretty(&report)?; + } else { + render_report(&report, &styles, verbose, printer); + } + return Ok(1); + } + }; + + report.sections[0] + .checks + .push(check_version_parity(&health.version)); + + match server.api().run_diagnostics().send().await { + Ok(response) => { + let diagnostics = response.into_inner(); + report + .sections + .extend(convert_diagnostics_sections(diagnostics.sections)); + } + Err(err) => { + report.sections.push(CheckSection { + title: "Server".to_string(), + checks: vec![CheckResult { + name: "Diagnostics".to_string(), + status: CheckStatus::Error, + summary: "probe failed".to_string(), + details: vec![CheckDetail::new(err.to_string())], + remediation: Some( + "Fix the server diagnostics failure and run `fabro doctor` again." + .to_string(), + ), + }], + }); + } + } + if let Some(spinner) = spinner { spinner.finish_and_clear(); } @@ -1226,513 +491,119 @@ pub(crate) async fn run_doctor( if globals.json { print_json_pretty(&report)?; } else { - let term_width = console::Term::stderr().size().1; - print!( - "{}", - report.render(&styles, verbose, None, Some(term_width)) - ); + render_report(&report, &styles, verbose, printer); } Ok(i32::from(report.has_errors())) } -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - #[cfg(test)] mod tests { use super::*; - // -- check_config -- - #[test] fn check_config_pass_with_path() { - let result = check_config(Some(PathBuf::from("/home/user/.fabro/user.toml")), None); + let result = check_config(Some(PathBuf::from("/home/user/.fabro/settings.toml")), &[]); assert_eq!(result.status, CheckStatus::Pass); - assert!(result.summary.contains(".fabro/user.toml")); + assert!(result.summary.contains(".fabro/settings.toml")); } #[test] fn check_config_warning_without_path() { - let result = check_config(None, None); + let result = check_config(None, &[]); assert_eq!(result.status, CheckStatus::Warning); assert!(result.remediation.is_some()); } #[test] fn check_config_warning_for_legacy_only_path() { - let result = check_config(None, Some(PathBuf::from("/home/user/.fabro/cli.toml"))); + let result = check_config(None, &[PathBuf::from("/home/user/.fabro/cli.toml")]); assert_eq!(result.status, CheckStatus::Warning); assert!(result.summary.contains("legacy")); - assert!( - result - .remediation - .as_deref() - .is_some_and(|remediation| remediation.contains(".fabro/user.toml")) - ); - } - - // -- check_llm_providers -- - - #[test] - fn check_llm_all_configured() { - let statuses: Vec<(Provider, bool)> = Provider::ALL.iter().map(|p| (*p, true)).collect(); - let result = check_llm_providers(&statuses, None); - assert_eq!(result.status, CheckStatus::Pass); - assert!(result.summary.contains("7 configured")); } #[test] - fn check_llm_some_configured() { - let mut statuses: Vec<(Provider, bool)> = - Provider::ALL.iter().map(|p| (*p, false)).collect(); - statuses[0].1 = true; // Anthropic - statuses[1].1 = true; // OpenAi - statuses[2].1 = true; // Gemini - statuses[3].1 = true; // Kimi - statuses[4].1 = true; // Zai - let result = check_llm_providers(&statuses, None); - assert_eq!(result.status, CheckStatus::Pass); - assert!(result.summary.contains("5 configured")); - } - - #[test] - fn check_llm_none_configured() { - let statuses: Vec<(Provider, bool)> = Provider::ALL.iter().map(|p| (*p, false)).collect(); - let result = check_llm_providers(&statuses, None); - assert_eq!(result.status, CheckStatus::Error); - assert!(result.summary.contains("none configured")); - } - - #[test] - fn check_llm_live_ok() { - let statuses = vec![(Provider::Anthropic, true)]; - let live = vec![(Provider::Anthropic, Ok(()))]; - let result = check_llm_providers(&statuses, Some(&live)); - assert_eq!(result.status, CheckStatus::Pass); - assert!( - result - .details - .iter() - .any(|d| d.text.contains("connectivity: OK")) - ); - } - - #[test] - fn check_llm_live_error() { - let statuses = vec![(Provider::Anthropic, true)]; - let live = vec![(Provider::Anthropic, Err("timeout".to_string()))]; - let result = check_llm_providers(&statuses, Some(&live)); + fn check_legacy_env_warning_when_present() { + let result = check_legacy_env(Some(PathBuf::from("/home/user/.fabro/.env"))); assert_eq!(result.status, CheckStatus::Warning); - assert!(result.details.iter().any(|d| d.text.contains("timeout"))); - let rem = result.remediation.unwrap(); - assert!( - rem.contains("anthropic"), - "remediation should name the failing provider: {rem}" - ); - } - - // -- check_brave_search -- - - #[test] - fn check_brave_configured() { - let result = check_brave_search(true, None); - assert_eq!(result.status, CheckStatus::Pass); + assert!(result.summary.contains("legacy secrets file")); } #[test] - fn check_brave_not_configured() { - let result = check_brave_search(false, None); - assert_eq!(result.status, CheckStatus::Warning); - assert!(result.remediation.is_some()); - } - - #[test] - fn check_brave_live_ok() { - let live = Ok(()); - let result = check_brave_search(true, Some(&live)); - assert_eq!(result.status, CheckStatus::Pass); - assert!(result.summary.contains("connected")); - } - - #[test] - fn check_brave_live_error() { - let live = Err("HTTP 401".to_string()); - let result = check_brave_search(true, Some(&live)); - assert_eq!(result.status, CheckStatus::Warning); - assert!(result.details.iter().any(|d| d.text.contains("HTTP 401"))); - } - - // -- check_sandbox -- - - #[test] - fn check_sandbox_daytona_probed_ok() { - let status = SandboxStatus { - daytona_configured: true, - daytona_probe: Some(Ok(())), - }; - let result = check_sandbox(&status); - assert_eq!(result.status, CheckStatus::Pass); - assert!(result.summary.contains("Daytona available")); - } - - #[test] - fn check_sandbox_nothing_configured() { - let status = SandboxStatus { - daytona_configured: false, - daytona_probe: None, - }; - let result = check_sandbox(&status); - assert_eq!(result.status, CheckStatus::Warning); - assert!(result.summary.contains("no sandbox configured")); - } - - #[test] - fn check_sandbox_daytona_configured_not_probed() { - let status = SandboxStatus { - daytona_configured: true, - daytona_probe: None, - }; - let result = check_sandbox(&status); - assert_eq!(result.status, CheckStatus::Pass); - assert!(result.summary.contains("Daytona configured")); - assert!(result.details.iter().any(|d| d.text.contains("configured"))); - } - - #[test] - fn check_sandbox_configured_but_broken() { - let status = SandboxStatus { - daytona_configured: true, - daytona_probe: Some(Err("connection refused".to_string())), - }; - let result = check_sandbox(&status); - assert_eq!(result.status, CheckStatus::Error); - } - - // -- check_github_app -- - - #[test] - fn check_github_sign_error_reports_error() { - let status = GithubAppStatus { - app_id: Some("12345".to_string()), - slug: None, - private_key_set: true, - sign_result: Some(Err( - "Signing failed: signature error: UnexpectedError".to_string() - )), - #[cfg(feature = "server")] - client_id: true, - #[cfg(feature = "server")] - client_secret: true, - #[cfg(feature = "server")] - webhook_secret: true, - }; - let result = check_github_app(&status); - assert_eq!(result.status, CheckStatus::Error); - assert!( - result.summary.contains("invalid"), - "got: {}", - result.summary - ); - let rem = result.remediation.unwrap(); - assert!(rem.contains("Generate a new private key"), "got: {rem}"); - } - - #[test] - fn check_github_not_configured() { - let status = GithubAppStatus { - app_id: None, - slug: None, - private_key_set: false, - sign_result: None, - #[cfg(feature = "server")] - client_id: false, - #[cfg(feature = "server")] - client_secret: false, - #[cfg(feature = "server")] - webhook_secret: false, - }; - let result = check_github_app(&status); + fn check_version_parity_warns_on_mismatch() { + let result = check_version_parity("0.0.0-test"); assert_eq!(result.status, CheckStatus::Warning); } - // -- Server-only checks (check_api, check_web, check_crypto) -- - - #[cfg(feature = "server")] - mod server_tests { - use super::*; - - #[test] - fn check_github_all_set() { - let status = GithubAppStatus { - app_id: Some("12345".to_string()), - slug: Some("my-app".to_string()), - private_key_set: true, - sign_result: Some(Ok(())), - client_id: true, - client_secret: true, - webhook_secret: true, - }; - let result = check_github_app(&status); - assert_eq!(result.status, CheckStatus::Pass); - } - - #[test] - fn check_github_none_set() { - let status = GithubAppStatus { - app_id: None, - slug: None, - private_key_set: false, - sign_result: None, - client_id: false, - client_secret: false, - webhook_secret: false, - }; - let result = check_github_app(&status); - assert_eq!(result.status, CheckStatus::Warning); - } - - #[test] - fn check_github_partial() { - let status = GithubAppStatus { - app_id: Some("12345".to_string()), - slug: None, - private_key_set: false, - sign_result: None, - client_id: true, - client_secret: false, - webhook_secret: false, - }; - let result = check_github_app(&status); - assert_eq!(result.status, CheckStatus::Error); - let rem = result.remediation.unwrap(); - assert!(rem.contains("GITHUB_APP_CLIENT_SECRET")); - assert!(rem.contains("GITHUB_APP_WEBHOOK_SECRET")); - assert!(rem.contains("GITHUB_APP_PRIVATE_KEY")); - } - - // -- check_api -- - - #[test] - fn check_api_shows_base_url() { - let status = ApiStatus { - base_url: "http://localhost:3000".to_string(), - authentication_strategies: vec![ApiAuthStrategy::Jwt], - }; - let result = check_api(&status, None); - assert_eq!(result.status, CheckStatus::Pass); - assert_eq!(result.summary, "http://localhost:3000"); - } - - #[test] - fn check_api_details_show_auth_strategy() { - let status = ApiStatus { - base_url: "https://api.example.com".to_string(), - authentication_strategies: vec![ApiAuthStrategy::Jwt], - }; - let result = check_api(&status, None); - assert!(result.details.iter().any(|d| d.text.contains("jwt"))); - assert!( - result - .details - .iter() - .any(|d| d.text.contains("https://api.example.com")) - ); - } - - #[test] - fn check_api_live_ok() { - let status = ApiStatus { - base_url: "http://localhost:3000".to_string(), - authentication_strategies: vec![ApiAuthStrategy::Jwt], - }; - let live = Ok(()); - let result = check_api(&status, Some(&live)); - assert_eq!(result.status, CheckStatus::Pass); - assert!( - result - .details - .iter() - .any(|d| d.text.contains("Connectivity: OK")) - ); - } - - #[test] - fn check_api_live_error() { - let status = ApiStatus { - base_url: "http://localhost:3000".to_string(), - authentication_strategies: vec![ApiAuthStrategy::Jwt], - }; - let live = Err("connection refused".to_string()); - let result = check_api(&status, Some(&live)); - assert_eq!(result.status, CheckStatus::Warning); - assert!( - result - .details - .iter() - .any(|d| d.text.contains("connection refused")) - ); - } - - // -- check_web -- - - #[test] - fn check_web_shows_url() { - let status = WebStatus { - url: "http://localhost:5173".to_string(), - auth_provider: AuthProvider::Github, - allowed_usernames_count: 0, - }; - let result = check_web(&status, None); - assert_eq!(result.status, CheckStatus::Pass); - assert_eq!(result.summary, "http://localhost:5173"); - } - - #[test] - fn check_web_details_show_auth() { - let status = WebStatus { - url: "https://arc.example.com".to_string(), - auth_provider: AuthProvider::Github, - allowed_usernames_count: 3, - }; - let result = check_web(&status, None); - assert!(result.details.iter().any(|d| d.text.contains("github"))); - assert!( - result - .details - .iter() - .any(|d| d.text.contains("https://arc.example.com")) - ); - assert!( - result - .details - .iter() - .any(|d| d.text.contains("Allowed usernames: 3")) - ); - } - - #[test] - fn check_web_live_ok() { - let status = WebStatus { - url: "http://localhost:5173".to_string(), - auth_provider: AuthProvider::Github, - allowed_usernames_count: 0, - }; - let live = Ok(()); - let result = check_web(&status, Some(&live)); - assert_eq!(result.status, CheckStatus::Pass); - assert!( - result - .details - .iter() - .any(|d| d.text.contains("Connectivity: OK")) - ); - } - - #[test] - fn check_web_live_error() { - let status = WebStatus { - url: "http://localhost:5173".to_string(), - auth_provider: AuthProvider::Github, - allowed_usernames_count: 0, - }; - let live = Err("connection refused".to_string()); - let result = check_web(&status, Some(&live)); - assert_eq!(result.status, CheckStatus::Warning); - assert!( - result - .details - .iter() - .any(|d| d.text.contains("connection refused")) - ); - } - } // mod server_tests (check_github_app, check_api, check_web) - - // -- parse_version (server only) -- - #[test] - #[cfg(feature = "server")] fn parse_version_openssl() { assert_eq!( - parse_version( - &OPENSSL_RE, - "OpenSSL 3.4.1 11 Feb 2025 (Library: OpenSSL 3.4.1 11 Feb 2025)" - ), - Some(Version::new(3, 4, 1)), + parse_version(&OPENSSL_RE, "OpenSSL 3.4.1 11 Feb 2025"), + Some(Version::new(3, 4, 1)) ); } #[test] - #[cfg(feature = "server")] - fn parse_version_libressl() { - assert_eq!( - parse_version(&OPENSSL_RE, "LibreSSL 3.3.6"), - Some(Version::new(3, 3, 6)) - ); - } - - #[test] - #[cfg(feature = "server")] - fn parse_version_node() { - assert_eq!( - parse_version(&NODE_RE, "v22.14.0"), - Some(Version::new(22, 14, 0)) - ); - } - - #[test] - #[cfg(feature = "server")] fn parse_version_dot() { assert_eq!( parse_version(&DOT_RE, "dot - graphviz version 12.2.1 (20241206.2024)"), - Some(Version::new(12, 2, 1)), + Some(Version::new(12, 2, 1)) ); } #[test] - #[cfg(feature = "server")] fn parse_version_garbage_returns_none() { assert_eq!(parse_version(&OPENSSL_RE, "not a version"), None); - assert_eq!(parse_version(&NODE_RE, "node not found"), None); assert_eq!(parse_version(&DOT_RE, "no version here"), None); } - // -- check_system_deps (server only) -- + #[test] + fn render_report_text_without_color_has_no_ansi() { + let report = CheckReport { + title: "Fabro Doctor".to_string(), + sections: vec![CheckSection { + title: "Local".to_string(), + checks: vec![CheckResult { + name: "Configuration".to_string(), + status: CheckStatus::Pass, + summary: "loaded".to_string(), + details: vec![CheckDetail::new( + "Loaded from ~/.fabro/settings.toml".into(), + )], + remediation: None, + }], + }], + }; - #[cfg(feature = "server")] - static TEST_RE: LazyLock = LazyLock::new(|| Regex::new(r"unused").unwrap()); + let rendered = render_report_text(&report, &Styles::new(false), false, Some(80)); + assert!( + !rendered.contains("\x1b["), + "rendered output should be plain text" + ); + assert!(rendered.contains("Fabro Doctor")); + assert!(rendered.contains("[✓] Configuration (loaded)")); + } - #[cfg(feature = "server")] fn spec(name: &'static str, required: bool, min_version: Version) -> DepSpec { DepSpec { name, - command: &["true"], + command: &["echo", "unused"], required, min_version, - pattern: &TEST_RE, + pattern: &DOT_RE, } } #[test] - #[cfg(feature = "server")] fn check_system_deps_all_present() { let specs = [ spec("openssl", true, Version::new(3, 0, 0)), - spec("node", true, Version::new(20, 0, 0)), - spec("gh", false, Version::new(2, 0, 0)), spec("dot", false, Version::new(2, 0, 0)), ]; let outcomes = [ ProbeOutcome::Ok { version: Some(Version::new(3, 4, 1)), }, - ProbeOutcome::Ok { - version: Some(Version::new(22, 14, 0)), - }, - ProbeOutcome::Ok { - version: Some(Version::new(2, 67, 0)), - }, ProbeOutcome::Ok { version: Some(Version::new(12, 2, 1)), }, @@ -1743,27 +614,22 @@ mod tests { } #[test] - #[cfg(feature = "server")] fn check_system_deps_required_missing_is_error() { let specs = [spec("openssl", true, Version::new(3, 0, 0))]; let outcomes = [ProbeOutcome::NotFound]; let result = check_system_deps(&specs, &outcomes); assert_eq!(result.status, CheckStatus::Error); - assert!(result.details[0].text.contains("not found (required)")); } #[test] - #[cfg(feature = "server")] fn check_system_deps_optional_missing_is_warning() { - let specs = [spec("gh", false, Version::new(2, 0, 0))]; + let specs = [spec("dot", false, Version::new(2, 0, 0))]; let outcomes = [ProbeOutcome::NotFound]; let result = check_system_deps(&specs, &outcomes); assert_eq!(result.status, CheckStatus::Warning); - assert!(result.details[0].text.contains("not found (optional)")); } #[test] - #[cfg(feature = "server")] fn check_system_deps_outdated_is_warning() { let specs = [spec("openssl", true, Version::new(3, 0, 0))]; let outcomes = [ProbeOutcome::Ok { @@ -1771,12 +637,9 @@ mod tests { }]; let result = check_system_deps(&specs, &outcomes); assert_eq!(result.status, CheckStatus::Warning); - assert!(result.details[0].text.contains("1.1.1")); - assert!(result.details[0].text.contains("minimum 3.0.0")); } #[test] - #[cfg(feature = "server")] fn check_system_deps_unparseable_success_is_pass() { let specs = [spec("openssl", true, Version::new(3, 0, 0))]; let outcomes = [ProbeOutcome::Ok { version: None }]; @@ -1786,295 +649,29 @@ mod tests { } #[test] - #[cfg(feature = "server")] fn check_system_deps_required_command_failed_is_error() { - let specs = [spec("node", true, Version::new(20, 0, 0))]; + let specs = [spec("openssl", true, Version::new(3, 0, 0))]; let outcomes = [ProbeOutcome::Failed]; let result = check_system_deps(&specs, &outcomes); assert_eq!(result.status, CheckStatus::Error); - assert!(result.details[0].text.contains("command failed (required)")); } #[test] - #[cfg(feature = "server")] fn check_system_deps_optional_command_failed_is_warning() { - let specs = [spec("gh", false, Version::new(2, 0, 0))]; + let specs = [spec("dot", false, Version::new(2, 0, 0))]; let outcomes = [ProbeOutcome::Failed]; let result = check_system_deps(&specs, &outcomes); assert_eq!(result.status, CheckStatus::Warning); - assert!(result.details[0].text.contains("command failed (optional)")); } #[test] - #[cfg(feature = "server")] fn check_system_deps_error_beats_warning() { let specs = [ spec("openssl", true, Version::new(3, 0, 0)), - spec("gh", false, Version::new(2, 0, 0)), + spec("dot", false, Version::new(2, 0, 0)), ]; let outcomes = [ProbeOutcome::NotFound, ProbeOutcome::NotFound]; let result = check_system_deps(&specs, &outcomes); assert_eq!(result.status, CheckStatus::Error); } - - // -- check_crypto -- - - #[cfg(feature = "server")] - mod server_crypto_tests { - use super::*; - - /// Generate a self-signed cert + private key PEM for TLS tests. - fn generate_test_tls_cert() -> (String, String) { - let output = std::process::Command::new("openssl") - .args([ - "req", - "-x509", - "-newkey", - "ec", - "-pkeyopt", - "ec_paramgen_curve:prime256v1", - "-keyout", - "/dev/stdout", - "-out", - "/dev/stdout", - "-days", - "3650", - "-nodes", - "-subj", - "/CN=test-server", - ]) - .output() - .expect("openssl must be available for tests"); - let combined = String::from_utf8(output.stdout).unwrap(); - let key_start = combined.find("-----BEGIN PRIVATE KEY-----").unwrap(); - let key_end = combined.find("-----END PRIVATE KEY-----").unwrap() - + "-----END PRIVATE KEY-----".len(); - let cert_start = combined.find("-----BEGIN CERTIFICATE-----").unwrap(); - let cert_end = combined.find("-----END CERTIFICATE-----").unwrap() - + "-----END CERTIFICATE-----".len(); - let key_pem = combined[key_start..key_end].to_string(); - let cert_pem = combined[cert_start..cert_end].to_string(); - (cert_pem, key_pem) - } - - fn generate_test_ed25519_keypair() -> (String, String) { - let output = std::process::Command::new("openssl") - .args(["genpkey", "-algorithm", "Ed25519"]) - .output() - .expect("openssl must be available for tests"); - let private_pem = String::from_utf8(output.stdout).unwrap(); - let output = std::process::Command::new("openssl") - .args(["pkey", "-pubout"]) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .spawn() - .and_then(|mut child| { - use std::io::Write; - child - .stdin - .take() - .unwrap() - .write_all(private_pem.as_bytes()) - .unwrap(); - child.wait_with_output() - }) - .expect("openssl pkey failed"); - let public_pem = String::from_utf8(output.stdout).unwrap(); - (public_pem, private_pem) - } - - fn crypto_input(auth_strategies: Vec) -> CryptoInput { - CryptoInput { - auth_strategies, - tls_files: None, - jwt_public_key: None, - jwt_private_key: None, - session_secret: None, - now_epoch: chrono::Utc::now().timestamp(), - } - } - - #[test] - fn crypto_all_keys_valid() { - let (cert_pem, key_pem) = generate_test_tls_cert(); - let (public_pem, private_pem) = generate_test_ed25519_keypair(); - let input = CryptoInput { - tls_files: Some(Ok(TlsCheckInput { - cert_pem: cert_pem.clone(), - key_pem, - ca_pem: cert_pem, - })), - jwt_public_key: Some(public_pem), - jwt_private_key: Some(private_pem), - session_secret: Some("a".repeat(64)), - ..crypto_input(vec![ApiAuthStrategy::Jwt, ApiAuthStrategy::Mtls]) - }; - let result = check_crypto(&input); - assert_eq!(result.status, CheckStatus::Pass); - assert_eq!(result.summary, "all keys valid"); - } - - #[test] - fn crypto_invalid_cert_pem() { - let (public_pem, private_pem) = generate_test_ed25519_keypair(); - let input = CryptoInput { - tls_files: Some(Ok(TlsCheckInput { - cert_pem: "not a pem".to_string(), - key_pem: "not a pem".to_string(), - ca_pem: "not a pem".to_string(), - })), - jwt_public_key: Some(public_pem), - jwt_private_key: Some(private_pem), - ..crypto_input(vec![ApiAuthStrategy::Jwt, ApiAuthStrategy::Mtls]) - }; - let result = check_crypto(&input); - assert_eq!(result.status, CheckStatus::Error); - assert!(result.details.iter().any(|d| d.text.contains("TLS cert"))); - } - - #[test] - fn crypto_expired_cert() { - let (cert_pem, _) = generate_test_tls_cert(); - let far_future = i64::MAX / 2; - let result = validate_tls_cert(&cert_pem, far_future); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("expired")); - } - - #[test] - fn crypto_session_secret_too_short() { - let input = CryptoInput { - session_secret: Some("abcdef".to_string()), - ..crypto_input(vec![]) - }; - let result = check_crypto(&input); - assert_eq!(result.status, CheckStatus::Error); - assert!(result.details.iter().any(|d| d.text.contains("too short"))); - } - - #[test] - fn crypto_session_secret_non_hex() { - let input = CryptoInput { - session_secret: Some("z".repeat(64)), - ..crypto_input(vec![]) - }; - let result = check_crypto(&input); - assert_eq!(result.status, CheckStatus::Error); - assert!(result.details.iter().any(|d| d.text.contains("non-hex"))); - } - - #[test] - fn crypto_no_auth_configured() { - let result = check_crypto(&crypto_input(vec![])); - assert_eq!(result.status, CheckStatus::Warning); - assert!(result.summary.contains("no authentication configured")); - } - - #[test] - fn crypto_jwt_configured_but_key_missing() { - let result = check_crypto(&crypto_input(vec![ApiAuthStrategy::Jwt])); - assert_eq!(result.status, CheckStatus::Error); - assert!( - result - .details - .iter() - .any(|d| d.text.contains("FABRO_JWT_PUBLIC_KEY not set")) - ); - } - - #[test] - fn crypto_mtls_configured_but_tls_not_set() { - let result = check_crypto(&crypto_input(vec![ApiAuthStrategy::Mtls])); - assert_eq!(result.status, CheckStatus::Error); - assert!( - result - .details - .iter() - .any(|d| d.text.contains("[api.tls] not set")) - ); - } - - #[test] - fn crypto_mtls_configured_but_files_unreadable() { - let input = CryptoInput { - tls_files: Some(Err("Permission denied: /path/to/cert.pem".to_string())), - ..crypto_input(vec![ApiAuthStrategy::Mtls]) - }; - let result = check_crypto(&input); - assert_eq!(result.status, CheckStatus::Error); - assert!( - result - .details - .iter() - .any(|d| d.text.contains("Permission denied")) - ); - } - - #[test] - fn crypto_invalid_jwt_public_key() { - let input = CryptoInput { - jwt_public_key: Some( - "-----BEGIN PUBLIC KEY-----\nINVALID\n-----END PUBLIC KEY-----".to_string(), - ), - ..crypto_input(vec![ApiAuthStrategy::Jwt]) - }; - let result = check_crypto(&input); - assert_eq!(result.status, CheckStatus::Error); - assert!( - result - .details - .iter() - .any(|d| d.text.contains("JWT public key: invalid")) - ); - } - - #[test] - fn crypto_invalid_jwt_private_key() { - let input = CryptoInput { - jwt_private_key: Some( - "-----BEGIN PRIVATE KEY-----\nINVALID\n-----END PRIVATE KEY-----".to_string(), - ), - ..crypto_input(vec![]) - }; - let result = check_crypto(&input); - assert_eq!(result.status, CheckStatus::Error); - assert!( - result - .details - .iter() - .any(|d| d.text.contains("JWT private key: invalid")) - ); - } - - #[test] - fn crypto_base64_encoded_jwt_key() { - let (public_pem, _) = generate_test_ed25519_keypair(); - let encoded = base64::Engine::encode( - &base64::engine::general_purpose::STANDARD, - public_pem.as_bytes(), - ); - let input = CryptoInput { - jwt_public_key: Some(encoded), - ..crypto_input(vec![ApiAuthStrategy::Jwt]) - }; - let result = check_crypto(&input); - assert_eq!(result.status, CheckStatus::Pass); - assert!( - result - .details - .iter() - .any(|d| d.text.contains("JWT public key: valid")) - ); - } - - #[test] - fn crypto_valid_session_secret() { - let input = CryptoInput { - session_secret: Some("a1b2c3d4e5f6".repeat(6)), - ..crypto_input(vec![]) - }; - let result = check_crypto(&input); - assert_eq!(result.status, CheckStatus::Pass); - } - } // mod server_crypto_tests } diff --git a/lib/crates/fabro-cli/src/commands/exec.rs b/lib/crates/fabro-cli/src/commands/exec.rs index cd962bc21..cf87fedab 100644 --- a/lib/crates/fabro-cli/src/commands/exec.rs +++ b/lib/crates/fabro-cli/src/commands/exec.rs @@ -1,72 +1,224 @@ -use anyhow::Result; -#[cfg(feature = "server")] -use fabro_agent::cli::run_with_args_and_client; -use fabro_agent::cli::{AgentArgs, OutputFormat, run_with_args}; -use fabro_config::mcp::McpServerEntry; -use fabro_mcp::config::McpServerConfig; +use std::collections::HashMap; +use std::sync::Arc; -use crate::args::GlobalArgs; +use anyhow::Result; +use fabro_agent::cli::{OutputFormat, run_with_args, run_with_args_and_client}; +use fabro_llm::client::Client; +use fabro_llm::providers::FabroServerAdapter; +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_util::printer::Printer; + +use crate::args::{ExecArgs, GlobalArgs}; use crate::user_config; -pub(crate) async fn execute(mut args: AgentArgs, globals: &GlobalArgs) -> Result<()> { - let cli_settings = user_config::load_user_settings_with_globals(globals)?; - #[cfg(feature = "sleep_inhibitor")] - let _sleep_guard = crate::sleep_inhibitor::guard(cli_settings.prevent_idle_sleep_enabled()); - let exec_defaults = cli_settings.exec.as_ref(); - args.apply_cli_defaults( - exec_defaults.and_then(|a| a.provider.as_deref()), - exec_defaults.and_then(|a| a.model.as_deref()), - exec_defaults.and_then(|a| a.permissions), - exec_defaults.and_then(|a| a.output_format), - ); - if globals.json { - args.output_format = Some(OutputFormat::Json); - } - #[cfg(feature = "server")] - let resolved = user_config::resolve_mode( - globals.storage_dir.as_deref(), - globals.server_url.as_deref(), - &cli_settings, - ); - let mcp_servers: Vec = cli_settings - .mcp_servers - .into_iter() - .map(|(name, entry): (String, McpServerEntry)| entry.into_config(name)) - .collect(); - #[cfg(feature = "server")] - { - match resolved.mode { - user_config::ExecutionMode::Server => { - tracing::info!(mode = "server", "Agent session starting"); - let http_client = user_config::build_server_client(resolved.tls.as_ref())?; - let provider_name = args - .provider - .clone() - .unwrap_or_else(|| "anthropic".to_string()); - let adapter = std::sync::Arc::new(fabro_llm::providers::FabroServerAdapter::new( - http_client, - &resolved.server_base_url, - &provider_name, - )); - let mut client = - fabro_llm::client::Client::new(std::collections::HashMap::new(), None, vec![]); - client - .register_provider(adapter) - .await - .map_err(|e| anyhow::anyhow!("Failed to register fabro server adapter: {e}"))?; - run_with_args_and_client(args, Some(client), mcp_servers).await? - } - user_config::ExecutionMode::Standalone => { - tracing::info!(mode = "standalone", "Agent session starting"); - run_with_args(args, mcp_servers).await? +fn runtime_mcp_server(name: &str, entry: &McpEntryLayer) -> McpServerSettings { + let transport = match entry { + McpEntryLayer::Stdio { + script, + command, + env, + .. + } => { + let command = if let Some(script) = script { + vec!["sh".to_string(), "-c".to_string(), script.as_source()] + } else { + command + .as_ref() + .map(|command| command.iter().map(InterpString::as_source).collect()) + .unwrap_or_default() + }; + McpTransport::Stdio { + command, + env: env + .iter() + .map(|(key, value)| (key.clone(), value.as_source())) + .collect(), } } + McpEntryLayer::Http { url, headers, .. } => McpTransport::Http { + url: url.as_source(), + headers: headers + .iter() + .map(|(key, value)| (key.clone(), value.as_source())) + .collect(), + }, + McpEntryLayer::Sandbox { + script, + command, + port, + env, + .. + } => { + let command = if let Some(script) = script { + vec!["sh".to_string(), "-c".to_string(), script.as_source()] + } else { + command + .as_ref() + .map(|command| command.iter().map(InterpString::as_source).collect()) + .unwrap_or_default() + }; + McpTransport::Sandbox { + command, + port: *port, + env: env + .iter() + .map(|(key, value)| (key.clone(), value.as_source())) + .collect(), + } + } + }; + let (startup_timeout_secs, tool_timeout_secs) = match entry { + McpEntryLayer::Http { + startup_timeout, + tool_timeout, + .. + } + | McpEntryLayer::Stdio { + startup_timeout, + tool_timeout, + .. + } + | McpEntryLayer::Sandbox { + startup_timeout, + tool_timeout, + .. + } => ( + startup_timeout.map_or(10, |duration| duration.as_std().as_secs()), + tool_timeout.map_or(60, |duration| duration.as_std().as_secs()), + ), + }; + McpServerSettings { + name: name.to_string(), + transport, + startup_timeout_secs, + tool_timeout_secs, } - #[cfg(not(feature = "server"))] +} + +pub(crate) async fn execute( + mut args: ExecArgs, + globals: &GlobalArgs, + _printer: Printer, +) -> Result<()> { + use fabro_agent::cli::PermissionLevel as AgentPermissionLevel; + use fabro_types::settings::run::AgentPermissions; + + let cli_settings = user_config::load_settings()?; + let resolved_cli = user_config::resolve_cli_settings(&cli_settings)?; + #[cfg(feature = "sleep_inhibitor")] + let _sleep_guard = crate::sleep_inhibitor::guard(resolved_cli.exec.prevent_idle_sleep); + let provider_str = resolved_cli + .exec + .model + .provider + .as_ref() + .map(InterpString::as_source); + let model_str = resolved_cli + .exec + .model + .name + .as_ref() + .map(InterpString::as_source); + let permissions = resolved_cli.exec.agent.permissions.map(|p| match p { + AgentPermissions::ReadOnly => AgentPermissionLevel::ReadOnly, + AgentPermissions::ReadWrite => AgentPermissionLevel::ReadWrite, + AgentPermissions::Full => AgentPermissionLevel::Full, + }); + let output_format = Some(match resolved_cli.output.format { + SettingsOutputFormat::Text => OutputFormat::Text, + SettingsOutputFormat::Json => OutputFormat::Json, + }); + args.agent.apply_cli_defaults( + provider_str.as_deref(), + model_str.as_deref(), + permissions, + output_format, + ); + if globals.json { + args.agent.output_format = Some(OutputFormat::Json); + } + let server_target = user_config::exec_server_target(&args.server, &cli_settings)?; + // v2 MCPs live under `cli.exec.agent.mcps` (owner-specific) or + // `run.agent.mcps`. For `fabro exec` we use the cli.exec path, falling + // back to run.agent.mcps if unset. + let mcp_servers: Vec = if !resolved_cli.exec.agent.mcps.is_empty() { + resolved_cli + .exec + .agent + .mcps + .values() + .map(|server| McpServerSettings { + name: server.name.clone(), + transport: server.transport.clone(), + startup_timeout_secs: server.startup_timeout_secs, + tool_timeout_secs: server.tool_timeout_secs, + }) + .collect() + } else if let Some(mcps) = cli_settings + .cli + .as_ref() + .and_then(|cli| cli.exec.as_ref()) + .and_then(|exec| exec.agent.as_ref()) + .map(|agent| &agent.mcps) + .filter(|mcps| !mcps.is_empty()) { - let _ = globals; - tracing::info!(mode = "standalone", "Agent session starting"); - run_with_args(args, mcp_servers).await?; + mcps.iter() + .map(|(name, entry)| runtime_mcp_server(name, entry)) + .collect() + } else { + fabro_config::resolve_run_from_file(&cli_settings) + .map(|settings| { + settings + .agent + .mcps + .values() + .map(|server| McpServerSettings { + name: server.name.clone(), + transport: server.transport.clone(), + startup_timeout_secs: server.startup_timeout_secs, + tool_timeout_secs: server.tool_timeout_secs, + }) + .collect() + }) + .unwrap_or_default() + }; + if let Some(target) = server_target { + tracing::info!(transport = "server", "Agent session starting"); + let provider_name = args + .agent + .provider + .clone() + .unwrap_or_else(|| "anthropic".to_string()); + let (api_url, http_client) = match &target { + user_config::ServerTarget::HttpUrl { api_url, tls } => ( + api_url.clone(), + user_config::build_server_client(tls.as_ref())?, + ), + user_config::ServerTarget::UnixSocket(path) => { + let http_client = fabro_http::HttpClientBuilder::new() + .unix_socket(path.as_path()) + .no_proxy() + .build()?; + ("http://fabro".to_string(), http_client) + } + }; + let adapter = Arc::new(FabroServerAdapter::new( + http_client, + &api_url, + &provider_name, + )); + let mut client = Client::new(HashMap::new(), None, vec![]); + client + .register_provider(adapter) + .await + .map_err(|e| anyhow::anyhow!("Failed to register fabro server adapter: {e}"))?; + run_with_args_and_client(args.agent, Some(client), mcp_servers).await?; + } else { + tracing::info!(transport = "direct", "Agent session starting"); + run_with_args(args.agent, mcp_servers).await?; } Ok(()) diff --git a/lib/crates/fabro-cli/src/commands/graph.rs b/lib/crates/fabro-cli/src/commands/graph.rs index e0fedb9a5..d3b42b19c 100644 --- a/lib/crates/fabro-cli/src/commands/graph.rs +++ b/lib/crates/fabro-cli/src/commands/graph.rs @@ -1,51 +1,65 @@ -use std::borrow::Cow; use std::io::Write; -use std::sync::LazyLock; use anyhow::bail; -use fabro_config::ConfigLayer; -use fabro_config::project::resolve_workflow_path; -use fabro_graphviz::render::render_dot; +use fabro_api::types; +use fabro_config::load::load_settings_user; +use fabro_config::user::active_settings_path; +use fabro_types::settings::SettingsLayer; +use fabro_util::printer::Printer; use fabro_util::terminal::Styles; -use fabro_validate::Severity; -use fabro_workflow::operations::{ValidateInput, WorkflowInput, validate}; use tracing::debug; -use crate::args::{GlobalArgs, GraphArgs, GraphDirection}; -use crate::shared::{ - absolute_or_current, print_diagnostics, print_json_pretty, read_workflow_file, relative_path, -}; +use crate::args::{GlobalArgs, GraphArgs, GraphDirection, GraphOutputFormat}; +use crate::command_context::CommandContext; +use crate::commands::run::output::api_diagnostics_to_local; +use crate::manifest_builder::{ManifestBuildInput, build_run_manifest}; +use crate::shared::{absolute_or_current, print_diagnostics, print_json_pretty, relative_path}; -static RANKDIR_RE: LazyLock = - LazyLock::new(|| regex::Regex::new(r"rankdir\s*=\s*\w+").unwrap()); - -pub(crate) fn run(args: &GraphArgs, styles: &Styles, globals: &GlobalArgs) -> anyhow::Result<()> { +pub(crate) async fn run( + args: &GraphArgs, + styles: &Styles, + globals: &GlobalArgs, + printer: Printer, +) -> anyhow::Result<()> { if globals.json && args.output.is_none() { globals.require_no_json()?; } - let cwd = std::env::current_dir()?; - let settings = ConfigLayer::for_workflow(&args.workflow, &cwd)? - .combine(ConfigLayer::user()?) - .resolve()?; - let resolution = resolve_workflow_path(&args.workflow, &cwd)?; - let validated = validate(ValidateInput { - workflow: WorkflowInput::Path(args.workflow.clone()), - settings, - cwd, - custom_transforms: Vec::new(), + let ctx = CommandContext::for_target(&args.target, printer)?; + let built = build_run_manifest(ManifestBuildInput { + workflow: args.workflow.clone(), + cwd: ctx.cwd().to_path_buf(), + args_layer: SettingsLayer::default(), + args: None, + run_id: None, + user_layer: load_settings_user()?, + user_settings_path: Some(active_settings_path(None)), })?; - let diagnostics = validated.diagnostics(); + let client = ctx.server().await?; + let preflight = client.run_preflight(built.manifest.clone()).await?; + let diagnostics = api_diagnostics_to_local(&preflight.workflow.diagnostics); - print_diagnostics(diagnostics, styles); - - if diagnostics.iter().any(|d| d.severity == Severity::Error) { + print_diagnostics(&diagnostics, styles, printer); + if diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == fabro_validate::Severity::Error) + { bail!("Validation failed"); } - let source = read_workflow_file(&resolution.dot_path)?; - let source = apply_direction(&source, args.direction); - let rendered = render_dot(&source, args.format.into())?; + let rendered = client + .render_workflow_graph(types::RenderWorkflowGraphRequest { + manifest: built.manifest, + format: Some(match args.format { + GraphOutputFormat::Svg => types::RenderWorkflowGraphFormat::Svg, + GraphOutputFormat::Png => types::RenderWorkflowGraphFormat::Png, + }), + direction: args.direction.map(|direction| match direction { + GraphDirection::Lr => types::RenderWorkflowGraphDirection::Lr, + GraphDirection::Tb => types::RenderWorkflowGraphDirection::Tb, + }), + }) + .await?; if let Some(ref output_path) = args.output { std::fs::write(output_path, &rendered)?; @@ -60,20 +74,10 @@ pub(crate) fn run(args: &GraphArgs, styles: &Styles, globals: &GlobalArgs) -> an } debug!( - path = %relative_path(&resolution.dot_path), + path = %relative_path(&built.target_path), format = %args.format, "Rendered workflow graph" ); Ok(()) } - -fn apply_direction(source: &str, direction: Option) -> Cow<'_, str> { - match direction { - Some(dir) => { - let replacement = format!("rankdir={dir}"); - RANKDIR_RE.replace(source, replacement.as_str()) - } - None => Cow::Borrowed(source), - } -} diff --git a/lib/crates/fabro-cli/src/commands/install.rs b/lib/crates/fabro-cli/src/commands/install.rs index b727ea3ac..3b152dabc 100644 --- a/lib/crates/fabro-cli/src/commands/install.rs +++ b/lib/crates/fabro-cli/src/commands/install.rs @@ -1,10 +1,8 @@ -#[cfg(feature = "server")] -use std::io::Write as _; use std::net::SocketAddr; use std::path::Path; -use std::process::{Command, Stdio}; +use std::process::Stdio; -use anyhow::{Context, Result, bail}; +use anyhow::{Context, Result, anyhow, bail}; use axum::extract::Query; use axum::response::Html; use axum::routing::get; @@ -13,31 +11,40 @@ use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use dialoguer::console::Term; use dialoguer::theme::ColorfulTheme; use dialoguer::{MultiSelect, Select}; -use fabro_config::user::USER_CONFIG_FILENAME; +use fabro_api::types::{CreateSecretRequest, SecretType as ApiSecretType}; +use fabro_config::user::SETTINGS_CONFIG_FILENAME; +use fabro_config::{Storage, envfile, legacy_env}; use fabro_model::Provider; +use fabro_util::printer::Printer; use fabro_util::terminal::Styles; +// Bootstrap-only direct vault writes for `fabro install` when no local server is running. +use fabro_vault::{SecretType, Vault}; use rand::Rng; +use tokio::io::AsyncWriteExt; use tokio::net::TcpListener; +use tokio::process::Command as TokioCommand; use tokio::sync::oneshot; use tokio::task::spawn_blocking; use super::doctor; -use crate::args::GlobalArgs; +use crate::args::{DoctorArgs, GlobalArgs, InstallArgs, ServerTargetArgs}; +use crate::commands::server::record; +use crate::gh::GhCli; use crate::shared::provider_auth::{ prompt_and_validate_key, prompt_confirm, provider_display_name, run_openai_oauth_or_api_key, - write_env_file, }; +use crate::{server_client, user_config}; // --------------------------------------------------------------------------- -// OpenSSL helpers (server mode only) +// OpenSSL helpers // --------------------------------------------------------------------------- /// Run an openssl subcommand and return stdout on success. -#[cfg(feature = "server")] -fn run_openssl(args: &[&str], description: &str) -> Result> { - let output = Command::new("openssl") +async fn run_openssl(args: &[&str], description: &str) -> Result> { + let output = TokioCommand::new("openssl") .args(args) .output() + .await .with_context(|| format!("failed to run openssl for: {description}"))?; if !output.status.success() { bail!( @@ -49,23 +56,30 @@ fn run_openssl(args: &[&str], description: &str) -> Result> { } /// Run an openssl subcommand that reads key material from stdin. -#[cfg(feature = "server")] -fn run_openssl_with_stdin(args: &[&str], stdin_data: &[u8], description: &str) -> Result> { - let mut child = Command::new("openssl") +async fn run_openssl_with_stdin( + args: &[&str], + stdin_data: &[u8], + description: &str, +) -> Result> { + let mut child = TokioCommand::new("openssl") .args(args) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .with_context(|| format!("failed to spawn openssl for: {description}"))?; - child + let mut stdin = child .stdin .take() - .context("openssl process missing stdin")? + .context("openssl process missing stdin")?; + stdin .write_all(stdin_data) + .await .with_context(|| format!("failed to write to openssl stdin for: {description}"))?; + drop(stdin); let output = child .wait_with_output() + .await .with_context(|| format!("failed to read openssl output for: {description}"))?; if !output.status.success() { bail!( @@ -77,10 +91,9 @@ fn run_openssl_with_stdin(args: &[&str], stdin_data: &[u8], description: &str) - } // --------------------------------------------------------------------------- -// Session secret (server mode only) +// Session secret // --------------------------------------------------------------------------- -#[cfg(feature = "server")] fn generate_session_secret() -> String { let mut rng = rand::thread_rng(); let bytes: [u8; 32] = rng.gen(); @@ -88,14 +101,14 @@ fn generate_session_secret() -> String { } // --------------------------------------------------------------------------- -// JWT keypair generation (server mode only) +// JWT keypair generation // --------------------------------------------------------------------------- -#[cfg(feature = "server")] -fn generate_jwt_keypair() -> Result<(String, String)> { - let private_pem = run_openssl(&["genpkey", "-algorithm", "Ed25519"], "generate keypair")?; +async fn generate_jwt_keypair() -> Result<(String, String)> { + let private_pem = + run_openssl(&["genpkey", "-algorithm", "Ed25519"], "generate keypair").await?; let public_pem = - run_openssl_with_stdin(&["pkey", "-pubout"], &private_pem, "extract public key")?; + run_openssl_with_stdin(&["pkey", "-pubout"], &private_pem, "extract public key").await?; let private_str = String::from_utf8(private_pem).context("private key is not valid UTF-8")?; let public_str = String::from_utf8(public_pem).context("public key is not valid UTF-8")?; @@ -103,15 +116,14 @@ fn generate_jwt_keypair() -> Result<(String, String)> { } // --------------------------------------------------------------------------- -// mTLS certificate generation (server mode only) +// mTLS certificate generation // --------------------------------------------------------------------------- -#[cfg(feature = "server")] -fn generate_mtls_certs(dir: &Path) -> Result<()> { +async fn generate_mtls_certs(dir: &Path) -> Result<()> { std::fs::create_dir_all(dir).context("failed to create certs directory")?; // 1. CA key + self-signed cert - let ca_key = run_openssl(&["genpkey", "-algorithm", "Ed25519"], "generate CA key")?; + let ca_key = run_openssl(&["genpkey", "-algorithm", "Ed25519"], "generate CA key").await?; let ca_key_path = dir.join("ca.key"); std::fs::write(&ca_key_path, &ca_key)?; @@ -130,27 +142,31 @@ fn generate_mtls_certs(dir: &Path) -> Result<()> { "/CN=Fabro CA", ], "generate CA cert", - )?; + ) + .await?; let ca_cert_path = dir.join("ca.crt"); std::fs::write(&ca_cert_path, &ca_cert)?; // 2. Server key + CSR signed by CA - let server_key = run_openssl(&["genpkey", "-algorithm", "Ed25519"], "generate server key")?; + let server_key = + run_openssl(&["genpkey", "-algorithm", "Ed25519"], "generate server key").await?; let server_key_path = dir.join("server.key"); std::fs::write(&server_key_path, &server_key)?; - let csr = run_openssl_with_stdin( + let csr = run_openssl( &[ "req", "-new", "-key", - "/dev/stdin", + server_key_path + .to_str() + .context("server key path is not valid UTF-8")?, "-subj", "/CN=localhost", ], - &server_key, "generate server CSR", - )?; + ) + .await?; let csr_path = dir.join("server.csr"); std::fs::write(&csr_path, &csr)?; @@ -174,7 +190,8 @@ fn generate_mtls_certs(dir: &Path) -> Result<()> { "3650", ], "sign server cert", - )?; + ) + .await?; std::fs::write(dir.join("server.crt"), &server_cert)?; // Clean up temporary files @@ -185,29 +202,129 @@ fn generate_mtls_certs(dir: &Path) -> Result<()> { } // --------------------------------------------------------------------------- -// Config TOML generation (server mode only) +// Config TOML generation // --------------------------------------------------------------------------- -#[cfg(feature = "server")] +fn root_table_mut(doc: &mut toml::Value) -> Result<&mut toml::Table> { + doc.as_table_mut() + .context("settings.toml root is not a table") +} + +fn ensure_table<'a>(table: &'a mut toml::Table, key: &str) -> Result<&'a mut toml::Table> { + table + .entry(key.to_string()) + .or_insert_with(|| toml::Value::Table(toml::Table::default())) + .as_table_mut() + .with_context(|| format!("settings.toml [{key}] is not a table")) +} + +fn merge_server_settings(doc: &mut toml::Value, username: &str) -> Result<()> { + let root = root_table_mut(doc)?; + root.insert("_version".to_string(), toml::Value::Integer(1)); + + let server = ensure_table(root, "server")?; + + let api = ensure_table(server, "api")?; + api.insert( + "url".to_string(), + toml::Value::String("https://localhost:3000/api/v1".to_string()), + ); + + let listen = ensure_table(server, "listen")?; + listen.insert("type".to_string(), toml::Value::String("tcp".to_string())); + let listen_tls = ensure_table(listen, "tls")?; + let certs_dir = fabro_util::Home::from_env().certs_dir(); + listen_tls.insert( + "cert".to_string(), + toml::Value::String(certs_dir.join("server.crt").to_string_lossy().to_string()), + ); + listen_tls.insert( + "key".to_string(), + toml::Value::String(certs_dir.join("server.key").to_string_lossy().to_string()), + ); + listen_tls.insert( + "ca".to_string(), + toml::Value::String(certs_dir.join("ca.crt").to_string_lossy().to_string()), + ); + + let web = ensure_table(server, "web")?; + web.insert("enabled".to_string(), toml::Value::Boolean(true)); + web.insert( + "url".to_string(), + toml::Value::String("http://localhost:3000".to_string()), + ); + + let auth = ensure_table(server, "auth")?; + let auth_api = ensure_table(auth, "api")?; + let jwt = ensure_table(auth_api, "jwt")?; + jwt.insert("enabled".to_string(), toml::Value::Boolean(true)); + let mtls = ensure_table(auth_api, "mtls")?; + mtls.insert("enabled".to_string(), toml::Value::Boolean(true)); + + let auth_web = ensure_table(auth, "web")?; + auth_web.insert( + "allowed_usernames".to_string(), + toml::Value::Array(vec![toml::Value::String(username.to_string())]), + ); + + Ok(()) +} + +fn github_integration_table(doc: &mut toml::Value) -> Result<&mut toml::Table> { + let root = doc + .as_table_mut() + .context("settings.toml root is not a table")?; + let server = root + .entry("server") + .or_insert(toml::Value::Table(toml::Table::default())); + let server_table = server + .as_table_mut() + .context("settings.toml [server] is not a table")?; + let integrations = server_table + .entry("integrations") + .or_insert(toml::Value::Table(toml::Table::default())); + let integrations_table = integrations + .as_table_mut() + .context("settings.toml [server.integrations] is not a table")?; + let github = integrations_table + .entry("github") + .or_insert(toml::Value::Table(toml::Table::default())); + github + .as_table_mut() + .context("settings.toml [server.integrations.github] is not a table") +} + +fn write_github_cli_settings(doc: &mut toml::Value) -> Result<()> { + let github = github_integration_table(doc)?; + github.insert("strategy".into(), toml::Value::String("gh_cli".to_string())); + github.remove("app_id"); + github.remove("slug"); + github.remove("client_id"); + Ok(()) +} + +fn write_github_app_settings( + doc: &mut toml::Value, + app_id: &str, + slug: &str, + client_id: &str, +) -> Result<()> { + let github = github_integration_table(doc)?; + github.insert("strategy".into(), toml::Value::String("app".to_string())); + github.insert("app_id".into(), toml::Value::String(app_id.to_string())); + github.insert("slug".into(), toml::Value::String(slug.to_string())); + github.insert( + "client_id".into(), + toml::Value::String(client_id.to_string()), + ); + Ok(()) +} + +#[cfg(test)] fn format_config_toml(username: &str) -> String { - format!( - r#"[web] -url = "http://localhost:5173" - -[web.auth] -provider = "github" -allowed_usernames = ["{username}"] - -[api] -base_url = "https://localhost:3000/api/v1" -authentication_strategies = ["jwt", "mtls"] - -[api.tls] -cert = "~/.fabro/certs/server.crt" -key = "~/.fabro/certs/server.key" -ca = "~/.fabro/certs/ca.crt" -"# - ) + let mut doc = toml::Value::Table(toml::Table::default()); + merge_server_settings(&mut doc, username).expect("default server config should be valid"); + toml::to_string_pretty(&doc).expect("default server config should serialize") } // --------------------------------------------------------------------------- @@ -215,12 +332,13 @@ ca = "~/.fabro/certs/ca.crt" // --------------------------------------------------------------------------- /// Check if a binary exists on PATH using the doctor.rs pattern. -fn detect_binary_on_path(binary: &str) -> bool { - Command::new(binary) +async fn detect_binary_on_path(binary: &str) -> bool { + TokioCommand::new(binary) .arg("--version") .stdout(Stdio::null()) .stderr(Stdio::null()) .status() + .await .map(|s| s.success()) .unwrap_or(false) } @@ -229,7 +347,6 @@ fn detect_binary_on_path(binary: &str) -> bool { // Interactive setup // --------------------------------------------------------------------------- -#[cfg(feature = "server")] fn prompt_input(prompt: &str) -> Result { Ok(dialoguer::Input::with_theme(&ColorfulTheme::default()) .with_prompt(prompt) @@ -250,6 +367,100 @@ fn prompt_multiselect(prompt: &str, items: &[String]) -> Result> { .interact_on(&Term::stderr())?) } +// --------------------------------------------------------------------------- +// GitHub App owner selection +// --------------------------------------------------------------------------- + +enum GitHubAppOwner { + Personal, + Organization(String), +} + +impl GitHubAppOwner { + fn manifest_form_action(&self) -> String { + match self { + Self::Personal => "https://github.com/settings/apps/new".to_string(), + Self::Organization(org) => { + format!("https://github.com/organizations/{org}/settings/apps/new") + } + } + } + + fn app_name(&self, username: Option<&str>) -> String { + match self { + Self::Organization(org) => format!("{org}-fabro"), + Self::Personal => { + if let Some(user) = username { + format!("{user}-fabro") + } else { + let mut rng = rand::thread_rng(); + let suffix: String = (0..6).fold(String::with_capacity(6), |mut s, _| { + use std::fmt::Write; + let _ = write!(s, "{:x}", rng.gen::() % 16); + s + }); + format!("Fabro-{suffix}") + } + } + } + } +} + +/// Ask the user where to create the GitHub App. +/// +/// Uses the `gh` CLI to discover the username and admin orgs. If `gh` is +/// unavailable or the user has no admin orgs, falls back gracefully. +/// Always offers a manual "Other" option so org app managers can enter a slug. +/// +/// Returns `(owner, username)`. +async fn prompt_github_app_owner(_s: &Styles) -> Result<(GitHubAppOwner, Option)> { + let spinner = indicatif::ProgressBar::new_spinner(); + spinner.set_style( + indicatif::ProgressStyle::with_template("{spinner:.cyan} {msg}") + .expect("valid template") + .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", ""]), + ); + spinner.set_message("Checking GitHub CLI..."); + spinner.enable_steady_tick(std::time::Duration::from_millis(80)); + + let Some(gh) = GhCli::detect().await else { + spinner.finish_and_clear(); + return Ok((GitHubAppOwner::Personal, None)); + }; + + let (username, orgs) = tokio::join!(gh.authenticated_user(), gh.list_admin_orgs()); + spinner.finish_and_clear(); + + // Build the selection menu + let personal_label = match &username { + Some(user) => format!("Personal account ({user})"), + None => "Personal account".to_string(), + }; + let mut items = vec![personal_label]; + for org in &orgs { + items.push(format!("Organization: {org}")); + } + items.push("Other (enter organization name)".to_string()); + + let selected: usize = spawn_blocking({ + let items = items.clone(); + move || prompt_select("Where should the GitHub App be created?", &items) + }) + .await??; + + let other_index = 1 + orgs.len(); + let owner = if selected == 0 { + GitHubAppOwner::Personal + } else if selected == other_index { + let org_slug: String = spawn_blocking(|| prompt_input("Organization name")).await??; + GitHubAppOwner::Organization(org_slug) + } else { + GitHubAppOwner::Organization(orgs[selected - 1].clone()) + }; + + Ok((owner, username)) +} + // --------------------------------------------------------------------------- // GitHub App manifest flow // --------------------------------------------------------------------------- @@ -280,20 +491,16 @@ fn build_github_app_manifest(app_name: &str, port: u16, web_url: &str) -> serde_ } /// Run the GitHub App manifest registration flow via a temporary local server. -/// Returns env var pairs (key, value) for secrets to merge into `.env`. +/// Returns secret pairs `(key, value)` to persist for the local server. async fn setup_github_app( - arc_dir: &Path, + fabro_dir: &Path, s: &Styles, web_url: &str, + owner: &GitHubAppOwner, + username: Option<&str>, + printer: Printer, ) -> Result> { - // Random suffix so app names don't collide - let mut rng = rand::thread_rng(); - let suffix: String = (0..6).fold(String::with_capacity(6), |mut s, _| { - use std::fmt::Write; - let _ = write!(s, "{:x}", rng.gen::() % 16); - s - }); - let app_name = format!("Arc-{suffix}"); + let app_name = owner.app_name(username); // Bind to random port let listener = TcpListener::bind("127.0.0.1:0") @@ -319,12 +526,13 @@ async fn setup_github_app( let code_tx = std::sync::Arc::new(std::sync::Mutex::new(Some(code_tx))); let shutdown_tx = std::sync::Arc::new(std::sync::Mutex::new(Some(shutdown_tx))); + let form_action = owner.manifest_form_action(); let index_html = format!( r#"

Redirecting to GitHub...

-
+
@@ -382,13 +590,14 @@ async fn setup_github_app( // Open browser let url = format!("http://127.0.0.1:{port}/"); - eprintln!(" {}", s.dim.apply_to("Opening browser...")); + fabro_util::printerr!(printer, " {}", s.dim.apply_to("Opening browser...")); if let Err(e) = open::that(&url) { - eprintln!(" Could not open browser automatically: {e}"); - eprintln!(" Please open this URL manually: {url}"); + fabro_util::printerr!(printer, " Could not open browser automatically: {e}"); + fabro_util::printerr!(printer, " Please open this URL manually: {url}"); } - eprintln!( + fabro_util::printerr!( + printer, " {}", s.dim.apply_to("Waiting for GitHub... (Ctrl+C to cancel)") ); @@ -399,8 +608,12 @@ async fn setup_github_app( .context("did not receive callback from GitHub (was the browser flow completed?)")?; // Exchange code for app credentials - eprintln!(" {}", s.dim.apply_to("Exchanging code with GitHub...")); - let client = reqwest::Client::new(); + fabro_util::printerr!( + printer, + " {}", + s.dim.apply_to("Exchanging code with GitHub...") + ); + let client = fabro_http::http_client()?; let resp = client .post(format!( "https://api.github.com/app-manifests/{code}/conversions" @@ -441,39 +654,30 @@ async fn setup_github_app( .context("missing 'pem' in GitHub response")? .to_string(); - // Write non-secret config to user.toml - let user_toml_path = arc_dir.join(USER_CONFIG_FILENAME); + // Write non-secret config to settings.toml + let user_toml_path = fabro_dir.join(SETTINGS_CONFIG_FILENAME); let existing = std::fs::read_to_string(&user_toml_path).unwrap_or_default(); let mut doc: toml::Value = if existing.is_empty() { toml::Value::Table(toml::Table::default()) } else { - toml::from_str(&existing).context("failed to parse existing user.toml")? + toml::from_str(&existing).context("failed to parse existing settings.toml")? }; - let table = doc - .as_table_mut() - .context("user.toml root is not a table")?; - let git = table - .entry("git") - .or_insert(toml::Value::Table(toml::Table::default())); - let git_table = git - .as_table_mut() - .context("user.toml [git] is not a table")?; - git_table.insert("app_id".into(), toml::Value::String(app_id)); - git_table.insert("slug".into(), toml::Value::String(slug.clone())); - git_table.insert("client_id".into(), toml::Value::String(client_id)); + write_github_app_settings(&mut doc, &app_id, &slug, &client_id)?; std::fs::write(&user_toml_path, toml::to_string_pretty(&doc)?)?; - eprintln!( + fabro_util::printerr!( + printer, " {}", s.dim .apply_to(format!("Wrote {}", user_toml_path.display())) ); - eprintln!( + fabro_util::printerr!( + printer, " {}", s.dim .apply_to(format!("App: https://github.com/apps/{slug}")) ); - // Return secrets as env pairs + // Return secret pairs let pem_b64 = BASE64_STANDARD.encode(pem.as_bytes()); let mut env_pairs = vec![ @@ -487,41 +691,119 @@ async fn setup_github_app( Ok(env_pairs) } -pub(crate) async fn run_install(web_url: &str, globals: &GlobalArgs) -> Result<()> { +async fn persist_vault_secrets( + storage_dir: &Path, + secrets: &[(String, String)], + server_was_running: bool, +) -> Result<()> { + if secrets.is_empty() { + return Ok(()); + } + + if server_was_running { + let client = server_client::connect_api_client(storage_dir).await?; + for (name, value) in secrets { + client + .create_secret() + .body(CreateSecretRequest { + name: name.clone(), + value: value.clone(), + type_: ApiSecretType::Environment, + description: None, + }) + .send() + .await?; + } + return Ok(()); + } + + let mut store = Vault::load(Storage::new(storage_dir).secrets_path())?; + for (name, value) in secrets { + store.set(name, value, SecretType::Environment, None)?; + } + Ok(()) +} + +fn persist_server_env_secrets(storage_dir: &Path, secrets: &[(String, String)]) -> Result<()> { + if secrets.is_empty() { + return Ok(()); + } + + envfile::merge_env_file( + &Storage::new(storage_dir).server_state().env_path(), + secrets.iter().cloned(), + )?; + Ok(()) +} + +async fn persist_install_outputs( + storage_dir: &Path, + server_env_secrets: &[(String, String)], + vault_secrets: &[(String, String)], + server_was_running: bool, +) -> Result<()> { + persist_server_env_secrets(storage_dir, server_env_secrets)?; + persist_vault_secrets(storage_dir, vault_secrets, server_was_running).await +} + +pub(crate) async fn run_install( + args: &InstallArgs, + globals: &GlobalArgs, + printer: Printer, +) -> Result<()> { globals.require_no_json()?; + let web_url = &args.web_url; let s = Styles::detect_stderr(); let emoji = console::Emoji("⚒️ ", ""); + let cli_settings = user_config::load_settings_with_storage_dir(args.storage_dir.as_deref())?; + let storage_dir = user_config::storage_dir(&cli_settings)?; + let server_was_running = record::active_server_record(&storage_dir).is_some(); - eprintln!(); - eprintln!(" {}{}", emoji, s.bold.apply_to("Fabro Install")); - eprintln!(); - eprintln!( + fabro_util::printerr!(printer, ""); + fabro_util::printerr!(printer, " {}{}", emoji, s.bold.apply_to("Fabro Install")); + fabro_util::printerr!(printer, ""); + fabro_util::printerr!( + printer, " {}", s.dim .apply_to("Let's get Fabro set up. This will configure your") ); - eprintln!(" {}", s.dim.apply_to("LLM providers and GitHub App.")); - eprintln!(); + fabro_util::printerr!( + printer, + " {}", + s.dim.apply_to("LLM providers and GitHub access.") + ); + fabro_util::printerr!(printer, ""); - let arc_dir = dirs::home_dir() - .context("could not determine home directory")? - .join(".fabro"); - std::fs::create_dir_all(&arc_dir)?; + let fabro_dir = fabro_util::Home::from_env().root().to_path_buf(); + std::fs::create_dir_all(&fabro_dir)?; - // Pre-flight checks (server mode only — standalone doesn't need openssl/node/dot) - #[cfg(feature = "server")] { - eprintln!( + let env_path = legacy_env::legacy_env_file_path(); + if env_path.exists() { + fabro_util::printerr!( + printer, + " Warning: {} is no longer read by fabro server. This install will persist runtime secrets in server.env and workflow-visible credentials in the vault instead.", + env_path.display() + ); + fabro_util::printerr!(printer, ""); + } + } + + // Pre-flight checks + { + fabro_util::printerr!( + printer, " {}", s.dim.apply_to("[Pre-flight] System dependency checks") ); - let dep_outcomes = doctor::probe_system_deps(); + let dep_outcomes = doctor::probe_system_deps().await; let dep_check = doctor::check_system_deps(doctor::DEP_SPECS, &dep_outcomes); if dep_check.status == doctor::CheckStatus::Error { - eprintln!(" Missing required system dependencies:"); + fabro_util::printerr!(printer, " Missing required system dependencies:"); for detail in &dep_check.details { - eprintln!(" {}", detail.text); + fabro_util::printerr!(printer, " {}", detail.text); } bail!("Install missing required tools before running setup"); } @@ -536,32 +818,34 @@ pub(crate) async fn run_install(web_url: &str, globals: &GlobalArgs) -> Result<( .await??; if install { - let status = Command::new("brew") + let status = TokioCommand::new("brew") .args(["install", "graphviz"]) .status() + .await .context("failed to run brew install graphviz")?; if !status.success() { - eprintln!(" Warning: brew install graphviz failed"); + fabro_util::printerr!(printer, " Warning: brew install graphviz failed"); } } } } for detail in &dep_check.details { - eprintln!(" {}", detail.text); + fabro_util::printerr!(printer, " {}", detail.text); } - eprintln!(); + fabro_util::printerr!(printer, ""); } // Step 1: LLM Providers - eprintln!(" {}", s.bold.apply_to("Step 1 · LLM Providers")); - eprintln!(" {}", s.dim.apply_to("──────────────────────")); - eprintln!(); + fabro_util::printerr!(printer, " {}", s.bold.apply_to("Step 1 · LLM Providers")); + fabro_util::printerr!(printer, " {}", s.dim.apply_to("──────────────────────")); + fabro_util::printerr!(printer, ""); - let mut env_pairs: Vec<(String, String)> = Vec::new(); + let mut vault_pairs: Vec<(String, String)> = Vec::new(); + let mut server_env_pairs: Vec<(String, String)> = Vec::new(); let mut configured_providers: Vec = Vec::new(); - let codex_detected = detect_binary_on_path("codex"); + let codex_detected = detect_binary_on_path("codex").await; let mut openai_via_oauth = false; if codex_detected { @@ -575,8 +859,8 @@ pub(crate) async fn run_install(web_url: &str, globals: &GlobalArgs) -> Result<( .await??; if use_oauth { - let pairs = run_openai_oauth_or_api_key(&s).await?; - env_pairs.extend(pairs); + let pairs = run_openai_oauth_or_api_key(&s, printer).await?; + vault_pairs.extend(pairs); configured_providers.push(Provider::OpenAi); openai_via_oauth = true; } @@ -598,14 +882,14 @@ pub(crate) async fn run_install(web_url: &str, globals: &GlobalArgs) -> Result<( let first_provider = primary_providers[primary_idx]; { - let (env_var, key) = prompt_and_validate_key(first_provider, &s).await?; - env_pairs.push((env_var, key)); + let (env_var, key) = prompt_and_validate_key(first_provider, &s, printer).await?; + vault_pairs.push((env_var, key)); configured_providers.push(first_provider); } } // Additional providers - eprintln!(); + fabro_util::printerr!(printer, ""); let add_more = spawn_blocking(|| prompt_confirm("Set up additional LLM providers?", false)).await??; @@ -632,65 +916,92 @@ pub(crate) async fn run_install(web_url: &str, globals: &GlobalArgs) -> Result<( for idx in selected_indices { let provider = remaining_providers[idx]; - let (env_var, key) = prompt_and_validate_key(provider, &s).await?; - env_pairs.push((env_var, key)); + let (env_var, key) = prompt_and_validate_key(provider, &s, printer).await?; + vault_pairs.push((env_var, key)); } } + fabro_util::printerr!(printer, ""); - // Write LLM provider env vars immediately - if !env_pairs.is_empty() { - write_env_file(&arc_dir, &env_pairs, &s)?; - } - eprintln!(); - - // Step 2: GitHub App - eprintln!(" {}", s.bold.apply_to("Step 2 · GitHub App")); - eprintln!(" {}", s.dim.apply_to("───────────────────")); - eprintln!(); + // Step 2: GitHub + fabro_util::printerr!(printer, " {}", s.bold.apply_to("Step 2 · GitHub")); + fabro_util::printerr!(printer, " {}", s.dim.apply_to("───────────────")); + fabro_util::printerr!(printer, ""); { - let setup_github = - spawn_blocking(|| prompt_confirm("Set up a GitHub App? (Recommended)", true)).await??; + let strategy_options = vec![ + "GitHub CLI — use your existing `gh` login".to_string(), + "GitHub App — recommended for teams".to_string(), + ]; + let strategy = spawn_blocking({ + let options = strategy_options.clone(); + move || prompt_select("How should Fabro authenticate with GitHub?", &options) + }) + .await??; - if setup_github { - let github_env_pairs = setup_github_app(&arc_dir, &s, web_url).await?; - let slug = { - let user_toml_path = arc_dir.join(USER_CONFIG_FILENAME); - let toml_content = std::fs::read_to_string(&user_toml_path).unwrap_or_default(); - let doc: toml::Value = toml::from_str(&toml_content) - .unwrap_or(toml::Value::Table(toml::Table::default())); - doc.get("git") - .and_then(|g| g.get("slug")) - .and_then(|s| s.as_str()) - .unwrap_or("unknown") - .to_string() - }; - eprintln!( - " {} GitHub App registered ({})", - s.green.apply_to("✔"), - slug - ); - // Merge GitHub env vars into .env - if !github_env_pairs.is_empty() { - write_env_file(&arc_dir, &github_env_pairs, &s)?; + match strategy { + 0 => { + let token = fabro_github::gh_auth_token().await.map_err(|err| { + anyhow!("{err}. Run `gh auth login` and rerun `fabro install`.") + })?; + let user_toml_path = fabro_dir.join(SETTINGS_CONFIG_FILENAME); + let existing = std::fs::read_to_string(&user_toml_path).unwrap_or_default(); + let mut doc: toml::Value = if existing.is_empty() { + toml::Value::Table(toml::Table::default()) + } else { + toml::from_str(&existing).context("failed to parse existing settings.toml")? + }; + write_github_cli_settings(&mut doc)?; + std::fs::write(&user_toml_path, toml::to_string_pretty(&doc)?)?; + fabro_util::printerr!(printer, " {} GitHub CLI configured", s.green.apply_to("✔")); + vault_pairs.push(("GITHUB_CLI_TOKEN".to_string(), token)); } - } else { - eprintln!(" Skipped"); + 1 => { + let (owner, username) = prompt_github_app_owner(&s).await?; + let github_env_pairs = setup_github_app( + &fabro_dir, + &s, + web_url, + &owner, + username.as_deref(), + printer, + ) + .await?; + let slug = { + let user_toml_path = fabro_dir.join(SETTINGS_CONFIG_FILENAME); + let toml_content = std::fs::read_to_string(&user_toml_path).unwrap_or_default(); + let doc: toml::Value = toml::from_str(&toml_content) + .unwrap_or(toml::Value::Table(toml::Table::default())); + doc.get("server") + .and_then(|server| server.get("integrations")) + .and_then(|integrations| integrations.get("github")) + .and_then(|github| github.get("slug")) + .and_then(|slug| slug.as_str()) + .unwrap_or("unknown") + .to_string() + }; + fabro_util::printerr!( + printer, + " {} GitHub App registered ({})", + s.green.apply_to("✔"), + slug + ); + server_env_pairs.extend(github_env_pairs); + } + _ => unreachable!("prompt_select returned an out-of-range index"), } } - eprintln!(); + fabro_util::printerr!(printer, ""); - // Server configuration (server mode only) - #[cfg(feature = "server")] + // Server configuration { - eprintln!(" {}", s.bold.apply_to("Server · Configuration")); - eprintln!(" {}", s.dim.apply_to("─────────────────────")); - eprintln!(); + fabro_util::printerr!(printer, " {}", s.bold.apply_to("Server · Configuration")); + fabro_util::printerr!(printer, " {}", s.dim.apply_to("─────────────────────")); + fabro_util::printerr!(printer, ""); - let config_path = arc_dir.join("server.toml"); + let config_path = fabro_dir.join(SETTINGS_CONFIG_FILENAME); let write_config = if config_path.exists() { spawn_blocking(|| { - prompt_confirm("~/.fabro/server.toml already exists. Overwrite?", false) + prompt_confirm("~/.fabro/settings.toml already exists. Overwrite?", false) }) .await?? } else { @@ -701,35 +1012,55 @@ pub(crate) async fn run_install(web_url: &str, globals: &GlobalArgs) -> Result<( let username: String = spawn_blocking(|| prompt_input("GitHub username for allowed access")).await??; - let toml_content = format_config_toml(&username); - std::fs::write(&config_path, &toml_content)?; - eprintln!( + let existing = std::fs::read_to_string(&config_path).unwrap_or_default(); + let mut doc: toml::Value = if existing.is_empty() { + toml::Value::Table(toml::Table::default()) + } else { + toml::from_str(&existing).context("failed to parse existing settings.toml")? + }; + merge_server_settings(&mut doc, &username)?; + std::fs::write(&config_path, toml::to_string_pretty(&doc)?)?; + fabro_util::printerr!( + printer, " {}", s.dim.apply_to(format!("Wrote {}", config_path.display())) ); } else { - eprintln!(" {}", s.dim.apply_to("Keeping existing server.toml")); + fabro_util::printerr!( + printer, + " {}", + s.dim.apply_to("Keeping existing settings.toml") + ); } - eprintln!(); + fabro_util::printerr!(printer, ""); } - // Secrets and certificates (server mode only) - #[cfg(feature = "server")] + // Secrets and certificates { - eprintln!( + fabro_util::printerr!( + printer, " {}", s.dim.apply_to("Generating secrets and certificates...") ); let session_secret = generate_session_secret(); - eprintln!(" {} Session secret generated", s.green.apply_to("✔")); + fabro_util::printerr!( + printer, + " {} Session secret generated", + s.green.apply_to("✔") + ); - let (jwt_private_pem, jwt_public_pem) = generate_jwt_keypair()?; - eprintln!(" {} Ed25519 JWT keypair generated", s.green.apply_to("✔")); + let (jwt_private_pem, jwt_public_pem) = generate_jwt_keypair().await?; + fabro_util::printerr!( + printer, + " {} Ed25519 JWT keypair generated", + s.green.apply_to("✔") + ); - let certs_dir = arc_dir.join("certs"); - generate_mtls_certs(&certs_dir)?; - eprintln!( + let certs_dir = fabro_dir.join("certs"); + generate_mtls_certs(&certs_dir).await?; + fabro_util::printerr!( + printer, " {} mTLS CA + server certificates generated", s.green.apply_to("✔") ); @@ -737,35 +1068,68 @@ pub(crate) async fn run_install(web_url: &str, globals: &GlobalArgs) -> Result<( 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 server_env_pairs = vec![ + let 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), ]; - write_env_file(&arc_dir, &server_env_pairs, &s)?; - eprintln!(); + server_env_pairs.extend(generated_server_env_pairs); + fabro_util::printerr!(printer, ""); - eprintln!(" To start Arc, run these commands:"); - eprintln!(); - eprintln!(" fabro server start"); - eprintln!(" cd apps/fabro-web && npx react-router dev"); - eprintln!(); + fabro_util::printerr!(printer, " To start Fabro, run these commands:"); + fabro_util::printerr!(printer, ""); + fabro_util::printerr!(printer, " fabro server start"); + fabro_util::printerr!(printer, ""); } + persist_install_outputs( + &storage_dir, + &server_env_pairs, + &vault_pairs, + server_was_running, + ) + .await?; + fabro_util::printerr!( + printer, + " {} Saved {} runtime secrets to {}", + s.green.apply_to("✔"), + server_env_pairs.len(), + Storage::new(&storage_dir) + .server_state() + .env_path() + .display() + ); + fabro_util::printerr!( + printer, + " {} Saved {} workflow-visible secrets to {}", + s.green.apply_to("✔"), + vault_pairs.len(), + Storage::new(&storage_dir).secrets_path().display() + ); + if server_was_running { + fabro_util::printerr!( + printer, + " Warning: the local fabro server was already running. Restart it to pick up the new server.env values." + ); + } + fabro_util::printerr!(printer, ""); + // Verify setup - let env_path = arc_dir.join(".env"); let run_doctor = spawn_blocking(|| prompt_confirm("Run fabro doctor to verify?", true)).await??; if run_doctor { - // Reload .env so doctor sees the values we just wrote - let _ = dotenvy::from_path(&env_path); - eprintln!(); - let _ = doctor::run_doctor(true, true, globals).await?; + fabro_util::printerr!(printer, ""); + let doctor_args = DoctorArgs { + target: ServerTargetArgs::default(), + verbose: true, + }; + let _ = doctor::run_doctor(&doctor_args, true, globals, printer).await?; } - eprintln!(); - eprintln!( + fabro_util::printerr!(printer, ""); + fabro_util::printerr!( + printer, " Setup complete! Go to your project and run {} to get started.", s.bold_cyan.apply_to("fabro repo init") ); @@ -773,13 +1137,18 @@ pub(crate) async fn run_install(web_url: &str, globals: &GlobalArgs) -> Result<( } // --------------------------------------------------------------------------- -// Hex encoding (server mode only — used by generate_session_secret) +// Hex encoding (used by generate_session_secret) // --------------------------------------------------------------------------- -#[cfg(feature = "server")] mod hex { - pub fn encode(bytes: &[u8]) -> String { - bytes.iter().map(|b| format!("{b:02x}")).collect() + use std::fmt::Write as _; + + pub(super) fn encode(bytes: &[u8]) -> String { + let mut encoded = String::with_capacity(bytes.len() * 2); + for byte in bytes { + write!(&mut encoded, "{byte:02x}").expect("writing to String should not fail"); + } + encoded } } @@ -789,80 +1158,75 @@ mod hex { #[cfg(test)] mod tests { + #![allow(clippy::absolute_paths)] + use super::*; // -- Binary detection -- - #[test] - fn detect_binary_finds_existing_command() { - assert!(detect_binary_on_path("git")); + #[tokio::test] + async fn detect_binary_finds_existing_command() { + assert!(detect_binary_on_path("git").await); } - #[test] - fn detect_binary_returns_false_for_nonexistent() { - assert!(!detect_binary_on_path("arc_nonexistent_xyz")); + #[tokio::test] + async fn detect_binary_returns_false_for_nonexistent() { + assert!(!detect_binary_on_path("arc_nonexistent_xyz").await); } - // -- Session secret (server only) -- + // -- Session secret -- #[test] - #[cfg(feature = "server")] fn session_secret_length() { let secret = generate_session_secret(); assert_eq!(secret.len(), 64); } #[test] - #[cfg(feature = "server")] fn session_secret_is_hex() { let secret = generate_session_secret(); assert!(secret.chars().all(|c| c.is_ascii_hexdigit())); } #[test] - #[cfg(feature = "server")] fn session_secret_is_lowercase() { let secret = generate_session_secret(); assert!(secret.chars().all(|c| !c.is_ascii_uppercase())); } - // -- JWT keypair (server only) -- + // -- JWT keypair -- - #[test] - #[cfg(feature = "server")] - fn jwt_keypair_private_pem_header() { - let (private, _) = generate_jwt_keypair().unwrap(); + #[tokio::test] + async fn jwt_keypair_private_pem_header() { + let (private, _) = generate_jwt_keypair().await.unwrap(); assert!( private.starts_with("-----BEGIN PRIVATE KEY-----"), "private PEM: {private}" ); } - #[test] - #[cfg(feature = "server")] - fn jwt_keypair_public_pem_header() { - let (_, public) = generate_jwt_keypair().unwrap(); + #[tokio::test] + async fn jwt_keypair_public_pem_header() { + let (_, public) = generate_jwt_keypair().await.unwrap(); assert!( public.starts_with("-----BEGIN PUBLIC KEY-----"), "public PEM: {public}" ); } - #[test] - #[cfg(feature = "server")] - fn jwt_keypair_public_parses() { - let (_, public) = generate_jwt_keypair().unwrap(); + #[tokio::test] + async fn jwt_keypair_public_parses() { + let (_, public) = generate_jwt_keypair().await.unwrap(); jsonwebtoken::DecodingKey::from_ed_pem(public.as_bytes()).expect("public key should parse"); } - // -- mTLS cert generation (server only) -- + // -- mTLS cert generation -- - #[test] - #[cfg(feature = "server")] - fn mtls_certs_creates_files() { + #[tokio::test] + async fn mtls_certs_creates_files() { let dir = tempfile::tempdir().unwrap(); let certs_dir = dir.path().join("certs"); - generate_mtls_certs(&certs_dir).unwrap(); + generate_mtls_certs(&certs_dir).await.unwrap(); assert!(certs_dir.join("ca.key").exists()); assert!(certs_dir.join("ca.crt").exists()); @@ -870,12 +1234,11 @@ mod tests { assert!(certs_dir.join("server.crt").exists()); } - #[test] - #[cfg(feature = "server")] - fn mtls_ca_cert_is_pem() { + #[tokio::test] + async fn mtls_ca_cert_is_pem() { let dir = tempfile::tempdir().unwrap(); let certs_dir = dir.path().join("certs"); - generate_mtls_certs(&certs_dir).unwrap(); + generate_mtls_certs(&certs_dir).await.unwrap(); let ca_crt = std::fs::read_to_string(certs_dir.join("ca.crt")).unwrap(); assert!( @@ -884,12 +1247,11 @@ mod tests { ); } - #[test] - #[cfg(feature = "server")] - fn mtls_server_cert_is_pem() { + #[tokio::test] + async fn mtls_server_cert_is_pem() { let dir = tempfile::tempdir().unwrap(); let certs_dir = dir.path().join("certs"); - generate_mtls_certs(&certs_dir).unwrap(); + generate_mtls_certs(&certs_dir).await.unwrap(); let server_crt = std::fs::read_to_string(certs_dir.join("server.crt")).unwrap(); assert!( @@ -898,12 +1260,11 @@ mod tests { ); } - #[test] - #[cfg(feature = "server")] - fn mtls_certs_parse_via_rustls() { + #[tokio::test] + async fn mtls_certs_parse_via_rustls() { let dir = tempfile::tempdir().unwrap(); let certs_dir = dir.path().join("certs"); - generate_mtls_certs(&certs_dir).unwrap(); + generate_mtls_certs(&certs_dir).await.unwrap(); let ca_pem = std::fs::read(certs_dir.join("ca.crt")).unwrap(); let mut reader = std::io::Cursor::new(&ca_pem); @@ -920,44 +1281,229 @@ mod tests { assert_eq!(server_certs.len(), 1); } - // -- Config TOML generation (server only) -- + // -- Config TOML generation -- #[test] - #[cfg(feature = "server")] fn config_toml_roundtrips() { + use fabro_types::settings::SettingsLayer; let toml_str = format_config_toml("brynary"); - let settings: fabro_config::FabroSettings = - toml::from_str(&toml_str).expect("config should parse"); - assert_eq!( - settings.web.unwrap().auth.allowed_usernames, - vec!["brynary"] - ); + let cfg: SettingsLayer = fabro_config::parse_settings_layer(&toml_str) + .expect("generated config should parse as v2"); + let allowed = cfg + .server + .as_ref() + .and_then(|s| s.auth.as_ref()) + .and_then(|a| a.web.as_ref()) + .map(|w| w.allowed_usernames.clone()) + .expect("server.auth.web.allowed_usernames should be set"); + assert_eq!(allowed, vec!["brynary".to_string()]); } #[test] - #[cfg(feature = "server")] fn config_toml_has_auth_strategies() { + use fabro_types::settings::SettingsLayer; let toml_str = format_config_toml("alice"); - let settings: fabro_config::FabroSettings = toml::from_str(&toml_str).unwrap(); - assert_eq!( - settings.api.unwrap().authentication_strategies, - vec![ - fabro_config::server::ApiAuthStrategy::Jwt, - fabro_config::server::ApiAuthStrategy::Mtls, - ] + let cfg: SettingsLayer = fabro_config::parse_settings_layer(&toml_str).unwrap(); + let auth_api = cfg + .server + .as_ref() + .and_then(|s| s.auth.as_ref()) + .and_then(|a| a.api.as_ref()) + .expect("server.auth.api should be set"); + assert!( + auth_api + .jwt + .as_ref() + .is_some_and(|jwt| jwt.enabled.unwrap_or(false)) + ); + assert!( + auth_api + .mtls + .as_ref() + .is_some_and(|mtls| mtls.enabled.unwrap_or(false)) ); } #[test] - #[cfg(feature = "server")] fn config_toml_has_tls_paths() { - use std::path::PathBuf; + use fabro_types::settings::SettingsLayer; + use fabro_types::settings::server::ServerListenLayer; let toml_str = format_config_toml("bob"); - let settings: fabro_config::FabroSettings = toml::from_str(&toml_str).unwrap(); - let tls = settings.api.unwrap().tls.expect("tls should be set"); - assert_eq!(tls.cert, PathBuf::from("~/.fabro/certs/server.crt")); - assert_eq!(tls.key, PathBuf::from("~/.fabro/certs/server.key")); - assert_eq!(tls.ca, PathBuf::from("~/.fabro/certs/ca.crt")); + let cfg: SettingsLayer = fabro_config::parse_settings_layer(&toml_str).unwrap(); + let listen = cfg + .server + .as_ref() + .and_then(|s| s.listen.as_ref()) + .expect("server.listen should be set"); + let tls = match listen { + ServerListenLayer::Tcp { tls, .. } => tls.as_ref().expect("server.listen.tls"), + ServerListenLayer::Unix { .. } => panic!("expected tcp listen"), + }; + let certs_dir = fabro_util::Home::from_env().certs_dir(); + assert_eq!( + tls.cert + .as_ref() + .map(fabro_types::settings::InterpString::as_source), + Some(certs_dir.join("server.crt").to_string_lossy().into_owned()) + ); + assert_eq!( + tls.key + .as_ref() + .map(fabro_types::settings::InterpString::as_source), + Some(certs_dir.join("server.key").to_string_lossy().into_owned()) + ); + assert_eq!( + tls.ca + .as_ref() + .map(fabro_types::settings::InterpString::as_source), + Some(certs_dir.join("ca.crt").to_string_lossy().into_owned()) + ); + } + + #[test] + fn merge_server_settings_preserves_existing_top_level_sections() { + let mut doc: toml::Value = toml::from_str( + r#" +_version = 1 + +[project] +name = "custom" +"#, + ) + .unwrap(); + + merge_server_settings(&mut doc, "alice").unwrap(); + + // Existing top-level [project] stays. + assert_eq!( + doc.get("project") + .and_then(toml::Value::as_table) + .and_then(|p| p.get("name")) + .and_then(toml::Value::as_str), + Some("custom") + ); + // New server.auth.web.allowed_usernames is added. + assert_eq!( + doc.get("server") + .and_then(toml::Value::as_table) + .and_then(|s| s.get("auth")) + .and_then(toml::Value::as_table) + .and_then(|a| a.get("web")) + .and_then(toml::Value::as_table) + .and_then(|w| w.get("allowed_usernames")) + .and_then(toml::Value::as_array) + .and_then(|u| u.first()) + .and_then(toml::Value::as_str), + Some("alice") + ); + } + + #[test] + fn write_github_cli_settings_uses_server_integrations_github() { + let mut doc: toml::Value = toml::from_str( + r#" +_version = 1 + +[server.integrations.github] +strategy = "app" +app_id = "123" +slug = "fabro-app" +client_id = "client-id" +"#, + ) + .unwrap(); + + write_github_cli_settings(&mut doc).unwrap(); + + let github = doc + .get("server") + .and_then(toml::Value::as_table) + .and_then(|server| server.get("integrations")) + .and_then(toml::Value::as_table) + .and_then(|integrations| integrations.get("github")) + .and_then(toml::Value::as_table) + .expect("server.integrations.github should exist"); + + assert_eq!( + github.get("strategy").and_then(toml::Value::as_str), + Some("gh_cli") + ); + assert!(!github.contains_key("app_id")); + assert!(!github.contains_key("slug")); + assert!(!github.contains_key("client_id")); + } + + #[test] + fn write_github_app_settings_uses_server_integrations_github() { + let mut doc = toml::Value::Table(toml::Table::default()); + + write_github_app_settings(&mut doc, "123", "fabro-app", "client-id").unwrap(); + + let github = doc + .get("server") + .and_then(toml::Value::as_table) + .and_then(|server| server.get("integrations")) + .and_then(toml::Value::as_table) + .and_then(|integrations| integrations.get("github")) + .and_then(toml::Value::as_table) + .expect("server.integrations.github should exist"); + + assert_eq!( + github.get("strategy").and_then(toml::Value::as_str), + Some("app") + ); + assert_eq!( + github.get("app_id").and_then(toml::Value::as_str), + Some("123") + ); + assert_eq!( + github.get("slug").and_then(toml::Value::as_str), + Some("fabro-app") + ); + assert_eq!( + github.get("client_id").and_then(toml::Value::as_str), + Some("client-id") + ); + } + + // -- GitHub App owner -- + + #[test] + fn github_app_owner_personal_url() { + let owner = GitHubAppOwner::Personal; + assert_eq!( + owner.manifest_form_action(), + "https://github.com/settings/apps/new" + ); + } + + #[test] + fn github_app_owner_org_url() { + let owner = GitHubAppOwner::Organization("my-org".to_string()); + assert_eq!( + owner.manifest_form_action(), + "https://github.com/organizations/my-org/settings/apps/new" + ); + } + + #[test] + fn github_app_owner_app_name_with_org() { + let owner = GitHubAppOwner::Organization("acme-corp".to_string()); + assert_eq!(owner.app_name(Some("alice")), "acme-corp-fabro"); + } + + #[test] + fn github_app_owner_app_name_personal_with_username() { + let owner = GitHubAppOwner::Personal; + assert_eq!(owner.app_name(Some("brynary")), "brynary-fabro"); + } + + #[test] + fn github_app_owner_app_name_personal_without_username() { + let owner = GitHubAppOwner::Personal; + let name = owner.app_name(None); + assert!(name.starts_with("Fabro-"), "expected Fabro- prefix: {name}"); + assert_eq!(name.len(), 12); // "Fabro-" (6) + 6 hex chars } // -- GitHub App manifest -- @@ -965,7 +1511,7 @@ mod tests { #[test] fn manifest_includes_callback_urls_and_setup_url() { let web_url = "https://app.example.com"; - let manifest = build_github_app_manifest("Arc-test", 12345, web_url); + let manifest = build_github_app_manifest("Fabro-test", 12345, web_url); assert_eq!( manifest["callback_urls"], @@ -976,4 +1522,27 @@ mod tests { serde_json::json!("https://app.example.com/setup/callback"), ); } + + #[tokio::test] + async fn persist_install_outputs_offline_splits_server_env_and_vault() { + 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_pairs = vec![("OPENAI_API_KEY".to_string(), "openai-key".to_string())]; + + persist_install_outputs(dir.path(), &server_env_pairs, &vault_pairs, false) + .await + .unwrap(); + + let server_env = + std::fs::read_to_string(Storage::new(dir.path()).server_state().env_path()).unwrap(); + assert!(server_env.contains("SESSION_SECRET=session")); + assert!(server_env.contains("FABRO_JWT_PUBLIC_KEY=public-key")); + + let vault = Vault::load(Storage::new(dir.path()).secrets_path()).unwrap(); + assert_eq!(vault.get("OPENAI_API_KEY"), Some("openai-key")); + assert_eq!(vault.get("SESSION_SECRET"), None); + } } diff --git a/lib/crates/fabro-cli/src/commands/llm/chat.rs b/lib/crates/fabro-cli/src/commands/llm/chat.rs deleted file mode 100644 index 4220730a8..000000000 --- a/lib/crates/fabro-cli/src/commands/llm/chat.rs +++ /dev/null @@ -1,49 +0,0 @@ -use anyhow::Result; -use fabro_config::FabroSettings; -use fabro_llm::cli::{ChatArgs, run_chat}; -#[cfg(feature = "server")] -use fabro_llm::cli::{ServerConnection, run_chat_via_server}; - -use crate::args::GlobalArgs; - -pub(super) async fn execute( - mut args: ChatArgs, - cli_settings: &FabroSettings, - globals: &GlobalArgs, -) -> Result<()> { - globals.require_no_json()?; - let llm_defaults = cli_settings.llm.as_ref(); - if args.model.is_none() { - args.model = llm_defaults.and_then(|l| l.model.clone()); - } - - #[cfg(feature = "server")] - { - let resolved = crate::user_config::resolve_mode( - globals.storage_dir.as_deref(), - globals.server_url.as_deref(), - cli_settings, - ); - match resolved.mode { - crate::user_config::ExecutionMode::Server => { - let client = crate::user_config::build_server_client(resolved.tls.as_ref())?; - let server = ServerConnection { - client, - base_url: resolved.server_base_url, - }; - run_chat_via_server(args, &server).await?; - } - crate::user_config::ExecutionMode::Standalone => { - run_chat(args).await?; - } - } - } - - #[cfg(not(feature = "server"))] - { - let _ = globals; - run_chat(args).await?; - } - - Ok(()) -} diff --git a/lib/crates/fabro-cli/src/commands/llm/mod.rs b/lib/crates/fabro-cli/src/commands/llm/mod.rs deleted file mode 100644 index e86c603fe..000000000 --- a/lib/crates/fabro-cli/src/commands/llm/mod.rs +++ /dev/null @@ -1,16 +0,0 @@ -mod chat; -mod prompt; - -use anyhow::Result; - -use crate::args::{GlobalArgs, LlmCommand, LlmNamespace}; -use crate::user_config::load_user_settings_with_globals; - -pub(crate) async fn dispatch(ns: LlmNamespace, globals: &GlobalArgs) -> Result<()> { - let cli_settings = load_user_settings_with_globals(globals)?; - - match ns.command { - LlmCommand::Prompt(args) => prompt::execute(args, &cli_settings, globals).await, - LlmCommand::Chat(args) => chat::execute(args, &cli_settings, globals).await, - } -} diff --git a/lib/crates/fabro-cli/src/commands/llm/prompt.rs b/lib/crates/fabro-cli/src/commands/llm/prompt.rs deleted file mode 100644 index 3cd66e468..000000000 --- a/lib/crates/fabro-cli/src/commands/llm/prompt.rs +++ /dev/null @@ -1,48 +0,0 @@ -use anyhow::Result; -use fabro_config::FabroSettings; -use fabro_llm::cli::{PromptArgs, run_prompt}; -#[cfg(feature = "server")] -use fabro_llm::cli::{ServerConnection, run_prompt_via_server}; - -use crate::args::GlobalArgs; - -pub(super) async fn execute( - mut args: PromptArgs, - cli_settings: &FabroSettings, - globals: &GlobalArgs, -) -> Result<()> { - let llm_defaults = cli_settings.llm.as_ref(); - if args.model.is_none() { - args.model = llm_defaults.and_then(|l| l.model.clone()); - } - - #[cfg(feature = "server")] - { - let resolved = crate::user_config::resolve_mode( - globals.storage_dir.as_deref(), - globals.server_url.as_deref(), - cli_settings, - ); - match resolved.mode { - crate::user_config::ExecutionMode::Server => { - let client = crate::user_config::build_server_client(resolved.tls.as_ref())?; - let server = ServerConnection { - client, - base_url: resolved.server_base_url, - }; - run_prompt_via_server(args, &server, globals.json).await?; - } - crate::user_config::ExecutionMode::Standalone => { - run_prompt(args, globals.json).await?; - } - } - } - - #[cfg(not(feature = "server"))] - { - let _ = globals; - run_prompt(args, globals.json).await?; - } - - Ok(()) -} diff --git a/lib/crates/fabro-cli/src/commands/mod.rs b/lib/crates/fabro-cli/src/commands/mod.rs index ab45ccf85..72995e763 100644 --- a/lib/crates/fabro-cli/src/commands/mod.rs +++ b/lib/crates/fabro-cli/src/commands/mod.rs @@ -1,10 +1,9 @@ -pub(crate) mod asset; +pub(crate) mod artifact; pub(crate) mod config; pub(crate) mod doctor; pub(crate) mod exec; pub(crate) mod graph; pub(crate) mod install; -pub(crate) mod llm; pub(crate) mod model; pub(crate) mod parse; pub(crate) mod pr; @@ -15,9 +14,10 @@ pub(crate) mod run; pub(crate) mod runs; pub(crate) mod sandbox; pub(crate) mod secret; -pub(crate) mod skill; +pub(crate) mod server; pub(crate) mod store; pub(crate) mod system; +pub(crate) mod uninstall; pub(crate) mod upgrade; pub(crate) mod validate; pub(crate) mod workflow; diff --git a/lib/crates/fabro-cli/src/commands/model.rs b/lib/crates/fabro-cli/src/commands/model.rs index 855bd3fa7..f4b25506c 100644 --- a/lib/crates/fabro-cli/src/commands/model.rs +++ b/lib/crates/fabro-cli/src/commands/model.rs @@ -1,39 +1,755 @@ -use anyhow::Result; -#[cfg(feature = "server")] -use fabro_llm::cli::ServerConnection; -use fabro_llm::cli::{ModelsCommand, run_models}; +use anyhow::{Context, Result, bail}; +use cli_table::format::{Border, Justify, Separator}; +use cli_table::{Cell, CellStruct, Color, Style, Table}; +use fabro_api::{self, types as api_types}; +use fabro_model::{Catalog, Model, Provider}; +use fabro_util::printer::Printer; +use fabro_util::terminal::Styles; +use serde::Serialize; +use serde::de::DeserializeOwned; -use crate::args::GlobalArgs; -#[cfg(feature = "server")] -use crate::user_config; +use crate::args::{GlobalArgs, ModelListArgs, ModelTestArgs, ModelsCommand}; +use crate::command_context::CommandContext; +use crate::server_client; -pub(crate) async fn execute(command: Option, globals: &GlobalArgs) -> Result<()> { - let server = { - #[cfg(feature = "server")] - { - let cli_settings = user_config::load_user_settings_with_globals(globals)?; - let resolved = user_config::resolve_mode( - globals.storage_dir.as_deref(), - globals.server_url.as_deref(), - &cli_settings, - ); - match resolved.mode { - user_config::ExecutionMode::Server => { - let client = user_config::build_server_client(resolved.tls.as_ref())?; - Some(ServerConnection { - client, - base_url: resolved.server_base_url, - }) +#[derive(Serialize)] +#[serde(rename_all = "snake_case")] +enum ModelTestResultKind { + Pass, + Fail, + Skip, +} + +#[derive(Serialize)] +struct ModelTestRow { + model: String, + provider: Provider, + result: ModelTestResultKind, + #[serde(skip_serializing_if = "Option::is_none")] + detail: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +#[derive(Serialize)] +struct ModelTestOutput { + results: Vec, + total: usize, + failures: u32, +} + +pub(crate) async fn execute( + command: Option, + globals: &GlobalArgs, + printer: Printer, +) -> 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)?; + let server = ctx.server().await?; + + run_models(command, server.api(), globals.json).await +} + +fn format_context_window(tokens: i64) -> String { + let rounded = ((tokens + 500) / 1_000) * 1_000; + if rounded >= 1_000_000 { + format!("{}m", rounded / 1_000_000) + } else if rounded >= 1_000 { + format!("{}k", rounded / 1_000) + } else { + tokens.to_string() + } +} + +fn format_cost(cost: Option) -> String { + match cost { + None => "-".to_string(), + Some(c) => format!("${c:.1}"), + } +} + +fn format_speed(tps: Option) -> String { + match tps { + None => "-".to_string(), + #[allow(clippy::cast_possible_truncation)] + Some(t) => format!("{} tok/s", t as i64), + } +} + +fn color_if(use_color: bool, color: Color) -> Option { + if use_color { Some(color) } else { None } +} + +fn color_choice(use_color: bool) -> cli_table::ColorChoice { + if use_color { + cli_table::ColorChoice::Auto + } else { + cli_table::ColorChoice::Never + } +} + +fn model_row(model: &Model, use_color: bool) -> Vec { + let aliases = model.aliases.join(", "); + let cost = format!( + "{} / {}", + format_cost(model.costs.input_cost_per_mtok), + format_cost(model.costs.output_cost_per_mtok), + ); + vec![ + model.id.clone().cell().bold(use_color), + model + .provider + .cell() + .foreground_color(color_if(use_color, Color::Ansi256(8))), + aliases + .cell() + .foreground_color(color_if(use_color, Color::Ansi256(8))), + format_context_window(model.limits.context_window) + .cell() + .justify(Justify::Right), + cost.cell().justify(Justify::Right), + format_speed(model.estimated_output_tps) + .cell() + .justify(Justify::Right) + .foreground_color(color_if(use_color, Color::Cyan)), + ] +} + +fn models_title(use_color: bool) -> Vec { + vec![ + "MODEL".cell().bold(use_color), + "PROVIDER".cell().bold(use_color), + "ALIASES".cell().bold(use_color), + "CONTEXT".cell().bold(use_color).justify(Justify::Right), + "COST".cell().bold(use_color).justify(Justify::Right), + "SPEED".cell().bold(use_color).justify(Justify::Right), + ] +} + +#[allow(clippy::print_stdout)] +fn print_models_table(models: &[Model], styles: &Styles) { + let use_color = styles.use_color; + let rows: Vec> = models + .iter() + .map(|model| model_row(model, use_color)) + .collect(); + let table = rows + .table() + .title(models_title(use_color)) + .color_choice(color_choice(use_color)) + .border(Border::builder().build()) + .separator(Separator::builder().build()); + println!("{}", table.display().unwrap()); +} + +fn model_test_row_from_status(model: &Model, status: &str, result_color: Color) -> ModelTestRow { + let trimmed = status.trim(); + match result_color { + Color::Green => ModelTestRow { + model: model.id.clone(), + provider: model.provider, + result: ModelTestResultKind::Pass, + detail: None, + error: None, + }, + Color::Yellow => ModelTestRow { + model: model.id.clone(), + provider: model.provider, + result: ModelTestResultKind::Skip, + detail: Some(trimmed.to_string()), + error: None, + }, + _ => ModelTestRow { + model: model.id.clone(), + provider: model.provider, + result: ModelTestResultKind::Fail, + detail: None, + error: Some( + trimmed + .strip_prefix("error: ") + .unwrap_or(trimmed) + .to_string(), + ), + }, + } +} + +fn convert_type(value: TInput) -> Result +where + TInput: serde::Serialize, + TOutput: DeserializeOwned, +{ + serde_json::from_value(serde_json::to_value(value)?).map_err(Into::into) +} + +async fn fetch_models_from_server( + client: &fabro_api::Client, + provider: Option<&str>, + query: Option<&str>, +) -> Result> { + let mut offset = 0u64; + let mut models = Vec::new(); + + loop { + let mut request = client.list_models().page_limit(100u64).page_offset(offset); + if let Some(provider) = provider { + request = request.provider(provider.to_string()); + } + if let Some(query) = query { + request = request.query(query.to_string()); + } + + let response = request.send().await.map_err(server_client::map_api_error)?; + let parsed = response.into_inner(); + let count = parsed.data.len() as u64; + models.extend(convert_type::<_, Vec>(parsed.data)?); + if !parsed.meta.has_more { + break; + } + offset += count; + } + + Ok(models) +} + +async fn test_model_via_server( + client: &fabro_api::Client, + model_id: &str, + mode: Option, +) -> Result { + let mut request = client.test_model().id(model_id.to_string()); + if let Some(mode) = mode { + request = request.mode(mode); + } + let response = request.send().await.map_err(server_client::map_api_error)?; + Ok(response.into_inner()) +} + +#[allow(clippy::print_stdout, clippy::print_stderr)] +async fn test_models_via_server( + client: &fabro_api::Client, + provider: Option<&str>, + model: Option<&str>, + deep: bool, + styles: &Styles, + json_output: bool, +) -> Result<()> { + let request_mode = deep.then_some(api_types::ModelTestMode::Deep); + + let use_color = styles.use_color; + let mut title = models_title(use_color); + title.push("RESULT".cell().bold(use_color)); + + let mut rows: Vec> = Vec::new(); + let mut json_rows = Vec::new(); + let mut failures = 0u32; + if let Some(model_id) = model { + if !json_output { + eprint!("Testing {model_id}..."); + } + let result = test_model_via_server(client, model_id, request_mode).await; + if !json_output { + eprintln!(" done"); + } + + let (info, result_color, status) = match result { + Ok(resp) => { + let info = Catalog::builtin() + .get(&resp.model_id) + .cloned() + .with_context(|| { + format!("Unknown model returned by server: {}", resp.model_id) + })?; + if resp.status == api_types::ModelTestResultStatus::Ok { + (info, Color::Green, "ok".to_string()) + } else { + failures += 1; + let message = resp + .error_message + .unwrap_or_else(|| "unknown error".to_string()); + (info, Color::Red, format!("error: {message}")) } - user_config::ExecutionMode::Standalone => None, + } + Err(err) if err.to_string().contains("Model not found") => { + bail!("Unknown model: {model_id}"); + } + Err(err) => { + let info = Catalog::builtin() + .get(model_id) + .cloned() + .with_context(|| format!("Unknown model: {model_id}"))?; + failures += 1; + (info, Color::Red, format!("error: {err}")) + } + }; + + let mut row = model_row(&info, use_color); + row.push( + status + .clone() + .cell() + .foreground_color(color_if(use_color, result_color)), + ); + rows.push(row); + json_rows.push(model_test_row_from_status(&info, &status, result_color)); + } else { + let models_to_test = fetch_models_from_server(client, provider, None).await?; + if models_to_test.is_empty() { + bail!("No models found"); + } + + for info in &models_to_test { + if !json_output { + eprint!("Testing {}...", info.id); + } + let result = test_model_via_server(client, &info.id, request_mode).await; + if !json_output { + eprintln!(" done"); + } + + let (result_color, status) = match result { + Ok(resp) if resp.status == api_types::ModelTestResultStatus::Ok => { + (Color::Green, "ok".to_string()) + } + Ok(resp) => { + failures += 1; + let message = resp + .error_message + .unwrap_or_else(|| "unknown error".to_string()); + (Color::Red, format!("error: {message}")) + } + Err(err) => { + failures += 1; + (Color::Red, format!("error: {err}")) + } + }; + + let mut row = model_row(info, use_color); + row.push( + status + .clone() + .cell() + .foreground_color(color_if(use_color, result_color)), + ); + rows.push(row); + json_rows.push(model_test_row_from_status(info, &status, result_color)); + } + } + + if json_output { + println!( + "{}", + serde_json::to_string_pretty(&ModelTestOutput { + total: json_rows.len(), + failures, + results: json_rows, + })? + ); + if failures > 0 { + bail!("{failures} model(s) failed"); + } + return Ok(()); + } + + let table = rows + .table() + .title(title) + .color_choice(color_choice(use_color)) + .border(Border::builder().build()) + .separator(Separator::builder().build()); + println!("{}", table.display()?); + + if failures > 0 { + bail!("{failures} model(s) failed"); + } + + Ok(()) +} + +#[allow(clippy::print_stdout)] +async fn run_models( + command: ModelsCommand, + client: &fabro_api::Client, + json_output: bool, +) -> Result<()> { + let styles = Styles::detect_stdout(); + + match command { + ModelsCommand::List(ModelListArgs { + provider, query, .. + }) => { + let models = + fetch_models_from_server(client, provider.as_deref(), query.as_deref()).await?; + + if json_output { + println!("{}", serde_json::to_string_pretty(&models)?); + } else { + print_models_table(&models, &styles); } } - #[cfg(not(feature = "server"))] - { - let _ = globals; - None + ModelsCommand::Test(ModelTestArgs { + provider, + model, + deep, + .. + }) => { + test_models_via_server( + client, + provider.as_deref(), + model.as_deref(), + deep, + &styles, + json_output, + ) + .await?; } - }; + } - run_models(command, server, globals.json).await + Ok(()) +} + +impl Default for ModelsCommand { + fn default() -> Self { + Self::List(ModelListArgs::default()) + } +} + +#[cfg(test)] +mod tests { + use fabro_model::{ModelCosts, ModelFeatures, ModelLimits}; + + use super::*; + + fn test_api_client(api_url: &str) -> fabro_api::Client { + fabro_api::Client::new_with_client(api_url, fabro_test::test_http_client()) + } + + fn test_model_json(id: &str, provider: Provider) -> serde_json::Value { + serde_json::to_value(Model { + id: id.to_string(), + provider, + family: "test".to_string(), + display_name: format!("{id} display"), + limits: ModelLimits { + context_window: 128_000, + max_output: Some(4096), + }, + training: None, + knowledge_cutoff: None, + features: ModelFeatures { + tools: true, + vision: false, + reasoning: false, + effort: false, + }, + costs: ModelCosts { + input_cost_per_mtok: Some(1.0), + output_cost_per_mtok: Some(2.0), + cache_input_cost_per_mtok: None, + }, + estimated_output_tps: Some(100.0), + aliases: vec!["tm".to_string()], + default: false, + }) + .unwrap() + } + + #[test] + fn format_context_window_millions() { + assert_eq!(format_context_window(1_000_000), "1m"); + } + + #[test] + fn format_context_window_thousands() { + assert_eq!(format_context_window(128_000), "128k"); + } + + #[test] + fn format_context_window_small() { + assert_eq!(format_context_window(400), "400"); + } + + #[test] + fn format_context_window_rounds_up() { + assert_eq!(format_context_window(1500), "2k"); + } + + #[test] + fn format_context_window_rounds_down() { + assert_eq!(format_context_window(1499), "1k"); + } + + #[test] + fn format_context_window_zero() { + assert_eq!(format_context_window(0), "0"); + } + + #[test] + fn format_cost_none() { + assert_eq!(format_cost(None), "-"); + } + + #[test] + fn format_cost_some() { + assert_eq!(format_cost(Some(3.0)), "$3.0"); + } + + #[test] + fn format_cost_fractional() { + assert_eq!(format_cost(Some(15.75)), "$15.8"); + } + + #[test] + fn format_speed_none() { + assert_eq!(format_speed(None), "-"); + } + + #[test] + fn format_speed_some() { + assert_eq!(format_speed(Some(85.5)), "85 tok/s"); + } + + #[tokio::test] + async fn test_model_via_server_parses_ok() { + let server = httpmock::MockServer::start_async().await; + server + .mock_async(|when, then| { + when.method("POST").path("/api/v1/models/test-model/test"); + then.status(200) + .header("Content-Type", "application/json") + .body( + serde_json::json!({ + "model_id": "test-model", + "status": "ok" + }) + .to_string(), + ); + }) + .await; + + let client = test_api_client(&server.url("")); + let response = test_model_via_server(&client, "test-model", None) + .await + .unwrap(); + + assert_eq!(response.status, api_types::ModelTestResultStatus::Ok); + assert!(response.error_message.is_none()); + } + + #[tokio::test] + async fn test_model_via_server_passes_mode_and_parses_error() { + let server = httpmock::MockServer::start_async().await; + server + .mock_async(|when, then| { + when.method("POST") + .path("/api/v1/models/test-model/test") + .query_param("mode", "deep"); + then.status(200) + .header("Content-Type", "application/json") + .body( + serde_json::json!({ + "model_id": "test-model", + "status": "error", + "error_message": "timeout" + }) + .to_string(), + ); + }) + .await; + + let client = test_api_client(&server.url("")); + let response = + test_model_via_server(&client, "test-model", Some(api_types::ModelTestMode::Deep)) + .await + .unwrap(); + + assert_eq!(response.status, api_types::ModelTestResultStatus::Error); + assert_eq!(response.error_message.as_deref(), Some("timeout")); + } + + #[tokio::test] + async fn test_model_via_server_404() { + let server = httpmock::MockServer::start_async().await; + server + .mock_async(|when, then| { + when.method("POST").path("/api/v1/models/bad-model/test"); + then.status(404) + .header("Content-Type", "application/json") + .body( + serde_json::json!({ + "errors": [{"status": "404", "title": "Not Found", "detail": "Model not found"}] + }) + .to_string(), + ); + }) + .await; + + let client = test_api_client(&server.url("")); + let result = test_model_via_server(&client, "bad-model", None).await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Model not found")); + } + + #[tokio::test] + async fn fetch_models_from_server_parses_response() { + let server = httpmock::MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method("GET") + .path("/api/v1/models") + .query_param("page[limit]", "100") + .query_param("page[offset]", "0"); + then.status(200) + .header("Content-Type", "application/json") + .body( + serde_json::json!({ + "data": [test_model_json("test-model", Provider::Anthropic)], + "meta": { "has_more": false } + }) + .to_string(), + ); + }) + .await; + + let client = test_api_client(&server.url("")); + let models = fetch_models_from_server(&client, None, None).await.unwrap(); + + mock.assert_async().await; + assert_eq!(models.len(), 1); + assert_eq!(models[0].id, "test-model"); + assert_eq!(models[0].provider, Provider::Anthropic); + } + + #[tokio::test] + async fn fetch_models_from_server_filters_by_provider() { + let server = httpmock::MockServer::start_async().await; + server + .mock_async(|when, then| { + when.method("GET") + .path("/api/v1/models") + .query_param("page[limit]", "100") + .query_param("page[offset]", "0") + .query_param("provider", "anthropic"); + then.status(200) + .header("Content-Type", "application/json") + .body( + serde_json::json!({ + "data": [test_model_json("model-a", Provider::Anthropic)], + "meta": { "has_more": false } + }) + .to_string(), + ); + }) + .await; + + let client = test_api_client(&server.url("")); + let models = fetch_models_from_server(&client, Some("anthropic"), None) + .await + .unwrap(); + + assert_eq!(models.len(), 1); + assert_eq!(models[0].id, "model-a"); + } + + #[tokio::test] + async fn fetch_models_from_server_passes_query_param() { + let server = httpmock::MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method("GET") + .path("/api/v1/models") + .query_param("page[limit]", "100") + .query_param("page[offset]", "0") + .query_param("query", "sonnet"); + then.status(200) + .header("Content-Type", "application/json") + .body( + serde_json::json!({ + "data": [test_model_json("claude-sonnet-4-5", Provider::Anthropic)], + "meta": { "has_more": false } + }) + .to_string(), + ); + }) + .await; + + let client = test_api_client(&server.url("")); + let models = fetch_models_from_server(&client, None, Some("sonnet")) + .await + .unwrap(); + + mock.assert_async().await; + assert_eq!(models.len(), 1); + assert_eq!(models[0].id, "claude-sonnet-4-5"); + } + + #[tokio::test] + async fn fetch_models_from_server_follows_pagination() { + let server = httpmock::MockServer::start_async().await; + let first_page = server + .mock_async(|when, then| { + when.method("GET") + .path("/api/v1/models") + .query_param("page[limit]", "100") + .query_param("page[offset]", "0"); + then.status(200) + .header("Content-Type", "application/json") + .body( + serde_json::json!({ + "data": [test_model_json("model-a", Provider::Anthropic)], + "meta": { "has_more": true } + }) + .to_string(), + ); + }) + .await; + let second_page = server + .mock_async(|when, then| { + when.method("GET") + .path("/api/v1/models") + .query_param("page[limit]", "100") + .query_param("page[offset]", "1"); + then.status(200) + .header("Content-Type", "application/json") + .body( + serde_json::json!({ + "data": [test_model_json("model-b", Provider::OpenAi)], + "meta": { "has_more": false } + }) + .to_string(), + ); + }) + .await; + + let client = test_api_client(&server.url("")); + let models = fetch_models_from_server(&client, None, None).await.unwrap(); + + first_page.assert_async().await; + second_page.assert_async().await; + assert_eq!(models.len(), 2); + assert_eq!(models[0].id, "model-a"); + assert_eq!(models[1].id, "model-b"); + } + + #[tokio::test] + async fn fetch_models_from_server_error_on_failure() { + let server = httpmock::MockServer::start_async().await; + server + .mock_async(|when, then| { + when.method("GET") + .path("/api/v1/models") + .query_param("page[limit]", "100") + .query_param("page[offset]", "0"); + then.status(500).body("internal error"); + }) + .await; + + let client = test_api_client(&server.url("")); + let result = fetch_models_from_server(&client, None, None).await; + assert!(result.is_err()); + } } diff --git a/lib/crates/fabro-cli/src/commands/parse.rs b/lib/crates/fabro-cli/src/commands/parse.rs index dfb3f44bb..ce6722e20 100644 --- a/lib/crates/fabro-cli/src/commands/parse.rs +++ b/lib/crates/fabro-cli/src/commands/parse.rs @@ -2,11 +2,12 @@ use std::io::Write; use fabro_config::project::resolve_workflow; use fabro_graphviz::parser::parse_ast; +use fabro_util::printer::Printer; use crate::args::{GlobalArgs, ParseArgs}; use crate::shared::read_workflow_file; -pub(crate) fn run(args: &ParseArgs, globals: &GlobalArgs) -> anyhow::Result<()> { +pub(crate) fn run(args: &ParseArgs, globals: &GlobalArgs, _printer: Printer) -> anyhow::Result<()> { let _ = globals; let stdout = std::io::stdout(); run_to(args, stdout.lock()) diff --git a/lib/crates/fabro-cli/src/commands/pr/close.rs b/lib/crates/fabro-cli/src/commands/pr/close.rs index 9c2df808d..8dc9523f8 100644 --- a/lib/crates/fabro-cli/src/commands/pr/close.rs +++ b/lib/crates/fabro-cli/src/commands/pr/close.rs @@ -1,35 +1,18 @@ -use std::path::Path; - -use anyhow::{Context, Result}; -use fabro_config::FabroSettingsExt; -use fabro_workflow::run_lookup::runs_base; +use anyhow::Result; +use fabro_util::printer::Printer; use tracing::info; use crate::args::{GlobalArgs, PrCloseArgs}; use crate::shared::print_json_pretty; -use crate::user_config::load_user_settings_with_globals; pub(super) async fn close_command( args: PrCloseArgs, - github_app: Option, globals: &GlobalArgs, + printer: Printer, ) -> Result<()> { - let cli_settings = load_user_settings_with_globals(globals)?; - let base = runs_base(&cli_settings.storage_dir()); - close_from(&base, args, github_app, globals).await -} + let (record, _run_id) = super::load_pr_record(&args.server, &args.run_id, printer).await?; -async fn close_from( - base: &Path, - args: PrCloseArgs, - github_app: Option, - globals: &GlobalArgs, -) -> Result<()> { - let (record, _run_dir) = super::load_pr_record(base, &args.run_id).await?; - - let creds = github_app.context( - "GitHub App credentials required — set GITHUB_APP_PRIVATE_KEY and configure app_id", - )?; + let creds = super::load_github_credentials_required(printer).await?; fabro_github::close_pull_request( &creds, @@ -48,7 +31,7 @@ async fn close_from( "html_url": record.html_url, }))?; } else { - println!("Closed #{} ({})", record.number, record.html_url); + fabro_util::printout!(printer, "Closed #{} ({})", record.number, record.html_url); } Ok(()) diff --git a/lib/crates/fabro-cli/src/commands/pr/create.rs b/lib/crates/fabro-cli/src/commands/pr/create.rs index 7a1fa16e0..52cae606c 100644 --- a/lib/crates/fabro-cli/src/commands/pr/create.rs +++ b/lib/crates/fabro-cli/src/commands/pr/create.rs @@ -1,77 +1,44 @@ -use std::path::Path; - use anyhow::{Context, Result, bail}; -use fabro_config::FabroSettingsExt; use fabro_model::Catalog; use fabro_sandbox::daytona::detect_repo_info; +use fabro_util::printer::Printer; use fabro_workflow::outcome::StageStatus; use fabro_workflow::pull_request::maybe_open_pull_request; -use fabro_workflow::records::{ - Conclusion, ConclusionExt, RunRecord, RunRecordExt, StartRecord, StartRecordExt, -}; -use fabro_workflow::run_lookup::{resolve_run_combined, runs_base}; use tracing::info; use crate::args::{GlobalArgs, PrCreateArgs}; +use crate::command_context::CommandContext; +use crate::commands::store::rebuild::rebuild_run_store; +use crate::server_runs::ServerSummaryLookup; use crate::shared::print_json_pretty; -use crate::store; -use crate::user_config::load_user_settings_with_globals; +use crate::shared::repo::ensure_matching_repo_origin; pub(super) async fn create_command( args: PrCreateArgs, - github_app: Option, globals: &GlobalArgs, + printer: Printer, ) -> Result<()> { - let cli_settings = load_user_settings_with_globals(globals)?; - let base = runs_base(&cli_settings.storage_dir()); - create_from(&base, args, github_app, globals).await -} + let ctx = CommandContext::for_target(&args.server, printer)?; + let lookup = ServerSummaryLookup::from_client(ctx.server().await?).await?; + let run = lookup.resolve(&args.run_id)?; + let run_id = run.run_id(); + let events = lookup.client().list_run_events(&run_id, None, None).await?; + let run_store = rebuild_run_store(&run_id, &events).await?; + let state = run_store.state().await?; -async fn create_from( - base: &Path, - args: PrCreateArgs, - github_app: Option, - globals: &GlobalArgs, -) -> Result<()> { - let storage_dir = base.parent().unwrap_or(base); - let store = store::build_store(storage_dir)?; - let run = resolve_run_combined(store.as_ref(), base, &args.run_id).await?; - let run_dir = run.path.clone(); - let run_store = store::open_run_reader(storage_dir, &run.run_id).await?; + let record = state.run.context("Failed to load run record from store")?; + ensure_matching_repo_origin( + record.repo_origin_url.as_deref(), + "create a pull request for", + )?; - let record = match run_store.as_ref() { - Some(run_store) => run_store - .get_run() - .await - .ok() - .flatten() - .or_else(|| RunRecord::load(&run_dir).ok()) - .context("Failed to load run.json")?, - None => RunRecord::load(&run_dir).context("Failed to load run.json")?, - }; + let start = state + .start + .context("Failed to load start record from store")?; - let start = match run_store.as_ref() { - Some(run_store) => run_store - .get_start() - .await - .ok() - .flatten() - .or_else(|| StartRecord::load(&run_dir).ok()) - .context("Failed to load start.json")?, - None => StartRecord::load(&run_dir).context("Failed to load start.json")?, - }; - - let conclusion = match run_store.as_ref() { - Some(run_store) => run_store - .get_conclusion() - .await - .ok() - .flatten() - .or_else(|| Conclusion::load(&run_dir.join("conclusion.json")).ok()) - .context("Failed to load conclusion.json — is the run finished?")?, - None => Conclusion::load(&run_dir.join("conclusion.json")) - .context("Failed to load conclusion.json — is the run finished?")?, - }; + let conclusion = state + .conclusion + .context("Failed to load conclusion from store — is the run finished?")?; match conclusion.status { StageStatus::Success | StageStatus::PartialSuccess => {} @@ -86,10 +53,11 @@ async fn create_from( .as_deref() .context("Run has no run_branch — was it run with git push enabled?")?; - let diff = std::fs::read_to_string(run_dir.join("final.patch")) - .context("Failed to read final.patch — no diff available")?; + let diff = state + .final_patch + .context("Failed to load final patch from store — no diff available")?; if diff.trim().is_empty() { - bail!("final.patch is empty — nothing to create a PR for"); + bail!("Stored diff is empty — nothing to create a PR for"); } let cwd = std::env::current_dir().context("Failed to get current directory")?; @@ -106,9 +74,7 @@ async fn create_from( let (owner, repo) = fabro_github::parse_github_owner_repo(&https_url) .map_err(|err| anyhow::anyhow!("{err}"))?; - let creds = github_app.context( - "GitHub App credentials required — set GITHUB_APP_PRIVATE_KEY and configure app_id", - )?; + let creds = super::load_github_credentials_required(printer).await?; let branch_found = fabro_github::branch_exists( &creds, @@ -136,13 +102,12 @@ async fn create_from( &origin_url, base_branch, run_branch, - record.goal(), + record.graph.goal(), &diff, &model, true, None, - run_store.as_deref(), - &run_dir, + &run_store.clone().into(), None, ) .await @@ -151,20 +116,17 @@ async fn create_from( match record { Some(record) => { info!(pr_url = %record.html_url, "Pull request created"); - if let Err(err) = record.save(&run_dir.join("pull_request.json")) { - tracing::warn!(error = %err, "Failed to save pull_request.json"); - } if globals.json { print_json_pretty(&record)?; } else { - println!("{}", record.html_url); + fabro_util::printout!(printer, "{}", record.html_url); } } None => { if globals.json { print_json_pretty(&serde_json::Value::Null)?; } else { - println!("No pull request created (empty diff)."); + fabro_util::printout!(printer, "No pull request created (empty diff)."); } } } diff --git a/lib/crates/fabro-cli/src/commands/pr/list.rs b/lib/crates/fabro-cli/src/commands/pr/list.rs index 63a62020d..53dae4201 100644 --- a/lib/crates/fabro-cli/src/commands/pr/list.rs +++ b/lib/crates/fabro-cli/src/commands/pr/list.rs @@ -1,59 +1,36 @@ -use std::path::Path; - -use anyhow::{Context, Result}; -use fabro_config::FabroSettingsExt; -use fabro_workflow::pull_request::PullRequestRecord; -use fabro_workflow::run_lookup::{runs_base, scan_runs_combined}; +use anyhow::Result; +use fabro_util::printer::Printer; use futures::future::join_all; use serde::Serialize; use tracing::info; use crate::args::{GlobalArgs, PrListArgs}; +use crate::command_context::CommandContext; +use crate::server_runs::ServerSummaryLookup; use crate::shared::print_json_pretty; -use crate::store; -use crate::user_config::load_user_settings_with_globals; #[derive(Serialize)] struct PrRow { run_id: String, number: u64, - state: String, - title: String, - url: String, + state: String, + title: String, + url: String, } pub(super) async fn list_command( args: PrListArgs, - github_app: Option, globals: &GlobalArgs, + printer: Printer, ) -> Result<()> { - let cli_settings = load_user_settings_with_globals(globals)?; - let base = runs_base(&cli_settings.storage_dir()); - let store = store::build_store(&cli_settings.storage_dir())?; - list_from(store.as_ref(), &base, args, github_app, globals).await -} + let ctx = CommandContext::for_target(&args.server, printer)?; + let lookup = ServerSummaryLookup::from_client(ctx.server().await?).await?; -async fn list_from( - store: &dyn fabro_store::Store, - base: &Path, - args: PrListArgs, - github_app: Option, - globals: &GlobalArgs, -) -> Result<()> { - let creds = github_app.context( - "GitHub App credentials required — set GITHUB_APP_PRIVATE_KEY and configure app_id", - )?; - - let runs = scan_runs_combined(store, base) - .await - .context("Failed to scan runs")?; - - let mut entries: Vec<(String, PullRequestRecord)> = Vec::new(); - for run in &runs { - let pr_path = run.path.join("pull_request.json"); - if let Ok(content) = std::fs::read_to_string(&pr_path) { - if let Ok(record) = serde_json::from_str::(&content) { - entries.push((run.run_id.to_string(), record)); + let mut entries = Vec::new(); + for run in lookup.runs() { + if let Ok(state) = lookup.client().get_run_state(&run.run_id()).await { + if let Some(record) = state.pull_request { + entries.push((run.run_id().to_string(), record)); } } } @@ -63,10 +40,12 @@ async fn list_from( print_json_pretty(&Vec::::new())?; return Ok(()); } - println!("No pull requests found."); + fabro_util::printout!(printer, "No pull requests found."); return Ok(()); } + let creds = super::load_github_credentials_required(printer).await?; + let futures: Vec<_> = entries .iter() .map(|(run_id, record)| { @@ -125,13 +104,20 @@ async fn list_from( } if rows.is_empty() { - println!("No open pull requests found. Use --all to include closed/merged."); + fabro_util::printout!( + printer, + "No open pull requests found. Use --all to include closed/merged." + ); return Ok(()); } - println!( + fabro_util::printout!( + printer, "{:<12} {:<6} {:<8} {:<50} URL", - "RUN", "#", "STATE", "TITLE" + "RUN", + "#", + "STATE", + "TITLE" ); for row in &rows { let short_id = if row.run_id.len() > 12 { @@ -144,9 +130,14 @@ async fn list_from( } else { row.title.clone() }; - println!( + fabro_util::printout!( + printer, "{:<12} {:<6} {:<8} {:<50} {}", - short_id, row.number, row.state, short_title, row.url + short_id, + row.number, + row.state, + short_title, + row.url ); } diff --git a/lib/crates/fabro-cli/src/commands/pr/merge.rs b/lib/crates/fabro-cli/src/commands/pr/merge.rs index e9438b34a..4e8f35f4f 100644 --- a/lib/crates/fabro-cli/src/commands/pr/merge.rs +++ b/lib/crates/fabro-cli/src/commands/pr/merge.rs @@ -1,36 +1,18 @@ -use std::path::Path; - -use anyhow::{Context, Result}; -use fabro_config::FabroSettingsExt; +use anyhow::Result; +use fabro_util::printer::Printer; use tracing::info; -use fabro_workflow::run_lookup::runs_base; - use crate::args::{GlobalArgs, PrMergeArgs}; use crate::shared::print_json_pretty; -use crate::user_config::load_user_settings_with_globals; pub(super) async fn merge_command( args: PrMergeArgs, - github_app: Option, globals: &GlobalArgs, + printer: Printer, ) -> Result<()> { - let cli_settings = load_user_settings_with_globals(globals)?; - let base = runs_base(&cli_settings.storage_dir()); - merge_from(&base, args, github_app, globals).await -} + let (record, _run_id) = super::load_pr_record(&args.server, &args.run_id, printer).await?; -async fn merge_from( - base: &Path, - args: PrMergeArgs, - github_app: Option, - globals: &GlobalArgs, -) -> Result<()> { - let (record, _run_dir) = super::load_pr_record(base, &args.run_id).await?; - - let creds = github_app.context( - "GitHub App credentials required — set GITHUB_APP_PRIVATE_KEY and configure app_id", - )?; + let creds = super::load_github_credentials_required(printer).await?; fabro_github::merge_pull_request( &creds, @@ -51,7 +33,7 @@ async fn merge_from( "method": args.method, }))?; } else { - println!("Merged #{} ({})", record.number, record.html_url); + fabro_util::printout!(printer, "Merged #{} ({})", record.number, record.html_url); } Ok(()) diff --git a/lib/crates/fabro-cli/src/commands/pr/mod.rs b/lib/crates/fabro-cli/src/commands/pr/mod.rs index 67a36628b..1a2a7b355 100644 --- a/lib/crates/fabro-cli/src/commands/pr/mod.rs +++ b/lib/crates/fabro-cli/src/commands/pr/mod.rs @@ -4,48 +4,73 @@ mod list; mod merge; mod view; -use std::path::{Path, PathBuf}; +use anyhow::{Context, Result, anyhow}; +use fabro_github::GitHubCredentials; +use fabro_types::PullRequestRecord; +use fabro_types::settings::InterpString; +use fabro_util::printer::Printer; -use anyhow::{Context, Result}; +use crate::args::{GlobalArgs, PrCommand, PrNamespace, ServerTargetArgs}; +use crate::command_context::CommandContext; +use crate::server_runs::ServerSummaryLookup; +use crate::shared::github::build_github_credentials; -use fabro_workflow::pull_request::PullRequestRecord; -use fabro_workflow::run_lookup::resolve_run_combined; - -use crate::args::{GlobalArgs, PrCommand, PrNamespace}; -use crate::shared::github::build_github_app_credentials; -use crate::store; -use crate::user_config::load_user_settings_with_globals; - -pub(crate) async fn dispatch(ns: PrNamespace, globals: &GlobalArgs) -> Result<()> { - let cli_settings = load_user_settings_with_globals(globals)?; - let github_app = build_github_app_credentials(cli_settings.app_id())?; +const GITHUB_CREDENTIALS_REQUIRED: &str = "GitHub credentials required — run `gh auth login` or configure a GitHub App with `fabro install`"; +pub(crate) async fn dispatch( + ns: PrNamespace, + globals: &GlobalArgs, + printer: Printer, +) -> Result<()> { match ns.command { - PrCommand::Create(args) => create::create_command(args, github_app, globals).await, - PrCommand::List(args) => list::list_command(args, github_app, globals).await, - PrCommand::View(args) => view::view_command(args, github_app, globals).await, - PrCommand::Merge(args) => merge::merge_command(args, github_app, globals).await, - PrCommand::Close(args) => close::close_command(args, github_app, globals).await, + PrCommand::Create(args) => Box::pin(create::create_command(args, globals, printer)).await, + PrCommand::List(args) => list::list_command(args, globals, printer).await, + PrCommand::View(args) => view::view_command(args, globals, printer).await, + PrCommand::Merge(args) => merge::merge_command(args, globals, printer).await, + PrCommand::Close(args) => close::close_command(args, globals, printer).await, } } -pub(crate) async fn load_pr_record( - base: &Path, - run_id: &str, -) -> Result<(PullRequestRecord, PathBuf)> { - let storage_dir = base.parent().unwrap_or(base); - let store = store::build_store(storage_dir)?; - let run_dir = resolve_run_combined(store.as_ref(), base, run_id) - .await? - .path; - let pr_path = run_dir.join("pull_request.json"); - let content = std::fs::read_to_string(&pr_path).with_context(|| { - format!( - "No pull_request.json found in run directory. \ - Create one first with: fabro pr create {run_id}" - ) - })?; - let record: PullRequestRecord = - serde_json::from_str(&content).context("Failed to parse pull_request.json")?; - Ok((record, run_dir)) +async fn load_github_credentials_required(printer: Printer) -> Result { + let ctx = CommandContext::base(printer)?; + let server_settings = + fabro_config::resolve_server_from_file(ctx.machine_settings()).map_err(|errors| { + anyhow!( + "failed to resolve server settings:\n{}", + errors + .into_iter() + .map(|error| error.to_string()) + .collect::>() + .join("\n") + ) + })?; + let creds = build_github_credentials( + server_settings.integrations.github.strategy, + server_settings + .integrations + .github + .app_id + .as_ref() + .map(InterpString::as_source) + .as_deref(), + ) + .await + .map_err(|_| anyhow!(GITHUB_CREDENTIALS_REQUIRED))?; + creds.context(GITHUB_CREDENTIALS_REQUIRED) +} + +pub(crate) async fn load_pr_record( + server: &ServerTargetArgs, + run_id: &str, + printer: Printer, +) -> Result<(PullRequestRecord, fabro_types::RunId)> { + let ctx = CommandContext::for_target(server, printer)?; + let lookup = ServerSummaryLookup::from_client(ctx.server().await?).await?; + let run = lookup.resolve(run_id)?; + let run_id = run.run_id(); + let state = lookup.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)) } diff --git a/lib/crates/fabro-cli/src/commands/pr/view.rs b/lib/crates/fabro-cli/src/commands/pr/view.rs index bb0d0bc23..6aca2c43c 100644 --- a/lib/crates/fabro-cli/src/commands/pr/view.rs +++ b/lib/crates/fabro-cli/src/commands/pr/view.rs @@ -1,36 +1,18 @@ -use std::path::Path; - -use anyhow::{Context, Result}; -use fabro_config::FabroSettingsExt; +use anyhow::Result; +use fabro_util::printer::Printer; use tracing::info; -use fabro_workflow::run_lookup::runs_base; - use crate::args::{GlobalArgs, PrViewArgs}; use crate::shared::print_json_pretty; -use crate::user_config::load_user_settings_with_globals; pub(super) async fn view_command( args: PrViewArgs, - github_app: Option, globals: &GlobalArgs, + printer: Printer, ) -> Result<()> { - let cli_settings = load_user_settings_with_globals(globals)?; - let base = runs_base(&cli_settings.storage_dir()); - view_from(&base, args, github_app, globals).await -} + let (record, _run_id) = super::load_pr_record(&args.server, &args.run_id, printer).await?; -async fn view_from( - base: &Path, - args: PrViewArgs, - github_app: Option, - globals: &GlobalArgs, -) -> Result<()> { - let (record, _run_dir) = super::load_pr_record(base, &args.run_id).await?; - - let creds = github_app.context( - "GitHub App credentials required — set GITHUB_APP_PRIVATE_KEY and configure app_id", - )?; + let creds = super::load_github_credentials_required(printer).await?; let detail = fabro_github::get_pull_request( &creds, @@ -49,23 +31,28 @@ async fn view_from( return Ok(()); } - println!("#{} {}", detail.number, detail.title); + fabro_util::printout!(printer, "#{} {}", detail.number, detail.title); let state_display = if detail.draft { "draft" } else { &detail.state }; - println!("State: {state_display}"); - println!("URL: {}", detail.html_url); - println!( + fabro_util::printout!(printer, "State: {state_display}"); + fabro_util::printout!(printer, "URL: {}", detail.html_url); + fabro_util::printout!( + printer, "Branch: {} -> {}", - detail.head.ref_name, detail.base.ref_name + detail.head.ref_name, + detail.base.ref_name ); - println!("Author: {}", detail.user.login); - println!( + fabro_util::printout!(printer, "Author: {}", detail.user.login); + fabro_util::printout!( + printer, "Changes: +{} -{} ({} files)", - detail.additions, detail.deletions, detail.changed_files + detail.additions, + detail.deletions, + detail.changed_files ); if let Some(body) = &detail.body { if !body.is_empty() { - println!(); - println!("{body}"); + fabro_util::printout!(printer, ""); + fabro_util::printout!(printer, "{body}"); } } diff --git a/lib/crates/fabro-cli/src/commands/preflight.rs b/lib/crates/fabro-cli/src/commands/preflight.rs index b5e1d9af4..a6689468e 100644 --- a/lib/crates/fabro-cli/src/commands/preflight.rs +++ b/lib/crates/fabro-cli/src/commands/preflight.rs @@ -1,483 +1,77 @@ -use std::path::Path; -use std::sync::Arc; - use anyhow::bail; -use fabro_config::project::{resolve_workflow_path, resolve_working_directory}; -use fabro_config::{ConfigLayer, FabroSettings}; -use fabro_graphviz::graph::{Graph, is_llm_handler_type}; -use fabro_llm::client::Client as LlmClient; -use fabro_model::{Catalog, Provider}; -use fabro_sandbox::daytona::{DaytonaConfig, detect_repo_info}; -use fabro_sandbox::{DockerSandboxConfig, Sandbox, SandboxProvider, SandboxSpec}; -use fabro_util::check_report::CheckReport; +use fabro_config::load::load_settings_user; +use fabro_config::user::active_settings_path; +use fabro_types::settings::cli::OutputVerbosity; +use fabro_util::printer::Printer; use fabro_util::terminal::Styles; -use fabro_workflow::git::{GitSyncStatus, sync_status}; -use fabro_workflow::operations::{ValidateInput, WorkflowInput, validate}; use crate::args::{GlobalArgs, PreflightArgs}; -use crate::shared::github::build_github_app_credentials; +use crate::command_context::CommandContext; +use crate::commands::run::output::{ + api_check_report_to_local, api_diagnostics_to_local, print_preflight_workflow_summary, +}; +use crate::commands::run::overrides::preflight_args_layer; +use crate::manifest_builder::{ManifestBuildInput, build_run_manifest, preflight_manifest_args}; use crate::shared::print_json_pretty; -use crate::user_config::{load_user_settings_with_globals, user_layer_with_globals}; -pub(crate) async fn execute(mut args: PreflightArgs, globals: &GlobalArgs) -> anyhow::Result<()> { +pub(crate) async fn execute( + mut args: PreflightArgs, + globals: &GlobalArgs, + printer: Printer, +) -> anyhow::Result<()> { let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); - let cli = user_layer_with_globals(globals)?; - let cli_settings: FabroSettings = load_user_settings_with_globals(globals)?; - args.verbose = args.verbose || cli_settings.verbose_enabled(); + let ctx = CommandContext::for_target(&args.target, printer)?; + args.verbose = args.verbose || ctx.cli_settings().output.verbosity == OutputVerbosity::Verbose; - let github_app = build_github_app_credentials(cli_settings.app_id())?; - let cli_args_config = ConfigLayer::try_from(&args)?; - let cwd = std::env::current_dir()?; - let settings = cli_args_config - .combine(ConfigLayer::for_workflow(&args.workflow, &cwd)?) - .combine(cli) - .resolve()?; - let resolution = resolve_workflow_path(&args.workflow, &cwd)?; - let working_directory = resolve_working_directory(&settings, &cwd); - - let (origin_url, detected_base_branch) = detect_repo_info(&working_directory) - .map(|(url, branch)| (Some(url), branch)) - .unwrap_or((None, None)); - let git_status = sync_status( - &working_directory, - "origin", - detected_base_branch.as_deref(), - ); - - let sandbox_provider = resolve_sandbox_provider(args.sandbox.map(Into::into), &settings)?; - - let validated = validate(ValidateInput { - workflow: WorkflowInput::Path(args.workflow.clone()), - settings: settings.clone(), - cwd, - custom_transforms: Vec::new(), + let manifest = build_run_manifest(ManifestBuildInput { + workflow: args.workflow.clone(), + cwd: ctx.cwd().to_path_buf(), + args_layer: preflight_args_layer(&args)?, + args: preflight_manifest_args(&args), + run_id: None, + user_layer: load_settings_user()?, + user_settings_path: Some(active_settings_path(None)), })?; - if !globals.json { - super::run::output::print_workflow_report(&validated, Some(&resolution.dot_path), styles); - if validated.has_errors() { + let client = ctx.server().await?; + let response = client.run_preflight(manifest.manifest).await?; + let diagnostics = api_diagnostics_to_local(&response.workflow.diagnostics); + + if globals.json { + print_json_pretty(&response)?; + } else { + print_preflight_workflow_summary( + &response.workflow, + Some(&manifest.target_path), + styles, + printer, + ); + if diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == fabro_validate::Severity::Error) + { bail!("Validation failed"); } + let report = api_check_report_to_local(&response.checks); + let term_width = console::Term::stderr().size().1; + { + use std::fmt::Write as _; + let _ = write!( + printer.stdout(), + "{}", + report.render(styles, true, None, Some(term_width)) + ); + } } - let (report, preflight_ok) = run_preflight( - validated.graph(), - &settings, - args.model.as_deref(), - args.provider.as_deref(), - git_status, - sandbox_provider, - &working_directory, - styles, - github_app, - origin_url.as_deref(), - !globals.json, - ) - .await?; - - if globals.json { - print_json_pretty(&serde_json::json!({ - "workflow": { - "name": validated.graph().name, - "graph_path": resolution.dot_path, - "nodes": validated.graph().nodes.len(), - "edges": validated.graph().edges.len(), - "goal": validated.graph().goal(), - "diagnostics": validated.diagnostics(), - }, - "checks": report, - }))?; - } else { - let term_width = console::Term::stderr().size().1; - print!("{}", report.render(styles, true, None, Some(term_width))); - } - - if validated.has_errors() { + if diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == fabro_validate::Severity::Error) + { bail!("Validation failed"); } - - if !preflight_ok { + if !response.ok { std::process::exit(1); } Ok(()) } - -fn resolve_model_provider( - cli_model: Option<&str>, - cli_provider: Option<&str>, - settings: &FabroSettings, - graph: &Graph, -) -> (String, Option) { - let configured_model = settings.llm.as_ref().and_then(|llm| llm.model.as_deref()); - let configured_provider = settings - .llm - .as_ref() - .and_then(|llm| llm.provider.as_deref()); - - let provider = cli_provider - .or(configured_provider) - .or_else(|| graph.attrs.get("default_provider").and_then(|v| v.as_str())) - .map(String::from); - - let model = cli_model - .or(configured_model) - .or_else(|| graph.attrs.get("default_model").and_then(|v| v.as_str())) - .map_or_else( - || { - let catalog = Catalog::builtin(); - let info = provider - .as_deref() - .and_then(|s| s.parse::().ok()) - .and_then(|p| catalog.default_for_provider(p)) - .unwrap_or_else(|| catalog.default_from_env()); - info.id.clone() - }, - String::from, - ); - - match Catalog::builtin().get(&model) { - Some(info) => ( - info.id.clone(), - provider.or(Some(info.provider.to_string())), - ), - None => (model, provider), - } -} - -fn parse_sandbox_provider(settings: &FabroSettings) -> anyhow::Result> { - settings - .sandbox_settings() - .and_then(|s| s.provider.as_deref()) - .map(str::parse::) - .transpose() - .map_err(|e| anyhow::anyhow!("Invalid sandbox provider: {e}")) -} - -fn resolve_sandbox_provider( - cli: Option, - settings: &FabroSettings, -) -> anyhow::Result { - Ok(cli - .or(parse_sandbox_provider(settings)?) - .unwrap_or_default()) -} - -fn resolve_daytona_config(settings: &FabroSettings) -> Option { - settings - .sandbox_settings() - .and_then(|sandbox| sandbox.daytona.clone()) -} - -async fn mint_github_token( - creds: &fabro_github::GitHubAppCredentials, - origin_url: &str, - permissions: &std::collections::HashMap, -) -> anyhow::Result { - let https_url = fabro_github::ssh_url_to_https(origin_url); - let (owner, repo) = - fabro_github::parse_github_owner_repo(&https_url).map_err(|e| anyhow::anyhow!("{e}"))?; - let jwt = fabro_github::sign_app_jwt(&creds.app_id, &creds.private_key_pem) - .map_err(|e| anyhow::anyhow!("{e}"))?; - let client = reqwest::Client::new(); - let perms_json = serde_json::to_value(permissions)?; - let token = fabro_github::create_installation_access_token_with_permissions( - &client, - &jwt, - &owner, - &repo, - &fabro_github::github_api_base_url(), - perms_json, - ) - .await - .map_err(|e| anyhow::anyhow!("{e}"))?; - Ok(token) -} - -#[allow(clippy::too_many_arguments)] -async fn run_preflight( - graph: &Graph, - settings: &FabroSettings, - cli_model: Option<&str>, - cli_provider: Option<&str>, - git_status: GitSyncStatus, - sandbox_provider: SandboxProvider, - working_directory: &Path, - styles: &'static Styles, - github_app: Option, - origin_url: Option<&str>, - show_progress: bool, -) -> anyhow::Result<(CheckReport, bool)> { - use fabro_util::check_report::{ - CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus, - }; - - let spinner = show_progress.then(|| { - let spinner = indicatif::ProgressBar::new_spinner(); - spinner.set_style( - indicatif::ProgressStyle::with_template("{spinner:.cyan} {msg}") - .expect("valid template") - .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", ""]), - ); - spinner.set_message("Running preflight checks..."); - spinner.enable_steady_tick(std::time::Duration::from_millis(80)); - spinner - }); - - let mut checks: Vec = Vec::new(); - - let setup_command_count = settings.setup_commands().len(); - let repo_summary = origin_url.map_or_else( - || "unknown".into(), - |url| { - let https = fabro_github::ssh_url_to_https(url); - fabro_github::parse_github_owner_repo(&https).map_or_else( - |_| url.to_string(), - |(owner, repo)| format!("{owner}/{repo}"), - ) - }, - ); - - checks.push(CheckResult { - name: "Repository".into(), - status: CheckStatus::Pass, - summary: repo_summary, - details: vec![ - CheckDetail::new(format!("Setup commands: {setup_command_count}")), - CheckDetail { - text: format!("Git: {git_status}"), - warn: git_status != GitSyncStatus::Synced, - }, - ], - remediation: None, - }); - - let (model, provider) = resolve_model_provider(cli_model, cli_provider, settings, graph); - checks.push(CheckResult { - name: "Workflow".into(), - status: CheckStatus::Pass, - summary: graph.name.clone(), - details: vec![ - CheckDetail::new(format!("Nodes: {}", graph.nodes.len())), - CheckDetail::new(format!("Edges: {}", graph.edges.len())), - CheckDetail::new(format!("Goal: {}", graph.goal())), - ], - remediation: None, - }); - - let daytona_config = resolve_daytona_config(settings); - - let sandbox_result: Result, String> = match sandbox_provider { - SandboxProvider::Local => SandboxSpec::Local { - working_directory: working_directory.to_path_buf(), - } - .build(None) - .await - .map_err(|e| e.to_string()), - SandboxProvider::Docker => SandboxSpec::Docker { - config: DockerSandboxConfig { - host_working_directory: working_directory.to_string_lossy().to_string(), - ..DockerSandboxConfig::default() - }, - } - .build(None) - .await - .map_err(|e| e.to_string()), - SandboxProvider::Daytona => SandboxSpec::Daytona { - config: daytona_config.unwrap_or_default(), - github_app: github_app.clone(), - run_id: None, - clone_branch: None, - } - .build(None) - .await - .map_err(|e| format!("Daytona sandbox creation failed: {e}")), - }; - - let sandbox_ok = match sandbox_result { - Ok(sandbox) => match sandbox.initialize().await { - Ok(()) => { - let _ = sandbox.cleanup().await; - true - } - Err(e) => { - let _ = sandbox.cleanup().await; - checks.push(CheckResult { - name: "Sandbox".into(), - status: CheckStatus::Error, - summary: "failed".into(), - details: vec![CheckDetail::new(format!("Provider: {sandbox_provider}"))], - remediation: Some(format!("Sandbox init failed: {e}")), - }); - false - } - }, - Err(e) => { - checks.push(CheckResult { - name: "Sandbox".into(), - status: CheckStatus::Error, - summary: "failed".into(), - details: vec![CheckDetail::new(format!("Provider: {sandbox_provider}"))], - remediation: Some(e), - }); - false - } - }; - - if sandbox_ok { - checks.push(CheckResult { - name: "Sandbox".into(), - status: CheckStatus::Pass, - summary: sandbox_provider.to_string(), - details: vec![CheckDetail::new(format!("Provider: {sandbox_provider}"))], - remediation: None, - }); - } - - let default_provider = provider.as_deref().unwrap_or("anthropic"); - let llm_ok = match LlmClient::from_env().await { - Ok(c) => { - let configured: Vec = c - .provider_names() - .iter() - .map(std::string::ToString::to_string) - .collect(); - - let mut model_providers = std::collections::BTreeSet::new(); - for node in graph.nodes.values() { - if !is_llm_handler_type(node.handler_type()) { - continue; - } - let node_model = node.model().unwrap_or(&model); - let node_provider = node.provider().unwrap_or(default_provider); - - let (resolved_model, resolved_provider) = - if let Some(info) = Catalog::builtin().get(node_model) { - (info.id.clone(), info.provider.to_string()) - } else { - (node_model.to_string(), node_provider.to_string()) - }; - - let final_provider = if node.provider().is_some() { - node_provider.to_string() - } else { - resolved_provider - }; - - model_providers.insert((resolved_model, final_provider)); - } - - if model_providers.is_empty() { - let (resolved_model, resolved_provider) = - if let Some(info) = Catalog::builtin().get(&model) { - (info.id.clone(), info.provider.to_string()) - } else { - (model.clone(), default_provider.to_string()) - }; - model_providers.insert((resolved_model, resolved_provider)); - } - - let mut all_ok = true; - for (model_id, provider_name) in &model_providers { - match provider_name.parse::() { - Ok(_) => { - let mut status = CheckStatus::Pass; - if !configured.iter().any(|n| n == provider_name) { - status = CheckStatus::Warning; - all_ok = false; - } - checks.push(CheckResult { - name: "LLM".into(), - status, - summary: model_id.clone(), - details: vec![CheckDetail::new(format!("Provider: {provider_name}"))], - remediation: if status == CheckStatus::Warning { - Some(format!("Provider \"{provider_name}\" is not configured")) - } else { - None - }, - }); - } - Err(e) => { - checks.push(CheckResult { - name: "LLM".into(), - status: CheckStatus::Error, - summary: model_id.clone(), - details: vec![CheckDetail::new(format!("Provider: {provider_name}"))], - remediation: Some(format!("Invalid provider \"{provider_name}\": {e}")), - }); - all_ok = false; - } - } - } - all_ok - } - Err(e) => { - checks.push(CheckResult { - name: "LLM".into(), - status: CheckStatus::Error, - summary: "initialization failed".into(), - details: vec![], - remediation: Some(format!("LLM client init failed: {e}")), - }); - false - } - }; - - if let Some(github_permissions) = settings.github_permissions() { - if !github_permissions.is_empty() { - let perm_details: Vec = github_permissions - .iter() - .map(|(k, v)| CheckDetail::new(format!("{k}: {v}"))) - .collect(); - match (&github_app, origin_url) { - (Some(creds), Some(url)) => { - match mint_github_token(creds, url, github_permissions).await { - Ok(_) => { - checks.push(CheckResult { - name: "GitHub Token".into(), - status: CheckStatus::Pass, - summary: "minted".into(), - details: perm_details, - remediation: None, - }); - } - Err(e) => { - checks.push(CheckResult { - name: "GitHub Token".into(), - status: CheckStatus::Error, - summary: "failed".into(), - details: perm_details, - remediation: Some(format!("Failed to mint GitHub token: {e}")), - }); - } - } - } - _ => { - checks.push(CheckResult { - name: "GitHub Token".into(), - status: CheckStatus::Warning, - summary: "skipped".into(), - details: vec![], - remediation: Some( - "No GitHub App credentials or origin URL available".to_string(), - ), - }); - } - } - } - } - - let report = CheckReport { - title: "Run Preflight".into(), - sections: vec![CheckSection { - title: String::new(), - checks, - }], - }; - if let Some(spinner) = spinner { - spinner.finish_and_clear(); - } - let _ = styles; - - Ok((report, sandbox_ok && llm_ok)) -} diff --git a/lib/crates/fabro-cli/src/commands/provider/login.rs b/lib/crates/fabro-cli/src/commands/provider/login.rs index 94a6c44df..49189bd94 100644 --- a/lib/crates/fabro-cli/src/commands/provider/login.rs +++ b/lib/crates/fabro-cli/src/commands/provider/login.rs @@ -1,30 +1,61 @@ -use anyhow::{Context, Result}; +use anyhow::Result; +use fabro_api::types; +use fabro_config::legacy_env; use fabro_model::Provider; +use fabro_util::printer::Printer; use fabro_util::terminal::Styles; use tokio::task::spawn_blocking; use crate::args::{GlobalArgs, ProviderLoginArgs}; +use crate::command_context::CommandContext; use crate::shared::provider_auth; -pub(super) async fn login_command(args: ProviderLoginArgs, globals: &GlobalArgs) -> Result<()> { +pub(super) async fn login_command( + args: ProviderLoginArgs, + globals: &GlobalArgs, + printer: Printer, +) -> Result<()> { globals.require_no_json()?; let s = Styles::detect_stderr(); - let arc_dir = dirs::home_dir() - .context("could not determine home directory")? - .join(".fabro"); - std::fs::create_dir_all(&arc_dir)?; + let ctx = CommandContext::for_target(&args.target, printer)?; + let server = ctx.server().await?; let use_oauth = args.provider == Provider::OpenAi && spawn_blocking(|| provider_auth::prompt_confirm("Log in via browser (OAuth)?", true)) .await??; let env_pairs = if use_oauth { - provider_auth::run_openai_oauth_or_api_key(&s).await? + provider_auth::run_openai_oauth_or_api_key(&s, printer).await? } else { - let (env_var, key) = provider_auth::prompt_and_validate_key(args.provider, &s).await?; + let (env_var, key) = + provider_auth::prompt_and_validate_key(args.provider, &s, printer).await?; vec![(env_var, key)] }; - provider_auth::write_env_file(&arc_dir, &env_pairs, &s)?; + { + let path = legacy_env::legacy_env_file_path(); + if path.exists() { + fabro_util::printerr!( + printer, + " Warning: {} is no longer read by fabro server. Re-enter credentials with `fabro provider login` or `fabro secret set`.", + path.display() + ); + } + } + + for (name, value) in env_pairs { + server + .api() + .create_secret() + .body(types::CreateSecretRequest { + name: name.clone(), + value, + type_: types::SecretType::Environment, + description: None, + }) + .send() + .await?; + fabro_util::printerr!(printer, " {} Saved {}", s.green.apply_to("✔"), name); + } Ok(()) } diff --git a/lib/crates/fabro-cli/src/commands/provider/mod.rs b/lib/crates/fabro-cli/src/commands/provider/mod.rs index c1fcb9431..79915a73a 100644 --- a/lib/crates/fabro-cli/src/commands/provider/mod.rs +++ b/lib/crates/fabro-cli/src/commands/provider/mod.rs @@ -1,11 +1,16 @@ mod login; use anyhow::Result; +use fabro_util::printer::Printer; use crate::args::{GlobalArgs, ProviderCommand, ProviderNamespace}; -pub(crate) async fn dispatch(ns: ProviderNamespace, globals: &GlobalArgs) -> Result<()> { +pub(crate) async fn dispatch( + ns: ProviderNamespace, + globals: &GlobalArgs, + printer: Printer, +) -> Result<()> { match ns.command { - ProviderCommand::Login(args) => login::login_command(args, globals).await, + ProviderCommand::Login(args) => login::login_command(args, globals, printer).await, } } diff --git a/lib/crates/fabro-cli/src/commands/repo/deinit.rs b/lib/crates/fabro-cli/src/commands/repo/deinit.rs index ec602b68e..926f91205 100644 --- a/lib/crates/fabro-cli/src/commands/repo/deinit.rs +++ b/lib/crates/fabro-cli/src/commands/repo/deinit.rs @@ -1,48 +1,37 @@ use anyhow::{Context, Result, bail}; +use fabro_util::printer::Printer; use crate::args::GlobalArgs; -pub(crate) fn run_deinit(globals: &GlobalArgs) -> Result> { +pub(crate) fn run_deinit(globals: &GlobalArgs, printer: Printer) -> Result> { let repo_root = super::init::git_repo_root()?; let mut removed = Vec::new(); - let fabro_toml = repo_root.join("fabro.toml"); + let fabro_dir = repo_root.join(".fabro"); + let project_toml = fabro_dir.join("project.toml"); let green = console::Style::new().green(); let dim = console::Style::new().dim(); - match std::fs::remove_file(&fabro_toml) { - Ok(()) => {} - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - bail!("not initialized — fabro.toml not found"); - } - Err(e) => bail!("failed to remove {}: {e}", fabro_toml.display()), + if !project_toml.exists() { + bail!("not initialized — .fabro/project.toml not found"); } - removed.push("fabro.toml".to_string()); + + std::fs::remove_dir_all(&fabro_dir) + .with_context(|| format!("failed to remove {}", fabro_dir.display()))?; + removed.push(".fabro/".to_string()); if !globals.json { - eprintln!( + fabro_util::printerr!( + printer, " {} {}", green.apply_to("✔"), - dim.apply_to("removed fabro.toml") + dim.apply_to("removed .fabro/") ); } - let fabro_dir = repo_root.join("fabro"); - if fabro_dir.exists() { - std::fs::remove_dir_all(&fabro_dir) - .with_context(|| format!("failed to remove {}", fabro_dir.display()))?; - removed.push("fabro/".to_string()); - if !globals.json { - eprintln!( - " {} {}", - green.apply_to("✔"), - dim.apply_to("removed fabro/") - ); - } - } - if !globals.json { - eprintln!( + fabro_util::printerr!( + printer, "\n{}", console::Style::new() .bold() diff --git a/lib/crates/fabro-cli/src/commands/repo/init.rs b/lib/crates/fabro-cli/src/commands/repo/init.rs index e38e07965..204144c87 100644 --- a/lib/crates/fabro-cli/src/commands/repo/init.rs +++ b/lib/crates/fabro-cli/src/commands/repo/init.rs @@ -1,12 +1,17 @@ use std::path::PathBuf; use anyhow::{Context, Result, bail}; +use fabro_util::printer::Printer; +use tokio::process::Command as TokioCommand; use tokio::task::spawn_blocking; -use crate::args::GlobalArgs; -use crate::shared::github::build_github_app_credentials; -use crate::user_config::load_user_settings; +use crate::args::{GlobalArgs, RepoInitArgs, ServerTargetArgs}; +use crate::command_context::CommandContext; +#[expect( + clippy::disallowed_methods, + reason = "This is a shared synchronous git helper used by repo deinit; async callers should use spawn_blocking." +)] pub(super) fn git_repo_root() -> Result { let output = std::process::Command::new("git") .args(["rev-parse", "--show-toplevel"]) @@ -22,52 +27,61 @@ pub(super) fn git_repo_root() -> Result { )) } -pub(crate) async fn run_init(globals: &GlobalArgs) -> Result> { - let repo_root = git_repo_root()?; +pub(crate) async fn run_init( + args: &RepoInitArgs, + globals: &GlobalArgs, + printer: Printer, +) -> Result> { + let repo_root = spawn_blocking(git_repo_root) + .await + .context("git repo root task panicked")??; let mut created = Vec::new(); - let fabro_toml = repo_root.join("fabro.toml"); - if fabro_toml.exists() { + let fabro_dir = repo_root.join(".fabro"); + let project_toml = fabro_dir.join("project.toml"); + if project_toml.exists() { bail!( - "already initialized — fabro.toml exists at {}", - fabro_toml.display() + "already initialized — .fabro/project.toml exists at {}", + project_toml.display() ); } - // Create fabro.toml + std::fs::create_dir_all(&fabro_dir) + .with_context(|| format!("failed to create {}", fabro_dir.display()))?; + + // Create .fabro/project.toml std::fs::write( - &fabro_toml, + &project_toml, "\ # Fabro project configuration # https://docs.fabro.computer/getting-started/quick-start -version = 1 - -[fabro] -root = \"fabro/\" - -# Disable retrospective analysis after workflow runs: -# retro = false +_version = 1 # Auto-create pull requests on successful workflow runs. -[pull_request] +[run.pull_request] enabled = true draft = true # auto_merge = true ", ) - .with_context(|| format!("failed to write {}", fabro_toml.display()))?; - created.push("fabro.toml".to_string()); + .with_context(|| format!("failed to write {}", project_toml.display()))?; + created.push(".fabro/project.toml".to_string()); let green = console::Style::new().green(); let bold = console::Style::new().bold(); let dim = console::Style::new().dim(); if !globals.json { - eprintln!(" {} {}", green.apply_to("✔"), dim.apply_to("fabro.toml")); + fabro_util::printerr!( + printer, + " {} {}", + green.apply_to("✔"), + dim.apply_to(".fabro/project.toml") + ); } // Create hello workflow directory - let workflow_dir = repo_root.join("fabro/workflows/hello"); + let workflow_dir = repo_root.join(".fabro/workflows/hello"); std::fs::create_dir_all(&workflow_dir) .with_context(|| format!("failed to create {}", workflow_dir.display()))?; @@ -89,12 +103,13 @@ draft = true "#, ) .with_context(|| format!("failed to write {}", dot_path.display()))?; - created.push("fabro/workflows/hello/workflow.fabro".to_string()); + created.push(".fabro/workflows/hello/workflow.fabro".to_string()); if !globals.json { - eprintln!( + fabro_util::printerr!( + printer, " {} {}", green.apply_to("✔"), - dim.apply_to("fabro/workflows/hello/workflow.fabro") + dim.apply_to(".fabro/workflows/hello/workflow.fabro") ); } @@ -102,20 +117,22 @@ draft = true let toml_path = workflow_dir.join("workflow.toml"); std::fs::write( &toml_path, - "version = 1\ngraph = \"workflow.fabro\"\n\n[sandbox]\nprovider = \"local\"\n", + "_version = 1\n\n[workflow]\ngraph = \"workflow.fabro\"\n\n[run.sandbox]\nprovider = \"local\"\n", ) .with_context(|| format!("failed to write {}", toml_path.display()))?; - created.push("fabro/workflows/hello/workflow.toml".to_string()); + created.push(".fabro/workflows/hello/workflow.toml".to_string()); if !globals.json { - eprintln!( + fabro_util::printerr!( + printer, " {} {}", green.apply_to("✔"), - dim.apply_to("fabro/workflows/hello/workflow.toml") + dim.apply_to(".fabro/workflows/hello/workflow.toml") ); } if !globals.json { - eprintln!( + fabro_util::printerr!( + printer, "\n{} Run a workflow with:\n\n {}", bold.apply_to("Project initialized!"), console::Style::new() @@ -126,30 +143,33 @@ draft = true } if !globals.json { - check_github_app_installation().await; + check_github_app_installation(&args.target, printer).await; } Ok(created) } -async fn check_github_app_installation() { +async fn check_github_app_installation(target: &ServerTargetArgs, printer: Printer) { // Get the git remote origin URL - let output = match std::process::Command::new("git") + let output = match TokioCommand::new("git") .args(["remote", "get-url", "origin"]) .output() + .await { Ok(o) if o.status.success() => o, _ => { let yellow = console::Style::new().yellow(); let dim = console::Style::new().dim(); - eprintln!( - "\n {} No git remote found — skipping GitHub App check", + fabro_util::printerr!( + printer, + "\n {} No git remote found — skipping GitHub check", yellow.apply_to("!") ); - eprintln!( + fabro_util::printerr!( + printer, " {}", dim.apply_to( - "Run `git remote add origin ` then `fabro install` to set up the GitHub App" + "Run `git remote add origin `, then `gh auth login` or `fabro install` to configure GitHub access" ) ); return; @@ -167,158 +187,106 @@ async fn check_github_app_installation() { return; // Not a GitHub repo — skip silently }; - // Load CLI config to get app_id and slug - let Ok(cli_settings) = load_user_settings() else { - return; + let ctx = match CommandContext::for_target(target, printer) { + Ok(ctx) => ctx, + Err(err) => { + fabro_util::printerr!( + printer, + "\n Warning: could not resolve fabro server settings: {err}" + ); + return; + } }; - let app_id = if let Some(id) = cli_settings.app_id() { - id.to_string() - } else { - eprintln!( - "\n Run {} to set up the GitHub App", - console::Style::new() - .cyan() - .bold() - .apply_to("fabro install") + let server = match ctx.server().await { + Ok(server) => server, + Err(err) => { + fabro_util::printerr!( + printer, + "\n Warning: could not connect to fabro server: {err}" + ); + return; + } + }; + + let check = match server + .api() + .get_github_repo() + .owner(owner.clone()) + .name(repo.clone()) + .send() + .await + { + Ok(response) => response.into_inner(), + Err(err) => { + fabro_util::printerr!(printer, "\n Warning: could not check GitHub access: {err}"); + return; + } + }; + + if check.accessible { + let green = console::Style::new().green(); + fabro_util::printerr!( + printer, + "\n {} GitHub access is configured for {owner}/{repo}", + green.apply_to("✔") ); return; - }; + } - let slug = cli_settings.slug().map(String::from); + let yellow = console::Style::new().yellow(); + fabro_util::printerr!( + printer, + "\n {} GitHub access is not available for {owner}/{repo}", + yellow.apply_to("!") + ); + if let Some(url) = &check.install_url { + fabro_util::printerr!(printer, " Install at: {url}"); + } else { + fabro_util::printerr!( + printer, + " Run `gh auth login` or `fabro install`, then try again." + ); + } - // Build GitHub App credentials - let creds = match build_github_app_credentials(Some(&app_id)) { - Ok(Some(creds)) => creds, - Ok(None) => { - eprintln!( - "\n Set {} to enable GitHub App integration", - console::Style::new() - .cyan() - .bold() - .apply_to("GITHUB_APP_PRIVATE_KEY") - ); - return; - } - Err(err) => { - eprintln!("\n Warning: invalid GITHUB_APP_PRIVATE_KEY: {err}"); - return; - } - }; + if std::io::IsTerminal::is_terminal(&std::io::stdin()) { + fabro_util::printerr!(printer, " Press Enter to continue after installing..."); + let _ = spawn_blocking(|| { + let mut buf = String::new(); + let _ = std::io::stdin().read_line(&mut buf); + }) + .await; - let jwt = match fabro_github::sign_app_jwt(&creds.app_id, &creds.private_key_pem) { - Ok(j) => j, - Err(e) => { - eprintln!("\n Warning: failed to sign GitHub App JWT: {e}"); - return; - } - }; - - let client = reqwest::Client::new(); - - match fabro_github::check_app_installed( - &client, - &jwt, - &owner, - &repo, - &fabro_github::github_api_base_url(), - ) - .await - { - Ok(true) => { - let green = console::Style::new().green(); - eprintln!( - "\n {} GitHub App is installed for {owner}/{repo}", - green.apply_to("✔") - ); - } - Ok(false) => { - let install_url = match &slug { - Some(s) => format!("https://github.com/apps/{s}/installations/new"), - None => format!("https://github.com/organizations/{owner}/settings/installations"), - }; - - let yellow = console::Style::new().yellow(); - - // Best-effort: warn if the app is private and the repo belongs to a different owner. - if let Ok(app_info) = fabro_github::get_authenticated_app( - &client, - &jwt, - &fabro_github::github_api_base_url(), - ) + match server + .api() + .get_github_repo() + .owner(owner.clone()) + .name(repo.clone()) + .send() .await - { - let cross_owner = !app_info.owner.login.eq_ignore_ascii_case(&owner); - let is_private = cross_owner - && fabro_github::is_app_public( - &client, - &app_info.slug, - &fabro_github::github_api_base_url(), - ) - .await - == Ok(false); - - if is_private { - eprintln!( - "\n {} GitHub App \"{}\" is private but this repo belongs to a different owner ({}).", - yellow.apply_to("!"), - app_info.slug, - owner + { + Ok(response) => { + let response = response.into_inner(); + if response.accessible { + let green = console::Style::new().green(); + fabro_util::printerr!( + printer, + " {} GitHub access is configured for {owner}/{repo}", + green.apply_to("✔") ); - eprintln!( - " The app must be made public before it can be installed outside {}.", - app_info.owner.login - ); - eprintln!( - " Update visibility at: https://github.com/settings/apps/{}", - app_info.slug - ); - } - } - eprintln!( - "\n {} GitHub App is not installed for {owner}/{repo}", - yellow.apply_to("!") - ); - eprintln!(" Install at: {install_url}"); - - // Only prompt if stdin is a terminal - if std::io::IsTerminal::is_terminal(&std::io::stdin()) { - eprintln!(" Press Enter to continue after installing..."); - let _ = spawn_blocking(|| { - let mut buf = String::new(); - let _ = std::io::stdin().read_line(&mut buf); - }) - .await; - - // Re-check after user presses Enter - match fabro_github::check_app_installed( - &client, - &jwt, - &owner, - &repo, - &fabro_github::github_api_base_url(), - ) - .await - { - Ok(true) => { - let green = console::Style::new().green(); - eprintln!( - " {} GitHub App is installed for {owner}/{repo}", - green.apply_to("✔") - ); - } - Ok(false) => { - eprintln!(" GitHub App is still not installed."); - eprintln!(" Install at: {install_url}"); - } - Err(e) => { - eprintln!(" Warning: could not re-check GitHub App installation: {e}"); + } else { + fabro_util::printerr!(printer, " GitHub access is still unavailable."); + if let Some(url) = &check.install_url { + fabro_util::printerr!(printer, " Install at: {url}"); } } } - } - Err(e) => { - eprintln!("\n Warning: could not check GitHub App installation: {e}"); + Err(err) => { + fabro_util::printerr!( + printer, + " Warning: could not re-check GitHub access: {err}" + ); + } } } } diff --git a/lib/crates/fabro-cli/src/commands/repo/mod.rs b/lib/crates/fabro-cli/src/commands/repo/mod.rs index 5835208b5..29d1be6b1 100644 --- a/lib/crates/fabro-cli/src/commands/repo/mod.rs +++ b/lib/crates/fabro-cli/src/commands/repo/mod.rs @@ -2,25 +2,26 @@ pub(crate) mod deinit; pub(crate) mod init; use anyhow::Result; +use fabro_util::printer::Printer; use crate::args::{GlobalArgs, RepoCommand, RepoNamespace}; use crate::shared::print_json_pretty; -pub(crate) async fn dispatch(ns: RepoNamespace, globals: &GlobalArgs) -> Result<()> { +pub(crate) async fn dispatch( + ns: RepoNamespace, + globals: &GlobalArgs, + printer: Printer, +) -> Result<()> { match ns.command { - RepoCommand::Init { skill } => { - let created = init::run_init(globals).await?; - if skill { - let base = std::env::current_dir()?.join(".claude").join("skills"); - super::skill::install_skill_to(&base)?; - } + RepoCommand::Init(args) => { + let created = init::run_init(&args, globals, printer).await?; if globals.json { print_json_pretty(&serde_json::json!({ "created": created }))?; } Ok(()) } RepoCommand::Deinit => { - let removed = deinit::run_deinit(globals)?; + let removed = deinit::run_deinit(globals, printer)?; if globals.json { print_json_pretty(&serde_json::json!({ "removed": removed }))?; } diff --git a/lib/crates/fabro-cli/src/commands/run/attach.rs b/lib/crates/fabro-cli/src/commands/run/attach.rs index 3dadaa04c..49ab984cf 100644 --- a/lib/crates/fabro-cli/src/commands/run/attach.rs +++ b/lib/crates/fabro-cli/src/commands/run/attach.rs @@ -1,510 +1,354 @@ -use std::io::{BufRead, BufReader, IsTerminal, Write}; -use std::path::{Path, PathBuf}; +use std::io::{IsTerminal, Write}; +#[cfg(test)] +use std::path::Path; +#[cfg(test)] +use std::path::PathBuf; use std::process::ExitCode; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::time::{Duration, Instant}; +use std::time::Duration; -use anyhow::{Result, bail}; -use fabro_config::FabroSettingsExt; -use fabro_types::RunId; -use futures::StreamExt; - -use fabro_interview::{AnswerValue, ConsoleInterviewer}; -use fabro_store::{EventEnvelope, RunStore, RuntimeState}; +use anyhow::Result; +use fabro_api::types; +use fabro_interview::{AnswerValue, ConsoleInterviewer, Question, QuestionOption, QuestionType}; +use fabro_store::EventEnvelope; +use fabro_types::settings::cli::OutputVerbosity; +use fabro_types::settings::run::ApprovalMode; +use fabro_types::{EventBody, RunEvent, RunId}; +use fabro_util::json::normalize_json_value; +use fabro_util::printer::Printer; use fabro_util::terminal::Styles; use fabro_workflow::outcome::StageStatus; -use fabro_workflow::records::{Conclusion, ConclusionExt, RunRecord, RunRecordExt}; -use fabro_workflow::run_status::{RunStatus, RunStatusRecord, RunStatusRecordExt}; +use fabro_workflow::run_status::RunStatus; use tokio::signal::ctrl_c; -use tokio::time::{self, sleep}; +use tokio::time::sleep; use super::run_progress; -use crate::store; +use crate::server_client; -#[cfg(test)] -const ATTACH_STARTUP_GRACE: Duration = Duration::from_millis(200); -#[cfg(not(test))] -const ATTACH_STARTUP_GRACE: Duration = Duration::from_secs(3); const INTERVIEW_UNANSWERED_MESSAGE: &str = "Interview ended without an answer. The run is still waiting for input; reattach to answer it."; const JSON_INTERVIEW_MESSAGE: &str = "This run is waiting for human input, but --json is non-interactive. Reattach without --json to answer it."; +const ATTACH_PREMATURE_EOF_MESSAGE: &str = "Attach stream ended before terminal run event."; /// Attach to a running (or finished) workflow run, rendering progress live. /// /// Returns exit code 0 for success/partial_success, 1 otherwise. +#[cfg(test)] pub(crate) async fn attach_run( run_dir: &Path, + storage_dir: Option<&Path>, run_id: Option<&RunId>, kill_on_detach: bool, styles: &'static Styles, - engine_child: Option, json_output: bool, ) -> Result { - let run_record = RunRecord::load(run_dir).ok(); - if let (Some(storage_dir), Some(run_id)) = ( - run_record - .as_ref() - .map(|record| record.settings.storage_dir()), - run_id.or_else(|| run_record.as_ref().map(|record| &record.run_id)), - ) { - match store::open_run_reader(&storage_dir, run_id).await { - Ok(Some(run_store)) => match run_store.list_events().await { - Ok(events) => { - let event_lines = events - .iter() - .map(event_payload_line) - .collect::>>()?; - return attach_run_store( - run_dir, - run_store.as_ref(), - event_lines, - events.last().map_or(0, |event| event.seq), - kill_on_detach, - styles, - engine_child, - json_output, - ) - .await; - } - Err(err) => { - tracing::warn!( - run_id = %run_id, - error = %err, - "Failed to list events from store; falling back to filesystem attach" - ); - } - }, - Ok(None) => {} - Err(err) => { - tracing::warn!( - run_id = %run_id, - error = %err, - "Failed to open store reader; falling back to filesystem attach" - ); - } - } + let inferred_storage_dir = infer_storage_dir(run_dir); + let inferred_run_id = infer_run_id(run_dir); + let storage_dir = storage_dir.map(Path::to_path_buf).or(inferred_storage_dir); + let run_id = run_id.copied().or(inferred_run_id); + + if let (Some(storage_dir), Some(run_id)) = (storage_dir.as_deref(), run_id.as_ref()) { + let client = server_client::connect_server(storage_dir).await?; + return attach_run_with_client( + &client, + run_id, + kill_on_detach, + styles, + json_output, + Printer::Default, + ) + .await; } - attach_run_files(run_dir, kill_on_detach, styles, engine_child, json_output).await -} - -async fn attach_run_store( - run_dir: &Path, - run_store: &dyn RunStore, - existing_events: Vec, - last_seq: u32, - kill_on_detach: bool, - styles: &'static Styles, - engine_child: Option, - json_output: bool, -) -> Result { - let runtime_state = RuntimeState::new(run_dir); - let runtime_interview_paths = InterviewPaths::from_runtime_state(&runtime_state); - - let mut engine_guard = engine_child.map(EngineChildGuard::new); - - let is_tty = std::io::stderr().is_terminal(); - let verbose = RunRecord::load(run_dir) - .map(|record| record.settings.verbose_enabled()) - .unwrap_or(false); - let mut progress_ui = run_progress::ProgressUI::new(is_tty, verbose); - - // Install Ctrl+C handler - let cancelled = Arc::new(AtomicBool::new(false)); - { - let cancelled = Arc::clone(&cancelled); - tokio::spawn(async move { - let _ = ctrl_c().await; - cancelled.store(true, Ordering::Relaxed); - }); - } - - for line in &existing_events { - emit_progress_line(&mut progress_ui, line, json_output)?; - } - - let mut stream = run_store - .watch_events_from(if last_seq == 0 { 1 } else { last_seq + 1 }) - .await?; - let mut cached_pid: Option = None; - let attach_started = Instant::now(); - - loop { - if cancelled.load(Ordering::Relaxed) { - if kill_on_detach { - if let Some(guard) = engine_guard.as_mut() { - if let Some(child) = guard.inner() { - let _ = child.kill(); - } - } else { - kill_engine(run_dir); - } - // Wait briefly for a terminal status or conclusion - for _ in 0..20 { - if run_store.get_conclusion().await.ok().flatten().is_some() - || run_store - .get_status() - .await - .ok() - .flatten() - .is_some_and(|record| record.status.is_terminal()) - { - break; - } - sleep(Duration::from_millis(100)).await; - } - } else { - if let Some(guard) = engine_guard.as_mut() { - guard.defuse(); - } - eprintln!("Detached from run (engine continues in background)"); - } - break; - } - - let mut saw_event = false; - match time::timeout(Duration::from_millis(100), stream.next()).await { - Ok(Some(Ok(event))) => { - let line = event_payload_line(&event)?; - emit_progress_line(&mut progress_ui, &line, json_output)?; - saw_event = true; - } - Ok(Some(Err(err))) => return Err(err.into()), - Ok(None) | Err(_) => {} - } - - // Check for interview request - if runtime_interview_paths.request_path.exists() { - let interview_paths = &runtime_interview_paths; - if !interview_paths.response_path.exists() { - if json_output { - defuse_engine_child(&mut engine_guard); - eprintln!("{JSON_INTERVIEW_MESSAGE}"); - return Ok(ExitCode::from(1)); - } - if let Some(_claim_guard) = - InterviewClaimGuard::acquire(&interview_paths.claim_path) - { - if let Ok(request_data) = std::fs::read_to_string(&interview_paths.request_path) - { - if let Ok(question) = - serde_json::from_str::(&request_data) - { - // Hide progress bars during interview - hide_progress(&mut progress_ui, json_output); - - // Prompt user via ConsoleInterviewer - let interviewer = ConsoleInterviewer::new(styles); - let answer = - fabro_interview::Interviewer::ask(&interviewer, question).await; - - // Show progress bars again before any return path. - show_progress(&mut progress_ui, json_output); - - if answer_requires_reattach(&answer) { - if let Some(guard) = engine_guard.as_mut() { - guard.defuse(); - } - eprintln!("{INTERVIEW_UNANSWERED_MESSAGE}"); - return Ok(ExitCode::from(1)); - } - - write_interview_response_atomically( - &interview_paths.response_path, - &answer, - )?; - } - } - } - } - } - - let terminal_status = run_store - .get_status() - .await - .ok() - .flatten() - .map(|record| record.status) - .filter(|status| status.is_terminal()); - - let child_alive_via_handle = engine_guard.as_mut().and_then(|guard| { - guard.inner().map(|child| match child.try_wait() { - Ok(None) => true, // still running - Ok(Some(_)) | Err(_) => false, // exited or error - }) - }); - - if let Some(child_alive) = child_alive_via_handle { - if !child_alive && !saw_event { - break; - } - } else { - if terminal_status.is_some() && !saw_event { - break; - } - - let engine_alive = match cached_pid { - Some(pid) => process_alive(pid), - None => { - if let Some(pid) = read_launcher_pid(run_dir) { - cached_pid = Some(pid); - process_alive(pid) - } else { - attach_started.elapsed() < ATTACH_STARTUP_GRACE || last_seq > 0 - } - } - }; - if !engine_alive { - break; - } - } - } - - finish_progress(&mut progress_ui, json_output); - - Ok(determine_exit_code_with_store(run_store, run_dir).await) -} - -async fn attach_run_files( - run_dir: &Path, - kill_on_detach: bool, - styles: &'static Styles, - engine_child: Option, - json_output: bool, -) -> Result { - let progress_path = run_dir.join("progress.jsonl"); - let conclusion_path = run_dir.join("conclusion.json"); - let status_path = run_dir.join("status.json"); - let runtime_state = RuntimeState::new(run_dir); - let runtime_interview_paths = InterviewPaths::from_runtime_state(&runtime_state); - - let mut engine_guard = engine_child.map(EngineChildGuard::new); - - let is_tty = std::io::stderr().is_terminal(); - let verbose = RunRecord::load(run_dir) - .map(|record| record.settings.verbose_enabled()) - .unwrap_or(false); - let mut progress_ui = run_progress::ProgressUI::new(is_tty, verbose); - - let cancelled = Arc::new(AtomicBool::new(false)); - { - let cancelled = Arc::clone(&cancelled); - tokio::spawn(async move { - let _ = ctrl_c().await; - cancelled.store(true, Ordering::Relaxed); - }); - } - - let mut wait_count = 0; - while !progress_path.exists() { - sleep(std::time::Duration::from_millis(100)).await; - wait_count += 1; - - if let Some(record) = read_status_record(&status_path) { - if record.status.is_terminal() { - finish_progress(&mut progress_ui, json_output); - return Ok(determine_exit_code(&conclusion_path, Some(record))); - } - } - - if let Some(guard) = engine_guard.as_mut() { - if let Some(child) = guard.inner() { - if matches!(child.try_wait(), Ok(Some(_))) { - finish_progress(&mut progress_ui, json_output); - return Ok(determine_exit_code( - &conclusion_path, - read_status_record(&status_path), - )); - } - } - } - - if let Some(pid) = read_launcher_pid(run_dir) { - if !process_alive(pid) && wait_count > 5 { - finish_progress(&mut progress_ui, json_output); - return Ok(determine_exit_code( - &conclusion_path, - read_status_record(&status_path), - )); - } - } - - if wait_count > 100 { - drop(engine_guard.take()); - bail!( - "Timed out waiting for progress.jsonl to appear in {}", - run_dir.display() - ); - } - if cancelled.load(Ordering::Relaxed) { - if !kill_on_detach { - if let Some(guard) = engine_guard.as_mut() { - guard.defuse(); - } - } - return Ok(ExitCode::from(1)); - } - } - - let file = std::fs::File::open(&progress_path)?; - let mut reader = BufReader::new(file); - let mut line = String::new(); - let mut cached_pid: Option = None; - let attach_started = Instant::now(); - - loop { - if cancelled.load(Ordering::Relaxed) { - if kill_on_detach { - if let Some(guard) = engine_guard.as_mut() { - if let Some(child) = guard.inner() { - let _ = child.kill(); - } - } else { - kill_engine(run_dir); - } - for _ in 0..20 { - if conclusion_path.exists() - || read_status_record(&status_path) - .is_some_and(|record| record.status.is_terminal()) - { - break; - } - sleep(Duration::from_millis(100)).await; - } - } else { - if let Some(guard) = engine_guard.as_mut() { - guard.defuse(); - } - eprintln!("Detached from run (engine continues in background)"); - } - break; - } - - loop { - line.clear(); - let bytes_read = reader.read_line(&mut line)?; - if bytes_read == 0 { - break; - } - let trimmed = line.trim(); - if !trimmed.is_empty() { - emit_progress_line(&mut progress_ui, trimmed, json_output)?; - } - } - - if runtime_interview_paths.request_path.exists() { - let interview_paths = &runtime_interview_paths; - if !interview_paths.response_path.exists() { - if json_output { - defuse_engine_child(&mut engine_guard); - eprintln!("{JSON_INTERVIEW_MESSAGE}"); - return Ok(ExitCode::from(1)); - } - if let Some(_claim_guard) = - InterviewClaimGuard::acquire(&interview_paths.claim_path) - { - if let Ok(request_data) = std::fs::read_to_string(&interview_paths.request_path) - { - if let Ok(question) = - serde_json::from_str::(&request_data) - { - hide_progress(&mut progress_ui, json_output); - - let interviewer = ConsoleInterviewer::new(styles); - let answer = - fabro_interview::Interviewer::ask(&interviewer, question).await; - - show_progress(&mut progress_ui, json_output); - - if answer_requires_reattach(&answer) { - if let Some(guard) = engine_guard.as_mut() { - guard.defuse(); - } - eprintln!("{INTERVIEW_UNANSWERED_MESSAGE}"); - return Ok(ExitCode::from(1)); - } - - write_interview_response_atomically( - &interview_paths.response_path, - &answer, - )?; - } - } - } - } - } - - let terminal_status = read_status_record(&status_path) - .map(|record| record.status) - .filter(|status| status.is_terminal()); - - let child_alive_via_handle = engine_guard.as_mut().and_then(|guard| { - guard.inner().map(|child| match child.try_wait() { - Ok(None) => true, - Ok(Some(_)) | Err(_) => false, - }) - }); - - if let Some(child_alive) = child_alive_via_handle { - if !child_alive { - drain_remaining(&mut reader, &mut line, &mut progress_ui, json_output)?; - break; - } - } else { - if terminal_status.is_some() { - drain_remaining(&mut reader, &mut line, &mut progress_ui, json_output)?; - break; - } - - let engine_alive = match cached_pid { - Some(pid) => process_alive(pid), - None => { - if let Some(pid) = read_launcher_pid(run_dir) { - cached_pid = Some(pid); - process_alive(pid) - } else { - attach_started.elapsed() < ATTACH_STARTUP_GRACE - || !progress_file_is_empty(&progress_path) - } - } - }; - if !engine_alive { - drain_remaining(&mut reader, &mut line, &mut progress_ui, json_output)?; - break; - } - } - - sleep(Duration::from_millis(100)).await; - } - - finish_progress(&mut progress_ui, json_output); - - Ok(determine_exit_code( - &conclusion_path, - read_status_record(&status_path), + Err(anyhow::anyhow!( + "Could not infer SlateDB storage location and run id for attach" )) } -fn drain_remaining( - reader: &mut BufReader, - line: &mut String, - progress_ui: &mut run_progress::ProgressUI, +pub(crate) async fn attach_run_with_client( + client: &server_client::ServerStoreClient, + run_id: &RunId, + kill_on_detach: bool, + styles: &'static Styles, json_output: bool, -) -> Result<()> { + printer: Printer, +) -> Result { + let state = client.get_run_state(run_id).await?; + let auto_approve = state.run.as_ref().is_some_and(|record| { + fabro_config::resolve_run_from_file(&record.settings) + .map(|settings| settings.execution.approval == ApprovalMode::Auto) + .unwrap_or(false) + }); + let verbose = state.run.as_ref().is_some_and(|record| { + fabro_config::resolve_cli_from_file(&record.settings) + .map(|settings| settings.output.verbosity == OutputVerbosity::Verbose) + .unwrap_or(false) + }); + let events = client.list_run_events(run_id, None, None).await?; + let replay_events = events.clone(); + let next_seq = events.last().map_or(1, |event| event.seq.saturating_add(1)); + let initial_exit_code = events.iter().rev().find_map(event_exit_code); + let state_exit_code = state_exit_code(&state); + + if state_is_terminal(&state) || initial_exit_code.is_some() { + return replay_run_with_client( + verbose, + events, + initial_exit_code + .or(state_exit_code) + .unwrap_or(ExitCode::from(1)), + json_output, + ); + } + + let stream = client.attach_run_events(run_id, Some(next_seq)).await?; + attach_live_run_with_client( + client, + run_id, + replay_events, + stream, + styles, + AttachOptions { + auto_approve, + verbose, + kill_on_detach, + json_output, + }, + printer, + ) + .await +} + +struct AttachOptions { + auto_approve: bool, + verbose: bool, + kill_on_detach: bool, + json_output: bool, +} + +fn replay_run_with_client( + verbose: bool, + events: Vec, + exit_code: ExitCode, + json_output: bool, +) -> Result { + let is_tty = std::io::stderr().is_terminal(); + let mut progress_ui = run_progress::ProgressUI::new(is_tty, verbose); + + for event in events { + let line = event_payload_line(&event)?; + emit_progress_line(&mut progress_ui, &line, json_output)?; + } + + finish_progress(&mut progress_ui, json_output); + + Ok(exit_code) +} + +async fn attach_live_run_with_client( + client: &server_client::ServerStoreClient, + run_id: &RunId, + existing_events: Vec, + mut stream: server_client::RunAttachEventStream, + styles: &'static Styles, + opts: AttachOptions, + printer: Printer, +) -> Result { + let is_tty = std::io::stderr().is_terminal(); + let mut progress_ui = run_progress::ProgressUI::new(is_tty, opts.verbose); + let ctrl_c_signal = ctrl_c(); + tokio::pin!(ctrl_c_signal); + + for event in existing_events { + let line = event_payload_line(&event)?; + emit_progress_line(&mut progress_ui, &line, opts.json_output)?; + } + + if let Some(exit_code) = handle_pending_server_interview( + client, + run_id, + opts.auto_approve, + &mut progress_ui, + styles, + opts.json_output, + printer, + ) + .await? + { + return Ok(exit_code); + } + loop { - line.clear(); - match reader.read_line(line) { - Ok(0) | Err(_) => break, - Ok(_) => { - let trimmed = line.trim(); - if !trimmed.is_empty() { - emit_progress_line(progress_ui, trimmed, json_output)?; - } + let next_event = tokio::select! { + _ = &mut ctrl_c_signal => { + handle_detach_signal(client, run_id, opts.kill_on_detach, printer).await; + finish_progress(&mut progress_ui, opts.json_output); + return Ok(ExitCode::from(1)); + } + result = stream.next_event() => result?, + }; + + let Some(event) = next_event else { + finish_progress(&mut progress_ui, opts.json_output); + return Err(anyhow::anyhow!(ATTACH_PREMATURE_EOF_MESSAGE)); + }; + + let line = event_payload_line(&event)?; + emit_progress_line(&mut progress_ui, &line, opts.json_output)?; + + if let Some(exit_code) = event_exit_code(&event) { + finish_progress(&mut progress_ui, opts.json_output); + return Ok(exit_code); + } + + if event_starts_interview(&event) { + if let Some(exit_code) = handle_pending_server_interview( + client, + run_id, + opts.auto_approve, + &mut progress_ui, + styles, + opts.json_output, + printer, + ) + .await? + { + return Ok(exit_code); } } } - Ok(()) +} + +async fn handle_pending_server_interview( + client: &server_client::ServerStoreClient, + run_id: &RunId, + auto_approve: bool, + progress_ui: &mut run_progress::ProgressUI, + styles: &'static Styles, + json_output: bool, + printer: Printer, +) -> Result> { + let Some(question) = client.list_run_questions(run_id).await?.into_iter().next() else { + return Ok(None); + }; + + if json_pending_interview_requires_manual_input(json_output, auto_approve) { + fabro_util::printerr!(printer, "{JSON_INTERVIEW_MESSAGE}"); + return Ok(Some(ExitCode::from(1))); + } + if json_output { + return Ok(None); + } + + hide_progress(progress_ui, json_output); + let interviewer = ConsoleInterviewer::new(styles); + let answer = + fabro_interview::Interviewer::ask(&interviewer, api_question_to_question(&question)).await; + show_progress(progress_ui, json_output); + + if answer_requires_reattach(&answer) { + fabro_util::printerr!(printer, "{INTERVIEW_UNANSWERED_MESSAGE}"); + return Ok(Some(ExitCode::from(1))); + } + + submit_server_interview_answer(client, run_id, &question.id, &answer).await?; + Ok(None) +} + +async fn handle_detach_signal( + client: &server_client::ServerStoreClient, + run_id: &RunId, + kill_on_detach: bool, + printer: Printer, +) { + if kill_on_detach { + let _ = client.cancel_run(run_id).await; + for _ in 0..20 { + if client + .get_run_state(run_id) + .await + .ok() + .is_some_and(|state| state_is_terminal(&state)) + { + break; + } + sleep(Duration::from_millis(100)).await; + } + } else { + fabro_util::printerr!( + printer, + "Detached from run (engine continues in background)" + ); + } +} + +fn api_question_to_question(question: &types::ApiQuestion) -> Question { + let question_type = match question.question_type { + types::QuestionType::YesNo => QuestionType::YesNo, + types::QuestionType::MultipleChoice => QuestionType::MultipleChoice, + types::QuestionType::MultiSelect => QuestionType::MultiSelect, + types::QuestionType::Freeform => QuestionType::Freeform, + types::QuestionType::Confirmation => QuestionType::Confirmation, + }; + let mut converted = Question::new(question.text.clone(), question_type); + converted.id.clone_from(&question.id); + converted.options = question + .options + .iter() + .map(|option| QuestionOption { + key: option.key.clone(), + label: option.label.clone(), + }) + .collect(); + converted.allow_freeform = question.allow_freeform; + converted.stage.clone_from(&question.stage); + converted.timeout_seconds = question.timeout_seconds; + converted + .context_display + .clone_from(&question.context_display); + converted +} + +async fn submit_server_interview_answer( + client: &server_client::ServerStoreClient, + run_id: &RunId, + qid: &str, + answer: &fabro_interview::Answer, +) -> Result { + let (value, selected_option_key, selected_option_keys) = match &answer.value { + AnswerValue::Text(text) => (Some(text.clone()), None, Vec::new()), + AnswerValue::Selected(key) => (None, Some(key.clone()), Vec::new()), + AnswerValue::MultiSelected(keys) => (None, None, keys.clone()), + AnswerValue::Yes => (Some("yes".to_string()), None, Vec::new()), + AnswerValue::No => (Some("no".to_string()), None, Vec::new()), + AnswerValue::Cancelled + | AnswerValue::Interrupted + | AnswerValue::Skipped + | AnswerValue::Timeout => { + return Ok(false); + } + }; + client + .submit_run_answer( + run_id, + qid, + value, + selected_option_key, + selected_option_keys, + ) + .await?; + Ok(true) +} + +fn json_pending_interview_requires_manual_input(json_output: bool, auto_approve: bool) -> bool { + json_output && !auto_approve +} + +fn state_is_terminal(state: &server_client::RunProjection) -> bool { + state.conclusion.is_some() + || state + .status + .as_ref() + .is_some_and(|record| record.status.is_terminal()) } fn emit_progress_line( @@ -541,399 +385,241 @@ fn show_progress(progress_ui: &mut run_progress::ProgressUI, json_output: bool) } fn event_payload_line(event: &EventEnvelope) -> Result { - serde_json::to_string(event.payload.as_value()).map_err(Into::into) + let mut value = normalize_json_value(event.payload.as_value().clone()); + restore_empty_run_properties(&mut value); + serde_json::to_string(&value).map_err(Into::into) } -fn read_status_record(path: &Path) -> Option { - RunStatusRecord::load(path).ok() -} - -fn read_launcher_pid(run_dir: &Path) -> Option { - super::launcher::active_launcher_record_for_run(run_dir).map(|record| record.pid) -} - -fn progress_file_is_empty(path: &Path) -> bool { - std::fs::metadata(path) - .map(|meta| meta.len() == 0) - .unwrap_or(true) -} - -#[allow(clippy::struct_field_names)] -#[derive(Debug, Clone, PartialEq, Eq)] -struct InterviewPaths { - claim_path: PathBuf, - request_path: PathBuf, - response_path: PathBuf, -} - -impl InterviewPaths { - fn from_runtime_state(runtime_state: &RuntimeState) -> Self { - Self { - claim_path: runtime_state.interview_claim_path(), - request_path: runtime_state.interview_request_path(), - response_path: runtime_state.interview_response_path(), +fn restore_empty_run_properties(value: &mut serde_json::Value) { + let Some(object) = value.as_object_mut() else { + return; + }; + let Some(event_name) = object.get("event").and_then(serde_json::Value::as_str) else { + return; + }; + if matches!(event_name, "run.submitted" | "run.running") && !object.contains_key("properties") { + let run_id = object.remove("run_id"); + let ts = object.remove("ts"); + object.insert("properties".to_string(), serde_json::json!({})); + if let Some(run_id) = run_id { + object.insert("run_id".to_string(), run_id); } - } -} - -struct InterviewClaimGuard { - claim_path: PathBuf, -} - -impl InterviewClaimGuard { - fn acquire(claim_path: &Path) -> Option { - if try_claim_interview_request(claim_path) { - Some(Self { - claim_path: claim_path.to_path_buf(), - }) - } else { - None + if let Some(ts) = ts { + object.insert("ts".to_string(), ts); } } } -impl Drop for InterviewClaimGuard { - fn drop(&mut self) { - let _ = std::fs::remove_file(&self.claim_path); - } -} - -struct EngineChildGuard { - child: Option, -} - -impl EngineChildGuard { - fn new(child: std::process::Child) -> Self { - Self { child: Some(child) } - } - - fn inner(&mut self) -> Option<&mut std::process::Child> { - self.child.as_mut() - } - - fn defuse(&mut self) { - self.child.take(); - } -} - -fn defuse_engine_child(engine_guard: &mut Option) { - if let Some(guard) = engine_guard.as_mut() { - guard.defuse(); - } -} - -impl Drop for EngineChildGuard { - fn drop(&mut self) { - if let Some(mut child) = self.child.take() { - let _ = child.kill(); - let _ = child.wait(); - } - } -} - -fn try_claim_interview_request(claim_path: &Path) -> bool { - if let Some(parent) = claim_path.parent() { - if std::fs::create_dir_all(parent).is_err() { - return false; - } - } - - if let Ok(existing) = std::fs::read_to_string(claim_path) { - if let Ok(pid) = existing.trim().parse::() { - if process_alive(pid) { - return pid == std::process::id(); - } - } - let _ = std::fs::remove_file(claim_path); - } - - match std::fs::OpenOptions::new() - .create_new(true) - .write(true) - .open(claim_path) - { - Ok(mut file) => { - let _ = writeln!(file, "{}", std::process::id()); - true - } - Err(_) => false, - } -} - -fn answer_requires_reattach(answer: &fabro_interview::Answer) -> bool { - matches!(answer.value, AnswerValue::Aborted | AnswerValue::Skipped) -} - -fn write_interview_response_atomically( - response_path: &Path, - answer: &fabro_interview::Answer, -) -> Result<()> { - let response_json = serde_json::to_string_pretty(answer)?; - if let Some(parent) = response_path.parent() { - std::fs::create_dir_all(parent)?; - } - let temp_path = response_path.with_extension("json.tmp"); - std::fs::write(&temp_path, response_json)?; - std::fs::rename(temp_path, response_path)?; - Ok(()) -} - -fn determine_exit_code(conclusion_path: &Path, status_record: Option) -> ExitCode { - if conclusion_path.exists() { - if let Ok(conclusion) = Conclusion::load(conclusion_path) { - let success = matches!( - conclusion.status, - StageStatus::Success | StageStatus::PartialSuccess - ); - return if success { - ExitCode::from(0) - } else { - ExitCode::from(1) - }; - } - } - - match status_record.map(|record| record.status) { - Some(RunStatus::Succeeded) => ExitCode::from(0), - Some(_) | None => ExitCode::from(1), - } -} - -async fn determine_exit_code_with_store(run_store: &dyn RunStore, run_dir: &Path) -> ExitCode { - if let Ok(Some(conclusion)) = run_store.get_conclusion().await { - let success = matches!( - conclusion.status, - StageStatus::Success | StageStatus::PartialSuccess - ); - if success { - ExitCode::from(0) - } else { - ExitCode::from(1) - } - } else { - let status_path = run_dir.join("status.json"); - let conclusion_path = run_dir.join("conclusion.json"); - let status_record = match run_store.get_status().await { - Ok(record) => record.or_else(|| read_status_record(&status_path)), - Err(_) => read_status_record(&status_path), - }; - determine_exit_code(&conclusion_path, status_record) - } -} - -#[allow(unsafe_code)] -fn kill_engine(run_dir: &Path) { - if let Some(pid) = read_launcher_pid(run_dir).map(|pid| i32::try_from(pid).unwrap()) { - #[cfg(unix)] - unsafe { - libc::kill(pid, libc::SIGTERM); - } - let _ = pid; - } -} - -#[allow(unsafe_code)] -fn process_alive(pid: u32) -> bool { - #[cfg(unix)] - { - unsafe { libc::kill(i32::try_from(pid).unwrap(), 0) == 0 } - } - #[cfg(not(unix))] - { - let _ = pid; - true - } -} - +#[cfg(test)] +fn infer_storage_dir(run_dir: &Path) -> Option { + let scratch_dir = run_dir.parent()?; + let storage_dir = scratch_dir.parent()?; + (scratch_dir.file_name()? == "scratch").then(|| storage_dir.to_path_buf()) +} + +#[cfg(test)] +fn infer_run_id(run_dir: &Path) -> Option { + run_dir + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .and_then(|name| name.rsplit('-').next().map(ToOwned::to_owned)) + .filter(|run_id| !run_id.is_empty()) + .and_then(|run_id| run_id.parse().ok()) +} + +fn answer_requires_reattach(answer: &fabro_interview::Answer) -> bool { + matches!( + answer.value, + AnswerValue::Interrupted | AnswerValue::Skipped + ) +} + +fn state_exit_code(state: &server_client::RunProjection) -> Option { + if let Some(conclusion) = &state.conclusion { + let success = matches!( + conclusion.status, + StageStatus::Success | StageStatus::PartialSuccess + ); + return Some(if success { + ExitCode::from(0) + } else { + ExitCode::from(1) + }); + } + + match state.status.as_ref() { + Some(record) if record.status == RunStatus::Succeeded => Some(ExitCode::from(0)), + Some(record) if record.status.is_terminal() => Some(ExitCode::from(1)), + Some(_) | None => None, + } +} + +fn event_exit_code(event: &EventEnvelope) -> Option { + let run_event = RunEvent::try_from(&event.payload).ok()?; + match run_event.body { + EventBody::RunCompleted(props) => Some( + if props.status == "success" || props.status == "partial_success" { + ExitCode::from(0) + } else { + ExitCode::from(1) + }, + ), + EventBody::RunFailed(_) => Some(ExitCode::from(1)), + _ => None, + } +} + +fn event_starts_interview(event: &EventEnvelope) -> bool { + let Ok(run_event) = RunEvent::try_from(&event.payload) else { + return false; + }; + matches!(run_event.body, EventBody::InterviewStarted(_)) +} + #[cfg(test)] mod tests { - use super::*; - use chrono::Utc; + #![allow(clippy::absolute_paths)] + use fabro_interview::{Answer, AnswerValue}; use fabro_util::terminal::Styles; - use fabro_workflow::outcome::StageStatus; - use fabro_workflow::records::Conclusion; - use fabro_workflow::run_status::{StatusReason, write_run_status}; + use httpmock::MockServer; + + use super::*; fn no_color_styles() -> &'static Styles { Box::leak(Box::new(Styles::new(false))) } - fn sample_conclusion(status: StageStatus) -> Conclusion { - Conclusion { - timestamp: Utc::now(), - status, - duration_ms: 0, - failure_reason: None, - final_git_commit_sha: None, - stages: Vec::new(), - total_cost: None, - total_retries: 0, - total_input_tokens: 0, - total_output_tokens: 0, - total_cache_read_tokens: 0, - total_cache_write_tokens: 0, - total_reasoning_tokens: 0, - has_pricing: false, - } + fn terminal_run_state_response() -> serde_json::Value { + serde_json::json!({ + "run": null, + "graph_source": null, + "start": null, + "status": { + "status": "failed", + "reason": "cancelled", + "updated_at": "2026-04-05T12:00:02Z" + }, + "checkpoint": null, + "checkpoints": [], + "conclusion": null, + "retro": null, + "retro_prompt": null, + "retro_response": null, + "sandbox": null, + "final_patch": null, + "pull_request": null, + "nodes": {} + }) + } + + fn cancel_run_response(run_id: RunId) -> serde_json::Value { + serde_json::json!({ + "id": run_id, + "status": "cancelled", + "error": null, + "queue_position": null, + "status_reason": "cancelled", + "pending_control": "cancel", + "created_at": "2026-04-05T12:00:00Z" + }) } #[tokio::test] - async fn attach_does_not_return_when_only_conclusion_exists() { + async fn attach_errors_without_store_context() { let dir = tempfile::tempdir().unwrap(); - std::fs::write(dir.path().join("progress.jsonl"), "").unwrap(); - sample_conclusion(StageStatus::Success) - .save(&dir.path().join("conclusion.json")) - .unwrap(); - let child = std::process::Command::new("sh") - .args(["-c", "sleep 0.35"]) - .spawn() - .unwrap(); - let started = Instant::now(); - - let exit = attach_run( - dir.path(), - None, - false, - no_color_styles(), - Some(child), - false, - ) - .await - .unwrap(); - - assert_eq!(exit, ExitCode::from(0)); - assert!( - started.elapsed() >= Duration::from_millis(250), - "attach returned before the owned child exited" - ); - } - - #[tokio::test] - async fn attach_missing_pid_and_failed_status_is_not_alive() { - let dir = tempfile::tempdir().unwrap(); - std::fs::write(dir.path().join("progress.jsonl"), "").unwrap(); - write_run_status( - dir.path(), - RunStatus::Failed, - Some(StatusReason::LaunchFailed), - ); - - let exit = attach_run(dir.path(), None, false, no_color_styles(), None, false) + let err = attach_run(dir.path(), None, None, false, no_color_styles(), false) .await - .unwrap(); + .unwrap_err(); - assert_eq!(exit, ExitCode::from(1)); - } - - #[test] - fn try_claim_interview_request_reclaims_stale_claim() { - let dir = tempfile::tempdir().unwrap(); - let claim_path = dir.path().join("runtime").join("interview_request.claim"); - std::fs::create_dir_all(claim_path.parent().unwrap()).unwrap(); - std::fs::write(&claim_path, "999999\n").unwrap(); - - assert!(try_claim_interview_request(&claim_path)); - assert_eq!( - std::fs::read_to_string(claim_path).unwrap(), - format!("{}\n", std::process::id()) + assert!( + err.to_string() + .contains("Could not infer SlateDB storage location and run id for attach") ); } #[test] - fn interview_claim_guard_releases_claim_on_drop() { + fn infer_storage_dir_detects_standard_run_layout() { let dir = tempfile::tempdir().unwrap(); - let claim_path = dir.path().join("runtime").join("interview_request.claim"); + let run_dir = dir + .path() + .join("storage") + .join("scratch") + .join("20260401-test"); + std::fs::create_dir_all(&run_dir).unwrap(); - { - let _guard = InterviewClaimGuard::acquire(&claim_path).unwrap(); - assert!(claim_path.exists()); - } - - assert!(!claim_path.exists()); + assert_eq!( + infer_storage_dir(&run_dir), + Some(dir.path().join("storage")) + ); } #[test] - fn answer_requires_reattach_for_aborted_and_skipped_answers() { - let aborted = Answer { - value: AnswerValue::Aborted, + fn infer_run_id_reads_run_dir_suffix() { + let dir = tempfile::tempdir().unwrap(); + let storage_dir = dir.path().join("storage"); + let run_id = fabro_types::fixtures::RUN_1; + let run_dir = storage_dir + .join("scratch") + .join(format!("20260401-{run_id}")); + std::fs::create_dir_all(&run_dir).unwrap(); + + assert_eq!(infer_run_id(&run_dir), Some(run_id)); + } + + #[test] + fn answer_requires_reattach_for_interrupted_and_skipped_answers() { + let interrupted = Answer { + value: AnswerValue::Interrupted, selected_option: None, - text: None, + text: None, }; let skipped = Answer { - value: AnswerValue::Skipped, + value: AnswerValue::Skipped, selected_option: None, - text: None, + text: None, }; let answered = Answer::yes(); - assert!(answer_requires_reattach(&aborted)); + assert!(answer_requires_reattach(&interrupted)); assert!(answer_requires_reattach(&skipped)); assert!(!answer_requires_reattach(&answered)); } #[test] - fn engine_child_guard_kills_on_drop() { - let child = std::process::Command::new("sleep") - .arg("60") - .spawn() - .unwrap(); - let pid = child.id(); - - { - let _guard = EngineChildGuard::new(child); - } - - // Process should be dead after guard is dropped - assert!( - !process_alive(pid), - "process should be dead after guard drop" - ); + fn json_pending_interview_requires_manual_input_when_auto_approve_is_disabled() { + assert!(json_pending_interview_requires_manual_input(true, false)); } #[test] - #[allow(unsafe_code)] - fn engine_child_guard_defuse_keeps_alive() { - let child = std::process::Command::new("sleep") - .arg("60") - .spawn() - .unwrap(); - let pid = child.id(); - - { - let mut guard = EngineChildGuard::new(child); - guard.defuse(); - } - - // Process should still be alive after defused guard is dropped - assert!( - process_alive(pid), - "process should still be alive after defused guard drop" - ); - - // Clean up - #[cfg(unix)] - unsafe { - libc::kill(i32::try_from(pid).unwrap(), libc::SIGKILL); - } + fn json_pending_interview_does_not_require_manual_input_when_auto_approve_is_enabled() { + assert!(!json_pending_interview_requires_manual_input(true, true)); } - #[test] - fn write_interview_response_atomically_persists_answer() { - let dir = tempfile::tempdir().unwrap(); - let response_path = dir.path().join("interview_response.json"); - let answer = Answer { - value: AnswerValue::Text("ship it".to_string()), - selected_option: None, - text: Some("ship it".to_string()), - }; + #[tokio::test] + async fn handle_detach_signal_with_kill_on_detach_cancels_active_run_via_server() { + let run_id = fabro_types::fixtures::RUN_1; + let server = MockServer::start(); + let cancel_mock = server.mock(|when, then| { + when.method("POST") + .path(format!("/api/v1/runs/{run_id}/cancel")); + then.status(200) + .header("Content-Type", "application/json") + .body(cancel_run_response(run_id).to_string()); + }); + let state_mock = server.mock(|when, then| { + when.method("GET") + .path(format!("/api/v1/runs/{run_id}/state")); + then.status(200) + .header("Content-Type", "application/json") + .body(terminal_run_state_response().to_string()); + }); + let client = server_client::ServerStoreClient::new_no_proxy(&server.base_url()).unwrap(); - write_interview_response_atomically(&response_path, &answer).unwrap(); + handle_detach_signal(&client, &run_id, true, Printer::Default).await; - let saved: Answer = - serde_json::from_str(&std::fs::read_to_string(&response_path).unwrap()).unwrap(); - assert_eq!(saved.text.as_deref(), Some("ship it")); - assert!(!response_path.with_extension("json.tmp").exists()); + cancel_mock.assert(); + state_mock.assert(); } } diff --git a/lib/crates/fabro-cli/src/commands/run/command.rs b/lib/crates/fabro-cli/src/commands/run/command.rs index 2936d2a31..8874a34e0 100644 --- a/lib/crates/fabro-cli/src/commands/run/command.rs +++ b/lib/crates/fabro-cli/src/commands/run/command.rs @@ -1,19 +1,29 @@ use anyhow::Result; +use fabro_types::settings::cli::OutputVerbosity; +use fabro_util::printer::Printer; use fabro_util::terminal::Styles; use crate::args::{GlobalArgs, RunArgs}; +use crate::command_context::CommandContext; use crate::shared::print_json_pretty; -use crate::user_config::{self, user_layer_with_globals}; +use crate::user_config::settings_layer_with_storage_dir; -pub(crate) async fn execute(mut args: RunArgs, globals: &GlobalArgs) -> Result<()> { +pub(crate) async fn execute( + mut args: RunArgs, + globals: &GlobalArgs, + printer: Printer, +) -> Result<()> { let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); - let cli_settings = user_config::load_user_settings_with_globals(globals)?; - let cli = user_layer_with_globals(globals)?; - args.verbose = args.verbose || cli_settings.verbose_enabled(); + let ctx = CommandContext::for_target(&args.target, printer)?; + let cli = settings_layer_with_storage_dir(None)?; + args.verbose = args.verbose || ctx.cli_settings().output.verbosity == OutputVerbosity::Verbose; let quiet = args.detach; - let prevent_idle_sleep = cli_settings.prevent_idle_sleep_enabled(); - let (run_id, run_dir) = super::create::create_run(&args, cli, styles, quiet)?; + let prevent_idle_sleep = ctx.cli_settings().exec.prevent_idle_sleep; + let created_run = Box::pin(super::create::create_run( + &ctx, &args, cli, styles, quiet, printer, + )) + .await?; #[cfg(feature = "sleep_inhibitor")] let _sleep_guard = crate::sleep_inhibitor::guard(prevent_idle_sleep); @@ -21,26 +31,34 @@ pub(crate) async fn execute(mut args: RunArgs, globals: &GlobalArgs) -> Result<( #[cfg(not(feature = "sleep_inhibitor"))] let _ = prevent_idle_sleep; - let child = super::start::start_run(&run_dir, false)?; + let client = ctx.server().await?; + super::start::start_run_with_client(&client, &created_run.run_id, false).await?; if args.detach { if globals.json { - print_json_pretty(&serde_json::json!({ "run_id": run_id }))?; + print_json_pretty(&serde_json::json!({ "run_id": created_run.run_id }))?; } else { - println!("{run_id}"); + fabro_util::printout!(printer, "{}", created_run.run_id); } } else { - let exit_code = super::attach::attach_run( - &run_dir, - Some(&run_id), + let exit_code = super::attach::attach_run_with_client( + &client, + &created_run.run_id, true, styles, - Some(child), globals.json, + printer, ) .await?; if !globals.json { - super::output::print_run_summary(&run_dir, run_id, styles); + super::output::print_run_summary_with_client( + &client, + &created_run.run_id, + created_run.local_run_dir.as_deref(), + styles, + printer, + ) + .await?; } if exit_code != std::process::ExitCode::SUCCESS { std::process::exit(1); diff --git a/lib/crates/fabro-cli/src/commands/run/cp.rs b/lib/crates/fabro-cli/src/commands/run/cp.rs index 30c3a76d1..6dbd1f9a1 100644 --- a/lib/crates/fabro-cli/src/commands/run/cp.rs +++ b/lib/crates/fabro-cli/src/commands/run/cp.rs @@ -1,35 +1,32 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; -use fabro_agent::sandbox::Sandbox; -use fabro_config::FabroSettingsExt; -use fabro_sandbox::SandboxRecordExt; -use fabro_sandbox::reconnect::reconnect; -use fabro_workflow::run_lookup::{resolve_run, runs_base}; +use fabro_util::printer::Printer; use tokio::fs; use tracing::{debug, info}; -use crate::args::{CpArgs, GlobalArgs}; +use crate::args::{CpArgs, GlobalArgs, ServerTargetArgs}; +use crate::command_context::CommandContext; +use crate::server_client::ServerStoreClient; +use crate::server_runs::ServerSummaryLookup; use crate::shared::{print_json_pretty, split_run_path}; -use crate::user_config::load_user_settings_with_globals; +#[derive(Debug)] enum CopyDirection { Download { - run_prefix: String, + run_prefix: String, remote_path: String, - local_path: PathBuf, + local_path: PathBuf, }, Upload { - local_path: PathBuf, - run_prefix: String, + local_path: PathBuf, + run_prefix: String, remote_path: String, }, } -pub(crate) async fn cp_command(args: CpArgs, globals: &GlobalArgs) -> Result<()> { +pub(crate) async fn cp_command(args: CpArgs, globals: &GlobalArgs, printer: Printer) -> Result<()> { let direction = parse_direction(&args.src, &args.dst)?; - let cli_settings = load_user_settings_with_globals(globals)?; - let base = runs_base(&cli_settings.storage_dir()); match direction { CopyDirection::Download { @@ -37,16 +34,14 @@ pub(crate) async fn cp_command(args: CpArgs, globals: &GlobalArgs) -> Result<()> remote_path, local_path, } => { - let sandbox = load_sandbox(&base, &run_prefix).await?; + let (client, run_id) = + resolve_client_and_run_id(&args.server, &run_prefix, printer).await?; let file_count = if args.recursive { - Some(download_recursive(&*sandbox, &remote_path, &local_path).await?) + Some(download_recursive(&client, &run_id, &remote_path, &local_path).await?) } else { debug!(path = %remote_path, "Downloading file from sandbox"); - sandbox - .download_file_to_local(&remote_path, &local_path) - .await - .map_err(|err| anyhow::anyhow!("{err}"))?; + write_sandbox_file(&client, &run_id, &remote_path, &local_path).await?; None }; @@ -70,16 +65,14 @@ pub(crate) async fn cp_command(args: CpArgs, globals: &GlobalArgs) -> Result<()> run_prefix, remote_path, } => { - let sandbox = load_sandbox(&base, &run_prefix).await?; + let (client, run_id) = + resolve_client_and_run_id(&args.server, &run_prefix, printer).await?; let file_count = if args.recursive { - Some(upload_recursive(&*sandbox, &local_path, &remote_path).await?) + Some(upload_recursive(&client, &run_id, &local_path, &remote_path).await?) } else { debug!(path = %remote_path, "Uploading file to sandbox"); - sandbox - .upload_file_from_local(&local_path, &remote_path) - .await - .map_err(|err| anyhow::anyhow!("{err}"))?; + upload_sandbox_file(&client, &run_id, &local_path, &remote_path).await?; None }; @@ -108,13 +101,13 @@ fn parse_direction(src: &str, dst: &str) -> Result { match (src_parts, dst_parts) { (Some((run_prefix, remote_path)), None) => Ok(CopyDirection::Download { - run_prefix: run_prefix.to_string(), + run_prefix: run_prefix.to_string(), remote_path: remote_path.to_string(), - local_path: PathBuf::from(dst), + local_path: PathBuf::from(dst), }), (None, Some((run_prefix, remote_path))) => Ok(CopyDirection::Upload { - local_path: PathBuf::from(src), - run_prefix: run_prefix.to_string(), + local_path: PathBuf::from(src), + run_prefix: run_prefix.to_string(), remote_path: remote_path.to_string(), }), (Some(_), Some(_)) => { @@ -124,27 +117,56 @@ fn parse_direction(src: &str, dst: &str) -> Result { } } -async fn load_sandbox(base: &Path, run_prefix: &str) -> Result> { - let run_dir = resolve_run(base, run_prefix)?.path; - let sandbox_json = run_dir.join("sandbox.json"); - debug!(path = %sandbox_json.display(), "Loading sandbox record"); - let record = fabro_sandbox::SandboxRecord::load(&sandbox_json).context( - "Failed to load sandbox.json — was this run started with a recent version of arc?", - )?; +async fn resolve_client_and_run_id( + server: &ServerTargetArgs, + run_prefix: &str, + printer: Printer, +) -> Result<(ServerStoreClient, fabro_types::RunId)> { + let ctx = CommandContext::for_target(server, printer)?; + let lookup = ServerSummaryLookup::from_client(ctx.server().await?).await?; + let run = lookup.resolve(run_prefix)?; + Ok((lookup.client().clone_for_reuse(), run.run_id())) +} - info!(run_id = %run_prefix, provider = %record.provider, "Connecting to sandbox"); - reconnect(&record).await +async fn write_sandbox_file( + client: &ServerStoreClient, + run_id: &fabro_types::RunId, + remote_path: &str, + local_path: &Path, +) -> Result<()> { + if let Some(parent) = local_path.parent() { + fs::create_dir_all(parent) + .await + .with_context(|| format!("Failed to create directory {}", parent.display()))?; + } + let bytes = client.get_sandbox_file(run_id, remote_path).await?; + fs::write(local_path, bytes) + .await + .with_context(|| format!("Failed to write {}", local_path.display()))?; + Ok(()) +} + +async fn upload_sandbox_file( + client: &ServerStoreClient, + run_id: &fabro_types::RunId, + local_path: &Path, + remote_path: &str, +) -> Result<()> { + let bytes = fs::read(local_path) + .await + .with_context(|| format!("Failed to read {}", local_path.display()))?; + client.put_sandbox_file(run_id, remote_path, bytes).await } async fn download_recursive( - sandbox: &dyn Sandbox, + client: &ServerStoreClient, + run_id: &fabro_types::RunId, remote_path: &str, local_path: &Path, ) -> Result { - let entries = sandbox - .list_directory(remote_path, Some(100)) - .await - .map_err(|err| anyhow::anyhow!("Failed to list directory {remote_path}: {err}"))?; + let entries = client + .list_sandbox_files(run_id, remote_path, Some(100)) + .await?; let mut file_count = 0usize; for entry in &entries { @@ -153,16 +175,8 @@ async fn download_recursive( } let remote_file = format!("{remote_path}/{}", entry.name); let local_file = local_path.join(&entry.name); - if let Some(parent) = local_file.parent() { - fs::create_dir_all(parent) - .await - .with_context(|| format!("Failed to create directory {}", parent.display()))?; - } debug!(path = %remote_file, "Downloading file from sandbox"); - sandbox - .download_file_to_local(&remote_file, &local_file) - .await - .map_err(|err| anyhow::anyhow!("{err}"))?; + write_sandbox_file(client, run_id, &remote_file, &local_file).await?; file_count += 1; } debug!(count = file_count, "Recursive download complete"); @@ -170,7 +184,8 @@ async fn download_recursive( } async fn upload_recursive( - sandbox: &dyn Sandbox, + client: &ServerStoreClient, + run_id: &fabro_types::RunId, local_path: &Path, remote_path: &str, ) -> Result { @@ -191,10 +206,7 @@ async fn upload_recursive( stack.push((entry_path, remote_file)); } else { debug!(path = %remote_file, "Uploading file to sandbox"); - sandbox - .upload_file_from_local(&entry_path, &remote_file) - .await - .map_err(|err| anyhow::anyhow!("{err}"))?; + upload_sandbox_file(client, run_id, &entry_path, &remote_file).await?; file_count += 1; } } @@ -242,9 +254,11 @@ mod tests { } #[test] - fn split_run_path_ignores_local_paths() { - assert_eq!(split_run_path("/tmp/file"), None); - assert_eq!(split_run_path("./file"), None); - assert_eq!(split_run_path("../file"), None); + fn parse_direction_rejects_sandbox_to_sandbox_copy() { + let err = parse_direction("abc123:/in.txt", "def456:/out.txt").unwrap_err(); + assert!( + err.to_string() + .contains("Cannot copy between two sandboxes") + ); } } diff --git a/lib/crates/fabro-cli/src/commands/run/create.rs b/lib/crates/fabro-cli/src/commands/run/create.rs index d83c17940..8019aa27c 100644 --- a/lib/crates/fabro-cli/src/commands/run/create.rs +++ b/lib/crates/fabro-cli/src/commands/run/create.rs @@ -1,34 +1,43 @@ use std::path::PathBuf; -use crate::args::RunArgs; -use fabro_config::{ConfigLayer, FabroSettings}; +use fabro_config::Storage; +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 fabro_workflow::error::FabroError; -use fabro_workflow::operations::{CreateRunInput, WorkflowInput, create}; -use super::output::{print_diagnostics_from_error, print_workflow_report_from_persisted}; +use super::output::{api_diagnostics_to_local, print_preflight_workflow_summary}; +use super::overrides::run_args_layer; +use crate::args::RunArgs; +use crate::command_context::CommandContext; +use crate::manifest_builder::{ManifestBuildInput, build_run_manifest, run_manifest_args}; +use crate::user_config::{self, ServerTarget}; -/// Create a workflow run: allocate run directory, persist RunRecord, return (run_id, run_dir). +pub(crate) struct CreatedRun { + pub(crate) run_id: RunId, + pub(crate) local_run_dir: Option, +} + +/// Create a workflow run: allocate run directory, persist RunRecord, return +/// (run_id, run_dir). /// /// This does NOT execute the workflow — it only prepares the run directory. -pub(crate) fn create_run( +pub(crate) async fn create_run( + ctx: &CommandContext, args: &RunArgs, - cli_defaults: ConfigLayer, + _cli_defaults: SettingsLayer, styles: &Styles, quiet: bool, -) -> anyhow::Result<(RunId, PathBuf)> { + printer: Printer, +) -> anyhow::Result { let workflow_path = args .workflow .as_ref() .ok_or_else(|| anyhow::anyhow!("--workflow is required"))?; - let cli_args_config = ConfigLayer::try_from(args)?; - let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - let settings: FabroSettings = cli_args_config - .combine(ConfigLayer::for_workflow(workflow_path, &cwd)?) - .combine(cli_defaults) - .resolve()?; - + let cli_args_config = run_args_layer(args)?; + let cwd = ctx.cwd().to_path_buf(); let run_id = args .run_id .as_deref() @@ -36,33 +45,46 @@ pub(crate) fn create_run( .transpose() .map_err(|err| anyhow::anyhow!("invalid run ID: {err}"))?; - let created = match create(CreateRunInput { - workflow: WorkflowInput::Path(workflow_path.clone()), - settings, + let built = build_run_manifest(ManifestBuildInput { + workflow: workflow_path.clone(), cwd, - workflow_slug: None, - run_dir: None, + args_layer: cli_args_config, + args: run_manifest_args(args), run_id, - base_branch: None, - host_repo_path: None, - }) { - Ok(created) => created, - Err(FabroError::ValidationFailed { diagnostics }) => { - if !quiet { - print_diagnostics_from_error(&diagnostics, styles); - } - anyhow::bail!("Validation failed"); - } - Err(err) => return Err(err.into()), - }; - + user_layer: load_settings_user()?, + user_settings_path: Some(active_settings_path(None)), + })?; + let target = user_config::resolve_server_target(&args.target, ctx.machine_settings())?; + let client = ctx.server().await?; if !quiet { - print_workflow_report_from_persisted( - &created.persisted, - created.dot_path.as_deref(), - styles, - ); + let preflight = client.run_preflight(built.manifest.clone()).await?; + let diagnostics = api_diagnostics_to_local(&preflight.workflow.diagnostics); + if !diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == fabro_validate::Severity::Error) + { + print_preflight_workflow_summary( + &preflight.workflow, + Some(&built.target_path), + styles, + printer, + ); + } } - Ok((created.run_id, created.run_dir)) + let created_run_id = client.create_run_from_manifest(built.manifest).await?; + let local_run_dir = match &target { + ServerTarget::UnixSocket(_) => Some( + Storage::new(user_config::storage_dir(ctx.machine_settings())?) + .run_scratch(&created_run_id) + .root() + .to_path_buf(), + ), + ServerTarget::HttpUrl { .. } => None, + }; + + Ok(CreatedRun { + run_id: created_run_id, + local_run_dir, + }) } diff --git a/lib/crates/fabro-cli/src/commands/run/detached.rs b/lib/crates/fabro-cli/src/commands/run/detached.rs deleted file mode 100644 index 56ea45633..000000000 --- a/lib/crates/fabro-cli/src/commands/run/detached.rs +++ /dev/null @@ -1,59 +0,0 @@ -use std::path::PathBuf; -use std::sync::Arc; - -use anyhow::Result; -use fabro_config::FabroSettingsExt; -use fabro_interview::FileInterviewer; -use fabro_store::RuntimeState; -use fabro_workflow::event::EventEmitter; -use fabro_workflow::operations::{ - StartServices, open_or_hydrate_run, resume as resume_run, start as start_run, -}; -use fabro_workflow::records::{RunRecord, RunRecordExt}; - -use crate::shared; -use crate::store; -pub(crate) async fn execute(run_dir: PathBuf, launcher_path: PathBuf, resume: bool) -> Result<()> { - let _ = fabro_proctitle::init(); - - let _launcher_guard = scopeguard::guard(launcher_path.clone(), |path| { - super::launcher::remove_launcher_record(&path); - }); - - let run_record = RunRecord::load(&run_dir)?; - let on_node: fabro_workflow::OnNodeCallback = Some({ - let run_id = run_record.run_id.to_string(); - let short_id = super::short_run_id(&run_id).to_string(); - fabro_proctitle::set(&format!("fabro: {short_id}")); - Arc::new(move |node_id: &str| { - fabro_proctitle::set(&format!("fabro: {short_id} {node_id}")); - }) as Arc - }); - let store = store::build_store(&run_record.settings.storage_dir())?; - let run_store = open_or_hydrate_run(store.as_ref(), &run_dir).await?; - - let github_app = shared::github::build_github_app_credentials(run_record.settings.app_id())?; - let runtime_state = RuntimeState::new(&run_dir); - - let services = StartServices { - cancel_token: None, - emitter: Arc::new(EventEmitter::new(run_record.run_id)), - interviewer: Arc::new(FileInterviewer::new( - runtime_state.interview_request_path(), - runtime_state.interview_response_path(), - runtime_state.interview_claim_path(), - )), - run_store, - github_app, - on_node, - registry_override: None, - }; - - if resume { - let _ = resume_run(&run_dir, services).await?; - } else { - let _ = start_run(&run_dir, services).await?; - } - - Ok(()) -} diff --git a/lib/crates/fabro-cli/src/commands/run/diff.rs b/lib/crates/fabro-cli/src/commands/run/diff.rs index 32e6ca7f0..7d7f3dcfc 100644 --- a/lib/crates/fabro-cli/src/commands/run/diff.rs +++ b/lib/crates/fabro-cli/src/commands/run/diff.rs @@ -1,42 +1,31 @@ use std::io::{self, IsTerminal, Write}; -use std::path::Path; use anyhow::{Context, Result, bail}; -use fabro_config::FabroSettingsExt; -use fabro_sandbox::SandboxRecordExt; -use fabro_sandbox::reconnect::reconnect; -use fabro_workflow::records::{StartRecord, StartRecordExt}; -use fabro_workflow::run_lookup::{resolve_run_combined, runs_base}; -use fabro_workflow::sandbox_git::GIT_REMOTE; +use fabro_util::printer::Printer; use tracing::{debug, info}; use crate::args::{DiffArgs, GlobalArgs}; +use crate::command_context::CommandContext; +use crate::server_client::RunProjection; +use crate::server_runs::ServerSummaryLookup; use crate::shared::print_json_pretty; -use crate::store; -use crate::user_config::load_user_settings_with_globals; -pub(crate) async fn run(args: DiffArgs, globals: &GlobalArgs) -> Result<()> { +pub(crate) async fn run(args: DiffArgs, globals: &GlobalArgs, printer: Printer) -> Result<()> { info!(run_id = %args.run, "Showing diff"); - let cli_settings = load_user_settings_with_globals(globals)?; - let base = runs_base(&cli_settings.storage_dir()); - let store = store::build_store(&cli_settings.storage_dir())?; - let run = resolve_run_combined(store.as_ref(), &base, &args.run).await?; - let run_store = store::open_run_reader(&cli_settings.storage_dir(), &run.run_id).await?; + let ctx = CommandContext::for_target(&args.server, printer)?; + let lookup = ServerSummaryLookup::from_client(ctx.server().await?).await?; + let run = lookup.resolve(&args.run)?; + let run_id = run.run_id(); + let state = lookup.client().get_run_state(&run_id).await?; - let patch = resolve_diff(&run.path, run_store.as_deref(), &args).await?; + let patch = resolve_diff(&state, &args)?; if globals.json { - let mut value = serde_json::json!({ - "run_id": run.run_id, + let value = serde_json::json!({ + "run_id": run_id, "node": args.node, + "diff": patch, }); - if args.shortstat { - value["shortstat"] = patch.trim_end().into(); - } else if args.stat { - value["stat"] = patch.trim_end().into(); - } else { - value["diff"] = patch.into(); - } print_json_pretty(&value)?; return Ok(()); } @@ -53,103 +42,44 @@ pub(crate) async fn run(args: DiffArgs, globals: &GlobalArgs) -> Result<()> { Ok(()) } -async fn resolve_diff( - run_dir: &Path, - run_store: Option<&dyn fabro_store::RunStore>, - args: &DiffArgs, -) -> Result { +fn resolve_diff(state: &RunProjection, args: &DiffArgs) -> Result { if let Some(ref node_id) = args.node { - debug!(node_id, "Reading per-node diff"); - let node_patch = run_dir.join("nodes").join(node_id).join("diff.patch"); - return std::fs::read_to_string(&node_patch).with_context(|| { - format!("No diff found for node '{node_id}' — check the node ID and try again") - }); + if let Some(visit) = state.list_node_visits(node_id).into_iter().max() { + if let Some(node) = state.node(&fabro_store::StageId::new(node_id, visit)) { + if let Some(patch) = node.diff.clone() { + debug!(node_id, visit, "Reading per-node diff from projected state"); + return Ok(patch); + } + } + } + + bail!("No diff found for node '{node_id}' — check the node ID and try again"); } - let start = match run_store { - Some(run_store) => run_store - .get_start() - .await - .ok() - .flatten() - .or_else(|| StartRecord::load(run_dir).ok()) - .context("Failed to load start.json")?, - None => StartRecord::load(run_dir).context("Failed to load start.json")?, - }; + let start = state + .start + .clone() + .context("Failed to load start record from store")?; let base_sha = start .base_sha .as_deref() .ok_or_else(|| anyhow::anyhow!("This run was not git-checkpointed; no diff available"))?; - let final_patch_path = run_dir.join("final.patch"); - if final_patch_path.exists() { - debug!("Reading final.patch"); - return std::fs::read_to_string(&final_patch_path).context("Failed to read final.patch"); + if let Some(patch) = state.final_patch.clone() { + debug!("Reading stored diff from run state"); + return Ok(patch); } - let run_concluded = match run_store { - Some(run_store) => { - run_store.get_conclusion().await.ok().flatten().is_some() - || run_dir.join("conclusion.json").exists() - } - None => run_dir.join("conclusion.json").exists(), - }; - if run_concluded { + if state.conclusion.is_some() { bail!( - "Run completed but no final.patch exists — the run may not have produced any changes" + "Run completed but no stored diff exists — the run may not have produced any changes" ); } - debug!("No final.patch found; attempting live diff from sandbox"); - let sandbox_json = run_dir.join("sandbox.json"); - let record = match run_store { - Some(run_store) => run_store - .get_sandbox() - .await - .ok() - .flatten() - .or_else(|| fabro_sandbox::SandboxRecord::load(&sandbox_json).ok()) - .context( - "Failed to load sandbox.json — was this run started with a recent version of arc?", - )?, - None => fabro_sandbox::SandboxRecord::load(&sandbox_json).context( - "Failed to load sandbox.json — was this run started with a recent version of arc?", - )?, - }; - - info!(provider = %record.provider, "Reconnecting to sandbox for live diff"); - let sandbox = reconnect(&record).await?; - - let cmd = build_live_diff_cmd(base_sha, args.stat, args.shortstat); - debug!(cmd, "Running git diff in sandbox"); - - let result = sandbox - .exec_command(&cmd, 30_000, None, None, None) - .await - .map_err(|e| anyhow::anyhow!("Failed to run git diff in sandbox: {e}"))?; - - if result.exit_code != 0 { - let stderr = result.stderr.trim(); - bail!("git diff failed (exit {}):\n{stderr}", result.exit_code); - } - - Ok(result.stdout) -} - -fn build_live_diff_cmd(base_sha: &str, stat: bool, shortstat: bool) -> String { - let mut flags = String::new(); - if stat { - flags.push_str(" --stat"); - } - if shortstat { - flags.push_str(" --shortstat"); - } - let quoted_sha = shlex::try_quote(base_sha).map_or_else( - |_| format!("'{}'", base_sha.replace('\'', "'\\''")), - |q| q.to_string(), - ); - format!("{GIT_REMOTE} add -N . && {GIT_REMOTE} diff{flags} {quoted_sha}") + bail!( + "Run is missing stored diff output since base commit {base_sha}; live sandbox diff is no longer supported" + ) } fn colorize_diff_line(line: &str) -> String { diff --git a/lib/crates/fabro-cli/src/commands/run/fork.rs b/lib/crates/fabro-cli/src/commands/run/fork.rs index adccaac1a..203cc8ff9 100644 --- a/lib/crates/fabro-cli/src/commands/run/fork.rs +++ b/lib/crates/fabro-cli/src/commands/run/fork.rs @@ -1,35 +1,43 @@ -use anyhow::Context; -use anyhow::Result; +use anyhow::{Context, Result}; use fabro_checkpoint::git::Store; -use fabro_config::FabroSettingsExt; +use fabro_util::printer::Printer; use fabro_util::terminal::Styles; -use fabro_workflow::operations::{ - ForkRunInput, RewindTarget, build_timeline_or_rebuild, find_run_id_by_prefix_or_store, fork, -}; +use fabro_workflow::operations::{ForkRunInput, RewindTarget, build_timeline_or_rebuild, fork}; use git2::Repository; use crate::args::{ForkArgs, GlobalArgs}; +use crate::command_context::CommandContext; +use crate::commands::store::rebuild::rebuild_run_store; +use crate::server_runs::ServerSummaryLookup; use crate::shared::print_json_pretty; -use crate::store::{build_store, open_run_reader}; -use crate::user_config::load_user_settings_with_globals; +use crate::shared::repo::ensure_matching_repo_origin; -pub(crate) async fn run(args: &ForkArgs, styles: &Styles, globals: &GlobalArgs) -> Result<()> { +pub(crate) async fn run( + args: &ForkArgs, + styles: &Styles, + globals: &GlobalArgs, + printer: Printer, +) -> Result<()> { let repo = Repository::discover(".").context("not in a git repository")?; - let cli_settings = load_user_settings_with_globals(globals)?; - let durable_store = build_store(&cli_settings.storage_dir())?; - let run_id = - find_run_id_by_prefix_or_store(&repo, durable_store.as_ref(), &args.run_id).await?; + let ctx = CommandContext::for_target(&args.server, printer)?; + let lookup = ServerSummaryLookup::from_client(ctx.server().await?).await?; + let run = lookup.resolve(&args.run_id)?; + let run_id = run.run_id(); + let state = lookup.client().get_run_state(&run_id).await?; + let record = state.run.context("Failed to load run record from store")?; + ensure_matching_repo_origin(record.repo_origin_url.as_deref(), "fork")?; let store = Store::new(repo); - let run_store = open_run_reader(&cli_settings.storage_dir(), &run_id).await?; + let events = lookup.client().list_run_events(&run_id, None, None).await?; + let run_store = rebuild_run_store(&run_id, &events).await?; - let timeline = build_timeline_or_rebuild(&store, run_store.as_deref(), &run_id).await?; + let timeline = build_timeline_or_rebuild(&store, Some(&run_store), &run_id).await?; if args.list { if globals.json { print_json_pretty(&super::rewind::timeline_entries_json(&timeline))?; return Ok(()); } - super::rewind::print_timeline(&timeline, styles); + super::rewind::print_timeline(&timeline, styles, printer); return Ok(()); } @@ -38,14 +46,11 @@ pub(crate) async fn run(args: &ForkArgs, styles: &Styles, globals: &GlobalArgs) .as_deref() .map(str::parse::) .transpose()?; - let new_run_id = fork( - &store, - &ForkRunInput { - source_run_id: run_id, - target, - push: !args.no_push, - }, - )?; + let new_run_id = fork(&store, &ForkRunInput { + source_run_id: run_id, + target, + push: !args.no_push, + })?; let run_id_string = run_id.to_string(); let new_run_id_string = new_run_id.to_string(); @@ -58,12 +63,14 @@ pub(crate) async fn run(args: &ForkArgs, styles: &Styles, globals: &GlobalArgs) "target": target, }))?; } else { - eprintln!( + fabro_util::printerr!( + printer, "\nForked run {} -> {}", &run_id_string[..8.min(run_id_string.len())], &new_run_id_string[..8.min(new_run_id_string.len())] ); - eprintln!( + fabro_util::printerr!( + printer, "To resume: fabro resume {}", &new_run_id_string[..8.min(new_run_id_string.len())] ); diff --git a/lib/crates/fabro-cli/src/commands/run/launcher.rs b/lib/crates/fabro-cli/src/commands/run/launcher.rs deleted file mode 100644 index 87e4ee8a4..000000000 --- a/lib/crates/fabro-cli/src/commands/run/launcher.rs +++ /dev/null @@ -1,200 +0,0 @@ -use std::path::{Path, PathBuf}; - -use anyhow::{Context, Result}; -use chrono::{DateTime, Utc}; -use fabro_config::FabroSettingsExt; -use fabro_types::RunId; -use fabro_workflow::records::{RunRecord, RunRecordExt}; -use serde::{Deserialize, Serialize}; - -#[cfg(test)] -use crate::commands::run::short_run_id; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub(crate) struct LauncherRecord { - pub run_id: RunId, - pub run_dir: PathBuf, - pub pid: u32, - pub resume: bool, - pub log_path: PathBuf, - pub started_at: DateTime, -} - -pub(crate) fn launcher_dir(storage_dir: &Path) -> PathBuf { - storage_dir.join("launchers") -} - -pub(crate) fn launcher_record_path(storage_dir: &Path, run_id: &RunId) -> PathBuf { - launcher_dir(storage_dir).join(format!("{run_id}.json")) -} - -pub(crate) fn launcher_log_path(storage_dir: &Path, run_id: &RunId) -> PathBuf { - launcher_dir(storage_dir).join(format!("{run_id}.log")) -} - -pub(crate) fn write_launcher_record(path: &Path, record: &LauncherRecord) -> Result<()> { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::write(path, serde_json::to_string_pretty(record)?) - .with_context(|| format!("Failed to write launcher metadata to {}", path.display())) -} - -pub(crate) fn read_launcher_record(path: &Path) -> Option { - let content = std::fs::read_to_string(path).ok()?; - serde_json::from_str(&content).ok() -} - -pub(crate) fn remove_launcher_record(path: &Path) { - let _ = std::fs::remove_file(path); -} - -pub(crate) fn active_launcher_record_for_run(run_dir: &Path) -> Option { - let run_record = RunRecord::load(run_dir).ok()?; - let path = launcher_record_path(&run_record.settings.storage_dir(), &run_record.run_id); - let launcher = read_launcher_record(&path)?; - if launcher_record_is_running(&launcher) { - Some(launcher) - } else { - remove_launcher_record(&path); - None - } -} - -pub(crate) fn launcher_record_is_running(record: &LauncherRecord) -> bool { - process_alive(record.pid) && launcher_process_matches(record) -} - -#[cfg(unix)] -#[allow(unsafe_code)] -fn process_alive(pid: u32) -> bool { - let Ok(pid) = i32::try_from(pid) else { - return false; - }; - unsafe { libc::kill(pid, 0) == 0 } -} - -#[cfg(not(unix))] -fn process_alive(_pid: u32) -> bool { - true -} - -#[cfg(unix)] -fn launcher_process_matches(record: &LauncherRecord) -> bool { - let output = match std::process::Command::new("ps") - .args(["-ww", "-o", "command=", "-p", &record.pid.to_string()]) - .output() - { - Ok(output) if output.status.success() => output, - _ => return false, - }; - - let command = String::from_utf8_lossy(&output.stdout); - command_matches_launcher(record, &command) -} - -#[cfg(unix)] -fn command_matches_launcher(record: &LauncherRecord, command: &str) -> bool { - let run_dir = record.run_dir.to_string_lossy(); - let old_match = command.contains("__detached") && command.contains(run_dir.as_ref()); - let run_id = record.run_id.to_string(); - let new_match = command.contains(&format!("fabro: {}", super::short_run_id(&run_id))); - old_match || new_match -} - -#[cfg(not(unix))] -fn launcher_process_matches(_record: &LauncherRecord) -> bool { - true -} - -#[cfg(test)] -mod tests { - use super::*; - use chrono::Utc; - use fabro_config::FabroSettings; - use fabro_graphviz::graph::Graph; - use fabro_types::fixtures; - use fabro_workflow::records::RunRecord; - - #[test] - fn active_launcher_record_for_run_removes_stale_record() { - let dir = tempfile::tempdir().unwrap(); - let storage_dir = dir.path().join("storage"); - let run_dir = dir.path().join("run"); - std::fs::create_dir_all(&run_dir).unwrap(); - - RunRecord { - run_id: fixtures::RUN_1, - created_at: Utc::now(), - settings: FabroSettings { - storage_dir: Some(storage_dir.clone()), - ..Default::default() - }, - graph: Graph::default(), - workflow_slug: None, - working_directory: dir.path().to_path_buf(), - host_repo_path: None, - base_branch: None, - labels: std::collections::HashMap::new(), - } - .save(&run_dir) - .unwrap(); - - let launcher_path = launcher_record_path(&storage_dir, &fixtures::RUN_1); - write_launcher_record( - &launcher_path, - &LauncherRecord { - run_id: fixtures::RUN_1, - run_dir: run_dir.clone(), - pid: u32::MAX, - resume: false, - log_path: dir.path().join("launcher.log"), - started_at: Utc::now(), - }, - ) - .unwrap(); - - assert!(active_launcher_record_for_run(&run_dir).is_none()); - assert!(!launcher_path.exists()); - } - - #[cfg(unix)] - #[test] - fn command_matches_launcher_accepts_old_detached_format() { - let dir = tempfile::tempdir().unwrap(); - let record = LauncherRecord { - run_id: fixtures::RUN_2, - run_dir: dir.path().join("run"), - pid: 42, - resume: false, - log_path: dir.path().join("launcher.log"), - started_at: Utc::now(), - }; - - let command = format!( - "/usr/local/bin/fabro __detached --run-dir {} --launcher-path /tmp/launcher.json", - record.run_dir.display() - ); - - assert!(command_matches_launcher(&record, &command)); - } - - #[cfg(unix)] - #[test] - fn command_matches_launcher_accepts_new_title_format() { - let dir = tempfile::tempdir().unwrap(); - let record = LauncherRecord { - run_id: fixtures::RUN_3, - run_dir: dir.path().join("run"), - pid: 42, - resume: false, - log_path: dir.path().join("launcher.log"), - started_at: Utc::now(), - }; - - assert!(command_matches_launcher( - &record, - &format!("fabro: {} plan", short_run_id(&record.run_id.to_string())) - )); - } -} diff --git a/lib/crates/fabro-cli/src/commands/run/logs.rs b/lib/crates/fabro-cli/src/commands/run/logs.rs index 747880d8d..ec3e65b07 100644 --- a/lib/crates/fabro-cli/src/commands/run/logs.rs +++ b/lib/crates/fabro-cli/src/commands/run/logs.rs @@ -1,65 +1,52 @@ use std::fmt::Write as _; -use std::io::{self, BufRead, IsTerminal, Write}; -use std::path::Path; +use std::io::{self, IsTerminal, Write}; use std::time::Duration; use anyhow::{Context, Result, bail}; use chrono::{DateTime, Utc}; -use fabro_config::FabroSettingsExt; -use fabro_store::RunStore; +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 fabro_workflow::run_lookup::{resolve_run_combined, runs_base}; -use futures::StreamExt; use tokio::time; -use tracing::{debug, info, warn}; +use tracing::{debug, info}; use crate::args::{GlobalArgs, LogsArgs}; -use crate::store; -use crate::user_config::load_user_settings_with_globals; +use crate::command_context::CommandContext; +use crate::server_client; +use crate::server_runs::ServerSummaryLookup; +use crate::shared::format_usd_micros; -pub(crate) async fn run(args: &LogsArgs, styles: &Styles, globals: &GlobalArgs) -> Result<()> { - let cli_settings = load_user_settings_with_globals(globals)?; - let base = runs_base(&cli_settings.storage_dir()); - let store = store::build_store(&cli_settings.storage_dir())?; - let run = resolve_run_combined(store.as_ref(), &base, &args.run).await?; +const FOLLOW_TERMINAL_GRACE: Duration = Duration::from_millis(500); - info!(run_id = %run.run_id, "Showing logs"); +pub(crate) async fn run( + args: &LogsArgs, + styles: &Styles, + globals: &GlobalArgs, + printer: Printer, +) -> Result<()> { + let ctx = CommandContext::for_target(&args.server, printer)?; + let lookup = ServerSummaryLookup::from_client(ctx.server().await?).await?; + let run = lookup.resolve(&args.run)?; + let client = lookup.client(); + + let run_id = run.run_id(); + info!(run_id = %run_id, "Showing logs"); let since_cutoff = match &args.since { Some(value) => Some(parse_since(value)?), None => None, }; - let run_store = store::open_run_reader(&cli_settings.storage_dir(), &run.run_id).await?; - let progress_path = run.path.join("progress.jsonl"); - let (all_lines, last_seq, use_store_follow) = if let Some(run_store) = run_store.as_ref() { - match run_store.list_events().await { - Ok(events) => { - let last_seq = events.last().map_or(0, |event| event.seq); - let lines = events - .iter() - .map(event_payload_line) - .collect::>>()?; - (lines, last_seq, true) - } - Err(err) => { - if !progress_path.exists() { - return Err(err).context("Failed to list store-backed run events"); - } - warn!( - run_id = %run.run_id, - error = %err, - "Failed to read events from store; falling back to progress.jsonl" - ); - (read_lines(&progress_path)?, 0, false) - } - } - } else { - if !progress_path.exists() { - bail!("No progress.jsonl found for run '{}'", run.run_id); - } - (read_lines(&progress_path)?, 0, false) - }; + let events = client + .list_run_events(&run_id, None, None) + .await + .context("Failed to list server-backed run events")?; + let last_seq = events.last().map_or(0, |event| event.seq); + let all_lines = events + .iter() + .map(event_payload_line) + .collect::>>()?; let filtered = apply_filters(&all_lines, since_cutoff.as_ref(), args.tail); let stdout = io::stdout(); @@ -78,67 +65,22 @@ pub(crate) async fn run(args: &LogsArgs, styles: &Styles, globals: &GlobalArgs) } if args.follow { - if use_store_follow { - if let Some(run_store) = run_store.as_ref() { - match follow_store_logs( - run_store.as_ref(), - if last_seq == 0 { 1 } else { last_seq + 1 }, - pretty, - styles, - is_tty, - ) - .await - { - Ok(()) => {} - Err(err) => { - if !progress_path.exists() { - return Err(err); - } - warn!( - run_id = %run.run_id, - error = %err, - "Failed to follow store events; falling back to progress.jsonl" - ); - let lines_seen = read_lines(&progress_path)?.len(); - follow_logs( - &progress_path, - &run.path, - lines_seen, - pretty, - styles, - is_tty, - )?; - } - } - } else { - unreachable!("store follow requested without a run store"); - } - } else { - follow_logs( - &progress_path, - &run.path, - all_lines.len(), - pretty, - styles, - is_tty, - )?; - } + follow_store_logs( + client, + &run_id, + if last_seq == 0 { 1 } else { last_seq + 1 }, + pretty, + styles, + is_tty, + ) + .await?; } Ok(()) } -fn read_lines(path: &Path) -> Result> { - let file = std::fs::File::open(path).context("Failed to open progress.jsonl")?; - let reader = io::BufReader::new(file); - let mut lines = Vec::new(); - for line in reader.lines() { - let line = line?; - if !line.trim().is_empty() { - lines.push(line); - } - } - Ok(lines) +fn event_name(event: &fabro_store::EventEnvelope) -> Option<&str> { + event.payload.as_value().get("event")?.as_str() } fn apply_filters( @@ -201,114 +143,108 @@ fn try_parse_relative_duration(s: &str) -> Option { } } -fn follow_logs( - progress_path: &Path, - run_dir: &Path, - mut lines_seen: usize, - pretty: bool, - styles: &Styles, - _is_tty: bool, -) -> Result<()> { - let conclusion_path = run_dir.join("conclusion.json"); - let stdout = io::stdout(); - let mut out = stdout.lock(); - - loop { - std::thread::sleep(std::time::Duration::from_millis(200)); - - let all_lines = read_lines(progress_path)?; - if all_lines.len() > lines_seen { - for line in &all_lines[lines_seen..] { - if pretty { - if let Some(formatted) = format_event_pretty(line, styles) { - writeln!(out, "{formatted}")?; - } - } else { - writeln!(out, "{line}")?; - } - } - out.flush()?; - lines_seen = all_lines.len(); - } - - if conclusion_path.exists() && all_lines.len() <= lines_seen { - debug!("Run concluded, stopping follow"); - break; - } - } - - Ok(()) -} - async fn follow_store_logs( - run_store: &dyn RunStore, + client: &server_client::ServerStoreClient, + run_id: &fabro_types::RunId, seq: u32, pretty: bool, styles: &Styles, _is_tty: bool, ) -> Result<()> { - let mut stream = run_store - .watch_events_from(seq) - .await - .context("Failed to watch store-backed run events")?; let stdout = io::stdout(); let mut out = stdout.lock(); let mut next_seq = seq; + let mut terminal_deadline = None; loop { - match time::timeout(Duration::from_millis(200), stream.next()).await { - Ok(Some(Ok(event))) => { - let line = event_payload_line(&event)?; - if pretty { - if let Some(formatted) = format_event_pretty(&line, styles) { - writeln!(out, "{formatted}")?; + match time::timeout( + Duration::from_millis(200), + client.list_run_events(run_id, Some(next_seq), None), + ) + .await + { + Ok(Ok(events)) => { + let had_events = !events.is_empty(); + let saw_terminal = events + .iter() + .any(|event| matches!(event_name(event), Some("run.completed" | "run.failed"))); + for event in events { + let line = event_payload_line(&event)?; + if pretty { + if let Some(formatted) = format_event_pretty(&line, styles) { + writeln!(out, "{formatted}")?; + } + } else { + writeln!(out, "{line}")?; } - } else { - writeln!(out, "{line}")?; + out.flush()?; + next_seq = event.seq.saturating_add(1); + } + if saw_terminal || (terminal_deadline.is_some() && had_events) { + terminal_deadline = Some(time::Instant::now() + FOLLOW_TERMINAL_GRACE); } - out.flush()?; - next_seq = event.seq.saturating_add(1); } - Ok(Some(Err(err))) => return Err(err.into()), - Ok(None) => break, Err(_) => { - let concluded = run_store - .get_conclusion() - .await - .context("Failed to read conclusion from store while following logs")? - .is_some() - || run_store - .get_status() - .await - .context("Failed to read status from store while following logs")? - .is_some_and(|record| record.status.is_terminal()); - - if concluded { - flush_remaining_store_events(run_store, next_seq, pretty, styles, &mut out) - .await?; - debug!("Run reached terminal status, stopping follow"); - break; + if run_concluded(client, run_id).await? { + terminal_deadline + .get_or_insert_with(|| time::Instant::now() + FOLLOW_TERMINAL_GRACE); } } + Ok(Err(err)) => return Err(err), } + + let Some(deadline) = terminal_deadline else { + continue; + }; + if time::Instant::now() < deadline { + continue; + } + + let flushed_next_seq = + flush_remaining_store_events(client, run_id, next_seq, pretty, styles, &mut out) + .await?; + if flushed_next_seq > next_seq { + next_seq = flushed_next_seq; + terminal_deadline = Some(time::Instant::now() + FOLLOW_TERMINAL_GRACE); + continue; + } + + debug!("Run reached terminal status and log tail is quiet, stopping follow"); + break; } Ok(()) } +async fn run_concluded( + client: &server_client::ServerStoreClient, + run_id: &fabro_types::RunId, +) -> Result { + let state = client + .get_run_state(run_id) + .await + .context("Failed to read run state from server while following logs")?; + Ok(state.conclusion.is_some() + || state + .status + .is_some_and(|record| record.status.is_terminal())) +} + async fn flush_remaining_store_events( - run_store: &dyn RunStore, + client: &server_client::ServerStoreClient, + run_id: &fabro_types::RunId, next_seq: u32, pretty: bool, styles: &Styles, out: &mut dyn Write, -) -> Result<()> { - let events = run_store - .list_events() +) -> Result { + let events = client + .list_run_events(run_id, Some(next_seq), None) .await - .context("Failed to list store-backed run events while finalizing follow")?; + .context("Failed to list server-backed run events while finalizing follow")?; - for event in events.into_iter().filter(|event| event.seq >= next_seq) { + let mut next_seq = next_seq; + for event in events { let line = event_payload_line(&event)?; if pretty { if let Some(formatted) = format_event_pretty(&line, styles) { @@ -317,13 +253,37 @@ async fn flush_remaining_store_events( } else { writeln!(out, "{line}")?; } + next_seq = event.seq.saturating_add(1); } out.flush()?; - Ok(()) + Ok(next_seq) } fn event_payload_line(event: &fabro_store::EventEnvelope) -> Result { - serde_json::to_string(event.payload.as_value()).map_err(Into::into) + let mut value = normalize_json_value(event.payload.as_value().clone()); + restore_empty_run_properties(&mut value); + let line = serde_json::to_string(&value)?; + Ok(redact_jsonl_line(&line)) +} + +fn restore_empty_run_properties(value: &mut serde_json::Value) { + let Some(object) = value.as_object_mut() else { + return; + }; + let Some(event_name) = object.get("event").and_then(serde_json::Value::as_str) else { + return; + }; + if matches!(event_name, "run.submitted" | "run.running") && !object.contains_key("properties") { + let run_id = object.remove("run_id"); + let ts = object.remove("ts"); + object.insert("properties".to_string(), serde_json::json!({})); + if let Some(run_id) = run_id { + object.insert("run_id".to_string(), run_id); + } + if let Some(ts) = ts { + object.insert("ts".to_string(), ts); + } + } } fn render_indented_markdown(styles: &Styles, text: &str, indent: &str) -> String { @@ -372,7 +332,10 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option "success" | "partial_success" => &styles.bold_green, _ => &styles.bold_red, }; - let cost = format_cost(prop_field(&envelope, "total_cost")); + let cost = format_cost( + prop_field(&envelope, "total_usd_micros") + .or_else(|| prop_field(&envelope, "total_cost")), + ); let mut lines = vec![format!( "{} {} {} {}", @@ -382,8 +345,10 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option styles.dim.apply_to(&cost), )]; - if let Some(usage) = prop_field(&envelope, "usage") { - let total = usage + if let Some(billing) = + prop_field(&envelope, "billing").or_else(|| prop_field(&envelope, "usage")) + { + let total = billing .get("total_tokens") .and_then(serde_json::Value::as_i64) .unwrap_or(0); @@ -398,11 +363,11 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option )) )); } - if let Some(cache_read) = usage + if let Some(cache_read) = billing .get("cache_read_tokens") .and_then(serde_json::Value::as_i64) { - let cache_write = usage + let cache_write = billing .get("cache_write_tokens") .and_then(serde_json::Value::as_i64) .unwrap_or(0); @@ -416,7 +381,7 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option )) )); } - if let Some(reasoning) = usage + if let Some(reasoning) = billing .get("reasoning_tokens") .and_then(serde_json::Value::as_i64) { @@ -478,13 +443,18 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option "stage.completed" => { let label = str_field(&envelope, "node_label").unwrap_or("?"); let duration = format_duration_ms(prop_field(&envelope, "duration_ms")); - let usage = prop_field(&envelope, "usage"); - let cost = format_cost(usage.and_then(|value| value.get("cost"))); - let input_tokens = usage + let billing = + prop_field(&envelope, "billing").or_else(|| prop_field(&envelope, "usage")); + let cost = format_cost( + billing + .and_then(|value| value.get("total_usd_micros")) + .or_else(|| billing.and_then(|value| value.get("cost"))), + ); + let input_tokens = billing .and_then(|value| value.get("input_tokens")) .and_then(serde_json::Value::as_u64) .unwrap_or(0); - let output_tokens = usage + let output_tokens = billing .and_then(|value| value.get("output_tokens")) .and_then(serde_json::Value::as_u64) .unwrap_or(0); @@ -747,11 +717,21 @@ fn format_duration_ms(value: Option<&serde_json::Value>) -> String { } fn format_cost(value: Option<&serde_json::Value>) -> String { - let cost = value.and_then(serde_json::Value::as_f64).unwrap_or(0.0); - if cost > 0.0 { - format!("${cost:.2}") - } else { - String::new() + match value { + Some(value) => { + if let Some(usd_micros) = value.as_i64() { + if usd_micros > 0 { + return format_usd_micros(usd_micros); + } + } + let cost = value.as_f64().unwrap_or(0.0); + if cost > 0.0 { + format!("${cost:.2}") + } else { + String::new() + } + } + None => String::new(), } } @@ -983,7 +963,7 @@ mod tests { #[test] fn pretty_workflow_run_completed() { let styles = no_color_styles(); - let line = r#"{"ts":"2026-01-01T14:23:32Z","run_id":"abc123","event":"run.completed","properties":{"duration_ms":25000,"status":"success","total_cost":0.57,"usage":{"input_tokens":5000,"output_tokens":2000,"total_tokens":7000,"cache_read_tokens":3000,"cache_write_tokens":500,"reasoning_tokens":800}}}"#; + let line = r#"{"ts":"2026-01-01T14:23:32Z","run_id":"abc123","event":"run.completed","properties":{"duration_ms":25000,"status":"success","total_usd_micros":570000,"billing":{"input_tokens":5000,"output_tokens":2000,"total_tokens":7000,"cache_read_tokens":3000,"cache_write_tokens":500,"reasoning_tokens":800}}}"#; let result = format_event_pretty(line, &styles).unwrap(); assert!(result.contains("SUCCESS"), "got: {result}"); assert!(result.contains("25s"), "got: {result}"); diff --git a/lib/crates/fabro-cli/src/commands/run/mod.rs b/lib/crates/fabro-cli/src/commands/run/mod.rs index 3158e73c8..a42ad6f0a 100644 --- a/lib/crates/fabro-cli/src/commands/run/mod.rs +++ b/lib/crates/fabro-cli/src/commands/run/mod.rs @@ -1,21 +1,19 @@ use anyhow::Result; -use fabro_config::FabroSettingsExt; +use fabro_util::printer::Printer; use fabro_util::terminal::Styles; -use fabro_workflow::run_lookup::{resolve_run_combined, runs_base}; -use crate::args::{GlobalArgs, RunArgs, RunCommands}; +use crate::args::{AttachArgs, GlobalArgs, RunArgs, RunCommands, RunWorkerArgs, StartArgs}; +use crate::command_context::CommandContext; +use crate::server_runs::ServerSummaryLookup; use crate::shared::print_json_pretty; -use crate::store; -use crate::user_config::{load_user_settings_with_globals, user_layer_with_globals}; +use crate::user_config::settings_layer_with_storage_dir; pub(crate) mod attach; pub(crate) mod command; pub(crate) mod cp; pub(crate) mod create; -pub(crate) mod detached; pub(crate) mod diff; pub(crate) mod fork; -pub(crate) mod launcher; pub(crate) mod logs; pub(crate) mod output; pub(crate) mod overrides; @@ -23,64 +21,65 @@ pub(crate) mod preview; pub(crate) mod resume; pub(crate) mod rewind; pub(crate) mod run_progress; +pub(crate) mod runner; pub(crate) mod ssh; pub(crate) mod start; pub(crate) mod wait; -pub(super) fn short_run_id(id: &str) -> &str { - if id.len() > 12 { &id[..12] } else { id } -} - fn apply_json_defaults(args: &mut RunArgs, globals: &GlobalArgs) { if globals.json { args.auto_approve = true; } } -pub(crate) async fn dispatch(cmd: RunCommands, globals: &GlobalArgs) -> Result<()> { +pub(crate) async fn dispatch( + cmd: RunCommands, + globals: &GlobalArgs, + printer: Printer, +) -> Result<()> { match cmd { RunCommands::Run(mut args) => { apply_json_defaults(&mut args, globals); - command::execute(args, globals).await + Box::pin(command::execute(args, globals, printer)).await } RunCommands::Create(mut args) => { apply_json_defaults(&mut args, globals); let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); - let cli = user_layer_with_globals(globals)?; - let (run_id, _run_dir) = create::create_run(&args, cli, styles, true)?; + let cli = settings_layer_with_storage_dir(None)?; + let ctx = CommandContext::for_target(&args.target, printer)?; + let created_run = + Box::pin(create::create_run(&ctx, &args, cli, styles, true, printer)).await?; + if globals.json { + print_json_pretty(&serde_json::json!({ "run_id": created_run.run_id }))?; + } else { + fabro_util::printout!(printer, "{}", created_run.run_id); + } + Ok(()) + } + RunCommands::Start(StartArgs { server, run }) => { + let ctx = CommandContext::for_target(&server, printer)?; + let lookup = ServerSummaryLookup::from_client(ctx.server().await?).await?; + let run_info = lookup.resolve(&run)?; + let run_id = run_info.run_id(); + start::start_run_with_client(lookup.client(), &run_id, false).await?; if globals.json { print_json_pretty(&serde_json::json!({ "run_id": run_id }))?; - } else { - println!("{run_id}"); } Ok(()) } - RunCommands::Start { run } => { - let cli_settings = load_user_settings_with_globals(globals)?; - let base = runs_base(&cli_settings.storage_dir()); - let store = store::build_store(&cli_settings.storage_dir())?; - let run_info = resolve_run_combined(store.as_ref(), &base, &run).await?; - let child = start::start_run(&run_info.path, false)?; - if globals.json { - print_json_pretty(&serde_json::json!({ "run_id": run_info.run_id }))?; - } else { - eprintln!("Started engine process (PID {})", child.id()); - } - Ok(()) - } - RunCommands::Attach { run } => { + RunCommands::Attach(AttachArgs { server, run }) => { let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); - let cli_settings = load_user_settings_with_globals(globals)?; - let base = runs_base(&cli_settings.storage_dir()); - let store = store::build_store(&cli_settings.storage_dir())?; - let run_info = resolve_run_combined(store.as_ref(), &base, &run).await?; - let exit_code = attach::attach_run( - &run_info.path, - Some(&run_info.run_id), + let ctx = CommandContext::for_target(&server, printer)?; + let lookup = ServerSummaryLookup::from_client(ctx.server().await?).await?; + let run_info = lookup.resolve(&run)?; + let run_id = run_info.run_id(); + let exit_code = attach::attach_run_with_client( + lookup.client(), + &run_id, false, styles, - None, globals.json, + printer, ) .await?; if exit_code != std::process::ExitCode::SUCCESS { @@ -88,36 +87,38 @@ pub(crate) async fn dispatch(cmd: RunCommands, globals: &GlobalArgs) -> Result<( } Ok(()) } - RunCommands::Detached { + RunCommands::RunWorker(RunWorkerArgs { + server, + artifact_upload_token, run_dir, - launcher_path, - resume, - } => detached::execute(run_dir, launcher_path, resume).await, - RunCommands::Diff(args) => diff::run(args, globals).await, + run_id, + mode, + }) => runner::execute(run_id, server, artifact_upload_token, run_dir, mode).await, + RunCommands::Diff(args) => diff::run(args, globals, printer).await, RunCommands::Logs(args) => { let styles = Styles::detect_stdout(); - logs::run(&args, &styles, globals).await + logs::run(&args, &styles, globals, printer).await } RunCommands::Resume(args) => { let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); #[cfg(feature = "sleep_inhibitor")] let _sleep_guard = { - let cli_settings = load_user_settings_with_globals(globals)?; - crate::sleep_inhibitor::guard(cli_settings.prevent_idle_sleep_enabled()) + let ctx = CommandContext::for_target(&args.server, printer)?; + crate::sleep_inhibitor::guard(ctx.cli_settings().exec.prevent_idle_sleep) }; - resume::resume_command(args, styles, globals).await + resume::resume_command(args, styles, globals, printer).await } RunCommands::Rewind(args) => { let styles = Styles::detect_stderr(); - rewind::run(&args, &styles, globals).await + Box::pin(rewind::run(&args, &styles, globals, printer)).await } RunCommands::Fork(args) => { let styles = Styles::detect_stderr(); - fork::run(&args, &styles, globals).await + Box::pin(fork::run(&args, &styles, globals, printer)).await } RunCommands::Wait(args) => { let styles = Styles::detect_stderr(); - wait::run(&args, &styles, globals).await + wait::run(&args, &styles, globals, printer).await } } } diff --git a/lib/crates/fabro-cli/src/commands/run/output.rs b/lib/crates/fabro-cli/src/commands/run/output.rs index 847424c0a..e51a9cddf 100644 --- a/lib/crates/fabro-cli/src/commands/run/output.rs +++ b/lib/crates/fabro-cli/src/commands/run/output.rs @@ -1,228 +1,402 @@ use std::path::Path; use std::time::Duration; -use fabro_graphviz::graph::Graph; -use fabro_store::RuntimeState; +use anyhow::{Context as _, Result}; +use fabro_api::types; +use fabro_types::{ + PullRequestRecord, RunBlobId, RunId, parse_blob_ref, parse_legacy_blob_file_ref, +}; +use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus}; +use fabro_util::printer::Printer; use fabro_util::terminal::Styles; use fabro_util::text::strip_goal_decoration; -use fabro_workflow::asset_snapshot::collect_asset_paths; -use fabro_workflow::outcome::{StageStatus, format_cost}; -use fabro_workflow::pipeline::{Persisted, Validated}; -use fabro_workflow::pull_request::PullRequestRecord; -use fabro_workflow::records::{Checkpoint, CheckpointExt, Conclusion, ConclusionExt}; +use fabro_workflow::outcome::StageStatus; +use fabro_workflow::records::Conclusion; use indicatif::HumanDuration; -use crate::shared::{format_tokens_human, print_diagnostics, relative_path, tilde_path}; +use crate::server_client; +use crate::shared::{ + format_tokens_human, format_usd_micros, print_diagnostics, relative_path, tilde_path, +}; -fn print_workflow_header( - graph: &Graph, - diagnostics: &[fabro_validate::Diagnostic], - dot_path: Option<&Path>, +pub(crate) fn print_preflight_workflow_summary( + workflow: &types::PreflightWorkflowSummary, + graph_path_override: Option<&Path>, styles: &Styles, + printer: Printer, ) { - eprintln!( + let graph_path = graph_path_override + .map(relative_path) + .or_else(|| { + workflow.graph_path.as_deref().map(|path| { + let path = Path::new(path); + if path.is_absolute() { + relative_path(path) + } else { + path.display().to_string() + } + }) + }) + .unwrap_or_else(|| "".to_string()); + let diagnostics = workflow + .diagnostics + .iter() + .map(api_diagnostic_to_local) + .collect::>(); + + fabro_util::printerr!( + printer, "{} {} {}", styles.bold.apply_to("Workflow:"), - graph.name, + workflow.name, styles.dim.apply_to(format!( "({} nodes, {} edges)", - graph.nodes.len(), - graph.edges.len() + workflow.nodes, workflow.edges )), ); - let graph_path = dot_path.map_or_else(|| "".to_string(), relative_path); - eprintln!( + fabro_util::printerr!( + printer, "{} {}", styles.dim.apply_to("Graph:"), styles.dim.apply_to(graph_path), ); - let goal = graph.goal(); - if !goal.is_empty() { - let stripped = strip_goal_decoration(goal); - eprintln!("{} {stripped}\n", styles.bold.apply_to("Goal:")); + if !workflow.goal.is_empty() { + let stripped = strip_goal_decoration(&workflow.goal); + fabro_util::printerr!(printer, "{} {stripped}\n", styles.bold.apply_to("Goal:")); } - print_diagnostics(diagnostics, styles); + print_diagnostics(&diagnostics, styles, printer); } -pub(crate) fn print_workflow_report( - validated: &Validated, - dot_path: Option<&Path>, +fn api_diagnostic_to_local(diagnostic: &types::WorkflowDiagnostic) -> fabro_validate::Diagnostic { + fabro_validate::Diagnostic { + rule: diagnostic.rule.clone(), + severity: match diagnostic.severity { + types::WorkflowDiagnosticSeverity::Error => fabro_validate::Severity::Error, + types::WorkflowDiagnosticSeverity::Warning => fabro_validate::Severity::Warning, + types::WorkflowDiagnosticSeverity::Info => fabro_validate::Severity::Info, + }, + message: diagnostic.message.clone(), + node_id: diagnostic.node_id.clone(), + edge: diagnostic + .edge + .as_ref() + .map(|edge| (edge[0].clone(), edge[1].clone())), + fix: diagnostic.fix.clone(), + } +} + +pub(crate) fn api_diagnostics_to_local( + diagnostics: &[types::WorkflowDiagnostic], +) -> Vec { + diagnostics.iter().map(api_diagnostic_to_local).collect() +} + +pub(crate) fn api_check_report_to_local(report: &types::PreflightCheckReport) -> CheckReport { + CheckReport { + title: report.title.clone(), + sections: report + .sections + .iter() + .map(|section| CheckSection { + title: section.title.clone(), + checks: section + .checks + .iter() + .map(|check| CheckResult { + name: check.name.clone(), + status: match check.status { + types::PreflightCheckResultStatus::Pass => CheckStatus::Pass, + types::PreflightCheckResultStatus::Warning => CheckStatus::Warning, + types::PreflightCheckResultStatus::Error => CheckStatus::Error, + }, + summary: check.summary.clone(), + details: check + .details + .iter() + .map(|detail| CheckDetail { + text: detail.text.clone(), + warn: detail.warn, + }) + .collect(), + remediation: check.remediation.clone(), + }) + .collect(), + }) + .collect(), + } +} + +pub(crate) async fn print_run_summary_with_client( + client: &server_client::ServerStoreClient, + run_id: &fabro_types::RunId, + local_run_dir: Option<&Path>, styles: &Styles, -) { - print_workflow_header(validated.graph(), validated.diagnostics(), dot_path, styles); -} - -pub(crate) fn print_workflow_report_from_persisted( - persisted: &Persisted, - dot_path: Option<&Path>, - styles: &Styles, -) { - print_workflow_header(persisted.graph(), persisted.diagnostics(), dot_path, styles); -} - -pub(crate) fn print_diagnostics_from_error( - diagnostics: &[fabro_validate::Diagnostic], - styles: &Styles, -) { - print_diagnostics(diagnostics, styles); -} - -pub(crate) fn print_run_summary(run_dir: &Path, run_id: impl std::fmt::Display, styles: &Styles) { - let run_id = run_id.to_string(); - let conclusion_path = run_dir.join("conclusion.json"); - let Ok(conclusion) = Conclusion::load(&conclusion_path) else { - return; + printer: Printer, +) -> Result<()> { + let run_state = client.get_run_state(run_id).await?; + let checkpoint = run_state.checkpoint.clone(); + let conclusion = run_state.conclusion.clone(); + let pr_url = run_state + .pull_request + .as_ref() + .map(|record: &PullRequestRecord| record.html_url.clone()); + let Some(conclusion) = conclusion else { + return Ok(()); }; - let pr_url = std::fs::read_to_string(run_dir.join("pull_request.json")) - .ok() - .and_then(|content| { - serde_json::from_str::(&content) - .ok() - .map(|record| record.html_url) - }); - print_run_conclusion( &conclusion, - &run_id, - run_dir, + run_id, + local_run_dir, None, pr_url.as_deref(), styles, + printer, ); - print_final_output(run_dir, styles); - print_assets(run_dir, styles); + let final_output = + resolve_final_output_with_client(client, run_id, checkpoint.as_ref()).await?; + print_final_output(final_output.as_deref(), styles, printer); + if local_run_dir.is_some() { + print_assets_with_client(client, run_id, styles, printer).await?; + } + Ok(()) } pub(crate) fn print_run_conclusion( conclusion: &Conclusion, run_id: impl std::fmt::Display, - run_dir: &Path, + run_dir: Option<&Path>, pushed_branch: Option<&str>, pr_url: Option<&str>, styles: &Styles, + printer: Printer, ) { let run_id = run_id.to_string(); - eprintln!("\n{}", styles.bold.apply_to("=== Run Result ===")); - eprintln!("{}", styles.dim.apply_to(format!("Run: {run_id}"))); + fabro_util::printerr!(printer, "\n{}", styles.bold.apply_to("=== Run Result ===")); + fabro_util::printerr!( + printer, + "{}", + styles.dim.apply_to(format!("Run: {run_id}")) + ); let status_str = conclusion.status.to_string().to_uppercase(); let status_color = match conclusion.status { StageStatus::Success | StageStatus::PartialSuccess => &styles.bold_green, _ => &styles.bold_red, }; - eprintln!("Status: {}", status_color.apply_to(&status_str)); - eprintln!( + fabro_util::printerr!(printer, "Status: {}", status_color.apply_to(&status_str)); + fabro_util::printerr!( + printer, "Duration: {}", HumanDuration(Duration::from_millis(conclusion.duration_ms)) ); - let total_tokens = conclusion.total_input_tokens + conclusion.total_output_tokens; - if total_tokens > 0 { - if conclusion.has_pricing { - if let Some(cost) = conclusion.total_cost { - if cost > 0.0 { - eprintln!( + if let Some(billing) = conclusion.billing.as_ref() { + let total_tokens = billing.total_tokens; + if total_tokens > 0 { + if let Some(total_usd_micros) = billing.total_usd_micros { + if total_usd_micros > 0 { + fabro_util::printerr!( + printer, "{}", styles.dim.apply_to(format!( "Cost: {} ({} toks)", - format_cost(cost), + format_usd_micros(total_usd_micros), format_tokens_human(total_tokens) )) ); } + } else { + fabro_util::printerr!( + printer, + "{}", + styles + .dim + .apply_to(format!("Toks: {}", format_tokens_human(total_tokens))) + ); } - } else { - eprintln!( + if billing.cache_read_tokens > 0 || billing.cache_write_tokens > 0 { + fabro_util::printerr!( + printer, + "{}", + styles.dim.apply_to(format!( + "Cache: {} read, {} write", + format_tokens_human(billing.cache_read_tokens), + format_tokens_human(billing.cache_write_tokens), + )), + ); + } + if billing.reasoning_tokens > 0 { + fabro_util::printerr!( + printer, + "{}", + styles.dim.apply_to(format!( + "Reasoning: {} tokens", + format_tokens_human(billing.reasoning_tokens), + )), + ); + } + } else if billing.total_usd_micros.is_none() { + fabro_util::printerr!( + printer, "{}", styles .dim .apply_to(format!("Toks: {}", format_tokens_human(total_tokens))) ); } - if conclusion.total_cache_read_tokens > 0 { - eprintln!( - "{}", - styles.dim.apply_to(format!( - "Cache: {} read, {} write", - format_tokens_human(conclusion.total_cache_read_tokens), - format_tokens_human(conclusion.total_cache_write_tokens), - )), - ); - } - if conclusion.total_reasoning_tokens > 0 { - eprintln!( - "{}", - styles.dim.apply_to(format!( - "Reasoning: {} tokens", - format_tokens_human(conclusion.total_reasoning_tokens), - )), - ); - } } - eprintln!( - "{}", - styles - .dim - .apply_to(format!("Run: {}", tilde_path(run_dir))) - ); + if let Some(run_dir) = run_dir { + fabro_util::printerr!( + printer, + "{}", + styles + .dim + .apply_to(format!("Run: {}", tilde_path(run_dir))) + ); + } if let Some(ref failure) = conclusion.failure_reason { - eprintln!("Failure: {}", styles.red.apply_to(failure)); + fabro_util::printerr!(printer, "Failure: {}", styles.red.apply_to(failure)); } if pushed_branch.is_some() || pr_url.is_some() { - eprintln!(); + fabro_util::printerr!(printer, ""); if let Some(branch) = pushed_branch { - eprintln!("{} {branch}", styles.bold.apply_to("Pushed branch:")); + fabro_util::printerr!( + printer, + "{} {branch}", + styles.bold.apply_to("Pushed branch:") + ); } if let Some(url) = pr_url { - eprintln!("{} {url}", styles.bold.apply_to("Pull request:")); + fabro_util::printerr!(printer, "{} {url}", styles.bold.apply_to("Pull request:")); } } } -pub(crate) fn print_final_output(run_dir: &Path, styles: &Styles) { - let Ok(checkpoint) = Checkpoint::load(&run_dir.join("checkpoint.json")) else { +pub(crate) fn print_final_output(output: Option<&str>, styles: &Styles, printer: Printer) { + let Some(output) = output else { return; }; + let text = output.trim(); + if !text.is_empty() { + fabro_util::printerr!(printer, "\n{}", styles.bold.apply_to("=== Output ===")); + fabro_util::printerr!(printer, "{}", styles.render_markdown(text)); + } +} + +async fn resolve_final_output_with_client( + client: &server_client::ServerStoreClient, + run_id: &RunId, + checkpoint: Option<&fabro_types::Checkpoint>, +) -> Result> { + let Some(checkpoint) = checkpoint else { + return Ok(None); + }; for node_id in checkpoint.completed_nodes.iter().rev() { let key = format!("response.{node_id}"); - if let Some(serde_json::Value::String(response)) = checkpoint.context_values.get(&key) { - let text = response.trim(); - if !text.is_empty() { - eprintln!("\n{}", styles.bold.apply_to("=== Output ===")); - eprintln!("{}", styles.render_markdown(text)); - } - return; + let Some(serde_json::Value::String(response)) = checkpoint.context_values.get(&key) else { + continue; + }; + let Some(output) = resolve_response_string(client, run_id, response).await? else { + continue; + }; + if !output.trim().is_empty() { + return Ok(Some(output)); } } + + Ok(None) } -pub(crate) fn print_assets(run_dir: &Path, styles: &Styles) { - let runtime_state = RuntimeState::new(run_dir); - let paths = collect_asset_paths(&runtime_state.assets_dir()); - if paths.is_empty() { - return; - } - let home = dirs::home_dir(); - eprintln!("\n{}", styles.bold.apply_to("=== Assets ===")); - for path in &paths { - let display = match &home { - Some(home_dir) => { - let home_str = home_dir.to_string_lossy(); - if let Some(rest) = path.strip_prefix(home_str.as_ref()) { - format!("~{rest}") - } else { - path.clone() - } - } - None => path.clone(), - }; - eprintln!("{display}"); - } +async fn resolve_response_string( + client: &server_client::ServerStoreClient, + run_id: &RunId, + response: &str, +) -> Result> { + let Some(blob_id) = blob_id_from_response(response) else { + return Ok(Some(response.to_string())); + }; + + let Some(bytes) = client.read_run_blob(run_id, &blob_id).await? else { + return Ok(None); + }; + let value: serde_json::Value = + serde_json::from_slice(&bytes).context("blob-backed final output should be valid JSON")?; + + Ok(Some(match value { + serde_json::Value::String(text) => text, + other => other.to_string(), + })) +} + +fn blob_id_from_response(response: &str) -> Option { + parse_blob_ref(response).or_else(|| parse_legacy_blob_file_ref(response)) +} + +async fn list_artifact_display_entries_with_client( + client: &server_client::ServerStoreClient, + run_id: &RunId, +) -> Result> { + let mut entries = Vec::new(); + for entry in client.list_run_artifacts(run_id).await? { + let retry = u32::try_from(entry.retry) + .context("server returned invalid negative artifact retry")?; + entries.push((entry.node_slug, retry, entry.relative_path)); + } + entries.sort(); + Ok(entries) +} + +async fn print_assets_with_client( + client: &server_client::ServerStoreClient, + run_id: &RunId, + styles: &Styles, + printer: Printer, +) -> Result<()> { + let entries = list_artifact_display_entries_with_client(client, run_id).await?; + if entries.is_empty() { + return Ok(()); + } + + let node_width = entries + .iter() + .map(|(node_slug, _, _)| node_slug.len()) + .max() + .unwrap_or(4) + .max(4); + let retry_width = entries + .iter() + .map(|(_, retry, _)| retry.to_string().len()) + .max() + .unwrap_or(5) + .max(5); + + fabro_util::printerr!(printer, "\n{}", styles.bold.apply_to("=== Artifacts ===")); + fabro_util::printerr!( + printer, + "{:retry_width$} PATH", + "NODE", + "RETRY" + ); + for (node_slug, retry, relative_path) in &entries { + fabro_util::printerr!( + printer, + "{node_slug:retry_width$} {relative_path}" + ); + } + fabro_util::printerr!(printer, ""); + fabro_util::printerr!( + printer, + "{}", + styles.dim.apply_to(format!( + "Copy with: fabro artifact cp {run_id}: --node --retry " + )) + ); + Ok(()) } diff --git a/lib/crates/fabro-cli/src/commands/run/overrides.rs b/lib/crates/fabro-cli/src/commands/run/overrides.rs index 14751ff71..9a8a813b4 100644 --- a/lib/crates/fabro-cli/src/commands/run/overrides.rs +++ b/lib/crates/fabro-cli/src/commands/run/overrides.rs @@ -1,9 +1,15 @@ use std::collections::HashMap; +use std::path::{Path, PathBuf}; -use anyhow::Result; -use fabro_config::run::LlmConfig; -use fabro_config::{ConfigLayer, sandbox as sandbox_config}; +use anyhow::{Result, anyhow}; use fabro_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 crate::args::{PreflightArgs, RunArgs}; @@ -19,72 +25,204 @@ pub(crate) fn parse_labels(labels: &[String]) -> HashMap { .collect() } -impl TryFrom<&RunArgs> for ConfigLayer { - type Error = anyhow::Error; +fn model_from_args(model: Option<&str>, provider: Option<&str>) -> Option { + if model.is_none() && provider.is_none() { + return None; + } + Some(RunModelLayer { + provider: provider.map(InterpString::parse), + name: model.map(InterpString::parse), + fallbacks: Vec::new(), + }) +} - fn try_from(args: &RunArgs) -> Result { - let llm = if args.model.is_some() || args.provider.is_some() { - Some(LlmConfig { - model: args.model.clone(), - provider: args.provider.clone(), - fallbacks: None, - }) - } else { - None - }; - let sandbox = if args.sandbox.is_some() || args.preserve_sandbox { - Some(sandbox_config::SandboxConfig { - provider: args - .sandbox - .map(Into::into) - .map(|provider: SandboxProvider| provider.to_string()), - preserve: sparse_flag(args.preserve_sandbox), - ..Default::default() - }) - } else { - None - }; +fn sandbox_layer( + sandbox: Option, + preserve: Option, +) -> Option { + if sandbox.is_none() && preserve.is_none() { + return None; + } + Some(RunSandboxLayer { + provider: sandbox.map(|p| p.to_string()), + preserve, + ..RunSandboxLayer::default() + }) +} - Ok(Self { - goal: args.goal.clone(), - goal_file: args.goal_file.clone(), - llm, - sandbox, - verbose: sparse_flag(args.verbose), - dry_run: sparse_flag(args.dry_run), - auto_approve: sparse_flag(args.auto_approve), - no_retro: sparse_flag(args.no_retro), - labels: parse_labels(&args.label), - ..Default::default() - }) +fn execution_layer( + dry_run: Option, + auto_approve: Option, + no_retro: Option, +) -> Option { + if dry_run.is_none() && auto_approve.is_none() && no_retro.is_none() { + return None; + } + Some(RunExecutionLayer { + mode: dry_run.map(|d| if d { RunMode::DryRun } else { RunMode::Normal }), + approval: auto_approve.map(|a| { + if a { + ApprovalMode::Auto + } else { + ApprovalMode::Prompt + } + }), + retros: no_retro.map(|nr| !nr), + }) +} + +fn cli_layer_for_verbose(verbose: bool) -> Option { + verbose.then(|| CliLayer { + output: Some(CliOutputLayer { + verbosity: Some(OutputVerbosity::Verbose), + ..CliOutputLayer::default() + }), + ..CliLayer::default() + }) +} + +/// Build the `run.goal` override from the `--goal` / `--goal-file` args. +/// +/// The two are mutually exclusive at the clap level; this helper assumes +/// at most one is set and returns an error if that invariant is violated. +/// +/// CLI-supplied file paths are anchored at `cwd` (where the user invoked +/// the command), matching standard Unix CLI-flag conventions. +fn goal_layer_from_args( + goal: Option<&str>, + goal_file: Option<&Path>, + cwd: &Path, +) -> Result> { + match (goal, goal_file) { + (Some(_), Some(_)) => Err(anyhow!( + "--goal and --goal-file are mutually exclusive; use exactly one" + )), + (Some(text), None) => Ok(Some(RunGoalLayer::Inline(InterpString::parse(text)))), + (None, Some(path)) => { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + cwd.join(path) + }; + Ok(Some(RunGoalLayer::File { + file: InterpString::parse(&absolute.to_string_lossy()), + })) + } + (None, None) => Ok(None), } } -impl TryFrom<&PreflightArgs> for ConfigLayer { - type Error = anyhow::Error; +fn current_dir_or_dot() -> PathBuf { + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) +} - fn try_from(args: &PreflightArgs) -> Result { - let llm = if args.model.is_some() || args.provider.is_some() { - Some(LlmConfig { - model: args.model.clone(), - provider: args.provider.clone(), - fallbacks: None, - }) - } else { - None +pub(crate) fn run_args_layer(args: &RunArgs) -> Result { + let model = model_from_args(args.model.as_deref(), args.provider.as_deref()); + let sandbox = sandbox_layer( + args.sandbox.map(Into::into), + sparse_flag(args.preserve_sandbox), + ); + let execution = execution_layer( + sparse_flag(args.dry_run), + sparse_flag(args.auto_approve), + sparse_flag(args.no_retro), + ); + + let cwd = current_dir_or_dot(); + let goal = goal_layer_from_args(args.goal.as_deref(), args.goal_file.as_deref(), &cwd)?; + + let run = RunLayer { + goal, + metadata: parse_labels(&args.label), + model, + sandbox, + execution, + ..RunLayer::default() + }; + + Ok(SettingsLayer { + run: Some(run), + cli: cli_layer_for_verbose(args.verbose), + ..SettingsLayer::default() + }) +} + +pub(crate) fn preflight_args_layer(args: &PreflightArgs) -> Result { + let model = model_from_args(args.model.as_deref(), args.provider.as_deref()); + let sandbox = args.sandbox.map(|s| RunSandboxLayer { + provider: Some(SandboxProvider::from(s).to_string()), + ..RunSandboxLayer::default() + }); + + let cwd = current_dir_or_dot(); + let goal = goal_layer_from_args(args.goal.as_deref(), args.goal_file.as_deref(), &cwd)?; + + let run = RunLayer { + goal, + model, + sandbox, + ..RunLayer::default() + }; + + Ok(SettingsLayer { + run: Some(run), + cli: cli_layer_for_verbose(args.verbose), + ..SettingsLayer::default() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn goal_and_goal_file_together_is_rejected() { + let err = goal_layer_from_args( + Some("inline text"), + Some(Path::new("goal.md")), + Path::new("/tmp"), + ) + .unwrap_err(); + assert!(err.to_string().contains("mutually exclusive")); + } + + #[test] + fn goal_file_is_anchored_at_cwd_when_relative() { + let layer = + goal_layer_from_args(None, Some(Path::new("prompts/goal.md")), Path::new("/cwd")) + .unwrap() + .expect("should build a goal layer"); + let RunGoalLayer::File { file } = layer else { + panic!("expected file variant"); }; - let sandbox = args.sandbox.map(|sandbox| sandbox_config::SandboxConfig { - provider: Some(SandboxProvider::from(sandbox).to_string()), - ..Default::default() - }); + assert_eq!(file.as_source(), "/cwd/prompts/goal.md"); + } - Ok(Self { - goal: args.goal.clone(), - goal_file: args.goal_file.clone(), - llm, - sandbox, - verbose: sparse_flag(args.verbose), - ..Default::default() - }) + #[test] + fn absolute_goal_file_is_preserved() { + let layer = goal_layer_from_args(None, Some(Path::new("/abs/goal.md")), Path::new("/cwd")) + .unwrap() + .expect("should build a goal layer"); + let RunGoalLayer::File { file } = layer else { + panic!("expected file variant"); + }; + assert_eq!(file.as_source(), "/abs/goal.md"); + } + + #[test] + fn inline_goal_builds_inline_variant() { + let layer = goal_layer_from_args(Some("inline goal"), None, Path::new("/cwd")) + .unwrap() + .expect("should build a goal layer"); + assert!(matches!(layer, RunGoalLayer::Inline(_))); + } + + #[test] + fn empty_args_produce_no_goal_layer() { + assert!( + goal_layer_from_args(None, None, Path::new("/cwd")) + .unwrap() + .is_none() + ); } } diff --git a/lib/crates/fabro-cli/src/commands/run/preview.rs b/lib/crates/fabro-cli/src/commands/run/preview.rs index 6fe90adea..67da6dd0f 100644 --- a/lib/crates/fabro-cli/src/commands/run/preview.rs +++ b/lib/crates/fabro-cli/src/commands/run/preview.rs @@ -1,81 +1,67 @@ use anyhow::{Context, Result}; -use fabro_config::FabroSettingsExt; -use fabro_sandbox::SandboxRecordExt; -use fabro_sandbox::daytona::DaytonaSandbox; -use fabro_workflow::run_lookup::{resolve_run_combined, runs_base}; +use fabro_util::printer::Printer; use tracing::info; use crate::args::{GlobalArgs, PreviewArgs}; -use crate::shared::{print_json_pretty, validate_daytona_provider}; -use crate::store; -use crate::user_config::load_user_settings_with_globals; +use crate::command_context::CommandContext; +use crate::server_runs::ServerSummaryLookup; +use crate::shared::print_json_pretty; -pub(crate) async fn run(args: PreviewArgs, globals: &GlobalArgs) -> Result<()> { - let cli_settings = load_user_settings_with_globals(globals)?; - let base = runs_base(&cli_settings.storage_dir()); - let store = store::build_store(&cli_settings.storage_dir())?; - let run = resolve_run_combined(store.as_ref(), &base, &args.run).await?; - let sandbox_json = run.path.join("sandbox.json"); - let record = match store::open_run_reader(&cli_settings.storage_dir(), &run.run_id).await? { - Some(run_store) => run_store - .get_sandbox() - .await - .ok() - .flatten() - .or_else(|| fabro_sandbox::SandboxRecord::load(&sandbox_json).ok()) - .context( - "Failed to load sandbox.json — was this run started with a recent version of arc?", - )?, - None => fabro_sandbox::SandboxRecord::load(&sandbox_json).context( - "Failed to load sandbox.json — was this run started with a recent version of arc?", - )?, - }; +pub(crate) async fn run(args: PreviewArgs, globals: &GlobalArgs, printer: Printer) -> Result<()> { + let ctx = CommandContext::for_target(&args.server, printer)?; + let lookup = ServerSummaryLookup::from_client(ctx.server().await?).await?; + let run = lookup.resolve(&args.run)?; + let run_id = run.run_id(); + let expires_in_secs = + u64::try_from(args.ttl).map_err(|_| anyhow::anyhow!("--ttl must be positive"))?; + let response = lookup + .client() + .generate_preview_url( + &run_id, + args.port, + expires_in_secs, + args.signed || args.open, + ) + .await?; - validate_daytona_provider(&record, "Preview URLs")?; + info!(run_id = %args.run, port = args.port, "Generating preview URL"); - let name = record - .identifier - .as_deref() - .context("Daytona sandbox record missing identifier (sandbox name)")?; - - info!(run_id = %args.run, provider = %record.provider, port = args.port, "Generating preview URL"); - - let daytona = DaytonaSandbox::reconnect(name) - .await - .map_err(|e| anyhow::anyhow!("{e}"))?; - - if args.signed || args.open { - let signed = daytona - .get_signed_preview_url(args.port, Some(args.ttl)) - .await - .map_err(|e| anyhow::anyhow!("{e}"))?; - if globals.json { - print_json_pretty(&serde_json::json!({ "url": signed.url }))?; - } else { - print!("{}", format_signed_output(&signed.url)); + if globals.json { + match response.token { + Some(token) => { + print_json_pretty(&serde_json::json!({ "url": response.url, "token": token }))?; + } + None => { + print_json_pretty(&serde_json::json!({ "url": response.url }))?; + } } - - if args.open && !globals.json { - std::process::Command::new("open") - .arg(&signed.url) - .spawn() - .context("Failed to open browser")?; + } else if let Some(token) = response.token.as_deref() { + { + use std::fmt::Write as _; + let _ = write!( + printer.stdout(), + "{}", + format_standard_output(&response.url, token) + ); } } else { - let preview = daytona - .get_preview_link(args.port) - .await - .map_err(|e| anyhow::anyhow!("{e}"))?; - if globals.json { - print_json_pretty(&serde_json::json!({ - "url": preview.url, - "token": preview.token, - }))?; - } else { - print!("{}", format_standard_output(&preview.url, &preview.token)); + { + use std::fmt::Write as _; + let _ = write!(printer.stdout(), "{}", format_signed_output(&response.url)); } } + if args.open && !globals.json { + #[expect( + clippy::disallowed_methods, + reason = "Preview URL opening is a fire-and-forget OS integration, not a Tokio-managed child process." + )] + let _browser = std::process::Command::new("open") + .arg(&response.url) + .spawn() + .context("Failed to open browser")?; + } + Ok(()) } diff --git a/lib/crates/fabro-cli/src/commands/run/resume.rs b/lib/crates/fabro-cli/src/commands/run/resume.rs index 5bf104fdc..eb37be3d0 100644 --- a/lib/crates/fabro-cli/src/commands/run/resume.rs +++ b/lib/crates/fabro-cli/src/commands/run/resume.rs @@ -1,60 +1,54 @@ -use anyhow::bail; -use fabro_config::FabroSettingsExt; +use fabro_util::printer::Printer; use fabro_util::terminal::Styles; -use fabro_workflow::records::{RunRecord, RunRecordExt}; -use fabro_workflow::run_lookup::{resolve_run_combined, runs_base}; use crate::args::{GlobalArgs, ResumeArgs}; +use crate::command_context::CommandContext; +use crate::server_runs::ServerSummaryLookup; use crate::shared::print_json_pretty; -use crate::store; -use crate::user_config::load_user_settings_with_globals; /// Resume an interrupted workflow run. /// /// Looks up the run by ID prefix, validates a checkpoint exists, cleans stale -/// artifacts from the previous execution, then spawns an engine subprocess +/// artifacts from the previous execution, then asks the server to resume it /// (identical to `fabro run`'s create→start→attach flow). pub(crate) async fn resume_command( args: ResumeArgs, styles: &'static Styles, globals: &GlobalArgs, + printer: Printer, ) -> anyhow::Result<()> { - let cli_settings = load_user_settings_with_globals(globals)?; - let base = runs_base(&cli_settings.storage_dir()); - let store = store::build_store(&cli_settings.storage_dir())?; - let run = resolve_run_combined(store.as_ref(), &base, &args.run).await?; - let run_dir = run.path; + let ctx = CommandContext::for_target(&args.server, printer)?; + let lookup = ServerSummaryLookup::from_client(ctx.server().await?).await?; + let run = lookup.resolve(&args.run)?; + let run_id = run.run_id(); - // find_run_by_prefix can match orphan directories (no run.json). - if !run_dir.join("run.json").exists() { - bail!("run directory exists but has no run.json — cannot resume"); - } - let run_id = RunRecord::load(&run_dir)?.run_id; - - if launcher_pid_alive(&run_dir) { - bail!("an engine process is still running for this run — cannot resume"); - } - - let child = super::start::start_run(&run_dir, true)?; + super::start::start_run_with_client(lookup.client(), &run_id, true).await?; if args.detach { if globals.json { print_json_pretty(&serde_json::json!({ "run_id": run_id }))?; } else { - println!("{run_id}"); + fabro_util::printout!(printer, "{run_id}"); } } else { - let exit_code = super::attach::attach_run( - &run_dir, - Some(&run_id), + let exit_code = super::attach::attach_run_with_client( + lookup.client(), + &run_id, true, styles, - Some(child), globals.json, + printer, ) .await?; if !globals.json { - super::output::print_run_summary(&run_dir, run_id, styles); + super::output::print_run_summary_with_client( + lookup.client(), + &run_id, + None, + styles, + printer, + ) + .await?; } if exit_code != std::process::ExitCode::SUCCESS { std::process::exit(1); @@ -62,32 +56,3 @@ pub(crate) async fn resume_command( } Ok(()) } - -fn launcher_pid_alive(run_dir: &std::path::Path) -> bool { - super::launcher::active_launcher_record_for_run(run_dir) - .is_some_and(|record| process_alive(record.pid)) -} - -#[allow(unsafe_code)] -fn process_alive(pid: u32) -> bool { - #[cfg(unix)] - { - unsafe { libc::kill(i32::try_from(pid).unwrap(), 0) == 0 } - } - #[cfg(not(unix))] - { - let _ = pid; - true - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn launcher_pid_alive_returns_false_for_missing_record() { - let dir = tempfile::tempdir().unwrap(); - assert!(!launcher_pid_alive(dir.path())); - } -} diff --git a/lib/crates/fabro-cli/src/commands/run/rewind.rs b/lib/crates/fabro-cli/src/commands/run/rewind.rs index ed2257182..89dc129ef 100644 --- a/lib/crates/fabro-cli/src/commands/run/rewind.rs +++ b/lib/crates/fabro-cli/src/commands/run/rewind.rs @@ -1,74 +1,72 @@ -use anyhow::Context; -use anyhow::Result; +use anyhow::{Context, Result}; use cli_table::format::{Border, Separator}; use cli_table::{Cell, CellStruct, Color, Style, Table}; use fabro_checkpoint::git::Store; -use fabro_config::FabroSettingsExt; +use fabro_types::run_event::{CheckpointCompletedProps, RunRewoundProps, RunSubmittedProps}; +use fabro_types::{EventBody, RunEvent}; +use fabro_util::printer::Printer; use fabro_util::terminal::Styles; use fabro_workflow::git::MetadataStore; use fabro_workflow::operations::{ - RewindInput, RewindTarget, RunTimeline, build_timeline_or_rebuild, - find_run_id_by_prefix_or_store, rewind, + RewindInput, RewindTarget, RunTimeline, TimelineEntry, build_timeline_or_rebuild, rewind, }; -use fabro_workflow::records::CheckpointExt; -use fabro_workflow::run_lookup::{resolve_run_combined, runs_base}; -use fabro_workflow::run_status::{self, RunStatus}; use git2::Repository; use serde::Serialize; use crate::args::{GlobalArgs, RewindArgs}; +use crate::command_context::CommandContext; +use crate::commands::store::rebuild::rebuild_run_store; +use crate::server_client::ServerStoreClient; +use crate::server_runs::ServerSummaryLookup; +use crate::shared::repo::ensure_matching_repo_origin; use crate::shared::{color_if, print_json_pretty}; -use crate::store::{build_store, open_run_reader}; -use crate::user_config::load_user_settings_with_globals; #[derive(Serialize)] pub(crate) struct TimelineEntryJson { - ordinal: usize, - node_name: String, - visit: usize, + ordinal: usize, + node_name: String, + visit: usize, run_commit_sha: Option, } -pub(crate) async fn run(args: &RewindArgs, styles: &Styles, globals: &GlobalArgs) -> Result<()> { +pub(crate) async fn run( + args: &RewindArgs, + styles: &Styles, + globals: &GlobalArgs, + printer: Printer, +) -> Result<()> { let repo = Repository::discover(".").context("not in a git repository")?; - let cli_settings = load_user_settings_with_globals(globals)?; - let durable_store = build_store(&cli_settings.storage_dir())?; - let run_id = - find_run_id_by_prefix_or_store(&repo, durable_store.as_ref(), &args.run_id).await?; + let ctx = CommandContext::for_target(&args.server, printer)?; + let lookup = ServerSummaryLookup::from_client(ctx.server().await?).await?; + let run = lookup.resolve(&args.run_id)?; + let run_id = run.run_id(); + let state = lookup.client().get_run_state(&run_id).await?; + let record = state.run.context("Failed to load run record from store")?; + ensure_matching_repo_origin(record.repo_origin_url.as_deref(), "rewind")?; let store = Store::new(repo); - let run_store = open_run_reader(&cli_settings.storage_dir(), &run_id).await?; - let run_info = resolve_run_combined( - durable_store.as_ref(), - &runs_base(&cli_settings.storage_dir()), - &run_id.to_string(), - ) - .await - .ok(); + let events = lookup.client().list_run_events(&run_id, None, None).await?; + let run_store = rebuild_run_store(&run_id, &events).await?; - let timeline = build_timeline_or_rebuild(&store, run_store.as_deref(), &run_id).await?; + let timeline = build_timeline_or_rebuild(&store, Some(&run_store), &run_id).await?; if args.list || args.target.is_none() { if globals.json { print_json_pretty(&timeline_entries_json(&timeline))?; return Ok(()); } - print_timeline(&timeline, styles); + print_timeline(&timeline, styles, printer); return Ok(()); } let target = args.target.as_deref().unwrap().parse::()?; - rewind( - &store, - &RewindInput { - run_id, - target, - push: !args.no_push, - }, - )?; - if let Some(run_info) = run_info.as_ref() { - reset_rewound_run_state(&store, durable_store.as_ref(), &run_id, &run_info.path).await?; - } + rewind(&store, &RewindInput { + run_id, + target: target.clone(), + push: !args.no_push, + })?; + let entry = timeline.resolve(&target)?; + reset_rewound_run_state(lookup.client(), &store, &run_id, entry).await?; let run_id_string = run_id.to_string(); @@ -78,7 +76,8 @@ pub(crate) async fn run(args: &RewindArgs, styles: &Styles, globals: &GlobalArgs "target": args.target.as_deref().unwrap(), }))?; } else { - eprintln!( + fabro_util::printerr!( + printer, "\nTo resume: fabro resume {}", &run_id_string[..8.min(run_id_string.len())] ); @@ -92,46 +91,130 @@ pub(crate) fn timeline_entries_json(timeline: &RunTimeline) -> Vec Result<()> { + let state = client.get_run_state(run_id).await.map_err(|err| { + anyhow::anyhow!("failed to load durable store state before rewind: {err}") + })?; + + let definition_blob = state.run.as_ref().and_then(|run| run.definition_blob); + let _run_record = state + .run + .context("failed to restore run record after rewind: missing run metadata")?; let checkpoint = MetadataStore::read_checkpoint(git_store.repo_dir(), &run_id.to_string())? .context("rewound metadata branch is missing checkpoint.json")?; - checkpoint.save(&run_dir.join("checkpoint.json"))?; - run_status::write_run_status(run_dir, RunStatus::Submitted, None); + let previous_status = state.status.map(|status| status.status.to_string()); - for name in [ - "conclusion.json", - "pull_request.json", - "detached_failure.json", - "progress.jsonl", - "retro.json", - "final.patch", - ] { - let _ = std::fs::remove_file(run_dir.join(name)); - } - - durable_store - .delete_run(run_id) + client + .append_run_event( + run_id, + &run_event( + *run_id, + None, + EventBody::RunRewound(RunRewoundProps { + target_checkpoint_ordinal: entry.ordinal, + target_node_id: entry.node_name.clone(), + target_visit: entry.visit, + previous_status, + run_commit_sha: entry.run_commit_sha.clone(), + }), + ), + ) .await - .map_err(|err| anyhow::anyhow!("failed to reset durable store run: {err}"))?; + .map_err(|err| anyhow::anyhow!("failed to append run rewound event: {err}"))?; + client + .append_run_event(run_id, &restored_checkpoint_event(*run_id, &checkpoint)) + .await + .map_err(|err| anyhow::anyhow!("failed to append restored checkpoint event: {err}"))?; + client + .append_run_event( + run_id, + &run_event( + *run_id, + None, + EventBody::RunSubmitted(RunSubmittedProps { + reason: None, + definition_blob, + }), + ), + ) + .await + .map_err(|err| anyhow::anyhow!("failed to append restored run status event: {err}"))?; Ok(()) } -pub(crate) fn print_timeline(timeline: &RunTimeline, styles: &Styles) { +fn restored_checkpoint_event( + run_id: fabro_types::RunId, + checkpoint: &fabro_types::Checkpoint, +) -> RunEvent { + let current_status = checkpoint + .node_outcomes + .get(&checkpoint.current_node) + .map_or_else( + || "success".to_string(), + |outcome| outcome.status.to_string(), + ); + run_event( + run_id, + Some(checkpoint.current_node.clone()), + EventBody::CheckpointCompleted(CheckpointCompletedProps { + status: current_status, + current_node: checkpoint.current_node.clone(), + completed_nodes: checkpoint.completed_nodes.clone(), + node_retries: checkpoint.node_retries.clone().into_iter().collect(), + context_values: checkpoint.context_values.clone().into_iter().collect(), + node_outcomes: checkpoint.node_outcomes.clone().into_iter().collect(), + next_node_id: checkpoint.next_node_id.clone(), + git_commit_sha: checkpoint.git_commit_sha.clone(), + loop_failure_signatures: checkpoint + .loop_failure_signatures + .iter() + .map(|(sig, count)| (sig.to_string(), *count)) + .collect(), + restart_failure_signatures: checkpoint + .restart_failure_signatures + .iter() + .map(|(sig, count)| (sig.to_string(), *count)) + .collect(), + node_visits: checkpoint.node_visits.clone().into_iter().collect(), + diff: None, + }), + ) +} + +fn run_event(run_id: fabro_types::RunId, node_id: Option, body: EventBody) -> RunEvent { + RunEvent { + id: ulid::Ulid::new().to_string(), + ts: chrono::Utc::now(), + run_id, + node_id, + 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: None, + body, + } +} + +pub(crate) fn print_timeline(timeline: &RunTimeline, styles: &Styles, printer: Printer) { if timeline.entries.is_empty() { - eprintln!("No checkpoints found."); + fabro_util::printerr!(printer, "No checkpoints found."); return; } diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs index c7ef9f6e0..6c58ab45e 100644 --- a/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs +++ b/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs @@ -1,31 +1,24 @@ use std::convert::TryFrom; use chrono::{DateTime, Utc}; +use fabro_types::{BilledModelUsage, EventBody, RunEvent}; use fabro_workflow::event::RunNoticeLevel; -use fabro_workflow::outcome::{StageUsage, compute_stage_cost}; -use serde_json::{Map, Value}; +use serde_json::Value; #[derive(Debug, Clone)] pub(super) struct ProgressUsage { - pub(super) model: Option, - pub(super) input_tokens: u64, + pub(super) input_tokens: u64, pub(super) output_tokens: u64, - pub(super) speed: Option, - pub(super) cost: Option, + pub(super) cost: Option, } impl ProgressUsage { - pub(super) fn from_value(value: &Value) -> Option { - let Value::Object(fields) = value else { - return None; - }; - + pub(super) fn from_stage_usage(usage: &BilledModelUsage) -> Option { + let tokens = usage.tokens(); Some(Self { - model: string_field(fields, "model"), - input_tokens: u64_field(fields, "input_tokens"), - output_tokens: u64_field(fields, "output_tokens"), - speed: string_field(fields, "speed"), - cost: f64_field(fields, "cost"), + input_tokens: u64::try_from(tokens.input_tokens).ok()?, + output_tokens: u64::try_from(tokens.billable_output_tokens()).ok()?, + cost: usage.total_usd_micros.map(|cost| cost as f64 / 1_000_000.0), }) } @@ -34,22 +27,7 @@ impl ProgressUsage { } pub(super) fn display_cost(&self) -> Option { - self.cost.or_else(|| { - let model = self.model.clone()?; - let input_tokens = i64::try_from(self.input_tokens).ok()?; - let output_tokens = i64::try_from(self.output_tokens).ok()?; - let usage = StageUsage { - model, - input_tokens, - output_tokens, - cache_read_tokens: None, - cache_write_tokens: None, - reasoning_tokens: None, - speed: self.speed.clone(), - cost: None, - }; - compute_stage_cost(&usage) - }) + self.cost } } @@ -57,8 +35,8 @@ impl ProgressUsage { pub(super) enum ProgressEvent { WorkflowStarted { worktree_dir: Option, - base_branch: Option, - base_sha: Option, + base_branch: Option, + base_sha: Option, }, WorkingDirectorySet { working_directory: String, @@ -67,12 +45,12 @@ pub(super) enum ProgressEvent { provider: String, }, SandboxReady { - provider: String, + provider: String, duration_ms: u64, - name: Option, - cpu: Option, - memory: Option, - url: Option, + name: Option, + cpu: Option, + memory: Option, + url: Option, }, SshAccessReady { ssh_command: String, @@ -84,98 +62,98 @@ pub(super) enum ProgressEvent { duration_ms: u64, }, SetupCommandCompleted { - command: String, + command: String, command_index: u64, - exit_code: i64, - duration_ms: u64, + exit_code: i64, + duration_ms: u64, }, CliEnsureStarted { cli_name: String, }, CliEnsureCompleted { - cli_name: String, + cli_name: String, already_installed: bool, - duration_ms: u64, + duration_ms: u64, }, CliEnsureFailed { cli_name: String, }, DevcontainerResolved { - dockerfile_lines: u64, - environment_count: u64, + dockerfile_lines: u64, + environment_count: u64, lifecycle_command_count: u64, - workspace_folder: String, + workspace_folder: String, }, DevcontainerLifecycleStarted { - phase: String, + phase: String, command_count: u64, }, DevcontainerLifecycleCompleted { - phase: String, + phase: String, duration_ms: u64, }, DevcontainerLifecycleFailed { - phase: String, - command: String, + phase: String, + command: String, exit_code: i64, - stderr: String, + stderr: String, }, DevcontainerLifecycleCommandCompleted { - command: String, + command: String, command_index: u64, - exit_code: i64, - duration_ms: u64, + exit_code: i64, + duration_ms: u64, }, StageStarted { node_id: String, - name: String, - script: Option, + name: String, + script: Option, }, StageCompleted { - node_id: String, - name: String, + node_id: String, + name: String, duration_ms: u64, - status: String, - usage: Option, + status: String, + usage: Option, }, StageFailed { node_id: String, - name: String, - error: String, + name: String, + error: String, }, StageRetrying { - name: String, - attempt: u64, + name: String, + attempt: u64, max_attempts: u64, - delay_ms: u64, + delay_ms: u64, }, ParallelStarted, ParallelBranchStarted { branch: String, }, ParallelBranchCompleted { - branch: String, + branch: String, duration_ms: u64, - status: String, + status: String, }, ParallelCompleted, AssistantMessage { stage_node_id: String, - model: String, + model: String, }, ToolCallStarted { stage_node_id: String, - tool_name: String, - tool_call_id: String, - arguments: Value, - timestamp: Option>, + tool_name: String, + tool_call_id: String, + arguments: Value, + timestamp: Option>, }, ToolCallCompleted { stage_node_id: String, - tool_call_id: String, - is_error: bool, - duration_ms: Option, - timestamp: Option>, + tool_call_id: String, + is_error: bool, + duration_ms: Option, + timestamp: Option>, }, ContextWindowWarning { stage_node_id: String, @@ -185,38 +163,38 @@ pub(super) enum ProgressEvent { stage_node_id: String, }, CompactionCompleted { - stage_node_id: String, - original_turn_count: u64, + stage_node_id: String, + original_turn_count: u64, preserved_turn_count: u64, - tracked_file_count: u64, + tracked_file_count: u64, }, LlmRetry { stage_node_id: String, - model: String, - attempt: u64, - delay_ms: u64, - error: String, + model: String, + attempt: u64, + delay_ms: u64, + error: String, }, SubagentSpawned { stage_node_id: String, - agent_id: String, - task: String, + agent_id: String, + task: String, }, SubagentCompleted { stage_node_id: String, - agent_id: String, - success: bool, - turns_used: u64, + agent_id: String, + success: bool, + turns_used: u64, }, EdgeSelected { from_node: String, - to_node: String, - label: Option, + to_node: String, + label: Option, condition: Option, }, LoopRestart { from_node: String, - to_node: String, + to_node: String, }, RetroStarted, RetroCompleted { @@ -226,264 +204,242 @@ pub(super) enum ProgressEvent { duration_ms: u64, }, RunNotice { - level: RunNoticeLevel, - code: String, + level: RunNoticeLevel, + code: String, message: String, }, PullRequestCreated { pr_url: String, - draft: bool, + draft: bool, }, PullRequestFailed { error: String, }, } -#[allow(clippy::needless_pass_by_value)] -pub(super) fn from_envelope_fields( - event_name: &str, - fields: &Map, -) -> Option { - match event_name { - "run.started" => Some(ProgressEvent::WorkflowStarted { - worktree_dir: prop_string_field(fields, "worktree_dir"), - base_branch: prop_string_field(fields, "base_branch"), - base_sha: prop_string_field(fields, "base_sha"), +pub(super) fn from_run_event(stored: &RunEvent) -> Option { + let node_id = stored.node_id.clone().unwrap_or_else(|| "?".to_string()); + let node_label = stored.node_label.clone().unwrap_or_else(|| node_id.clone()); + + match &stored.body { + EventBody::RunStarted(props) => Some(ProgressEvent::WorkflowStarted { + worktree_dir: props.worktree_dir.clone(), + base_branch: props.base_branch.clone(), + base_sha: props.base_sha.clone(), }), - "sandbox.initialized" => Some(ProgressEvent::WorkingDirectorySet { - working_directory: prop_string_field(fields, "working_directory")?, + EventBody::SandboxInitialized(props) => Some(ProgressEvent::WorkingDirectorySet { + working_directory: props.working_directory.clone(), }), - "sandbox.initializing" => Some(ProgressEvent::SandboxInitializing { - provider: prop_string_field(fields, "provider") - .unwrap_or_else(|| "unknown".to_string()), + EventBody::SandboxInitializing(props) => Some(ProgressEvent::SandboxInitializing { + provider: props.provider.clone(), }), - "sandbox.ready" => Some(ProgressEvent::SandboxReady { - provider: prop_string_field(fields, "provider") - .unwrap_or_else(|| "unknown".to_string()), - duration_ms: prop_u64_field(fields, "duration_ms"), - name: prop_string_field(fields, "name"), - cpu: prop_f64_field(fields, "cpu"), - memory: prop_f64_field(fields, "memory"), - url: prop_string_field(fields, "url"), + EventBody::SandboxReady(props) => Some(ProgressEvent::SandboxReady { + provider: props.provider.clone(), + duration_ms: props.duration_ms, + name: props.name.clone(), + cpu: props.cpu, + memory: props.memory, + url: props.url.clone(), }), - "ssh.ready" => Some(ProgressEvent::SshAccessReady { - ssh_command: prop_string_field(fields, "ssh_command")?, + EventBody::SshAccessReady(props) => Some(ProgressEvent::SshAccessReady { + ssh_command: props.ssh_command.clone(), }), - "setup.started" => Some(ProgressEvent::SetupStarted { - command_count: prop_u64_field(fields, "command_count"), + EventBody::SetupStarted(props) => Some(ProgressEvent::SetupStarted { + command_count: props.command_count as u64, }), - "setup.completed" => Some(ProgressEvent::SetupCompleted { - duration_ms: prop_u64_field(fields, "duration_ms"), + EventBody::SetupCompleted(props) => Some(ProgressEvent::SetupCompleted { + duration_ms: props.duration_ms, }), - "setup.command.completed" => Some(ProgressEvent::SetupCommandCompleted { - command: prop_string_field(fields, "command").unwrap_or_else(|| "?".to_string()), - command_index: prop_u64_field(fields, "index"), - exit_code: prop_i64_field(fields, "exit_code"), - duration_ms: prop_u64_field(fields, "duration_ms"), + EventBody::SetupCommandCompleted(props) => Some(ProgressEvent::SetupCommandCompleted { + command: props.command.clone(), + command_index: props.index as u64, + exit_code: i64::from(props.exit_code), + duration_ms: props.duration_ms, }), - "cli.ensure.started" => Some(ProgressEvent::CliEnsureStarted { - cli_name: prop_string_field(fields, "cli_name").unwrap_or_else(|| "?".to_string()), + EventBody::CliEnsureStarted(props) => Some(ProgressEvent::CliEnsureStarted { + cli_name: props.cli_name.clone(), }), - "cli.ensure.completed" => Some(ProgressEvent::CliEnsureCompleted { - cli_name: prop_string_field(fields, "cli_name").unwrap_or_else(|| "?".to_string()), - already_installed: prop_bool_field(fields, "already_installed"), - duration_ms: prop_u64_field(fields, "duration_ms"), + EventBody::CliEnsureCompleted(props) => Some(ProgressEvent::CliEnsureCompleted { + cli_name: props.cli_name.clone(), + already_installed: props.already_installed, + duration_ms: props.duration_ms, }), - "cli.ensure.failed" => Some(ProgressEvent::CliEnsureFailed { - cli_name: prop_string_field(fields, "cli_name").unwrap_or_else(|| "?".to_string()), + EventBody::CliEnsureFailed(props) => Some(ProgressEvent::CliEnsureFailed { + cli_name: props.cli_name.clone(), }), - "devcontainer.resolved" => Some(ProgressEvent::DevcontainerResolved { - dockerfile_lines: prop_u64_field(fields, "dockerfile_lines"), - environment_count: prop_u64_field(fields, "environment_count"), - lifecycle_command_count: prop_u64_field(fields, "lifecycle_command_count"), - workspace_folder: prop_string_field(fields, "workspace_folder") - .unwrap_or_else(|| "?".to_string()), + EventBody::DevcontainerResolved(props) => Some(ProgressEvent::DevcontainerResolved { + dockerfile_lines: props.dockerfile_lines as u64, + environment_count: props.environment_count as u64, + lifecycle_command_count: props.lifecycle_command_count as u64, + workspace_folder: props.workspace_folder.clone(), }), - "devcontainer.lifecycle.started" => Some(ProgressEvent::DevcontainerLifecycleStarted { - phase: prop_string_field(fields, "phase").unwrap_or_else(|| "?".to_string()), - command_count: prop_u64_field(fields, "command_count"), - }), - "devcontainer.lifecycle.completed" => Some(ProgressEvent::DevcontainerLifecycleCompleted { - phase: prop_string_field(fields, "phase").unwrap_or_else(|| "?".to_string()), - duration_ms: prop_u64_field(fields, "duration_ms"), - }), - "devcontainer.lifecycle.failed" => Some(ProgressEvent::DevcontainerLifecycleFailed { - phase: prop_string_field(fields, "phase").unwrap_or_else(|| "?".to_string()), - command: prop_string_field(fields, "command").unwrap_or_else(|| "?".to_string()), - exit_code: prop_i64_field(fields, "exit_code"), - stderr: prop_display_field(fields, "stderr").unwrap_or_default(), - }), - "devcontainer.lifecycle.command.completed" => { - Some(ProgressEvent::DevcontainerLifecycleCommandCompleted { - command: prop_string_field(fields, "command").unwrap_or_else(|| "?".to_string()), - command_index: prop_u64_field(fields, "index"), - exit_code: prop_i64_field(fields, "exit_code"), - duration_ms: prop_u64_field(fields, "duration_ms"), + EventBody::DevcontainerLifecycleStarted(props) => { + Some(ProgressEvent::DevcontainerLifecycleStarted { + phase: props.phase.clone(), + command_count: props.command_count as u64, }) } - "stage.started" => Some(ProgressEvent::StageStarted { - node_id: string_field(fields, "node_id").unwrap_or_else(|| "?".to_string()), - name: string_field(fields, "node_label").unwrap_or_else(|| "?".to_string()), - script: prop_string_field(fields, "script"), + EventBody::DevcontainerLifecycleCompleted(props) => { + Some(ProgressEvent::DevcontainerLifecycleCompleted { + phase: props.phase.clone(), + duration_ms: props.duration_ms, + }) + } + EventBody::DevcontainerLifecycleFailed(props) => { + Some(ProgressEvent::DevcontainerLifecycleFailed { + phase: props.phase.clone(), + command: props.command.clone(), + exit_code: i64::from(props.exit_code), + stderr: props.stderr.clone(), + }) + } + EventBody::DevcontainerLifecycleCommandCompleted(props) => { + Some(ProgressEvent::DevcontainerLifecycleCommandCompleted { + command: props.command.clone(), + command_index: props.index as u64, + exit_code: i64::from(props.exit_code), + duration_ms: props.duration_ms, + }) + } + EventBody::StageStarted(_) => Some(ProgressEvent::StageStarted { + node_id, + name: node_label, + script: None, }), - "stage.completed" => Some(ProgressEvent::StageCompleted { - node_id: string_field(fields, "node_id").unwrap_or_else(|| "?".to_string()), - name: string_field(fields, "node_label").unwrap_or_else(|| "?".to_string()), - duration_ms: prop_u64_field(fields, "duration_ms"), - status: prop_string_field(fields, "status").unwrap_or_else(|| "success".to_string()), - usage: prop_value(fields, "usage").and_then(ProgressUsage::from_value), + EventBody::StageCompleted(props) => Some(ProgressEvent::StageCompleted { + node_id, + name: node_label, + duration_ms: props.duration_ms, + status: props.status.to_string(), + usage: props + .billing + .as_ref() + .and_then(ProgressUsage::from_stage_usage), }), - "stage.failed" => Some(ProgressEvent::StageFailed { - node_id: string_field(fields, "node_id").unwrap_or_else(|| "?".to_string()), - name: string_field(fields, "node_label").unwrap_or_else(|| "?".to_string()), - error: prop_display_field(fields, "error") - .unwrap_or_else(|| "unknown error".to_string()), + EventBody::StageFailed(props) => Some(ProgressEvent::StageFailed { + node_id, + name: node_label, + error: props.failure.as_ref().map_or_else( + || "unknown error".to_string(), + |failure| failure.message.clone(), + ), }), - "stage.retrying" => Some(ProgressEvent::StageRetrying { - name: string_field(fields, "node_label").unwrap_or_else(|| "?".to_string()), - attempt: prop_u64_field(fields, "attempt"), - max_attempts: prop_u64_field(fields, "max_attempts"), - delay_ms: prop_u64_field(fields, "delay_ms"), + EventBody::StageRetrying(props) => Some(ProgressEvent::StageRetrying { + name: node_label, + attempt: props.attempt as u64, + max_attempts: props.max_attempts as u64, + delay_ms: props.delay_ms, }), - "parallel.started" => Some(ProgressEvent::ParallelStarted), - "parallel.branch.started" => Some(ProgressEvent::ParallelBranchStarted { - branch: string_field(fields, "node_id").unwrap_or_else(|| "?".to_string()), + EventBody::ParallelStarted(_) => Some(ProgressEvent::ParallelStarted), + EventBody::ParallelBranchStarted(_) => { + Some(ProgressEvent::ParallelBranchStarted { branch: node_id }) + } + EventBody::ParallelBranchCompleted(props) => Some(ProgressEvent::ParallelBranchCompleted { + branch: node_id, + duration_ms: props.duration_ms, + status: props.status.clone(), }), - "parallel.branch.completed" => Some(ProgressEvent::ParallelBranchCompleted { - branch: string_field(fields, "node_id").unwrap_or_else(|| "?".to_string()), - duration_ms: prop_u64_field(fields, "duration_ms"), - status: prop_string_field(fields, "status").unwrap_or_else(|| "success".to_string()), + EventBody::ParallelCompleted(_) => Some(ProgressEvent::ParallelCompleted), + EventBody::AgentMessage(props) => Some(ProgressEvent::AssistantMessage { + stage_node_id: node_id, + model: props.model.clone(), }), - "parallel.completed" => Some(ProgressEvent::ParallelCompleted), - "agent.message" => Some(ProgressEvent::AssistantMessage { - stage_node_id: string_field(fields, "node_id").unwrap_or_else(|| "?".to_string()), - model: prop_string_field(fields, "model").unwrap_or_else(|| "?".to_string()), + EventBody::AgentToolStarted(props) => Some(ProgressEvent::ToolCallStarted { + stage_node_id: node_id, + tool_name: props.tool_name.clone(), + tool_call_id: props.tool_call_id.clone(), + arguments: props.arguments.clone(), + timestamp: Some(stored.ts), }), - "agent.tool.started" => Some(ProgressEvent::ToolCallStarted { - stage_node_id: string_field(fields, "node_id").unwrap_or_else(|| "?".to_string()), - tool_name: prop_string_field(fields, "tool_name").unwrap_or_else(|| "?".to_string()), - tool_call_id: prop_string_field(fields, "tool_call_id") - .unwrap_or_else(|| "?".to_string()), - arguments: prop_value(fields, "arguments") - .cloned() - .unwrap_or_else(|| Value::Object(Map::new())), - timestamp: timestamp_field(fields, "ts"), + EventBody::AgentToolCompleted(props) => Some(ProgressEvent::ToolCallCompleted { + stage_node_id: node_id, + tool_call_id: props.tool_call_id.clone(), + is_error: props.is_error, + duration_ms: None, + timestamp: Some(stored.ts), }), - "agent.tool.completed" => Some(ProgressEvent::ToolCallCompleted { - stage_node_id: string_field(fields, "node_id").unwrap_or_else(|| "?".to_string()), - tool_call_id: prop_string_field(fields, "tool_call_id") - .unwrap_or_else(|| "?".to_string()), - is_error: prop_bool_field(fields, "is_error"), - duration_ms: prop_optional_u64_field(fields, "duration_ms"), - timestamp: timestamp_field(fields, "ts"), - }), - "agent.warning" - if prop_string_field(fields, "kind").as_deref() == Some("context_window") => - { - let usage_percent = prop_value(fields, "details") - .and_then(Value::as_object) + EventBody::AgentWarning(props) if props.kind == "context_window" => { + let usage_percent = props + .details + .as_object() .and_then(|details| details.get("usage_percent")) .and_then(Value::as_u64) .unwrap_or(0); Some(ProgressEvent::ContextWindowWarning { - stage_node_id: string_field(fields, "node_id").unwrap_or_else(|| "?".to_string()), + stage_node_id: node_id, usage_percent, }) } - "agent.compaction.started" => Some(ProgressEvent::CompactionStarted { - stage_node_id: string_field(fields, "node_id").unwrap_or_else(|| "?".to_string()), + EventBody::AgentCompactionStarted(_) => Some(ProgressEvent::CompactionStarted { + stage_node_id: node_id, }), - "agent.compaction.completed" => Some(ProgressEvent::CompactionCompleted { - stage_node_id: string_field(fields, "node_id").unwrap_or_else(|| "?".to_string()), - original_turn_count: prop_u64_field(fields, "original_turn_count"), - preserved_turn_count: prop_u64_field(fields, "preserved_turn_count"), - tracked_file_count: prop_u64_field(fields, "tracked_file_count"), + EventBody::AgentCompactionCompleted(props) => Some(ProgressEvent::CompactionCompleted { + stage_node_id: node_id, + original_turn_count: props.original_turn_count as u64, + preserved_turn_count: props.preserved_turn_count as u64, + tracked_file_count: props.tracked_file_count as u64, }), - "agent.llm.retry" => { - let delay_secs = prop_f64_field(fields, "delay_secs").unwrap_or(0.0); + EventBody::AgentLlmRetry(props) => { #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] - let delay_ms = (delay_secs * 1000.0) as u64; + let delay_ms = (props.delay_secs * 1000.0) as u64; Some(ProgressEvent::LlmRetry { - stage_node_id: string_field(fields, "node_id").unwrap_or_else(|| "?".to_string()), - model: prop_string_field(fields, "model").unwrap_or_else(|| "?".to_string()), - attempt: prop_u64_field(fields, "attempt"), + stage_node_id: node_id, + model: props.model.clone(), + attempt: props.attempt as u64, delay_ms, - error: prop_display_field(fields, "error") - .unwrap_or_else(|| "unknown error".to_string()), + error: display_value(&props.error).unwrap_or_else(|| "unknown error".to_string()), }) } - "agent.sub.spawned" => Some(ProgressEvent::SubagentSpawned { - stage_node_id: string_field(fields, "node_id").unwrap_or_else(|| "?".to_string()), - agent_id: prop_string_field(fields, "agent_id").unwrap_or_else(|| "?".to_string()), - task: prop_string_field(fields, "task").unwrap_or_default(), + EventBody::AgentSubSpawned(props) => Some(ProgressEvent::SubagentSpawned { + stage_node_id: node_id, + agent_id: props.agent_id.clone(), + task: props.task.clone(), }), - "agent.sub.completed" => Some(ProgressEvent::SubagentCompleted { - stage_node_id: string_field(fields, "node_id").unwrap_or_else(|| "?".to_string()), - agent_id: prop_string_field(fields, "agent_id").unwrap_or_else(|| "?".to_string()), - success: prop_bool_field(fields, "success"), - turns_used: prop_u64_field(fields, "turns_used"), + EventBody::AgentSubCompleted(props) => Some(ProgressEvent::SubagentCompleted { + stage_node_id: node_id, + agent_id: props.agent_id.clone(), + success: props.success, + turns_used: props.turns_used as u64, }), - "edge.selected" => Some(ProgressEvent::EdgeSelected { - from_node: prop_string_field(fields, "from_node").unwrap_or_else(|| "?".to_string()), - to_node: prop_string_field(fields, "to_node").unwrap_or_else(|| "?".to_string()), - label: prop_string_field(fields, "label"), - condition: prop_string_field(fields, "condition"), + EventBody::EdgeSelected(props) => Some(ProgressEvent::EdgeSelected { + from_node: props.from_node.clone(), + to_node: props.to_node.clone(), + label: props.label.clone(), + condition: props.condition.clone(), }), - "loop.restart" => Some(ProgressEvent::LoopRestart { - from_node: prop_string_field(fields, "from_node").unwrap_or_else(|| "?".to_string()), - to_node: prop_string_field(fields, "to_node").unwrap_or_else(|| "?".to_string()), + EventBody::LoopRestart(props) => Some(ProgressEvent::LoopRestart { + from_node: props.from_node.clone(), + to_node: props.to_node.clone(), }), - "retro.started" => Some(ProgressEvent::RetroStarted), - "retro.completed" => Some(ProgressEvent::RetroCompleted { - duration_ms: prop_u64_field(fields, "duration_ms"), + EventBody::RetroStarted(_) => Some(ProgressEvent::RetroStarted), + EventBody::RetroCompleted(props) => Some(ProgressEvent::RetroCompleted { + duration_ms: props.duration_ms, }), - "retro.failed" => Some(ProgressEvent::RetroFailed { - duration_ms: prop_u64_field(fields, "duration_ms"), + EventBody::RetroFailed(props) => Some(ProgressEvent::RetroFailed { + duration_ms: props.duration_ms, }), - "run.notice" => Some(ProgressEvent::RunNotice { - level: parse_run_notice_level(prop_string_field(fields, "level").as_deref()), - code: prop_string_field(fields, "code").unwrap_or_default(), - message: prop_string_field(fields, "message").unwrap_or_default(), + EventBody::RunNotice(props) => Some(ProgressEvent::RunNotice { + level: props.level, + code: props.code.clone(), + message: props.message.clone(), }), - "pull_request.created" => Some(ProgressEvent::PullRequestCreated { - pr_url: prop_string_field(fields, "pr_url").unwrap_or_else(|| "?".to_string()), - draft: prop_bool_field(fields, "draft"), + EventBody::PullRequestCreated(props) => Some(ProgressEvent::PullRequestCreated { + pr_url: props.pr_url.clone(), + draft: props.draft, }), - "pull_request.failed" => Some(ProgressEvent::PullRequestFailed { - error: prop_display_field(fields, "error") - .unwrap_or_else(|| "unknown error".to_string()), + EventBody::PullRequestFailed(props) => Some(ProgressEvent::PullRequestFailed { + error: props.error.clone(), }), _ => None, } } -fn parse_run_notice_level(level: Option<&str>) -> RunNoticeLevel { - match level.unwrap_or("info") { - "warn" => RunNoticeLevel::Warn, - "error" => RunNoticeLevel::Error, - _ => RunNoticeLevel::Info, - } +pub(super) fn from_json_line(line: &str) -> Option { + let stored = RunEvent::from_json_str(line).ok()?; + from_run_event(&stored) } -fn string_field(fields: &Map, key: &str) -> Option { - fields.get(key).and_then(Value::as_str).map(str::to_owned) -} - -fn prop_value<'a>(fields: &'a Map, key: &str) -> Option<&'a Value> { - fields - .get("properties") - .and_then(Value::as_object) - .and_then(|properties| properties.get(key)) -} - -fn prop_string_field(fields: &Map, key: &str) -> Option { - prop_value(fields, key) - .and_then(Value::as_str) - .map(str::to_owned) -} - -fn prop_display_field(fields: &Map, key: &str) -> Option { - let value = prop_value(fields, key)?; +fn display_value(value: &Value) -> Option { match value { Value::Null => None, Value::String(value) => Some(value.clone()), @@ -511,73 +467,29 @@ fn prop_display_field(fields: &Map, key: &str) -> Option } } -fn u64_field(fields: &Map, key: &str) -> u64 { - fields.get(key).and_then(Value::as_u64).unwrap_or(0) -} - -fn prop_u64_field(fields: &Map, key: &str) -> u64 { - prop_value(fields, key).and_then(Value::as_u64).unwrap_or(0) -} - -fn prop_optional_u64_field(fields: &Map, key: &str) -> Option { - prop_value(fields, key).and_then(Value::as_u64) -} - -fn prop_i64_field(fields: &Map, key: &str) -> i64 { - prop_value(fields, key).and_then(Value::as_i64).unwrap_or(0) -} - -fn f64_field(fields: &Map, key: &str) -> Option { - fields.get(key).and_then(Value::as_f64) -} - -fn prop_f64_field(fields: &Map, key: &str) -> Option { - prop_value(fields, key).and_then(Value::as_f64) -} - -fn prop_bool_field(fields: &Map, key: &str) -> bool { - prop_value(fields, key) - .and_then(Value::as_bool) - .unwrap_or(false) -} - -fn timestamp_field(fields: &Map, key: &str) -> Option> { - let value = fields.get(key)?.as_str()?; - DateTime::parse_from_rfc3339(value) - .ok() - .map(|timestamp| timestamp.with_timezone(&Utc)) -} - #[cfg(test)] mod tests { use fabro_agent::AgentEvent; use fabro_types::fixtures; - use fabro_workflow::event::{WorkflowRunEvent, canonicalize_event}; + use fabro_workflow::event::{Event, to_run_event}; use super::*; - fn json_map(value: Value) -> Map { - value.as_object().cloned().expect("json object") - } - - fn canonical_fields(event: &WorkflowRunEvent) -> (String, Map) { - let envelope = canonicalize_event(&fixtures::RUN_1, event); - let event_name = envelope.event.clone(); - let fields = json_map(serde_json::to_value(envelope).expect("serializable envelope")); - (event_name, fields) - } - #[test] fn parse_edge_selected() { - let fields = json_map(serde_json::json!({ - "properties": { - "from_node": "a", - "to_node": "b", - "label": "yes" - } - })); + let stored = to_run_event(&fixtures::RUN_1, &Event::EdgeSelected { + from_node: "a".into(), + to_node: "b".into(), + label: Some("yes".into()), + condition: None, + reason: "condition".into(), + preferred_label: None, + suggested_next_ids: Vec::new(), + stage_status: "success".into(), + is_jump: false, + }); - let event = from_envelope_fields("edge.selected", &fields).unwrap(); + let event = from_run_event(&stored).unwrap(); assert!(matches!( event, ProgressEvent::EdgeSelected { @@ -591,7 +503,7 @@ mod tests { #[test] fn round_trip_stage_completed() { - let event = WorkflowRunEvent::StageCompleted { + let event = Event::StageCompleted { node_id: "plan".into(), name: "Plan".into(), index: 0, @@ -599,16 +511,23 @@ mod tests { status: "success".into(), preferred_label: None, suggested_next_ids: Vec::new(), - usage: None, + billing: None, failure: None, notes: None, files_touched: Vec::new(), + context_updates: None, + jump_to_node: None, + context_values: None, + node_visits: None, + loop_failure_signatures: None, + restart_failure_signatures: None, + response: None, attempt: 1, max_attempts: 1, }; - let (name, fields) = canonical_fields(&event); - let parsed = from_envelope_fields(&name, &fields).unwrap(); + let stored = to_run_event(&fixtures::RUN_1, &event); + let parsed = from_run_event(&stored).unwrap(); assert!(matches!( parsed, ProgressEvent::StageCompleted { @@ -622,19 +541,20 @@ mod tests { #[test] fn round_trip_agent_tool_call() { - let event = WorkflowRunEvent::Agent { - stage: "code".into(), - event: AgentEvent::ToolCallStarted { - tool_name: "read_file".into(), + let event = Event::Agent { + stage: "code".into(), + visit: 1, + event: AgentEvent::ToolCallStarted { + tool_name: "read_file".into(), tool_call_id: "tc1".into(), - arguments: serde_json::json!({"path": "src/main.rs"}), + arguments: serde_json::json!({"path": "src/main.rs"}), }, - session_id: None, + session_id: None, parent_session_id: None, }; - let (name, fields) = canonical_fields(&event); - let parsed = from_envelope_fields(&name, &fields).unwrap(); + let stored = to_run_event(&fixtures::RUN_1, &event); + let parsed = from_run_event(&stored).unwrap(); assert!(matches!( parsed, ProgressEvent::ToolCallStarted { @@ -647,28 +567,44 @@ mod tests { } #[test] - fn parse_tool_call_timestamps_from_jsonl_envelope() { - let started_fields = json_map(serde_json::json!({ - "ts": "2026-03-30T12:00:00.000Z", - "node_id": "code", - "properties": { - "tool_name": "read_file", - "tool_call_id": "tc1", - "arguments": {"path": "src/main.rs"} - } - })); - let completed_fields = json_map(serde_json::json!({ - "ts": "2026-03-30T12:00:00.500Z", - "node_id": "code", - "properties": { - "tool_call_id": "tc1", - "is_error": false, - "duration_ms": 500 - } - })); - - let started = from_envelope_fields("agent.tool.started", &started_fields).unwrap(); - let completed = from_envelope_fields("agent.tool.completed", &completed_fields).unwrap(); + fn parse_tool_call_timestamps_from_jsonl() { + let started = from_json_line( + &serde_json::json!({ + "id": "evt_1", + "ts": "2026-03-30T12:00:00.000Z", + "run_id": fixtures::RUN_1.to_string(), + "event": "agent.tool.started", + "node_id": "code", + "node_label": "code", + "properties": { + "tool_name": "read_file", + "tool_call_id": "tc1", + "arguments": {"path": "src/main.rs"}, + "visit": 1 + } + }) + .to_string(), + ) + .unwrap(); + let completed = from_json_line( + &serde_json::json!({ + "id": "evt_2", + "ts": "2026-03-30T12:00:00.500Z", + "run_id": fixtures::RUN_1.to_string(), + "event": "agent.tool.completed", + "node_id": "code", + "node_label": "code", + "properties": { + "tool_name": "read_file", + "tool_call_id": "tc1", + "output": {"ok": true}, + "is_error": false, + "visit": 1 + } + }) + .to_string(), + ) + .unwrap(); assert!(matches!( started, @@ -682,7 +618,7 @@ mod tests { assert!(matches!( completed, ProgressEvent::ToolCallCompleted { - duration_ms: Some(500), + duration_ms: None, timestamp: Some(timestamp), .. } if timestamp == DateTime::parse_from_rfc3339("2026-03-30T12:00:00.500Z") @@ -693,19 +629,19 @@ mod tests { #[test] fn round_trip_sandbox_ready() { - let event = WorkflowRunEvent::Sandbox { + let event = Event::Sandbox { event: fabro_agent::SandboxEvent::Ready { - provider: "daytona".into(), + provider: "daytona".into(), duration_ms: 2500, - name: Some("sandbox-1".into()), - cpu: Some(4.0), - memory: Some(8.0), - url: Some("https://example.test".into()), + name: Some("sandbox-1".into()), + cpu: Some(4.0), + memory: Some(8.0), + url: Some("https://example.test".into()), }, }; - let (name, fields) = canonical_fields(&event); - let parsed = from_envelope_fields(&name, &fields).unwrap(); + let stored = to_run_event(&fixtures::RUN_1, &event); + let parsed = from_run_event(&stored).unwrap(); assert!(matches!( parsed, ProgressEvent::SandboxReady { @@ -719,14 +655,14 @@ mod tests { #[test] fn round_trip_run_notice() { - let event = WorkflowRunEvent::RunNotice { - level: RunNoticeLevel::Warn, - code: "sandbox_cleanup_failed".into(), + let event = Event::RunNotice { + level: RunNoticeLevel::Warn, + code: "sandbox_cleanup_failed".into(), message: "sandbox cleanup failed".into(), }; - let (name, fields) = canonical_fields(&event); - let parsed = from_envelope_fields(&name, &fields).unwrap(); + let stored = to_run_event(&fixtures::RUN_1, &event); + let parsed = from_run_event(&stored).unwrap(); assert!(matches!( parsed, ProgressEvent::RunNotice { diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs index 88518def9..4640061f5 100644 --- a/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs +++ b/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs @@ -1,6 +1,4 @@ -use serde_json::Value; - -use fabro_workflow::event::RunEventEnvelope; +use fabro_types::RunEvent; mod event; mod info_display; @@ -9,7 +7,7 @@ mod setup_display; mod stage_display; mod styles; -use event::{ProgressEvent, from_envelope_fields}; +use event::{ProgressEvent, from_json_line, from_run_event}; use info_display::InfoDisplay; use renderer::ProgressRenderer; use setup_display::SetupDisplay; @@ -17,9 +15,9 @@ use stage_display::StageDisplay; pub(crate) struct ProgressUI { renderer: ProgressRenderer, - stage: StageDisplay, - setup: SetupDisplay, - info: InfoDisplay, + stage: StageDisplay, + setup: SetupDisplay, + info: InfoDisplay, } impl ProgressUI { @@ -68,23 +66,14 @@ impl ProgressUI { } #[cfg_attr(not(test), allow(dead_code))] - pub(crate) fn handle_event(&mut self, event: &RunEventEnvelope) { - let Ok(Value::Object(envelope)) = serde_json::to_value(event) else { - return; - }; - if let Some(progress_event) = from_envelope_fields(&event.event, &envelope) { + pub(crate) fn handle_event(&mut self, event: &RunEvent) { + if let Some(progress_event) = from_run_event(event) { self.dispatch(progress_event); } } pub(crate) fn handle_json_line(&mut self, line: &str) { - let Ok(Value::Object(envelope)) = serde_json::from_str(line) else { - return; - }; - let Some(event_name) = envelope.get("event").and_then(|value| value.as_str()) else { - return; - }; - if let Some(progress_event) = from_envelope_fields(event_name, &envelope) { + if let Some(progress_event) = from_json_line(line) { self.dispatch(progress_event); } } @@ -422,14 +411,18 @@ impl ProgressUI { #[cfg(test)] mod tests { + #![allow(clippy::absolute_paths, clippy::needless_pass_by_value)] + use std::io::{self, Write}; use std::sync::{Arc, Mutex}; + use chrono::{DateTime, Utc}; use fabro_agent::{AgentEvent, SandboxEvent}; - use fabro_llm::types::Usage; - use fabro_types::fixtures; - use fabro_workflow::event::{RunNoticeLevel, WorkflowRunEvent, canonicalize_event}; - use fabro_workflow::outcome::StageUsage; + use fabro_llm::types::TokenCounts; + use fabro_model::Provider; + use fabro_types::{ParallelBranchId, StageId, fixtures}; + use fabro_workflow::event::{Event, RunNoticeLevel, to_run_event, to_run_event_at}; + use fabro_workflow::outcome::billed_model_usage_from_llm; use super::*; use crate::commands::run::run_progress::stage_display::ToolCallStatus; @@ -469,51 +462,48 @@ mod tests { .expect("valid utf-8") } - fn emit(ui: &mut ProgressUI, event: WorkflowRunEvent) { - let envelope = canonicalize_event(&fixtures::RUN_1, &event); - ui.handle_event(&envelope); + fn emit(ui: &mut ProgressUI, event: Event) { + let stored = to_run_event(&fixtures::RUN_1, &event); + ui.handle_event(&stored); } - fn emit_ref(ui: &mut ProgressUI, event: &WorkflowRunEvent) { - let envelope = canonicalize_event(&fixtures::RUN_1, event); - ui.handle_event(&envelope); + fn emit_ref(ui: &mut ProgressUI, event: &Event) { + let stored = to_run_event(&fixtures::RUN_1, event); + ui.handle_event(&stored); } - fn agent_event(stage: &str, event: AgentEvent) -> WorkflowRunEvent { - WorkflowRunEvent::Agent { + fn agent_event(stage: &str, event: AgentEvent) -> Event { + Event::Agent { stage: stage.into(), + visit: 1, event, session_id: None, parent_session_id: None, } } - fn stage_started(node_id: &str, name: &str) -> WorkflowRunEvent { - WorkflowRunEvent::StageStarted { - node_id: node_id.into(), - name: name.into(), - index: 0, - handler_type: None, - script: None, - attempt: 1, + fn stage_started(node_id: &str, name: &str) -> Event { + Event::StageStarted { + node_id: node_id.into(), + name: name.into(), + index: 0, + handler_type: String::new(), + attempt: 1, max_attempts: 1, } } - fn assistant_message(stage: &str, model: &str) -> WorkflowRunEvent { - agent_event( - stage, - AgentEvent::AssistantMessage { - text: "done".into(), - model: model.into(), - usage: Usage::default(), - tool_call_count: 0, - }, - ) + fn assistant_message(stage: &str, model: &str) -> Event { + agent_event(stage, AgentEvent::AssistantMessage { + text: "done".into(), + model: model.into(), + usage: TokenCounts::default(), + tool_call_count: 0, + }) } - fn stage_completed(node_id: &str, name: &str) -> WorkflowRunEvent { - WorkflowRunEvent::StageCompleted { + fn stage_completed(node_id: &str, name: &str) -> Event { + Event::StageCompleted { node_id: node_id.into(), name: name.into(), index: 0, @@ -521,19 +511,26 @@ mod tests { status: "success".into(), preferred_label: None, suggested_next_ids: Vec::new(), - usage: Some(StageUsage { - model: "gpt-5-mini".into(), - input_tokens: 1200, - output_tokens: 300, - cache_read_tokens: None, - cache_write_tokens: None, - reasoning_tokens: None, - speed: None, - cost: Some(0.12), - }), + billing: Some(billed_model_usage_from_llm( + "gpt-5-mini", + Provider::OpenAi, + None, + &TokenCounts { + input_tokens: 1200, + output_tokens: 300, + ..TokenCounts::default() + }, + )), failure: None, notes: None, files_touched: Vec::new(), + context_updates: None, + jump_to_node: None, + context_values: None, + node_visits: None, + loop_failure_signatures: None, + restart_failure_signatures: None, + response: None, attempt: 1, max_attempts: 1, } @@ -547,22 +544,20 @@ mod tests { assert!(ui.stage.active_stages.contains_key("fork1")); assert!(ui.stage.parallel_parent.is_none()); - emit( - &mut ui, - WorkflowRunEvent::ParallelStarted { - branch_count: 2, - join_policy: "wait_all".into(), - }, - ); + emit(&mut ui, Event::ParallelStarted { + node_id: "fork1".into(), + visit: 1, + branch_count: 2, + join_policy: "wait_all".into(), + }); assert_eq!(ui.stage.parallel_parent.as_deref(), Some("fork1")); - emit( - &mut ui, - WorkflowRunEvent::ParallelBranchStarted { - branch: "security".into(), - index: 0, - }, - ); + emit(&mut ui, Event::ParallelBranchStarted { + parallel_group_id: StageId::new("fork1", 1), + parallel_branch_id: ParallelBranchId::new(StageId::new("fork1", 1), 0), + branch: "security".into(), + index: 0, + }); let stage = &ui.stage.active_stages["fork1"]; assert_eq!(stage.tool_calls.len(), 1); assert_eq!(stage.tool_calls[0].tool_call_id, "security"); @@ -571,15 +566,15 @@ mod tests { ToolCallStatus::Running )); - emit( - &mut ui, - WorkflowRunEvent::ParallelBranchCompleted { - branch: "security".into(), - index: 0, - duration_ms: 2000, - status: "success".into(), - }, - ); + emit(&mut ui, Event::ParallelBranchCompleted { + parallel_group_id: StageId::new("fork1", 1), + parallel_branch_id: ParallelBranchId::new(StageId::new("fork1", 1), 0), + branch: "security".into(), + index: 0, + duration_ms: 2000, + status: "success".into(), + head_sha: None, + }); let stage = &ui.stage.active_stages["fork1"]; assert!(matches!( stage.tool_calls[0].status, @@ -592,20 +587,18 @@ mod tests { let mut ui = ProgressUI::new(true, false); emit(&mut ui, stage_started("fork1", "Fork")); - emit( - &mut ui, - WorkflowRunEvent::ParallelStarted { - branch_count: 1, - join_policy: "wait_all".into(), - }, - ); - emit( - &mut ui, - WorkflowRunEvent::ParallelBranchStarted { - branch: "security".into(), - index: 0, - }, - ); + emit(&mut ui, Event::ParallelStarted { + node_id: "fork1".into(), + visit: 1, + branch_count: 1, + join_policy: "wait_all".into(), + }); + emit(&mut ui, Event::ParallelBranchStarted { + parallel_group_id: StageId::new("fork1", 1), + parallel_branch_id: ParallelBranchId::new(StageId::new("fork1", 1), 0), + branch: "security".into(), + index: 0, + }); let stage = &ui.stage.active_stages["fork1"]; let message = stage.tool_calls[0].bar.message(); @@ -624,27 +617,21 @@ mod tests { emit( &mut ui, - agent_event( - "s1", - AgentEvent::CompactionStarted { - estimated_tokens: 5000, - context_window_size: 8000, - }, - ), + agent_event("s1", AgentEvent::CompactionStarted { + estimated_tokens: 5000, + context_window_size: 8000, + }), ); assert!(ui.stage.active_stages["s1"].compaction_bar.is_some()); emit( &mut ui, - agent_event( - "s1", - AgentEvent::CompactionCompleted { - original_turn_count: 20, - preserved_turn_count: 6, - summary_token_estimate: 500, - tracked_file_count: 3, - }, - ), + agent_event("s1", AgentEvent::CompactionCompleted { + original_turn_count: 20, + preserved_turn_count: 6, + summary_token_estimate: 500, + tracked_file_count: 3, + }), ); assert!(ui.stage.active_stages["s1"].compaction_bar.is_none()); } @@ -662,98 +649,87 @@ mod tests { fn handle_json_line_matches_handle_event_for_verbose_events() { let events = vec![ stage_started("code", "Code"), - WorkflowRunEvent::SandboxInitialized { - working_directory: "/home/daytona/workspace".into(), + Event::SandboxInitialized { + working_directory: "/home/daytona/workspace".into(), + provider: "daytona".into(), + identifier: None, + host_working_directory: None, + container_mount_point: None, }, - agent_event( - "code", - AgentEvent::ToolCallStarted { - tool_name: "read_file".into(), - tool_call_id: "tc1".into(), - arguments: serde_json::json!({ - "file_path": "/home/daytona/workspace/src/main.rs" - }), - }, - ), + agent_event("code", AgentEvent::ToolCallStarted { + tool_name: "read_file".into(), + tool_call_id: "tc1".into(), + arguments: serde_json::json!({ + "file_path": "/home/daytona/workspace/src/main.rs" + }), + }), assistant_message("code", "gpt-5-mini"), - WorkflowRunEvent::EdgeSelected { - from_node: "code".into(), - to_node: "review".into(), - label: Some("ship".into()), - condition: None, - reason: "condition".into(), - preferred_label: None, + Event::EdgeSelected { + from_node: "code".into(), + to_node: "review".into(), + label: Some("ship".into()), + condition: None, + reason: "condition".into(), + preferred_label: None, suggested_next_ids: Vec::new(), - stage_status: "success".into(), - is_jump: false, + stage_status: "success".into(), + is_jump: false, }, - WorkflowRunEvent::StageRetrying { - node_id: "code".into(), - name: "Code".into(), - index: 0, - attempt: 2, + Event::StageRetrying { + node_id: "code".into(), + name: "Code".into(), + index: 0, + attempt: 2, max_attempts: 3, - delay_ms: 1500, + delay_ms: 1500, }, - agent_event( - "code", - AgentEvent::Warning { - kind: "context_window".into(), - message: "high usage".into(), - details: serde_json::json!({"usage_percent": 92}), + agent_event("code", AgentEvent::Warning { + kind: "context_window".into(), + message: "high usage".into(), + details: serde_json::json!({"usage_percent": 92}), + }), + agent_event("code", AgentEvent::LlmRetry { + provider: "openai".into(), + model: "gpt-5-mini".into(), + attempt: 2, + delay_secs: 1.5, + error: fabro_llm::Error::Configuration { + message: "busy".into(), + source: None, }, - ), - agent_event( - "code", - AgentEvent::LlmRetry { - provider: "openai".into(), - model: "gpt-5-mini".into(), - attempt: 2, - delay_secs: 1.5, - error: fabro_llm::error::SdkError::Configuration { - message: "busy".into(), - source: None, - }, - }, - ), - agent_event( - "code", - AgentEvent::SubAgentSpawned { - agent_id: "a1".into(), - depth: 1, - task: "review recent changes".into(), - }, - ), - agent_event( - "code", - AgentEvent::SubAgentCompleted { - agent_id: "a1".into(), - depth: 1, - success: true, - turns_used: 3, - }, - ), - WorkflowRunEvent::SetupStarted { command_count: 1 }, - WorkflowRunEvent::SetupCommandCompleted { - command: "bun install".into(), - index: 0, - exit_code: 0, + }), + agent_event("code", AgentEvent::SubAgentSpawned { + agent_id: "a1".into(), + depth: 1, + task: "review recent changes".into(), + }), + agent_event("code", AgentEvent::SubAgentCompleted { + agent_id: "a1".into(), + depth: 1, + success: true, + turns_used: 3, + }), + Event::SetupStarted { command_count: 1 }, + Event::SetupCommandCompleted { + command: "bun install".into(), + index: 0, + exit_code: 0, duration_ms: 2200, }, - WorkflowRunEvent::SetupCompleted { duration_ms: 2200 }, - WorkflowRunEvent::DevcontainerLifecycleStarted { - phase: "postCreate".into(), + Event::SetupCompleted { duration_ms: 2200 }, + Event::DevcontainerLifecycleStarted { + phase: "postCreate".into(), command_count: 1, }, - WorkflowRunEvent::DevcontainerLifecycleCommandCompleted { - phase: "postCreate".into(), - command: "npm run setup".into(), - index: 0, - exit_code: 0, + Event::DevcontainerLifecycleCommandCompleted { + phase: "postCreate".into(), + command: "npm run setup".into(), + index: 0, + exit_code: 0, duration_ms: 1400, }, - WorkflowRunEvent::DevcontainerLifecycleCompleted { - phase: "postCreate".into(), + Event::DevcontainerLifecycleCompleted { + phase: "postCreate".into(), duration_ms: 1400, }, ]; @@ -765,7 +741,7 @@ mod tests { let (mut json_ui, json_buffer) = capture_ui(true); for event in &events { - let line = serde_json::to_string(&canonicalize_event(&fixtures::RUN_1, event)).unwrap(); + let line = serde_json::to_string(&to_run_event(&fixtures::RUN_1, event)).unwrap(); json_ui.handle_json_line(&line); } @@ -780,103 +756,71 @@ mod tests { emit(&mut ui, assistant_message("plan", "gpt-5-mini")); emit( &mut ui, - agent_event( - "plan", - AgentEvent::ToolCallStarted { - tool_name: "read_file".into(), - tool_call_id: "tc1".into(), - arguments: serde_json::json!({"path": "src/main.rs"}), - }, - ), + agent_event("plan", AgentEvent::ToolCallStarted { + tool_name: "read_file".into(), + tool_call_id: "tc1".into(), + arguments: serde_json::json!({"path": "src/main.rs"}), + }), ); emit( &mut ui, - agent_event( - "plan", - AgentEvent::ToolCallCompleted { - tool_name: "read_file".into(), - tool_call_id: "tc1".into(), - output: serde_json::json!({"ok": true}), - is_error: false, - }, - ), + agent_event("plan", AgentEvent::ToolCallCompleted { + tool_name: "read_file".into(), + tool_call_id: "tc1".into(), + output: serde_json::json!({"ok": true}), + is_error: false, + }), ); emit(&mut ui, stage_completed("plan", "Plan")); - insta::assert_snapshot!(rendered(&buffer), @r" - ✓ Plan $0.12 5s - "); + insta::assert_snapshot!(rendered(&buffer), @" ✓ Plan $0.00 5s"); } #[test] fn plain_default_setup_snapshot() { let (mut ui, buffer) = capture_ui(false); - emit( - &mut ui, - WorkflowRunEvent::Sandbox { - event: SandboxEvent::Initializing { - provider: "daytona".into(), - }, + emit(&mut ui, Event::Sandbox { + event: SandboxEvent::Initializing { + provider: "daytona".into(), }, - ); - emit( - &mut ui, - WorkflowRunEvent::Sandbox { - event: SandboxEvent::Ready { - provider: "daytona".into(), - duration_ms: 2500, - name: Some("sandbox-1".into()), - cpu: Some(4.0), - memory: Some(8.0), - url: None, - }, + }); + emit(&mut ui, Event::Sandbox { + event: SandboxEvent::Ready { + provider: "daytona".into(), + duration_ms: 2500, + name: Some("sandbox-1".into()), + cpu: Some(4.0), + memory: Some(8.0), + url: None, }, - ); - emit( - &mut ui, - WorkflowRunEvent::SshAccessReady { - ssh_command: "ssh daytona@example".into(), - }, - ); - emit(&mut ui, WorkflowRunEvent::SetupStarted { command_count: 2 }); - emit( - &mut ui, - WorkflowRunEvent::SetupCompleted { duration_ms: 8200 }, - ); - emit( - &mut ui, - WorkflowRunEvent::CliEnsureCompleted { - cli_name: "gh".into(), - provider: "github".into(), - already_installed: false, - node_installed: false, - duration_ms: 600, - }, - ); - emit( - &mut ui, - WorkflowRunEvent::DevcontainerResolved { - dockerfile_lines: 24, - environment_count: 3, - lifecycle_command_count: 2, - workspace_folder: "/workspace".into(), - }, - ); - emit( - &mut ui, - WorkflowRunEvent::DevcontainerLifecycleStarted { - phase: "postCreate".into(), - command_count: 2, - }, - ); - emit( - &mut ui, - WorkflowRunEvent::DevcontainerLifecycleCompleted { - phase: "postCreate".into(), - duration_ms: 1800, - }, - ); + }); + emit(&mut ui, Event::SshAccessReady { + ssh_command: "ssh daytona@example".into(), + }); + emit(&mut ui, Event::SetupStarted { command_count: 2 }); + emit(&mut ui, Event::SetupCompleted { duration_ms: 8200 }); + emit(&mut ui, Event::CliEnsureCompleted { + cli_name: "gh".into(), + provider: "github".into(), + already_installed: false, + node_installed: false, + duration_ms: 600, + }); + emit(&mut ui, Event::DevcontainerResolved { + dockerfile_lines: 24, + environment_count: 3, + lifecycle_command_count: 2, + workspace_folder: "/workspace".into(), + }); + emit(&mut ui, Event::DevcontainerLifecycleStarted { + phase: "postCreate".into(), + command_count: 2, + }); + emit(&mut ui, Event::DevcontainerLifecycleCompleted { + phase: "postCreate".into(), + duration_ms: 1800, + }); insta::assert_snapshot!(rendered(&buffer), @r" Sandbox: daytona (ready in 2s) @@ -896,139 +840,104 @@ mod tests { let (mut ui, buffer) = capture_ui(true); emit(&mut ui, stage_started("code", "Code")); + emit(&mut ui, Event::SandboxInitialized { + working_directory: "/home/daytona/workspace".into(), + provider: "daytona".into(), + identifier: None, + host_working_directory: None, + container_mount_point: None, + }); emit( &mut ui, - WorkflowRunEvent::SandboxInitialized { - working_directory: "/home/daytona/workspace".into(), - }, - ); - emit( - &mut ui, - agent_event( - "code", - AgentEvent::ToolCallStarted { - tool_name: "read_file".into(), - tool_call_id: "tc1".into(), - arguments: serde_json::json!({ - "file_path": "/home/daytona/workspace/src/main.rs" - }), - }, - ), + agent_event("code", AgentEvent::ToolCallStarted { + tool_name: "read_file".into(), + tool_call_id: "tc1".into(), + arguments: serde_json::json!({ + "file_path": "/home/daytona/workspace/src/main.rs" + }), + }), ); emit(&mut ui, assistant_message("code", "gpt-5-mini")); + emit(&mut ui, Event::EdgeSelected { + from_node: "code".into(), + to_node: "review".into(), + label: Some("ship".into()), + condition: None, + reason: "condition".into(), + preferred_label: None, + suggested_next_ids: Vec::new(), + stage_status: "success".into(), + is_jump: false, + }); + emit(&mut ui, Event::StageRetrying { + node_id: "code".into(), + name: "Code".into(), + index: 0, + attempt: 2, + max_attempts: 3, + delay_ms: 1500, + }); emit( &mut ui, - WorkflowRunEvent::EdgeSelected { - from_node: "code".into(), - to_node: "review".into(), - label: Some("ship".into()), - condition: None, - reason: "condition".into(), - preferred_label: None, - suggested_next_ids: Vec::new(), - stage_status: "success".into(), - is_jump: false, - }, + agent_event("code", AgentEvent::Warning { + kind: "context_window".into(), + message: "high usage".into(), + details: serde_json::json!({"usage_percent": 92}), + }), ); emit( &mut ui, - WorkflowRunEvent::StageRetrying { - node_id: "code".into(), - name: "Code".into(), - index: 0, - attempt: 2, - max_attempts: 3, - delay_ms: 1500, - }, - ); - emit( - &mut ui, - agent_event( - "code", - AgentEvent::Warning { - kind: "context_window".into(), - message: "high usage".into(), - details: serde_json::json!({"usage_percent": 92}), + agent_event("code", AgentEvent::LlmRetry { + provider: "openai".into(), + model: "gpt-5-mini".into(), + attempt: 2, + delay_secs: 1.5, + error: fabro_llm::Error::Configuration { + message: "busy".into(), + source: None, }, - ), + }), ); emit( &mut ui, - agent_event( - "code", - AgentEvent::LlmRetry { - provider: "openai".into(), - model: "gpt-5-mini".into(), - attempt: 2, - delay_secs: 1.5, - error: fabro_llm::error::SdkError::Configuration { - message: "busy".into(), - source: None, - }, - }, - ), + agent_event("code", AgentEvent::SubAgentSpawned { + agent_id: "a1".into(), + depth: 1, + task: "review recent changes".into(), + }), ); emit( &mut ui, - agent_event( - "code", - AgentEvent::SubAgentSpawned { - agent_id: "a1".into(), - depth: 1, - task: "review recent changes".into(), - }, - ), - ); - emit( - &mut ui, - agent_event( - "code", - AgentEvent::SubAgentCompleted { - agent_id: "a1".into(), - depth: 1, - success: true, - turns_used: 3, - }, - ), - ); - emit(&mut ui, WorkflowRunEvent::SetupStarted { command_count: 1 }); - emit( - &mut ui, - WorkflowRunEvent::SetupCommandCompleted { - command: "bun install".into(), - index: 0, - exit_code: 0, - duration_ms: 2200, - }, - ); - emit( - &mut ui, - WorkflowRunEvent::SetupCompleted { duration_ms: 2200 }, - ); - emit( - &mut ui, - WorkflowRunEvent::DevcontainerLifecycleStarted { - phase: "postCreate".into(), - command_count: 1, - }, - ); - emit( - &mut ui, - WorkflowRunEvent::DevcontainerLifecycleCommandCompleted { - phase: "postCreate".into(), - command: "npm run setup".into(), - index: 0, - exit_code: 0, - duration_ms: 1400, - }, - ); - emit( - &mut ui, - WorkflowRunEvent::DevcontainerLifecycleCompleted { - phase: "postCreate".into(), - duration_ms: 1400, - }, + agent_event("code", AgentEvent::SubAgentCompleted { + agent_id: "a1".into(), + depth: 1, + success: true, + turns_used: 3, + }), ); + emit(&mut ui, Event::SetupStarted { command_count: 1 }); + emit(&mut ui, Event::SetupCommandCompleted { + command: "bun install".into(), + index: 0, + exit_code: 0, + duration_ms: 2200, + }); + emit(&mut ui, Event::SetupCompleted { duration_ms: 2200 }); + emit(&mut ui, Event::DevcontainerLifecycleStarted { + phase: "postCreate".into(), + command_count: 1, + }); + emit(&mut ui, Event::DevcontainerLifecycleCommandCompleted { + phase: "postCreate".into(), + command: "npm run setup".into(), + index: 0, + exit_code: 0, + duration_ms: 1400, + }); + emit(&mut ui, Event::DevcontainerLifecycleCompleted { + phase: "postCreate".into(), + duration_ms: 1400, + }); emit(&mut ui, stage_completed("code", "Code")); insta::assert_snapshot!(rendered(&buffer), @r#" @@ -1043,7 +952,7 @@ mod tests { Running devcontainer postCreate (1 commands)... ✓ [1/1] npm run setup 1s Devcontainer: postCreate (1s) - ✓ Code $0.12 5s (1 turns, 0 tools, 1.5k toks) + ✓ Code $0.00 5s (1 turns, 0 tools, 1.5k toks) "#); } @@ -1051,28 +960,24 @@ mod tests { fn plain_notice_snapshot() { let (mut ui, buffer) = capture_ui(false); - emit( - &mut ui, - WorkflowRunEvent::RunNotice { - level: RunNoticeLevel::Warn, - code: "sandbox_cleanup_failed".into(), - message: "sandbox cleanup failed".into(), - }, - ); - emit( - &mut ui, - WorkflowRunEvent::PullRequestCreated { - pr_url: "https://github.com/fabro-sh/fabro/pull/42".into(), - pr_number: 42, - draft: true, - }, - ); - emit( - &mut ui, - WorkflowRunEvent::PullRequestFailed { - error: "auth token expired".into(), - }, - ); + emit(&mut ui, Event::RunNotice { + level: RunNoticeLevel::Warn, + code: "sandbox_cleanup_failed".into(), + message: "sandbox cleanup failed".into(), + }); + emit(&mut ui, Event::PullRequestCreated { + pr_url: "https://github.com/fabro-sh/fabro/pull/42".into(), + pr_number: 42, + owner: "fabro-sh".into(), + repo: "fabro".into(), + base_branch: "main".into(), + head_branch: "fabro/run/42".into(), + title: "Ship the change".into(), + draft: true, + }); + emit(&mut ui, Event::PullRequestFailed { + error: "auth token expired".into(), + }); insta::assert_snapshot!(rendered(&buffer), @r" Warning: sandbox cleanup failed [sandbox_cleanup_failed] @@ -1086,29 +991,27 @@ mod tests { let mut ui = ProgressUI::new(true, false); emit(&mut ui, stage_started("fork1", "Fork")); - emit( - &mut ui, - WorkflowRunEvent::ParallelStarted { - branch_count: 1, - join_policy: "wait_all".into(), - }, - ); - emit( - &mut ui, - WorkflowRunEvent::ParallelBranchStarted { - branch: "security".into(), - index: 0, - }, - ); - emit( - &mut ui, - WorkflowRunEvent::ParallelBranchCompleted { - branch: "security".into(), - index: 0, - duration_ms: 500, - status: "success".into(), - }, - ); + emit(&mut ui, Event::ParallelStarted { + node_id: "fork1".into(), + visit: 1, + branch_count: 1, + join_policy: "wait_all".into(), + }); + emit(&mut ui, Event::ParallelBranchStarted { + parallel_group_id: StageId::new("fork1", 1), + parallel_branch_id: ParallelBranchId::new(StageId::new("fork1", 1), 0), + branch: "security".into(), + index: 0, + }); + emit(&mut ui, Event::ParallelBranchCompleted { + parallel_group_id: StageId::new("fork1", 1), + parallel_branch_id: ParallelBranchId::new(StageId::new("fork1", 1), 0), + branch: "security".into(), + index: 0, + duration_ms: 500, + status: "success".into(), + head_sha: None, + }); let stage = &ui.stage.active_stages["fork1"]; assert_eq!(stage.tool_calls[0].bar.prefix(), "500ms"); @@ -1118,15 +1021,54 @@ mod tests { fn tty_tool_call_completion_uses_jsonl_timestamps() { let mut ui = ProgressUI::new(true, false); - ui.handle_json_line( - r#"{"ts":"2026-03-30T12:00:00.000Z","event":"stage.started","node_id":"code","node_label":"Code","properties":{"attempt":1,"max_attempts":1}}"#, - ); - ui.handle_json_line( - r#"{"ts":"2026-03-30T12:00:00.000Z","event":"agent.tool.started","node_id":"code","properties":{"tool_name":"read_file","tool_call_id":"tc1","arguments":{"path":"src/main.rs"}}}"#, - ); - ui.handle_json_line( - r#"{"ts":"2026-03-30T12:00:00.500Z","event":"agent.tool.completed","node_id":"code","properties":{"tool_call_id":"tc1","is_error":false}}"#, - ); + let started_ts = DateTime::parse_from_rfc3339("2026-03-30T12:00:00.000Z") + .unwrap() + .with_timezone(&Utc); + let completed_ts = DateTime::parse_from_rfc3339("2026-03-30T12:00:00.500Z") + .unwrap() + .with_timezone(&Utc); + + let stage_started = serde_json::to_string(&to_run_event_at( + &fixtures::RUN_1, + &Event::StageStarted { + node_id: "code".into(), + name: "Code".into(), + index: 0, + handler_type: "agent".into(), + attempt: 1, + max_attempts: 1, + }, + started_ts, + None, + )) + .unwrap(); + let tool_started = serde_json::to_string(&to_run_event_at( + &fixtures::RUN_1, + &agent_event("code", AgentEvent::ToolCallStarted { + tool_name: "read_file".into(), + tool_call_id: "tc1".into(), + arguments: serde_json::json!({"path": "src/main.rs"}), + }), + started_ts, + None, + )) + .unwrap(); + let tool_completed = serde_json::to_string(&to_run_event_at( + &fixtures::RUN_1, + &agent_event("code", AgentEvent::ToolCallCompleted { + tool_name: "read_file".into(), + tool_call_id: "tc1".into(), + output: serde_json::json!({"ok": true}), + is_error: false, + }), + completed_ts, + None, + )) + .unwrap(); + + ui.handle_json_line(&stage_started); + ui.handle_json_line(&tool_started); + ui.handle_json_line(&tool_completed); let stage = &ui.stage.active_stages["code"]; assert_eq!(stage.tool_calls[0].bar.prefix(), "500ms"); diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/renderer.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/renderer.rs index 0f25fbf09..c522efac2 100644 --- a/lib/crates/fabro-cli/src/commands/run/run_progress/renderer.rs +++ b/lib/crates/fabro-cli/src/commands/run/run_progress/renderer.rs @@ -12,14 +12,14 @@ enum RendererInner { } pub(super) struct ProgressRenderer { - inner: RendererInner, + inner: RendererInner, styles: Styles, } impl ProgressRenderer { pub(super) fn new_tty() -> Self { Self { - inner: RendererInner::Tty { + inner: RendererInner::Tty { multi: MultiProgress::new(), }, styles: Styles::new(console::colors_enabled_stderr()), @@ -28,7 +28,7 @@ impl ProgressRenderer { pub(super) fn new_plain(out: Box, colors: bool) -> Self { Self { - inner: RendererInner::Plain { + inner: RendererInner::Plain { out: Mutex::new(out), }, styles: Styles::new(colors), diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/stage_display.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/stage_display.rs index 32cb3475c..e02124787 100644 --- a/lib/crates/fabro-cli/src/commands/run/run_progress/stage_display.rs +++ b/lib/crates/fabro-cli/src/commands/run/run_progress/stage_display.rs @@ -3,9 +3,8 @@ use std::convert::TryFrom; use std::time::Duration; use chrono::{DateTime, Utc}; -use indicatif::ProgressBar; - use fabro_workflow::outcome::{StageStatus, format_cost}; +use indicatif::ProgressBar; use super::event::ProgressUsage; use super::renderer::ProgressRenderer; @@ -25,18 +24,18 @@ pub(super) enum ToolCallStatus { pub(super) struct ToolCallEntry { pub(super) display_name: String, pub(super) tool_call_id: String, - pub(super) status: ToolCallStatus, - pub(super) bar: ProgressBar, - pub(super) is_branch: bool, - pub(super) started_at: Option>, + pub(super) status: ToolCallStatus, + pub(super) bar: ProgressBar, + pub(super) is_branch: bool, + pub(super) started_at: Option>, } #[derive(Debug)] pub(super) struct ActiveStage { - pub(super) display_name: String, - pub(super) has_model: bool, - pub(super) spinner: ProgressBar, - pub(super) tool_calls: VecDeque, + pub(super) display_name: String, + pub(super) has_model: bool, + pub(super) spinner: ProgressBar, + pub(super) tool_calls: VecDeque, pub(super) compaction_bar: Option, } @@ -49,12 +48,12 @@ impl ActiveStage { } pub(super) struct StageDisplay { - verbose: bool, - pub(super) active_stages: HashMap, - pub(super) stage_counts: HashMap, + verbose: bool, + pub(super) active_stages: HashMap, + pub(super) stage_counts: HashMap, pub(super) parallel_parent: Option, - any_stage_started: bool, - working_directory: Option, + any_stage_started: bool, + working_directory: Option, } impl StageDisplay { @@ -118,16 +117,13 @@ impl StageDisplay { if renderer.is_tty() { bar.enable_steady_tick(Duration::from_millis(100)); } - self.active_stages.insert( - node_id.to_string(), - ActiveStage { - display_name, - has_model: false, - spinner: bar, - tool_calls: VecDeque::new(), - compaction_bar: None, - }, - ); + self.active_stages.insert(node_id.to_string(), ActiveStage { + display_name, + has_model: false, + spinner: bar, + tool_calls: VecDeque::new(), + compaction_bar: None, + }); } pub(super) fn on_stage_completed( diff --git a/lib/crates/fabro-cli/src/commands/run/runner.rs b/lib/crates/fabro-cli/src/commands/run/runner.rs new file mode 100644 index 000000000..6e3a01b48 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/run/runner.rs @@ -0,0 +1,776 @@ +use std::io::{BufRead as StdBufRead, BufReader as StdBufReader}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use anyhow::{Context, Result, anyhow}; +use async_trait::async_trait; +use fabro_interview::{ControlInterviewer, WorkerControlEnvelope, WorkerControlMessage}; +use fabro_store::{EventEnvelope, EventPayload, RunProjection}; +use fabro_types::settings::run::RunMode; +use fabro_types::settings::{InterpString, SettingsLayer}; +use fabro_types::{EventBody, RunBlobId, RunEvent, RunId, StatusReason}; +use fabro_workflow::artifact_snapshot::CapturedArtifactInfo; +use fabro_workflow::artifact_upload::{ArtifactSink, StageArtifactUploader}; +use fabro_workflow::event::{Emitter, RunEventSink}; +use fabro_workflow::operations::{self, StartServices}; +use fabro_workflow::run_control::RunControlState; +use fabro_workflow::runtime_store::{RunStoreBackend, RunStoreHandle}; +#[cfg(unix)] +use tokio::signal::unix::{SignalKind, signal}; +use tokio::sync::{Mutex, mpsc}; +use tokio::time::sleep; + +use crate::args::RunWorkerMode; +use crate::server_client; +use crate::shared::github::build_github_credentials; + +const RUN_STORE_RETRY_DELAYS: [Duration; 3] = [ + Duration::from_millis(50), + Duration::from_millis(100), + Duration::from_millis(250), +]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum WorkerTitlePhase { + Start, + Resume, + Init, + Running, + Waiting, + Paused, + Succeeded, + Failed, + Cancelled, +} + +pub(crate) async fn execute( + run_id: RunId, + server: String, + artifact_upload_token: Option, + run_dir: PathBuf, + mode: RunWorkerMode, +) -> 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 run_store = HttpRunStore::connect(run_id, client.clone_for_reuse()).await?; + let run_state = run_store + .state() + .await + .with_context(|| format!("failed to load run state for {run_id}"))?; + let run_record = run_state + .run + .as_ref() + .ok_or_else(|| anyhow!("Run {run_id} has no run record in store"))?; + let artifact_sink = Some(ArtifactSink::Uploader(build_artifact_uploader( + run_id, + client.clone_for_reuse(), + artifact_upload_token, + ))); + let interviewer = Arc::new(ControlInterviewer::new()); + let cancel_token = Arc::new(AtomicBool::new(false)); + spawn_worker_control_stream(Arc::clone(&interviewer), Arc::clone(&cancel_token))?; + let run_control = RunControlState::new(); + install_signal_handlers(Arc::clone(&run_control), Arc::clone(&cancel_token))?; + let github_app = maybe_build_github_credentials(&run_record.settings).await?; + let services = StartServices { + run_id, + cancel_token: Some(Arc::clone(&cancel_token)), + 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(()) } + }), + ]), + artifact_sink, + run_control: Some(run_control), + github_app, + on_node: None, + registry_override: None, + }; + + match mode { + RunWorkerMode::Start => { + operations::start(&run_dir, services).await?; + } + RunWorkerMode::Resume => { + operations::resume(&run_dir, services).await?; + } + } + + Ok(()) +} + +#[derive(Debug, PartialEq, Eq)] +enum WorkerControlStreamEvent { + Line(String), + Eof, +} + +#[expect( + clippy::disallowed_methods, + reason = "Worker control reads blocking stdin on a dedicated OS thread and forwards lines into Tokio." +)] +fn spawn_worker_control_stream( + interviewer: Arc, + cancel_token: Arc, +) -> Result<()> { + let (event_tx, event_rx) = mpsc::unbounded_channel(); + tokio::spawn(handle_worker_control_stream_events( + interviewer, + cancel_token, + event_rx, + )); + std::thread::Builder::new() + .name("fabro-worker-control".to_string()) + .spawn(move || { + read_worker_control_stream_blocking(StdBufReader::new(std::io::stdin()), &event_tx); + }) + .context("failed to spawn worker control reader thread")?; + Ok(()) +} + +fn read_worker_control_stream_blocking( + mut reader: R, + event_tx: &mpsc::UnboundedSender, +) where + R: StdBufRead, +{ + let mut line = String::new(); + loop { + line.clear(); + match reader.read_line(&mut line) { + Ok(0) | Err(_) => { + let _ = event_tx.send(WorkerControlStreamEvent::Eof); + break; + } + Ok(_) => { + let line = line.trim_end_matches(['\r', '\n']).to_string(); + if event_tx.send(WorkerControlStreamEvent::Line(line)).is_err() { + break; + } + } + } + } +} + +async fn handle_worker_control_stream_events( + interviewer: Arc, + cancel_token: Arc, + mut event_rx: mpsc::UnboundedReceiver, +) { + while let Some(event) = event_rx.recv().await { + match event { + WorkerControlStreamEvent::Line(line) => { + apply_worker_control_line(&interviewer, &cancel_token, &line).await; + } + WorkerControlStreamEvent::Eof => { + interviewer.interrupt_all().await; + return; + } + } + } + + interviewer.interrupt_all().await; +} + +async fn apply_worker_control_line( + interviewer: &ControlInterviewer, + cancel_token: &AtomicBool, + line: &str, +) { + if line.trim().is_empty() { + return; + } + + let Ok(message) = serde_json::from_str::(line) else { + return; + }; + + match message.message { + WorkerControlMessage::InterviewAnswer { qid, answer } => { + let _ = interviewer.submit(&qid, answer.into()).await; + } + WorkerControlMessage::RunCancel => { + cancel_token.store(true, Ordering::SeqCst); + interviewer.interrupt_all().await; + } + } +} + +fn build_artifact_uploader( + run_id: RunId, + client: server_client::ServerStoreClient, + artifact_upload_token: Option, +) -> Arc { + match artifact_upload_token { + Some(token) => Arc::new(HttpArtifactUploader { + run_id, + client, + bearer_token: token, + }), + None => Arc::new(MissingArtifactUploadTokenUploader { run_id }), + } +} + +struct HttpArtifactUploader { + run_id: RunId, + client: server_client::ServerStoreClient, + bearer_token: String, +} + +#[async_trait] +impl StageArtifactUploader for HttpArtifactUploader { + async fn upload_stage_artifacts( + &self, + stage_id: &fabro_types::StageId, + artifact_capture_dir: &Path, + artifacts: &[CapturedArtifactInfo], + ) -> Result<()> { + if artifacts.is_empty() { + return Ok(()); + } + + if artifacts.len() == 1 { + let artifact = &artifacts[0]; + return self + .client + .upload_stage_artifact_file( + &self.run_id, + stage_id, + &artifact.path, + &artifact_capture_dir.join(&artifact.path), + &self.bearer_token, + ) + .await; + } + + self.client + .upload_stage_artifact_batch( + &self.run_id, + stage_id, + artifact_capture_dir, + artifacts, + &self.bearer_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: &[CapturedArtifactInfo], + ) -> 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, + client: server_client::ServerStoreClient, + state: Arc>, + events: Arc>>>, +} + +impl HttpRunStore { + async fn connect( + run_id: RunId, + client: server_client::ServerStoreClient, + ) -> Result { + let state = client + .get_run_state(&run_id) + .await + .with_context(|| format!("failed to fetch run state for {run_id}"))?; + Ok(RunStoreHandle::new(Arc::new(Self { + run_id, + client, + state: Arc::new(Mutex::new(state)), + events: Arc::new(Mutex::new(None)), + }))) + } + + async fn with_retries(&self, operation: &'static str, mut op: F) -> Result + where + F: FnMut() -> Fut, + Fut: std::future::Future>, + { + let mut last_error = None; + for attempt in 0..=RUN_STORE_RETRY_DELAYS.len() { + match op().await { + Ok(value) => return Ok(value), + Err(err) => last_error = Some(err), + } + if let Some(delay) = RUN_STORE_RETRY_DELAYS.get(attempt) { + sleep(*delay).await; + } + } + Err(last_error + .unwrap_or_else(|| anyhow!("run store operation failed")) + .context(format!( + "worker lost canonical run store during {operation}" + ))) + } + + async fn refresh_state_from_server(&self) -> Result { + self.with_retries("refresh state", || { + let client = self.client.clone_for_reuse(); + let run_id = self.run_id; + async move { client.get_run_state(&run_id).await } + }) + .await + } + + async fn apply_acknowledged_event(&self, seq: u32, event: &RunEvent) -> Result<()> { + let payload = EventPayload::new(event.to_value()?, &self.run_id)?; + let envelope = EventEnvelope { seq, payload }; + + { + let mut state = self.state.lock().await; + if let Err(err) = state.apply_event(&envelope) { + tracing::warn!(run_id = %self.run_id, error = %err, "failed to apply acknowledged event to local run-state mirror; refreshing from server"); + drop(state); + let refreshed = self.refresh_state_from_server().await?; + *self.state.lock().await = refreshed; + } + } + + let mut events = self.events.lock().await; + if let Some(cached) = events.as_mut() { + cached.push(envelope); + } + + Ok(()) + } +} + +#[async_trait] +impl RunStoreBackend for HttpRunStore { + async fn load_state(&self) -> Result { + Ok(self.state.lock().await.clone()) + } + + async fn list_events(&self) -> Result> { + let mut cached = self.events.lock().await; + if let Some(events) = cached.as_ref() { + return Ok(events.clone()); + } + + let events = self + .with_retries("list run events", || { + let client = self.client.clone_for_reuse(); + let run_id = self.run_id; + async move { client.list_run_events(&run_id, None, None).await } + }) + .await?; + *cached = Some(events.clone()); + Ok(events) + } + + async fn append_run_event(&self, event: &RunEvent) -> Result<()> { + let seq = self + .with_retries("append run event", || { + let client = self.client.clone_for_reuse(); + let run_id = self.run_id; + let event = event.clone(); + async move { client.append_run_event(&run_id, &event).await } + }) + .await?; + self.apply_acknowledged_event(seq, event).await + } + + async fn write_blob(&self, data: &[u8]) -> Result { + self.with_retries("write run blob", || { + let client = self.client.clone_for_reuse(); + let run_id = self.run_id; + let data = data.to_vec(); + async move { client.write_run_blob(&run_id, &data).await } + }) + .await + } + + async fn read_blob(&self, id: &RunBlobId) -> Result> { + self.with_retries("read run blob", || { + let client = self.client.clone_for_reuse(); + let run_id = self.run_id; + let blob_id = *id; + async move { client.read_run_blob(&run_id, &blob_id).await } + }) + .await + } +} + +fn set_worker_title(run_id: &RunId, phase: WorkerTitlePhase) { + fabro_proc::title_set(&worker_title(run_id, phase)); +} + +fn initial_worker_title_phase(mode: RunWorkerMode) -> WorkerTitlePhase { + match mode { + RunWorkerMode::Start => WorkerTitlePhase::Start, + RunWorkerMode::Resume => WorkerTitlePhase::Resume, + } +} + +fn worker_title(run_id: &RunId, phase: WorkerTitlePhase) -> String { + let short_id: String = run_id.to_string().chars().take(12).collect(); + let phase = match phase { + WorkerTitlePhase::Start => "start", + WorkerTitlePhase::Resume => "resume", + WorkerTitlePhase::Init => "init", + WorkerTitlePhase::Running => "running", + WorkerTitlePhase::Waiting => "waiting", + WorkerTitlePhase::Paused => "paused", + WorkerTitlePhase::Succeeded => "succeeded", + WorkerTitlePhase::Failed => "failed", + WorkerTitlePhase::Cancelled => "cancelled", + }; + format!("fabro {short_id} {phase}") +} + +fn worker_title_phase_for_event(body: &EventBody) -> Option { + match body { + EventBody::RunStarting(_) => Some(WorkerTitlePhase::Init), + EventBody::RunRunning(_) | EventBody::RunUnpaused(_) => Some(WorkerTitlePhase::Running), + EventBody::InterviewStarted(_) => Some(WorkerTitlePhase::Waiting), + EventBody::InterviewCompleted(_) | EventBody::InterviewTimeout(_) => { + Some(WorkerTitlePhase::Running) + } + EventBody::RunPaused(_) => Some(WorkerTitlePhase::Paused), + EventBody::RunCompleted(_) => Some(WorkerTitlePhase::Succeeded), + EventBody::RunFailed(props) => Some(if props.reason == Some(StatusReason::Cancelled) { + WorkerTitlePhase::Cancelled + } else { + WorkerTitlePhase::Failed + }), + _ => None, + } +} + +fn update_worker_title_from_event(event: &RunEvent) { + if let Some(phase) = worker_title_phase_for_event(&event.body) { + set_worker_title(&event.run_id, phase); + } +} + +async fn maybe_build_github_credentials( + settings: &SettingsLayer, +) -> Result> { + let resolved_run = fabro_config::resolve_run_from_file(settings).ok(); + let resolved_server = fabro_config::resolve_server_from_file(settings).ok(); + let required_github_credentials = resolved_run.as_ref().is_some_and(|settings| { + settings.execution.mode != RunMode::DryRun && settings.sandbox.provider == "daytona" + }) || resolved_server + .as_ref() + .is_some_and(|settings| !settings.integrations.github.permissions.is_empty()); + let pull_request_enabled = resolved_run.as_ref().is_some_and(|settings| { + settings.execution.mode != RunMode::DryRun && settings.pull_request.is_some() + }); + let strategy = resolved_server + .as_ref() + .map(|settings| settings.integrations.github.strategy) + .unwrap_or_default(); + let app_id = resolved_server + .as_ref() + .and_then(|settings| settings.integrations.github.app_id.as_ref()) + .map(InterpString::as_source); + + if required_github_credentials { + return build_github_credentials(strategy, app_id.as_deref()).await; + } + + if pull_request_enabled { + return Ok(build_github_credentials(strategy, app_id.as_deref()) + .await + .ok() + .flatten()); + } + + Ok(None) +} + +fn install_signal_handlers( + run_control: Arc, + cancel_token: Arc, +) -> Result<()> { + #[cfg(unix)] + { + let mut pause = signal(SignalKind::user_defined1())?; + let pause_control = Arc::clone(&run_control); + tokio::spawn(async move { + while pause.recv().await.is_some() { + pause_control.request_pause(); + } + }); + + let mut unpause = signal(SignalKind::user_defined2())?; + tokio::spawn(async move { + while unpause.recv().await.is_some() { + run_control.request_unpause(); + } + }); + + let mut terminate = signal(SignalKind::terminate())?; + let terminate_cancel = Arc::clone(&cancel_token); + tokio::spawn(async move { + while terminate.recv().await.is_some() { + terminate_cancel.store(true, Ordering::SeqCst); + } + }); + + let mut interrupt = signal(SignalKind::interrupt())?; + tokio::spawn(async move { + while interrupt.recv().await.is_some() { + cancel_token.store(true, Ordering::SeqCst); + } + }); + } + + Ok(()) +} + +#[cfg(test)] +#[allow(clippy::absolute_paths)] +mod tests { + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + + use fabro_interview::{AnswerValue, ControlInterviewer, Interviewer, Question, QuestionType}; + use fabro_types::run_event::{ + InterviewCompletedProps, InterviewStartedProps, RunCompletedProps, RunControlEffectProps, + RunFailedProps, RunStatusTransitionProps, + }; + use fabro_types::{EventBody, StatusReason, fixtures}; + use fabro_workflow::artifact_upload::StageArtifactUploader; + + use super::{ + MissingArtifactUploadTokenUploader, WorkerControlStreamEvent, WorkerTitlePhase, + apply_worker_control_line, handle_worker_control_stream_events, initial_worker_title_phase, + read_worker_control_stream_blocking, worker_title, worker_title_phase_for_event, + }; + use crate::args::RunWorkerMode; + + #[test] + fn worker_title_uses_short_run_id_and_phase() { + let short_id: String = fixtures::RUN_1.to_string().chars().take(12).collect(); + assert_eq!( + worker_title(&fixtures::RUN_1, WorkerTitlePhase::Start), + format!("fabro {short_id} start") + ); + assert_eq!( + worker_title(&fixtures::RUN_1, WorkerTitlePhase::Succeeded), + format!("fabro {short_id} succeeded") + ); + } + + #[test] + fn initial_worker_title_phase_matches_mode() { + assert_eq!( + initial_worker_title_phase(RunWorkerMode::Start), + WorkerTitlePhase::Start + ); + assert_eq!( + initial_worker_title_phase(RunWorkerMode::Resume), + WorkerTitlePhase::Resume + ); + } + + #[test] + fn worker_title_phase_tracks_lifecycle_events() { + assert_eq!( + worker_title_phase_for_event(&EventBody::RunStarting(RunStatusTransitionProps { + reason: None, + })), + Some(WorkerTitlePhase::Init) + ); + assert_eq!( + worker_title_phase_for_event(&EventBody::RunPaused(RunControlEffectProps::default())), + Some(WorkerTitlePhase::Paused) + ); + assert_eq!( + worker_title_phase_for_event(&EventBody::InterviewStarted(InterviewStartedProps { + question_id: "q-1".to_string(), + question: "Approve?".to_string(), + stage: "gate".to_string(), + question_type: "yes_no".to_string(), + options: Vec::new(), + allow_freeform: false, + timeout_seconds: None, + context_display: None, + })), + Some(WorkerTitlePhase::Waiting) + ); + assert_eq!( + worker_title_phase_for_event(&EventBody::InterviewCompleted(InterviewCompletedProps { + question_id: "q-1".to_string(), + question: "Approve?".to_string(), + answer: "yes".to_string(), + duration_ms: 10, + })), + Some(WorkerTitlePhase::Running) + ); + assert_eq!( + worker_title_phase_for_event(&EventBody::RunCompleted(RunCompletedProps { + duration_ms: 10, + artifact_count: 0, + status: "success".to_string(), + reason: None, + total_usd_micros: None, + final_git_commit_sha: None, + final_patch: None, + billing: None, + })), + Some(WorkerTitlePhase::Succeeded) + ); + assert_eq!( + worker_title_phase_for_event(&EventBody::RunFailed(RunFailedProps { + error: "cancelled".to_string(), + duration_ms: 10, + reason: Some(StatusReason::Cancelled), + git_commit_sha: None, + })), + Some(WorkerTitlePhase::Cancelled) + ); + assert_eq!( + worker_title_phase_for_event(&EventBody::RunFailed(RunFailedProps { + error: "boom".to_string(), + duration_ms: 10, + reason: Some(StatusReason::Terminated), + git_commit_sha: None, + })), + Some(WorkerTitlePhase::Failed) + ); + } + + #[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") + ); + assert!(!error.to_string().contains("object-backed artifacts")); + } + + #[tokio::test] + async fn worker_control_line_routes_answer_by_question_id() { + let interviewer = Arc::new(ControlInterviewer::new()); + let cancel_token = Arc::new(AtomicBool::new(false)); + let mut question = Question::new("Approve?", QuestionType::YesNo); + question.id = "q-1".to_string(); + let ask_interviewer = Arc::clone(&interviewer); + let answer_task = tokio::spawn(async move { ask_interviewer.ask(question).await }); + + apply_worker_control_line( + &interviewer, + &cancel_token, + r#"{"v":1,"type":"interview.answer","qid":"q-1","answer":{"kind":"yes"}}"#, + ) + .await; + + let answer: fabro_interview::Answer = answer_task.await.unwrap(); + assert_eq!(answer.value, AnswerValue::Yes); + assert!(!cancel_token.load(Ordering::SeqCst)); + } + + #[tokio::test] + async fn worker_control_line_cancel_sets_cancel_token_and_interrupts_pending_interviews() { + let interviewer = Arc::new(ControlInterviewer::new()); + let cancel_token = Arc::new(AtomicBool::new(false)); + let mut question = Question::new("Approve?", QuestionType::YesNo); + question.id = "q-1".to_string(); + let ask_interviewer = Arc::clone(&interviewer); + let answer_task = tokio::spawn(async move { ask_interviewer.ask(question).await }); + tokio::task::yield_now().await; + + apply_worker_control_line( + &interviewer, + &cancel_token, + r#"{"v":1,"type":"run.cancel"}"#, + ) + .await; + + let answer: fabro_interview::Answer = answer_task.await.unwrap(); + assert_eq!(answer.value, AnswerValue::Interrupted); + assert!(cancel_token.load(Ordering::SeqCst)); + } + + #[tokio::test] + async fn blocking_worker_control_stream_emits_lines_and_eof() { + let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel(); + + read_worker_control_stream_blocking( + std::io::Cursor::new( + b"{\"v\":1,\"type\":\"run.cancel\"}\n{\"v\":1,\"type\":\"interview.answer\",\"qid\":\"q-1\",\"answer\":{\"kind\":\"yes\"}}\n", + ), + &event_tx, + ); + + assert_eq!( + event_rx.try_recv(), + Ok(WorkerControlStreamEvent::Line( + r#"{"v":1,"type":"run.cancel"}"#.to_string() + )) + ); + assert_eq!( + event_rx.try_recv(), + Ok(WorkerControlStreamEvent::Line( + r#"{"v":1,"type":"interview.answer","qid":"q-1","answer":{"kind":"yes"}}"# + .to_string() + )) + ); + assert_eq!(event_rx.try_recv(), Ok(WorkerControlStreamEvent::Eof)); + } + + #[tokio::test] + async fn worker_control_event_loop_eof_interrupts_pending_interviews() { + let interviewer = Arc::new(ControlInterviewer::new()); + let cancel_token = Arc::new(AtomicBool::new(false)); + let mut question = Question::new("Approve?", QuestionType::YesNo); + question.id = "q-1".to_string(); + let ask_interviewer = Arc::clone(&interviewer); + let answer_task = tokio::spawn(async move { ask_interviewer.ask(question).await }); + let (event_tx, event_rx) = tokio::sync::mpsc::unbounded_channel(); + + event_tx.send(WorkerControlStreamEvent::Eof).unwrap(); + drop(event_tx); + + handle_worker_control_stream_events( + Arc::clone(&interviewer), + Arc::clone(&cancel_token), + event_rx, + ) + .await; + + let answer: fabro_interview::Answer = answer_task.await.unwrap(); + assert_eq!(answer.value, AnswerValue::Interrupted); + assert!(!cancel_token.load(Ordering::SeqCst)); + } +} diff --git a/lib/crates/fabro-cli/src/commands/run/ssh.rs b/lib/crates/fabro-cli/src/commands/run/ssh.rs index 5d27d2a8e..d960f408e 100644 --- a/lib/crates/fabro-cli/src/commands/run/ssh.rs +++ b/lib/crates/fabro-cli/src/commands/run/ssh.rs @@ -1,66 +1,39 @@ -use anyhow::{Context, Result, bail}; -use fabro_config::FabroSettingsExt; -use fabro_sandbox::SandboxRecordExt; -use fabro_sandbox::daytona::DaytonaSandbox; -use fabro_workflow::run_lookup::{resolve_run_combined, runs_base}; +use anyhow::{Result, bail}; +use fabro_util::printer::Printer; use tracing::info; use crate::args::{GlobalArgs, SshArgs}; -use crate::shared::{print_json_pretty, validate_daytona_provider}; -use crate::store; -use crate::user_config::load_user_settings_with_globals; +use crate::command_context::CommandContext; +use crate::server_runs::ServerSummaryLookup; +use crate::shared::print_json_pretty; -pub(crate) async fn run(args: SshArgs, globals: &GlobalArgs) -> Result<()> { +pub(crate) async fn run(args: SshArgs, globals: &GlobalArgs, printer: Printer) -> Result<()> { if globals.json && !args.print { globals.require_no_json()?; } - let cli_settings = load_user_settings_with_globals(globals)?; - let base = runs_base(&cli_settings.storage_dir()); - let store = store::build_store(&cli_settings.storage_dir())?; - let run = resolve_run_combined(store.as_ref(), &base, &args.run).await?; - let sandbox_json = run.path.join("sandbox.json"); - let record = match store::open_run_reader(&cli_settings.storage_dir(), &run.run_id).await? { - Some(run_store) => run_store - .get_sandbox() - .await - .ok() - .flatten() - .or_else(|| fabro_sandbox::SandboxRecord::load(&sandbox_json).ok()) - .context( - "Failed to load sandbox.json — was this run started with a recent version of arc?", - )?, - None => fabro_sandbox::SandboxRecord::load(&sandbox_json).context( - "Failed to load sandbox.json — was this run started with a recent version of arc?", - )?, - }; - - validate_daytona_provider(&record, "SSH access")?; - - let name = record - .identifier - .as_deref() - .context("Daytona sandbox record missing identifier (sandbox name)")?; + let ctx = CommandContext::for_target(&args.server, printer)?; + let lookup = ServerSummaryLookup::from_client(ctx.server().await?).await?; + let run = lookup.resolve(&args.run)?; + let run_id = run.run_id(); + let ssh = lookup + .client() + .create_run_ssh_access(&run_id, args.ttl) + .await?; info!(run_id = %args.run, ttl_minutes = args.ttl, "Creating SSH access"); - let daytona = DaytonaSandbox::reconnect(name) - .await - .map_err(|e| anyhow::anyhow!("{e}"))?; - - let ssh_cmd = daytona - .create_ssh_access(Some(args.ttl)) - .await - .map_err(|e| anyhow::anyhow!("{e}"))?; - if args.print { if globals.json { - print_json_pretty(&serde_json::json!({ "command": ssh_cmd }))?; + print_json_pretty(&serde_json::json!({ "command": ssh.command }))?; } else { - print!("{}", format_output(&ssh_cmd)); + { + use std::fmt::Write as _; + let _ = write!(printer.stdout(), "{}", format_output(&ssh.command)); + } } } else { - exec_ssh(&ssh_cmd)?; + exec_ssh(&ssh.command)?; } Ok(()) @@ -71,12 +44,16 @@ fn format_output(ssh_command: &str) -> String { } #[cfg(unix)] +#[expect( + clippy::disallowed_methods, + reason = "This path replaces the current process via CommandExt::exec; Tokio child APIs are not a substitute." +)] fn exec_ssh(ssh_cmd: &str) -> Result<()> { use std::os::unix::process::CommandExt; let parts: Vec<&str> = ssh_cmd.split_whitespace().collect(); if parts.is_empty() { - bail!("Empty SSH command returned from Daytona"); + bail!("Empty SSH command returned from server"); } let err = std::process::Command::new(parts[0]) .args(&parts[1..]) @@ -86,5 +63,5 @@ fn exec_ssh(ssh_cmd: &str) -> Result<()> { #[cfg(not(unix))] fn exec_ssh(_ssh_cmd: &str) -> Result<()> { - bail!("Direct SSH connection is only supported on Unix systems; use --print instead"); + bail!("Direct SSH connection is only supported on Unix systems; use --print instead") } diff --git a/lib/crates/fabro-cli/src/commands/run/start.rs b/lib/crates/fabro-cli/src/commands/run/start.rs index c454b0909..1b8e7c46b 100644 --- a/lib/crates/fabro-cli/src/commands/run/start.rs +++ b/lib/crates/fabro-cli/src/commands/run/start.rs @@ -1,108 +1,12 @@ -use std::path::Path; +use anyhow::Result; +use fabro_types::RunId; -use anyhow::{Result, anyhow, bail}; -use chrono::Utc; -use fabro_config::FabroSettingsExt; -use fabro_workflow::records::{RunRecord, RunRecordExt}; -use fabro_workflow::run_status::{RunStatus, RunStatusRecord, RunStatusRecordExt}; +use crate::server_client; -use super::launcher::{ - LauncherRecord, active_launcher_record_for_run, launcher_log_path, launcher_record_path, - remove_launcher_record, write_launcher_record, -}; - -/// Spawn a detached engine process for the given run directory. -/// -/// The engine process reads `run.json` from the run directory and executes the -/// workflow. Returns the child process handle (use `.id()` for the PID). -#[allow(unsafe_code)] -pub(crate) fn start_run(run_dir: &Path, resume: bool) -> Result { - if !resume { - ensure_startable_run(run_dir)?; - } - - let record = RunRecord::load(run_dir) - .map_err(|e| anyhow!("Cannot start run: failed to load run.json: {e}"))?; - - let storage_dir = record.settings.storage_dir(); - let launcher_path = launcher_record_path(&storage_dir, &record.run_id); - let log_path = launcher_log_path(&storage_dir, &record.run_id); - - if let Some(parent) = log_path.parent() { - std::fs::create_dir_all(parent)?; - } - - let log_file = std::fs::File::create(&log_path)?; - let stdout_log = log_file.try_clone()?; - let exe = std::env::current_exe()?; - - let mut cmd = std::process::Command::new(&exe); - cmd.args(["__detached", "--run-dir"]) - .arg(run_dir) - .args(["--launcher-path"]) - .arg(&launcher_path); - if resume { - cmd.arg("--resume"); - } - cmd.env_remove("FABRO_JSON"); - cmd.stdout(stdout_log) - .stderr(log_file) - .stdin(std::process::Stdio::null()); - - #[cfg(unix)] - { - use std::os::unix::process::CommandExt; - unsafe { - cmd.pre_exec(|| { - libc::setsid(); - Ok(()) - }); - } - } - - let mut child = cmd.spawn()?; - - if let Err(err) = write_launcher_record( - &launcher_path, - &LauncherRecord { - run_id: record.run_id, - run_dir: run_dir.to_path_buf(), - pid: child.id(), - resume, - log_path, - started_at: Utc::now(), - }, - ) { - kill_child_best_effort(&mut child); - return Err(err); - } - - if matches!(child.try_wait(), Ok(Some(_))) { - remove_launcher_record(&launcher_path); - } - - Ok(child) -} - -fn ensure_startable_run(run_dir: &Path) -> Result<()> { - if active_launcher_record_for_run(run_dir).is_some() { - bail!("an engine process is still running for this run — cannot start"); - } - - let status_path = run_dir.join("status.json"); - if let Ok(record) = RunStatusRecord::load(&status_path) { - if !matches!(record.status, RunStatus::Submitted | RunStatus::Starting) { - bail!( - "cannot start run: status is {:?}, expected submitted", - record.status - ); - } - } - - Ok(()) -} - -fn kill_child_best_effort(child: &mut std::process::Child) { - let _ = child.kill(); - let _ = child.wait(); +pub(crate) async fn start_run_with_client( + client: &server_client::ServerStoreClient, + run_id: &RunId, + resume: bool, +) -> Result<()> { + client.start_run(run_id, resume).await } diff --git a/lib/crates/fabro-cli/src/commands/run/wait.rs b/lib/crates/fabro-cli/src/commands/run/wait.rs index e4464d0ae..7acf6ffd3 100644 --- a/lib/crates/fabro-cli/src/commands/run/wait.rs +++ b/lib/crates/fabro-cli/src/commands/run/wait.rs @@ -1,49 +1,57 @@ use std::io::Write; use anyhow::{Result, bail}; -use fabro_config::FabroSettingsExt; use fabro_types::RunId; +use fabro_util::printer::Printer; use fabro_util::terminal::Styles; -use fabro_workflow::records::{Conclusion, ConclusionExt}; -use fabro_workflow::run_lookup::{resolve_run_combined, runs_base}; -use fabro_workflow::run_status::{RunStatus, RunStatusRecord, RunStatusRecordExt}; +use fabro_workflow::records::Conclusion; +use fabro_workflow::run_status::RunStatus; +use tokio::time; use tracing::info; use crate::args::{GlobalArgs, WaitArgs}; -use crate::shared::format_duration_ms; -use crate::store; -use crate::user_config::load_user_settings_with_globals; +use crate::command_context::CommandContext; +use crate::server_runs::ServerSummaryLookup; +use crate::shared::{format_duration_ms, format_usd_micros}; -pub(crate) async fn run(args: &WaitArgs, styles: &Styles, globals: &GlobalArgs) -> Result<()> { - let cli_settings = load_user_settings_with_globals(globals)?; - let base = runs_base(&cli_settings.storage_dir()); - let store = store::build_store(&cli_settings.storage_dir())?; - let run_info = resolve_run_combined(store.as_ref(), &base, &args.run).await?; +#[cfg(test)] +const WAIT_STARTUP_GRACE: std::time::Duration = std::time::Duration::from_millis(500); +#[cfg(not(test))] +const WAIT_STARTUP_GRACE: std::time::Duration = std::time::Duration::from_secs(3); - info!(run_id = %run_info.run_id, "Waiting for run to complete"); +pub(crate) async fn run( + args: &WaitArgs, + styles: &Styles, + globals: &GlobalArgs, + printer: Printer, +) -> Result<()> { + let ctx = CommandContext::for_target(&args.server, printer)?; + let lookup = ServerSummaryLookup::from_client(ctx.server().await?).await?; + let run_info = lookup.resolve(&args.run)?; + let client = lookup.client(); + + let run_id = run_info.run_id(); + info!(run_id = %run_id, "Waiting for run to complete"); - let run_store = store::open_run_reader(&cli_settings.storage_dir(), &run_info.run_id).await?; - let status_path = run_info.path.join("status.json"); let deadline = args .timeout .map(|secs| std::time::Instant::now() + std::time::Duration::from_secs(secs)); let interval = std::time::Duration::from_millis(args.interval); + let started_waiting_at = std::time::Instant::now(); let final_status = loop { - let status = match run_store.as_ref() { - Some(run_store) => match run_store.get_status().await { - Ok(Some(record)) => record.status, - Ok(None) => RunStatus::Dead, - Err(_) => match RunStatusRecord::load(&status_path) { - Ok(record) => record.status, - Err(_) => RunStatus::Dead, - }, - }, - None => match RunStatusRecord::load(&status_path) { - Ok(record) => record.status, - Err(_) => RunStatus::Dead, - }, - }; + let status = client + .get_run_state(&run_id) + .await? + .status + .map(|record| record.status); + let status = status.unwrap_or_else(|| { + if started_waiting_at.elapsed() < WAIT_STARTUP_GRACE { + RunStatus::Submitted + } else { + RunStatus::Dead + } + }); if status.is_terminal() { break status; @@ -55,33 +63,24 @@ pub(crate) async fn run(args: &WaitArgs, styles: &Styles, globals: &GlobalArgs) bail!( "Timed out after {}s waiting for run '{}'", args.timeout.unwrap(), - run_info.run_id + run_id ); } - std::thread::sleep(interval.min(dl - now)); + time::sleep(interval.min(dl - now)).await; } else { - std::thread::sleep(interval); + time::sleep(interval).await; } }; - let conclusion_path = run_info.path.join("conclusion.json"); - let conclusion = match run_store.as_ref() { - Some(run_store) => run_store - .get_conclusion() - .await - .ok() - .flatten() - .or_else(|| Conclusion::load(&conclusion_path).ok()), - None => Conclusion::load(&conclusion_path).ok(), - }; + let conclusion = client.get_run_state(&run_id).await?.conclusion; if globals.json { - let json_value = build_json_output(final_status, &run_info.run_id, conclusion.as_ref()); + 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)?; writeln!(out)?; } else { - print_human_output(final_status, &run_info.run_id, conclusion.as_ref(), styles); + print_human_output(final_status, &run_id, conclusion.as_ref(), styles, printer); } if final_status == RunStatus::Succeeded { @@ -102,8 +101,12 @@ fn build_json_output( }); if let Some(c) = conclusion { value["duration_ms"] = c.duration_ms.into(); - if let Some(cost) = c.total_cost { - value["total_cost"] = cost.into(); + if let Some(total_usd_micros) = c + .billing + .as_ref() + .and_then(|billing| billing.total_usd_micros) + { + value["total_usd_micros"] = total_usd_micros.into(); } } value @@ -114,6 +117,7 @@ fn print_human_output( run_id: &RunId, conclusion: Option<&Conclusion>, styles: &Styles, + printer: Printer, ) { let (style, label) = match status { RunStatus::Succeeded => (&styles.bold_green, "Succeeded"), @@ -128,15 +132,18 @@ fn print_human_output( Some(c) => { let duration = format_duration_ms(c.duration_ms); let cost = c - .total_cost - .map(|v| format!(" ${v:.2}")) + .billing + .as_ref() + .and_then(|billing| billing.total_usd_micros) + .map(|value| format!(" {}", format_usd_micros(value))) .unwrap_or_default(); format!(" {duration}{cost}") } None => String::new(), }; - eprintln!( + fabro_util::printerr!( + printer, "{} {}{details}", status_display, styles.dim.apply_to(run_id), @@ -145,10 +152,12 @@ fn print_human_output( #[cfg(test)] mod tests { - use super::*; - use fabro_types::fixtures; + use fabro_types::{BilledTokenCounts, fixtures}; use fabro_workflow::outcome::StageStatus; use fabro_workflow::records::Conclusion; + use fabro_workflow::run_status::RunStatusRecord; + + use super::*; fn no_color_styles() -> Styles { Styles::new(false) @@ -158,26 +167,28 @@ mod tests { fn json_output_succeeded_with_conclusion() { let run_id = fixtures::RUN_1; let conclusion = Conclusion { - timestamp: chrono::Utc::now(), - status: StageStatus::Success, - duration_ms: 12345, - failure_reason: None, + timestamp: chrono::Utc::now(), + status: StageStatus::Success, + duration_ms: 12345, + failure_reason: None, final_git_commit_sha: None, - stages: vec![], - total_cost: Some(0.42), - total_retries: 0, - total_input_tokens: 0, - total_output_tokens: 0, - total_cache_read_tokens: 0, - total_cache_write_tokens: 0, - total_reasoning_tokens: 0, - has_pricing: false, + stages: vec![], + billing: Some(BilledTokenCounts { + input_tokens: 0, + output_tokens: 0, + total_tokens: 0, + reasoning_tokens: 0, + cache_read_tokens: 0, + cache_write_tokens: 0, + total_usd_micros: Some(420_000), + }), + total_retries: 0, }; let json = build_json_output(RunStatus::Succeeded, &run_id, Some(&conclusion)); assert_eq!(json["run_id"], run_id.to_string()); assert_eq!(json["status"], "succeeded"); assert_eq!(json["duration_ms"], 12345); - assert!((json["total_cost"].as_f64().unwrap() - 0.42).abs() < f64::EPSILON); + assert_eq!(json["total_usd_micros"], 420_000); } #[test] @@ -187,7 +198,7 @@ mod tests { assert_eq!(json["run_id"], run_id.to_string()); assert_eq!(json["status"], "failed"); assert!(json.get("duration_ms").is_none()); - assert!(json.get("total_cost").is_none()); + assert!(json.get("total_usd_micros").is_none()); } #[test] @@ -200,23 +211,17 @@ mod tests { fn json_output_no_cost_when_none() { let run_id = fixtures::RUN_4; let conclusion = Conclusion { - timestamp: chrono::Utc::now(), - status: StageStatus::Fail, - duration_ms: 500, - failure_reason: Some("error".into()), + timestamp: chrono::Utc::now(), + status: StageStatus::Fail, + duration_ms: 500, + failure_reason: Some("error".into()), final_git_commit_sha: None, - stages: vec![], - total_cost: None, - total_retries: 0, - total_input_tokens: 0, - total_output_tokens: 0, - total_cache_read_tokens: 0, - total_cache_write_tokens: 0, - total_reasoning_tokens: 0, - has_pricing: false, + stages: vec![], + billing: None, + total_retries: 0, }; let json = build_json_output(RunStatus::Failed, &run_id, Some(&conclusion)); - assert!(json.get("total_cost").is_none()); + assert!(json.get("total_usd_micros").is_none()); assert_eq!(json["duration_ms"], 500); } @@ -225,29 +230,43 @@ mod tests { let styles = no_color_styles(); let run_id = fixtures::RUN_5; let conclusion = Conclusion { - timestamp: chrono::Utc::now(), - status: StageStatus::Success, - duration_ms: 8000, - failure_reason: None, + timestamp: chrono::Utc::now(), + status: StageStatus::Success, + duration_ms: 8000, + failure_reason: None, final_git_commit_sha: None, - stages: vec![], - total_cost: Some(0.15), - total_retries: 0, - total_input_tokens: 0, - total_output_tokens: 0, - total_cache_read_tokens: 0, - total_cache_write_tokens: 0, - total_reasoning_tokens: 0, - has_pricing: false, + stages: vec![], + billing: Some(BilledTokenCounts { + input_tokens: 0, + output_tokens: 0, + total_tokens: 0, + reasoning_tokens: 0, + cache_read_tokens: 0, + cache_write_tokens: 0, + total_usd_micros: Some(150_000), + }), + total_retries: 0, }; // Just verify no panic; actual stderr output is hard to capture - print_human_output(RunStatus::Succeeded, &run_id, Some(&conclusion), &styles); + print_human_output( + RunStatus::Succeeded, + &run_id, + Some(&conclusion), + &styles, + Printer::Default, + ); } #[test] fn human_output_failed_no_conclusion() { let styles = no_color_styles(); - print_human_output(RunStatus::Failed, &fixtures::RUN_6, None, &styles); + print_human_output( + RunStatus::Failed, + &fixtures::RUN_6, + None, + &styles, + Printer::Default, + ); } #[test] @@ -255,18 +274,25 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let status_path = dir.path().join("status.json"); let record = RunStatusRecord::new(RunStatus::Succeeded, None); - record.save(&status_path).unwrap(); + std::fs::write(&status_path, serde_json::to_string_pretty(&record).unwrap()).unwrap(); // Simulate what the poll loop does - let status = RunStatusRecord::load(&status_path).unwrap().status; + let status = serde_json::from_str::( + &std::fs::read_to_string(&status_path).unwrap(), + ) + .unwrap() + .status; assert!(status.is_terminal()); assert_eq!(status, RunStatus::Succeeded); } #[test] fn missing_status_treated_as_dead() { - let status = match RunStatusRecord::load(std::path::Path::new("/nonexistent/status.json")) { - Ok(record) => record.status, + let status = match std::fs::read_to_string(std::path::Path::new("/nonexistent/status.json")) + { + Ok(data) => serde_json::from_str::(&data) + .map(|record| record.status) + .unwrap_or(RunStatus::Dead), Err(_) => RunStatus::Dead, }; assert_eq!(status, RunStatus::Dead); diff --git a/lib/crates/fabro-cli/src/commands/runs/inspect.rs b/lib/crates/fabro-cli/src/commands/runs/inspect.rs index 8e644dc6a..b6f9b01dd 100644 --- a/lib/crates/fabro-cli/src/commands/runs/inspect.rs +++ b/lib/crates/fabro-cli/src/commands/runs/inspect.rs @@ -1,105 +1,57 @@ -use std::path::{Path, PathBuf}; - use anyhow::Result; -use fabro_config::FabroSettingsExt; -use fabro_sandbox::SandboxRecordExt; -use fabro_types::RunId; -use fabro_workflow::records::{CheckpointExt, ConclusionExt, RunRecordExt, StartRecordExt}; +use fabro_util::printer::Printer; +use fabro_workflow::run_status::RunStatus; use serde::Serialize; -use fabro_workflow::records::{Checkpoint, Conclusion, RunRecord, StartRecord}; -use fabro_workflow::run_lookup::{resolve_run_combined, runs_base}; -use fabro_workflow::run_status::RunStatus; - use crate::args::{GlobalArgs, InspectArgs}; -use crate::store; -use crate::user_config::load_user_settings_with_globals; +use crate::command_context::CommandContext; +use crate::server_client::RunProjection; +use crate::server_runs::{ServerRunSummaryInfo, ServerSummaryLookup}; #[derive(Debug, Serialize)] pub(crate) struct InspectOutput { - pub run_id: String, - pub run_dir: PathBuf, - pub status: RunStatus, - pub run_record: Option, + pub run_id: String, + pub status: RunStatus, + pub run_record: Option, pub start_record: Option, - pub conclusion: Option, - pub checkpoint: Option, - pub sandbox: Option, + pub conclusion: Option, + pub checkpoint: Option, + pub sandbox: Option, } -pub(crate) async fn run(args: &InspectArgs, globals: &GlobalArgs) -> Result<()> { - let cli_settings = load_user_settings_with_globals(globals)?; - let base = runs_base(&cli_settings.storage_dir()); - let store = store::build_store(&cli_settings.storage_dir())?; - let run = resolve_run_combined(store.as_ref(), &base, &args.run).await?; - let output = match store::open_run_reader(&cli_settings.storage_dir(), &run.run_id).await? { - Some(run_store) => { - inspect_run_store(&run.run_id, &run.path, run.status, run_store.as_ref()).await - } - None => inspect_run_dir(&run.run_id, &run.path, run.status), - }; +pub(crate) async fn run(args: &InspectArgs, _globals: &GlobalArgs, printer: Printer) -> Result<()> { + let ctx = CommandContext::for_target(&args.server, printer)?; + let lookup = ServerSummaryLookup::from_client(ctx.server().await?).await?; + let run = lookup.resolve(&args.run)?; + let run_id = run.run_id(); + let state = lookup.client().get_run_state(&run_id).await?; + let output = inspect_run_state(&run, state); let json = serde_json::to_string_pretty(&[output])?; - println!("{json}"); + fabro_util::printout!(printer, "{json}"); Ok(()) } -async fn inspect_run_store( - run_id: &RunId, - run_dir: &Path, - status: RunStatus, - run_store: &dyn fabro_store::RunStore, -) -> InspectOutput { - match run_store.get_snapshot().await { - Ok(Some(snapshot)) => InspectOutput { - run_id: run_id.to_string(), - run_dir: run_dir.to_path_buf(), - status: snapshot - .status - .as_ref() - .map_or(status, |record| record.status), - run_record: serde_json::to_value(snapshot.run).ok(), - start_record: snapshot - .start - .and_then(|record| serde_json::to_value(record).ok()), - conclusion: snapshot - .conclusion - .and_then(|record| serde_json::to_value(record).ok()), - checkpoint: snapshot - .checkpoint - .and_then(|record| serde_json::to_value(record).ok()), - sandbox: snapshot - .sandbox - .and_then(|record| serde_json::to_value(record).ok()), - }, - _ => inspect_run_dir(run_id, run_dir, status), - } -} - -fn inspect_run_dir(run_id: &RunId, run_dir: &Path, status: RunStatus) -> InspectOutput { - let run_record = RunRecord::load(run_dir) - .ok() - .and_then(|v| serde_json::to_value(v).ok()); - let start_record = StartRecord::load(run_dir) - .ok() - .and_then(|v| serde_json::to_value(v).ok()); - let conclusion = Conclusion::load(&run_dir.join("conclusion.json")) - .ok() - .and_then(|v| serde_json::to_value(v).ok()); - let checkpoint = Checkpoint::load(&run_dir.join("checkpoint.json")) - .ok() - .and_then(|v| serde_json::to_value(v).ok()); - let sandbox = fabro_sandbox::SandboxRecord::load(&run_dir.join("sandbox.json")) - .ok() - .and_then(|v| serde_json::to_value(v).ok()); - +fn inspect_run_state(run: &ServerRunSummaryInfo, state: RunProjection) -> InspectOutput { InspectOutput { - run_id: run_id.to_string(), - run_dir: run_dir.to_path_buf(), - status, - run_record, - start_record, - conclusion, - checkpoint, - sandbox, + run_id: run.run_id().to_string(), + status: state + .status + .as_ref() + .map_or(run.status(), |record| record.status), + run_record: state + .run + .and_then(|record| serde_json::to_value(record).ok()), + start_record: state + .start + .and_then(|record| serde_json::to_value(record).ok()), + conclusion: state + .conclusion + .and_then(|record| serde_json::to_value(record).ok()), + checkpoint: state + .checkpoint + .and_then(|record| serde_json::to_value(record).ok()), + sandbox: state + .sandbox + .and_then(|record| serde_json::to_value(record).ok()), } } diff --git a/lib/crates/fabro-cli/src/commands/runs/list.rs b/lib/crates/fabro-cli/src/commands/runs/list.rs index 62ac9922c..47d2dfa45 100644 --- a/lib/crates/fabro-cli/src/commands/runs/list.rs +++ b/lib/crates/fabro-cli/src/commands/runs/list.rs @@ -4,61 +4,72 @@ use anyhow::Result; use chrono::Utc; use cli_table::format::{Border, Separator}; use cli_table::{Cell, CellStruct, Color, Style, Table}; -use fabro_config::FabroSettingsExt; +use fabro_util::printer::Printer; use fabro_util::terminal::Styles; - use fabro_util::text::strip_goal_decoration; -use fabro_workflow::run_lookup::{StatusFilter, filter_runs, runs_base, scan_runs_combined}; use fabro_workflow::run_status::RunStatus; -use crate::args::{GlobalArgs, RunsListArgs}; -use crate::shared::{color_if, format_duration_ms, tilde_path}; -use crate::store; -use crate::user_config::load_user_settings_with_globals; - use super::short_run_id; +use crate::args::{GlobalArgs, RunsListArgs}; +use crate::command_context::CommandContext; +use crate::server_runs::{ServerSummaryLookup, filter_server_runs}; +use crate::shared::{color_if, format_duration_ms, tilde_path}; -#[allow(clippy::print_stdout)] pub(crate) async fn list_command( args: &RunsListArgs, styles: &Styles, globals: &GlobalArgs, + printer: Printer, ) -> Result<()> { - let cli_settings = load_user_settings_with_globals(globals)?; - let base = runs_base(&cli_settings.storage_dir()); - let store = store::build_store(&cli_settings.storage_dir())?; - let runs = scan_runs_combined(store.as_ref(), &base).await?; + let ctx = CommandContext::for_target(&args.server, printer)?; + let lookup = ServerSummaryLookup::from_client(ctx.server().await?).await?; let label_filters = parse_label_filters(&args.filter.label); - let filtered = filter_runs( - &runs, + let filtered = filter_server_runs( + lookup.runs(), args.filter.before.as_deref(), args.filter.workflow.as_deref(), &label_filters, - args.filter.orphans, - if args.all { - StatusFilter::All - } else { - StatusFilter::RunningOnly - }, + !args.all, ); if globals.json { - println!("{}", serde_json::to_string_pretty(&filtered)?); + let json_rows: Vec<_> = filtered + .iter() + .map(|run| { + serde_json::json!({ + "run_id": run.run_id(), + "workflow_name": run.workflow_name(), + "workflow_slug": run.workflow_slug(), + "status": run.status(), + "status_reason": run.status_reason(), + "start_time": run.start_time(), + "labels": run.labels(), + "duration_ms": run.duration_ms(), + "total_usd_micros": run.total_usd_micros(), + "host_repo_path": run.host_repo_path(), + "goal": run.goal(), + }) + }) + .collect(); + fabro_util::printout!(printer, "{}", serde_json::to_string_pretty(&json_rows)?); return Ok(()); } if args.quiet { for run in &filtered { - println!("{}", run.run_id); + fabro_util::printout!(printer, "{}", run.run_id()); } return Ok(()); } if filtered.is_empty() { if args.all { - eprintln!("No runs found."); + fabro_util::printerr!(printer, "No runs found."); } else { - eprintln!("No running processes found. Use -a to show all runs."); + fabro_util::printerr!( + printer, + "No running processes found. Use -a to show all runs." + ); } return Ok(()); } @@ -80,9 +91,9 @@ pub(crate) async fn list_command( let rows: Vec> = display_runs .iter() .map(|run| { - let duration_display = match run.duration_ms { + let duration_display = match run.duration_ms() { Some(ms) => format_duration_ms(ms), - None => match run.start_time_dt { + None => match run.start_time_dt() { Some(start) => { let elapsed = now.signed_duration_since(start); format_duration_ms( @@ -93,20 +104,19 @@ pub(crate) async fn list_command( }, }; let dir_display = run - .host_repo_path - .as_deref() + .host_repo_path() .map_or_else(|| "-".to_string(), |p| tilde_path(Path::new(p))); - let run_id = run.run_id.to_string(); + let run_id = run.run_id().to_string(); vec![ short_run_id(&run_id) .cell() .foreground_color(color_if(use_color, Color::Ansi256(8))), - run.workflow_name.clone().cell(), - status_cell(run.status, use_color), + run.workflow_name().cell(), + status_cell(run.status(), use_color), dir_display.cell(), duration_display.cell(), - truncate_goal(&run.goal, 50) + truncate_goal(&run.goal(), 50) .cell() .foreground_color(color_if(use_color, Color::Ansi256(8))), ] @@ -124,9 +134,9 @@ pub(crate) async fn list_command( .color_choice(color_choice) .border(Border::builder().build()) .separator(Separator::builder().build()); - println!("{}", table.display()?); + fabro_util::printout!(printer, "{}", table.display()?); - eprintln!("\n{} run(s) listed.", display_runs.len()); + fabro_util::printerr!(printer, "\n{} run(s) listed.", display_runs.len()); Ok(()) } diff --git a/lib/crates/fabro-cli/src/commands/runs/mod.rs b/lib/crates/fabro-cli/src/commands/runs/mod.rs index a05f1d76b..22dc39a0e 100644 --- a/lib/crates/fabro-cli/src/commands/runs/mod.rs +++ b/lib/crates/fabro-cli/src/commands/runs/mod.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use fabro_util::printer::Printer; use fabro_util::terminal::Styles; use crate::args::{GlobalArgs, RunsCommands}; @@ -7,14 +8,18 @@ pub(crate) mod inspect; pub(crate) mod list; pub(crate) mod rm; -pub(crate) async fn dispatch(cmd: RunsCommands, globals: &GlobalArgs) -> Result<()> { +pub(crate) async fn dispatch( + cmd: RunsCommands, + globals: &GlobalArgs, + printer: Printer, +) -> Result<()> { match cmd { RunsCommands::Ps(args) => { let styles = Styles::detect_stdout(); - list::list_command(&args, &styles, globals).await + list::list_command(&args, &styles, globals, printer).await } - RunsCommands::Rm(args) => rm::remove_command(&args, globals).await, - RunsCommands::Inspect(args) => inspect::run(&args, globals).await, + RunsCommands::Rm(args) => rm::remove_command(&args, globals, printer).await, + RunsCommands::Inspect(args) => inspect::run(&args, globals, printer).await, } } diff --git a/lib/crates/fabro-cli/src/commands/runs/rm.rs b/lib/crates/fabro-cli/src/commands/runs/rm.rs index 67d79f238..4c8a33f57 100644 --- a/lib/crates/fabro-cli/src/commands/runs/rm.rs +++ b/lib/crates/fabro-cli/src/commands/runs/rm.rs @@ -1,45 +1,42 @@ -use std::path::Path; - use anyhow::{Context, Result, bail}; -use fabro_config::FabroSettingsExt; -use fabro_sandbox::SandboxRecordExt; -use fabro_store::Store; -use tracing::warn; - -use fabro_sandbox::reconnect::reconnect as reconnect_sandbox; -use fabro_workflow::run_lookup::{resolve_run_combined, runs_base}; -use fabro_workflow::run_status::{RunStatus, RunStatusRecord, write_run_status}; - -use crate::args::{GlobalArgs, RunsRemoveArgs}; -use crate::shared::print_json_pretty; -use crate::store; -use crate::user_config::load_user_settings_with_globals; +use fabro_util::printer::Printer; use super::short_run_id; +use crate::args::{GlobalArgs, RunsRemoveArgs}; +use crate::command_context::CommandContext; +use crate::server_client; +use crate::server_runs::{ + ServerRunSummaryInfo, ServerSummaryLookup, resolve_server_run_from_summaries, +}; +use crate::shared::print_json_pretty; -pub(crate) async fn remove_command(args: &RunsRemoveArgs, globals: &GlobalArgs) -> Result<()> { - let cli_settings = load_user_settings_with_globals(globals)?; - let base = runs_base(&cli_settings.storage_dir()); - let store = store::build_store(&cli_settings.storage_dir())?; - remove_from(args, store.as_ref(), &base, globals).await +pub(crate) async fn remove_command( + args: &RunsRemoveArgs, + globals: &GlobalArgs, + printer: Printer, +) -> Result<()> { + let ctx = CommandContext::for_target(&args.server, printer)?; + let lookup = ServerSummaryLookup::from_client(ctx.server().await?).await?; + remove_from(args, lookup.client(), lookup.runs(), globals, printer).await } async fn remove_from( args: &RunsRemoveArgs, - store: &dyn Store, - base: &Path, + client: &server_client::ServerStoreClient, + runs: &[ServerRunSummaryInfo], globals: &GlobalArgs, + printer: Printer, ) -> Result<()> { let mut had_errors = false; let mut removed = Vec::new(); let mut errors = Vec::new(); for identifier in &args.runs { - let run = match resolve_run_combined(store, base, identifier).await { + let run = match resolve_server_run_from_summaries(runs, identifier) { Ok(run) => run, Err(err) => { if !globals.json { - eprintln!("error: {identifier}: {err}"); + fabro_util::printerr!(printer, "error: {identifier}: {err}"); } errors.push(serde_json::json!({ "identifier": identifier, @@ -50,15 +47,15 @@ async fn remove_from( } }; - if run.status.is_active() && !args.force { - let run_id = run.run_id.to_string(); + if run.status().is_active() && !args.force { + let run_id = run.run_id().to_string(); let error = format!( "cannot remove active run {} (status: {}, use -f to force)", short_run_id(&run_id), - run.status + run.status() ); if !globals.json { - eprintln!("{error}"); + fabro_util::printerr!(printer, "{error}"); } errors.push(serde_json::json!({ "identifier": identifier, @@ -68,42 +65,10 @@ async fn remove_from( continue; } - write_run_status(&run.path, RunStatus::Removing, None); - if let Ok(Some(run_store)) = store.open_run_reader(&run.run_id).await { - if let Err(err) = run_store - .put_status(&RunStatusRecord::new(RunStatus::Removing, None)) - .await - { - warn!( - run_id = %run.run_id, - error = %err, - "failed to save removing status to store" - ); - } - } - - let sandbox_path = run.path.join("sandbox.json"); - if let Ok(record) = fabro_sandbox::SandboxRecord::load(&sandbox_path) { - if record.provider != "local" { - match reconnect_sandbox(&record).await { - Ok(sandbox) => { - if let Err(err) = sandbox.cleanup().await { - warn!(run_id = %run.run_id, error = %err, "sandbox cleanup failed"); - } - } - Err(err) => { - warn!(run_id = %run.run_id, error = %err, "sandbox reconnect failed"); - } - } - } - } - - let run_id = run.run_id.to_string(); - if let Err(err) = std::fs::remove_dir_all(&run.path) - .with_context(|| format!("failed to delete {}", run.path.display())) - { + let run_id = run.run_id().to_string(); + if let Err(err) = delete_server_run(client, &run).await { if !globals.json { - eprintln!("error: {identifier}: {err}"); + fabro_util::printerr!(printer, "error: {identifier}: {err}"); } errors.push(serde_json::json!({ "identifier": identifier, @@ -114,21 +79,7 @@ async fn remove_from( } removed.push(run_id.clone()); if !globals.json { - eprintln!("{}", short_run_id(&run_id)); - } - if let Err(err) = store - .delete_run(&run.run_id) - .await - .with_context(|| format!("failed to delete store state for {}", run.run_id)) - { - if !globals.json { - eprintln!("error: {identifier}: {err}"); - } - errors.push(serde_json::json!({ - "identifier": identifier, - "error": err.to_string(), - })); - had_errors = true; + fabro_util::printerr!(printer, "{}", short_run_id(&run_id)); } } @@ -144,3 +95,13 @@ async fn remove_from( } Ok(()) } + +async fn delete_server_run( + client: &server_client::ServerStoreClient, + run: &ServerRunSummaryInfo, +) -> Result<()> { + client + .delete_store_run(&run.run_id()) + .await + .with_context(|| format!("failed to delete store state for {}", run.run_id())) +} diff --git a/lib/crates/fabro-cli/src/commands/sandbox/mod.rs b/lib/crates/fabro-cli/src/commands/sandbox/mod.rs index 354cff411..85885729f 100644 --- a/lib/crates/fabro-cli/src/commands/sandbox/mod.rs +++ b/lib/crates/fabro-cli/src/commands/sandbox/mod.rs @@ -1,11 +1,16 @@ use anyhow::Result; +use fabro_util::printer::Printer; use crate::args::{GlobalArgs, SandboxCommand}; -pub(crate) async fn dispatch(command: SandboxCommand, globals: &GlobalArgs) -> Result<()> { +pub(crate) async fn dispatch( + command: SandboxCommand, + globals: &GlobalArgs, + printer: Printer, +) -> Result<()> { match command { - SandboxCommand::Cp(args) => super::run::cp::cp_command(args, globals).await, - SandboxCommand::Preview(args) => super::run::preview::run(args, globals).await, - SandboxCommand::Ssh(args) => super::run::ssh::run(args, globals).await, + SandboxCommand::Cp(args) => super::run::cp::cp_command(args, globals, printer).await, + SandboxCommand::Preview(args) => super::run::preview::run(args, globals, printer).await, + SandboxCommand::Ssh(args) => super::run::ssh::run(args, globals, printer).await, } } diff --git a/lib/crates/fabro-cli/src/commands/secret/get.rs b/lib/crates/fabro-cli/src/commands/secret/get.rs deleted file mode 100644 index a70accce3..000000000 --- a/lib/crates/fabro-cli/src/commands/secret/get.rs +++ /dev/null @@ -1,23 +0,0 @@ -use anyhow::{Result, bail}; - -use crate::args::{GlobalArgs, SecretGetArgs}; -use crate::shared::print_json_pretty; -use fabro_config::dotenv; - -pub(super) fn get_command(args: &SecretGetArgs, globals: &GlobalArgs) -> Result<()> { - let path = dotenv::env_file_path()?; - match dotenv::get_env_value(&path, &args.key)? { - Some(value) => { - if globals.json { - print_json_pretty(&serde_json::json!({ - "key": args.key, - "value": value, - }))?; - } else { - println!("{value}"); - } - Ok(()) - } - None => bail!("secret not found: {}", args.key), - } -} diff --git a/lib/crates/fabro-cli/src/commands/secret/list.rs b/lib/crates/fabro-cli/src/commands/secret/list.rs index df1a7ef2c..6fe367089 100644 --- a/lib/crates/fabro-cli/src/commands/secret/list.rs +++ b/lib/crates/fabro-cli/src/commands/secret/list.rs @@ -1,42 +1,36 @@ -use anyhow::{Result, bail}; +use anyhow::Result; +use fabro_api::Client; +use fabro_util::printer::Printer; use crate::args::{GlobalArgs, SecretListArgs}; +use crate::server_client; use crate::shared::print_json_pretty; -use fabro_config::dotenv; -pub(super) fn list_command(args: &SecretListArgs, globals: &GlobalArgs) -> Result<()> { - let path = dotenv::env_file_path()?; - let contents = match std::fs::read_to_string(&path) { - Ok(c) => c, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - if globals.json { - print_json_pretty(&Vec::::new())?; - } - return Ok(()); - } - Err(e) => bail!("failed to read {}: {e}", path.display()), - }; - let pairs = dotenv::parse_env(&contents); +pub(super) async fn list_command( + client: &Client, + args: &SecretListArgs, + globals: &GlobalArgs, + printer: Printer, +) -> Result<()> { + let response = client + .list_secrets() + .send() + .await + .map_err(server_client::map_api_error)?; + let secrets = response.into_inner().data; if globals.json { - let values = pairs - .into_iter() - .map(|(key, value)| { - if args.show_values { - serde_json::json!({ "key": key, "value": value }) - } else { - serde_json::json!({ "key": key }) - } - }) - .collect::>(); - print_json_pretty(&values)?; + print_json_pretty(&secrets)?; return Ok(()); } - for (key, value) in pairs { - if args.show_values { - println!("{key}={value}"); - } else { - println!("{key}"); - } + let _ = args; + for secret in secrets { + fabro_util::printout!( + printer, + "{}\t{}\t{}", + secret.name, + secret.type_, + secret.updated_at + ); } Ok(()) } diff --git a/lib/crates/fabro-cli/src/commands/secret/mod.rs b/lib/crates/fabro-cli/src/commands/secret/mod.rs index 4c1103e2a..5dff3ac2d 100644 --- a/lib/crates/fabro-cli/src/commands/secret/mod.rs +++ b/lib/crates/fabro-cli/src/commands/secret/mod.rs @@ -1,17 +1,25 @@ -mod get; mod list; mod rm; mod set; use anyhow::Result; +use fabro_util::printer::Printer; use crate::args::{GlobalArgs, SecretCommand, SecretNamespace}; +use crate::command_context::CommandContext; -pub(crate) fn dispatch(ns: SecretNamespace, globals: &GlobalArgs) -> Result<()> { +pub(crate) async fn dispatch( + ns: SecretNamespace, + globals: &GlobalArgs, + printer: Printer, +) -> Result<()> { + let ctx = CommandContext::for_target(&ns.target, printer)?; + let server = ctx.server().await?; match ns.command { - SecretCommand::Get(args) => get::get_command(&args, globals), - SecretCommand::List(args) => list::list_command(&args, globals), - SecretCommand::Rm(args) => rm::rm_command(&args, globals), - SecretCommand::Set(args) => set::set_command(&args, globals), + SecretCommand::List(args) => { + list::list_command(server.api(), &args, globals, printer).await + } + SecretCommand::Rm(args) => rm::rm_command(server.api(), &args, globals, printer).await, + SecretCommand::Set(args) => set::set_command(server.api(), &args, globals, printer).await, } } diff --git a/lib/crates/fabro-cli/src/commands/secret/rm.rs b/lib/crates/fabro-cli/src/commands/secret/rm.rs index ebead9acb..62a97fe73 100644 --- a/lib/crates/fabro-cli/src/commands/secret/rm.rs +++ b/lib/crates/fabro-cli/src/commands/secret/rm.rs @@ -1,29 +1,29 @@ -use anyhow::{Result, bail}; +use anyhow::Result; +use fabro_api::{Client, types}; +use fabro_util::printer::Printer; use crate::args::{GlobalArgs, SecretRmArgs}; +use crate::server_client; use crate::shared::print_json_pretty; -use fabro_config::dotenv; -pub(super) fn rm_command(args: &SecretRmArgs, globals: &GlobalArgs) -> Result<()> { - let path = dotenv::env_file_path()?; - let contents = match std::fs::read_to_string(&path) { - Ok(c) => c, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - bail!("secret not found: {}", args.key) - } - Err(e) => bail!("failed to read {}: {e}", path.display()), - }; - let updated = dotenv::remove_env_key(&contents, &args.key); - match updated { - Some(new_contents) => { - dotenv::write_env_file(&path, &new_contents)?; - if globals.json { - print_json_pretty(&serde_json::json!({ "key": args.key }))?; - } else { - eprintln!("Removed {}", args.key); - } - Ok(()) - } - None => bail!("secret not found: {}", args.key), +pub(super) async fn rm_command( + client: &Client, + args: &SecretRmArgs, + globals: &GlobalArgs, + printer: Printer, +) -> Result<()> { + client + .delete_secret_by_name() + .body(types::DeleteSecretRequest { + name: args.key.clone(), + }) + .send() + .await + .map_err(server_client::map_api_error)?; + if globals.json { + print_json_pretty(&serde_json::json!({ "key": args.key }))?; + } else { + fabro_util::printerr!(printer, "Removed {}", args.key); } + Ok(()) } diff --git a/lib/crates/fabro-cli/src/commands/secret/set.rs b/lib/crates/fabro-cli/src/commands/secret/set.rs index 30442864f..c70b8ad2b 100644 --- a/lib/crates/fabro-cli/src/commands/secret/set.rs +++ b/lib/crates/fabro-cli/src/commands/secret/set.rs @@ -1,18 +1,40 @@ use anyhow::Result; +use fabro_api::{Client, types}; +use fabro_util::printer::Printer; -use crate::args::{GlobalArgs, SecretSetArgs}; +use crate::args::{GlobalArgs, SecretSetArgs, SecretTypeArg}; +use crate::server_client; use crate::shared::print_json_pretty; -use fabro_config::dotenv; -pub(super) fn set_command(args: &SecretSetArgs, globals: &GlobalArgs) -> Result<()> { - let path = dotenv::env_file_path()?; - let existing = std::fs::read_to_string(&path).unwrap_or_default(); - let merged = dotenv::merge_env(&existing, &[(&args.key, &args.value)]); - dotenv::write_env_file(&path, &merged)?; +fn api_secret_type(secret_type: SecretTypeArg) -> types::SecretType { + match secret_type { + SecretTypeArg::Environment => types::SecretType::Environment, + SecretTypeArg::File => types::SecretType::File, + } +} + +pub(super) async fn set_command( + client: &Client, + args: &SecretSetArgs, + globals: &GlobalArgs, + printer: Printer, +) -> Result<()> { + let meta = client + .create_secret() + .body(types::CreateSecretRequest { + name: args.key.clone(), + value: args.value.clone(), + type_: api_secret_type(args.r#type), + description: args.description.clone(), + }) + .send() + .await + .map_err(server_client::map_api_error)? + .into_inner(); if globals.json { - print_json_pretty(&serde_json::json!({ "key": args.key }))?; + print_json_pretty(&meta)?; } else { - eprintln!("Set {}", args.key); + fabro_util::printerr!(printer, "Set {}", meta.name); } Ok(()) } diff --git a/lib/crates/fabro-cli/src/commands/server/foreground.rs b/lib/crates/fabro-cli/src/commands/server/foreground.rs new file mode 100644 index 000000000..79a6501fa --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/server/foreground.rs @@ -0,0 +1,63 @@ +use std::path::PathBuf; + +use anyhow::Result; +use chrono::Utc; +use fabro_config::Storage; +use fabro_server::bind::BindRequest; +use fabro_server::serve; +use fabro_server::serve::ServeArgs; +use fabro_util::printer::Printer; +use fabro_util::terminal::Styles; + +use super::record; + +pub(crate) async fn execute( + record_path: PathBuf, + mut serve_args: ServeArgs, + bind: BindRequest, + storage_dir: Option, + styles: &'static Styles, + printer: Printer, +) -> Result<()> { + let _ = printer; + serve_args.bind = Some(bind.to_string()); + + let _record_guard = scopeguard::guard(record_path.clone(), |path| { + record::remove_server_record(&path); + }); + + let _socket_guard = if let BindRequest::Unix(ref path) = bind { + let path = path.clone(); + Some(scopeguard::guard(path, |p| { + let _ = std::fs::remove_file(p); + })) + } else { + None + }; + + let log_path = storage_dir.as_ref().map_or_else( + || { + record_path.parent().map_or_else( + || PathBuf::from("server.log"), + |parent| parent.join("server.log"), + ) + }, + |dir| Storage::new(dir).server_state().log_path(), + ); + let pid = std::process::id(); + + Box::pin(serve::serve_command( + serve_args, + styles, + storage_dir, + move |resolved_bind| { + record::write_server_record(&record_path, &record::ServerRecord { + pid, + bind: resolved_bind.clone(), + log_path: log_path.clone(), + started_at: Utc::now(), + }) + }, + )) + .await +} diff --git a/lib/crates/fabro-cli/src/commands/server/mod.rs b/lib/crates/fabro-cli/src/commands/server/mod.rs new file mode 100644 index 000000000..2e16117de --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/server/mod.rs @@ -0,0 +1,99 @@ +pub(crate) mod foreground; +pub(crate) mod record; +pub(crate) mod start; +pub(crate) mod status; +pub(crate) mod stop; + +use std::time::Duration; + +use anyhow::Result; +use fabro_server::bind; +use fabro_server::bind::BindRequest; +use fabro_server::serve::ServeArgs; +use fabro_util::printer::Printer; +use fabro_util::terminal::Styles; + +use crate::args::{ + GlobalArgs, ServerCommand, ServerServeArgs, ServerStartArgs, ServerStatusArgs, ServerStopArgs, +}; +use crate::user_config; + +pub(crate) async fn dispatch( + command: ServerCommand, + _globals: &GlobalArgs, + printer: Printer, +) -> Result<()> { + match command { + ServerCommand::Start(ServerStartArgs { + storage_dir, + foreground, + serve_args, + }) => { + let settings = user_config::load_settings_with_config_and_storage_dir( + serve_args.config.as_deref(), + storage_dir.as_deref(), + )?; + let storage_dir = user_config::storage_dir(&settings)?; + let bind_addr = match serve_args.bind.as_deref() { + Some(s) => bind::parse_bind(s)?, + None => BindRequest::Unix(user_config::default_socket_path()), + }; + let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); + Box::pin(start::execute( + bind_addr, + foreground, + serve_args, + storage_dir, + styles, + printer, + )) + .await + } + ServerCommand::Stop(ServerStopArgs { + storage_dir, + timeout, + }) => { + let settings = user_config::load_settings_with_storage_dir(storage_dir.as_deref())?; + let storage_dir = user_config::storage_dir(&settings)?; + stop::execute(&storage_dir, Duration::from_secs(timeout), printer).await; + Ok(()) + } + ServerCommand::Status(ServerStatusArgs { storage_dir, json }) => { + let settings = user_config::load_settings_with_storage_dir(storage_dir.as_deref())?; + let storage_dir = user_config::storage_dir(&settings)?; + status::execute(&storage_dir, json, printer) + } + ServerCommand::Serve(ServerServeArgs { + storage_dir, + record_path, + serve_args, + }) => { + let active_config_path = Some( + serve_args + .config + .clone() + .unwrap_or_else(|| user_config::active_settings_path(None)), + ); + let bind_addr = if let Some(s) = serve_args.bind.as_deref() { + bind::parse_bind(s)? + } else { + // __serve should always receive an explicit --bind from the parent, + // but fall back to the storage dir default if missing. + BindRequest::Unix(user_config::default_socket_path()) + }; + let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); + Box::pin(foreground::execute( + record_path, + ServeArgs { + config: active_config_path, + ..serve_args + }, + bind_addr, + storage_dir.clone_path(), + styles, + printer, + )) + .await + } + } +} diff --git a/lib/crates/fabro-cli/src/commands/server/record.rs b/lib/crates/fabro-cli/src/commands/server/record.rs new file mode 100644 index 000000000..274d7c00d --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/server/record.rs @@ -0,0 +1,145 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; +use fabro_config::Storage; +use fabro_config::user::legacy_default_storage_root; +use fabro_server::bind::Bind; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct ServerRecord { + pub pid: u32, + pub bind: Bind, + pub log_path: PathBuf, + pub started_at: DateTime, +} + +#[derive(Debug, Clone)] +pub(crate) struct ActiveServerRecord { + pub record: ServerRecord, + pub record_path: PathBuf, +} + +pub(crate) fn write_server_record(path: &Path, record: &ServerRecord) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, serde_json::to_string_pretty(record)?) + .with_context(|| format!("Failed to write server metadata to {}", path.display())) +} + +pub(crate) fn read_server_record(path: &Path) -> Option { + let content = std::fs::read_to_string(path).ok()?; + serde_json::from_str(&content).ok() +} + +pub(crate) fn remove_server_record(path: &Path) { + let _ = std::fs::remove_file(path); +} + +pub(crate) fn server_record_is_running(record: &ServerRecord) -> bool { + fabro_proc::process_alive(record.pid) && server_process_matches(record) +} + +fn server_record_path(storage_dir: &Path) -> PathBuf { + Storage::new(storage_dir).server_state().record_path() +} + +fn legacy_record_path(storage_dir: &Path) -> Option { + let default_storage_dir = legacy_default_storage_root().join("storage"); + if storage_dir == default_storage_dir { + Some(server_record_path(&legacy_default_storage_root())) + } else { + None + } +} + +fn active_server_record_at_path(path: PathBuf) -> Option { + let record = read_server_record(&path)?; + if server_record_is_running(&record) { + Some(ActiveServerRecord { + record, + record_path: path, + }) + } else { + remove_server_record(&path); + None + } +} + +pub(crate) fn active_server_record_details(storage_dir: &Path) -> Option { + let primary_path = server_record_path(storage_dir); + active_server_record_at_path(primary_path) + .or_else(|| legacy_record_path(storage_dir).and_then(active_server_record_at_path)) +} + +pub(crate) fn active_server_record(storage_dir: &Path) -> Option { + active_server_record_details(storage_dir).map(|active| active.record) +} + +#[cfg(unix)] +#[expect( + clippy::disallowed_methods, + reason = "This synchronous process identity probe is shared by async server start and sync server status flows." +)] +fn server_process_matches(record: &ServerRecord) -> bool { + let output = match std::process::Command::new("ps") + .args(["-ww", "-o", "command=", "-p", &record.pid.to_string()]) + .output() + { + Ok(output) if output.status.success() => output, + _ => return false, + }; + let command = String::from_utf8_lossy(&output.stdout); + command.contains("fabro") && command.contains("server") +} + +#[cfg(not(unix))] +fn server_process_matches(_record: &ServerRecord) -> bool { + true +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_record(bind: Bind) -> ServerRecord { + ServerRecord { + pid: std::process::id(), + bind, + log_path: PathBuf::from("/tmp/server.log"), + started_at: Utc::now(), + } + } + + #[test] + fn write_and_read_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let path = Storage::new(dir.path()).server_state().record_path(); + let record = test_record(Bind::Tcp("127.0.0.1:3000".parse().unwrap())); + write_server_record(&path, &record).unwrap(); + + let loaded = read_server_record(&path).unwrap(); + assert_eq!(loaded.pid, record.pid); + assert_eq!(loaded.bind, record.bind); + } + + #[test] + fn active_server_record_returns_none_when_no_file() { + let dir = tempfile::tempdir().unwrap(); + assert!(active_server_record(dir.path()).is_none()); + } + + #[test] + fn active_server_record_cleans_stale_dead_pid() { + let dir = tempfile::tempdir().unwrap(); + let path = Storage::new(dir.path()).server_state().record_path(); + let mut record = test_record(Bind::Tcp("127.0.0.1:3000".parse().unwrap())); + record.pid = u32::MAX; // definitely not alive + write_server_record(&path, &record).unwrap(); + + assert!(active_server_record(dir.path()).is_none()); + assert!(!path.exists()); // lazy cleanup removed file + } +} diff --git a/lib/crates/fabro-cli/src/commands/server/start.rs b/lib/crates/fabro-cli/src/commands/server/start.rs new file mode 100644 index 000000000..54d8d43f5 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/server/start.rs @@ -0,0 +1,371 @@ +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::{Result, bail}; +use chrono::Utc; +use fabro_config::Storage; +use fabro_config::user::default_socket_path; +use fabro_server::bind::{Bind, BindRequest}; +use fabro_server::jwt_auth::FABRO_LOCAL_NO_AUTH_ENV; +use fabro_server::serve; +use fabro_server::serve::{DEFAULT_TCP_PORT, ServeArgs}; +use fabro_util::printer::Printer; +use fabro_util::terminal::Styles; +use tokio::process::Command as TokioCommand; +use tokio::time; + +use super::record; + +pub(crate) async fn execute( + bind: BindRequest, + foreground: bool, + mut serve_args: ServeArgs, + storage_dir: PathBuf, + styles: &'static Styles, + printer: Printer, +) -> Result<()> { + serve_args.bind = Some(bind.to_string()); + + if foreground { + Box::pin(execute_foreground(bind, serve_args, storage_dir, styles)).await + } else { + execute_daemon(&bind, &serve_args, &storage_dir, true, printer).await + } +} + +pub(crate) async fn ensure_server_running_for_storage( + storage_dir: &Path, + config_path: &Path, +) -> Result { + if let Some(existing) = record::active_server_record(storage_dir) { + return Ok(existing.bind); + } + + let bind = Bind::Unix(default_socket_path()); + ensure_server_running_with_bind(bind, config_path, storage_dir).await +} + +pub(crate) async fn ensure_server_running_on_socket( + socket_path: &Path, + config_path: &Path, + storage_dir: &Path, +) -> Result<()> { + let bind = Bind::Unix(socket_path.to_path_buf()); + let _ = ensure_server_running_with_bind(bind, config_path, storage_dir).await?; + Ok(()) +} + +async fn ensure_server_running_with_bind( + bind: Bind, + config_path: &Path, + storage_dir: &Path, +) -> Result { + if let Some(existing) = record::active_server_record(storage_dir) { + if existing.bind == bind { + return Ok(existing.bind); + } + bail!( + "Server already running (pid {}) on {}", + existing.pid, + existing.bind + ); + } + + let serve_args = ServeArgs { + bind: None, + web: false, + no_web: false, + model: None, + provider: None, + dry_run: false, + sandbox: None, + max_concurrent_runs: server_max_concurrent_runs_override(), + config: Some(config_path.to_path_buf()), + }; + + let bind_request = match &bind { + Bind::Unix(path) => BindRequest::Unix(path.clone()), + Bind::Tcp(addr) => BindRequest::Tcp(*addr), + }; + + match execute_daemon( + &bind_request, + &serve_args, + storage_dir, + false, + Printer::Silent, + ) + .await + { + Ok(()) => Ok(bind), + Err(err) => { + if let Some(existing) = record::active_server_record(storage_dir) { + Ok(existing.bind) + } else { + Err(err) + } + } + } +} + +fn server_max_concurrent_runs_override() -> Option { + std::env::var("FABRO_SERVER_MAX_CONCURRENT_RUNS") + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|value| *value > 0) +} + +// --------------------------------------------------------------------------- +// Foreground mode +// --------------------------------------------------------------------------- + +async fn execute_foreground( + bind: BindRequest, + serve_args: ServeArgs, + storage_dir: PathBuf, + styles: &'static Styles, +) -> Result<()> { + let lock_file = acquire_lock(&storage_dir).await?; + let _lock_file = lock_file; // keep alive for the duration + + if let Some(existing) = record::active_server_record(&storage_dir) { + bail!( + "Server already running (pid {}) on {}", + existing.pid, + existing.bind + ); + } + + let server_state = Storage::new(&storage_dir).server_state(); + let record_path = server_state.record_path(); + let log_path = server_state.log_path(); + let pid = std::process::id(); + + let _record_guard = scopeguard::guard(record_path.clone(), |path| { + record::remove_server_record(&path); + }); + + let _socket_guard = if let BindRequest::Unix(ref path) = bind { + let path = path.clone(); + Some(scopeguard::guard(path, |p| { + let _ = std::fs::remove_file(p); + })) + } else { + None + }; + + Box::pin(serve::serve_command( + serve_args, + styles, + Some(storage_dir), + move |resolved_bind| { + record::write_server_record(&record_path, &record::ServerRecord { + pid, + bind: resolved_bind.clone(), + log_path: log_path.clone(), + started_at: Utc::now(), + }) + }, + )) + .await +} + +// --------------------------------------------------------------------------- +// Daemon mode +// --------------------------------------------------------------------------- + +async fn execute_daemon( + bind: &BindRequest, + serve_args: &ServeArgs, + storage_dir: &Path, + announce: bool, + printer: Printer, +) -> Result<()> { + let lock_file = acquire_lock(storage_dir).await?; + let _lock_file = lock_file; // keep alive until function returns + + if let Some(existing) = record::active_server_record(storage_dir) { + if announce { + bail!( + "Server already running (pid {}) on {}", + existing.pid, + existing.bind + ); + } + return Ok(()); + } + + let server_state = Storage::new(storage_dir).server_state(); + let log_path = server_state.log_path(); + if let Some(parent) = log_path.parent() { + std::fs::create_dir_all(parent)?; + } + + let record_path = server_state.record_path(); + let log_file = std::fs::File::create(&log_path)?; + let stdout_log = log_file.try_clone()?; + let exe = std::env::current_exe()?; + + let mut cmd = TokioCommand::new(&exe); + cmd.args(["server", "__serve"]) + .arg("--record-path") + .arg(&record_path) + .arg("--bind") + .arg(bind.to_string()); + + if let Some(ref model) = serve_args.model { + cmd.args(["--model", model]); + } + if let Some(ref provider) = serve_args.provider { + cmd.args(["--provider", provider]); + } + if serve_args.web { + cmd.arg("--web"); + } + if serve_args.no_web { + cmd.arg("--no-web"); + } + if serve_args.dry_run { + cmd.arg("--dry-run"); + } + if let Some(ref sandbox) = serve_args.sandbox { + cmd.args(["--sandbox", &sandbox.to_string()]); + } + if let Some(max) = serve_args.max_concurrent_runs { + cmd.args(["--max-concurrent-runs", &max.to_string()]); + } + if let Some(ref config) = serve_args.config { + cmd.arg("--config").arg(config); + } + + cmd.arg("--storage-dir").arg(storage_dir); + if matches!(bind, BindRequest::Unix(_)) { + cmd.env(FABRO_LOCAL_NO_AUTH_ENV, "1"); + } + + cmd.env_remove("FABRO_JSON"); + cmd.stdout(stdout_log) + .stderr(log_file) + .stdin(std::process::Stdio::null()); + + #[cfg(unix)] + fabro_proc::pre_exec_setsid(cmd.as_std_mut()); + + let mut child = cmd.spawn()?; + + if let Ok(Some(status)) = child.try_wait() { + record::remove_server_record(&record_path); + let tail = read_log_tail(&log_path, 20); + if !tail.is_empty() { + fabro_util::printerr!(printer, "{tail}"); + } + bail!("Server exited immediately with status {status}"); + } + + let poll_interval = Duration::from_millis(50); + let timeout = Duration::from_secs(5); + let mut elapsed = Duration::ZERO; + + while elapsed < timeout { + if let Some(record) = record::read_server_record(&record_path) { + if try_connect(&record.bind) { + if announce { + let pid = child.id().unwrap_or_default(); + maybe_warn_host_port_fallback(bind, &record.bind, printer); + fabro_util::printerr!( + printer, + "Server started (pid {}) on {}", + pid, + record.bind + ); + } + return Ok(()); + } + } + + if let Ok(Some(status)) = child.try_wait() { + record::remove_server_record(&record_path); + let tail = read_log_tail(&log_path, 20); + if !tail.is_empty() { + fabro_util::printerr!(printer, "{tail}"); + } + bail!("Server exited during startup with status {status}"); + } + + time::sleep(poll_interval).await; + elapsed += poll_interval; + } + + record::remove_server_record(&record_path); + let _ = child.kill().await; + let _ = child.wait().await; + let tail = read_log_tail(&log_path, 20); + if !tail.is_empty() { + fabro_util::printerr!(printer, "{tail}"); + } + bail!("Server did not become ready within {timeout:?}"); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async fn acquire_lock(storage_dir: &Path) -> Result { + let lock_path = Storage::new(storage_dir).server_state().lock_path(); + if let Some(parent) = lock_path.parent() { + std::fs::create_dir_all(parent)?; + } + let lock_file = std::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(false) + .open(&lock_path)?; + + let poll_interval = Duration::from_millis(50); + let timeout = Duration::from_secs(5); + let mut elapsed = Duration::ZERO; + + while !fabro_proc::try_flock_exclusive(&lock_file)? { + if elapsed >= timeout { + bail!("timed out waiting for server lock"); + } + time::sleep(poll_interval).await; + elapsed += poll_interval; + } + + Ok(lock_file) +} + +fn try_connect(bind: &Bind) -> bool { + match bind { + Bind::Tcp(addr) => { + std::net::TcpStream::connect_timeout(addr, Duration::from_millis(100)).is_ok() + } + Bind::Unix(path) => std::os::unix::net::UnixStream::connect(path).is_ok(), + } +} + +fn maybe_warn_host_port_fallback(requested: &BindRequest, resolved: &Bind, printer: Printer) { + let BindRequest::TcpHost(host) = requested else { + return; + }; + let Bind::Tcp(addr) = resolved else { + return; + }; + if addr.ip() == *host && addr.port() != DEFAULT_TCP_PORT { + fabro_util::printerr!( + printer, + "Warning: TCP port {DEFAULT_TCP_PORT} is unavailable on {host}; falling back to a random port." + ); + } +} + +fn read_log_tail(log_path: &Path, lines: usize) -> String { + match std::fs::read_to_string(log_path) { + Ok(content) => { + let tail: Vec<&str> = content.lines().rev().take(lines).collect(); + tail.into_iter().rev().collect::>().join("\n") + } + Err(_) => String::new(), + } +} diff --git a/lib/crates/fabro-cli/src/commands/server/status.rs b/lib/crates/fabro-cli/src/commands/server/status.rs new file mode 100644 index 000000000..298328786 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/server/status.rs @@ -0,0 +1,56 @@ +use std::path::Path; + +use anyhow::Result; +use chrono::Utc; +use fabro_util::printer::Printer; + +use super::record; + +pub(crate) fn execute(storage_dir: &Path, json: bool, printer: Printer) -> Result<()> { + let Some(record) = record::active_server_record(storage_dir) else { + if json { + fabro_util::printout!(printer, r#"{{"status":"stopped"}}"#); + } else { + fabro_util::printerr!(printer, "Server is not running"); + } + std::process::exit(1); + }; + + if json { + let uptime_seconds = (Utc::now() - record.started_at).num_seconds().max(0); + let output = serde_json::json!({ + "status": "running", + "pid": record.pid, + "bind": record.bind.to_string(), + "started_at": record.started_at.to_rfc3339(), + "uptime_seconds": uptime_seconds, + }); + fabro_util::printout!(printer, "{}", serde_json::to_string_pretty(&output)?); + } else { + let uptime = format_uptime(Utc::now() - record.started_at); + fabro_util::printerr!( + printer, + "Server running (pid {}) on {}, started {} ago", + record.pid, + record.bind, + uptime + ); + } + + Ok(()) +} + +fn format_uptime(duration: chrono::Duration) -> String { + let total_seconds = duration.num_seconds().max(0); + let hours = total_seconds / 3600; + let minutes = (total_seconds % 3600) / 60; + let seconds = total_seconds % 60; + + if hours > 0 { + format!("{hours}h {minutes}m {seconds}s") + } else if minutes > 0 { + format!("{minutes}m {seconds}s") + } else { + format!("{seconds}s") + } +} diff --git a/lib/crates/fabro-cli/src/commands/server/stop.rs b/lib/crates/fabro-cli/src/commands/server/stop.rs new file mode 100644 index 000000000..2e53337ff --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/server/stop.rs @@ -0,0 +1,41 @@ +use std::path::Path; +use std::time::Duration; + +use fabro_server::bind::Bind; +use fabro_util::printer::Printer; +use tokio::time; + +use super::record; + +pub(crate) async fn execute(storage_dir: &Path, timeout: Duration, printer: Printer) { + let Some(active) = record::active_server_record_details(storage_dir) else { + fabro_util::printerr!(printer, "Server is not running"); + std::process::exit(1); + }; + let record = active.record; + + fabro_proc::sigterm(record.pid); + + let poll_interval = Duration::from_millis(100); + let mut elapsed = Duration::ZERO; + while elapsed < timeout { + if !fabro_proc::process_alive(record.pid) { + break; + } + time::sleep(poll_interval).await; + elapsed += poll_interval; + } + + if fabro_proc::process_alive(record.pid) { + fabro_proc::sigkill(record.pid); + time::sleep(Duration::from_millis(100)).await; + } + + record::remove_server_record(&active.record_path); + + if let Bind::Unix(ref path) = record.bind { + let _ = std::fs::remove_file(path); + } + + fabro_util::printerr!(printer, "Server stopped"); +} diff --git a/lib/crates/fabro-cli/src/commands/skill/install.rs b/lib/crates/fabro-cli/src/commands/skill/install.rs deleted file mode 100644 index 1f0fa818d..000000000 --- a/lib/crates/fabro-cli/src/commands/skill/install.rs +++ /dev/null @@ -1,134 +0,0 @@ -use std::path::Path; - -use anyhow::{Result, bail}; -use tracing::{debug, info}; - -use crate::args::{GlobalArgs, SkillDir, SkillInstallArgs, SkillScope}; -use crate::shared::{absolute_or_current, print_json_pretty}; - -const SKILL_MD: &str = include_str!("../../../../../../skills/fabro-create-workflow/SKILL.md"); -const REF_DOT_LANGUAGE: &str = - include_str!("../../../../../../skills/fabro-create-workflow/references/dot-language.md"); -const REF_EXAMPLE_WORKFLOWS: &str = - include_str!("../../../../../../skills/fabro-create-workflow/references/example-workflows.md"); -const REF_RUN_CONFIGURATION: &str = - include_str!("../../../../../../skills/fabro-create-workflow/references/run-configuration.md"); - -const SKILL_FILES: &[(&str, &str)] = &[ - ("SKILL.md", SKILL_MD), - ("references/dot-language.md", REF_DOT_LANGUAGE), - ("references/example-workflows.md", REF_EXAMPLE_WORKFLOWS), - ("references/run-configuration.md", REF_RUN_CONFIGURATION), -]; - -/// Install all skill files under `base_dir/fabro-create-workflow/`. -pub(crate) fn install_skill_to(base_dir: &Path) -> Result<()> { - let skill_dir = base_dir.join("fabro-create-workflow"); - - for (rel_path, content) in SKILL_FILES { - let dest = skill_dir.join(rel_path); - if let Some(parent) = dest.parent() { - std::fs::create_dir_all(parent)?; - } - debug!(file = %rel_path, "Writing skill file"); - std::fs::write(&dest, content)?; - } - - info!(path = %skill_dir.display(), "Skill installed"); - Ok(()) -} - -pub(super) fn run_skill_install(args: &SkillInstallArgs, globals: &GlobalArgs) -> Result<()> { - let base_dir = resolve_base_dir(&args.scope, &args.dir)?; - let skill_dir = base_dir.join("fabro-create-workflow"); - - if globals.json && skill_dir.exists() && !args.force { - globals.require_no_json()?; - } - - if skill_dir.exists() && !args.force { - let confirm = dialoguer::Confirm::new() - .with_prompt(format!( - "Skill directory already exists at {}. Overwrite?", - skill_dir.display() - )) - .default(false) - .interact()?; - - if !confirm { - bail!("Aborted: skill directory already exists"); - } - } - - install_skill_to(&base_dir)?; - - if globals.json { - print_json_pretty(&serde_json::json!({ - "skill": "fabro-create-workflow", - "path": absolute_or_current(&skill_dir), - "files": SKILL_FILES.iter().map(|(rel_path, _)| (*rel_path).to_string()).collect::>(), - }))?; - } - - Ok(()) -} - -fn resolve_base_dir(scope: &SkillScope, dir: &SkillDir) -> Result { - let dir_name = match dir { - SkillDir::Claude => ".claude", - SkillDir::Agents => ".agents", - }; - - let root = match scope { - SkillScope::User => { - dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))? - } - SkillScope::Project => std::env::current_dir()?, - }; - - Ok(root.join(dir_name).join("skills")) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn embedded_files_are_non_empty() { - assert!(!SKILL_MD.is_empty()); - assert!(!REF_DOT_LANGUAGE.is_empty()); - assert!(!REF_EXAMPLE_WORKFLOWS.is_empty()); - assert!(!REF_RUN_CONFIGURATION.is_empty()); - } - - #[test] - fn install_writes_all_files() { - let tmp = tempfile::tempdir().unwrap(); - let base = tmp.path().join("skills"); - - install_skill_to(&base).unwrap(); - - for (rel_path, content) in SKILL_FILES { - let path = base.join("fabro-create-workflow").join(rel_path); - assert!(path.exists(), "Missing file: {rel_path}"); - let written = std::fs::read_to_string(&path).unwrap(); - assert_eq!(written, *content, "Content mismatch: {rel_path}"); - } - } - - #[test] - fn install_overwrites_existing_files() { - let tmp = tempfile::tempdir().unwrap(); - let base = tmp.path().join("skills"); - - install_skill_to(&base).unwrap(); - - let sentinel_path = base.join("fabro-create-workflow/SKILL.md"); - std::fs::write(&sentinel_path, "old content").unwrap(); - - install_skill_to(&base).unwrap(); - - let content = std::fs::read_to_string(&sentinel_path).unwrap(); - assert_eq!(content, SKILL_MD); - } -} diff --git a/lib/crates/fabro-cli/src/commands/skill/mod.rs b/lib/crates/fabro-cli/src/commands/skill/mod.rs deleted file mode 100644 index 1b2bdc696..000000000 --- a/lib/crates/fabro-cli/src/commands/skill/mod.rs +++ /dev/null @@ -1,13 +0,0 @@ -mod install; - -use anyhow::Result; - -use crate::args::{GlobalArgs, SkillCommand, SkillNamespace}; - -pub(crate) use install::install_skill_to; - -pub(crate) fn dispatch(ns: SkillNamespace, globals: &GlobalArgs) -> Result<()> { - match ns.command { - SkillCommand::Install(args) => install::run_skill_install(&args, globals), - } -} diff --git a/lib/crates/fabro-cli/src/commands/store/dump.rs b/lib/crates/fabro-cli/src/commands/store/dump.rs index e1dd9b592..fd63ad3fd 100644 --- a/lib/crates/fabro-cli/src/commands/store/dump.rs +++ b/lib/crates/fabro-cli/src/commands/store/dump.rs @@ -1,56 +1,214 @@ -use std::io::{ErrorKind, Write}; -use std::path::{Component, Path, PathBuf}; +use std::io::ErrorKind; +use std::path::Path; -use anyhow::{Context, Result, bail}; -use fabro_config::FabroSettingsExt; -use fabro_store::{NodeVisitRef, RunSnapshot, RunStore}; -use fabro_workflow::run_lookup::{resolve_run_combined, runs_base}; -use serde::Serialize; +use anyhow::{Context, Result}; +use bytes::Bytes; +#[cfg(test)] +use fabro_store::{ArtifactStore, RunDatabase}; +use fabro_store::{EventEnvelope, RunProjection, StageId}; +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 crate::args::{GlobalArgs, StoreDumpArgs}; +use crate::server_client::ServerStoreClient; +use crate::server_runs::ServerRunLookup; use crate::shared::{absolute_or_current, print_json_pretty}; -use crate::store; -use crate::user_config::load_user_settings_with_globals; +use crate::user_config::{load_settings_with_storage_dir, storage_dir}; -pub(crate) async fn dump_command(args: &StoreDumpArgs, globals: &GlobalArgs) -> Result<()> { - let cli_settings = load_user_settings_with_globals(globals)?; - let base = runs_base(&cli_settings.storage_dir()); - let store = store::build_store(&cli_settings.storage_dir())?; - let run = resolve_run_combined(store.as_ref(), &base, &args.run).await?; - let run_store = store::open_run_reader(&cli_settings.storage_dir(), &run.run_id) - .await? - .with_context(|| { - format!( - "run {} is not in the store (it may be a legacy filesystem-only run)", - run.run_id - ) - })?; - - let file_count = export_run(run_store.as_ref(), &args.output).await?; +pub(crate) async fn dump_command( + args: &StoreDumpArgs, + globals: &GlobalArgs, + printer: Printer, +) -> Result<()> { + let cli_settings = load_settings_with_storage_dir(args.storage_dir.as_deref())?; + let lookup = ServerRunLookup::connect(&storage_dir(&cli_settings)?).await?; + let run = lookup.resolve(&args.run)?; + let run_id = run.run_id(); + let state = lookup.client().get_run_state(&run_id).await?; + let source = ServerDumpSource::new(lookup.client(), &run_id); + let file_count = export_run_from_source(&source, &state, &args.output).await?; if globals.json { print_json_pretty(&serde_json::json!({ - "run_id": run.run_id, + "run_id": run_id, "output_dir": absolute_or_current(&args.output), "file_count": file_count, }))?; } else { - println!( + fabro_util::printout!( + printer, "Exported {file_count} files for run {} to {}", - run.run_id, + run_id, args.output.display() ); } Ok(()) } -pub(crate) async fn export_run(run_store: &dyn RunStore, output_dir: &Path) -> Result { - let snapshot = run_store - .get_snapshot() - .await? +#[cfg(test)] +pub(crate) async fn export_run( + run_store: &RunDatabase, + artifact_store: &ArtifactStore, + output_dir: &Path, +) -> Result { + let state = run_store.state().await?; + let run_id = state + .run + .as_ref() + .map(|run| run.run_id) .context("run has no data in the store")?; + let source = LocalDumpSource::new(run_store, artifact_store, run_id); + export_run_from_source(&source, &state, output_dir).await +} +fn finalize_export( + output_dir: &Path, + output_state: OutputDirState, + staging_dir: tempfile::TempDir, + staging_path: &Path, + file_count: usize, +) -> Result { + if matches!(output_state, OutputDirState::ExistingEmpty) { + std::fs::remove_dir(output_dir) + .with_context(|| format!("failed to replace {}", output_dir.display()))?; + } + std::fs::rename(staging_path, output_dir).with_context(|| { + format!( + "failed to move staged export {} into {}", + staging_path.display(), + output_dir.display() + ) + })?; + let _ = staging_dir.keep(); + + Ok(file_count) +} + +struct DumpArtifact { + stage_id: StageId, + relative_path: String, + data: Vec, +} + +trait DumpDataSource { + fn list_events(&self) -> BoxFuture<'_, Result>>; + + fn read_blob(&self, blob_id: RunBlobId) -> BoxFuture<'_, Result>>; + + fn list_artifacts(&self) -> BoxFuture<'_, Result>>; +} + +#[cfg(test)] +struct LocalDumpSource<'a> { + run_store: &'a RunDatabase, + artifact_store: &'a ArtifactStore, + run_id: RunId, +} + +#[cfg(test)] +impl<'a> LocalDumpSource<'a> { + fn new(run_store: &'a RunDatabase, artifact_store: &'a ArtifactStore, run_id: RunId) -> Self { + Self { + run_store, + artifact_store, + run_id, + } + } +} + +#[cfg(test)] +impl DumpDataSource for LocalDumpSource<'_> { + fn list_events(&self) -> BoxFuture<'_, Result>> { + Box::pin(async move { Ok(self.run_store.list_events().await?) }) + } + + fn read_blob(&self, blob_id: RunBlobId) -> BoxFuture<'_, Result>> { + Box::pin(async move { Ok(self.run_store.read_blob(&blob_id).await?) }) + } + + fn list_artifacts(&self) -> BoxFuture<'_, Result>> { + Box::pin(async move { + let mut artifacts = Vec::new(); + for asset in self.artifact_store.list_for_run(&self.run_id).await? { + let data = self + .artifact_store + .get(&self.run_id, &asset.node, &asset.filename) + .await? + .with_context(|| { + format!( + "asset {:?} for node {:?} visit {} is missing from the store", + asset.filename, + asset.node.node_id(), + asset.node.visit() + ) + })?; + artifacts.push(DumpArtifact { + stage_id: asset.node, + relative_path: asset.filename, + data: data.to_vec(), + }); + } + Ok(artifacts) + }) + } +} + +struct ServerDumpSource<'a> { + client: &'a ServerStoreClient, + run_id: &'a RunId, +} + +impl<'a> ServerDumpSource<'a> { + fn new(client: &'a ServerStoreClient, run_id: &'a RunId) -> Self { + Self { client, run_id } + } +} + +impl DumpDataSource for ServerDumpSource<'_> { + fn list_events(&self) -> BoxFuture<'_, Result>> { + Box::pin(async move { self.client.list_run_events(self.run_id, None, None).await }) + } + + fn read_blob(&self, blob_id: RunBlobId) -> BoxFuture<'_, Result>> { + Box::pin(async move { self.client.read_run_blob(self.run_id, &blob_id).await }) + } + + fn list_artifacts(&self) -> BoxFuture<'_, Result>> { + Box::pin(async move { + let mut artifacts = Vec::new(); + for artifact in self.client.list_run_artifacts(self.run_id).await? { + let stage_id: StageId = artifact.stage_id.parse().with_context(|| { + format!("server returned invalid stage id {:?}", artifact.stage_id) + })?; + let data = self + .client + .download_stage_artifact(self.run_id, &stage_id, &artifact.relative_path) + .await + .with_context(|| { + format!( + "failed to download artifact {} for stage {}", + artifact.relative_path, artifact.stage_id + ) + })?; + artifacts.push(DumpArtifact { + stage_id, + relative_path: artifact.relative_path, + data, + }); + } + Ok(artifacts) + }) + } +} + +async fn export_run_from_source( + source: &impl DumpDataSource, + state: &RunProjection, + output_dir: &Path, +) -> Result { let output_state = inspect_output_dir(output_dir)?; let staging_parent = output_parent_dir(output_dir); std::fs::create_dir_all(staging_parent) @@ -67,159 +225,32 @@ pub(crate) async fn export_run(run_store: &dyn RunStore, output_dir: &Path) -> R })?; let staging_path = staging_dir.path().to_path_buf(); - let file_count = export_run_to_dir(run_store, &snapshot, &staging_path).await?; - - if matches!(output_state, OutputDirState::ExistingEmpty) { - std::fs::remove_dir(output_dir) - .with_context(|| format!("failed to replace {}", output_dir.display()))?; - } - std::fs::rename(&staging_path, output_dir).with_context(|| { - format!( - "failed to move staged export {} into {}", - staging_path.display(), - output_dir.display() - ) - })?; - let _ = staging_dir.keep(); - - Ok(file_count) + let file_count = write_run_dump(source, state, &staging_path).await?; + finalize_export( + output_dir, + output_state, + staging_dir, + &staging_path, + file_count, + ) } -async fn export_run_to_dir( - run_store: &dyn RunStore, - snapshot: &RunSnapshot, +async fn write_run_dump( + source: &impl DumpDataSource, + state: &RunProjection, output_dir: &Path, ) -> Result { - let mut file_count = 0; + let events = source.list_events().await?; + let mut dump = RunDump::from_store_state_and_events(state, &events)?; - write_json_file(&output_dir.join("run.json"), &snapshot.run)?; - file_count += 1; - file_count += usize::from(write_optional_json_file( - &output_dir.join("start.json"), - snapshot.start.as_ref(), - )?); - file_count += usize::from(write_optional_json_file( - &output_dir.join("status.json"), - snapshot.status.as_ref(), - )?); - file_count += usize::from(write_optional_json_file( - &output_dir.join("checkpoint.json"), - snapshot.checkpoint.as_ref(), - )?); - file_count += usize::from(write_optional_json_file( - &output_dir.join("conclusion.json"), - snapshot.conclusion.as_ref(), - )?); - file_count += usize::from(write_optional_json_file( - &output_dir.join("retro.json"), - snapshot.retro.as_ref(), - )?); - file_count += usize::from(write_optional_text_file( - &output_dir.join("graph.fabro"), - snapshot.graph.as_deref(), - )?); - file_count += usize::from(write_optional_json_file( - &output_dir.join("sandbox.json"), - snapshot.sandbox.as_ref(), - )?); + dump.hydrate_referenced_blobs_with_reader(|blob_id| source.read_blob(blob_id)) + .await?; - for node in &snapshot.nodes { - let node_id = validate_single_path_segment("node id", &node.node_id)?; - let base = output_dir - .join("nodes") - .join(node_id) - .join(format!("visit-{}", node.visit)); - file_count += usize::from(write_optional_text_file( - &base.join("prompt.md"), - node.prompt.as_deref(), - )?); - file_count += usize::from(write_optional_text_file( - &base.join("response.md"), - node.response.as_deref(), - )?); - file_count += usize::from(write_optional_json_file( - &base.join("status.json"), - node.status.as_ref(), - )?); - file_count += usize::from(write_optional_text_file( - &base.join("stdout.log"), - node.stdout.as_deref(), - )?); - file_count += usize::from(write_optional_text_file( - &base.join("stderr.log"), - node.stderr.as_deref(), - )?); + for artifact in source.list_artifacts().await? { + dump.add_artifact_bytes(&artifact.stage_id, &artifact.relative_path, artifact.data)?; } - file_count += usize::from(write_optional_text_file( - &output_dir.join("retro").join("prompt.md"), - run_store.get_retro_prompt().await?.as_deref(), - )?); - file_count += usize::from(write_optional_text_file( - &output_dir.join("retro").join("response.md"), - run_store.get_retro_response().await?.as_deref(), - )?); - - write_events_jsonl( - &output_dir.join("events.jsonl"), - &run_store.list_events().await?, - )?; - file_count += 1; - - for (seq, checkpoint) in run_store.list_checkpoints().await? { - write_json_file( - &output_dir - .join("checkpoints") - .join(format!("{seq:04}.json")), - &checkpoint, - )?; - file_count += 1; - } - - for artifact_id in run_store.list_artifact_values().await? { - let artifact_id_segment = validate_single_path_segment("artifact id", &artifact_id)?; - let value = run_store - .get_artifact_value(&artifact_id) - .await? - .with_context(|| format!("artifact value {artifact_id:?} is missing from the store"))?; - write_json_file( - &output_dir - .join("artifacts") - .join("values") - .join(format!("{}.json", artifact_id_segment.display())), - &value, - )?; - file_count += 1; - } - - for (node_id, visit, filename) in run_store.list_all_assets().await? { - let node_id_segment = validate_single_path_segment("node id", &node_id)?; - let filename_path = validate_relative_path("asset filename", &filename)?; - let node = NodeVisitRef { - node_id: &node_id, - visit, - }; - let data = run_store - .get_asset(&node, &filename) - .await? - .with_context(|| { - format!( - "asset {filename:?} for node {node_id:?} visit {visit} is missing from the store" - ) - })?; - write_bytes_file( - &output_dir - .join("artifacts") - .join("nodes") - .join(node_id_segment) - .join(format!("visit-{visit}")) - .join(filename_path), - data.as_ref(), - )?; - file_count += 1; - } - - Ok(file_count) + dump.write_to_dir(output_dir) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -262,108 +293,26 @@ fn output_parent_dir(path: &Path) -> &Path { } } -fn validate_single_path_segment(kind: &str, value: &str) -> Result { - let path = validate_relative_path(kind, value)?; - if path.components().count() != 1 { - bail!("{kind} {value:?} must be a single path segment"); - } - Ok(path) -} - -fn validate_relative_path(kind: &str, value: &str) -> Result { - let mut normalized = PathBuf::new(); - for component in Path::new(value).components() { - match component { - Component::Normal(part) => normalized.push(part), - Component::CurDir => {} - Component::ParentDir | Component::RootDir | Component::Prefix(_) => { - bail!("{kind} {value:?} must be a relative path without '..'"); - } - } - } - if normalized.as_os_str().is_empty() { - bail!("{kind} {value:?} must not be empty"); - } - Ok(normalized) -} - -fn write_optional_json_file(path: &Path, value: Option<&T>) -> Result -where - T: Serialize, -{ - match value { - Some(value) => { - write_json_file(path, value)?; - Ok(true) - } - None => Ok(false), - } -} - -fn write_json_file(path: &Path, value: &T) -> Result<()> -where - T: Serialize, -{ - ensure_parent_dir(path)?; - let bytes = serde_json::to_vec_pretty(value)?; - std::fs::write(path, bytes).with_context(|| format!("failed to write {}", path.display()))?; - Ok(()) -} - -fn write_optional_text_file(path: &Path, value: Option<&str>) -> Result { - match value { - Some(value) => { - write_text_file(path, value)?; - Ok(true) - } - None => Ok(false), - } -} - -fn write_text_file(path: &Path, value: &str) -> Result<()> { - write_bytes_file(path, value.as_bytes()) -} - -fn write_bytes_file(path: &Path, value: &[u8]) -> Result<()> { - ensure_parent_dir(path)?; - std::fs::write(path, value).with_context(|| format!("failed to write {}", path.display()))?; - Ok(()) -} - -fn write_events_jsonl(path: &Path, events: &[fabro_store::EventEnvelope]) -> Result<()> { - ensure_parent_dir(path)?; - let mut file = std::fs::File::create(path) - .with_context(|| format!("failed to create {}", path.display()))?; - for event in events { - serde_json::to_writer(&mut file, event)?; - file.write_all(b"\n")?; - } - Ok(()) -} - -fn ensure_parent_dir(path: &Path) -> Result<()> { - let parent = path - .parent() - .with_context(|| format!("path {} has no parent", path.display()))?; - std::fs::create_dir_all(parent) - .with_context(|| format!("failed to create {}", parent.display()))?; - Ok(()) -} - #[cfg(test)] mod tests { - use super::*; - use std::collections::HashMap; use std::path::PathBuf; + use std::sync::Arc; + use std::time::Duration; use chrono::{DateTime, Utc}; - use fabro_store::{EventEnvelope, EventPayload, InMemoryStore, Store as _}; + use fabro_store::{Database, EventEnvelope, EventPayload}; + use fabro_types::settings::SettingsLayer; use fabro_types::{ - AggregateStats, AttrValue, Checkpoint, Conclusion, FabroSettings, Graph, NodeStatusRecord, - Retro, RunId, RunRecord, RunStatus, RunStatusRecord, SandboxRecord, StageStatus, - StartRecord, StatusReason, fixtures, + AggregateStats, AttrValue, BilledTokenCounts, Checkpoint, Conclusion, Graph, + NodeStatusRecord, Retro, RunId, RunRecord, RunStatus, RunStatusRecord, SandboxRecord, + StageStatus, StartRecord, StatusReason, fixtures, }; + use fabro_workflow::event::{Event, append_event}; + use object_store::ObjectStore; + use object_store::memory::InMemory; + + use super::*; fn dt(rfc3339: &str) -> DateTime { DateTime::parse_from_rfc3339(rfc3339) @@ -375,7 +324,18 @@ mod tests { fixtures::RUN_1 } - fn sample_run_record(run_id: RunId, created_at: DateTime) -> RunRecord { + fn test_store_bundle() -> (Arc, ArtifactStore) { + let object_store: Arc = Arc::new(InMemory::new()); + let store = Arc::new(Database::new( + Arc::clone(&object_store), + "", + Duration::from_millis(1), + )); + let artifact_store = ArtifactStore::new(object_store, "artifacts"); + (store, artifact_store) + } + + fn sample_run_record(run_id: RunId, _created_at: DateTime) -> RunRecord { let mut graph = Graph::new("night-sky"); graph.attrs.insert( "goal".to_string(), @@ -383,14 +343,17 @@ mod tests { ); RunRecord { run_id, - created_at, - settings: FabroSettings::default(), + settings: SettingsLayer::default(), graph, workflow_slug: Some("night-sky".to_string()), working_directory: PathBuf::from("/tmp/night-sky"), host_repo_path: Some("github.com/fabro-sh/fabro".to_string()), + repo_origin_url: Some("https://github.com/fabro-sh/fabro".to_string()), base_branch: Some("main".to_string()), labels: HashMap::from([("team".to_string(), "infra".to_string())]), + provenance: None, + manifest_blob: None, + definition_blob: None, } } @@ -405,47 +368,52 @@ mod tests { fn sample_status() -> RunStatusRecord { RunStatusRecord { - status: RunStatus::Running, - reason: Some(StatusReason::SandboxInitializing), + status: RunStatus::Running, + reason: Some(StatusReason::SandboxInitializing), updated_at: dt("2026-03-27T12:05:00Z"), } } fn sample_checkpoint(current_node: &str, visit: u32) -> Checkpoint { Checkpoint { - timestamp: dt("2026-03-27T12:10:00Z"), - current_node: current_node.to_string(), - completed_nodes: vec!["plan".to_string()], - node_retries: HashMap::from([(current_node.to_string(), visit.saturating_sub(1))]), - context_values: HashMap::from([( + timestamp: dt("2026-03-27T12:10:00Z"), + current_node: current_node.to_string(), + completed_nodes: vec!["plan".to_string()], + node_retries: HashMap::from([( + current_node.to_string(), + visit.saturating_sub(1), + )]), + context_values: HashMap::from([( "artifact".to_string(), serde_json::json!({"kind": "summary"}), )]), - node_outcomes: HashMap::new(), - next_node_id: Some("review".to_string()), - git_commit_sha: Some("def456".to_string()), - loop_failure_signatures: HashMap::new(), + node_outcomes: HashMap::new(), + next_node_id: Some("review".to_string()), + git_commit_sha: Some("def456".to_string()), + loop_failure_signatures: HashMap::new(), restart_failure_signatures: HashMap::new(), - node_visits: HashMap::from([(current_node.to_string(), visit as usize)]), + node_visits: HashMap::from([(current_node.to_string(), visit as usize)]), } } fn sample_conclusion() -> Conclusion { Conclusion { - timestamp: dt("2026-03-27T12:15:00Z"), - status: StageStatus::Success, - duration_ms: 3210, - failure_reason: None, + timestamp: dt("2026-03-27T12:15:00Z"), + status: StageStatus::Success, + duration_ms: 3210, + failure_reason: None, final_git_commit_sha: Some("feedbeef".to_string()), - stages: Vec::new(), - total_cost: Some(1.25), - total_retries: 2, - total_input_tokens: 10, - total_output_tokens: 20, - total_cache_read_tokens: 30, - total_cache_write_tokens: 40, - total_reasoning_tokens: 50, - has_pricing: true, + stages: Vec::new(), + billing: Some(BilledTokenCounts { + input_tokens: 10, + output_tokens: 20, + total_tokens: 150, + reasoning_tokens: 50, + cache_read_tokens: 30, + cache_write_tokens: 40, + total_usd_micros: Some(1_250_000), + }), + total_retries: 2, } } @@ -458,12 +426,12 @@ mod tests { smoothness: None, stages: Vec::new(), stats: AggregateStats { - total_duration_ms: 3210, - total_cost: Some(1.25), - total_retries: 2, - files_touched: vec!["src/lib.rs".to_string()], - stages_completed: 3, - stages_failed: 0, + total_duration_ms: 3210, + total_billing_usd_micros: Some(1_250_000), + total_retries: 2, + files_touched: vec!["src/lib.rs".to_string()], + stages_completed: 3, + stages_failed: 0, }, intent: Some("ship the fix".to_string()), outcome: Some("done".to_string()), @@ -475,113 +443,257 @@ mod tests { fn sample_sandbox() -> SandboxRecord { SandboxRecord { - provider: "local".to_string(), - working_directory: "/tmp/night-sky".to_string(), - identifier: Some("sandbox-1".to_string()), + provider: "local".to_string(), + working_directory: "/tmp/night-sky".to_string(), + identifier: Some("sandbox-1".to_string()), host_working_directory: Some("/tmp/night-sky".to_string()), - container_mount_point: None, + container_mount_point: None, } } - fn sample_node_status() -> NodeStatusRecord { - NodeStatusRecord { - status: StageStatus::PartialSuccess, - notes: Some("captured output".to_string()), - failure_reason: Some("minor lint".to_string()), - timestamp: dt("2026-03-27T12:12:00Z"), - } - } - - fn event_payload(run_id: RunId, ts: &str, event: &str) -> EventPayload { - EventPayload::new( - serde_json::json!({ - "id": format!("evt-{run_id}-{event}"), - "ts": ts, - "run_id": run_id.to_string(), - "event": event - }), - &run_id, - ) - .unwrap() - } - fn read_json(path: &Path) -> T { - serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap() + let bytes = std::fs::read(path) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display())); + serde_json::from_slice(&bytes) + .unwrap_or_else(|err| panic!("failed to parse {}: {err}", path.display())) } #[tokio::test] async fn export_run_writes_expected_directory_tree() { - let store = InMemoryStore::default(); + let (store, artifact_store) = test_store_bundle(); let created_at = dt("2026-03-27T12:00:00Z"); let run_id = test_run_id(); - let run = store.create_run(&run_id, created_at, None).await.unwrap(); + let run = store.create_run(&run_id).await.unwrap(); + let run_record = sample_run_record(run_id, created_at); + let start_record = sample_start_record(run_id, created_at); + let status_record = sample_status(); + let mut first_checkpoint = sample_checkpoint("plan", 1); + let mut second_checkpoint = sample_checkpoint("code", 2); + let conclusion = sample_conclusion(); + let retro = sample_retro(run_id); + let sandbox = sample_sandbox(); + let summary_blob = run.write_blob(br#"{"done":true}"#).await.unwrap(); + let plan_blob = run.write_blob(br#"{"steps":3}"#).await.unwrap(); + first_checkpoint.context_values.insert( + "artifact".to_string(), + serde_json::json!(fabro_types::format_blob_ref(&plan_blob)), + ); + second_checkpoint.context_values.insert( + "artifact".to_string(), + serde_json::json!(fabro_types::format_blob_ref(&summary_blob)), + ); - run.put_run(&sample_run_record(run_id, created_at)) - .await - .unwrap(); - run.put_start(&sample_start_record(run_id, created_at)) - .await - .unwrap(); - run.put_status(&sample_status()).await.unwrap(); - run.append_checkpoint(&sample_checkpoint("plan", 1)) - .await - .unwrap(); - run.append_checkpoint(&sample_checkpoint("code", 2)) - .await - .unwrap(); - run.put_conclusion(&sample_conclusion()).await.unwrap(); - run.put_retro(&sample_retro(run_id)).await.unwrap(); - run.put_graph("digraph night_sky {}").await.unwrap(); - run.put_sandbox(&sample_sandbox()).await.unwrap(); - - let node = NodeVisitRef { - node_id: "code", - visit: 2, - }; - run.put_node_prompt(&node, "Plan the fix").await.unwrap(); - run.put_node_response(&node, "Implemented").await.unwrap(); - run.put_node_status(&node, &sample_node_status()) - .await - .unwrap(); - run.put_node_stdout(&node, "stdout line").await.unwrap(); - run.put_node_stderr(&node, "").await.unwrap(); - run.put_retro_prompt("How did it go?").await.unwrap(); - run.put_retro_response("Smooth enough").await.unwrap(); - run.append_event(&event_payload( + let node = StageId::new("code", 2); + append_event(&run, &run_id, &Event::RunCreated { run_id, - "2026-03-27T12:00:00.000Z", - "run.started", - )) + settings: serde_json::to_value(&run_record.settings).unwrap(), + graph: serde_json::to_value(&run_record.graph).unwrap(), + workflow_source: Some("digraph night_sky {}".to_string()), + workflow_config: None, + labels: run_record.labels.clone().into_iter().collect(), + run_dir: "/tmp/night-sky-run".to_string(), + working_directory: run_record.working_directory.display().to_string(), + host_repo_path: run_record.host_repo_path.clone(), + repo_origin_url: run_record.repo_origin_url.clone(), + base_branch: run_record.base_branch.clone(), + workflow_slug: run_record.workflow_slug.clone(), + db_prefix: None, + provenance: run_record.provenance.clone(), + manifest_blob: None, + }) .await .unwrap(); - run.append_event(&event_payload( + append_event(&run, &run_id, &Event::WorkflowRunStarted { + name: "night-sky".to_string(), run_id, - "2026-03-27T12:00:01.000Z", - "stage.completed", - )) + base_branch: run_record.base_branch.clone(), + base_sha: start_record.base_sha.clone(), + run_branch: start_record.run_branch.clone(), + worktree_dir: None, + goal: Some("map the constellations".to_string()), + }) .await .unwrap(); - run.put_artifact_value("summary", &serde_json::json!({"done": true})) + append_event(&run, &run_id, &Event::RunRunning { + reason: status_record.reason, + }) + .await + .unwrap(); + for checkpoint in [&first_checkpoint, &second_checkpoint] { + append_event(&run, &run_id, &Event::CheckpointCompleted { + node_id: checkpoint.current_node.clone(), + status: "success".to_string(), + current_node: checkpoint.current_node.clone(), + completed_nodes: checkpoint.completed_nodes.clone(), + node_retries: checkpoint.node_retries.clone().into_iter().collect(), + context_values: checkpoint.context_values.clone().into_iter().collect(), + node_outcomes: checkpoint.node_outcomes.clone().into_iter().collect(), + next_node_id: checkpoint.next_node_id.clone(), + git_commit_sha: checkpoint.git_commit_sha.clone(), + loop_failure_signatures: checkpoint + .loop_failure_signatures + .clone() + .into_iter() + .map(|(signature, count)| (signature.to_string(), count)) + .collect(), + restart_failure_signatures: checkpoint + .restart_failure_signatures + .clone() + .into_iter() + .map(|(signature, count)| (signature.to_string(), count)) + .collect(), + node_visits: checkpoint.node_visits.clone().into_iter().collect(), + diff: None, + }) .await .unwrap(); - run.put_artifact_value("plan", &serde_json::json!({"steps": 3})) - .await - .unwrap(); - run.put_asset(&node, "src/lib.rs", b"fn main() {}") + } + append_event(&run, &run_id, &Event::SandboxInitialized { + working_directory: sandbox.working_directory.clone(), + provider: sandbox.provider.clone(), + identifier: sandbox.identifier.clone(), + host_working_directory: sandbox.host_working_directory.clone(), + container_mount_point: sandbox.container_mount_point.clone(), + }) + .await + .unwrap(); + append_event(&run, &run_id, &Event::Prompt { + stage: "code".to_string(), + visit: 2, + text: "Plan the fix".to_string(), + mode: None, + provider: None, + model: None, + }) + .await + .unwrap(); + append_event(&run, &run_id, &Event::PromptCompleted { + node_id: "code".to_string(), + response: "Implemented".to_string(), + model: "gpt-5".to_string(), + provider: "openai".to_string(), + billing: None, + }) + .await + .unwrap(); + append_event(&run, &run_id, &Event::StageCompleted { + node_id: "code".to_string(), + name: "Code".to_string(), + index: 1, + duration_ms: 250, + status: "partial_success".to_string(), + preferred_label: None, + suggested_next_ids: Vec::new(), + billing: None, + failure: None, + notes: Some("captured output".to_string()), + files_touched: Vec::new(), + context_updates: None, + jump_to_node: None, + context_values: None, + node_visits: Some(std::collections::BTreeMap::from([( + "code".to_string(), + 2usize, + )])), + loop_failure_signatures: None, + restart_failure_signatures: None, + response: Some("Implemented".to_string()), + attempt: 1, + max_attempts: 1, + }) + .await + .unwrap(); + append_event(&run, &run_id, &Event::CommandStarted { + node_id: "code".to_string(), + script: "echo hi".to_string(), + command: "echo hi".to_string(), + language: "sh".to_string(), + timeout_ms: None, + }) + .await + .unwrap(); + append_event(&run, &run_id, &Event::CommandCompleted { + node_id: "code".to_string(), + stdout: "stdout line".to_string(), + stderr: String::new(), + exit_code: Some(0), + duration_ms: 100, + timed_out: false, + }) + .await + .unwrap(); + append_event(&run, &run_id, &Event::RetroStarted { + prompt: Some("How did it go?".to_string()), + provider: None, + model: None, + }) + .await + .unwrap(); + append_event(&run, &run_id, &Event::RetroCompleted { + duration_ms: 50, + response: Some("Smooth enough".to_string()), + retro: Some(serde_json::to_value(&retro).unwrap()), + }) + .await + .unwrap(); + append_event(&run, &run_id, &Event::WorkflowRunCompleted { + duration_ms: conclusion.duration_ms, + artifact_count: 0, + status: "success".to_string(), + reason: None, + total_usd_micros: conclusion + .billing + .as_ref() + .and_then(|billing| billing.total_usd_micros), + final_git_commit_sha: conclusion.final_git_commit_sha.clone(), + final_patch: None, + billing: conclusion.billing.clone(), + }) + .await + .unwrap(); + run.append_event( + &EventPayload::new( + serde_json::json!({ + "id": format!("evt-{run_id}-stage-completed"), + "ts": "2026-03-27T12:00:01.000Z", + "run_id": run_id.to_string(), + "event": "stage.completed", + "node_id": "code", + "node_label": "Code", + "properties": { + "index": 1, + "duration_ms": 1, + "status": "success", + "response": "Implemented", + "notes": "all good", + "files_touched": ["src/lib.rs"], + "node_visits": {"code": 2}, + "attempt": 1, + "max_attempts": 1 + } + }), + &run_id, + ) + .unwrap(), + ) + .await + .unwrap(); + artifact_store + .put(&run_id, &node, "src/lib.rs", b"fn main() {}") .await .unwrap(); - let asset_only_node = NodeVisitRef { - node_id: "artifact-only", - visit: 7, - }; - run.put_asset(&asset_only_node, "logs/output.txt", b"hello") + let artifact_only_node = StageId::new("artifact-only", 7); + artifact_store + .put(&run_id, &artifact_only_node, "logs/output.txt", b"hello") .await .unwrap(); let output = tempfile::tempdir().unwrap(); - let file_count = export_run(run.as_ref(), output.path()).await.unwrap(); - assert_eq!(file_count, 22); + let file_count = export_run(&run, &artifact_store, output.path()) + .await + .unwrap(); + assert_eq!(file_count, 20); let exported_run: RunRecord = read_json(&output.path().join("run.json")); assert_eq!(exported_run.run_id, run_id); @@ -590,10 +702,14 @@ mod tests { assert_eq!(exported_start.run_id, run_id); let exported_status: RunStatusRecord = read_json(&output.path().join("status.json")); - assert_eq!(exported_status.status, RunStatus::Running); + assert_eq!(exported_status.status, RunStatus::Succeeded); let exported_checkpoint: Checkpoint = read_json(&output.path().join("checkpoint.json")); assert_eq!(exported_checkpoint.current_node, "code"); + assert_eq!( + exported_checkpoint.context_values.get("artifact"), + Some(&serde_json::json!({"done": true})) + ); assert_eq!( std::fs::read_to_string(output.path().join("graph.fabro")).unwrap(), "digraph night_sky {}" @@ -609,7 +725,7 @@ mod tests { ); let node_status: NodeStatusRecord = read_json(&output.path().join("nodes/code/visit-2/status.json")); - assert_eq!(node_status.status, StageStatus::PartialSuccess); + assert_eq!(node_status.status, StageStatus::Success); assert_eq!( std::fs::read_to_string(output.path().join("nodes/code/visit-2/stdout.log")).unwrap(), "stdout line" @@ -633,21 +749,23 @@ mod tests { .lines() .map(|line| serde_json::from_str(line).unwrap()) .collect(); - assert_eq!(events.len(), 2); + assert_eq!(events.len(), 15); assert_eq!(events[0].seq, 1); - assert_eq!(events[1].seq, 2); + assert_eq!(events.last().unwrap().seq, 15); - let first_checkpoint: Checkpoint = read_json(&output.path().join("checkpoints/0001.json")); - let second_checkpoint: Checkpoint = read_json(&output.path().join("checkpoints/0002.json")); + let first_checkpoint: Checkpoint = read_json(&output.path().join("checkpoints/0004.json")); + let second_checkpoint: Checkpoint = read_json(&output.path().join("checkpoints/0005.json")); assert_eq!(first_checkpoint.current_node, "plan"); assert_eq!(second_checkpoint.current_node, "code"); - - let exported_plan: serde_json::Value = - read_json(&output.path().join("artifacts/values/plan.json")); - let exported_summary: serde_json::Value = - read_json(&output.path().join("artifacts/values/summary.json")); - assert_eq!(exported_plan, serde_json::json!({"steps": 3})); - assert_eq!(exported_summary, serde_json::json!({"done": true})); + assert_eq!( + first_checkpoint.context_values.get("artifact"), + Some(&serde_json::json!({"steps": 3})) + ); + assert_eq!( + second_checkpoint.context_values.get("artifact"), + Some(&serde_json::json!({"done": true})) + ); + assert!(!output.path().join("blobs").exists()); assert_eq!( std::fs::read( @@ -670,34 +788,6 @@ mod tests { assert!(!output.path().join("nodes/artifact-only").exists()); } - #[tokio::test] - async fn export_run_rejects_path_traversal_and_leaves_no_partial_output() { - let store = InMemoryStore::default(); - let created_at = dt("2026-03-27T12:00:00Z"); - let run_id = test_run_id(); - let run = store.create_run(&run_id, created_at, None).await.unwrap(); - - run.put_run(&sample_run_record(run_id, created_at)) - .await - .unwrap(); - run.put_asset( - &NodeVisitRef { - node_id: "code", - visit: 1, - }, - "../escape.txt", - b"boom", - ) - .await - .unwrap(); - - let temp = tempfile::tempdir().unwrap(); - let output = temp.path().join("dump"); - let err = export_run(run.as_ref(), &output).await.unwrap_err(); - assert!(err.to_string().contains("asset filename")); - assert!(!output.exists()); - } - #[test] fn inspect_output_dir_rejects_non_empty_directory() { let dir = tempfile::tempdir().unwrap(); diff --git a/lib/crates/fabro-cli/src/commands/store/mod.rs b/lib/crates/fabro-cli/src/commands/store/mod.rs index 1012ac373..3947c5dd5 100644 --- a/lib/crates/fabro-cli/src/commands/store/mod.rs +++ b/lib/crates/fabro-cli/src/commands/store/mod.rs @@ -1,11 +1,17 @@ -mod dump; +pub(crate) mod dump; +pub(crate) mod rebuild; use anyhow::Result; +use fabro_util::printer::Printer; use crate::args::{GlobalArgs, StoreCommand, StoreNamespace}; -pub(crate) async fn dispatch(ns: StoreNamespace, globals: &GlobalArgs) -> Result<()> { +pub(crate) async fn dispatch( + ns: StoreNamespace, + globals: &GlobalArgs, + printer: Printer, +) -> Result<()> { match ns.command { - StoreCommand::Dump(args) => dump::dump_command(&args, globals).await, + StoreCommand::Dump(args) => dump::dump_command(&args, globals, printer).await, } } diff --git a/lib/crates/fabro-cli/src/commands/store/rebuild.rs b/lib/crates/fabro-cli/src/commands/store/rebuild.rs new file mode 100644 index 000000000..43fcebcdb --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/store/rebuild.rs @@ -0,0 +1,23 @@ +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Result; +use fabro_store::{Database, EventEnvelope, EventPayload, RunDatabase}; +use object_store::memory::InMemory; + +pub(crate) async fn rebuild_run_store( + run_id: &fabro_types::RunId, + events: &[EventEnvelope], +) -> Result { + let store = Arc::new(Database::new( + Arc::new(InMemory::new()), + "", + Duration::from_millis(1), + )); + let run_store = store.create_run(run_id).await?; + for event in events { + let payload = EventPayload::new(event.payload.as_value().clone(), run_id)?; + run_store.append_event(&payload).await?; + } + Ok(run_store) +} diff --git a/lib/crates/fabro-cli/src/commands/system/df.rs b/lib/crates/fabro-cli/src/commands/system/df.rs index dfc6c3e80..f76ab1ef2 100644 --- a/lib/crates/fabro-cli/src/commands/system/df.rs +++ b/lib/crates/fabro-cli/src/commands/system/df.rs @@ -1,150 +1,75 @@ -use std::path::Path; - use anyhow::Result; use chrono::{DateTime, Utc}; use cli_table::format::{Border, Justify, Separator}; use cli_table::{Cell, CellStruct, Style, Table}; -use fabro_config::FabroSettingsExt; -use serde::Serialize; - -use fabro_workflow::run_lookup::{logs_base, runs_base, scan_runs_combined}; -use fabro_workflow::run_status::RunStatus; +use fabro_api::types; +use fabro_util::printer::Printer; use crate::args::{DfArgs, GlobalArgs}; +use crate::command_context::CommandContext; +use crate::server_client; use crate::shared::{format_size, print_json_pretty}; -use crate::store; -use crate::user_config::load_user_settings_with_globals; -#[derive(Serialize)] -struct SummaryRow { - r#type: String, - count: u64, - #[serde(skip_serializing_if = "Option::is_none")] - active: Option, - size_bytes: u64, - #[serde(skip_serializing_if = "Option::is_none")] - reclaimable_bytes: Option, -} +pub(super) async fn df_command( + args: &DfArgs, + globals: &GlobalArgs, + printer: Printer, +) -> Result<()> { + let ctx = CommandContext::for_connection(&args.connection, printer)?; + let server = ctx.server().await?; + let output = server + .api() + .get_system_disk_usage() + .verbose(args.verbose) + .send() + .await + .map_err(server_client::map_api_error)? + .into_inner(); -#[derive(Serialize)] -struct RunSizeRow { - run_id: String, - workflow_name: String, - status: RunStatus, - start_time: String, - size_bytes: u64, - reclaimable: bool, -} + let storage_dir = if globals.json { + None + } else { + server + .api() + .get_system_info() + .send() + .await + .map_err(server_client::map_api_error)? + .into_inner() + .storage_dir + }; -#[derive(Serialize)] -struct DfOutput { - summary: Vec, - total_size_bytes: u64, - total_reclaimable_bytes: u64, - #[serde(skip_serializing_if = "Option::is_none")] - runs: Option>, -} - -pub(super) async fn df_command(args: &DfArgs, globals: &GlobalArgs) -> Result<()> { - let cli_settings = load_user_settings_with_globals(globals)?; - let data_dir = cli_settings.storage_dir(); - let runs_base_dir = runs_base(&data_dir); - let logs_base_dir = logs_base(&data_dir); - let store = store::build_store(&data_dir)?; - df_from( - args, - store.as_ref(), - &data_dir, - &runs_base_dir, - &logs_base_dir, - globals, - ) - .await + df_from(&output, storage_dir.as_deref(), globals) } #[allow(clippy::print_stdout)] -async fn df_from( - args: &DfArgs, - store: &dyn fabro_store::Store, - data_dir: &Path, - runs_base: &Path, - logs_base: &Path, +fn df_from( + output: &types::DiskUsageResponse, + storage_dir: Option<&str>, globals: &GlobalArgs, ) -> Result<()> { - struct RunSizeInfo { - run_id: String, - workflow_name: String, - status: RunStatus, - start_time: String, - start_time_dt: Option>, - size: u64, - } + let runs_summary = output + .summary + .iter() + .find(|row| row.type_.as_deref() == Some("runs")); + let logs_summary = output + .summary + .iter() + .find(|row| row.type_.as_deref() == Some("logs")); - let runs = scan_runs_combined(store, runs_base).await?; - let mut active_count = 0u64; - let mut total_run_size = 0u64; - let mut reclaimable_run_size = 0u64; + let run_count = runs_summary.and_then(|row| row.count).map_or(0, as_u64); + let active_count = runs_summary.and_then(|row| row.active).map_or(0, as_u64); + let total_run_size = runs_summary + .and_then(|row| row.size_bytes) + .map_or(0, as_u64); + let reclaimable_run_size = runs_summary + .and_then(|row| row.reclaimable_bytes) + .map_or(0, as_u64); - let mut run_details = Vec::new(); - for run in &runs { - let size = dir_size(&run.path); - total_run_size += size; - if run.status.is_active() { - active_count += 1; - } else { - reclaimable_run_size += size; - } - if args.verbose { - run_details.push(RunSizeInfo { - run_id: run.run_id.to_string(), - workflow_name: run.workflow_name.clone(), - status: run.status, - start_time: run.start_time.clone(), - start_time_dt: run.start_time_dt, - size, - }); - } - } - - let mut log_count = 0u64; - let mut total_log_size = 0u64; - if let Ok(entries) = std::fs::read_dir(logs_base) { - for entry in entries.flatten() { - let path = entry.path(); - if !path.is_file() { - continue; - } - if path.extension().is_some_and(|ext| ext == "log") { - if let Ok(meta) = path.metadata() { - log_count += 1; - total_log_size += meta.len(); - } - } - } - } - - let mut db_count = 0u64; - let mut total_db_size = 0u64; - if let Ok(entries) = std::fs::read_dir(data_dir) { - for entry in entries.flatten() { - let path = entry.path(); - if !path.is_file() { - continue; - } - let name = entry.file_name().to_string_lossy().to_string(); - if std::path::Path::new(&name) - .extension() - .is_some_and(|ext| ext.eq_ignore_ascii_case("db")) - || name.ends_with(".db-wal") - || name.ends_with(".db-shm") - { - if let Ok(meta) = path.metadata() { - db_count += 1; - total_db_size += meta.len(); - } - } - } - } + let log_count = logs_summary.and_then(|row| row.count).map_or(0, as_u64); + let total_log_size = logs_summary + .and_then(|row| row.size_bytes) + .map_or(0, as_u64); let run_reclaim_pct = if total_run_size > 0 { #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] @@ -158,48 +83,7 @@ async fn df_from( let log_reclaim_pct = if total_log_size > 0 { 100 } else { 0 }; if globals.json { - let summary = vec![ - SummaryRow { - r#type: "runs".to_string(), - count: runs.len().try_into().unwrap(), - active: Some(active_count), - size_bytes: total_run_size, - reclaimable_bytes: Some(reclaimable_run_size), - }, - SummaryRow { - r#type: "logs".to_string(), - count: log_count, - active: None, - size_bytes: total_log_size, - reclaimable_bytes: Some(total_log_size), - }, - SummaryRow { - r#type: "databases".to_string(), - count: db_count, - active: None, - size_bytes: total_db_size, - reclaimable_bytes: Some(0), - }, - ]; - let runs = args.verbose.then(|| { - run_details - .iter() - .map(|detail| RunSizeRow { - run_id: detail.run_id.clone(), - workflow_name: detail.workflow_name.clone(), - status: detail.status, - start_time: detail.start_time.clone(), - size_bytes: detail.size, - reclaimable: !detail.status.is_active(), - }) - .collect::>() - }); - print_json_pretty(&DfOutput { - summary, - total_size_bytes: total_run_size + total_log_size + total_db_size, - total_reclaimable_bytes: reclaimable_run_size + total_log_size, - runs, - })?; + print_json_pretty(output)?; return Ok(()); } @@ -220,7 +104,7 @@ async fn df_from( let summary_rows: Vec> = vec![ vec![ "Runs".cell(), - runs.len().cell().justify(Justify::Right), + run_count.cell().justify(Justify::Right), active_count.cell().justify(Justify::Right), format_size(total_run_size).cell().justify(Justify::Right), format!("{} ({run_reclaim_pct}%)", format_size(reclaimable_run_size)) @@ -236,15 +120,6 @@ async fn df_from( .cell() .justify(Justify::Right), ], - vec![ - "Databases".cell(), - db_count.cell().justify(Justify::Right), - "-".cell().justify(Justify::Right), - format_size(total_db_size).cell().justify(Justify::Right), - format!("{} (0%)", format_size(0)) - .cell() - .justify(Justify::Right), - ], ]; let summary_table = summary_rows .table() @@ -254,13 +129,15 @@ async fn df_from( .separator(Separator::builder().build()); println!("{}", summary_table.display()?); - println!(); - println!("Data directory: {}", data_dir.display()); - - if !args.verbose { - return Ok(()); + if let Some(storage_dir) = storage_dir { + println!(); + println!("Data directory: {storage_dir}"); } + let Some(run_rows) = output.runs.as_ref() else { + return Ok(()); + }; + println!(); let verbose_title = vec![ "RUN ID".cell().bold(use_color), @@ -271,30 +148,36 @@ async fn df_from( ]; let now = Utc::now(); - let verbose_rows: Vec> = run_details + let verbose_rows: Vec> = run_rows .iter() .map(|detail| { - let age = if let Some(dt) = detail.start_time_dt { - let dur = now.signed_duration_since(dt); - if dur.num_days() > 0 { - format!("{}d", dur.num_days()) - } else if dur.num_hours() > 0 { - format!("{}h", dur.num_hours()) - } else { - format!("{}m", dur.num_minutes().max(1)) - } + let age = detail + .start_time + .as_deref() + .and_then(parse_start_time) + .map_or_else( + || "-".to_string(), + |dt| { + let dur = now.signed_duration_since(dt); + if dur.num_days() > 0 { + format!("{}d", dur.num_days()) + } else if dur.num_hours() > 0 { + format!("{}h", dur.num_hours()) + } else { + format!("{}m", dur.num_minutes().max(1)) + } + }, + ); + let size = detail.size_bytes.map_or(0, as_u64); + let size_display = if detail.reclaimable.unwrap_or(false) { + format!("{} *", format_size(size)) } else { - "-".to_string() - }; - let size_display = if detail.status.is_active() { - format_size(detail.size) - } else { - format!("{} *", format_size(detail.size)) + format_size(size) }; vec![ - short_run_id(&detail.run_id).cell(), - truncate_str(&detail.workflow_name, 16).cell(), - detail.status.to_string().cell(), + short_run_id(detail.run_id.as_deref().unwrap_or("-")).cell(), + truncate_str(detail.workflow_name.as_deref().unwrap_or("-"), 16).cell(), + detail.status.as_deref().unwrap_or("-").cell(), age.cell().justify(Justify::Right), size_display.cell().justify(Justify::Right), ] @@ -317,6 +200,16 @@ fn short_run_id(id: &str) -> &str { if id.len() > 12 { &id[..12] } else { id } } +fn as_u64(value: i64) -> u64 { + value.try_into().unwrap_or_default() +} + +fn parse_start_time(value: &str) -> Option> { + chrono::DateTime::parse_from_rfc3339(value) + .ok() + .map(|dt| dt.with_timezone(&Utc)) +} + fn truncate_str(s: &str, max_len: usize) -> String { let char_count = s.chars().count(); if char_count <= max_len { @@ -325,13 +218,3 @@ fn truncate_str(s: &str, max_len: usize) -> String { let truncated: String = s.chars().take(max_len - 3).collect(); format!("{truncated}...") } - -fn dir_size(path: &Path) -> u64 { - walkdir::WalkDir::new(path) - .into_iter() - .filter_map(std::result::Result::ok) - .filter_map(|entry| entry.metadata().ok()) - .filter(std::fs::Metadata::is_file) - .map(|metadata| metadata.len()) - .sum() -} diff --git a/lib/crates/fabro-cli/src/commands/system/events.rs b/lib/crates/fabro-cli/src/commands/system/events.rs new file mode 100644 index 000000000..64ed80d7e --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/system/events.rs @@ -0,0 +1,78 @@ +use anyhow::Result; +use fabro_util::printer::Printer; +use futures::StreamExt; + +use crate::args::{GlobalArgs, SystemEventsArgs}; +use crate::command_context::CommandContext; +use crate::{server_client, sse}; + +pub(super) async fn events_command( + args: &SystemEventsArgs, + globals: &GlobalArgs, + printer: Printer, +) -> Result<()> { + let ctx = CommandContext::for_connection(&args.connection, printer)?; + let server = ctx.server().await?; + + let mut request = server.api().attach_events(); + if !args.run_ids.is_empty() { + request = request.run_id(args.run_ids.join(",")); + } + + let response = request.send().await.map_err(server_client::map_api_error)?; + let mut stream = response.into_inner(); + let mut pending = Vec::new(); + + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|err| anyhow::anyhow!("{err}"))?; + pending.extend_from_slice(&chunk); + for payload in sse::drain_sse_payloads(&mut pending, false) { + render_sse_payload(&payload, globals.json)?; + } + } + + for payload in sse::drain_sse_payloads(&mut pending, true) { + render_sse_payload(&payload, globals.json)?; + } + + Ok(()) +} + +fn render_sse_payload(data: &str, json_output: bool) -> Result<()> { + if json_output { + #[allow(clippy::print_stdout)] + { + println!("{data}"); + } + return Ok(()); + } + + let value: serde_json::Value = serde_json::from_str(data)?; + let payload = value + .get("payload") + .and_then(serde_json::Value::as_object) + .cloned() + .unwrap_or_default(); + let ts = payload + .get("ts") + .and_then(serde_json::Value::as_str) + .unwrap_or("-"); + let run_id = payload + .get("run_id") + .and_then(serde_json::Value::as_str) + .unwrap_or("-"); + let event = payload + .get("event") + .and_then(serde_json::Value::as_str) + .unwrap_or("-"); + + #[allow(clippy::print_stdout)] + { + println!("{ts} {} {event}", short_run_id(run_id)); + } + Ok(()) +} + +fn short_run_id(run_id: &str) -> &str { + run_id.get(..12).unwrap_or(run_id) +} diff --git a/lib/crates/fabro-cli/src/commands/system/info.rs b/lib/crates/fabro-cli/src/commands/system/info.rs new file mode 100644 index 000000000..43d0549cd --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/system/info.rs @@ -0,0 +1,74 @@ +use anyhow::Result; +use fabro_util::printer::Printer; + +use crate::args::{GlobalArgs, SystemInfoArgs}; +use crate::command_context::CommandContext; +use crate::server_client; +use crate::shared::print_json_pretty; + +pub(super) async fn info_command( + args: &SystemInfoArgs, + globals: &GlobalArgs, + printer: Printer, +) -> Result<()> { + let ctx = CommandContext::for_connection(&args.connection, printer)?; + let server = ctx.server().await?; + let response = server + .api() + .get_system_info() + .send() + .await + .map_err(server_client::map_api_error)? + .into_inner(); + + if globals.json { + print_json_pretty(&response)?; + return Ok(()); + } + + #[allow(clippy::print_stdout)] + { + println!( + "Version: {}", + response + .version + .as_deref() + .unwrap_or(env!("CARGO_PKG_VERSION")) + ); + println!( + "Build: {} {}", + response.git_sha.as_deref().unwrap_or("unknown"), + response.build_date.as_deref().unwrap_or("unknown") + ); + println!( + "Platform: {}/{}", + response.os.as_deref().unwrap_or("unknown"), + response.arch.as_deref().unwrap_or("unknown") + ); + println!( + "Storage: {} ({})", + response.storage_dir.as_deref().unwrap_or("unknown"), + response.storage_engine.as_deref().unwrap_or("unknown") + ); + println!( + "Runs: total={} active={}", + response + .runs + .as_ref() + .and_then(|runs| runs.total) + .unwrap_or_default(), + response + .runs + .as_ref() + .and_then(|runs| runs.active) + .unwrap_or_default() + ); + println!( + "Sandbox: {}", + response.sandbox_provider.as_deref().unwrap_or("unknown") + ); + println!("Uptime: {}s", response.uptime_secs.unwrap_or_default()); + } + + Ok(()) +} diff --git a/lib/crates/fabro-cli/src/commands/system/mod.rs b/lib/crates/fabro-cli/src/commands/system/mod.rs index 09062e017..cb734381d 100644 --- a/lib/crates/fabro-cli/src/commands/system/mod.rs +++ b/lib/crates/fabro-cli/src/commands/system/mod.rs @@ -1,15 +1,23 @@ mod df; +mod events; +mod info; mod prune; use anyhow::Result; +use fabro_util::printer::Printer; +pub(crate) use prune::parse_duration; use crate::args::{GlobalArgs, SystemCommand, SystemNamespace}; -pub(crate) use prune::parse_duration; - -pub(crate) async fn dispatch(ns: SystemNamespace, globals: &GlobalArgs) -> Result<()> { +pub(crate) async fn dispatch( + ns: SystemNamespace, + globals: &GlobalArgs, + printer: Printer, +) -> Result<()> { match ns.command { - SystemCommand::Prune(args) => prune::prune_command(&args, globals).await, - SystemCommand::Df(args) => df::df_command(&args, globals).await, + SystemCommand::Info(args) => info::info_command(&args, globals, printer).await, + SystemCommand::Prune(args) => prune::prune_command(&args, globals, printer).await, + SystemCommand::Df(args) => df::df_command(&args, globals, printer).await, + SystemCommand::Events(args) => events::events_command(&args, globals, printer).await, } } diff --git a/lib/crates/fabro-cli/src/commands/system/prune.rs b/lib/crates/fabro-cli/src/commands/system/prune.rs index d0148fc5f..5f405ae7e 100644 --- a/lib/crates/fabro-cli/src/commands/system/prune.rs +++ b/lib/crates/fabro-cli/src/commands/system/prune.rs @@ -1,32 +1,38 @@ -use std::path::Path; +use std::collections::HashMap; use anyhow::{Context, Result, bail}; -use chrono::Utc; -use fabro_config::FabroSettingsExt; -use fabro_store::Store; -use serde::Serialize; +use fabro_api::types; +use fabro_util::printer::Printer; use tracing::{debug, info}; -use fabro_workflow::run_lookup::{StatusFilter, filter_runs, runs_base, scan_runs_combined}; - use crate::args::{GlobalArgs, RunsPruneArgs}; +use crate::command_context::CommandContext; +use crate::server_client; use crate::shared::{format_size, print_json_pretty}; -use crate::store; -use crate::user_config::load_user_settings_with_globals; -#[derive(Serialize)] -struct PruneRunRow { - run_id: String, - dir_name: String, - workflow_name: String, - size_bytes: u64, -} - -pub(super) async fn prune_command(args: &RunsPruneArgs, globals: &GlobalArgs) -> Result<()> { - let cli_settings = load_user_settings_with_globals(globals)?; - let base = runs_base(&cli_settings.storage_dir()); - let store = store::build_store(&cli_settings.storage_dir())?; - prune_from(args, store.as_ref(), &base, globals).await +pub(super) async fn prune_command( + args: &RunsPruneArgs, + globals: &GlobalArgs, + printer: Printer, +) -> Result<()> { + let ctx = CommandContext::for_connection(&args.connection, printer)?; + let server = ctx.server().await?; + let response = server + .api() + .prune_runs() + .body(types::PruneRunsRequest { + before: args.filter.before.clone(), + dry_run: !args.yes, + labels: parse_label_filters(&args.filter.label), + older_than: args.older_than.map(format_duration), + orphans: args.filter.orphans, + workflow: args.filter.workflow.clone(), + }) + .send() + .await + .map_err(server_client::map_api_error)? + .into_inner(); + prune_from(&response, globals, printer) } pub(crate) fn parse_duration(s: &str) -> Result { @@ -45,126 +51,63 @@ pub(crate) fn parse_duration(s: &str) -> Result { } } -async fn prune_from( - args: &RunsPruneArgs, - store: &dyn Store, - base: &Path, +fn prune_from( + response: &types::PruneRunsResponse, globals: &GlobalArgs, + printer: Printer, ) -> Result<()> { - let runs = scan_runs_combined(store, base).await?; - let label_filters = parse_label_filters(&args.filter.label); - let mut filtered = filter_runs( - &runs, - args.filter.before.as_deref(), - args.filter.workflow.as_deref(), - &label_filters, - args.filter.orphans, - StatusFilter::All, + let total_count = response.total_count.unwrap_or_default(); + let total_size_bytes = response.total_size_bytes.unwrap_or_default(); + + info!( + count = total_count, + bytes = total_size_bytes, + dry_run = response.dry_run.unwrap_or(true), + "pruning runs" ); - let has_explicit_filters = - args.filter.before.is_some() || args.filter.workflow.is_some() || !label_filters.is_empty(); - let staleness_threshold = if let Some(duration) = args.older_than { - Some(duration) - } else if !has_explicit_filters { - Some(chrono::Duration::hours(24)) - } else { - None - }; - - if let Some(threshold) = staleness_threshold { - let cutoff = Utc::now() - threshold; - filtered.retain(|run| { - run.end_time - .or(run.start_time_dt) - .is_some_and(|time| time < cutoff) - }); - } - - filtered.retain(|run| !run.status.is_active()); - - if filtered.is_empty() { - if globals.json { - if args.yes { - print_json_pretty(&serde_json::json!({ - "dry_run": false, - "deleted_count": 0, - "freed_bytes": 0, - }))?; - } else { - print_json_pretty(&serde_json::json!({ - "dry_run": true, - "runs": Vec::::new(), - "total_count": 0, - "total_size_bytes": 0, - }))?; - } - } else { - eprintln!("No matching runs to prune."); - } + if globals.json { + print_json_pretty(response)?; return Ok(()); } - let rows: Vec = filtered - .iter() - .map(|run| PruneRunRow { - run_id: run.run_id.to_string(), - dir_name: run.dir_name.clone(), - workflow_name: run.workflow_name.clone(), - size_bytes: dir_size(&run.path), - }) - .collect(); - let total_bytes: u64 = rows.iter().map(|row| row.size_bytes).sum(); - info!(count = filtered.len(), bytes = total_bytes, "pruning runs"); + if total_count == 0 { + fabro_util::printerr!(printer, "No matching runs to prune."); + return Ok(()); + } - if args.yes { - for run in &filtered { - info!(run_id = %run.run_id, path = %run.path.display(), "deleting run"); - std::fs::remove_dir_all(&run.path)?; - store - .delete_run(&run.run_id) - .await - .with_context(|| format!("failed to delete store state for {}", run.run_id))?; - } - if globals.json { - print_json_pretty(&serde_json::json!({ - "dry_run": false, - "deleted_count": filtered.len(), - "freed_bytes": total_bytes, - }))?; - } else { - eprintln!( - "{} run(s) deleted ({} freed).", - filtered.len(), - format_size(total_bytes) + if response.dry_run.unwrap_or(true) { + for run in response.runs.as_deref().unwrap_or(&[]) { + debug!( + run_id = run.run_id.as_deref().unwrap_or("-"), + "would delete run (dry-run)" + ); + fabro_util::printout!( + printer, + "would delete: {} ({})", + run.dir_name.as_deref().unwrap_or("-"), + run.workflow_name.as_deref().unwrap_or("-") ); } + fabro_util::printerr!( + printer, + "\n{} run(s) would be deleted ({} freed). Pass --yes to confirm.", + total_count, + format_size(as_u64(total_size_bytes)) + ); return Ok(()); } - if globals.json { - print_json_pretty(&serde_json::json!({ - "dry_run": true, - "runs": rows, - "total_count": filtered.len(), - "total_size_bytes": total_bytes, - }))?; - return Ok(()); - } - - for run in &filtered { - debug!(run_id = %run.run_id, "would delete run (dry-run)"); - println!("would delete: {} ({})", run.dir_name, run.workflow_name); - } - eprintln!( - "\n{} run(s) would be deleted ({} freed). Pass --yes to confirm.", - filtered.len(), - format_size(total_bytes) + fabro_util::printerr!( + printer, + "{} run(s) deleted ({} freed).", + response.deleted_count.unwrap_or(total_count), + format_size(as_u64(response.freed_bytes.unwrap_or(total_size_bytes))) ); Ok(()) } -fn parse_label_filters(label_args: &[String]) -> Vec<(String, String)> { +fn parse_label_filters(label_args: &[String]) -> HashMap { label_args .iter() .filter_map(|s| s.split_once('=')) @@ -172,12 +115,14 @@ fn parse_label_filters(label_args: &[String]) -> Vec<(String, String)> { .collect() } -fn dir_size(path: &Path) -> u64 { - walkdir::WalkDir::new(path) - .into_iter() - .filter_map(std::result::Result::ok) - .filter_map(|entry| entry.metadata().ok()) - .filter(std::fs::Metadata::is_file) - .map(|metadata| metadata.len()) - .sum() +fn format_duration(duration: chrono::Duration) -> String { + if duration.num_hours() % 24 == 0 { + format!("{}d", duration.num_days()) + } else { + format!("{}h", duration.num_hours()) + } +} + +fn as_u64(value: i64) -> u64 { + value.try_into().unwrap_or_default() } diff --git a/lib/crates/fabro-cli/src/commands/uninstall.rs b/lib/crates/fabro-cli/src/commands/uninstall.rs new file mode 100644 index 000000000..9cd9593b0 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/uninstall.rs @@ -0,0 +1,763 @@ +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::{Context, Result}; +use fabro_util::Home; +use fabro_util::printer::Printer; +use serde::Serialize; +use tracing::warn; + +use crate::args::{GlobalArgs, UninstallArgs}; +use crate::commands::server; +use crate::shared::{format_size, print_json_pretty, tilde_path}; +use crate::user_config; + +#[derive(Debug, Serialize)] +struct Inventory { + home_root: PathBuf, + storage_dir: PathBuf, + home_exists: bool, + home_size: u64, + server_running: bool, + shell_configs: Vec, + binary_path: Option, + binary_is_managed: bool, +} + +#[allow(clippy::unused_async)] // call site requires async +pub(crate) async fn run_uninstall( + args: &UninstallArgs, + globals: &GlobalArgs, + printer: Printer, +) -> Result<()> { + let home = Home::from_env(); + let home_root = home.root().to_path_buf(); + + if !looks_like_fabro_home(&home_root) { + if globals.json { + print_json_pretty(&serde_json::json!({ "status": "not_installed" }))?; + } else { + fabro_util::printerr!(printer, "Fabro is not installed."); + } + return Ok(()); + } + + let storage_dir = user_config::load_settings().map_or_else( + |_| home.storage_dir(), + |settings| user_config::storage_dir(&settings).unwrap_or_else(|_| home.storage_dir()), + ); + + let inventory = build_inventory(&home_root, &storage_dir); + + if !args.yes { + if globals.json { + print_json_pretty(&inventory)?; + } else { + print_preview(&inventory, printer); + } + return Ok(()); + } + + execute_uninstall(&inventory, globals.json, printer).await +} + +fn build_inventory(home_root: &Path, storage_dir: &Path) -> Inventory { + let home_size = dir_size(home_root); + let server_running = server::record::active_server_record_details(storage_dir).is_some(); + let shell_configs = find_shell_configs_with_sentinel(); + let (binary_path, binary_is_managed) = resolve_binary(home_root); + + Inventory { + home_root: home_root.to_path_buf(), + storage_dir: storage_dir.to_path_buf(), + home_exists: true, + home_size, + server_running, + shell_configs, + binary_path, + binary_is_managed, + } +} + +fn dir_size(path: &Path) -> u64 { + let mut total: u64 = 0; + if let Ok(entries) = fs::read_dir(path) { + for entry in entries.flatten() { + let Ok(ft) = entry.file_type() else { + continue; + }; + if ft.is_dir() { + total += dir_size(&entry.path()); + } else { + total += entry.metadata().map(|m| m.len()).unwrap_or(0); + } + } + } + total +} + +fn find_shell_configs_with_sentinel() -> Vec { + let mut found = Vec::new(); + let Some(home) = dirs::home_dir() else { + return found; + }; + + let zdotdir = std::env::var("ZDOTDIR") + .ok() + .map_or_else(|| home.clone(), PathBuf::from); + + let candidates = [ + zdotdir.join(".zshrc"), + home.join(".bashrc"), + home.join(".bash_profile"), + home.join(".config/fish/config.fish"), + ]; + + for path in &candidates { + if file_contains_sentinel(path) { + found.push(path.clone()); + } + } + found +} + +fn file_contains_sentinel(path: &Path) -> bool { + let Ok(content) = fs::read_to_string(path) else { + return false; + }; + content.lines().any(|line| line.trim() == "# fabro") +} + +fn resolve_binary(home_root: &Path) -> (Option, bool) { + let binary_path = std::env::current_exe() + .ok() + .and_then(|p| p.canonicalize().ok()); + let is_managed = binary_path + .as_ref() + .is_some_and(|p| p.starts_with(home_root)); + (binary_path, is_managed) +} + +fn print_preview(inventory: &Inventory, printer: Printer) { + let green = console::Style::new().green(); + let dim = console::Style::new().dim(); + let bold = console::Style::new().bold(); + + fabro_util::printerr!( + printer, + "\n{}", + bold.apply_to("The following will be removed:") + ); + fabro_util::printerr!( + printer, + " {} {} {}", + green.apply_to("~"), + tilde_path(&inventory.home_root), + dim.apply_to(format!("({})", format_size(inventory.home_size))) + ); + + if inventory.storage_dir != inventory.home_root.join("storage") + && !inventory.storage_dir.starts_with(&inventory.home_root) + { + let storage_size = dir_size(&inventory.storage_dir); + fabro_util::printerr!( + printer, + " {} {} {}", + green.apply_to("~"), + tilde_path(&inventory.storage_dir), + dim.apply_to(format!("({})", format_size(storage_size))) + ); + } + + if inventory.server_running { + fabro_util::printerr!( + printer, + "\n {} A running server will be stopped first.", + console::Style::new().yellow().apply_to("!") + ); + } + + if !inventory.shell_configs.is_empty() { + fabro_util::printerr!(printer, "\n Shell configs with PATH entries:"); + for path in &inventory.shell_configs { + fabro_util::printerr!(printer, " {}", tilde_path(path)); + } + } + + match (&inventory.binary_path, inventory.binary_is_managed) { + (Some(_), true) => { + fabro_util::printerr!( + printer, + "\n {} Binary is inside {} and will be removed.", + dim.apply_to("i"), + tilde_path(&inventory.home_root) + ); + } + (Some(bin), false) => { + fabro_util::printerr!( + printer, + "\n {} Binary at {} is outside {} and must be removed manually.", + dim.apply_to("i"), + tilde_path(bin), + tilde_path(&inventory.home_root) + ); + } + (None, _) => { + fabro_util::printerr!( + printer, + "\n {} Could not determine binary location.", + dim.apply_to("i") + ); + } + } + + fabro_util::printerr!(printer, "\nPass --yes to confirm."); +} + +#[derive(Debug, Serialize)] +struct UninstallResult { + status: &'static str, + home_removed: bool, + server_stopped: bool, + shell_configs_cleaned: Vec, + binary_removed: bool, + binary_hint: Option, +} + +async fn execute_uninstall(inventory: &Inventory, json: bool, printer: Printer) -> Result<()> { + let green = console::Style::new().green(); + let dim = console::Style::new().dim(); + let bold = console::Style::new().bold(); + let mut critical_failure = false; + let mut result = UninstallResult { + status: "completed", + home_removed: false, + server_stopped: false, + shell_configs_cleaned: Vec::new(), + binary_removed: false, + binary_hint: None, + }; + + // Unit 3a: Server stop + if inventory.server_running { + server::stop::execute(&inventory.storage_dir, Duration::from_secs(5), printer).await; + result.server_stopped = true; + } + + // Unit 3b: Safety guardrails + if let Err(e) = validate_safe_to_delete(&inventory.home_root) { + fabro_util::printerr!( + printer, + "Refusing to delete {}: {e}", + inventory.home_root.display() + ); + return Err(e); + } + + // Unit 3c: Directory removal + match fs::remove_dir_all(&inventory.home_root) { + Ok(()) => { + result.home_removed = true; + if !json { + fabro_util::printerr!( + printer, + " {} Removed {}", + green.apply_to("\u{2714}"), + tilde_path(&inventory.home_root) + ); + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + result.home_removed = true; + if !json { + fabro_util::printerr!( + printer, + " {} {} already removed", + dim.apply_to("-"), + tilde_path(&inventory.home_root) + ); + } + } + Err(e) => { + if !json { + fabro_util::printerr!( + printer, + " Failed to remove {}: {e}", + tilde_path(&inventory.home_root) + ); + } + critical_failure = true; + } + } + + // Remove external storage_dir if outside home_root + if !inventory.storage_dir.starts_with(&inventory.home_root) && inventory.storage_dir.exists() { + if let Err(e) = validate_safe_to_delete(&inventory.storage_dir) { + if !json { + fabro_util::printerr!( + printer, + "Refusing to delete storage dir {}: {e}", + inventory.storage_dir.display() + ); + } + critical_failure = true; + } else { + match fs::remove_dir_all(&inventory.storage_dir) { + Ok(()) => { + if !json { + fabro_util::printerr!( + printer, + " {} Removed {}", + green.apply_to("\u{2714}"), + tilde_path(&inventory.storage_dir) + ); + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => { + if !json { + fabro_util::printerr!( + printer, + " Failed to remove {}: {e}", + tilde_path(&inventory.storage_dir) + ); + } + critical_failure = true; + } + } + } + } + + // Unit 4: Shell config cleanup + for path in &inventory.shell_configs { + match clean_shell_config(path) { + Ok(()) => { + result.shell_configs_cleaned.push(path.clone()); + if !json { + fabro_util::printerr!( + printer, + " {} Cleaned {}", + green.apply_to("\u{2714}"), + tilde_path(path) + ); + } + } + Err(e) => { + warn!("Failed to clean shell config {}: {e}", path.display()); + if !json { + fabro_util::printerr!( + printer, + " {} Could not clean {}: {e}", + console::Style::new().yellow().apply_to("!"), + tilde_path(path) + ); + } + } + } + } + + // Unit 5: Binary reporting + match (&inventory.binary_path, inventory.binary_is_managed) { + (Some(_), true) => { + result.binary_removed = true; + if !json { + fabro_util::printerr!( + printer, + " {} Binary removed {}", + green.apply_to("\u{2714}"), + dim.apply_to("(was inside ~/.fabro/bin/)") + ); + } + } + (Some(bin), false) => { + let hint = binary_removal_hint(bin); + result.binary_hint = Some(hint.clone()); + if !json { + fabro_util::printerr!(printer, "\n {} {}", dim.apply_to("i"), hint); + } + } + (None, _) => { + warn!("Could not determine binary path; skipping binary removal hint"); + } + } + + if critical_failure { + result.status = "partial"; + } + + // Final output + if json { + print_json_pretty(&result)?; + } else { + fabro_util::printerr!( + printer, + "\n{}", + bold.apply_to("Fabro has been uninstalled.") + ); + } + + if critical_failure { + std::process::exit(1); + } + + Ok(()) +} + +/// Returns true if the directory exists and contains Fabro artifacts +/// (settings.toml, certs/, or storage/). An empty directory auto-created +/// by the CLI's logging setup is not considered an installation. +fn looks_like_fabro_home(path: &Path) -> bool { + path.exists() + && (path.join("settings.toml").exists() + || path.join("certs").exists() + || path.join("storage").exists()) +} + +fn validate_safe_to_delete(path: &Path) -> Result<()> { + let root = Path::new("/"); + anyhow::ensure!(path != root, "path is the filesystem root"); + + if let Some(home) = dirs::home_dir() { + anyhow::ensure!(path != home, "path is the user home directory"); + } + + let has_settings = path.join("settings.toml").exists(); + let has_certs = path.join("certs").exists(); + anyhow::ensure!( + has_settings || has_certs, + "path does not look like a Fabro home (missing settings.toml and certs/)" + ); + + Ok(()) +} + +fn clean_shell_config(path: &Path) -> Result<()> { + let content = + fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?; + + let lines: Vec<&str> = content.lines().collect(); + let mut output = Vec::with_capacity(lines.len()); + let mut i = 0; + + while i < lines.len() { + if lines[i].trim() == "# fabro" { + // Check if next line is a PATH export or fish_add_path + if i + 1 < lines.len() { + let next = lines[i + 1].trim(); + if next.starts_with("export PATH=") || next.starts_with("fish_add_path") { + // Skip both sentinel and PATH line + i += 2; + continue; + } + } + // Skip only the sentinel line + i += 1; + continue; + } + output.push(lines[i]); + i += 1; + } + + let mut result = output.join("\n"); + // Preserve trailing newline if original had one + if content.ends_with('\n') { + result.push('\n'); + } + + // Atomic write: write to temp file in same directory, then rename + let parent = path + .parent() + .with_context(|| format!("no parent directory for {}", path.display()))?; + let mut tmp = tempfile::NamedTempFile::new_in(parent) + .with_context(|| format!("creating temp file in {}", parent.display()))?; + tmp.write_all(result.as_bytes()) + .with_context(|| format!("writing temp file for {}", path.display()))?; + tmp.persist(path) + .with_context(|| format!("renaming temp file to {}", path.display()))?; + + Ok(()) +} + +fn binary_removal_hint(bin: &Path) -> String { + let bin_str = bin.to_string_lossy(); + if bin_str.contains("Cellar") || bin_str.contains("homebrew") || bin_str.contains("linuxbrew") { + format!( + "Binary at {} was installed via Homebrew. Run: brew uninstall fabro", + tilde_path(bin) + ) + } else if bin_str.contains(".cargo") { + format!( + "Binary at {} was installed via Cargo. Run: cargo uninstall fabro", + tilde_path(bin) + ) + } else { + format!("Binary at {} must be removed manually.", tilde_path(bin)) + } +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::{Path, PathBuf}; + + use super::{ + binary_removal_hint, build_inventory, clean_shell_config, dir_size, file_contains_sentinel, + validate_safe_to_delete, + }; + + fn create_fabro_home(dir: &Path) { + fs::create_dir_all(dir.join("certs")).unwrap(); + fs::write(dir.join("settings.toml"), "# fabro settings\n").unwrap(); + fs::create_dir_all(dir.join("storage")).unwrap(); + fs::write(dir.join("storage/data.db"), "fake-db-content").unwrap(); + fs::create_dir_all(dir.join("bin")).unwrap(); + fs::write(dir.join("bin/fabro"), "fake-binary").unwrap(); + } + + #[test] + fn dir_size_sums_nested_files() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + + fs::write(root.join("a.txt"), "hello").unwrap(); + fs::create_dir(root.join("sub")).unwrap(); + fs::write(root.join("sub/b.txt"), "world!").unwrap(); + + let size = dir_size(root); + assert_eq!(size, 11); // "hello" (5) + "world!" (6) + } + + #[test] + fn dir_size_empty_directory_is_zero() { + let tmp = tempfile::tempdir().unwrap(); + assert_eq!(dir_size(tmp.path()), 0); + } + + #[test] + fn dir_size_nonexistent_is_zero() { + let path = PathBuf::from("/nonexistent-fabro-test-dir-xyz"); + assert_eq!(dir_size(&path), 0); + } + + #[test] + fn file_contains_sentinel_exact_match() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join(".zshrc"); + fs::write( + &path, + "some stuff\n# fabro\nexport PATH=\"$HOME/.fabro/bin:$PATH\"\n", + ) + .unwrap(); + + assert!(file_contains_sentinel(&path)); + } + + #[test] + fn file_contains_sentinel_with_leading_whitespace() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join(".zshrc"); + fs::write(&path, " # fabro\nexport PATH=\"$HOME/.fabro/bin:$PATH\"\n").unwrap(); + + assert!(file_contains_sentinel(&path)); + } + + #[test] + fn file_contains_sentinel_rejects_substring() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join(".zshrc"); + fs::write(&path, "# fabro-workflow\nsome other line\n").unwrap(); + + assert!(!file_contains_sentinel(&path)); + } + + #[test] + fn file_contains_sentinel_rejects_missing_file() { + let path = PathBuf::from("/nonexistent-fabro-test-file-xyz"); + assert!(!file_contains_sentinel(&path)); + } + + #[test] + fn file_contains_sentinel_rejects_comment_in_middle() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join(".zshrc"); + fs::write(&path, "echo '# fabro'\nother stuff\n").unwrap(); + + assert!(!file_contains_sentinel(&path)); + } + + #[test] + fn clean_shell_config_removes_sentinel_and_path_line() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join(".zshrc"); + fs::write( + &path, + "existing line\n# fabro\nexport PATH=\"$HOME/.fabro/bin:$PATH\"\nafter line\n", + ) + .unwrap(); + + clean_shell_config(&path).unwrap(); + + let result = fs::read_to_string(&path).unwrap(); + assert_eq!(result, "existing line\nafter line\n"); + } + + #[test] + fn clean_shell_config_removes_fish_sentinel_and_path() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("config.fish"); + fs::write( + &path, + "set -gx EDITOR vim\n# fabro\nfish_add_path $HOME/.fabro/bin\nend\n", + ) + .unwrap(); + + clean_shell_config(&path).unwrap(); + + let result = fs::read_to_string(&path).unwrap(); + assert_eq!(result, "set -gx EDITOR vim\nend\n"); + } + + #[test] + fn clean_shell_config_removes_only_sentinel_when_next_line_unrelated() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join(".bashrc"); + fs::write(&path, "before\n# fabro\necho hello\nafter\n").unwrap(); + + clean_shell_config(&path).unwrap(); + + let result = fs::read_to_string(&path).unwrap(); + assert_eq!(result, "before\necho hello\nafter\n"); + } + + #[test] + fn clean_shell_config_removes_sentinel_at_end_of_file() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join(".bashrc"); + fs::write(&path, "before\n# fabro\n").unwrap(); + + clean_shell_config(&path).unwrap(); + + let result = fs::read_to_string(&path).unwrap(); + assert_eq!(result, "before\n"); + } + + #[test] + fn clean_shell_config_preserves_trailing_newline() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join(".zshrc"); + fs::write(&path, "keep this\n# fabro\nexport PATH=\"foo\"\n").unwrap(); + + clean_shell_config(&path).unwrap(); + + let result = fs::read_to_string(&path).unwrap(); + assert!(result.ends_with('\n')); + assert_eq!(result, "keep this\n"); + } + + #[test] + fn clean_shell_config_no_trailing_newline_when_original_lacks_one() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join(".zshrc"); + fs::write(&path, "keep this\n# fabro\nexport PATH=\"foo\"").unwrap(); + + clean_shell_config(&path).unwrap(); + + let result = fs::read_to_string(&path).unwrap(); + assert_eq!(result, "keep this"); + } + + #[test] + fn validate_safe_to_delete_refuses_root() { + let result = validate_safe_to_delete(Path::new("/")); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("filesystem root"), "got: {msg}"); + } + + #[test] + fn validate_safe_to_delete_refuses_home_dir() { + if let Some(home) = dirs::home_dir() { + let result = validate_safe_to_delete(&home); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("home directory"), "got: {msg}"); + } + } + + #[test] + fn validate_safe_to_delete_refuses_dir_without_markers() { + let tmp = tempfile::tempdir().unwrap(); + let result = validate_safe_to_delete(tmp.path()); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("does not look like"), "got: {msg}"); + } + + #[test] + fn validate_safe_to_delete_accepts_dir_with_settings_toml() { + let tmp = tempfile::tempdir().unwrap(); + fs::write(tmp.path().join("settings.toml"), "").unwrap(); + + let result = validate_safe_to_delete(tmp.path()); + assert!(result.is_ok()); + } + + #[test] + fn validate_safe_to_delete_accepts_dir_with_certs() { + let tmp = tempfile::tempdir().unwrap(); + fs::create_dir(tmp.path().join("certs")).unwrap(); + + let result = validate_safe_to_delete(tmp.path()); + assert!(result.is_ok()); + } + + #[test] + fn build_inventory_populates_fields() { + let tmp = tempfile::tempdir().unwrap(); + let home_root = tmp.path().join("fake-fabro-home"); + create_fabro_home(&home_root); + + let storage_dir = home_root.join("storage"); + let inv = build_inventory(&home_root, &storage_dir); + + assert!(inv.home_exists); + assert!(inv.home_size > 0); + assert!(!inv.server_running); + assert_eq!(inv.home_root, home_root); + assert_eq!(inv.storage_dir, storage_dir); + } + + #[test] + fn build_inventory_shell_configs_empty_in_temp() { + let tmp = tempfile::tempdir().unwrap(); + let home_root = tmp.path().join("fake-fabro-home"); + create_fabro_home(&home_root); + + let inv = build_inventory(&home_root, &home_root.join("storage")); + // shell_configs depends on the actual user's shell config files, + // but we verify the field is populated (even if empty in CI/test) + assert!(inv.shell_configs.is_empty() || !inv.shell_configs.is_empty()); + } + + #[test] + fn binary_removal_hint_homebrew() { + let hint = binary_removal_hint(Path::new("/opt/homebrew/Cellar/fabro/1.0/bin/fabro")); + assert!(hint.contains("Homebrew"), "got: {hint}"); + assert!(hint.contains("brew uninstall"), "got: {hint}"); + } + + #[test] + fn binary_removal_hint_cargo() { + let hint = binary_removal_hint(Path::new("/home/user/.cargo/bin/fabro")); + assert!(hint.contains("Cargo"), "got: {hint}"); + assert!(hint.contains("cargo uninstall"), "got: {hint}"); + } + + #[test] + fn binary_removal_hint_manual() { + let hint = binary_removal_hint(Path::new("/usr/local/bin/fabro")); + assert!(hint.contains("manually"), "got: {hint}"); + } +} diff --git a/lib/crates/fabro-cli/src/commands/upgrade.rs b/lib/crates/fabro-cli/src/commands/upgrade.rs index 116ea81e5..6b5886690 100644 --- a/lib/crates/fabro-cli/src/commands/upgrade.rs +++ b/lib/crates/fabro-cli/src/commands/upgrade.rs @@ -3,12 +3,12 @@ use std::io::{IsTerminal, Write}; use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; +use fabro_util::printer::Printer; use semver::Version; use sha2::{Digest, Sha256}; -use tracing::debug; - use tokio::process::Command as TokioCommand; use tokio::task::JoinHandle; +use tracing::debug; use crate::args::{GlobalArgs, UpgradeArgs}; use crate::shared::print_json_pretty; @@ -19,11 +19,11 @@ const GITHUB_REPO: &str = "fabro-sh/fabro"; enum Backend { Gh, - Http(reqwest::Client), + Http(fabro_http::HttpClient), } -fn http_client() -> Result { - reqwest::Client::builder() +fn http_client() -> Result { + fabro_http::HttpClientBuilder::new() .user_agent("fabro-cli") .build() .context("failed to build HTTP client") @@ -194,7 +194,7 @@ const LAST_CHECK_FILE: &str = "last_upgrade_check.json"; #[derive(serde::Serialize, serde::Deserialize)] struct UpgradeCheckState { - checked_at: u64, + checked_at: u64, latest_version: String, } @@ -224,7 +224,11 @@ impl UpgradeCheckState { // ── Main upgrade command ─────────────────────────────────────────────────── -pub(crate) async fn run_upgrade(args: UpgradeArgs, globals: &GlobalArgs) -> Result<()> { +pub(crate) async fn run_upgrade( + args: UpgradeArgs, + globals: &GlobalArgs, + printer: Printer, +) -> Result<()> { let backend = select_backend().await; let current = @@ -250,7 +254,7 @@ pub(crate) async fn run_upgrade(args: UpgradeArgs, globals: &GlobalArgs) -> Resu ); } // Explicit --version: warn + prompt - eprintln!("Warning: downgrading from {current} to {target}"); + fabro_util::printerr!(printer, "Warning: downgrading from {current} to {target}"); if std::io::stdin().is_terminal() { let confirm = dialoguer::Confirm::new() .with_prompt("Continue with downgrade?") @@ -270,7 +274,7 @@ pub(crate) async fn run_upgrade(args: UpgradeArgs, globals: &GlobalArgs) -> Resu "installed_version": current.to_string(), }))?; } else { - eprintln!("Already on version {current}"); + fabro_util::printerr!(printer, "Already on version {current}"); } return Ok(()); } @@ -285,9 +289,9 @@ pub(crate) async fn run_upgrade(args: UpgradeArgs, globals: &GlobalArgs) -> Resu "dry_run": true, }))?; } else { - eprintln!("Would upgrade fabro from {current} to {target}"); - eprintln!(" tag: {tag}"); - eprintln!(" target: {}", detect_target()?); + fabro_util::printerr!(printer, "Would upgrade fabro from {current} to {target}"); + fabro_util::printerr!(printer, " tag: {tag}"); + fabro_util::printerr!(printer, " target: {}", detect_target()?); } return Ok(()); } @@ -306,7 +310,7 @@ pub(crate) async fn run_upgrade(args: UpgradeArgs, globals: &GlobalArgs) -> Resu .context("failed to create temp directory")?; // Download tarball and checksum in parallel - eprintln!("Downloading fabro {target}..."); + fabro_util::printerr!(printer, "Downloading fabro {target}..."); let (tarball_path, checksum_path) = tokio::try_join!( backend.download_release(&tag, &tarball_name, tmp_dir.path()), backend.download_release(&tag, &checksum_name, tmp_dir.path()), @@ -318,7 +322,7 @@ pub(crate) async fn run_upgrade(args: UpgradeArgs, globals: &GlobalArgs) -> Resu debug!("SHA256 checksum verified"); // Extract tarball - let status = std::process::Command::new("tar") + let status = TokioCommand::new("tar") .args([ "xzf", &tarball_path.to_string_lossy(), @@ -326,6 +330,7 @@ pub(crate) async fn run_upgrade(args: UpgradeArgs, globals: &GlobalArgs) -> Resu &tmp_dir.path().to_string_lossy(), ]) .status() + .await .context("failed to run tar")?; if !status.success() { bail!("tar extraction failed"); @@ -359,7 +364,7 @@ pub(crate) async fn run_upgrade(args: UpgradeArgs, globals: &GlobalArgs) -> Resu "installed_version": target.to_string(), }))?; } else { - eprintln!("Upgraded fabro to {target}"); + fabro_util::printerr!(printer, "Upgraded fabro to {target}"); } Ok(()) } @@ -372,22 +377,20 @@ pub(crate) async fn run_upgrade(args: UpgradeArgs, globals: &GlobalArgs) -> Resu pub(crate) fn spawn_upgrade_check( no_upgrade_check: bool, upgrade_check_enabled: bool, + printer: Printer, ) -> Option> { if no_upgrade_check || !upgrade_check_enabled { return None; } - Some(tokio::spawn(async { - if let Err(e) = check_and_print_notice().await { + Some(tokio::spawn(async move { + if let Err(e) = check_and_print_notice(printer).await { debug!(%e, "Upgrade check failed (silently swallowed)"); } })) } -async fn check_and_print_notice() -> Result<()> { - let Some(home) = dirs::home_dir() else { - return Ok(()); - }; - let state_path = home.join(".fabro").join(LAST_CHECK_FILE); +async fn check_and_print_notice(printer: Printer) -> Result<()> { + let state_path = fabro_util::Home::from_env().root().join(LAST_CHECK_FILE); let current = Version::parse(env!("CARGO_PKG_VERSION"))?; @@ -396,7 +399,7 @@ async fn check_and_print_notice() -> Result<()> { if !state.is_stale() { if let Ok(latest) = Version::parse(&state.latest_version) { if latest > current { - print_notice(¤t, &latest); + print_notice(¤t, &latest, printer); } } return Ok(()); @@ -414,21 +417,24 @@ async fn check_and_print_notice() -> Result<()> { .unwrap_or_default() .as_secs(); let state = UpgradeCheckState { - checked_at: now, + checked_at: now, latest_version: latest.to_string(), }; let _ = state.save(&state_path); if latest > current { - print_notice(¤t, &latest); + print_notice(¤t, &latest, printer); } Ok(()) } -fn print_notice(current: &Version, latest: &Version) { - eprintln!("A new version of fabro is available: {latest} (current: {current})"); - eprintln!("Run `fabro upgrade` to update."); +fn print_notice(current: &Version, latest: &Version, printer: Printer) { + fabro_util::printerr!( + printer, + "A new version of fabro is available: {latest} (current: {current})" + ); + fabro_util::printerr!(printer, "Run `fabro upgrade` to update."); } // ── Tests ────────────────────────────────────────────────────────────────── @@ -507,7 +513,7 @@ mod tests { #[test] fn upgrade_check_state_roundtrip() { let state = UpgradeCheckState { - checked_at: 1_710_000_000, + checked_at: 1_710_000_000, latest_version: "0.5.0".to_string(), }; let json = serde_json::to_string(&state).unwrap(); @@ -519,7 +525,7 @@ mod tests { #[test] fn upgrade_check_state_stale() { let old = UpgradeCheckState { - checked_at: 0, // epoch — definitely stale + checked_at: 0, // epoch — definitely stale latest_version: "0.1.0".to_string(), }; assert!(old.is_stale()); @@ -532,7 +538,7 @@ mod tests { .unwrap() .as_secs(); let fresh = UpgradeCheckState { - checked_at: now, + checked_at: now, latest_version: "0.5.0".to_string(), }; assert!(!fresh.is_stale()); @@ -543,7 +549,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("state.json"); let state = UpgradeCheckState { - checked_at: 1_710_000_000, + checked_at: 1_710_000_000, latest_version: "0.5.0".to_string(), }; state.save(&path).unwrap(); diff --git a/lib/crates/fabro-cli/src/commands/validate.rs b/lib/crates/fabro-cli/src/commands/validate.rs index 08aa26211..7de1102c5 100644 --- a/lib/crates/fabro-cli/src/commands/validate.rs +++ b/lib/crates/fabro-cli/src/commands/validate.rs @@ -1,65 +1,79 @@ use anyhow::bail; -use fabro_config::ConfigLayer; -use fabro_config::project::resolve_workflow_path; +use fabro_config::load::load_settings_user; +use fabro_config::user::active_settings_path; +use fabro_types::settings::SettingsLayer; +use fabro_util::printer::Printer; use fabro_util::terminal::Styles; -use fabro_validate::Severity; -use fabro_workflow::operations::{ValidateInput, WorkflowInput, validate}; use crate::args::{GlobalArgs, ValidateArgs}; +use crate::command_context::CommandContext; +use crate::commands::run::output::api_diagnostics_to_local; +use crate::manifest_builder::{ManifestBuildInput, build_run_manifest}; use crate::shared::{print_diagnostics, print_json_pretty, relative_path}; -pub(crate) fn run( +pub(crate) async fn run( args: &ValidateArgs, styles: &Styles, globals: &GlobalArgs, + printer: Printer, ) -> anyhow::Result<()> { - let cwd = std::env::current_dir()?; - let settings = ConfigLayer::for_workflow(&args.workflow, &cwd)? - .combine(ConfigLayer::user()?) - .resolve()?; - let resolution = resolve_workflow_path(&args.workflow, &cwd)?; - let validated = validate(ValidateInput { - workflow: WorkflowInput::Path(args.workflow.clone()), - settings, - cwd, - custom_transforms: Vec::new(), + let ctx = CommandContext::for_target(&args.target, printer)?; + let built = build_run_manifest(ManifestBuildInput { + workflow: args.workflow.clone(), + cwd: ctx.cwd().to_path_buf(), + args_layer: SettingsLayer::default(), + args: None, + run_id: None, + user_layer: load_settings_user()?, + user_settings_path: Some(active_settings_path(None)), })?; - let graph = validated.graph(); - let diagnostics = validated.diagnostics(); + let client = ctx.server().await?; + let response = client.run_preflight(built.manifest).await?; + let diagnostics = api_diagnostics_to_local(&response.workflow.diagnostics); if globals.json { print_json_pretty(&serde_json::json!({ - "workflow_name": graph.name, - "nodes": graph.nodes.len(), - "edges": graph.edges.len(), - "valid": !diagnostics.iter().any(|d| d.severity == Severity::Error), + "workflow_name": response.workflow.name, + "nodes": response.workflow.nodes, + "edges": response.workflow.edges, + "valid": !diagnostics.iter().any(|d| d.severity == fabro_validate::Severity::Error), "diagnostics": diagnostics, }))?; - if diagnostics.iter().any(|d| d.severity == Severity::Error) { + if diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == fabro_validate::Severity::Error) + { bail!("Validation failed"); } return Ok(()); } - eprintln!( + fabro_util::printerr!( + printer, "{} ({} nodes, {} edges)", - styles.bold.apply_to(format!("Workflow: {}", graph.name)), - graph.nodes.len(), - graph.edges.len(), + styles + .bold + .apply_to(format!("Workflow: {}", response.workflow.name)), + response.workflow.nodes, + response.workflow.edges, ); - eprintln!( + fabro_util::printerr!( + printer, "{} {}", styles.dim.apply_to("Graph:"), - styles.dim.apply_to(relative_path(&resolution.dot_path)), + styles.dim.apply_to(relative_path(&built.target_path)), ); - print_diagnostics(diagnostics, styles); + print_diagnostics(&diagnostics, styles, printer); - if diagnostics.iter().any(|d| d.severity == Severity::Error) { + if diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == fabro_validate::Severity::Error) + { bail!("Validation failed"); } - eprintln!("Validation: {}", styles.green.apply_to("OK")); + fabro_util::printerr!(printer, "Validation: {}", styles.green.apply_to("OK")); Ok(()) } diff --git a/lib/crates/fabro-cli/src/commands/workflow/create.rs b/lib/crates/fabro-cli/src/commands/workflow/create.rs index d507e85c2..ee3437f2b 100644 --- a/lib/crates/fabro-cli/src/commands/workflow/create.rs +++ b/lib/crates/fabro-cli/src/commands/workflow/create.rs @@ -1,18 +1,22 @@ use std::path::Path; use anyhow::{Context, Result, bail}; - use fabro_config::project::{discover_project_config, resolve_fabro_root}; +use fabro_util::printer::Printer; use crate::args::{GlobalArgs, WorkflowCreateArgs}; use crate::shared::{print_json_pretty, relative_path}; -pub(super) fn create_command(args: &WorkflowCreateArgs, globals: &GlobalArgs) -> Result<()> { +pub(super) fn create_command( + args: &WorkflowCreateArgs, + globals: &GlobalArgs, + printer: Printer, +) -> Result<()> { let cwd = std::env::current_dir()?; let Some((config_path, config)) = discover_project_config(&cwd)? else { bail!( - "No fabro.toml found in {cwd} or any parent directory", + "No .fabro/project.toml found in {cwd} or any parent directory", cwd = cwd.display() ); }; @@ -36,27 +40,36 @@ pub(super) fn create_command(args: &WorkflowCreateArgs, globals: &GlobalArgs) -> let dim = console::Style::new().dim(); let rel_dir = relative_path(&workflows_dir); - eprintln!( + fabro_util::printerr!( + printer, " {} {}", green.apply_to("✔"), dim.apply_to(format!("{rel_dir}/workflow.fabro")) ); - eprintln!( + fabro_util::printerr!( + printer, " {} {}", green.apply_to("✔"), dim.apply_to(format!("{rel_dir}/workflow.toml")) ); - eprintln!("\n{} Next steps:\n", bold.apply_to("Workflow created!")); - eprintln!( + fabro_util::printerr!( + printer, + "\n{} Next steps:\n", + bold.apply_to("Workflow created!") + ); + fabro_util::printerr!( + printer, " 1. Edit the graph: {}", cyan_bold.apply_to(format!("{rel_dir}/workflow.fabro")) ); - eprintln!( + fabro_util::printerr!( + printer, " 2. Validate: {}", cyan_bold.apply_to(format!("fabro validate {}", args.name)) ); - eprintln!( + fabro_util::printerr!( + printer, " 3. Run: {}", cyan_bold.apply_to(format!("fabro run {}", args.name)) ); @@ -104,7 +117,7 @@ fn write_workflow_scaffold( .with_context(|| format!("failed to write {}", dot_path.display()))?; let toml_path = workflows_dir.join("workflow.toml"); - std::fs::write(&toml_path, "version = 1\n") + std::fs::write(&toml_path, "_version = 1\n") .with_context(|| format!("failed to write {}", toml_path.display()))?; Ok(vec![dot_path, toml_path]) diff --git a/lib/crates/fabro-cli/src/commands/workflow/list.rs b/lib/crates/fabro-cli/src/commands/workflow/list.rs index afe8eefc4..5603200cf 100644 --- a/lib/crates/fabro-cli/src/commands/workflow/list.rs +++ b/lib/crates/fabro-cli/src/commands/workflow/list.rs @@ -1,30 +1,34 @@ use anyhow::{Result, bail}; -use fabro_util::terminal::Styles; - use fabro_config::project::{ WorkflowInfo, WorkflowSource, discover_project_config, list_workflows_detailed, resolve_fabro_root, }; +use fabro_util::printer::Printer; +use fabro_util::terminal::Styles; use crate::args::{GlobalArgs, WorkflowListArgs}; use crate::shared::{print_json_pretty, relative_path}; const GOAL_MAX_LEN: usize = 60; -pub(super) fn list_command(_args: &WorkflowListArgs, globals: &GlobalArgs) -> Result<()> { +pub(super) fn list_command( + _args: &WorkflowListArgs, + globals: &GlobalArgs, + printer: Printer, +) -> Result<()> { let styles = Styles::detect_stderr(); let cwd = std::env::current_dir()?; let Some((config_path, config)) = discover_project_config(&cwd)? else { bail!( - "No fabro.toml found in {cwd} or any parent directory", + "No .fabro/project.toml found in {cwd} or any parent directory", cwd = cwd.display() ); }; let fabro_root = resolve_fabro_root(&config_path, &config); let project_wf_dir = fabro_root.join("workflows"); - let user_wf_dir = dirs::home_dir().map(|h| h.join(".fabro").join("workflows")); + let user_wf_dir = Some(fabro_util::Home::from_env().workflows_dir()); let workflows = list_workflows_detailed(Some(&project_wf_dir), user_wf_dir.as_deref()); @@ -44,7 +48,8 @@ pub(super) fn list_command(_args: &WorkflowListArgs, globals: &GlobalArgs) -> Re let name_width = workflows.iter().map(|w| w.name.len()).max().unwrap_or(0); - eprintln!( + fabro_util::printerr!( + printer, "{} workflow(s) found\n", styles.bold.apply_to(workflows.len()) ); @@ -52,9 +57,16 @@ pub(super) fn list_command(_args: &WorkflowListArgs, globals: &GlobalArgs) -> Re let user_path = user_wf_dir .as_deref() .map_or_else(|| "~/.fabro/workflows".to_string(), relative_path); - print_section("User Workflows", &user_path, &user, name_width, &styles); + print_section( + "User Workflows", + &user_path, + &user, + name_width, + &styles, + printer, + ); - eprintln!(); + fabro_util::printerr!(printer, ""); print_section( "Project Workflows", @@ -62,6 +74,7 @@ pub(super) fn list_command(_args: &WorkflowListArgs, globals: &GlobalArgs) -> Re &project, name_width, &styles, + printer, ); Ok(()) @@ -73,18 +86,21 @@ fn print_section( workflows: &[&WorkflowInfo], name_width: usize, styles: &Styles, + printer: Printer, ) { - eprintln!( + fabro_util::printerr!( + printer, "{} {}", styles.bold.apply_to(title), styles.dim.apply_to(format!("({path})")), ); if workflows.is_empty() { - eprintln!(" {}", styles.dim.apply_to("(none)")); + fabro_util::printerr!(printer, " {}", styles.dim.apply_to("(none)")); return; } - eprintln!(); - eprintln!( + fabro_util::printerr!(printer, ""); + fabro_util::printerr!( + printer, " {: Result<()> { +pub(crate) fn dispatch( + ns: WorkflowNamespace, + globals: &GlobalArgs, + printer: Printer, +) -> Result<()> { match ns.command { - WorkflowCommand::List(args) => list::list_command(&args, globals), - WorkflowCommand::Create(args) => create::create_command(&args, globals), + WorkflowCommand::List(args) => list::list_command(&args, globals, printer), + WorkflowCommand::Create(args) => create::create_command(&args, globals, printer), } } diff --git a/lib/crates/fabro-cli/src/gh.rs b/lib/crates/fabro-cli/src/gh.rs new file mode 100644 index 000000000..cfd7fc275 --- /dev/null +++ b/lib/crates/fabro-cli/src/gh.rs @@ -0,0 +1,104 @@ +use tokio::process::Command; +use tracing::debug; + +/// Best-effort wrapper around the `gh` CLI. +/// +/// All methods degrade gracefully: if `gh` is missing or not authenticated +/// the caller receives `None` / empty results rather than errors. +pub(crate) struct GhCli { + _private: (), +} + +impl GhCli { + /// Attempt to find an authenticated `gh` CLI on PATH. + /// + /// Returns `None` if `gh` is not installed or not authenticated + /// against github.com. + pub(crate) async fn detect() -> Option { + let version = Command::new("gh").arg("--version").output().await; + let Ok(output) = version else { + debug!("gh CLI not found on PATH"); + return None; + }; + if !output.status.success() { + debug!("gh --version failed"); + return None; + } + + let auth = Command::new("gh") + .args(["auth", "status", "--hostname", "github.com"]) + .output() + .await; + match auth { + Ok(o) if o.status.success() => { + debug!("gh CLI available and authenticated for github.com"); + Some(Self { _private: () }) + } + _ => { + debug!("gh is not authenticated for github.com"); + None + } + } + } + + /// Return the login of the authenticated GitHub user, or `None` on failure. + pub(crate) async fn authenticated_user(&self) -> Option { + let output = Command::new("gh") + .args(["api", "--hostname", "github.com", "/user", "--jq", ".login"]) + .output() + .await + .ok()?; + if !output.status.success() { + debug!("gh api /user failed"); + return None; + } + let login = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if login.is_empty() { None } else { Some(login) } + } + + /// List GitHub organizations the authenticated user is an admin of. + /// + /// Returns an empty vec on any failure (network, parse, etc.). + pub(crate) async fn list_admin_orgs(&self) -> Vec { + let output = Command::new("gh") + .args([ + "api", + "--hostname", + "github.com", + "--paginate", + "/user/memberships/orgs", + "--jq", + r#".[] | select(.role == "admin" and .state == "active") | .organization.login"#, + ]) + .output() + .await; + let Ok(output) = output else { + debug!("gh api /user/memberships/orgs failed to execute"); + return Vec::new(); + }; + if !output.status.success() { + debug!( + "gh api /user/memberships/orgs exited with {}", + output.status + ); + return Vec::new(); + } + String::from_utf8_lossy(&output.stdout) + .lines() + .filter(|line| !line.is_empty()) + .map(String::from) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn detect_does_not_panic() { + // Validates graceful degradation — in CI where gh may not be installed + // this returns None without panicking. + let _result = GhCli::detect().await; + } +} diff --git a/lib/crates/fabro-cli/src/logging.rs b/lib/crates/fabro-cli/src/logging.rs index a5eca12d3..e787b6b13 100644 --- a/lib/crates/fabro-cli/src/logging.rs +++ b/lib/crates/fabro-cli/src/logging.rs @@ -1,7 +1,13 @@ +use std::path::Path; + use anyhow::{Context, Result}; use fabro_util::run_log; use tracing_appender::rolling; -use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt}; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; +use tracing_subscriber::{EnvFilter, fmt}; + +const LOG_RETENTION_DAYS: u32 = 7; pub(crate) fn init_tracing( debug: bool, @@ -16,16 +22,19 @@ pub(crate) fn init_tracing( let filter = EnvFilter::try_from_env("FABRO_LOG").unwrap_or_else(|_| EnvFilter::new(default_level)); - let log_dir = - dirs::home_dir().map_or_else(|| ".fabro/logs".into(), |h| h.join(".fabro").join("logs")); + let log_dir = fabro_util::Home::from_env().logs_dir(); std::fs::create_dir_all(&log_dir) .with_context(|| format!("Failed to create log directory: {}", log_dir.display()))?; - let filename = chrono::Local::now() - .format(&format!("{log_prefix}-%Y-%m-%d.log")) - .to_string(); - let file_appender = rolling::never(&log_dir, &filename); + let file_appender = rolling::RollingFileAppender::builder() + .rotation(rolling::Rotation::DAILY) + .filename_prefix(log_prefix) + .filename_suffix("log") + .build(&log_dir) + .with_context(|| "Failed to create log file appender")?; + + cleanup_old_logs(&log_dir, log_prefix, LOG_RETENTION_DAYS); let run_log_writer = run_log::init(); @@ -47,3 +56,35 @@ pub(crate) fn init_tracing( Ok(()) } + +fn cleanup_old_logs(log_dir: &Path, prefix: &str, max_age_days: u32) { + let cutoff = chrono::Utc::now().date_naive() - chrono::Duration::days(i64::from(max_age_days)); + let Ok(entries) = std::fs::read_dir(log_dir) else { + return; + }; + + let date_prefix = format!("{prefix}."); + let date_suffix = ".log"; + + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name) = name.to_str() else { + continue; + }; + + let Some(rest) = name.strip_prefix(&date_prefix) else { + continue; + }; + let Some(date_str) = rest.strip_suffix(date_suffix) else { + continue; + }; + + let Ok(date) = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d") else { + continue; + }; + + if date < cutoff { + let _ = std::fs::remove_file(entry.path()); + } + } +} diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index 8dbfffec2..768ba0ce4 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -1,20 +1,28 @@ -#![allow(clippy::print_stdout, clippy::print_stderr, clippy::exit)] +#![allow(clippy::exit)] mod args; +mod command_context; mod commands; +mod gh; mod logging; +mod manifest_builder; +mod server_client; +mod server_runs; mod shared; #[cfg(feature = "sleep_inhibitor")] mod sleep_inhibitor; -mod store; +mod sse; mod user_config; +#[cfg(test)] +use std::ffi::OsString; + use anyhow::Result; -use args::{Commands, GlobalArgs, LONG_VERSION, RunCommands}; -#[cfg(feature = "server")] -use args::{ServerCommand, ServerNamespace}; +use args::{Commands, GlobalArgs, LONG_VERSION, RunCommands, ServerCommand, ServerNamespace}; use clap::{CommandFactory, Parser}; +use fabro_config::user::load_settings_config; use fabro_telemetry::{git, panic as tel_panic, sanitize, sender}; +use fabro_types::settings::cli::OutputVerbosity; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; use rustls::crypto::ring::default_provider; @@ -30,6 +38,22 @@ struct Cli { command: Box, } +impl Cli { + fn parse() -> Self { + ::parse() + } + + #[cfg(test)] + fn try_parse_from(args: I) -> Result + where + I: IntoIterator, + T: Into + Clone, + { + ::try_parse_from(args) + } +} + +#[expect(clippy::print_stderr, reason = "fatal error reporting before exit")] #[tokio::main] async fn main() { tel_panic::install_panic_hook(); @@ -38,7 +62,7 @@ async fn main() { let start = std::time::Instant::now(); let raw_args: Vec = std::env::args().collect(); - let (command_name, result) = main_inner().await; + let (command_name, result) = Box::pin(main_inner()).await; let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap(); let is_error = result.is_err(); @@ -94,61 +118,55 @@ async fn main_inner() -> (String, Result<()>) { let _ = default_provider().install_default(); let cli = Cli::parse(); - if let Some(home) = dirs::home_dir() { - let env_path = home.join(".fabro").join(".env"); - if dotenvy::from_path(&env_path).is_ok() { - debug!(path = %env_path.display(), "Loaded environment file"); - } - } let Cli { globals, command } = cli; - let _printer = Printer::from_flags(globals.quiet, globals.verbose); + let printer = Printer::from_flags(globals.quiet, globals.verbose); let command_name = command.name().to_string(); let (config_log_level, upgrade_check_enabled) = { - #[cfg(feature = "server")] + if let Commands::Server(ServerNamespace { + command: + ServerCommand::Start(args::ServerStartArgs { + serve_args: args, .. + }) + | ServerCommand::Serve(args::ServerServeArgs { + serve_args: args, .. + }), + }) = command.as_ref() { - if let Commands::Server(ServerNamespace { - command: ServerCommand::Start(args), - }) = command.as_ref() - { - match fabro_config::server::load_server_settings(args.config.as_deref()) { - Ok(server_settings) => ( - server_settings.log.as_ref().and_then(|l| l.level.clone()), + match load_settings_config(args.config.as_deref()) { + Ok(layer) => { + let server_settings = layer; + ( + server_settings + .server + .as_ref() + .and_then(|server| server.logging.as_ref()) + .and_then(|logging| logging.level.clone()), false, - ), - Err(err) => return (command_name, Err(err)), - } - } else { - match user_config::load_user_settings() { - Ok(cli_settings) => ( - cli_settings.log.as_ref().and_then(|l| l.level.clone()), - cli_settings.upgrade_check_enabled(), - ), - Err(err) => return (command_name, Err(err)), + ) } + Err(err) => return (command_name, Err(err.into())), } - } - #[cfg(not(feature = "server"))] - { - match user_config::load_user_settings() { - Ok(cli_settings) => ( - cli_settings.log.as_ref().and_then(|l| l.level.clone()), - cli_settings.upgrade_check_enabled(), - ), + } else { + match user_config::load_settings() { + Ok(cli_settings) => match user_config::resolve_cli_settings(&cli_settings) { + Ok(resolved_cli) => (resolved_cli.logging.level, resolved_cli.updates.check), + Err(err) => return (command_name, Err(err)), + }, Err(err) => return (command_name, Err(err)), } } }; - let log_prefix = if command_name == "server start" { + let log_prefix = if command_name == "server start" || command_name == "server __serve" { "server" } else { "cli" }; if let Err(err) = logging::init_tracing(globals.debug, config_log_level.as_deref(), log_prefix) { - eprintln!("Warning: failed to initialize logging: {err:#}"); + fabro_util::printerr!(printer, "Warning: failed to initialize logging: {err:#}"); } debug!(command = %command_name, "CLI command started"); @@ -158,45 +176,55 @@ async fn main_inner() -> (String, Result<()>) { Commands::RunCmd(RunCommands::Run(_) | RunCommands::Create(_)) | Commands::Exec(_) | Commands::Repo(_) - | Commands::Install { .. } + | Commands::Install(_) ) { - commands::upgrade::spawn_upgrade_check(globals.no_upgrade_check, upgrade_check_enabled) + commands::upgrade::spawn_upgrade_check( + globals.no_upgrade_check, + upgrade_check_enabled, + printer, + ) } else { None }; - let result = async move { + let result = Box::pin(async move { match *command { - Commands::Llm(ns) => commands::llm::dispatch(ns, &globals).await?, - Commands::Exec(args) => commands::exec::execute(args, &globals).await?, - Commands::RunCmd(cmd) => commands::run::dispatch(cmd, &globals).await?, - Commands::Preflight(args) => commands::preflight::execute(args, &globals).await?, + Commands::Exec(args) => commands::exec::execute(args, &globals, printer).await?, + Commands::RunCmd(cmd) => { + Box::pin(commands::run::dispatch(cmd, &globals, printer)).await?; + } + Commands::Preflight(args) => { + commands::preflight::execute(args, &globals, printer).await?; + } Commands::Validate(args) => { let styles = Styles::detect_stderr(); - commands::validate::run(&args, &styles, &globals)?; + commands::validate::run(&args, &styles, &globals, printer).await?; } Commands::Graph(args) => { let styles = Styles::detect_stderr(); - commands::graph::run(&args, &styles, &globals)?; + commands::graph::run(&args, &styles, &globals, printer).await?; } Commands::Parse(args) => { - commands::parse::run(&args, &globals)?; + commands::parse::run(&args, &globals, printer)?; + } + Commands::Artifact(ns) => commands::artifact::dispatch(ns, &globals, printer).await?, + Commands::Store(ns) => commands::store::dispatch(ns, &globals, printer).await?, + Commands::RunsCmd(cmd) => commands::runs::dispatch(cmd, &globals, printer).await?, + Commands::Model { command } => { + commands::model::execute(command, &globals, printer).await?; } - Commands::Asset(ns) => commands::asset::dispatch(ns, &globals)?, - Commands::Store(ns) => commands::store::dispatch(ns, &globals).await?, - Commands::RunsCmd(cmd) => commands::runs::dispatch(cmd, &globals).await?, - Commands::Model { command } => commands::model::execute(command, &globals).await?, - #[cfg(feature = "server")] Commands::Server(ns) => { - let ServerCommand::Start(args) = ns.command; - let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); - fabro_server::serve::serve_command(args, styles, globals.storage_dir.clone()) - .await?; + Box::pin(commands::server::dispatch(ns.command, &globals, printer)).await?; } - Commands::Doctor { verbose, dry_run } => { - let cli_settings = user_config::load_user_settings()?; - let verbose = verbose || cli_settings.verbose_enabled(); - let exit_code = commands::doctor::run_doctor(verbose, !dry_run, &globals).await?; + Commands::Doctor(args) => { + let cli_settings = user_config::load_settings()?; + let verbose = args.verbose + || user_config::resolve_cli_settings(&cli_settings)? + .output + .verbosity + == OutputVerbosity::Verbose; + let exit_code = + commands::doctor::run_doctor(&args, verbose, &globals, printer).await?; std::process::exit(exit_code); } Commands::Discord => { @@ -217,21 +245,27 @@ async fn main_inner() -> (String, Result<()>) { open::that("https://docs.fabro.sh/")?; } } - Commands::Repo(ns) => commands::repo::dispatch(ns, &globals).await?, - Commands::Install { web_url } => { - commands::install::run_install(&web_url, &globals).await?; + Commands::Repo(ns) => commands::repo::dispatch(ns, &globals, printer).await?, + Commands::Install(args) => { + commands::install::run_install(&args, &globals, printer).await?; } - Commands::Pr(ns) => commands::pr::dispatch(ns, &globals).await?, - Commands::Secret(ns) => commands::secret::dispatch(ns, &globals)?, - Commands::Settings(args) => commands::config::execute(&args, &globals)?, - Commands::Workflow(ns) => commands::workflow::dispatch(ns, &globals)?, - Commands::Skill(ns) => commands::skill::dispatch(ns, &globals)?, + Commands::Uninstall(args) => { + commands::uninstall::run_uninstall(&args, &globals, printer).await?; + } + Commands::Pr(ns) => Box::pin(commands::pr::dispatch(ns, &globals, printer)).await?, + Commands::Secret(ns) => commands::secret::dispatch(ns, &globals, printer).await?, + Commands::Settings(args) => { + Box::pin(commands::config::execute(&args, &globals, printer)).await?; + } + Commands::Workflow(ns) => commands::workflow::dispatch(ns, &globals, printer)?, Commands::Upgrade(args) => { - commands::upgrade::run_upgrade(args, &globals).await?; + commands::upgrade::run_upgrade(args, &globals, printer).await?; } - Commands::Provider(ns) => commands::provider::dispatch(ns, &globals).await?, - Commands::Sandbox { command } => commands::sandbox::dispatch(command, &globals).await?, - Commands::System(ns) => commands::system::dispatch(ns, &globals).await?, + Commands::Provider(ns) => commands::provider::dispatch(ns, &globals, printer).await?, + Commands::Sandbox { command } => { + commands::sandbox::dispatch(command, &globals, printer).await?; + } + Commands::System(ns) => commands::system::dispatch(ns, &globals, printer).await?, Commands::Completion(args) => { globals.require_no_json()?; let mut cmd = Cli::command(); @@ -264,10 +298,16 @@ async fn main_inner() -> (String, Result<()>) { let _ = std::fs::remove_file(&path); result?; } + #[cfg(debug_assertions)] + Commands::TestPanic { message } => { + let event = tel_panic::build_event(&message); + let json = serde_json::to_string_pretty(&event)?; + fabro_util::printout!(printer, "{json}"); + } } Ok(()) - } + }) .await; // Print upgrade notice after command completes (non-blocking during execution) @@ -280,9 +320,11 @@ async fn main_inner() -> (String, Result<()>) { #[cfg(test)] mod tests { + use args::{ + Commands, ModelsCommand, ProviderCommand, ProviderNamespace, StoreCommand, StoreNamespace, + }; + use super::*; - use args::{ProviderCommand, ProviderNamespace, StoreCommand, StoreNamespace}; - use clap::Parser; #[test] fn parse_provider_login_openai() { @@ -341,43 +383,92 @@ mod tests { } #[test] - fn parse_global_storage_dir_after_subcommand() { - let cli = Cli::try_parse_from([ + fn parse_run_storage_dir_after_subcommand_is_rejected() { + let result = Cli::try_parse_from([ "fabro", "run", "test/simple.fabro", "--storage-dir", "/tmp/fabro", + ]); + assert!(result.is_err(), "should reject run --storage-dir"); + } + + #[test] + fn parse_model_list_server_target_after_subcommand() { + let cli = Cli::try_parse_from([ + "fabro", + "model", + "list", + "--server", + "http://localhost:3000/api/v1", ]) .expect("should parse"); - assert_eq!( - cli.globals.storage_dir.as_deref(), - Some(std::path::Path::new("/tmp/fabro")) - ); match *cli.command { - Commands::RunCmd(RunCommands::Run(args)) => { - assert_eq!( - args.workflow.as_deref(), - Some(std::path::Path::new("test/simple.fabro")) - ); + Commands::Model { + command: Some(ModelsCommand::List(args)), + } => assert_eq!(args.target.as_deref(), Some("http://localhost:3000/api/v1")), + _ => panic!("unexpected command variant"), + } + } + + #[test] + fn parse_exec_server_target_after_subcommand() { + let cli = Cli::try_parse_from([ + "fabro", + "exec", + "--server", + "http://localhost:3000/api/v1", + "fix the bug", + ]) + .expect("should parse"); + match *cli.command { + Commands::Exec(args) => { + assert_eq!(args.server.as_deref(), Some("http://localhost:3000/api/v1")); } _ => panic!("unexpected command variant"), } } #[test] - #[cfg(feature = "server")] - fn parse_server_url_conflicts_with_storage_dir() { + fn parse_model_server_target_conflicts_with_storage_dir() { + let result = Cli::try_parse_from([ + "fabro", + "model", + "list", + "--storage-dir", + "/tmp/fabro", + "--server", + "http://localhost:3000", + ]); + assert!( + result.is_err(), + "should fail with conflicting model target flags" + ); + } + + #[test] + fn parse_global_server_target_before_subcommand_is_rejected() { + let result = Cli::try_parse_from([ + "fabro", + "--server", + "http://localhost:3000/api/v1", + "model", + "list", + ]); + assert!(result.is_err(), "should reject top-level --server"); + } + + #[test] + fn parse_global_storage_dir_before_subcommand_is_rejected() { let result = Cli::try_parse_from([ "fabro", "--storage-dir", "/tmp/fabro", - "--server-url", - "http://localhost:3000", - "model", - "list", + "run", + "test/simple.fabro", ]); - assert!(result.is_err(), "should fail with conflicting global flags"); + assert!(result.is_err(), "should reject top-level --storage-dir"); } #[test] @@ -399,8 +490,8 @@ mod tests { fn parse_start_command() { let cli = Cli::try_parse_from(["fabro", "start", "ABC123"]).expect("should parse"); match *cli.command { - Commands::RunCmd(RunCommands::Start { run }) => { - assert_eq!(run, "ABC123"); + Commands::RunCmd(RunCommands::Start(args)) => { + assert_eq!(args.run, "ABC123"); } _ => panic!("unexpected command variant"), } @@ -410,8 +501,8 @@ mod tests { fn parse_attach_command() { let cli = Cli::try_parse_from(["fabro", "attach", "ABC123"]).expect("should parse"); match *cli.command { - Commands::RunCmd(RunCommands::Attach { run }) => { - assert_eq!(run, "ABC123"); + Commands::RunCmd(RunCommands::Attach(args)) => { + assert_eq!(args.run, "ABC123"); } _ => panic!("unexpected command variant"), } @@ -434,57 +525,56 @@ mod tests { } #[test] - fn parse_detached_command() { + fn parse_run_worker_command() { let cli = Cli::try_parse_from([ "fabro", - "__detached", + "__run-worker", + "--server", + "/tmp/fabro.sock", + "--artifact-upload-token", + "token-123", "--run-dir", - "/tmp/fabro/runs/01ABC", - "--launcher-path", - "/tmp/fabro/launchers/01ABC.json", + "/tmp/run", + "--run-id", + "01ARZ3NDEKTSV4RRFFQ69G5FAV", + "--mode", + "start", ]) .expect("should parse"); match *cli.command { - Commands::RunCmd(RunCommands::Detached { - run_dir, - launcher_path, - resume, - }) => { - assert_eq!(run_dir, std::path::PathBuf::from("/tmp/fabro/runs/01ABC")); - assert_eq!( - launcher_path, - std::path::PathBuf::from("/tmp/fabro/launchers/01ABC.json") - ); - assert!(!resume); + 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)); } _ => panic!("unexpected command variant"), } } #[test] - fn parse_detached_with_resume() { + fn parse_run_worker_with_resume_mode() { let cli = Cli::try_parse_from([ "fabro", - "__detached", + "__run-worker", + "--server", + "http://127.0.0.1:3000", "--run-dir", - "/tmp/fabro/runs/01ABC", - "--launcher-path", - "/tmp/fabro/launchers/01ABC.json", - "--resume", + "/tmp/run", + "--run-id", + "01ARZ3NDEKTSV4RRFFQ69G5FAV", + "--mode", + "resume", ]) .expect("should parse"); match *cli.command { - Commands::RunCmd(RunCommands::Detached { - run_dir, - launcher_path, - resume, - }) => { - assert_eq!(run_dir, std::path::PathBuf::from("/tmp/fabro/runs/01ABC")); - assert_eq!( - launcher_path, - std::path::PathBuf::from("/tmp/fabro/launchers/01ABC.json") - ); - assert!(resume); + 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)); } _ => panic!("unexpected command variant"), } @@ -496,6 +586,8 @@ mod tests { assert_eq!(cli.command.name(), "settings"); match *cli.command { Commands::Settings(args) => { + assert!(!args.local); + assert!(args.target.server.is_none()); assert!(args.workflow.is_none()); } _ => panic!("unexpected command variant"), @@ -513,6 +605,19 @@ mod tests { } } + #[test] + fn parse_settings_local_mode() { + let cli = + Cli::try_parse_from(["fabro", "settings", "--local", "demo"]).expect("should parse"); + match *cli.command { + Commands::Settings(args) => { + assert!(args.local); + assert_eq!(args.workflow, Some(std::path::PathBuf::from("demo"))); + } + _ => panic!("unexpected command variant"), + } + } + #[test] fn parse_quiet_flag() { let cli = Cli::try_parse_from(["fabro", "--quiet", "settings"]).expect("should parse"); diff --git a/lib/crates/fabro-cli/src/manifest_builder.rs b/lib/crates/fabro-cli/src/manifest_builder.rs new file mode 100644 index 000000000..4aefd3007 --- /dev/null +++ b/lib/crates/fabro-cli/src/manifest_builder.rs @@ -0,0 +1,753 @@ +use std::collections::{HashMap, HashSet}; +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_workflow::git::{GitSyncStatus, head_sha, sync_status}; + +use crate::args::{PreflightArgs, RunArgs}; + +#[derive(Debug)] +pub(crate) struct ManifestBuildInput { + pub workflow: PathBuf, + pub cwd: PathBuf, + pub args_layer: SettingsLayer, + pub args: Option, + pub run_id: Option, + /// User-level settings layer. Production callers load via + /// `load_settings_user()`; tests pass `SettingsLayer::default()`. + pub user_layer: SettingsLayer, + /// Path to the user settings file (for inclusion in + /// `RunManifest.configs`). `None` skips the user config entry. + pub user_settings_path: Option, +} + +#[derive(Debug)] +pub(crate) struct BuiltManifest { + pub manifest: types::RunManifest, + pub target_path: PathBuf, +} + +struct CollectContext<'a> { + cwd: &'a Path, + workflows: HashMap, + visited_workflows: HashSet, +} + +#[derive(Clone)] +struct WorkflowScanInput { + absolute_dot_path: PathBuf, + logical_dot_path: PathBuf, + source: String, +} + +pub(crate) fn build_run_manifest(input: ManifestBuildInput) -> Result { + 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 root_resolution = resolve_workflow_path(&input.workflow, &input.cwd)?; + let target_path = root_resolution.dot_path.clone(); + let target_logical_path = to_logical_path(&target_path, &input.cwd)?; + let target_logical_path_string = logical_path_string(&target_logical_path); + + let mut context = CollectContext { + cwd: &input.cwd, + workflows: HashMap::new(), + visited_workflows: HashSet::new(), + }; + collect_workflow_entry(&mut context, &input.workflow, &input.cwd)?; + + let root_source = context + .workflows + .get(&target_logical_path_string) + .map(|workflow| workflow.source.clone()) + .ok_or_else(|| anyhow!("root workflow missing from manifest bundle"))?; + + let mut configs = Vec::new(); + if let Some((path, _config)) = discover_project_config( + root_resolution + .resolved_workflow_path + .parent() + .unwrap_or_else(|| Path::new(".")), + )? { + let source = std::fs::read_to_string(&path) + .with_context(|| format!("Failed to read {}", path.display()))?; + configs.push(types::ManifestConfig { + path: Some(path.display().to_string()), + source: Some(source), + type_: types::ManifestConfigType::Project, + }); + } + if let Some(path) = input.user_settings_path.filter(|p| p.is_file()) { + let source = std::fs::read_to_string(&path) + .with_context(|| format!("Failed to read {}", path.display()))?; + configs.push(types::ManifestConfig { + path: Some(path.display().to_string()), + source: Some(source), + type_: types::ManifestConfigType::User, + }); + } + + let goal = resolve_manifest_goal( + &input.args_layer, + &merged_settings, + &root_source, + &target_path, + &input.cwd, + )?; + + let git = build_manifest_git(&input.cwd); + let args = input.args.filter(|args| !manifest_args_is_empty(args)); + + Ok(BuiltManifest { + manifest: types::RunManifest { + args, + configs, + cwd: input.cwd.display().to_string(), + git, + goal, + run_id: input.run_id.map(|run_id| run_id.to_string()), + target: types::ManifestTarget { + identifier: input.workflow.display().to_string(), + path: target_logical_path_string, + }, + version: 1, + workflows: context.workflows, + }, + target_path, + }) +} + +pub(crate) fn run_manifest_args(args: &RunArgs) -> Option { + let payload = types::ManifestArgs { + auto_approve: args.auto_approve.then_some(true), + dry_run: args.dry_run.then_some(true), + label: args.label.clone(), + model: args.model.clone(), + no_retro: args.no_retro.then_some(true), + preserve_sandbox: args.preserve_sandbox.then_some(true), + provider: args.provider.clone(), + sandbox: args + .sandbox + .map(|provider| fabro_sandbox::SandboxProvider::from(provider).to_string()), + verbose: args.verbose.then_some(true), + }; + (!manifest_args_is_empty(&payload)).then_some(payload) +} + +pub(crate) fn preflight_manifest_args(args: &PreflightArgs) -> Option { + let payload = types::ManifestArgs { + auto_approve: None, + dry_run: None, + label: Vec::new(), + model: args.model.clone(), + no_retro: None, + preserve_sandbox: None, + provider: args.provider.clone(), + sandbox: args + .sandbox + .map(|provider| fabro_sandbox::SandboxProvider::from(provider).to_string()), + verbose: args.verbose.then_some(true), + }; + (!manifest_args_is_empty(&payload)).then_some(payload) +} + +fn collect_workflow_entry( + context: &mut CollectContext<'_>, + workflow: &Path, + resolve_from: &Path, +) -> Result<()> { + let normalized_workflow = if workflow.extension().is_some() && workflow.is_relative() { + normalize_absolute_path(resolve_from, &workflow.to_string_lossy()).ok_or_else(|| { + anyhow!( + "unsupported manifest workflow reference: {}", + workflow.display() + ) + })? + } else { + workflow.to_path_buf() + }; + let resolution = resolve_workflow_path(&normalized_workflow, resolve_from)?; + let logical_dot_path = to_logical_path(&resolution.dot_path, context.cwd)?; + let logical_dot_key = logical_path_string(&logical_dot_path); + if !context.visited_workflows.insert(logical_dot_key.clone()) { + return Ok(()); + } + + let source = std::fs::read_to_string(&resolution.dot_path) + .with_context(|| format!("Failed to read {}", resolution.dot_path.display()))?; + let config = if let Some(workflow_toml_path) = resolution.workflow_toml_path.as_ref() { + Some(types::ManifestWorkflowConfig { + path: logical_path_string(&to_logical_path(workflow_toml_path, context.cwd)?), + source: std::fs::read_to_string(workflow_toml_path) + .with_context(|| format!("Failed to read {}", workflow_toml_path.display()))?, + }) + } else { + None + }; + + let scan = WorkflowScanInput { + absolute_dot_path: resolution.dot_path, + logical_dot_path, + source: source.clone(), + }; + let mut files = HashMap::new(); + let mut visited_imports = HashSet::new(); + if let Some(config) = config.as_ref() { + collect_workflow_config_files(context, config, &mut files)?; + } + collect_workflow_files(context, &scan, &mut files, &mut visited_imports)?; + + context + .workflows + .insert(logical_dot_key, types::ManifestWorkflow { + config, + files, + source, + }); + + Ok(()) +} + +fn collect_workflow_files( + context: &mut CollectContext<'_>, + workflow: &WorkflowScanInput, + files: &mut HashMap, + visited_imports: &mut HashSet, +) -> Result<()> { + let graph = parser::parse(&workflow.source).map_err(|err| { + anyhow!( + "Failed to parse {}: {err}", + workflow.absolute_dot_path.display() + ) + })?; + + if let Some(goal_ref) = graph.attrs.get("goal").and_then(AttrValue::as_str) { + if goal_ref.starts_with('@') { + collect_bundled_file( + files, + workflow + .absolute_dot_path + .parent() + .unwrap_or_else(|| Path::new(".")), + context.cwd, + goal_ref.trim_start_matches('@'), + types::ManifestFileRefType::FileInline, + Some(workflow.logical_dot_path.clone()), + )?; + } + } + + for node in graph.nodes.values() { + if let Some(prompt_ref) = node.attrs.get("prompt").and_then(AttrValue::as_str) { + if prompt_ref.starts_with('@') { + collect_bundled_file( + files, + workflow + .absolute_dot_path + .parent() + .unwrap_or_else(|| Path::new(".")), + context.cwd, + prompt_ref.trim_start_matches('@'), + types::ManifestFileRefType::FileInline, + Some(workflow.logical_dot_path.clone()), + )?; + } + } + + if let Some(import_ref) = node.attrs.get("import").and_then(AttrValue::as_str) { + let imported = collect_bundled_file( + files, + workflow + .absolute_dot_path + .parent() + .unwrap_or_else(|| Path::new(".")), + context.cwd, + import_ref, + types::ManifestFileRefType::Import, + Some(workflow.logical_dot_path.clone()), + )?; + let import_key = logical_path_string(&imported.logical_path); + if visited_imports.insert(import_key) { + let imported_source = std::fs::read_to_string(&imported.absolute_path) + .with_context(|| { + format!("Failed to read {}", imported.absolute_path.display()) + })?; + let imported_scan = WorkflowScanInput { + absolute_dot_path: imported.absolute_path, + logical_dot_path: imported.logical_path, + source: imported_source, + }; + collect_workflow_files(context, &imported_scan, files, visited_imports)?; + } + } + + if let Some(child_ref) = node + .attrs + .get("stack.child_workflow") + .or_else(|| node.attrs.get("stack.child_dotfile")) + .and_then(AttrValue::as_str) + { + collect_workflow_entry( + context, + Path::new(child_ref), + workflow + .absolute_dot_path + .parent() + .unwrap_or_else(|| Path::new(".")), + )?; + } + } + + Ok(()) +} + +fn collect_workflow_config_files( + context: &CollectContext<'_>, + config: &types::ManifestWorkflowConfig, + files: &mut HashMap, +) -> Result<()> { + let config_layer = parse_run_config(&config.source)?; + let dockerfile = config_layer + .run + .as_ref() + .and_then(|run| run.sandbox.as_ref()) + .and_then(|sandbox| sandbox.daytona.as_ref()) + .and_then(|daytona| daytona.snapshot.as_ref()) + .and_then(|snapshot| snapshot.dockerfile.as_ref()); + + let Some(DaytonaDockerfileLayer::Path { path }) = dockerfile else { + return Ok(()); + }; + + let config_path = context.cwd.join(&config.path); + collect_bundled_file( + files, + config_path.parent().unwrap_or_else(|| Path::new(".")), + context.cwd, + path, + types::ManifestFileRefType::Dockerfile, + Some(PathBuf::from(&config.path)), + )?; + Ok(()) +} + +struct BundledFile { + absolute_path: PathBuf, + logical_path: PathBuf, +} + +fn collect_bundled_file( + files: &mut HashMap, + base_dir: &Path, + cwd: &Path, + reference: &str, + ref_type: types::ManifestFileRefType, + from: Option, +) -> Result { + let absolute_path = normalize_absolute_path(base_dir, reference) + .ok_or_else(|| anyhow!("unsupported manifest reference: {reference}"))?; + let logical_path = to_logical_path(&absolute_path, cwd)?; + let key = logical_path_string(&logical_path); + if !files.contains_key(&key) { + let content = std::fs::read_to_string(&absolute_path) + .with_context(|| format!("Failed to read {}", absolute_path.display()))?; + files.insert(key.clone(), types::ManifestFileEntry { + content, + ref_: types::ManifestFileRef { + from: from.map(|value| logical_path_string(&value)), + original: reference.to_string(), + type_: ref_type, + }, + }); + } + + Ok(BundledFile { + absolute_path, + logical_path, + }) +} + +fn resolve_manifest_goal( + args_layer: &SettingsLayer, + settings: &SettingsLayer, + root_source: &str, + root_dot_path: &Path, + cwd: &Path, +) -> Result> { + let working_directory = project::resolve_working_directory(settings, cwd); + + // Precedence 1: CLI args (`--goal` / `--goal-file`). These are already + // resolved to absolute paths by `overrides::goal_layer_from_args`. + if let Some(resolved) = resolve_run_goal(args_layer, &working_directory) + .context("failed to resolve --goal-file contents")? + { + return Ok(Some(resolved_goal_to_manifest(resolved))); + } + + // Precedence 2: merged config `run.goal`. Config-sourced `goal.file` + // paths were rewritten to absolute by `load_settings_path` at the + // directory of the config file that declared them. + if let Some(resolved) = resolve_run_goal(settings, &working_directory) + .context("failed to resolve run.goal.file contents")? + { + return Ok(Some(resolved_goal_to_manifest(resolved))); + } + + // Precedence 3: graph-level `goal` attribute in the DOT, with `@file` + // sugar for workflow-colocated goal files. + let graph = parser::parse(root_source) + .map_err(|err| anyhow!("Failed to parse {}: {err}", root_dot_path.display()))?; + let Some(goal) = graph.attrs.get("goal").and_then(AttrValue::as_str) else { + return Ok(None); + }; + if let Some(reference) = goal.strip_prefix('@') { + let goal_path = normalize_absolute_path( + root_dot_path.parent().unwrap_or_else(|| Path::new(".")), + reference, + ) + .ok_or_else(|| anyhow!("unsupported manifest goal reference: {reference}"))?; + return Ok(Some(types::ManifestGoal { + path: Some(reference.to_string()), + text: std::fs::read_to_string(&goal_path) + .with_context(|| format!("Failed to read {}", goal_path.display()))?, + type_: types::ManifestGoalType::Graph, + })); + } + + Ok(Some(types::ManifestGoal { + path: None, + text: goal.to_string(), + type_: types::ManifestGoalType::Graph, + })) +} + +/// Translate a [`ResolvedRunGoal`] into the wire-level `ManifestGoal` +/// shape. Inline goals get `type = Value`; file-sourced goals keep their +/// absolute path as the `path` field and use `type = File`. +fn resolved_goal_to_manifest(resolved: ResolvedRunGoal) -> types::ManifestGoal { + match resolved.source { + ResolvedGoalSource::Inline => types::ManifestGoal { + path: None, + text: resolved.text, + type_: types::ManifestGoalType::Value, + }, + ResolvedGoalSource::File { path } => types::ManifestGoal { + path: Some(path.to_string_lossy().into_owned()), + text: resolved.text, + type_: types::ManifestGoalType::File, + }, + } +} + +fn build_manifest_git(cwd: &Path) -> Option { + let (origin_url, branch) = detect_repo_info(cwd).ok()?; + let branch = branch?; + let sha = head_sha(cwd).ok()?; + let clean = sync_status(cwd, "origin", Some(&branch)) != GitSyncStatus::Dirty; + Some(types::ManifestGit { + branch, + clean, + origin_url: sanitize_origin_url(&origin_url), + sha, + }) +} + +fn sanitize_origin_url(origin_url: &str) -> String { + fabro_github::normalize_repo_origin_url(origin_url) +} + +fn normalize_absolute_path(base_dir: &Path, reference: &str) -> Option { + let path = Path::new(reference); + if path.is_absolute() || reference.starts_with('~') { + return None; + } + + let mut normalized = PathBuf::new(); + for component in base_dir.join(path).components() { + match component { + Component::CurDir => {} + Component::Normal(part) => normalized.push(part), + Component::ParentDir => { + normalized.pop(); + } + Component::RootDir => normalized.push(Path::new("/")), + Component::Prefix(prefix) => normalized.push(prefix.as_os_str()), + } + } + Some(normalized) +} + +fn to_logical_path(path: &Path, cwd: &Path) -> Result { + if let Ok(stripped) = path.strip_prefix(cwd) { + return Ok(stripped.to_path_buf()); + } + + relative_path_from(path, cwd) + .ok_or_else(|| anyhow!("Failed to compute logical path for {}", path.display())) +} + +fn relative_path_from(path: &Path, base: &Path) -> Option { + let path_components = path.components().collect::>(); + let base_components = base.components().collect::>(); + if path_components.is_empty() || base_components.is_empty() { + return None; + } + + let mut common = 0; + while common < path_components.len() + && common < base_components.len() + && path_components[common] == base_components[common] + { + common += 1; + } + + let mut relative = PathBuf::new(); + for component in &base_components[common..] { + if matches!(component, Component::Normal(_)) { + relative.push(".."); + } + } + for component in &path_components[common..] { + match component { + Component::Normal(part) => relative.push(part), + Component::CurDir => {} + Component::ParentDir => relative.push(".."), + Component::RootDir | Component::Prefix(_) => return None, + } + } + Some(relative) +} + +fn logical_path_string(path: &Path) -> String { + path.to_string_lossy().to_string() +} + +fn manifest_args_is_empty(args: &types::ManifestArgs) -> bool { + args.auto_approve.is_none() + && args.dry_run.is_none() + && args.label.is_empty() + && args.model.is_none() + && args.no_retro.is_none() + && args.preserve_sandbox.is_none() + && args.provider.is_none() + && args.sandbox.is_none() + && args.verbose.is_none() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_manifest_bundles_imports_prompts_and_children() { + let temp = tempfile::tempdir().unwrap(); + let project = temp.path(); + let workflow_dir = project.join(".fabro/workflows/demo"); + let child_dir = project.join(".fabro/workflows/child"); + std::fs::create_dir_all(workflow_dir.join("prompts")).unwrap(); + std::fs::create_dir_all(workflow_dir.join("imports")).unwrap(); + std::fs::create_dir_all(&child_dir).unwrap(); + std::fs::write(project.join(".fabro/project.toml"), "_version = 1\n").unwrap(); + std::fs::write( + workflow_dir.join("workflow.toml"), + "_version = 1\n\n[workflow]\ngraph = \"workflow.fabro\"\n", + ) + .unwrap(); + std::fs::write( + workflow_dir.join("workflow.fabro"), + r#"digraph Demo { + graph [goal="@prompts/goal.md"] + start [shape=Mdiamond] + exit [shape=Msquare] + plan [prompt="@prompts/plan.md"] + imported [import="./imports/checks.fabro"] + child [shape=house, stack.child_workflow="../child/workflow.fabro"] + start -> plan -> imported -> child -> exit + }"#, + ) + .unwrap(); + std::fs::write(workflow_dir.join("prompts/goal.md"), "ship it").unwrap(); + std::fs::write(workflow_dir.join("prompts/plan.md"), "plan it").unwrap(); + std::fs::write( + workflow_dir.join("imports/checks.fabro"), + r#"digraph Checks { + start [shape=Mdiamond] + exit [shape=Msquare] + lint [prompt="@../prompts/lint.md"] + start -> lint -> exit + }"#, + ) + .unwrap(); + std::fs::write(workflow_dir.join("prompts/lint.md"), "lint it").unwrap(); + std::fs::write( + child_dir.join("workflow.fabro"), + r"digraph Child { start [shape=Mdiamond] exit [shape=Msquare] start -> exit }", + ) + .unwrap(); + + let built = build_run_manifest(ManifestBuildInput { + workflow: PathBuf::from(".fabro/workflows/demo/workflow.toml"), + cwd: project.to_path_buf(), + args_layer: SettingsLayer::default(), + args: None, + run_id: None, + user_layer: SettingsLayer::default(), + user_settings_path: None, + }) + .unwrap(); + + assert_eq!( + built.manifest.target.path, + ".fabro/workflows/demo/workflow.fabro" + ); + assert_eq!(built.manifest.workflows.len(), 2); + let root = &built.manifest.workflows[".fabro/workflows/demo/workflow.fabro"]; + assert!( + root.files + .contains_key(".fabro/workflows/demo/prompts/goal.md") + ); + assert!( + root.files + .contains_key(".fabro/workflows/demo/prompts/plan.md") + ); + assert!( + root.files + .contains_key(".fabro/workflows/demo/imports/checks.fabro") + ); + assert!( + root.files + .contains_key(".fabro/workflows/demo/prompts/lint.md") + ); + assert_eq!(built.manifest.goal.unwrap().text, "ship it"); + assert!( + built + .manifest + .workflows + .contains_key(".fabro/workflows/child/workflow.fabro") + ); + } + + /// A relative `[run.goal] file = "..."` declared in `.fabro/project.toml` + /// must resolve against the directory of `.fabro/project.toml`, not against + /// the invocation cwd. We exercise this by invoking from a subdirectory + /// below the project root. + #[test] + fn build_manifest_resolves_relative_goal_file_in_project_config() { + let temp = tempfile::tempdir().unwrap(); + let project = temp.path(); + let workflow_dir = project.join(".fabro/workflows/demo"); + std::fs::create_dir_all(&workflow_dir).unwrap(); + std::fs::create_dir_all(project.join(".fabro/prompts")).unwrap(); + + std::fs::write( + project.join(".fabro/project.toml"), + r#"_version = 1 + +[run.goal] +file = "prompts/goal.md" +"#, + ) + .unwrap(); + std::fs::write( + project.join(".fabro/prompts/goal.md"), + "ship from project root", + ) + .unwrap(); + + std::fs::write( + workflow_dir.join("workflow.toml"), + "_version = 1\n\n[workflow]\ngraph = \"workflow.fabro\"\n", + ) + .unwrap(); + std::fs::write( + workflow_dir.join("workflow.fabro"), + r"digraph Demo { start [shape=Mdiamond] exit [shape=Msquare] start -> exit }", + ) + .unwrap(); + + let built = build_run_manifest(ManifestBuildInput { + workflow: PathBuf::from(".fabro/workflows/demo/workflow.toml"), + cwd: project.to_path_buf(), + args_layer: SettingsLayer::default(), + args: None, + run_id: None, + user_layer: SettingsLayer::default(), + user_settings_path: None, + }) + .unwrap(); + + let goal = built.manifest.goal.expect("manifest goal should be set"); + assert_eq!(goal.text, "ship from project root"); + assert_eq!(goal.type_, types::ManifestGoalType::File); + let resolved = goal.path.expect("file goal must carry a path"); + let expected = project.join(".fabro").join("prompts").join("goal.md"); + assert_eq!(PathBuf::from(resolved), expected); + } + + /// A relative `[run.goal] file = "..."` declared in `workflow.toml` + /// must resolve against the directory of `workflow.toml`, not against + /// the invocation cwd or project root. + #[test] + fn build_manifest_resolves_relative_goal_file_in_workflow_config() { + let temp = tempfile::tempdir().unwrap(); + let project = temp.path(); + let workflow_dir = project.join(".fabro/workflows/demo"); + std::fs::create_dir_all(workflow_dir.join("prompts")).unwrap(); + + std::fs::write(project.join(".fabro/project.toml"), "_version = 1\n").unwrap(); + std::fs::write( + workflow_dir.join("workflow.toml"), + r#"_version = 1 + +[workflow] +graph = "workflow.fabro" + +[run.goal] +file = "prompts/goal.md" +"#, + ) + .unwrap(); + std::fs::write( + workflow_dir.join("prompts/goal.md"), + "ship from workflow dir", + ) + .unwrap(); + std::fs::write( + workflow_dir.join("workflow.fabro"), + r"digraph Demo { start [shape=Mdiamond] exit [shape=Msquare] start -> exit }", + ) + .unwrap(); + + let built = build_run_manifest(ManifestBuildInput { + workflow: PathBuf::from(".fabro/workflows/demo/workflow.toml"), + cwd: project.to_path_buf(), + args_layer: SettingsLayer::default(), + args: None, + run_id: None, + user_layer: SettingsLayer::default(), + user_settings_path: None, + }) + .unwrap(); + + let goal = built.manifest.goal.expect("manifest goal should be set"); + assert_eq!(goal.text, "ship from workflow dir"); + assert_eq!(goal.type_, types::ManifestGoalType::File); + let resolved = goal.path.expect("file goal must carry a path"); + let expected = workflow_dir.join("prompts").join("goal.md"); + assert_eq!(PathBuf::from(resolved), expected); + } +} diff --git a/lib/crates/fabro-cli/src/server_client.rs b/lib/crates/fabro-cli/src/server_client.rs new file mode 100644 index 000000000..b8cca5020 --- /dev/null +++ b/lib/crates/fabro-cli/src/server_client.rs @@ -0,0 +1,878 @@ +use std::collections::VecDeque; +use std::num::NonZeroU64; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::{Context as _, Result, anyhow, bail}; +use bytes::Bytes; +use fabro_api::types; +use fabro_http::header::{CONTENT_LENGTH, CONTENT_TYPE}; +use fabro_http::multipart::{Form, Part}; +use fabro_server::bind::Bind; +use fabro_store::{EventEnvelope, RunSummary, StageId}; +use fabro_types::settings::SettingsLayer; +use fabro_types::{RunBlobId, RunEvent, RunId}; +use fabro_workflow::artifact_snapshot::CapturedArtifactInfo; +use futures::StreamExt; +use serde::Serialize; +use serde::de::DeserializeOwned; +use tokio::fs::File; +use tokio::time::sleep; +use tokio_util::io::ReaderStream; + +use crate::args::ServerTargetArgs; +use crate::commands::server::start; +use crate::user_config::cli_http_client_builder; +use crate::{sse, user_config}; + +#[derive(Clone)] +pub(crate) struct ServerStoreClient { + client: fabro_api::Client, + http_client: fabro_http::HttpClient, + base_url: String, +} + +#[derive(Debug, Clone)] +struct LocalServerRuntime { + active_config_path: PathBuf, + storage_dir: PathBuf, +} + +pub(crate) struct RunAttachEventStream { + stream: progenitor_client::ByteStream, + pending_bytes: Vec, + buffered_events: VecDeque, +} + +impl RunAttachEventStream { + fn new(stream: progenitor_client::ByteStream) -> Self { + Self { + stream, + pending_bytes: Vec::new(), + buffered_events: VecDeque::new(), + } + } + + pub(crate) async fn next_event(&mut self) -> Result> { + loop { + if let Some(event) = self.buffered_events.pop_front() { + return Ok(Some(event)); + } + + if let Some(chunk) = self.stream.next().await { + let chunk = chunk.map_err(|err| anyhow!("{err}"))?; + self.pending_bytes.extend_from_slice(&chunk); + self.buffer_sse_events(false)?; + } else { + self.buffer_sse_events(true)?; + return Ok(self.buffered_events.pop_front()); + } + } + } + + fn buffer_sse_events(&mut self, finalize: bool) -> Result<()> { + for payload in sse::drain_sse_payloads(&mut self.pending_bytes, finalize) { + self.buffered_events + .push_back(serde_json::from_str(&payload)?); + } + Ok(()) + } +} + +pub(crate) use fabro_store::RunProjection; + +pub(crate) async fn connect_server(storage_dir: &Path) -> Result { + connect_api_client_bundle(storage_dir).await +} + +pub(crate) async fn connect_server_target_direct(target: &str) -> Result { + if target.starts_with("http://") || target.starts_with("https://") { + connect_remote_api_client_bundle(target, None) + } else { + let path = Path::new(target); + if !path.is_absolute() { + bail!("server target must be an http(s) URL or absolute Unix socket path"); + } + connect_unix_socket_api_client_bundle(path).await + } +} + +pub(crate) async fn connect_server_with_settings( + args: &ServerTargetArgs, + settings: &SettingsLayer, + base_config_path: &Path, +) -> Result { + let target = user_config::resolve_server_target(args, settings)?; + let runtime = LocalServerRuntime { + active_config_path: base_config_path.to_path_buf(), + storage_dir: user_config::storage_dir(settings)?, + }; + connect_target_api_client_bundle(&target, &runtime).await +} + +async fn connect_api_client_bundle(storage_dir: &Path) -> Result { + let config_path = user_config::active_settings_path(None); + let bind = start::ensure_server_running_for_storage(storage_dir, &config_path) + .await + .with_context(|| format!("Failed to start fabro server for {}", storage_dir.display()))?; + match bind { + Bind::Unix(path) => connect_unix_socket_api_client_bundle(&path).await, + Bind::Tcp(addr) => Err(anyhow!( + "Unsupported server bind for store client auto-connect: {addr}" + )), + } +} + +pub(crate) async fn connect_api_client(storage_dir: &Path) -> Result { + connect_api_client_bundle(storage_dir) + .await + .map(|client| client.client) +} + +async fn connect_target_api_client_bundle( + target: &user_config::ServerTarget, + runtime: &LocalServerRuntime, +) -> Result { + match target { + user_config::ServerTarget::HttpUrl { api_url, tls } => { + connect_remote_api_client_bundle(api_url, tls.as_ref()) + } + user_config::ServerTarget::UnixSocket(path) => { + if let Ok(client) = try_connect_unix_socket_api_client_bundle(path).await { + Ok(client) + } else { + start::ensure_server_running_on_socket( + path, + &runtime.active_config_path, + &runtime.storage_dir, + ) + .await + .with_context(|| format!("Failed to start fabro server for {}", path.display()))?; + connect_unix_socket_api_client_bundle(path).await + } + } + } +} + +fn connect_remote_api_client_bundle( + api_url: &str, + tls: Option<&user_config::ClientTlsSettings>, +) -> Result { + let http_client = user_config::build_server_client(tls)?; + let normalized = normalize_remote_server_target(api_url); + let client = fabro_api::Client::new_with_client(&normalized, http_client.clone()); + Ok(ServerStoreClient { + client, + http_client, + base_url: normalized, + }) +} + +fn normalize_remote_server_target(api_url: &str) -> String { + api_url + .trim_end_matches('/') + .strip_suffix("/api/v1") + .unwrap_or(api_url.trim_end_matches('/')) + .to_string() +} + +fn build_unix_socket_http_client(path: &Path) -> Result { + cli_http_client_builder() + .unix_socket(path) + .no_proxy() + .build() + .context("Failed to build Unix-socket HTTP client for fabro server") +} + +fn unix_socket_api_client_bundle(http_client: fabro_http::HttpClient) -> ServerStoreClient { + let base_url = "http://fabro".to_string(); + let client = fabro_api::Client::new_with_client(&base_url, http_client.clone()); + ServerStoreClient { + client, + http_client, + base_url, + } +} + +async fn try_connect_unix_socket_api_client_bundle(path: &Path) -> Result { + let http_client = build_unix_socket_http_client(path)?; + check_server_ready(&http_client).await?; + Ok(unix_socket_api_client_bundle(http_client)) +} + +async fn connect_unix_socket_api_client_bundle(path: &Path) -> Result { + let http_client = build_unix_socket_http_client(path)?; + wait_for_server_ready(&http_client).await?; + Ok(unix_socket_api_client_bundle(http_client)) +} + +async fn check_server_ready(http_client: &fabro_http::HttpClient) -> Result<()> { + match http_client.get("http://fabro/health").send().await { + Ok(response) if response.status().is_success() => Ok(()), + Ok(response) => bail!("server health check returned status {}", response.status()), + Err(err) => Err(anyhow!(err)), + } +} + +async fn wait_for_server_ready(http_client: &fabro_http::HttpClient) -> Result<()> { + let deadline = std::time::Instant::now() + Duration::from_secs(5); + let mut last_error = None; + + while std::time::Instant::now() < deadline { + match check_server_ready(http_client).await { + Ok(()) => return Ok(()), + Err(err) => { + last_error = Some(err); + } + } + sleep(Duration::from_millis(50)).await; + } + + Err(last_error.unwrap_or_else(|| anyhow!("server did not become ready in time"))) +} + +#[derive(Debug, Serialize)] +struct ArtifactBatchUploadManifest { + entries: Vec, +} + +#[derive(Debug, Serialize)] +struct ArtifactBatchUploadEntry { + part: String, + path: String, + #[serde(skip_serializing_if = "Option::is_none")] + sha256: Option, + #[serde(skip_serializing_if = "Option::is_none")] + expected_bytes: Option, + #[serde(skip_serializing_if = "Option::is_none")] + content_type: Option, +} + +impl ServerStoreClient { + /// Build a client for tests that bypasses proxy discovery. + #[cfg(test)] + pub(crate) fn new_no_proxy(base_url: &str) -> Result { + let http_client = cli_http_client_builder().no_proxy().build()?; + let client = fabro_api::Client::new_with_client(base_url, http_client.clone()); + Ok(Self { + client, + http_client, + base_url: base_url.to_string(), + }) + } + + pub(crate) fn clone_for_reuse(&self) -> Self { + self.clone() + } + + pub(crate) fn api(&self) -> &fabro_api::Client { + &self.client + } + + #[allow(dead_code)] + pub(crate) fn http_client(&self) -> &fabro_http::HttpClient { + &self.http_client + } + + #[allow(dead_code)] + pub(crate) fn base_url(&self) -> &str { + &self.base_url + } + + pub(crate) async fn retrieve_server_settings(&self) -> Result { + let response = self + .client + .retrieve_server_settings() + .send() + .await + .map_err(map_api_error)?; + let raw = serde_json::Value::Object(response.into_inner().into()); + serde_json::from_value::(raw) + .context("server returned a settings payload that does not match the v2 schema") + } + + pub(crate) async fn create_run_from_manifest( + &self, + manifest: types::RunManifest, + ) -> Result { + let response = self + .client + .create_run() + .body(manifest) + .send() + .await + .map_err(map_api_error)?; + let status = response.into_inner(); + status + .id + .parse() + .map_err(|err| anyhow!("invalid run ID from server: {err}")) + } + + pub(crate) async fn run_preflight( + &self, + manifest: types::RunManifest, + ) -> Result { + self.client + .run_preflight() + .body(manifest) + .send() + .await + .map(progenitor_client::ResponseValue::into_inner) + .map_err(map_api_error) + } + + pub(crate) async fn render_workflow_graph( + &self, + request: types::RenderWorkflowGraphRequest, + ) -> Result> { + let response = self + .client + .render_workflow_graph() + .body(request) + .send() + .await + .map_err(map_api_error)?; + let mut stream = response.into_inner(); + let mut bytes = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|err| anyhow!("{err}"))?; + bytes.extend_from_slice(&chunk); + } + Ok(bytes) + } + + pub(crate) async fn start_run(&self, run_id: &RunId, resume: bool) -> Result<()> { + self.client + .start_run() + .id(run_id.to_string()) + .body(types::StartRunRequest { resume }) + .send() + .await + .map_err(map_api_error)?; + Ok(()) + } + + pub(crate) async fn cancel_run(&self, run_id: &RunId) -> Result<()> { + self.client + .cancel_run() + .id(run_id.to_string()) + .send() + .await + .map_err(map_api_error)?; + Ok(()) + } + + pub(crate) async fn list_store_runs(&self) -> Result> { + let response = self + .client + .list_runs() + .send() + .await + .map_err(map_api_error)?; + response + .into_inner() + .into_iter() + .map(convert_type) + .collect::>>() + } + + pub(crate) async fn get_run_state(&self, run_id: &RunId) -> Result { + let response = self + .client + .get_run_state() + .id(run_id.to_string()) + .send() + .await + .map_err(map_api_error)?; + convert_type(response.into_inner()) + } + + pub(crate) async fn list_run_events( + &self, + run_id: &RunId, + since_seq: Option, + limit: Option, + ) -> Result> { + let mut next_since_seq = since_seq; + let mut all_events = Vec::new(); + + loop { + let mut request = self.client.list_run_events().id(run_id.to_string()); + if let Some(seq) = next_since_seq.and_then(non_zero_u64_from_u32) { + request = request.since_seq(seq); + } + if let Some(limit) = limit.and_then(non_zero_u64_from_usize) { + request = request.limit(limit); + } + + let response = request.send().await.map_err(map_api_error)?; + let parsed = response.into_inner(); + let page_events = parsed + .data + .into_iter() + .map(convert_type::<_, EventEnvelope>) + .collect::>>()?; + let next_page_since_seq = page_events.last().map(|event| event.seq.saturating_add(1)); + all_events.extend(page_events); + + if limit.is_some() || !parsed.meta.has_more || next_page_since_seq.is_none() { + break; + } + next_since_seq = next_page_since_seq; + } + + Ok(all_events) + } + + pub(crate) async fn attach_run_events( + &self, + run_id: &RunId, + since_seq: Option, + ) -> Result { + let mut request = self.client.attach_run_events().id(run_id.to_string()); + if let Some(seq) = since_seq.and_then(non_zero_u64_from_u32) { + request = request.since_seq(seq); + } + let response = request.send().await.map_err(map_api_error)?; + Ok(RunAttachEventStream::new(response.into_inner())) + } + + pub(crate) async fn list_run_questions( + &self, + run_id: &RunId, + ) -> Result> { + let response = self + .client + .list_run_questions() + .id(run_id.to_string()) + .page_limit(100) + .page_offset(0) + .send() + .await + .map_err(map_api_error)?; + Ok(response.into_inner().data) + } + + pub(crate) async fn submit_run_answer( + &self, + run_id: &RunId, + qid: &str, + value: Option, + selected_option_key: Option, + selected_option_keys: Vec, + ) -> Result<()> { + self.client + .submit_run_answer() + .id(run_id.to_string()) + .qid(qid) + .body(types::SubmitAnswerRequest { + value, + selected_option_key, + selected_option_keys, + }) + .send() + .await + .map_err(map_api_error)?; + Ok(()) + } + + pub(crate) async fn append_run_event(&self, run_id: &RunId, event: &RunEvent) -> Result { + let body: types::RunEvent = convert_type(event)?; + let response = self + .client + .append_run_event() + .id(run_id.to_string()) + .body(body) + .send() + .await + .map_err(map_api_error)?; + u32::try_from(response.into_inner().seq).context("append_run_event returned invalid seq") + } + + pub(crate) async fn write_run_blob(&self, run_id: &RunId, data: &[u8]) -> Result { + let response = self + .client + .write_run_blob() + .id(run_id.to_string()) + .body(data.to_vec()) + .send() + .await + .map_err(map_api_error)?; + response + .into_inner() + .id + .parse() + .context("write_run_blob returned invalid blob id") + } + + pub(crate) async fn read_run_blob( + &self, + run_id: &RunId, + blob_id: &RunBlobId, + ) -> Result> { + let response = self + .client + .read_run_blob() + .id(run_id.to_string()) + .blob_id(blob_id.to_string()) + .send() + .await; + match response { + Ok(response) => { + let mut stream = response.into_inner(); + let mut bytes = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|err| anyhow!("{err}"))?; + bytes.extend_from_slice(&chunk); + } + Ok(Some(Bytes::from(bytes))) + } + Err(err) => { + if is_not_found_error(&err) { + Ok(None) + } else { + Err(map_api_error(err)) + } + } + } + } + + pub(crate) async fn delete_store_run(&self, run_id: &RunId) -> Result<()> { + self.client + .delete_run() + .id(run_id.to_string()) + .send() + .await + .map_err(map_api_error)?; + Ok(()) + } + + pub(crate) async fn list_run_artifacts( + &self, + run_id: &RunId, + ) -> Result> { + let response = self + .client + .list_run_artifacts() + .id(run_id.to_string()) + .send() + .await + .map_err(map_api_error)?; + Ok(response.into_inner().data) + } + + pub(crate) async fn download_stage_artifact( + &self, + run_id: &RunId, + stage_id: &StageId, + filename: &str, + ) -> Result> { + let response = self + .client + .get_stage_artifact() + .id(run_id.to_string()) + .stage_id(stage_id.to_string()) + .filename(filename) + .send() + .await + .map_err(map_api_error)?; + let mut stream = response.into_inner(); + let mut bytes = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|err| anyhow!("{err}"))?; + bytes.extend_from_slice(&chunk); + } + Ok(bytes) + } + + fn stage_artifacts_url(&self, run_id: &RunId, stage_id: &StageId) -> Result { + let mut url = fabro_http::Url::parse(&self.base_url) + .with_context(|| format!("invalid server base URL {}", self.base_url))?; + url.path_segments_mut() + .map_err(|()| anyhow!("server base URL cannot accept path segments"))? + .extend([ + "api", + "v1", + "runs", + &run_id.to_string(), + "stages", + &stage_id.to_string(), + "artifacts", + ]); + Ok(url) + } + + pub(crate) async fn upload_stage_artifact_file( + &self, + run_id: &RunId, + stage_id: &StageId, + filename: &str, + path: &Path, + bearer_token: &str, + ) -> Result<()> { + let mut url = self.stage_artifacts_url(run_id, stage_id)?; + url.query_pairs_mut().append_pair("filename", filename); + + let file = File::open(path) + .await + .with_context(|| format!("failed to open artifact {}", path.display()))?; + let content_length = file + .metadata() + .await + .with_context(|| format!("failed to stat artifact {}", path.display()))? + .len(); + let body = fabro_http::Body::wrap_stream(ReaderStream::new(file)); + + let response = self + .http_client + .post(url) + .bearer_auth(bearer_token) + .header(CONTENT_TYPE, "application/octet-stream") + .header(CONTENT_LENGTH, content_length.to_string()) + .body(body) + .send() + .await + .with_context(|| format!("failed to upload artifact {}", path.display()))?; + ensure_raw_response_success(response).await + } + + pub(crate) async fn upload_stage_artifact_batch( + &self, + run_id: &RunId, + stage_id: &StageId, + artifact_capture_dir: &Path, + artifacts: &[CapturedArtifactInfo], + bearer_token: &str, + ) -> Result<()> { + let url = self.stage_artifacts_url(run_id, stage_id)?; + let mut manifest_entries = Vec::with_capacity(artifacts.len()); + let mut file_parts = Vec::with_capacity(artifacts.len()); + + for (index, artifact) in artifacts.iter().enumerate() { + let part_name = format!("file{}", index + 1); + let path = artifact_capture_dir.join(&artifact.path); + let file = File::open(&path) + .await + .with_context(|| format!("failed to open artifact {}", path.display()))?; + let content_length = file + .metadata() + .await + .with_context(|| format!("failed to stat artifact {}", path.display()))? + .len(); + + manifest_entries.push(ArtifactBatchUploadEntry { + part: part_name.clone(), + path: artifact.path.clone(), + sha256: Some(artifact.content_sha256.clone()), + expected_bytes: Some(artifact.bytes), + content_type: Some(artifact.mime.clone()), + }); + + file_parts.push(( + part_name, + Part::stream_with_length( + fabro_http::Body::wrap_stream(ReaderStream::new(file)), + content_length, + ) + .file_name(artifact.path.clone()), + )); + } + + let manifest = ArtifactBatchUploadManifest { + entries: manifest_entries, + }; + let manifest_part = + Part::text(serde_json::to_string(&manifest)?).mime_str("application/json")?; + let mut form = Form::new().part("manifest", manifest_part); + for (part_name, part) in file_parts { + form = form.part(part_name, part); + } + + let response = self + .http_client + .post(url) + .bearer_auth(bearer_token) + .multipart(form) + .send() + .await + .context("failed to upload artifact batch")?; + ensure_raw_response_success(response).await + } + + pub(crate) async fn generate_preview_url( + &self, + run_id: &RunId, + port: u16, + expires_in_secs: u64, + signed: bool, + ) -> Result { + let expires_in_secs = NonZeroU64::new(expires_in_secs) + .ok_or_else(|| anyhow!("preview expiry must be greater than zero"))?; + let response = self + .client + .generate_preview_url() + .id(run_id.to_string()) + .body(types::PreviewUrlRequest { + expires_in_secs, + port: i64::from(port), + signed, + }) + .send() + .await + .map_err(map_api_error)?; + Ok(response.into_inner()) + } + + pub(crate) async fn create_run_ssh_access( + &self, + run_id: &RunId, + ttl_minutes: f64, + ) -> Result { + let response = self + .client + .create_run_ssh_access() + .id(run_id.to_string()) + .body(types::SshAccessRequest { ttl_minutes }) + .send() + .await + .map_err(map_api_error)?; + Ok(response.into_inner()) + } + + pub(crate) async fn list_sandbox_files( + &self, + run_id: &RunId, + path: &str, + depth: Option, + ) -> Result> { + let mut request = self + .client + .list_sandbox_files() + .id(run_id.to_string()) + .path(path); + if let Some(depth) = depth.and_then(non_zero_u64_from_u32) { + request = request.depth(depth); + } + let response = request.send().await.map_err(map_api_error)?; + Ok(response.into_inner().data) + } + + pub(crate) async fn get_sandbox_file(&self, run_id: &RunId, path: &str) -> Result> { + let response = self + .client + .get_sandbox_file() + .id(run_id.to_string()) + .path(path) + .send() + .await + .map_err(map_api_error)?; + let mut stream = response.into_inner(); + let mut bytes = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|err| anyhow!("{err}"))?; + bytes.extend_from_slice(&chunk); + } + Ok(bytes) + } + + pub(crate) async fn put_sandbox_file( + &self, + run_id: &RunId, + path: &str, + bytes: Vec, + ) -> Result<()> { + self.client + .put_sandbox_file() + .id(run_id.to_string()) + .path(path) + .body(bytes) + .send() + .await + .map_err(map_api_error)?; + Ok(()) + } +} + +pub(crate) fn map_api_error(err: progenitor_client::Error) -> anyhow::Error +where + E: serde::Serialize + std::fmt::Debug, +{ + match err { + progenitor_client::Error::ErrorResponse(response) => { + let status = response.status(); + if let Ok(value) = serde_json::to_value(response.into_inner()) { + if let Some(detail) = value + .get("errors") + .and_then(serde_json::Value::as_array) + .and_then(|errors| errors.first()) + .and_then(|entry| entry.get("detail")) + .and_then(serde_json::Value::as_str) + { + return anyhow!("{detail}"); + } + } + anyhow!("request failed with status {status}") + } + progenitor_client::Error::UnexpectedResponse(response) => { + anyhow!("request failed with status {}", response.status()) + } + other => anyhow!("{other}"), + } +} + +async fn ensure_raw_response_success(response: fabro_http::Response) -> Result<()> { + if response.status().is_success() { + return Ok(()); + } + + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + if let Ok(value) = serde_json::from_str::(&body) { + if let Some(detail) = value + .get("errors") + .and_then(serde_json::Value::as_array) + .and_then(|errors| errors.first()) + .and_then(|entry| entry.get("detail")) + .and_then(serde_json::Value::as_str) + { + bail!("{detail}"); + } + } + + if body.is_empty() { + bail!("request failed with status {status}"); + } + + bail!("request failed with status {status}: {body}"); +} + +fn is_not_found_error(err: &progenitor_client::Error) -> bool +where + E: serde::Serialize + std::fmt::Debug, +{ + match err { + progenitor_client::Error::ErrorResponse(response) => { + response.status() == fabro_http::StatusCode::NOT_FOUND + } + progenitor_client::Error::UnexpectedResponse(response) => { + response.status() == fabro_http::StatusCode::NOT_FOUND + } + _ => false, + } +} +fn convert_type(value: TInput) -> Result +where + TInput: serde::Serialize, + TOutput: DeserializeOwned, +{ + serde_json::from_value(serde_json::to_value(value)?).map_err(Into::into) +} + +fn non_zero_u64_from_u32(value: u32) -> Option { + NonZeroU64::new(u64::from(value)) +} + +fn non_zero_u64_from_usize(value: usize) -> Option { + u64::try_from(value).ok().and_then(NonZeroU64::new) +} diff --git a/lib/crates/fabro-cli/src/server_runs.rs b/lib/crates/fabro-cli/src/server_runs.rs new file mode 100644 index 000000000..8020f7882 --- /dev/null +++ b/lib/crates/fabro-cli/src/server_runs.rs @@ -0,0 +1,227 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use anyhow::{Result, bail}; +use chrono::{DateTime, Utc}; +use fabro_store::RunSummary; +use fabro_types::{RunId, RunStatus, StatusReason}; +use fabro_workflow::run_lookup::{RunInfo, resolve_run_from_summaries, scratch_base}; + +use crate::server_client::{self, ServerStoreClient}; + +pub(crate) struct ServerRunLookup { + client: ServerStoreClient, + scratch_base: PathBuf, + summaries: Vec, +} + +impl ServerRunLookup { + pub(crate) async fn connect(storage_dir: &Path) -> Result { + Self::connect_from_scratch_base(&scratch_base(storage_dir)).await + } + + pub(crate) async fn connect_from_scratch_base(scratch_base: &Path) -> Result { + let storage_dir = scratch_base.parent().unwrap_or(scratch_base); + let client = server_client::connect_server(storage_dir).await?; + let summaries = client.list_store_runs().await?; + Ok(Self { + client, + scratch_base: scratch_base.to_path_buf(), + summaries, + }) + } + + pub(crate) fn client(&self) -> &ServerStoreClient { + &self.client + } + + pub(crate) fn resolve(&self, selector: &str) -> Result { + resolve_run_from_summaries(&self.summaries, &self.scratch_base, selector) + } +} + +#[derive(Debug, Clone)] +pub(crate) struct ServerRunSummaryInfo { + summary: RunSummary, +} + +impl ServerRunSummaryInfo { + pub(crate) fn run_id(&self) -> RunId { + self.summary.run_id + } + + pub(crate) fn workflow_name(&self) -> String { + self.summary + .workflow_name + .clone() + .unwrap_or_else(|| "[no run record]".to_string()) + } + + pub(crate) fn workflow_slug(&self) -> Option<&str> { + self.summary.workflow_slug.as_deref() + } + + pub(crate) fn status(&self) -> RunStatus { + self.summary.status.unwrap_or(RunStatus::Dead) + } + + pub(crate) fn status_reason(&self) -> Option { + self.summary.status_reason + } + + pub(crate) fn start_time(&self) -> String { + self.start_time_dt() + .map(|time| time.to_rfc3339()) + .unwrap_or_default() + } + + pub(crate) fn start_time_dt(&self) -> Option> { + self.summary + .start_time + .or(Some(self.summary.run_id.created_at())) + } + + pub(crate) fn labels(&self) -> &HashMap { + &self.summary.labels + } + + pub(crate) fn duration_ms(&self) -> Option { + self.summary.duration_ms + } + + pub(crate) fn total_usd_micros(&self) -> Option { + self.summary.total_usd_micros + } + + pub(crate) fn host_repo_path(&self) -> Option<&str> { + self.summary.host_repo_path.as_deref() + } + + pub(crate) fn goal(&self) -> String { + self.summary.goal.clone().unwrap_or_default() + } +} + +pub(crate) struct ServerSummaryLookup { + client: Arc, + runs: Vec, +} + +impl ServerSummaryLookup { + pub(crate) async fn from_client(client: Arc) -> Result { + let summaries = client.list_store_runs().await?; + let mut runs = summaries + .into_iter() + .map(|summary| ServerRunSummaryInfo { summary }) + .collect::>(); + runs.sort_by(|a, b| { + b.start_time_dt() + .cmp(&a.start_time_dt()) + .then_with(|| b.run_id().cmp(&a.run_id())) + }); + Ok(Self { client, runs }) + } + + pub(crate) fn client(&self) -> &ServerStoreClient { + self.client.as_ref() + } + + pub(crate) fn runs(&self) -> &[ServerRunSummaryInfo] { + &self.runs + } + + pub(crate) fn resolve(&self, selector: &str) -> Result { + resolve_server_run_from_infos(&self.runs, selector) + } +} + +pub(crate) fn resolve_server_run_from_summaries( + runs: &[ServerRunSummaryInfo], + selector: &str, +) -> Result { + resolve_server_run_from_infos(runs, selector) +} + +pub(crate) fn filter_server_runs( + runs: &[ServerRunSummaryInfo], + before: Option<&str>, + workflow: Option<&str>, + labels: &[(String, String)], + running_only: bool, +) -> Vec { + runs.iter() + .filter(|run| !running_only || run.status().is_active()) + .filter(|run| { + before.is_none_or(|before| { + let start_time = run.start_time(); + start_time.is_empty() || start_time.as_str() < before + }) + }) + .filter(|run| workflow.is_none_or(|pattern| run.workflow_name().contains(pattern))) + .filter(|run| { + labels.iter().all(|(key, value)| { + run.labels() + .get(key) + .is_some_and(|current| current == value) + }) + }) + .cloned() + .collect() +} + +fn resolve_server_run_from_infos( + runs: &[ServerRunSummaryInfo], + identifier: &str, +) -> Result { + let id_matches: Vec<_> = runs + .iter() + .filter(|run| run_id_matches(run.run_id(), identifier)) + .collect(); + + match id_matches.len() { + 1 => return Ok(id_matches[0].clone()), + count if count > 1 => { + let ids: Vec = id_matches + .iter() + .map(|run| run.run_id().to_string()) + .collect(); + bail!( + "Ambiguous prefix '{identifier}': {count} runs match: {}", + ids.join(", ") + ); + } + _ => {} + } + + let id_lower = identifier.to_lowercase(); + let id_collapsed = collapse_separators(&id_lower); + let workflow_match = runs + .iter() + .filter(|run| { + if let Some(slug) = run.workflow_slug() { + if slug.to_lowercase() == id_lower { + return true; + } + } + let name_lower = run.workflow_name().to_lowercase(); + name_lower.contains(&id_lower) + || collapse_separators(&name_lower).contains(&id_collapsed) + }) + .max_by_key(|run| run.run_id().created_at()); + + match workflow_match { + Some(run) => Ok(run.clone()), + None => { + bail!("No run found matching '{identifier}' (tried run ID prefix and workflow name)") + } + } +} + +fn collapse_separators(s: &str) -> String { + s.chars().filter(|c| *c != '-' && *c != '_').collect() +} + +fn run_id_matches(run_id: RunId, prefix: &str) -> bool { + run_id.to_string().starts_with(prefix) +} diff --git a/lib/crates/fabro-cli/src/shared/github.rs b/lib/crates/fabro-cli/src/shared/github.rs index 894436a47..72fa8d2c0 100644 --- a/lib/crates/fabro-cli/src/shared/github.rs +++ b/lib/crates/fabro-cli/src/shared/github.rs @@ -1,8 +1,18 @@ use anyhow::anyhow; -use fabro_github::GitHubAppCredentials; +use fabro_github::GitHubCredentials; +use fabro_types::settings::server::GithubIntegrationStrategy; -pub(crate) fn build_github_app_credentials( +pub(crate) async fn build_github_credentials( + strategy: GithubIntegrationStrategy, app_id: Option<&str>, -) -> anyhow::Result> { - GitHubAppCredentials::from_env(app_id).map_err(|err| anyhow!(err)) +) -> anyhow::Result> { + match strategy { + GithubIntegrationStrategy::App => { + GitHubCredentials::from_env(app_id).map_err(|err| anyhow!(err)) + } + GithubIntegrationStrategy::GhCli => fabro_github::gh_auth_token() + .await + .map(|token| Some(GitHubCredentials::Token(token))) + .map_err(|err| anyhow!(err)), + } } diff --git a/lib/crates/fabro-cli/src/shared/mod.rs b/lib/crates/fabro-cli/src/shared/mod.rs index 4aca2e627..89c85fde6 100644 --- a/lib/crates/fabro-cli/src/shared/mod.rs +++ b/lib/crates/fabro-cli/src/shared/mod.rs @@ -1,6 +1,7 @@ pub(crate) mod github; pub(crate) mod openai_jwt; pub(crate) mod provider_auth; +pub(crate) mod repo; mod utilities; pub(crate) use utilities::*; diff --git a/lib/crates/fabro-cli/src/shared/openai_jwt.rs b/lib/crates/fabro-cli/src/shared/openai_jwt.rs index 712fbf7fd..e15665550 100644 --- a/lib/crates/fabro-cli/src/shared/openai_jwt.rs +++ b/lib/crates/fabro-cli/src/shared/openai_jwt.rs @@ -11,9 +11,9 @@ struct JwtPayload { #[serde(default)] chatgpt_account_id: Option, #[serde(default, rename = "https://api.openai.com/auth")] - auth_claim: Option, + auth_claim: Option, #[serde(default)] - organizations: Option>, + organizations: Option>, } #[derive(Deserialize)] diff --git a/lib/crates/fabro-cli/src/shared/provider_auth.rs b/lib/crates/fabro-cli/src/shared/provider_auth.rs index 738734534..524ae2cff 100644 --- a/lib/crates/fabro-cli/src/shared/provider_auth.rs +++ b/lib/crates/fabro-cli/src/shared/provider_auth.rs @@ -1,19 +1,18 @@ -use std::path::Path; +use std::sync::Arc; use anyhow::Result; use dialoguer::console::Term; use dialoguer::theme::ColorfulTheme; use dialoguer::{Confirm, Password}; -use fabro_config::dotenv::{merge_env, write_env_file as write_env}; use fabro_llm::client::Client as LlmClient; use fabro_llm::generate::{GenerateParams, generate}; -use fabro_model::Provider; +use fabro_model::{Catalog, Provider}; +use fabro_util::printer::Printer; use fabro_util::terminal::Styles; use tokio::task::spawn_blocking; use tokio::time::timeout; use super::openai_jwt; -use crate::commands::doctor; // --------------------------------------------------------------------------- // Provider key URLs @@ -51,7 +50,7 @@ pub(crate) fn provider_display_name(provider: Provider) -> &'static str { // OpenAI OAuth helpers // --------------------------------------------------------------------------- -/// Convert OAuth tokens to env var pairs for ~/.fabro/.env. +/// Convert OAuth tokens to secret name/value pairs. pub(crate) fn openai_oauth_env_pairs( access_token: &str, refresh_token: &str, @@ -76,8 +75,12 @@ pub(crate) fn openai_oauth_env_pairs( /// Run the OpenAI OAuth browser flow, falling back to manual API key entry on /// failure. Returns the env-var pairs to persist. -pub(crate) async fn run_openai_oauth_or_api_key(s: &Styles) -> Result> { - eprintln!( +pub(crate) async fn run_openai_oauth_or_api_key( + s: &Styles, + printer: Printer, +) -> Result> { + fabro_util::printerr!( + printer, " {}", s.dim.apply_to("Opening browser for OpenAI login...") ); @@ -102,7 +105,8 @@ pub(crate) async fn run_openai_oauth_or_api_key(s: &Styles) -> Result Result { tracing::warn!(error = %e, "OpenAI OAuth browser flow failed"); - eprintln!(" Browser login failed: {e}"); - eprintln!( + fabro_util::printerr!(printer, " Browser login failed: {e}"); + fabro_util::printerr!( + printer, " {}", s.dim.apply_to("Falling back to manual API key entry.") ); - let (env_var, key) = prompt_and_validate_key(Provider::OpenAi, s).await?; + let (env_var, key) = prompt_and_validate_key(Provider::OpenAi, s, printer).await?; Ok(vec![(env_var, key)]) } } @@ -138,46 +143,32 @@ pub(crate) fn prompt_password(prompt: &str) -> Result { .interact_on(&Term::stderr())?) } -// --------------------------------------------------------------------------- -// Env file writing -// --------------------------------------------------------------------------- - -pub(crate) fn write_env_file( - arc_dir: &Path, - env_pairs: &[(String, String)], - s: &Styles, -) -> Result<()> { - let env_path = arc_dir.join(".env"); - let existing = std::fs::read_to_string(&env_path).unwrap_or_default(); - let refs: Vec<(&str, &str)> = env_pairs - .iter() - .map(|(k, v)| (k.as_str(), v.as_str())) - .collect(); - let merged = merge_env(&existing, &refs); - write_env(&env_path, &merged)?; - eprintln!( - " {}", - s.dim.apply_to(format!("Wrote {}", env_path.display())) - ); - Ok(()) -} - // --------------------------------------------------------------------------- // API key validation // --------------------------------------------------------------------------- pub(crate) async fn validate_api_key(provider: Provider, api_key: &str) -> Result<(), String> { - // Temporarily set the env var so Client::from_env() picks it up let env_var = provider.api_key_env_vars()[0]; - std::env::set_var(env_var, api_key); + let client = LlmClient::from_lookup(|name| { + if name == env_var { + Some(api_key.to_string()) + } else { + None + } + }) + .await + .map_err(|e| e.to_string())?; - let client = LlmClient::from_env().await.map_err(|e| e.to_string())?; + let probe_model = Catalog::builtin().probe_for_provider(provider).map_or_else( + || format!("unknown-{}", provider.as_str()), + |model| model.id.clone(), + ); - let params = GenerateParams::new(doctor::probe_model(provider)) + let params = GenerateParams::new(probe_model) .provider(provider.as_str()) .prompt("Say OK") .max_tokens(16) - .client(std::sync::Arc::new(client)); + .client(Arc::new(client)); timeout(std::time::Duration::from_secs(30), generate(params)) .await @@ -189,10 +180,12 @@ pub(crate) async fn validate_api_key(provider: Provider, api_key: &str) -> Resul pub(crate) async fn prompt_and_validate_key( provider: Provider, s: &Styles, + printer: Printer, ) -> Result<(String, String)> { let env_var = provider.api_key_env_vars()[0]; let url = provider_key_url(provider); - eprintln!( + fabro_util::printerr!( + printer, " {}", s.dim.apply_to(format!("Get your API key at: {url}")) ); @@ -201,14 +194,14 @@ pub(crate) async fn prompt_and_validate_key( let prompt = env_var.to_string(); let key: String = spawn_blocking(move || prompt_password(&prompt)).await??; - eprintln!(" {}", s.dim.apply_to("Validating API key...")); + fabro_util::printerr!(printer, " {}", s.dim.apply_to("Validating API key...")); match validate_api_key(provider, &key).await { Ok(()) => { - eprintln!(" {} API key is valid", s.green.apply_to("✔")); + fabro_util::printerr!(printer, " {} API key is valid", s.green.apply_to("✔")); return Ok((env_var.to_string(), key)); } Err(e) => { - eprintln!(" [error] API key validation failed: {e}"); + fabro_util::printerr!(printer, " [error] API key validation failed: {e}"); let retry = spawn_blocking(|| prompt_confirm("Try again with a different key?", true)) .await??; diff --git a/lib/crates/fabro-cli/src/shared/repo.rs b/lib/crates/fabro-cli/src/shared/repo.rs new file mode 100644 index 000000000..44dd0ad99 --- /dev/null +++ b/lib/crates/fabro-cli/src/shared/repo.rs @@ -0,0 +1,37 @@ +use anyhow::{Result, bail}; +use fabro_sandbox::daytona::detect_repo_info; + +pub(crate) fn ensure_matching_repo_origin( + expected_origin_url: Option<&str>, + action: &str, +) -> Result<()> { + let Some(expected_origin_url) = expected_origin_url else { + return Ok(()); + }; + + let cwd = std::env::current_dir()?; + let (origin_url, _) = detect_repo_info(&cwd).map_err(|_| { + anyhow::anyhow!( + "Current directory is not a git repository with an origin remote; refusing to {action} run from repository '{expected_origin_url}'" + ) + })?; + let current_origin_url = fabro_github::normalize_repo_origin_url(&origin_url); + + if current_origin_url != expected_origin_url { + bail!( + "Current repository origin '{current_origin_url}' does not match run repository '{expected_origin_url}'; refusing to {action} this run from the wrong checkout" + ); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::ensure_matching_repo_origin; + + #[test] + fn missing_expected_origin_skips_guard() { + ensure_matching_repo_origin(None, "fork").unwrap(); + } +} diff --git a/lib/crates/fabro-cli/src/shared/utilities.rs b/lib/crates/fabro-cli/src/shared/utilities.rs index 029f95b5f..e2e2e77d6 100644 --- a/lib/crates/fabro-cli/src/shared/utilities.rs +++ b/lib/crates/fabro-cli/src/shared/utilities.rs @@ -1,9 +1,9 @@ -use std::path::Path; +use std::io::Write; +use std::path::{Path, PathBuf}; use std::time::Duration; -use std::{io::Write, path::PathBuf}; -use anyhow::{Result, bail}; use cli_table::Color; +use fabro_util::printer::Printer; use fabro_util::terminal::Styles; use fabro_validate::{Diagnostic, Severity}; use serde::Serialize; @@ -24,7 +24,7 @@ where Ok(()) } -pub(crate) fn print_diagnostics(diagnostics: &[Diagnostic], styles: &Styles) { +pub(crate) fn print_diagnostics(diagnostics: &[Diagnostic], styles: &Styles, printer: Printer) { for d in diagnostics { let location = match (&d.node_id, &d.edge) { (Some(node), _) => format!(" [node: {node}]"), @@ -32,19 +32,22 @@ pub(crate) fn print_diagnostics(diagnostics: &[Diagnostic], styles: &Styles) { _ => String::new(), }; match d.severity { - Severity::Error => eprintln!( + Severity::Error => fabro_util::printerr!( + printer, "{}{location}: {} ({})", styles.red.apply_to("error"), d.message, styles.dim.apply_to(&d.rule), ), - Severity::Warning => eprintln!( + Severity::Warning => fabro_util::printerr!( + printer, "{}{location}: {} ({})", styles.yellow.apply_to("warning"), d.message, styles.dim.apply_to(&d.rule), ), - Severity::Info => eprintln!( + Severity::Info => fabro_util::printerr!( + printer, "{}", styles .dim @@ -73,6 +76,10 @@ pub(crate) fn format_tokens_human(tokens: i64) -> String { } } +pub(crate) fn format_usd_micros(usd_micros: i64) -> String { + format!("${:.2}", usd_micros as f64 / 1_000_000.0) +} + pub(crate) fn tilde_path(path: &Path) -> String { if let Some(home) = dirs::home_dir() { if let Ok(suffix) = path.strip_prefix(&home) { @@ -103,19 +110,6 @@ pub(crate) fn split_run_path(s: &str) -> Option<(&str, &str)> { s.split_once(':') } -pub(crate) fn validate_daytona_provider( - record: &fabro_sandbox::SandboxRecord, - feature: &str, -) -> Result<()> { - if record.provider != "daytona" { - bail!( - "{feature} is only supported for Daytona sandboxes (this run uses '{}')", - record.provider - ); - } - Ok(()) -} - pub(crate) fn format_duration_ms(ms: u64) -> String { let duration = Duration::from_millis(ms); let secs = duration.as_secs(); @@ -146,7 +140,7 @@ pub(crate) fn format_size(bytes: u64) -> String { #[cfg(test)] mod tests { - use super::format_tokens_human; + use super::{format_tokens_human, format_usd_micros}; #[test] fn format_tokens_human_zero() { @@ -177,4 +171,9 @@ mod tests { fn format_tokens_human_mid_millions() { assert_eq!(format_tokens_human(3_456_789), "3.5m"); } + + #[test] + fn format_usd_micros_two_decimals() { + assert_eq!(format_usd_micros(570_000), "$0.57"); + } } diff --git a/lib/crates/fabro-cli/src/sleep_inhibitor/linux.rs b/lib/crates/fabro-cli/src/sleep_inhibitor/linux.rs index 9424508d6..fd2cb9dac 100644 --- a/lib/crates/fabro-cli/src/sleep_inhibitor/linux.rs +++ b/lib/crates/fabro-cli/src/sleep_inhibitor/linux.rs @@ -1,5 +1,5 @@ -use std::os::unix::process::CommandExt; use std::process::{Child, Command}; + use tracing::{debug, warn}; pub(crate) struct LinuxSleepInhibitor { @@ -22,15 +22,14 @@ impl LinuxSleepInhibitor { /// Spawn a command with `PR_SET_PDEATHSIG` so the child is automatically /// killed if the parent process dies (prevents orphan `sleep infinity`). fn spawn_with_pdeathsig(cmd: &mut Command) -> std::io::Result { - unsafe { - cmd.pre_exec(|| { - libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM); - Ok(()) - }); - } + fabro_proc::pre_exec_pdeathsig(cmd); cmd.spawn() } + #[expect( + clippy::disallowed_methods, + reason = "Sleep inhibitor ownership is tied to a std::process::Child dropped synchronously with pre-exec hooks." + )] fn try_systemd_inhibit() -> Option { let mut cmd = Command::new("systemd-inhibit"); cmd.args([ @@ -57,6 +56,10 @@ impl LinuxSleepInhibitor { } } + #[expect( + clippy::disallowed_methods, + reason = "Sleep inhibitor ownership is tied to a std::process::Child dropped synchronously with pre-exec hooks." + )] fn try_gnome_inhibit() -> Option { let mut cmd = Command::new("gnome-session-inhibit"); cmd.args([ diff --git a/lib/crates/fabro-cli/src/sleep_inhibitor/macos.rs b/lib/crates/fabro-cli/src/sleep_inhibitor/macos.rs index 419887dec..e9c00c46f 100644 --- a/lib/crates/fabro-cli/src/sleep_inhibitor/macos.rs +++ b/lib/crates/fabro-cli/src/sleep_inhibitor/macos.rs @@ -1,3 +1,5 @@ +#![allow(unsafe_code)] + use core_foundation::base::TCFType; use tracing::{debug, warn}; diff --git a/lib/crates/fabro-cli/src/sse.rs b/lib/crates/fabro-cli/src/sse.rs new file mode 100644 index 000000000..7208e625d --- /dev/null +++ b/lib/crates/fabro-cli/src/sse.rs @@ -0,0 +1,51 @@ +pub(crate) fn drain_sse_payloads(buffer: &mut Vec, finalize: bool) -> Vec { + let mut payloads = Vec::new(); + + while let Some(pos) = buffer.iter().position(|byte| *byte == b'\n') { + let line = buffer.drain(..=pos).collect::>(); + if let Some(payload) = sse_data_line(&line) { + payloads.push(payload); + } + } + + if finalize && !buffer.is_empty() { + let line = std::mem::take(buffer); + if let Some(payload) = sse_data_line(&line) { + payloads.push(payload); + } + } + + payloads +} + +fn sse_data_line(line: &[u8]) -> Option { + let line = String::from_utf8_lossy(line); + let line = line.trim_end_matches(['\r', '\n']); + line.strip_prefix("data:") + .map(|data| data.trim().to_string()) +} + +#[cfg(test)] +mod tests { + use super::drain_sse_payloads; + + #[test] + fn drain_sse_payloads_handles_chunk_boundaries() { + let mut buffer = b"data: {\"a\":1}\n\ndat".to_vec(); + + assert_eq!(drain_sse_payloads(&mut buffer, false), vec![r#"{"a":1}"#]); + assert_eq!(buffer, b"dat"); + + buffer.extend_from_slice(b"a: {\"b\":2}\n\n"); + assert_eq!(drain_sse_payloads(&mut buffer, false), vec![r#"{"b":2}"#]); + assert!(buffer.is_empty()); + } + + #[test] + fn drain_sse_payloads_ignores_keepalives_and_finalizes_trailing_line() { + let mut buffer = b": keep-alive\n\n\ndata: {\"a\":1}".to_vec(); + + assert_eq!(drain_sse_payloads(&mut buffer, true), vec![r#"{"a":1}"#]); + assert!(buffer.is_empty()); + } +} diff --git a/lib/crates/fabro-cli/src/store.rs b/lib/crates/fabro-cli/src/store.rs deleted file mode 100644 index 8242edb8f..000000000 --- a/lib/crates/fabro-cli/src/store.rs +++ /dev/null @@ -1,29 +0,0 @@ -use std::path::Path; -use std::sync::Arc; -use std::time::Duration; - -use anyhow::Result; -use fabro_store::{RunStore, SlateStore, Store}; -use fabro_types::RunId; -use object_store::local::LocalFileSystem; - -pub(crate) fn build_store(storage_dir: &Path) -> Result> { - let store_path = storage_dir.join("store"); - std::fs::create_dir_all(&store_path)?; - let object_store = Arc::new(LocalFileSystem::new_with_prefix(&store_path)?); - Ok(Arc::new(SlateStore::new( - object_store, - "", - Duration::from_millis(5), - ))) -} - -pub(crate) async fn open_run_reader( - storage_dir: &Path, - run_id: &RunId, -) -> Result>> { - build_store(storage_dir)? - .open_run_reader(run_id) - .await - .map_err(Into::into) -} diff --git a/lib/crates/fabro-cli/src/user_config.rs b/lib/crates/fabro-cli/src/user_config.rs index 5a473637e..316f45919 100644 --- a/lib/crates/fabro-cli/src/user_config.rs +++ b/lib/crates/fabro-cli/src/user_config.rs @@ -1,96 +1,207 @@ -#[cfg(feature = "server")] -use std::path::Path; +use std::path::{Path, PathBuf}; -#[allow(unused_imports)] +use anyhow::{Result, bail}; pub(crate) use fabro_config::user::*; - -use fabro_config::ConfigLayer; -use fabro_config::FabroSettings; - -use crate::args::GlobalArgs; - -#[cfg(feature = "server")] +use fabro_types::settings::cli::CliTargetSettings; +use fabro_types::settings::{CliSettings, SettingsLayer}; +use fabro_util::version::FABRO_VERSION; +use serde::{Deserialize, Serialize}; use tracing::debug; -pub(crate) fn load_user_settings() -> anyhow::Result { - ConfigLayer::user()?.resolve() +/// Client-side TLS material for the CLI's remote server target. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub(crate) struct ClientTlsSettings { + pub cert: PathBuf, + pub key: PathBuf, + pub ca: PathBuf, } -pub(crate) fn user_layer_with_globals(globals: &GlobalArgs) -> anyhow::Result { - let layer = ConfigLayer::user()?; - Ok(apply_global_overrides(layer, globals)) +use crate::args::ServerTargetArgs; + +pub(crate) fn load_settings() -> anyhow::Result { + load_settings_with_config_and_storage_dir(None, None) } -pub(crate) fn load_user_settings_with_globals( - globals: &GlobalArgs, -) -> anyhow::Result { - user_layer_with_globals(globals)?.resolve() +pub(crate) fn settings_layer_with_config_and_storage_dir( + config_path: Option<&Path>, + storage_dir: Option<&Path>, +) -> anyhow::Result { + let layer = load_settings_config(config_path)?; + Ok(apply_storage_dir_override(layer, storage_dir)) } -pub(crate) fn apply_global_overrides(mut layer: ConfigLayer, globals: &GlobalArgs) -> ConfigLayer { - if let Some(dir) = &globals.storage_dir { - layer.storage_dir = Some(dir.clone()); - layer.mode = Some(ExecutionMode::Standalone); - } +pub(crate) fn settings_layer_with_storage_dir( + storage_dir: Option<&Path>, +) -> anyhow::Result { + settings_layer_with_config_and_storage_dir(None, storage_dir) +} - #[cfg(feature = "server")] - if let Some(url) = &globals.server_url { - layer.server.get_or_insert_with(Default::default).base_url = Some(url.clone()); - layer.mode = Some(ExecutionMode::Server); +pub(crate) fn load_settings_with_storage_dir( + storage_dir: Option<&Path>, +) -> anyhow::Result { + settings_layer_with_storage_dir(storage_dir) +} + +pub(crate) fn load_settings_with_config_and_storage_dir( + config_path: Option<&Path>, + storage_dir: Option<&Path>, +) -> anyhow::Result { + settings_layer_with_config_and_storage_dir(config_path, storage_dir) +} + +fn render_resolve_errors(errors: Vec) -> anyhow::Error { + anyhow::anyhow!( + "failed to resolve cli settings:\n{}", + errors + .into_iter() + .map(|error| error.to_string()) + .collect::>() + .join("\n") + ) +} + +pub(crate) fn resolve_cli_settings(file: &SettingsLayer) -> anyhow::Result { + fabro_config::resolve_cli_from_file(file).map_err(render_resolve_errors) +} + +pub(crate) fn apply_storage_dir_override( + mut layer: SettingsLayer, + storage_dir: Option<&Path>, +) -> SettingsLayer { + use fabro_types::settings::interp::InterpString; + use fabro_types::settings::server::{ServerLayer, ServerStorageLayer}; + if let Some(dir) = storage_dir { + let server = layer.server.get_or_insert_with(ServerLayer::default); + let storage = server + .storage + .get_or_insert_with(ServerStorageLayer::default); + storage.root = Some(InterpString::parse(&dir.display().to_string())); } layer } -#[cfg(feature = "server")] -#[derive(Debug, PartialEq)] -pub(crate) struct ResolvedMode { - pub mode: ExecutionMode, - pub server_base_url: String, - pub tls: Option, +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum ServerTarget { + HttpUrl { + api_url: String, + tls: Option, + }, + UnixSocket(PathBuf), } -#[cfg(feature = "server")] -const DEFAULT_SERVER_URL: &str = "http://localhost:3000/api/v1"; - -#[cfg(feature = "server")] -pub(crate) fn resolve_mode( - cli_storage_dir: Option<&Path>, - cli_server_url: Option<&str>, - settings: &FabroSettings, -) -> ResolvedMode { - let mode = if cli_server_url.is_some() { - ExecutionMode::Server - } else if cli_storage_dir.is_some() { - ExecutionMode::Standalone - } else { - settings.mode.clone().unwrap_or_default() - }; - - let server_defaults = settings.server.as_ref(); - - let server_base_url = cli_server_url - .map(String::from) - .or_else(|| server_defaults.and_then(|s| s.base_url.clone())) - .unwrap_or_else(|| DEFAULT_SERVER_URL.to_string()); - - let tls = server_defaults.and_then(|s| s.tls.clone()); - - debug!(mode = ?mode, base_url = %server_base_url, tls = tls.is_some(), "CLI mode resolved"); - - ResolvedMode { - mode, - server_base_url, - tls, +/// Pull the resolved CLI target configuration out of `[cli.target]`. +/// Returns `(target_string, tls)` where `target_string` is either an +/// http(s) URL or a unix socket path. +fn cli_target_from_settings(settings: &CliSettings) -> Option<(String, Option)> { + let target = settings.target.as_ref()?; + match target { + CliTargetSettings::Http { url, tls } => { + let tls_settings = tls.as_ref().map(|tls| ClientTlsSettings { + cert: PathBuf::from(tls.cert.as_source()), + key: PathBuf::from(tls.key.as_source()), + ca: PathBuf::from(tls.ca.as_source()), + }); + Some((url.as_source(), tls_settings)) + } + CliTargetSettings::Unix { path } => Some((path.as_source(), None)), } } -#[cfg(feature = "server")] +fn configured_server_target(settings: &SettingsLayer) -> Result> { + let cli_settings = resolve_cli_settings(settings)?; + let Some((value, tls)) = cli_target_from_settings(&cli_settings) else { + return Ok(None); + }; + parse_server_target(&value, tls).map(Some) +} + +pub(crate) fn default_server_target() -> ServerTarget { + ServerTarget::UnixSocket(default_socket_path()) +} + +pub(crate) fn storage_dir(settings: &SettingsLayer) -> anyhow::Result { + let resolved = fabro_config::resolve_server_from_file(settings).map_err(|errors| { + anyhow::anyhow!( + "failed to resolve server settings:\n{}", + errors + .into_iter() + .map(|error| error.to_string()) + .collect::>() + .join("\n") + ) + })?; + let resolved_root = resolved + .storage + .root + .resolve(|name| std::env::var(name).ok()) + .map_err(|err| { + anyhow::anyhow!( + "failed to resolve {}: {err}", + resolved.storage.root.as_source() + ) + })?; + Ok(PathBuf::from(resolved_root.value)) +} + +fn parse_server_target(value: &str, tls: Option) -> Result { + if value.starts_with("http://") || value.starts_with("https://") { + return Ok(ServerTarget::HttpUrl { + api_url: value.to_string(), + tls, + }); + } + + let path = Path::new(value); + if path.is_absolute() { + return Ok(ServerTarget::UnixSocket(path.to_path_buf())); + } + + bail!("server target must be an http(s) URL or absolute Unix socket path") +} + +fn explicit_server_target( + args: &ServerTargetArgs, + settings: &SettingsLayer, +) -> Result> { + let cli_settings = resolve_cli_settings(settings)?; + args.as_deref() + .map(|value| { + parse_server_target( + value, + cli_target_from_settings(&cli_settings).and_then(|(_, tls)| tls), + ) + }) + .transpose() +} + +pub(crate) fn resolve_server_target( + args: &ServerTargetArgs, + settings: &SettingsLayer, +) -> Result { + explicit_server_target(args, settings)? + .or(configured_server_target(settings)?) + .map_or_else(|| Ok(default_server_target()), Ok) +} + +pub(crate) fn exec_server_target( + args: &ServerTargetArgs, + settings: &SettingsLayer, +) -> Result> { + let target = explicit_server_target(args, settings)?; + debug!(?target, "Resolved exec server target"); + Ok(target) +} + +pub(crate) fn cli_http_client_builder() -> fabro_http::HttpClientBuilder { + fabro_http::HttpClientBuilder::new().user_agent(format!("fabro-cli/{FABRO_VERSION}")) +} + pub(crate) fn build_server_client( tls: Option<&ClientTlsSettings>, -) -> anyhow::Result { +) -> anyhow::Result { let Some(tls) = tls else { - return Ok(reqwest::Client::new()); + return Ok(cli_http_client_builder().build()?); }; let cert_path = fabro_config::expand_tilde(&tls.cert); @@ -105,10 +216,10 @@ pub(crate) fn build_server_client( identity_pem.push(b'\n'); identity_pem.extend_from_slice(&key_pem); - let identity = reqwest::Identity::from_pem(&identity_pem)?; - let ca_cert = reqwest::Certificate::from_pem(&ca_pem)?; + let identity = fabro_http::Identity::from_pem(&identity_pem)?; + let ca_cert = fabro_http::Certificate::from_pem(&ca_pem)?; - let client = reqwest::Client::builder() + let client = cli_http_client_builder() .use_rustls_tls() .identity(identity) .add_root_certificate(ca_cert) @@ -117,91 +228,193 @@ pub(crate) fn build_server_client( Ok(client) } -#[cfg(all(test, feature = "server"))] +#[cfg(test)] mod tests { - use std::path::{Path, PathBuf}; + use fabro_config::parse_settings_layer; use super::*; + use crate::args::ServerTargetArgs; - // --- resolve_mode precedence --- + fn server_target_args(value: Option<&str>) -> ServerTargetArgs { + ServerTargetArgs { + server: value.map(str::to_string), + } + } - #[test] - fn resolve_mode_defaults_to_standalone() { - let settings = FabroSettings::default(); - let resolved = resolve_mode(None, None, &settings); - assert_eq!(resolved.mode, ExecutionMode::Standalone); - assert_eq!(resolved.server_base_url, DEFAULT_SERVER_URL); - assert_eq!(resolved.tls, None); + fn parse_v2(source: &str) -> SettingsLayer { + parse_settings_layer(source).expect("fixture should parse") } #[test] - fn resolve_mode_storage_dir_forces_standalone() { - let settings = FabroSettings { - mode: Some(ExecutionMode::Server), - ..FabroSettings::default() - }; - let resolved = resolve_mode(Some(Path::new("/tmp/fabro")), None, &settings); - assert_eq!(resolved.mode, ExecutionMode::Standalone); + fn exec_has_no_server_target_by_default() { + let settings = SettingsLayer::default(); + assert_eq!( + exec_server_target(&server_target_args(None), &settings).unwrap(), + None + ); } #[test] - fn resolve_mode_server_url_forces_server() { - let settings = FabroSettings { - mode: Some(ExecutionMode::Standalone), - server: Some(ServerSettings { - base_url: Some("https://config.example.com".to_string()), - tls: None, - }), - ..FabroSettings::default() - }; - let resolved = resolve_mode(None, Some("https://cli.example.com"), &settings); - assert_eq!(resolved.mode, ExecutionMode::Server); - assert_eq!(resolved.server_base_url, "https://cli.example.com"); + fn exec_uses_cli_server_target() { + let settings = SettingsLayer::default(); + assert_eq!( + exec_server_target( + &server_target_args(Some("https://cli.example.com")), + &settings + ) + .unwrap(), + Some(ServerTarget::HttpUrl { + api_url: "https://cli.example.com".to_string(), + tls: None, + }) + ); } #[test] - fn resolve_mode_config_overrides_default() { - let settings = FabroSettings { - mode: Some(ExecutionMode::Server), - server: Some(ServerSettings { - base_url: Some("https://config.example.com".to_string()), - tls: None, - }), - ..FabroSettings::default() - }; - let resolved = resolve_mode(None, None, &settings); - assert_eq!(resolved.mode, ExecutionMode::Server); - assert_eq!(resolved.server_base_url, "https://config.example.com"); + fn exec_supports_explicit_unix_socket_target() { + let settings = SettingsLayer::default(); + assert_eq!( + exec_server_target(&server_target_args(Some("/tmp/fabro.sock")), &settings).unwrap(), + Some(ServerTarget::UnixSocket(PathBuf::from("/tmp/fabro.sock"))) + ); } #[test] - fn resolve_mode_cli_url_overrides_config_url() { - let settings = FabroSettings { - server: Some(ServerSettings { - base_url: Some("https://config.example.com".to_string()), - tls: None, - }), - ..FabroSettings::default() - }; - let resolved = resolve_mode(None, Some("https://cli.example.com"), &settings); - assert_eq!(resolved.server_base_url, "https://cli.example.com"); + fn exec_ignores_configured_server_target_without_cli_override() { + let settings = parse_v2( + r#" +_version = 1 + +[cli.target] +type = "http" +url = "https://config.example.com" +"#, + ); + assert_eq!( + exec_server_target(&server_target_args(None), &settings).unwrap(), + None + ); } #[test] - fn resolve_mode_tls_from_config() { - let tls = ClientTlsSettings { + fn resolve_server_target_uses_configured_server_target() { + let settings = parse_v2( + r#" +_version = 1 + +[cli.target] +type = "http" +url = "https://config.example.com" +"#, + ); + assert_eq!( + resolve_server_target(&server_target_args(None), &settings).unwrap(), + ServerTarget::HttpUrl { + api_url: "https://config.example.com".to_string(), + tls: None, + } + ); + } + + #[test] + fn resolve_server_target_explicit_target_overrides_config_target() { + let settings = parse_v2( + r#" +_version = 1 + +[cli.target] +type = "http" +url = "https://config.example.com" +"#, + ); + assert_eq!( + resolve_server_target( + &server_target_args(Some("https://cli.example.com")), + &settings + ) + .unwrap(), + ServerTarget::HttpUrl { + api_url: "https://cli.example.com".to_string(), + tls: None, + } + ); + } + + #[test] + fn resolve_server_target_defaults_to_default_unix_socket_target() { + let settings = SettingsLayer::default(); + assert_eq!( + resolve_server_target(&server_target_args(None), &settings).unwrap(), + ServerTarget::UnixSocket(dirs::home_dir().unwrap().join(".fabro/fabro.sock")) + ); + } + + #[test] + fn explicit_server_target_overrides_config_target() { + let settings = parse_v2( + r#" +_version = 1 + +[cli.target] +type = "http" +url = "https://config.example.com" +"#, + ); + assert_eq!( + resolve_server_target( + &server_target_args(Some("https://cli.example.com")), + &settings + ) + .unwrap(), + ServerTarget::HttpUrl { + api_url: "https://cli.example.com".to_string(), + tls: None, + } + ); + } + + #[test] + fn remote_target_uses_tls_from_config() { + let expected_tls = ClientTlsSettings { cert: PathBuf::from("cert.pem"), - key: PathBuf::from("key.pem"), - ca: PathBuf::from("ca.pem"), + key: PathBuf::from("key.pem"), + ca: PathBuf::from("ca.pem"), }; - let settings = FabroSettings { - server: Some(ServerSettings { - base_url: None, - tls: Some(tls.clone()), - }), - ..FabroSettings::default() - }; - let resolved = resolve_mode(None, None, &settings); - assert_eq!(resolved.tls, Some(tls)); + let settings = parse_v2( + r#" +_version = 1 + +[cli.target] +type = "http" +url = "https://config.example.com" + +[cli.target.tls] +cert = "cert.pem" +key = "key.pem" +ca = "ca.pem" +"#, + ); + assert_eq!( + exec_server_target( + &server_target_args(Some("https://cli.example.com")), + &settings + ) + .unwrap(), + Some(ServerTarget::HttpUrl { + api_url: "https://cli.example.com".to_string(), + tls: Some(expected_tls), + }) + ); + } + + #[test] + fn invalid_server_target_is_rejected() { + let settings = SettingsLayer::default(); + let error = + exec_server_target(&server_target_args(Some("fabro.internal")), &settings).unwrap_err(); + assert_eq!( + error.to_string(), + "server target must be an http(s) URL or absolute Unix socket path" + ); } } diff --git a/lib/crates/fabro-cli/tests/it/cmd/artifact_cp.rs b/lib/crates/fabro-cli/tests/it/cmd/artifact_cp.rs new file mode 100644 index 000000000..71faa451b --- /dev/null +++ b/lib/crates/fabro-cli/tests/it/cmd/artifact_cp.rs @@ -0,0 +1,20 @@ +use fabro_test::{fabro_snapshot, test_context}; + +use super::support::setup_completed_fast_dry_run; + +#[test] +fn artifact_cp_empty_run_reports_no_artifacts() { + let context = test_context!(); + let run = setup_completed_fast_dry_run(&context); + let dest = context.temp_dir.join("artifact-dest"); + let mut cmd = context.command(); + cmd.args(["artifact", "cp", &run.run_id, dest.to_str().unwrap()]); + + fabro_snapshot!(context.filters(), cmd, @" + success: false + exit_code: 1 + ----- stdout ----- + ----- stderr ----- + error: No artifacts found for this run + "); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/artifact_list.rs b/lib/crates/fabro-cli/tests/it/cmd/artifact_list.rs new file mode 100644 index 000000000..f2415fc16 --- /dev/null +++ b/lib/crates/fabro-cli/tests/it/cmd/artifact_list.rs @@ -0,0 +1,19 @@ +use fabro_test::{fabro_snapshot, test_context}; + +use super::support::setup_completed_fast_dry_run; + +#[test] +fn artifact_list_empty_run_reports_no_artifacts() { + let context = test_context!(); + let run = setup_completed_fast_dry_run(&context); + let mut cmd = context.command(); + cmd.args(["artifact", "list", &run.run_id]); + + fabro_snapshot!(context.filters(), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + No artifacts found for this run. + ----- stderr ----- + "); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/asset_cp.rs b/lib/crates/fabro-cli/tests/it/cmd/asset_cp.rs deleted file mode 100644 index 329d29d77..000000000 --- a/lib/crates/fabro-cli/tests/it/cmd/asset_cp.rs +++ /dev/null @@ -1,150 +0,0 @@ -use fabro_test::{fabro_snapshot, test_context}; - -use super::support::{read_text, setup_asset_run, setup_completed_dry_run, text_tree}; - -#[test] -fn help() { - let context = test_context!(); - let mut cmd = context.command(); - cmd.args(["asset", "cp", "--help"]); - fabro_snapshot!(context.filters(), cmd, @" - success: true - exit_code: 0 - ----- stdout ----- - Copy assets from a workflow run - - Usage: fabro asset cp [OPTIONS] [DEST] - - Arguments: - Source: RUN_ID (all assets) or RUN_ID:path (specific asset) - [DEST] Destination directory (defaults to current directory) [default: .] - - Options: - --json Output as JSON [env: FABRO_JSON=] - --node Filter to assets from a specific node - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] - --retry Filter to assets from a specific retry attempt - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --tree Preserve {node_slug}/retry_{N}/ directory structure - --quiet Suppress non-essential output [env: FABRO_QUIET=] - --verbose Enable verbose output [env: FABRO_VERBOSE=] - --storage-dir Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] - -h, --help Print help - ----- stderr ----- - "); -} - -#[test] -fn asset_cp_empty_run_reports_no_assets() { - let context = test_context!(); - let run = setup_completed_dry_run(&context); - let dest = context.temp_dir.join("asset-dest"); - let mut cmd = context.command(); - cmd.args(["asset", "cp", &run.run_id, dest.to_str().unwrap()]); - - fabro_snapshot!(context.filters(), cmd, @" - success: false - exit_code: 1 - ----- stdout ----- - ----- stderr ----- - error: No assets found for this run - "); -} - -#[test] -fn asset_cp_specific_path_copies_single_asset() { - let context = test_context!(); - let setup = setup_asset_run(&context); - let dest = context.temp_dir.join("asset-one"); - let mut cmd = context.command(); - cmd.args([ - "asset", - "cp", - &format!("{}:assets/shared/report.txt", setup.run.run_id), - dest.to_str().unwrap(), - "--node", - "create_assets", - ]); - - fabro_snapshot!(context.filters(), cmd, @" - success: true - exit_code: 0 - ----- stdout ----- - Copied assets/shared/report.txt to [TEMP_DIR]/asset-one/report.txt - ----- stderr ----- - "); - assert_eq!(read_text(&dest.join("report.txt")), "one"); -} - -#[test] -fn asset_cp_ambiguous_path_requires_node_or_retry() { - let context = test_context!(); - let setup = setup_asset_run(&context); - let dest = context.temp_dir.join("asset-one"); - let mut cmd = context.command(); - cmd.args([ - "asset", - "cp", - &format!("{}:assets/retry/report.txt", setup.run.run_id), - dest.to_str().unwrap(), - ]); - - fabro_snapshot!(context.filters(), cmd, @" - success: false - exit_code: 1 - ----- stdout ----- - ----- stderr ----- - error: Path 'assets/retry/report.txt' matches multiple assets: create_colliding:retry_1, retry_assets:retry_1, retry_assets:retry_2. Use --node and/or --retry to disambiguate. - "); -} - -#[test] -fn asset_cp_tree_preserves_structure() { - let context = test_context!(); - let setup = setup_asset_run(&context); - let dest = context.temp_dir.join("asset-tree"); - let mut cmd = context.command(); - cmd.args([ - "asset", - "cp", - &setup.run.run_id, - dest.to_str().unwrap(), - "--tree", - ]); - - fabro_snapshot!(context.filters(), cmd, @" - success: true - exit_code: 0 - ----- stdout ----- - Copied 6 asset(s) to [TEMP_DIR]/asset-tree - ----- stderr ----- - "); - insta::assert_snapshot!( - text_tree(&dest).join("\n"), - @r" - create_assets/retry_1/assets/node_a/summary.txt = alpha - create_assets/retry_1/assets/shared/report.txt = one - create_colliding/retry_1/assets/other/summary.txt = beta - create_colliding/retry_1/assets/retry/report.txt = second - retry_assets/retry_1/assets/retry/report.txt = first - retry_assets/retry_2/assets/retry/report.txt = second - " - ); -} - -#[test] -fn asset_cp_flat_mode_rejects_filename_collisions() { - let context = test_context!(); - let setup = setup_asset_run(&context); - let dest = context.temp_dir.join("asset-flat"); - let mut cmd = context.command(); - cmd.args(["asset", "cp", &setup.run.run_id, dest.to_str().unwrap()]); - - fabro_snapshot!(context.filters(), cmd, @" - success: false - exit_code: 1 - ----- stdout ----- - ----- stderr ----- - error: Filename collision: 'summary.txt' exists in both create_assets:retry_1 and create_colliding:retry_1. Use --tree to preserve directory structure, or --node and/or --retry to filter. - "); -} diff --git a/lib/crates/fabro-cli/tests/it/cmd/asset_list.rs b/lib/crates/fabro-cli/tests/it/cmd/asset_list.rs deleted file mode 100644 index bc5681295..000000000 --- a/lib/crates/fabro-cli/tests/it/cmd/asset_list.rs +++ /dev/null @@ -1,151 +0,0 @@ -use fabro_test::{fabro_snapshot, test_context}; - -use super::support::{setup_asset_run, setup_completed_dry_run}; - -#[test] -fn help() { - let context = test_context!(); - let mut cmd = context.command(); - cmd.args(["asset", "list", "--help"]); - fabro_snapshot!(context.filters(), cmd, @" - success: true - exit_code: 0 - ----- stdout ----- - List assets for a workflow run - - Usage: fabro asset list [OPTIONS] - - Arguments: - Run ID (or prefix) - - Options: - --json Output as JSON [env: FABRO_JSON=] - --node Filter to assets from a specific node - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] - --retry Filter to assets from a specific retry attempt - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --quiet Suppress non-essential output [env: FABRO_QUIET=] - --verbose Enable verbose output [env: FABRO_VERBOSE=] - --storage-dir Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] - -h, --help Print help - ----- stderr ----- - "); -} - -#[test] -fn asset_list_empty_run_reports_no_assets() { - let context = test_context!(); - let run = setup_completed_dry_run(&context); - let mut cmd = context.command(); - cmd.args(["asset", "list", &run.run_id]); - - fabro_snapshot!(context.filters(), cmd, @" - success: true - exit_code: 0 - ----- stdout ----- - No assets found for this run. - ----- stderr ----- - "); -} - -#[test] -fn asset_list_json_outputs_entries() { - let context = test_context!(); - let setup = setup_asset_run(&context); - let mut filters = context.filters(); - filters.push(( - r"\[STORAGE_DIR\]/runs/\d{8}-\[ULID\]".to_string(), - "[RUN_DIR]".to_string(), - )); - let mut cmd = context.command(); - cmd.args(["asset", "list", &setup.run.run_id, "--json"]); - - fabro_snapshot!(filters, cmd, @r#" - success: true - exit_code: 0 - ----- stdout ----- - [ - { - "node_slug": "create_assets", - "retry": 1, - "relative_path": "assets/node_a/summary.txt", - "absolute_path": "[RUN_DIR]/cache/artifacts/assets/create_assets/retry_1/assets/node_a/summary.txt", - "size": 5 - }, - { - "node_slug": "create_assets", - "retry": 1, - "relative_path": "assets/shared/report.txt", - "absolute_path": "[RUN_DIR]/cache/artifacts/assets/create_assets/retry_1/assets/shared/report.txt", - "size": 3 - }, - { - "node_slug": "create_colliding", - "retry": 1, - "relative_path": "assets/other/summary.txt", - "absolute_path": "[RUN_DIR]/cache/artifacts/assets/create_colliding/retry_1/assets/other/summary.txt", - "size": 4 - }, - { - "node_slug": "create_colliding", - "retry": 1, - "relative_path": "assets/retry/report.txt", - "absolute_path": "[RUN_DIR]/cache/artifacts/assets/create_colliding/retry_1/assets/retry/report.txt", - "size": 6 - }, - { - "node_slug": "retry_assets", - "retry": 1, - "relative_path": "assets/retry/report.txt", - "absolute_path": "[RUN_DIR]/cache/artifacts/assets/retry_assets/retry_1/assets/retry/report.txt", - "size": 5 - }, - { - "node_slug": "retry_assets", - "retry": 2, - "relative_path": "assets/retry/report.txt", - "absolute_path": "[RUN_DIR]/cache/artifacts/assets/retry_assets/retry_2/assets/retry/report.txt", - "size": 6 - } - ] - ----- stderr ----- - "#); -} - -#[test] -fn asset_list_filters_by_node_and_retry() { - let context = test_context!(); - let setup = setup_asset_run(&context); - let mut filters = context.filters(); - filters.push(( - r"\[STORAGE_DIR\]/runs/\d{8}-\[ULID\]".to_string(), - "[RUN_DIR]".to_string(), - )); - let mut cmd = context.command(); - cmd.args([ - "asset", - "list", - &setup.run.run_id, - "--node", - "retry_assets", - "--retry", - "2", - "--json", - ]); - - fabro_snapshot!(filters, cmd, @r#" - success: true - exit_code: 0 - ----- stdout ----- - [ - { - "node_slug": "retry_assets", - "retry": 2, - "relative_path": "assets/retry/report.txt", - "absolute_path": "[RUN_DIR]/cache/artifacts/assets/retry_assets/retry_2/assets/retry/report.txt", - "size": 6 - } - ] - ----- stderr ----- - "#); -} diff --git a/lib/crates/fabro-cli/tests/it/cmd/attach.rs b/lib/crates/fabro-cli/tests/it/cmd/attach.rs index 0bf4777c7..6ac58780e 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/attach.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/attach.rs @@ -1,63 +1,149 @@ -use std::time::Duration; +use std::io::{BufRead, BufReader, Read}; +use std::path::Path; +use std::process::{Output, Stdio}; +use std::sync::mpsc; +use std::time::{Duration, Instant}; -use fabro_test::{fabro_snapshot, run_and_format, test_context}; +use fabro_test::{apply_filters, fabro_snapshot, test_context}; use serde_json::Value; -use crate::support::{example_fixture, fabro_json_snapshot, run_output_filters}; +use super::support::{ + output_stdout, resolve_run, server_target, wait_for_status, write_gated_workflow, +}; +use crate::support::{example_fixture, fabro_json_snapshot, run_output_filters, unique_run_id}; -use super::support::{output_stdout, resolve_run, wait_for_status, write_gated_workflow}; +const SHARED_DAEMON_TIMEOUT: Duration = Duration::from_secs(30); -#[test] -fn help() { - let context = test_context!(); - let mut cmd = context.command(); - cmd.args(["attach", "--help"]); - fabro_snapshot!(context.filters(), cmd, @" - success: true - exit_code: 0 - ----- stdout ----- - Attach to a running or finished workflow run - - Usage: fabro attach [OPTIONS] - - Arguments: - Run ID prefix or workflow name - - Options: - --json Output as JSON [env: FABRO_JSON=] - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --quiet Suppress non-essential output [env: FABRO_QUIET=] - --verbose Enable verbose output [env: FABRO_VERBOSE=] - --storage-dir Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] - -h, --help Print help - ----- stderr ----- - "); +fn server_endpoint(storage_dir: &Path) -> (fabro_http::HttpClient, String) { + let target = server_target(storage_dir); + if target.starts_with('/') { + ( + fabro_http::HttpClientBuilder::new() + .unix_socket(target) + .no_proxy() + .build() + .expect("test Unix-socket HTTP client should build"), + "http://fabro".to_string(), + ) + } else { + ( + fabro_http::HttpClientBuilder::new() + .no_proxy() + .build() + .expect("test TCP HTTP client should build"), + target, + ) + } } -#[test] -fn attach_requires_run_arg() { - let context = test_context!(); - let mut cmd = context.command(); - cmd.arg("attach"); - fabro_snapshot!(context.filters(), cmd, @" - success: false - exit_code: 2 - ----- stdout ----- - ----- stderr ----- - error: the following required arguments were not provided: - +async fn wait_for_server_question( + client: &fabro_http::HttpClient, + base_url: &str, + run_id: &str, +) -> Value { + let deadline = std::time::Instant::now() + SHARED_DAEMON_TIMEOUT; + loop { + let response = client + .get(format!("{base_url}/api/v1/runs/{run_id}/questions")) + .query(&[("page[limit]", "100"), ("page[offset]", "0")]) + .send() + .await + .expect("question request should succeed"); + assert!( + response.status().is_success(), + "question request failed: {}", + response.status() + ); + let body: Value = response + .json() + .await + .expect("question response should parse"); + if let Some(question) = body["data"].as_array().and_then(|items| items.first()) { + return question.clone(); + } + assert!( + std::time::Instant::now() < deadline, + "timed out waiting for a pending question" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } +} - Usage: fabro attach --no-upgrade-check --storage-dir +fn format_output_snapshot(output: &Output, filters: &[(String, String)]) -> String { + let stdout = apply_filters(&String::from_utf8_lossy(&output.stdout), filters); + let stderr = apply_filters(&String::from_utf8_lossy(&output.stderr), filters); - For more information, try '--help'. - "); + format!( + "success: {success}\nexit_code: {code}\n----- stdout -----\n{stdout}----- stderr -----\n{stderr}", + success = output.status.success(), + code = output.status.code().unwrap_or(-1), + stdout = stdout, + stderr = stderr, + ) +} + +fn wait_for_output_signal( + child: &mut std::process::Child, + stdout: &mut impl Read, + stderr_reader: std::thread::JoinHandle>, + signal_rx: &mpsc::Receiver<()>, + needle: &str, +) -> std::thread::JoinHandle> { + let deadline = Instant::now() + SHARED_DAEMON_TIMEOUT; + let mut stderr_reader = Some(stderr_reader); + + loop { + match signal_rx.recv_timeout(Duration::from_millis(20)) { + Ok(()) => { + return stderr_reader + .take() + .expect("stderr reader should still be available"); + } + Err(mpsc::RecvTimeoutError::Timeout | mpsc::RecvTimeoutError::Disconnected) => {} + } + + if let Some(status) = child.try_wait().expect("attach should stay alive") { + let mut stdout_bytes = Vec::new(); + stdout + .read_to_end(&mut stdout_bytes) + .expect("attach stdout should be readable"); + let stderr_bytes = stderr_reader + .take() + .expect("stderr reader should still be available") + .join() + .expect("stderr reader should join"); + panic!( + "attach exited before emitting {needle:?}\nstatus: {status}\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&stdout_bytes), + String::from_utf8_lossy(&stderr_bytes) + ); + } + + if Instant::now() >= deadline { + let _ = child.kill(); + let status = child.wait().expect("attach should exit after kill"); + let mut stdout_bytes = Vec::new(); + stdout + .read_to_end(&mut stdout_bytes) + .expect("attach stdout should be readable"); + let stderr_bytes = stderr_reader + .take() + .expect("stderr reader should still be available") + .join() + .expect("stderr reader should join"); + panic!( + "timed out waiting for attach output {needle:?}\nstatus: {status}\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&stdout_bytes), + String::from_utf8_lossy(&stderr_bytes) + ); + } + } } #[test] fn attach_replays_completed_detached_run() { let context = test_context!(); - let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FAQ"; + let run_id = unique_run_id(); context .command() @@ -68,7 +154,7 @@ fn attach_replays_completed_detached_run() { "--no-retro", "--detach", "--run-id", - run_id, + run_id.as_str(), example_fixture("simple.fabro").to_str().unwrap(), ]) .assert() @@ -76,14 +162,14 @@ fn attach_replays_completed_detached_run() { context .command() - .args(["wait", run_id]) - .timeout(std::time::Duration::from_secs(10)) + .args(["wait", &run_id]) + .timeout(SHARED_DAEMON_TIMEOUT) .assert() .success(); let mut cmd = context.command(); - cmd.args(["attach", run_id]); - cmd.timeout(std::time::Duration::from_secs(10)); + cmd.args(["attach", &run_id]); + cmd.timeout(SHARED_DAEMON_TIMEOUT); fabro_snapshot!(run_output_filters(&context), cmd, @" success: true exit_code: 0 @@ -98,6 +184,10 @@ fn attach_replays_completed_detached_run() { } #[test] +#[expect( + clippy::disallowed_methods, + reason = "This sync integration test uses a dedicated stderr reader thread so the child process can stream output concurrently." +)] fn attach_before_completion_streams_to_finished_state() { let context = test_context!(); let gate = write_gated_workflow(&context.temp_dir.join("slow.fabro"), "slow", "Run slowly"); @@ -130,14 +220,65 @@ fn attach_before_completion_streams_to_finished_state() { r"\b\d+(\.\d+)?(ms|s)\b".to_string(), "[DURATION]".to_string(), )); - let release_gate = std::thread::spawn(move || { - std::thread::sleep(Duration::from_millis(300)); - gate.release(); - }); - let mut attach_cmd = context.command(); + let mut attach_cmd = std::process::Command::new(env!("CARGO_BIN_EXE_fabro")); + attach_cmd.current_dir(&context.temp_dir); + attach_cmd.env("NO_COLOR", "1"); + attach_cmd.env("HOME", &context.home_dir); + attach_cmd + .env("FABRO_NO_UPGRADE_CHECK", "true") + .env("FABRO_HTTP_PROXY_POLICY", "disabled"); + attach_cmd.env("FABRO_SERVER_MAX_CONCURRENT_RUNS", "64"); + attach_cmd.env("FABRO_TEST_IN_MEMORY_STORE", "1"); attach_cmd.args(["attach", &run_id]); - let (snapshot, _output) = run_and_format(&mut attach_cmd, &filters); - release_gate.join().expect("gate releaser should join"); + attach_cmd.stdout(Stdio::piped()); + attach_cmd.stderr(Stdio::piped()); + let mut child = attach_cmd.spawn().expect("attach should spawn"); + let mut stdout = child.stdout.take().expect("attach stdout should be piped"); + let stderr = child.stderr.take().expect("attach stderr should be piped"); + let (signal_tx, signal_rx) = mpsc::channel(); + let stderr_reader = std::thread::spawn(move || { + let mut reader = BufReader::new(stderr); + let mut stderr_bytes = Vec::new(); + let mut line = Vec::new(); + + loop { + line.clear(); + let read = reader + .read_until(b'\n', &mut line) + .expect("attach stderr should be readable"); + if read == 0 { + break; + } + if line + .windows("✓ start".len()) + .any(|window| window == "✓ start".as_bytes()) + { + let _ = signal_tx.send(()); + } + stderr_bytes.extend_from_slice(&line); + } + + stderr_bytes + }); + let stderr_reader = wait_for_output_signal( + &mut child, + &mut stdout, + stderr_reader, + &signal_rx, + "✓ start", + ); + gate.release(); + let status = child.wait().expect("attach should exit"); + let mut stdout_bytes = Vec::new(); + stdout + .read_to_end(&mut stdout_bytes) + .expect("attach stdout should be readable"); + let output = Output { + status, + stdout: stdout_bytes, + stderr: stderr_reader.join().expect("stderr reader should join"), + }; + let snapshot = format_output_snapshot(&output, &filters); wait_for_status(&run.run_dir, &["succeeded"]); insta::assert_snapshot!(snapshot, @" @@ -147,10 +288,16 @@ fn attach_before_completion_streams_to_finished_state() { ----- stderr ----- Sandbox: local (ready in [TIME]) ✓ start [DURATION] + ✓ wait [DURATION] + ✓ exit [DURATION] "); } #[test] +#[expect( + clippy::disallowed_methods, + reason = "This sync integration test polls logs for a human gate without creating a Tokio runtime." +)] fn attach_json_errors_without_prompting_for_human_input() { let context = test_context!(); let workflow = context.temp_dir.join("human-gate.fabro"); @@ -198,14 +345,30 @@ fn attach_json_errors_without_prompting_for_human_input() { scopeguard::defer! { let _ = context.command().args(["rm", "--force", &cleanup_run_id]).output(); } - let run_dir = context.find_run_dir(&run_id); - - let request_path = run_dir.join("runtime/interview_request.json"); - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); - while !request_path.exists() { + let deadline = std::time::Instant::now() + SHARED_DAEMON_TIMEOUT; + loop { + let logs_output = context + .command() + .args(["logs", &run_id, "--json"]) + .output() + .expect("logs should execute"); + assert!(logs_output.status.success(), "logs should succeed"); + let log_events: Vec = String::from_utf8(logs_output.stdout) + .expect("stdout should be UTF-8") + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str(line).expect("log line should be valid JSON")) + .collect(); + if log_events.iter().any(|event| { + event["event"] == "stage.started" + && event["node_id"] == "approve" + && event["properties"]["handler_type"] == "human" + }) { + break; + } assert!( std::time::Instant::now() < deadline, - "timed out waiting for interview request for {run_id}" + "timed out waiting for human gate to start for {run_id}" ); std::thread::sleep(std::time::Duration::from_millis(50)); } @@ -213,7 +376,7 @@ fn attach_json_errors_without_prompting_for_human_input() { let output = context .command() .args(["--json", "attach", &run_id]) - .timeout(std::time::Duration::from_secs(5)) + .timeout(SHARED_DAEMON_TIMEOUT) .output() .expect("attach should execute"); @@ -224,12 +387,34 @@ fn attach_json_errors_without_prompting_for_human_input() { !stderr.contains("Approve?"), "attach should not prompt on stderr" ); + let logs_output = context + .command() + .args(["logs", &run_id, "--json"]) + .output() + .expect("logs should execute"); + assert!(logs_output.status.success(), "logs should succeed"); + let log_events: Vec = String::from_utf8(logs_output.stdout) + .expect("stdout should be UTF-8") + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str(line).expect("log line should be valid JSON")) + .collect(); assert!( - request_path.exists(), - "the run should still be waiting on the interview request" + log_events.iter().any(|event| { + event["event"] == "stage.started" + && event["node_id"] == "approve" + && event["properties"]["handler_type"] == "human" + }), + "the run should still be waiting on the human gate" ); assert!( - !run_dir.join("runtime/interview_response.json").exists(), + !log_events.iter().any(|event| { + event["node_id"] == "approve" + && matches!( + event["event"].as_str(), + Some("stage.completed" | "stage.failed" | "interview.completed") + ) + }), "attach --json should not answer the interview" ); @@ -238,125 +423,416 @@ fn attach_json_errors_without_prompting_for_human_input() { .lines() .filter(|line| !line.trim().is_empty()) .map(|line| serde_json::from_str(line).expect("attach JSON output should be JSONL")) + .map(|mut event: Value| { + if let Some(properties) = event.get_mut("properties").and_then(Value::as_object_mut) { + if properties.contains_key("manifest_blob") { + properties.insert( + "manifest_blob".to_string(), + Value::String("[BLOB_ID]".to_string()), + ); + } + if properties.contains_key("definition_blob") { + properties.insert( + "definition_blob".to_string(), + Value::String("[BLOB_ID]".to_string()), + ); + } + } + // Strip v2-shape server/version fields that the bridge emits, + // since the test fixture's socket path is randomised per run. + if let Some(settings) = event + .pointer_mut("/properties/settings") + .and_then(Value::as_object_mut) + { + settings.remove("_version"); + settings.remove("server"); + settings.remove("version"); + } + if let Some(target) = event + .pointer_mut("/properties/settings/cli/target") + .and_then(Value::as_object_mut) + { + if target.contains_key("path") { + target.insert( + "path".to_string(), + Value::String("[CLI_SOCKET]".to_string()), + ); + } + } + event + }) .collect(); fabro_json_snapshot!(context, &progress, @r#" [ { + "event": "run.created", "id": "[EVENT_ID]", - "ts": "[TIMESTAMP]", + "properties": { + "graph": { + "attrs": { + "goal": { + "String": "Wait for approval" + } + }, + "edges": [ + { + "attrs": {}, + "from": "start", + "to": "approve" + }, + { + "attrs": { + "label": { + "String": "[A] Approve" + } + }, + "from": "approve", + "to": "ship" + }, + { + "attrs": { + "label": { + "String": "[R] Revise" + } + }, + "from": "approve", + "to": "revise" + }, + { + "attrs": {}, + "from": "ship", + "to": "exit" + }, + { + "attrs": {}, + "from": "revise", + "to": "exit" + } + ], + "name": "HumanGate", + "nodes": { + "approve": { + "attrs": { + "label": { + "String": "Approve?" + }, + "shape": { + "String": "hexagon" + } + }, + "id": "approve" + }, + "exit": { + "attrs": { + "label": { + "String": "Exit" + }, + "shape": { + "String": "Msquare" + } + }, + "id": "exit" + }, + "revise": { + "attrs": { + "script": { + "String": "echo revised" + }, + "shape": { + "String": "parallelogram" + } + }, + "id": "revise" + }, + "ship": { + "attrs": { + "script": { + "String": "echo shipped" + }, + "shape": { + "String": "parallelogram" + } + }, + "id": "ship" + }, + "start": { + "attrs": { + "label": { + "String": "Start" + }, + "shape": { + "String": "Mdiamond" + } + }, + "id": "start" + } + } + }, + "host_repo_path": "[TEMP_DIR]", + "manifest_blob": "[BLOB_ID]", + "provenance": { + "client": { + "name": "fabro-cli", + "user_agent": "fabro-cli/0.176.2", + "version": "0.176.2" + }, + "server": { + "version": "0.176.2" + }, + "subject": { + "auth_method": "disabled" + } + }, + "run_dir": "[RUN_DIR]", + "settings": { + "run": { + "execution": { + "retros": false + }, + "goal": "Wait for approval", + "model": { + "name": "gpt-5.4", + "provider": "openai" + }, + "sandbox": { + "provider": "local" + } + }, + "cli": { + "target": { + "path": "[CLI_SOCKET]", + "type": "unix" + } + } + }, + "workflow_slug": "human-gate", + "workflow_source": "digraph HumanGate {/n graph [goal=\"Wait for approval\"]/n start [shape=Mdiamond, label=\"Start\"]/n exit [shape=Msquare, label=\"Exit\"]/n approve [shape=hexagon, label=\"Approve?\"]/n ship [shape=parallelogram, script=\"echo shipped\"]/n revise [shape=parallelogram, script=\"echo revised\"]/n start -> approve/n approve -> ship [label=\"[A] Approve\"]/n approve -> revise [label=\"[R] Revise\"]/n ship -> exit/n revise -> exit/n}/n", + "working_directory": "[TEMP_DIR]" + }, "run_id": "[ULID]", + "ts": "[TIMESTAMP]" + }, + { + "event": "run.submitted", + "id": "[EVENT_ID]", + "properties": { + "definition_blob": "[BLOB_ID]" + }, + "run_id": "[ULID]", + "ts": "[TIMESTAMP]" + }, + { + "event": "run.starting", + "id": "[EVENT_ID]", + "properties": { + "reason": "sandbox_initializing" + }, + "run_id": "[ULID]", + "ts": "[TIMESTAMP]" + }, + { "event": "sandbox.initializing", + "id": "[EVENT_ID]", "properties": { "provider": "local" - } + }, + "run_id": "[ULID]", + "ts": "[TIMESTAMP]" }, { - "id": "[EVENT_ID]", - "ts": "[TIMESTAMP]", - "run_id": "[ULID]", "event": "sandbox.ready", + "id": "[EVENT_ID]", + "properties": { + "duration_ms": "[DURATION_MS]", + "provider": "local" + }, + "run_id": "[ULID]", + "ts": "[TIMESTAMP]" + }, + { + "event": "sandbox.initialized", + "id": "[EVENT_ID]", "properties": { "provider": "local", - "duration_ms": "[DURATION_MS]", - "name": null, - "cpu": null, - "memory": null, - "url": null - } - }, - { - "id": "[EVENT_ID]", - "ts": "[TIMESTAMP]", - "run_id": "[ULID]", - "event": "sandbox.initialized", - "properties": { "working_directory": "[TEMP_DIR]" - } + }, + "run_id": "[ULID]", + "ts": "[TIMESTAMP]" }, { - "id": "[EVENT_ID]", - "ts": "[TIMESTAMP]", - "run_id": "[ULID]", "event": "run.started", + "id": "[EVENT_ID]", "properties": { - "name": "HumanGate", - "goal": "Wait for approval" - } + "goal": "Wait for approval", + "name": "HumanGate" + }, + "run_id": "[ULID]", + "ts": "[TIMESTAMP]" }, { + "event": "run.running", "id": "[EVENT_ID]", - "ts": "[TIMESTAMP]", + "properties": {}, "run_id": "[ULID]", + "ts": "[TIMESTAMP]" + }, + { "event": "stage.started", + "id": "[EVENT_ID]", "node_id": "start", "node_label": "Start", "properties": { - "max_attempts": 1, "attempt": 1, + "handler_type": "start", "index": 0, - "handler_type": "start" - } + "max_attempts": 1 + }, + "run_id": "[ULID]", + "stage_id": "start@1", + "ts": "[TIMESTAMP]" }, { - "id": "[EVENT_ID]", - "ts": "[TIMESTAMP]", - "run_id": "[ULID]", "event": "stage.completed", + "id": "[EVENT_ID]", "node_id": "start", "node_label": "Start", "properties": { - "max_attempts": 1, "attempt": 1, - "index": 0, + "context_values": { + "current.preamble": "Goal: Wait for approval/n", + "current_node": "start", + "graph.goal": "Wait for approval", + "internal.fidelity": "compact", + "internal.node_visit_count": 1, + "internal.run_id": "[ULID]", + "internal.thread_id": null + }, "duration_ms": "[DURATION_MS]", - "status": "success", - "preferred_label": null, - "suggested_next_ids": [], - "usage": null, - "notes": null, - "files_touched": [] - } + "index": 0, + "max_attempts": 1, + "node_visits": { + "start": 1 + }, + "status": "success" + }, + "run_id": "[ULID]", + "stage_id": "start@1", + "ts": "[TIMESTAMP]" }, { - "id": "[EVENT_ID]", - "ts": "[TIMESTAMP]", - "run_id": "[ULID]", "event": "edge.selected", + "id": "[EVENT_ID]", "properties": { "from_node": "start", - "to_node": "approve", - "label": null, - "condition": null, + "is_jump": false, "reason": "unconditional", "stage_status": "success", - "is_jump": false - } + "to_node": "approve" + }, + "run_id": "[ULID]", + "ts": "[TIMESTAMP]" }, { - "id": "[EVENT_ID]", - "ts": "[TIMESTAMP]", - "run_id": "[ULID]", "event": "checkpoint.completed", + "id": "[EVENT_ID]", "node_id": "start", "node_label": "start", "properties": { + "completed_nodes": [ + "start" + ], + "context_values": { + "current_node": "start", + "failure_class": "", + "failure_signature": "", + "graph.goal": "Wait for approval", + "internal.fidelity": "compact", + "internal.node_visit_count": 1, + "internal.retry_count.start": 0, + "internal.run_id": "[ULID]", + "internal.thread_id": null, + "outcome": "success" + }, + "current_node": "start", + "next_node_id": "approve", + "node_outcomes": { + "start": { + "status": "success", + "usage": null + } + }, + "node_visits": { + "start": 1 + }, "status": "success" - } + }, + "run_id": "[ULID]", + "stage_id": "start@1", + "ts": "[TIMESTAMP]" }, { - "id": "[EVENT_ID]", - "ts": "[TIMESTAMP]", - "run_id": "[ULID]", "event": "stage.started", + "id": "[EVENT_ID]", "node_id": "approve", "node_label": "Approve?", "properties": { - "max_attempts": 1, "attempt": 1, + "handler_type": "human", "index": 1, - "handler_type": "human" - } + "max_attempts": 1 + }, + "run_id": "[ULID]", + "stage_id": "approve@1", + "ts": "[TIMESTAMP]" + }, + { + "event": "interview.started", + "id": "[EVENT_ID]", + "node_id": "approve", + "node_label": "approve", + "properties": { + "allow_freeform": false, + "options": [ + { + "key": "A", + "label": "[A] Approve" + }, + { + "key": "R", + "label": "[R] Revise" + } + ], + "question": "Approve?", + "question_id": "[ULID]", + "question_type": "multiple_choice", + "stage": "approve" + }, + "run_id": "[ULID]", + "stage_id": "approve@1", + "ts": "[TIMESTAMP]" } ] "#); + + let run = resolve_run(&context, &run_id); + tokio::runtime::Runtime::new() + .expect("test runtime should build") + .block_on(async { + let (client, base_url) = server_endpoint(&context.storage_dir); + let question = wait_for_server_question(&client, &base_url, &run_id).await; + let question_id = question["id"] + .as_str() + .expect("question id should be present"); + + let response = client + .post(format!( + "{base_url}/api/v1/runs/{run_id}/questions/{question_id}/answer" + )) + .json(&serde_json::json!({ "selected_option_key": "A" })) + .send() + .await + .expect("answer submission should succeed"); + assert_eq!(response.status(), fabro_http::StatusCode::NO_CONTENT); + }); + wait_for_status(&run.run_dir, &["succeeded"]); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/completion.rs b/lib/crates/fabro-cli/tests/it/cmd/completion.rs deleted file mode 100644 index c3c819aed..000000000 --- a/lib/crates/fabro-cli/tests/it/cmd/completion.rs +++ /dev/null @@ -1,45 +0,0 @@ -use fabro_test::{fabro_snapshot, test_context}; - -#[test] -fn help() { - let context = test_context!(); - let mut cmd = context.command(); - cmd.args(["completion", "--help"]); - fabro_snapshot!(context.filters(), cmd, @" - success: true - exit_code: 0 - ----- stdout ----- - Generate shell completions - - Usage: fabro completion [OPTIONS] - - Arguments: - Shell to generate completions for [possible values: bash, elvish, fish, powershell, zsh] - - Options: - --json Output as JSON [env: FABRO_JSON=] - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --quiet Suppress non-essential output [env: FABRO_QUIET=] - --verbose Enable verbose output [env: FABRO_VERBOSE=] - --storage-dir Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] - -h, --help Print help - ----- stderr ----- - "); -} - -#[test] -fn generates_zsh_completions() { - let context = test_context!(); - let mut cmd = context.command(); - cmd.args(["completion", "zsh"]); - cmd.assert().success(); -} - -#[test] -fn generates_fish_completions() { - let context = test_context!(); - let mut cmd = context.command(); - cmd.args(["completion", "fish"]); - cmd.assert().success(); -} diff --git a/lib/crates/fabro-cli/tests/it/cmd/config.rs b/lib/crates/fabro-cli/tests/it/cmd/config.rs index c9dc6d4a7..22e89b974 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/config.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/config.rs @@ -1,39 +1,13 @@ use std::path::PathBuf; -use fabro_config::FabroSettings; -use fabro_config::mcp::McpTransport; -#[cfg(feature = "server")] -use fabro_config::user::ExecutionMode; +use fabro_config::parse_settings_layer; use fabro_test::{fabro_snapshot, test_context}; +use fabro_types::settings::SettingsLayer; +use httpmock::MockServer; use predicates::prelude::*; -#[test] -fn help() { - let context = test_context!(); - let mut cmd = context.settings(); - cmd.arg("--help"); - fabro_snapshot!(context.filters(), cmd, @" - success: true - exit_code: 0 - ----- stdout ----- - Inspect merged configuration - - Usage: fabro settings [OPTIONS] [WORKFLOW] - - Arguments: - [WORKFLOW] Optional workflow name, .fabro path, or .toml run config to overlay - - Options: - --json Output as JSON [env: FABRO_JSON=] - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --quiet Suppress non-essential output [env: FABRO_QUIET=] - --verbose Enable verbose output [env: FABRO_VERBOSE=] - --storage-dir Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] - -h, --help Print help - ----- stderr ----- - "); -} +use super::support::run_state; +use crate::support::unique_run_id; #[test] fn old_config_show_command_is_rejected() { @@ -57,117 +31,247 @@ fn old_config_show_command_is_rejected() { // Helpers // --------------------------------------------------------------------------- -fn parse_settings(stdout: &[u8]) -> FabroSettings { - serde_yaml::from_slice(stdout).expect("stdout should be valid YAML FabroSettings") +fn parse_settings(stdout: &[u8]) -> SettingsLayer { + serde_yaml::from_slice(stdout).expect("stdout should be valid YAML SettingsLayer") +} + +fn resolve_cli(settings: &SettingsLayer) -> fabro_types::settings::CliSettings { + fabro_config::resolve_cli_from_file(settings).expect("cli settings should resolve") +} + +fn resolve_project(settings: &SettingsLayer) -> fabro_types::settings::ProjectSettings { + fabro_config::resolve_project_from_file(settings).expect("project settings should resolve") +} + +fn resolve_run(settings: &SettingsLayer) -> fabro_types::settings::RunSettings { + fabro_config::resolve_run_from_file(settings).expect("run settings should resolve") +} + +fn resolve_server(settings: &SettingsLayer) -> fabro_types::settings::ServerSettings { + fabro_config::resolve_server_from_file(settings).expect("server settings should resolve") +} + +fn run_goal_inline(settings: &SettingsLayer) -> Option { + match resolve_run(settings).goal { + Some(fabro_types::settings::run::RunGoal::Inline(value)) => Some(value.as_source()), + _ => None, + } +} + +fn run_model_name(settings: &SettingsLayer) -> Option { + resolve_run(settings) + .model + .name + .as_ref() + .map(fabro_types::settings::InterpString::as_source) +} + +fn run_model_provider(settings: &SettingsLayer) -> Option { + resolve_run(settings) + .model + .provider + .as_ref() + .map(fabro_types::settings::InterpString::as_source) +} + +fn run_inputs(settings: &SettingsLayer) -> &std::collections::HashMap { + settings + .run + .as_ref() + .and_then(|run| run.inputs.as_ref()) + .expect("run.inputs") +} + +fn run_sandbox(settings: &SettingsLayer) -> &fabro_types::settings::run::RunSandboxLayer { + settings + .run + .as_ref() + .and_then(|run| run.sandbox.as_ref()) + .expect("run.sandbox") +} + +fn run_checkpoint(settings: &SettingsLayer) -> &fabro_types::settings::run::RunCheckpointLayer { + settings + .run + .as_ref() + .and_then(|run| run.checkpoint.as_ref()) + .expect("run.checkpoint") +} + +fn run_hooks(settings: &SettingsLayer) -> &[fabro_types::settings::run::HookEntry] { + settings + .run + .as_ref() + .map_or(&[], |run| run.hooks.as_slice()) +} + +fn run_agent_mcps( + settings: &SettingsLayer, +) -> &std::collections::HashMap { + settings + .run + .as_ref() + .and_then(|run| run.agent.as_ref()) + .map(|agent| &agent.mcps) + .expect("run.agent.mcps") +} + +fn auto_approve_enabled(settings: &SettingsLayer) -> bool { + resolve_run(settings).execution.approval == fabro_types::settings::run::ApprovalMode::Auto +} + +fn run_prepare_commands(settings: &SettingsLayer) -> Vec { + resolve_run(settings).prepare.commands +} + +fn server_storage_root(settings: &SettingsLayer) -> String { + resolve_server(settings).storage.root.as_source() +} + +fn server_settings_fixture() -> SettingsLayer { + parse_settings_layer( + r#" +_version = 1 + +[server.storage] +root = "/srv/fabro-server" + +[run.model] +name = "server-model" +provider = "openai" + +[run.inputs] +server_only = "1" +shared = "server" +"#, + ) + .expect("server settings fixture should parse") +} + +fn server_settings_body(settings: &SettingsLayer) -> String { + serde_json::to_string(settings).expect("settings payload should serialize") } /// Set up home config and project config for settings command tests. /// Uses `context.home_dir` for the home directory. Returns project tempdir. fn setup_settings_fixture(context: &fabro_test::TestContext) -> tempfile::TempDir { context.write_home( - ".fabro/user.toml", + ".fabro/settings.toml", r#" -verbose = true +_version = 1 -[llm] -model = "cli-model" +[cli.output] +verbosity = "verbose" + +[run.model] +name = "cli-model" provider = "openai" -[vars] +[run.inputs] cli_only = "1" shared = "cli" -[checkpoint] +[run.checkpoint] exclude_globs = ["cli-only", "shared"] -[[hooks]] +[[run.hooks]] +id = "shared" name = "shared" event = "run_start" -command = "echo cli" +script = "echo cli" -[mcp_servers.shared] +[run.agent.mcps.shared] type = "stdio" command = ["echo", "cli"] -[sandbox] +[run.sandbox] provider = "daytona" -[sandbox.daytona] -labels = { cli_only = "1", shared = "cli" } - -[sandbox.env] +[run.sandbox.env] CLI_ONLY = "1" SHARED = "cli" + +[run.sandbox.daytona] + +[run.sandbox.daytona.labels] +cli_only = "1" +shared = "cli" "#, ); let project = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(project.path().join(".fabro")).unwrap(); std::fs::write( - project.path().join("fabro.toml"), + project.path().join(".fabro/project.toml"), r#" -version = 1 +_version = 1 -[fabro] -root = "fabro" +[run.model] +name = "project-model" -[llm] -model = "project-model" - -[vars] +[run.inputs] project_only = "1" shared = "project" -[[hooks]] +[[run.hooks]] +id = "project" name = "project" event = "run_complete" -command = "echo project" +script = "echo project" "#, ) .unwrap(); - let workflow_dir = project.path().join("fabro").join("workflows").join("demo"); + let workflow_dir = project.path().join(".fabro").join("workflows").join("demo"); std::fs::create_dir_all(&workflow_dir).unwrap(); std::fs::write( workflow_dir.join("workflow.toml"), r#" -version = 1 +_version = 1 + +[run] goal = "demo goal" -[llm] -model = "run-model" +[run.model] +name = "run-model" provider = "anthropic" -[vars] +[run.inputs] run_only = "1" shared = "run" -[checkpoint] +[run.checkpoint] exclude_globs = ["run-only", "shared"] -[[hooks]] +[[run.hooks]] +id = "shared" name = "shared" event = "run_start" -command = "echo run" +script = "echo run" -[[hooks]] +[[run.hooks]] +id = "run-only" name = "run-only" event = "run_complete" -command = "echo run-only" +script = "echo run-only" -[mcp_servers.shared] +[run.agent.mcps.shared] type = "stdio" command = ["echo", "run"] -[mcp_servers.run_only] +[run.agent.mcps.run_only] type = "stdio" command = ["echo", "run-only"] -[sandbox.daytona] -labels = { run_only = "1", shared = "run" } - -[sandbox.env] +[run.sandbox.env] RUN_ONLY = "1" SHARED = "run" + +[run.sandbox.daytona] + +[run.sandbox.daytona.labels] +run_only = "1" +shared = "run" "#, ) .unwrap(); @@ -181,37 +285,44 @@ SHARED = "run" project } -/// Set up an external workflow fixture with a custom storage_dir in user.toml. -/// Returns (project_tempdir, storage_dir_path). +/// Set up an external workflow fixture with a custom storage_dir in +/// settings.toml. Returns (project_tempdir, storage_dir_path). fn setup_external_workflow_fixture( - context: &fabro_test::TestContext, + context: &mut fabro_test::TestContext, ) -> (tempfile::TempDir, PathBuf) { let storage_dir = context.home_dir.join("fabro-data"); + context.manage_storage_dir(&storage_dir); context.write_home( - ".fabro/user.toml", + ".fabro/settings.toml", format!( r#" -storage_dir = "{}" -auto_approve = true +_version = 1 -[setup] -commands = ["cli-setup"] +[server.storage] +root = "{}" + +[run.execution] +approval = "auto" + +[[run.prepare.steps]] +script = "cli-setup" "#, storage_dir.display() ), ); let project = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(project.path().join(".fabro")).unwrap(); std::fs::write( - project.path().join("fabro.toml"), + project.path().join(".fabro/project.toml"), r#" -version = 1 +_version = 1 -[setup] -commands = ["project-setup"] +[[run.prepare.steps]] +script = "project-setup" -[sandbox] +[run.sandbox] preserve = true "#, ) @@ -232,15 +343,19 @@ digraph Test { std::fs::write( project.path().join("workflow.toml"), r#" -version = 1 -goal = "Ship it" +_version = 1 + +[workflow] graph = "workflow.fabro" -[llm] -model = "claude-sonnet-4-6" +[run] +goal = "Ship it" -[setup] -commands = ["workflow-setup"] +[run.model] +name = "claude-sonnet-4-6" + +[[run.prepare.steps]] +script = "workflow-setup" "#, ) .unwrap(); @@ -253,12 +368,13 @@ commands = ["workflow-setup"] // --------------------------------------------------------------------------- #[test] -fn settings_merges_cli_and_project_defaults() { +fn settings_local_merges_cli_and_project_defaults() { let context = test_context!(); let project = setup_settings_fixture(&context); let output = context .settings() + .arg("--local") .current_dir(project.path()) .assert() .success() @@ -267,36 +383,39 @@ fn settings_merges_cli_and_project_defaults() { .clone(); let cfg = parse_settings(&output); - let llm = cfg.llm.as_ref().expect("llm config"); - assert_eq!(llm.model.as_deref(), Some("project-model")); - assert_eq!(llm.provider.as_deref(), Some("openai")); - assert_eq!(cfg.goal.as_deref(), None); - assert_eq!(cfg.fabro.as_ref().map(|f| f.root.as_str()), Some("fabro")); + assert_eq!(run_model_name(&cfg).as_deref(), Some("project-model")); + assert_eq!(run_model_provider(&cfg).as_deref(), Some("openai")); + assert_eq!(run_goal_inline(&cfg).as_deref(), None); + assert_eq!(resolve_project(&cfg).directory, "."); - let vars = cfg.vars.as_ref().expect("vars"); - assert_eq!(vars.get("cli_only").map(String::as_str), Some("1")); - assert_eq!(vars.get("project_only").map(String::as_str), Some("1")); - assert_eq!(vars.get("shared").map(String::as_str), Some("project")); + // v2 R22: run.inputs replaces the inherited map wholesale rather than + // merging by key, so the project layer wipes out the CLI layer's inputs. + let vars = run_inputs(&cfg); + assert_eq!(vars.get("project_only").and_then(|v| v.as_str()), Some("1")); + assert_eq!(vars.get("shared").and_then(|v| v.as_str()), Some("project")); + assert!( + !vars.contains_key("cli_only"), + "run.inputs should replace across layers, not merge by key" + ); - let sandbox = cfg.sandbox.as_ref().expect("sandbox"); - let labels = sandbox - .daytona - .as_ref() - .and_then(|d| d.labels.as_ref()) - .expect("daytona labels"); + // v2 R71: provider-native maps such as run.sandbox.daytona.labels remain + // sticky merge-by-key, so CLI labels persist under the project layer. + let sandbox = run_sandbox(&cfg); + let labels = &sandbox.daytona.as_ref().expect("daytona").labels; assert_eq!(labels.get("cli_only").map(String::as_str), Some("1")); assert_eq!(labels.get("shared").map(String::as_str), Some("cli")); } #[test] -fn settings_workflow_name_applies_run_overlay_and_deep_merges() { +fn settings_local_workflow_name_applies_run_overlay_and_deep_merges() { + use fabro_types::settings::run::McpEntryLayer; let context = test_context!(); let project = setup_settings_fixture(&context); let output = context .settings() .current_dir(project.path()) - .args(["demo"]) + .args(["--local", "demo"]) .assert() .success() .get_output() @@ -304,79 +423,101 @@ fn settings_workflow_name_applies_run_overlay_and_deep_merges() { .clone(); let cfg = parse_settings(&output); - let llm = cfg.llm.as_ref().expect("llm config"); - assert_eq!(cfg.goal.as_deref(), Some("demo goal")); - assert_eq!(llm.model.as_deref(), Some("run-model")); - assert_eq!(llm.provider.as_deref(), Some("anthropic")); + assert_eq!(run_goal_inline(&cfg).as_deref(), Some("demo goal")); + assert_eq!(run_model_name(&cfg).as_deref(), Some("run-model")); + assert_eq!(run_model_provider(&cfg).as_deref(), Some("anthropic")); - let vars = cfg.vars.as_ref().expect("vars"); - assert_eq!(vars.get("cli_only").map(String::as_str), Some("1")); - assert_eq!(vars.get("project_only").map(String::as_str), Some("1")); - assert_eq!(vars.get("run_only").map(String::as_str), Some("1")); - assert_eq!(vars.get("shared").map(String::as_str), Some("run")); + // v2 R22: run.inputs replaces wholesale, so the workflow layer wins + // over project and cli. + let vars = run_inputs(&cfg); + assert_eq!(vars.get("run_only").and_then(|v| v.as_str()), Some("1")); + assert_eq!(vars.get("shared").and_then(|v| v.as_str()), Some("run")); - assert_eq!( - cfg.checkpoint.exclude_globs, - vec![ - "cli-only".to_string(), - "run-only".to_string(), - "shared".to_string() - ] - ); + // checkpoint.exclude_globs is a security/policy list: replace by default. + let checkpoint = run_checkpoint(&cfg); + assert_eq!(checkpoint.exclude_globs, vec![ + "run-only".to_string(), + "shared".to_string() + ]); - assert_eq!(cfg.hooks.len(), 3); - let shared_hook = cfg - .hooks + // Hooks: id-based replacement. The "shared" hook appears in both cli and + // workflow layers and resolves to the workflow entry; project and run-only + // contribute the other two ids. + let hooks = run_hooks(&cfg); + assert!(hooks.len() >= 2); + let shared_hook = hooks .iter() .find(|hook| hook.name.as_deref() == Some("shared")) .expect("shared hook"); - assert_eq!(shared_hook.command.as_deref(), Some("echo run")); - assert!( - cfg.hooks - .iter() - .any(|hook| hook.name.as_deref() == Some("project")) + assert_eq!( + shared_hook + .script + .as_ref() + .map(fabro_types::settings::InterpString::as_source) + .as_deref(), + Some("echo run") ); assert!( - cfg.hooks + hooks .iter() .any(|hook| hook.name.as_deref() == Some("run-only")) ); - match &cfg.mcp_servers["shared"].transport { - McpTransport::Stdio { command, .. } => assert_eq!(command, &vec!["echo", "run"]), + let mcps = run_agent_mcps(&cfg); + match mcps.get("shared").expect("shared mcp") { + McpEntryLayer::Stdio { command, .. } => { + let command = command.as_ref().expect("command"); + let parts: Vec = command + .iter() + .map(fabro_types::settings::InterpString::as_source) + .collect(); + assert_eq!(parts, vec!["echo".to_string(), "run".to_string()]); + } other => panic!("unexpected MCP transport: {other:?}"), } - assert!(cfg.mcp_servers.contains_key("run_only")); + assert!(mcps.contains_key("run_only")); - let sandbox = cfg.sandbox.as_ref().expect("sandbox"); - let labels = sandbox - .daytona - .as_ref() - .and_then(|d| d.labels.as_ref()) - .expect("daytona labels"); - assert_eq!(labels.get("cli_only").map(String::as_str), Some("1")); + // run.sandbox.daytona.labels stays sticky merge-by-key per R71. + let sandbox = run_sandbox(&cfg); + let labels = &sandbox.daytona.as_ref().expect("daytona").labels; assert_eq!(labels.get("run_only").map(String::as_str), Some("1")); assert_eq!(labels.get("shared").map(String::as_str), Some("run")); - let env = sandbox.env.as_ref().expect("sandbox env"); - assert_eq!(env.get("CLI_ONLY").map(String::as_str), Some("1")); - assert_eq!(env.get("RUN_ONLY").map(String::as_str), Some("1")); - assert_eq!(env.get("SHARED").map(String::as_str), Some("run")); + // run.sandbox.env stays sticky merge-by-key per R71. + let env = &sandbox.env; + assert_eq!( + env.get("CLI_ONLY") + .map(fabro_types::settings::InterpString::as_source) + .as_deref(), + Some("1") + ); + assert_eq!( + env.get("RUN_ONLY") + .map(fabro_types::settings::InterpString::as_source) + .as_deref(), + Some("1") + ); + assert_eq!( + env.get("SHARED") + .map(fabro_types::settings::InterpString::as_source) + .as_deref(), + Some("run") + ); } #[test] -fn settings_explicit_workflow_path_uses_workflow_project_layers() { - let context = test_context!(); - let (project, _storage_dir) = setup_external_workflow_fixture(&context); +fn settings_local_explicit_workflow_path_uses_workflow_project_layers() { + let mut context = test_context!(); + let (project, _storage_dir) = setup_external_workflow_fixture(&mut context); let cwd = tempfile::tempdir().unwrap(); let workflow = project.path().join("workflow.toml"); - // Remove FABRO_STORAGE_DIR so the CLI uses storage_dir from user.toml + // Remove FABRO_STORAGE_DIR so the CLI uses storage_dir from settings.toml let output = context .settings() .env_remove("FABRO_STORAGE_DIR") .current_dir(cwd.path()) - .args([workflow.to_str().unwrap()]) + .args(["--local", workflow.to_str().unwrap()]) .assert() .success() .get_output() @@ -384,30 +525,24 @@ fn settings_explicit_workflow_path_uses_workflow_project_layers() { .clone(); let cfg = parse_settings(&output); - assert_eq!(cfg.auto_approve, Some(true)); - assert_eq!( - cfg.setup.as_ref().expect("setup config").commands, - vec![ - "workflow-setup".to_string(), - "project-setup".to_string(), - "cli-setup".to_string(), - ] - ); - assert_eq!( - cfg.sandbox.as_ref().expect("sandbox config").preserve, - Some(true) - ); + assert!(auto_approve_enabled(&cfg)); + // v2 R30: run.prepare.steps replaces the whole ordered list across layers. + // The highest-precedence layer (workflow) wins. + assert_eq!(run_prepare_commands(&cfg), vec![ + "workflow-setup".to_string() + ]); + assert_eq!(run_sandbox(&cfg).preserve, Some(true)); } #[test] fn create_explicit_workflow_path_uses_project_config_relative_to_workflow() { - let context = test_context!(); - let (project, storage_dir) = setup_external_workflow_fixture(&context); + let mut context = test_context!(); + let (project, storage_dir) = setup_external_workflow_fixture(&mut context); let cwd = tempfile::tempdir().unwrap(); let workflow = project.path().join("workflow.toml"); - let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FB8"; + let run_id = unique_run_id(); - // Remove FABRO_STORAGE_DIR so the CLI uses storage_dir from user.toml + // Remove FABRO_STORAGE_DIR so the CLI uses storage_dir from settings.toml context .command() .env_remove("FABRO_STORAGE_DIR") @@ -418,13 +553,13 @@ fn create_explicit_workflow_path_uses_project_config_relative_to_workflow() { "--model", "gpt-5.2", "--run-id", - run_id, + run_id.as_str(), workflow.to_str().unwrap(), ]) .assert() .success(); - let runs_dir = storage_dir.join("runs"); + let runs_dir = storage_dir.join("scratch"); let run_dir = std::fs::read_dir(&runs_dir) .unwrap() .flatten() @@ -433,7 +568,7 @@ fn create_explicit_workflow_path_uses_project_config_relative_to_workflow() { path.is_dir() && path .file_name() - .is_some_and(|name| name.to_string_lossy().ends_with(run_id)) + .is_some_and(|name| name.to_string_lossy().ends_with(&run_id)) }) .unwrap_or_else(|| { panic!( @@ -442,24 +577,29 @@ fn create_explicit_workflow_path_uses_project_config_relative_to_workflow() { ) }); - let run_record: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(run_dir.join("run.json")).unwrap()).unwrap(); - assert_eq!(run_record["settings"]["auto_approve"].as_bool(), Some(true)); + let state = run_state(&run_dir); + let run_record = + serde_json::to_value(state.run.as_ref().expect("run record should exist")).unwrap(); assert_eq!( - run_record["settings"]["storage_dir"].as_str(), + run_record["settings"]["run"]["execution"]["approval"].as_str(), + Some("auto") + ); + assert_eq!( + run_record["settings"]["server"]["storage"]["root"].as_str(), Some(storage_dir.to_str().unwrap()) ); assert_eq!( - run_record["settings"]["sandbox"]["preserve"].as_bool(), + run_record["settings"]["run"]["sandbox"]["preserve"].as_bool(), Some(true) ); assert_eq!( - run_record["settings"]["llm"]["model"].as_str(), + run_record["settings"]["run"]["model"]["name"].as_str(), Some("gpt-5.2") ); + // v2 R30: run.prepare.steps replaces the whole ordered list across layers. assert_eq!( - run_record["settings"]["setup"]["commands"], - serde_json::json!(["workflow-setup", "project-setup", "cli-setup"]) + run_record["settings"]["run"]["prepare"]["steps"], + serde_json::json!([{"script": "workflow-setup"}]) ); } @@ -470,6 +610,7 @@ fn settings_fabro_path_matches_ambient_defaults() { let ambient = context .settings() + .arg("--local") .current_dir(project.path()) .assert() .success() @@ -479,7 +620,7 @@ fn settings_fabro_path_matches_ambient_defaults() { let graph = context .settings() .current_dir(project.path()) - .args(["standalone.fabro"]) + .args(["--local", "standalone.fabro"]) .assert() .success() .get_output() @@ -496,14 +637,19 @@ fn settings_missing_run_config_errors() { let mut cmd = context.settings(); cmd.current_dir(project.path()); - cmd.args(["missing.toml"]); - fabro_snapshot!(context.filters(), cmd, @" - success: false - exit_code: 1 - ----- stdout ----- - ----- stderr ----- - error: Workflow not found: missing.toml - "); + cmd.args(["--local", "missing.toml"]); + let output = cmd.output().expect("command should execute"); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stdout).trim().is_empty()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("workflow not found:"), + "stderr should report missing workflow path, got:\n{stderr}" + ); + assert!( + stderr.contains("missing.toml"), + "stderr should include missing workflow filename, got:\n{stderr}" + ); } #[test] @@ -514,15 +660,19 @@ fn settings_legacy_cli_config_warns_and_ignores_it() { context.write_home( ".fabro/cli.toml", r#" -verbose = true +_version = 1 -[llm] -model = "legacy-model" +[cli.output] +verbosity = "verbose" + +[run.model] +name = "legacy-model" "#, ); let assert = context .settings() + .arg("--local") .current_dir(project.path()) .assert() .success() @@ -530,8 +680,16 @@ model = "legacy-model" .stderr(predicate::str::contains("Rename it to")); let cfg = parse_settings(&assert.get_output().stdout); - assert_eq!(cfg.verbose, None); - assert_eq!(cfg.llm, None); + assert_eq!( + resolve_cli(&cfg).output.verbosity, + fabro_types::settings::cli::OutputVerbosity::Normal + ); + assert!( + cfg.run + .as_ref() + .and_then(|run| run.model.as_ref()) + .is_none() + ); } #[test] @@ -541,62 +699,243 @@ fn settings_user_config_wins_over_legacy_cli_config() { context.write_home( ".fabro/cli.toml", r#" -[llm] -model = "legacy-model" +_version = 1 -[vars] +[run.model] +name = "legacy-model" + +[run.inputs] shared = "legacy" "#, ); let assert = context .settings() + .arg("--local") .current_dir(project.path()) .assert() .success() .stderr(predicate::str::contains("ignoring legacy config file")); let cfg = parse_settings(&assert.get_output().stdout); - let llm = cfg.llm.as_ref().expect("llm config"); - assert_eq!(llm.model.as_deref(), Some("project-model")); + assert_eq!(run_model_name(&cfg).as_deref(), Some("project-model")); + let vars = run_inputs(&cfg); + assert_eq!(vars.get("shared").and_then(|v| v.as_str()), Some("project")); +} + +#[test] +fn settings_uses_fabro_home_for_home_config_resolution() { + let context = test_context!(); + let fabro_home = tempfile::tempdir().unwrap(); + + std::fs::write( + fabro_home.path().join("settings.toml"), + r#" +_version = 1 + +[cli.output] +verbosity = "verbose" + +[run.model] +name = "from-fabro-home" +"#, + ) + .unwrap(); + + let output = context + .settings() + .args(["--local", "--json"]) + .env("FABRO_HOME", fabro_home.path()) + .env_remove("FABRO_STORAGE_DIR") + .output() + .expect("command should execute"); + + assert!( + output.status.success(), + "settings command failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + + let cfg: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(cfg["cli"]["output"]["verbosity"].as_str(), Some("verbose")); assert_eq!( - cfg.vars - .as_ref() - .and_then(|vars| vars.get("shared").map(String::as_str)), - Some("project") + cfg["run"]["model"]["name"].as_str(), + Some("from-fabro-home") ); } #[test] -#[cfg(feature = "server")] -fn settings_server_url_overrides_cli_defaults() { +fn settings_rejects_server_url_flag() { + let context = test_context!(); + context + .command() + .args(["--server-url", "https://cli.example.com", "settings"]) + .assert() + .failure() + .stderr(predicate::str::contains( + "unexpected argument '--server-url' found", + )); +} + +#[test] +fn settings_rejects_storage_dir_flag() { + let context = test_context!(); + context + .settings() + .args(["--storage-dir", "/tmp/fabro-settings"]) + .assert() + .failure() + .stderr(predicate::str::contains( + "unexpected argument '--storage-dir' found", + )); +} + +#[test] +fn settings_rejects_local_and_server_combination() { + let context = test_context!(); + context + .settings() + .args(["--local", "--server", "https://cli.example.com"]) + .assert() + .failure() + .stderr(predicate::str::contains( + "the argument '--local' cannot be used with '--server '", + )); +} + +#[test] +fn settings_fetches_server_settings_and_merges_with_local_config() { let context = test_context!(); let project = setup_settings_fixture(&context); - let user_toml_path = context.home_dir.join(".fabro/user.toml"); - let existing = std::fs::read_to_string(&user_toml_path).unwrap(); + let server = MockServer::start(); + let server_settings = server_settings_fixture(); + let mock = server.mock(|when, then| { + when.method("GET").path("/api/v1/settings"); + then.status(200) + .header("Content-Type", "application/json") + .body(server_settings_body(&server_settings)); + }); context.write_home( - ".fabro/user.toml", + ".fabro/settings.toml", format!( - "{existing}\nmode = \"standalone\"\n[server]\nbase_url = \"https://config.example.com\"\n" + r#" +_version = 1 + +[cli.target] +type = "http" +url = "{}/api/v1" + +[cli.output] +verbosity = "verbose" + +[run.model] +name = "cli-model" +provider = "openai" + +[run.inputs] +cli_only = "1" +shared = "cli" +"#, + server.base_url() ), ); let output = context - .command() + .settings() .current_dir(project.path()) - .args(["--server-url", "https://cli.example.com", "settings"]) .assert() .success() .get_output() .stdout .clone(); + mock.assert(); let cfg = parse_settings(&output); - assert_eq!(cfg.mode, Some(ExecutionMode::Server)); + assert_eq!(run_model_name(&cfg).as_deref(), Some("project-model")); + assert_eq!(run_model_provider(&cfg).as_deref(), Some("openai")); + assert_eq!(server_storage_root(&cfg), "/srv/fabro-server"); assert_eq!( - cfg.server - .as_ref() - .and_then(|server| server.base_url.as_deref()), - Some("https://cli.example.com") + resolve_cli(&cfg).output.verbosity, + fabro_types::settings::cli::OutputVerbosity::Verbose + ); + + // R22: run.inputs replaces wholesale across layers. Project is the + // highest-precedence layer that sets inputs, so project's vars win + // and server-side vars are discarded rather than merged. + let vars = run_inputs(&cfg); + assert_eq!(vars.get("project_only").and_then(|v| v.as_str()), Some("1")); + assert_eq!(vars.get("shared").and_then(|v| v.as_str()), Some("project")); + assert!( + !vars.contains_key("server_only"), + "v2 merge matrix replaces run.inputs wholesale; server_only should be dropped" ); } + +#[test] +fn settings_cli_server_target_overrides_configured_server_target() { + let context = test_context!(); + let project = setup_settings_fixture(&context); + let configured_server = MockServer::start(); + let configured_mock = configured_server.mock(|when, then| { + when.method("GET").path("/api/v1/settings"); + then.status(500) + .body("configured-server-should-not-be-used"); + }); + let cli_server = MockServer::start(); + let cli_server_settings = server_settings_fixture(); + let cli_mock = cli_server.mock(|when, then| { + when.method("GET").path("/api/v1/settings"); + then.status(200) + .header("Content-Type", "application/json") + .body(server_settings_body(&cli_server_settings)); + }); + context.write_home( + ".fabro/settings.toml", + format!( + r#" +_version = 1 + +[cli.target] +type = "http" +url = "{}/api/v1" + +[cli.output] +verbosity = "verbose" +"#, + configured_server.base_url() + ), + ); + + let output = context + .settings() + .current_dir(project.path()) + .args(["--server", &format!("{}/api/v1", cli_server.base_url())]) + .assert() + .success() + .get_output() + .stdout + .clone(); + + cli_mock.assert(); + configured_mock.assert_calls(0); + let cfg = parse_settings(&output); + assert_eq!(server_storage_root(&cfg), "/srv/fabro-server"); +} + +#[test] +fn settings_unreachable_http_target_fails_clearly() { + let context = test_context!(); + let project = setup_settings_fixture(&context); + + context + .settings() + .current_dir(project.path()) + .args(["--server", "http://127.0.0.1:9"]) + .assert() + .failure() + .stderr( + predicate::str::contains("retrieve_server_settings") + .or(predicate::str::contains("error sending request")), + ); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/create.rs b/lib/crates/fabro-cli/tests/it/cmd/create.rs index 8f6835b44..7441032cc 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/create.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/create.rs @@ -1,11 +1,24 @@ +use fabro_test::{fabro_snapshot, test_context}; +use httpmock::MockServer; use insta::assert_snapshot; use serde_json::json; -use fabro_test::{fabro_snapshot, test_context}; +use super::support::{fixture, output_stdout, resolve_run, run_count_for_test_case, run_state}; +use crate::support::{fabro_json_snapshot, unique_run_id}; -use crate::support::{fabro_json_snapshot, read_json}; +fn resolved_run( + settings: &fabro_types::settings::SettingsLayer, +) -> fabro_types::settings::RunSettings { + fabro_config::resolve_run_from_file(settings).expect("run settings should resolve") +} -use super::support::{fixture, output_stdout, resolve_run}; +fn run_status_response(run_id: &str, status: &str) -> serde_json::Value { + serde_json::json!({ + "id": run_id, + "status": status, + "created_at": "2026-04-05T12:00:00Z" + }) +} #[test] fn help() { @@ -24,32 +37,169 @@ fn help() { Path to a .fabro workflow file or .toml task config Options: - --dry-run Execute with simulated LLM backend - --json Output as JSON [env: FABRO_JSON=] - --auto-approve Auto-approve all human gates - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] - --goal Override the workflow goal (exposed as $goal in prompts) - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --goal-file Read the workflow goal from a file - --quiet Suppress non-essential output [env: FABRO_QUIET=] - --model Override default LLM model - --provider Override default LLM provider - --storage-dir Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] - -v, --verbose Enable verbose output - --sandbox Sandbox for agent tools [possible values: local, docker, daytona] - --label Attach a label to this run (repeatable, format: KEY=VALUE) - --no-retro Skip retro generation after the run - --preserve-sandbox Keep the sandbox alive after the run finishes (for debugging) - -d, --detach Run the workflow in the background and print the run ID - -h, --help Print help + --json Output as JSON [env: FABRO_JSON=] + --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --dry-run Execute with simulated LLM backend + --auto-approve Auto-approve all human gates + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --goal Override the workflow goal (available as {{ goal }} in prompts) + --quiet Suppress non-essential output [env: FABRO_QUIET=] + --goal-file Read the workflow goal from a file + --model Override default LLM model + --provider Override default LLM provider + -v, --verbose Enable verbose output + --sandbox Sandbox for agent tools [possible values: local, docker, daytona] + --label Attach a label to this run (repeatable, format: KEY=VALUE) + --no-retro Skip retro generation after the run + --preserve-sandbox Keep the sandbox alive after the run finishes (for debugging) + -d, --detach Run the workflow in the background and print the run ID + -h, --help Print help ----- stderr ----- "); } +#[test] +fn create_uses_explicit_server_target_and_prints_remote_run_id() { + let context = test_context!(); + let server = MockServer::start(); + let run_id = unique_run_id(); + let mock = server.mock(|when, then| { + when.method("POST").path("/api/v1/runs"); + then.status(201) + .header("Content-Type", "application/json") + .body(run_status_response(run_id.as_str(), "submitted").to_string()); + }); + + let output = context + .create_cmd() + .args([ + "--server", + &format!("{}/api/v1", server.base_url()), + "--dry-run", + fixture("simple.fabro").to_str().unwrap(), + ]) + .output() + .expect("command should execute"); + + assert!( + output.status.success(), + "command failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + mock.assert(); + assert_eq!(output_stdout(&output).trim(), run_id.as_str()); +} + +#[test] +fn create_uses_configured_server_target_without_server_flag() { + let context = test_context!(); + let server = MockServer::start(); + let run_id = unique_run_id(); + let mock = server.mock(|when, then| { + when.method("POST").path("/api/v1/runs"); + then.status(201) + .header("Content-Type", "application/json") + .body(run_status_response(run_id.as_str(), "submitted").to_string()); + }); + context.write_home( + ".fabro/settings.toml", + format!( + "_version = 1\n\n[cli.target]\ntype = \"http\"\nurl = \"{}/api/v1\"\n", + server.base_url() + ), + ); + + let output = context + .create_cmd() + .args(["--dry-run", fixture("simple.fabro").to_str().unwrap()]) + .output() + .expect("command should execute"); + + assert!( + output.status.success(), + "command failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + mock.assert(); + assert_eq!(output_stdout(&output).trim(), run_id.as_str()); +} + +#[test] +fn create_rejects_storage_dir_flag() { + let context = test_context!(); + let output = context + .create_cmd() + .args([ + "--storage-dir", + "/tmp/fabro-create", + "--dry-run", + fixture("simple.fabro").to_str().unwrap(), + ]) + .output() + .expect("command should execute"); + + assert!( + !output.status.success(), + "command should reject --storage-dir" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("unexpected argument '--storage-dir'")); +} + +#[test] +fn create_cli_server_target_overrides_configured_server_target() { + let context = test_context!(); + let config_server = MockServer::start(); + let config_mock = config_server.mock(|when, then| { + when.method("POST").path("/api/v1/runs"); + then.status(500) + .body("configured-server-should-not-be-used"); + }); + let cli_server = MockServer::start(); + let run_id = unique_run_id(); + let cli_mock = cli_server.mock(|when, then| { + when.method("POST").path("/api/v1/runs"); + then.status(201) + .header("Content-Type", "application/json") + .body(run_status_response(run_id.as_str(), "submitted").to_string()); + }); + context.write_home( + ".fabro/settings.toml", + format!( + "_version = 1\n\n[cli.target]\ntype = \"http\"\nurl = \"{}/api/v1\"\n", + config_server.base_url() + ), + ); + + let output = context + .create_cmd() + .args([ + "--server", + &format!("{}/api/v1", cli_server.base_url()), + "--dry-run", + fixture("simple.fabro").to_str().unwrap(), + ]) + .output() + .expect("command should execute"); + + assert!( + output.status.success(), + "command failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + cli_mock.assert(); + config_mock.assert_calls(0); + assert_eq!(output_stdout(&output).trim(), run_id.as_str()); +} + #[test] fn create_persists_directory_workflow_slug_and_cached_graph() { let context = test_context!(); - let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FAA"; + let run_id = unique_run_id(); let workflow_path = context.temp_dir.join("sluggy/workflow.fabro"); context.write_temp( @@ -70,21 +220,21 @@ digraph BarBaz { "--dry-run", "--auto-approve", "--run-id", - run_id, + run_id.as_str(), workflow_path.to_str().unwrap(), ]) .assert() .success(); - let run_dir = context.find_run_dir(run_id); - let run_record = read_json(run_dir.join("run.json")); - let cached_graph = std::fs::read_to_string(run_dir.join("workflow.fabro")).unwrap(); + let run_dir = context.find_run_dir(&run_id); + let state = run_state(&run_dir); + let run = state.run.as_ref().expect("run record should exist"); fabro_json_snapshot!( context, serde_json::json!({ - "workflow_slug": run_record["workflow_slug"], - "graph_name": run_record["graph"]["name"], - "cached_graph_lines": cached_graph.lines().collect::>(), + "workflow_slug": run.workflow_slug, + "graph_name": run.graph.name, + "cached_graph_lines": state.graph_source.as_ref().expect("graph should exist").lines().collect::>(), }), @r#" { @@ -105,7 +255,7 @@ digraph BarBaz { #[test] fn create_persists_file_stem_slug_for_standalone_file() { let context = test_context!(); - let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FAB"; + let run_id = unique_run_id(); let workflow_path = context.temp_dir.join("alpha.fabro"); context.write_temp( @@ -126,21 +276,21 @@ digraph FooWorkflow { "--dry-run", "--auto-approve", "--run-id", - run_id, + run_id.as_str(), workflow_path.to_str().unwrap(), ]) .assert() .success(); - let run_dir = context.find_run_dir(run_id); - let run_record = read_json(run_dir.join("run.json")); - let cached_graph = std::fs::read_to_string(run_dir.join("workflow.fabro")).unwrap(); + let run_dir = context.find_run_dir(&run_id); + let state = run_state(&run_dir); + let run = state.run.as_ref().expect("run record should exist"); fabro_json_snapshot!( context, serde_json::json!({ - "workflow_slug": run_record["workflow_slug"], - "graph_name": run_record["graph"]["name"], - "cached_graph_lines": cached_graph.lines().collect::>(), + "workflow_slug": run.workflow_slug, + "graph_name": run.graph.name, + "cached_graph_lines": state.graph_source.as_ref().expect("graph should exist").lines().collect::>(), }), @r#" { @@ -159,7 +309,7 @@ digraph FooWorkflow { } #[test] -fn create_persists_requested_overrides_into_run_json() { +fn create_persists_requested_overrides_into_store() { let context = test_context!(); let workflow = fixture("simple.fabro"); let mut cmd = context.command(); @@ -200,26 +350,33 @@ fn create_persists_requested_overrides_into_run_json() { .expect("create should print a run ID") .to_string(); let run = resolve_run(&context, &run_id); - let run_json = read_json(run.run_dir.join("run.json")); + let state = run_state(&run.run_dir); + let run_record = state.run.as_ref().expect("run record should exist"); let labels = json!({ - "env": run_json.pointer("/labels/env"), - "team": run_json.pointer("/labels/team"), + "env": run_record.labels.get("env"), + "team": run_record.labels.get("team"), }); + let settings = &run_record.settings; + let resolved_run = resolved_run(settings); + let cli_settings = fabro_config::resolve_cli_from_file(settings).expect("cli settings"); let compact = json!({ - "workflow_slug": run_json["workflow_slug"], + "workflow_slug": run_record.workflow_slug, "settings": { - "goal": run_json.pointer("/settings/goal"), - "dry_run": run_json.pointer("/settings/dry_run"), - "auto_approve": run_json.pointer("/settings/auto_approve"), - "no_retro": run_json.pointer("/settings/no_retro"), - "verbose": run_json.pointer("/settings/verbose"), + "goal": match resolved_run.goal.as_ref() { + Some(fabro_types::settings::run::RunGoal::Inline(value)) => Some(value.as_source()), + _ => None, + }, + "dry_run": resolved_run.execution.mode == fabro_types::settings::run::RunMode::DryRun, + "auto_approve": resolved_run.execution.approval == fabro_types::settings::run::ApprovalMode::Auto, + "no_retro": !resolved_run.execution.retros, + "verbose": cli_settings.output.verbosity == fabro_types::settings::cli::OutputVerbosity::Verbose, "llm": { - "model": run_json.pointer("/settings/llm/model"), - "provider": run_json.pointer("/settings/llm/provider"), + "model": resolved_run.model.name.as_ref().map(fabro_types::settings::InterpString::as_source), + "provider": resolved_run.model.provider.as_ref().map(fabro_types::settings::InterpString::as_source), }, "sandbox": { - "provider": run_json.pointer("/settings/sandbox/provider"), - "preserve": run_json.pointer("/settings/sandbox/preserve"), + "provider": resolved_run.sandbox.provider, + "preserve": resolved_run.sandbox.preserve, }, }, "labels": labels, @@ -274,11 +431,18 @@ fn create_json_implies_auto_approve() { .as_str() .expect("create JSON should include run_id"); let run = resolve_run(&context, run_id); - let run_json = read_json(run.run_dir.join("run.json")); - assert_eq!( - run_json.pointer("/settings/auto_approve"), - Some(&json!(true)) + assert!( + resolved_run( + &run_state(&run.run_dir) + .run + .as_ref() + .expect("run record should exist") + .settings, + ) + .execution + .approval + == fabro_types::settings::run::ApprovalMode::Auto ); } @@ -286,8 +450,9 @@ fn create_json_implies_auto_approve() { fn create_invalid_workflow_fails_without_creating_run() { let context = test_context!(); let workflow = fixture("invalid.fabro"); - let mut cmd = context.command(); - cmd.args(["create", workflow.to_str().unwrap()]); + let initial_run_count = run_count_for_test_case(&context); + let mut cmd = context.create_cmd(); + cmd.arg(workflow.to_str().unwrap()); fabro_snapshot!(context.filters(), cmd, @" success: false @@ -297,10 +462,9 @@ fn create_invalid_workflow_fails_without_creating_run() { error: Validation failed "); - let runs_dir = context.storage_dir.join("runs"); - let run_count = std::fs::read_dir(&runs_dir) - .ok() - .map(|entries| entries.flatten().count()) - .unwrap_or(0); - assert_eq!(run_count, 0, "invalid create should not persist a run"); + let run_count = run_count_for_test_case(&context); + assert_eq!( + run_count, initial_run_count, + "invalid create should not persist a run for this test case" + ); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/detached.rs b/lib/crates/fabro-cli/tests/it/cmd/detached.rs deleted file mode 100644 index e201f5912..000000000 --- a/lib/crates/fabro-cli/tests/it/cmd/detached.rs +++ /dev/null @@ -1,268 +0,0 @@ -use fabro_test::{fabro_snapshot, test_context}; - -use crate::support::{fabro_json_snapshot, read_json}; - -#[test] -fn help() { - let context = test_context!(); - let mut cmd = context.command(); - cmd.args(["__detached", "--help"]); - fabro_snapshot!(context.filters(), cmd, @" - success: true - exit_code: 0 - ----- stdout ----- - Internal: run the engine process (reads run.json from run dir) - - Usage: fabro __detached [OPTIONS] --run-dir --launcher-path - - Options: - --json Output as JSON [env: FABRO_JSON=] - --run-dir Run directory - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] - --launcher-path Launcher metadata path - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --resume Resume from checkpoint instead of fresh start - --quiet Suppress non-essential output [env: FABRO_QUIET=] - --verbose Enable verbose output [env: FABRO_VERBOSE=] - --storage-dir Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] - -h, --help Print help - ----- stderr ----- - "); -} - -fn launcher_path(context: &fabro_test::TestContext, run_id: &str) -> std::path::PathBuf { - context - .storage_dir - .join("launchers") - .join(format!("{run_id}.json")) -} - -#[test] -fn detached_uses_cached_graph_after_source_deleted() { - let context = test_context!(); - let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FAF"; - let workflow_path = context.temp_dir.join("workflow.fabro"); - - context.write_temp( - "workflow.fabro", - "\ -digraph CachedGraph { - start [shape=Mdiamond, label=\"Start\"] - exit [shape=Msquare, label=\"Exit\"] - start -> exit -} -", - ); - - context - .command() - .args([ - "create", - "--dry-run", - "--auto-approve", - "--run-id", - run_id, - workflow_path.to_str().unwrap(), - ]) - .assert() - .success(); - - let run_dir = context.find_run_dir(run_id); - std::fs::remove_file(&workflow_path).unwrap(); - - context - .command() - .args([ - "__detached", - "--run-dir", - run_dir.to_str().unwrap(), - "--launcher-path", - launcher_path(&context, run_id).to_str().unwrap(), - ]) - .timeout(std::time::Duration::from_secs(15)) - .assert() - .success(); - - let conclusion = read_json(run_dir.join("conclusion.json")); - fabro_json_snapshot!( - context, - serde_json::json!({ - "status": conclusion["status"], - }), - @r#" - { - "status": "success" - } - "# - ); -} - -#[test] -fn detached_uses_snapshotted_app_id_for_github_credentials() { - let context = test_context!(); - let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FAG"; - let workflow_path = context.temp_dir.join("workflow.fabro"); - - context.write_home( - ".fabro/user.toml", - "\ -version = 1 - -[git] -app_id = \"snapshotted-app-id\" -", - ); - context.write_temp( - "workflow.fabro", - "\ -digraph GitHubApp { - start [shape=Mdiamond, label=\"Start\"] - exit [shape=Msquare, label=\"Exit\"] - start -> exit -} -", - ); - - context - .command() - .args([ - "create", - "--dry-run", - "--auto-approve", - "--run-id", - run_id, - workflow_path.to_str().unwrap(), - ]) - .assert() - .success(); - - let run_dir = context.find_run_dir(run_id); - let run_record = read_json(run_dir.join("run.json")); - fabro_json_snapshot!( - context, - serde_json::json!({ - "app_id": run_record["settings"]["git"]["app_id"], - }), - @r#" - { - "app_id": "snapshotted-app-id" - } - "# - ); - - context.write_home(".fabro/user.toml", "version = 1\n"); - - let mut cmd = context.command(); - cmd.env("GITHUB_APP_PRIVATE_KEY", "%%%not-base64%%%"); - cmd.args([ - "__detached", - "--run-dir", - run_dir.to_str().unwrap(), - "--launcher-path", - launcher_path(&context, run_id).to_str().unwrap(), - ]); - cmd.timeout(std::time::Duration::from_secs(10)); - fabro_snapshot!(context.filters(), cmd, @" - success: false - exit_code: 1 - ----- stdout ----- - ----- stderr ----- - error: GITHUB_APP_PRIVATE_KEY is not valid PEM or base64: Invalid symbol 37, offset 0. - "); -} - -#[test] -fn detached_resume_rejects_completed_run_without_mutating_it() { - let context = test_context!(); - context.write_temp( - "workflow.fabro", - "\ -digraph Test { - start [shape=Mdiamond, label=\"Start\"] - exit [shape=Msquare, label=\"Exit\"] - start -> exit -} -", - ); - - let run = context - .command() - .args([ - "run", - "--dry-run", - "--auto-approve", - "--no-retro", - "--detach", - context.temp_dir.join("workflow.fabro").to_str().unwrap(), - ]) - .assert() - .success(); - let run_id = String::from_utf8(run.get_output().stdout.clone()) - .unwrap() - .trim() - .to_string(); - - context - .command() - .args(["wait", &run_id]) - .timeout(std::time::Duration::from_secs(10)) - .assert() - .success(); - - let inspect_before = context - .command() - .args(["inspect", &run_id]) - .assert() - .success(); - let before: serde_json::Value = - serde_json::from_slice(&inspect_before.get_output().stdout).unwrap(); - let before_summary = serde_json::json!({ - "run_dir": before[0]["run_dir"], - "start_time": before[0]["start_record"]["start_time"], - "conclusion_timestamp": before[0]["conclusion"]["timestamp"], - "conclusion_status": before[0]["conclusion"]["status"], - }); - let run_dir = before_summary["run_dir"].as_str().unwrap().to_string(); - fabro_json_snapshot!(context, &before_summary, @r#" - { - "run_dir": "[DRY_RUN_DIR]", - "start_time": "[TIMESTAMP]", - "conclusion_timestamp": "[TIMESTAMP]", - "conclusion_status": "success" - } - "#); - - let mut cmd = context.command(); - cmd.args([ - "__detached", - "--run-dir", - &run_dir, - "--launcher-path", - launcher_path(&context, &run_id).to_str().unwrap(), - "--resume", - ]); - cmd.timeout(std::time::Duration::from_secs(10)); - fabro_snapshot!(context.filters(), cmd, @" - success: false - exit_code: 1 - ----- stdout ----- - ----- stderr ----- - error: Precondition failed: run already finished successfully — nothing to resume - "); - - let inspect_after = context - .command() - .args(["inspect", &run_id]) - .assert() - .success(); - let after: serde_json::Value = - serde_json::from_slice(&inspect_after.get_output().stdout).unwrap(); - let after_summary = serde_json::json!({ - "run_dir": after[0]["run_dir"], - "start_time": after[0]["start_record"]["start_time"], - "conclusion_timestamp": after[0]["conclusion"]["timestamp"], - "conclusion_status": after[0]["conclusion"]["status"], - }); - - assert_eq!(after_summary, before_summary); -} diff --git a/lib/crates/fabro-cli/tests/it/cmd/diff.rs b/lib/crates/fabro-cli/tests/it/cmd/diff.rs index 9a59fa499..af9d0ea82 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/diff.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/diff.rs @@ -19,16 +19,14 @@ fn help() { Run ID or prefix Options: - --json Output as JSON [env: FABRO_JSON=] - --node Show diff for a specific node - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] - --stat Show diffstat instead of full patch (live diffs only) - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --shortstat Show only files-changed/insertions/deletions summary (live diffs only) - --quiet Suppress non-essential output [env: FABRO_QUIET=] - --verbose Enable verbose output [env: FABRO_VERBOSE=] - --storage-dir Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] - -h, --help Print help + --json Output as JSON [env: FABRO_JSON=] + --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --node Show diff for a specific node + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --quiet Suppress non-essential output [env: FABRO_QUIET=] + --verbose Enable verbose output [env: FABRO_VERBOSE=] + -h, --help Print help ----- stderr ----- "); } @@ -45,7 +43,7 @@ fn diff_completed_run_without_changes_reports_no_patch() { exit_code: 1 ----- stdout ----- ----- stderr ----- - error: Run completed but no final.patch exists — the run may not have produced any changes + error: Run completed but no stored diff exists — the run may not have produced any changes "); } @@ -62,7 +60,6 @@ fn diff_missing_node_diff_reports_helpful_error() { ----- stdout ----- ----- stderr ----- error: No diff found for node 'missing' — check the node ID and try again - > No such file or directory (os error 2) "); } @@ -89,6 +86,31 @@ fn diff_completed_run_with_changes_prints_patch() { "); } +#[test] +fn diff_completed_run_reads_store_final_patch_without_disk_file() { + let context = test_context!(); + let setup = setup_git_backed_changed_run(&context); + let _ = std::fs::remove_file(setup.run.run_dir.join("final.patch")); + + let mut cmd = context.command(); + cmd.args(["diff", &setup.run.run_id]); + + fabro_snapshot!(git_filters(&context), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + diff --git a/story.txt b/story.txt + index [SHA]..[SHA] 100644 + --- a/story.txt + +++ b/story.txt + @@ -1 +1,3 @@ + line 1 + +line 2 + +line 3 + ----- stderr ----- + "); +} + #[test] fn diff_node_outputs_specific_patch() { let context = test_context!(); @@ -110,3 +132,26 @@ fn diff_node_outputs_specific_patch() { ----- stderr ----- "); } + +#[test] +fn diff_node_reads_store_patch_without_disk_file() { + let context = test_context!(); + let setup = setup_git_backed_changed_run(&context); + + let mut cmd = context.command(); + cmd.args(["diff", &setup.run.run_id, "--node", "step_one"]); + + fabro_snapshot!(git_filters(&context), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + diff --git a/story.txt b/story.txt + index [SHA]..[SHA] 100644 + --- a/story.txt + +++ b/story.txt + @@ -1 +1,2 @@ + line 1 + +line 2 + ----- stderr ----- + "); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/discord.rs b/lib/crates/fabro-cli/tests/it/cmd/discord.rs index 4bb3195dd..b2315cf6a 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/discord.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/discord.rs @@ -14,13 +14,12 @@ fn help() { Usage: fabro discord [OPTIONS] Options: - --json Output as JSON [env: FABRO_JSON=] - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --quiet Suppress non-essential output [env: FABRO_QUIET=] - --verbose Enable verbose output [env: FABRO_VERBOSE=] - --storage-dir Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] - -h, --help Print help + --json Output as JSON [env: FABRO_JSON=] + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --quiet Suppress non-essential output [env: FABRO_QUIET=] + --verbose Enable verbose output [env: FABRO_VERBOSE=] + -h, --help Print help ----- stderr ----- "); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/docs.rs b/lib/crates/fabro-cli/tests/it/cmd/docs.rs index d1af75144..b21695665 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/docs.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/docs.rs @@ -14,13 +14,12 @@ fn help() { Usage: fabro docs [OPTIONS] Options: - --json Output as JSON [env: FABRO_JSON=] - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --quiet Suppress non-essential output [env: FABRO_QUIET=] - --verbose Enable verbose output [env: FABRO_VERBOSE=] - --storage-dir Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] - -h, --help Print help + --json Output as JSON [env: FABRO_JSON=] + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --quiet Suppress non-essential output [env: FABRO_QUIET=] + --verbose Enable verbose output [env: FABRO_VERBOSE=] + -h, --help Print help ----- stderr ----- "); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/doctor.rs b/lib/crates/fabro-cli/tests/it/cmd/doctor.rs index 0547e67e5..553af0aeb 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/doctor.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/doctor.rs @@ -1,7 +1,8 @@ +#![allow(clippy::absolute_paths)] + use std::process::Output; use fabro_test::{fabro_snapshot, test_context, twin_openai}; -use predicates::prelude::*; async fn run_success_output(mut cmd: assert_cmd::Command) -> Output { tokio::task::spawn_blocking(move || cmd.assert().success().get_output().clone()) @@ -23,51 +24,32 @@ fn help() { Usage: fabro doctor [OPTIONS] Options: - --json Output as JSON [env: FABRO_JSON=] - -v, --verbose Show detailed information for each check - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] - --dry-run Skip live service probes (LLM, sandbox, API, web, Brave Search) - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --quiet Suppress non-essential output [env: FABRO_QUIET=] - --storage-dir Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] - -h, --help Print help + --json Output as JSON [env: FABRO_JSON=] + --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + -v, --verbose Show detailed information for each check + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --quiet Suppress non-essential output [env: FABRO_QUIET=] + -h, --help Print help ----- stderr ----- "); } #[test] -fn dry_run_flag() { +fn dry_run_flag_is_rejected() { let context = test_context!(); let mut cmd = context.doctor(); cmd.arg("--dry-run"); - cmd.env( - "PATH", - "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin", - ); - cmd.env("ANTHROPIC_API_KEY", "sk-test-dummy"); fabro_snapshot!(context.filters(), cmd, @" - success: true - exit_code: 0 + success: false + exit_code: 2 ----- stdout ----- - Fabro Doctor - - Required - [!] Configuration (no user config file found) - [✓] LLM providers (1 configured) - [!] GitHub App (not configured) - - Optional - [!] Cloud sandbox (no sandbox configured) - [!] Brave Search (not configured) - - Found issues in 4 categories. - - Warnings: - • Configuration — Create ~/.fabro/user.toml - • GitHub App — Configure GitHub App in server.toml and set env vars to enable GitHub integration - • Cloud sandbox — Set DAYTONA_API_KEY to enable cloud sandbox execution - • Brave Search — Set BRAVE_SEARCH_API_KEY to enable web search ----- stderr ----- + error: unexpected argument '--dry-run' found + + Usage: fabro doctor [OPTIONS] + + For more information, try '--help'. "); } @@ -87,7 +69,8 @@ async fn twin_doctor() { cmd.env_clear(); cmd.env("NO_COLOR", "1"); cmd.env("HOME", &context.home_dir); - cmd.env("FABRO_NO_UPGRADE_CHECK", "true"); + cmd.env("FABRO_NO_UPGRADE_CHECK", "true") + .env("FABRO_HTTP_PROXY_POLICY", "disabled"); cmd.env("FABRO_STORAGE_DIR", &context.storage_dir); cmd.env( "PATH", @@ -102,13 +85,3 @@ async fn twin_doctor() { "expected verbose doctor output to include openai probe success, got: {stdout}" ); } - -#[test] -fn doctor_no_color_when_no_color_set() { - let context = test_context!(); - let mut cmd = context.doctor(); - cmd.arg("--dry-run"); - cmd.env_clear(); - cmd.env("NO_COLOR", "1"); - cmd.assert().stdout(predicate::str::contains("\x1b[").not()); -} diff --git a/lib/crates/fabro-cli/tests/it/cmd/exec.rs b/lib/crates/fabro-cli/tests/it/cmd/exec.rs index f0a20f9fb..4cc2ab30b 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/exec.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/exec.rs @@ -1,6 +1,9 @@ +#![allow(clippy::absolute_paths)] + use std::process::Output; use fabro_test::{fabro_snapshot, test_context}; +use httpmock::MockServer; async fn run_success_output(mut cmd: assert_cmd::Command) -> Output { tokio::task::spawn_blocking(move || cmd.assert().success().get_output().clone()) @@ -26,14 +29,14 @@ fn help() { Options: --json Output as JSON [env: FABRO_JSON=] + --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] --provider LLM provider (anthropic, openai, gemini, kimi, zai, minimax, inception) --model Model name (defaults per provider) --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] --permissions Permission level for tool execution [possible values: read-only, read-write, full] - --auto-approve Skip interactive prompts; deny tools outside permission level --quiet Suppress non-essential output [env: FABRO_QUIET=] + --auto-approve Skip interactive prompts; deny tools outside permission level --debug Print LLM request/response debug info to stderr - --storage-dir Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] --verbose Print full LLM request/response JSON to stderr --skills-dir Directory containing skill files (overrides default discovery) --output-format Output format (text for human-readable, json for NDJSON event stream) [possible values: text, json] @@ -70,7 +73,7 @@ fn no_prompt() { error: the following required arguments were not provided: - Usage: fabro exec --no-upgrade-check --storage-dir + Usage: fabro exec --no-upgrade-check For more information, try '--help'. "); @@ -96,8 +99,8 @@ fn exec_missing_api_key_exits_with_error() { fn exec_uses_user_config_defaults() { let context = test_context!(); context.write_home( - ".fabro/user.toml", - "[exec]\nprovider = \"openai\"\nmodel = \"gpt-4.1-mini\"\npermissions = \"read-only\"\noutput_format = \"json\"\n", + ".fabro/settings.toml", + "_version = 1\n\n[cli.exec.model]\nprovider = \"openai\"\nname = \"gpt-4.1-mini\"\n\n[cli.exec.agent]\npermissions = \"read-only\"\n\n[cli.output]\nformat = \"json\"\n", ); let mut cmd = context.exec_cmd(); @@ -105,7 +108,8 @@ fn exec_uses_user_config_defaults() { cmd.env_clear(); cmd.env("HOME", &context.home_dir); cmd.env("FABRO_STORAGE_DIR", &context.storage_dir); - cmd.env("FABRO_NO_UPGRADE_CHECK", "true"); + cmd.env("FABRO_NO_UPGRADE_CHECK", "true") + .env("FABRO_HTTP_PROXY_POLICY", "disabled"); fabro_snapshot!(context.filters(), cmd, @" success: false @@ -116,6 +120,135 @@ fn exec_uses_user_config_defaults() { "); } +#[test] +fn exec_server_target_uses_remote_transport_instead_of_local_api_key_resolution() { + let context = test_context!(); + let server = MockServer::start(); + server.mock(|when, then| { + when.method("POST").path("/api/v1/completions"); + // Use a non-retriable error so this test covers transport routing + // without paying the retry backoff cost of a 5xx response. + then.status(400).body("server-routed-marker"); + }); + + let mut cmd = context.exec_cmd(); + cmd.env_clear(); + cmd.env("HOME", &context.home_dir); + cmd.env("FABRO_NO_UPGRADE_CHECK", "true") + .env("FABRO_HTTP_PROXY_POLICY", "disabled"); + cmd.args([ + "--server", + &format!("{}/api/v1", server.base_url()), + "--provider", + "openai", + "--model", + "gpt-5.4-mini", + "test prompt", + ]); + + let output = cmd.assert().failure().get_output().clone(); + let stderr = String::from_utf8(output.stderr).expect("valid utf8"); + assert!( + stderr.contains("server-routed-marker"), + "expected remote server failure marker, got: {stderr}" + ); + assert!( + !stderr.contains("API key not set"), + "exec should not fail local API key validation when --server is set: {stderr}" + ); +} + +#[test] +fn exec_configured_server_target_alone_does_not_reroute_exec() { + let context = test_context!(); + let server = MockServer::start(); + server.mock(|when, then| { + when.method("POST").path("/api/v1/completions"); + then.status(500).body("config-should-not-be-used"); + }); + context.write_home( + ".fabro/settings.toml", + format!( + "_version = 1\n\n[cli.target]\ntype = \"http\"\nurl = \"{}/api/v1\"\n", + server.base_url() + ), + ); + + let mut cmd = context.exec_cmd(); + cmd.env_clear(); + cmd.env("HOME", &context.home_dir); + cmd.env("FABRO_NO_UPGRADE_CHECK", "true") + .env("FABRO_HTTP_PROXY_POLICY", "disabled"); + cmd.args([ + "--provider", + "openai", + "--model", + "gpt-5.4-mini", + "test prompt", + ]); + + let output = cmd.assert().failure().get_output().clone(); + let stderr = String::from_utf8(output.stderr).expect("valid utf8"); + assert!( + stderr.contains("API key not set for provider 'openai'"), + "expected local API key validation failure, got: {stderr}" + ); + assert!( + !stderr.contains("config-should-not-be-used"), + "exec should ignore configured server.target without --server: {stderr}" + ); +} + +#[test] +fn exec_cli_server_target_overrides_configured_server_target() { + let context = test_context!(); + let config_server = MockServer::start(); + config_server.mock(|when, then| { + when.method("POST").path("/api/v1/completions"); + then.status(500).body("config-should-not-be-used"); + }); + let cli_server = MockServer::start(); + cli_server.mock(|when, then| { + when.method("POST").path("/api/v1/completions"); + // Use a non-retriable error so this test covers target precedence + // without paying the retry backoff cost of a 5xx response. + then.status(400).body("cli-override-marker"); + }); + context.write_home( + ".fabro/settings.toml", + format!( + "_version = 1\n\n[cli.target]\ntype = \"http\"\nurl = \"{}/api/v1\"\n", + config_server.base_url() + ), + ); + + let mut cmd = context.exec_cmd(); + cmd.env_clear(); + cmd.env("HOME", &context.home_dir); + cmd.env("FABRO_NO_UPGRADE_CHECK", "true") + .env("FABRO_HTTP_PROXY_POLICY", "disabled"); + cmd.args([ + "--server", + &format!("{}/api/v1", cli_server.base_url()), + "--provider", + "openai", + "--model", + "gpt-5.4-mini", + "test prompt", + ]); + + let output = cmd.assert().failure().get_output().clone(); + let stderr = String::from_utf8(output.stderr).expect("valid utf8"); + assert!( + stderr.contains("cli-override-marker"), + "expected CLI server target to win, got: {stderr}" + ); + assert!( + !stderr.contains("config-should-not-be-used"), + "configured server.target should not be used when --server is passed: {stderr}" + ); +} + #[fabro_macros::e2e_test(live("ANTHROPIC_API_KEY"))] fn exec_creates_file() { let context = test_context!(); diff --git a/lib/crates/fabro-cli/tests/it/cmd/fabro.rs b/lib/crates/fabro-cli/tests/it/cmd/fabro.rs index 3077a8bb9..c9be584b0 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/fabro.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/fabro.rs @@ -14,7 +14,7 @@ fn help() { Commands: run Launch a workflow run create Create a workflow run (allocate run dir, persist spec) - start Start a created workflow run (spawn engine process) + start Start a created workflow run on the server attach Attach to a running or finished workflow run logs View the event log of a workflow run resume Resume an interrupted workflow run @@ -24,16 +24,18 @@ fn help() { preflight Validate run configuration without executing validate Validate a workflow graph Render a workflow graph as SVG or PNG - asset Inspect and copy run assets (screenshots, reports, traces) + artifact Inspect and copy run artifacts (screenshots, reports, traces) store Export store-backed run state for debugging rm Remove one or more workflow runs inspect Show detailed information about a workflow run model List and test LLM models + server Server operations doctor Check environment and integration health install Set up the Fabro environment (LLMs, certs, GitHub) + uninstall Uninstall Fabro from this machine pr Pull request operations - secret Manage secrets in ~/.fabro/.env - settings Inspect merged configuration + secret Manage server-owned secrets + settings Inspect effective settings workflow Workflow operations discord Open the Discord community in the browser docs Open the docs website in the browser @@ -46,14 +48,31 @@ fn help() { help Print this message or the help of the given subcommand(s) Options: - --json Output as JSON [env: FABRO_JSON=] - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --quiet Suppress non-essential output [env: FABRO_QUIET=] - --verbose Enable verbose output [env: FABRO_VERBOSE=] - --storage-dir Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] - -h, --help Print help - -V, --version Print version + --json Output as JSON [env: FABRO_JSON=] + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --quiet Suppress non-essential output [env: FABRO_QUIET=] + --verbose Enable verbose output [env: FABRO_VERBOSE=] + -h, --help Print help + -V, --version Print version ----- stderr ----- "); } + +#[test] +fn llm_namespace_is_not_available() { + let context = test_context!(); + let mut cmd = context.command(); + cmd.arg("llm"); + fabro_snapshot!(context.filters(), cmd, @" + success: false + exit_code: 2 + ----- stdout ----- + ----- stderr ----- + error: unrecognized subcommand 'llm' + + Usage: fabro [OPTIONS] + + For more information, try '--help'. + "); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/fork.rs b/lib/crates/fabro-cli/tests/it/cmd/fork.rs index 989492c6d..162bdf6a5 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/fork.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/fork.rs @@ -1,6 +1,5 @@ -use insta::assert_snapshot; - use fabro_test::{fabro_snapshot, run_and_format, test_context}; +use insta::assert_snapshot; use super::support::{ git_filters, git_show_json, git_stdout, metadata_run_ids, run_branch_commits, @@ -25,15 +24,15 @@ fn help() { [TARGET] Target checkpoint: node name, node@visit, or @ordinal (omit to fork from latest) Options: - --json Output as JSON [env: FABRO_JSON=] - --list Show the checkpoint timeline instead of forking - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] - --no-push Skip pushing new branches to the remote - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --quiet Suppress non-essential output [env: FABRO_QUIET=] - --verbose Enable verbose output [env: FABRO_VERBOSE=] - --storage-dir Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] - -h, --help Print help + --json Output as JSON [env: FABRO_JSON=] + --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --list Show the checkpoint timeline instead of forking + --no-push Skip pushing new branches to the remote + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --quiet Suppress non-essential output [env: FABRO_QUIET=] + --verbose Enable verbose output [env: FABRO_VERBOSE=] + -h, --help Print help ----- stderr ----- "); } @@ -85,10 +84,10 @@ fn fork_latest_prints_new_run_and_resume_hint() { ); let new_run_id = &new_run_ids[0]; - let new_head = git_stdout( - &setup.repo_dir, - &["rev-parse", &format!("fabro/run/{new_run_id}")], - ); + let new_head = git_stdout(&setup.repo_dir, &[ + "rev-parse", + &format!("fabro/run/{new_run_id}"), + ]); let expected_head = run_branch_commits(&setup.repo_dir, &setup.run.run_id) .into_iter() .last() @@ -129,10 +128,10 @@ fn fork_from_earlier_checkpoint_uses_expected_sha() { ); let new_run_id = &new_run_ids[0]; - let new_head = git_stdout( - &setup.repo_dir, - &["rev-parse", &format!("fabro/run/{new_run_id}")], - ); + let new_head = git_stdout(&setup.repo_dir, &[ + "rev-parse", + &format!("fabro/run/{new_run_id}"), + ]); assert_eq!(new_head.trim(), expected_head); let checkpoint = git_show_json( diff --git a/lib/crates/fabro-cli/tests/it/cmd/graph.rs b/lib/crates/fabro-cli/tests/it/cmd/graph.rs index af4fa665c..5f4a3dc14 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/graph.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/graph.rs @@ -20,21 +20,31 @@ fn help() { Path to the .fabro workflow file, .toml task config, or project workflow name Options: + --json + Output as JSON + + [env: FABRO_JSON=] + + --server + Fabro server target: http(s) URL or absolute Unix socket path + + [env: FABRO_SERVER=] + + --debug + Enable DEBUG-level logging (default is INFO) + + [env: FABRO_DEBUG=] + --format Output format [default: svg] [possible values: svg, png] - --json - Output as JSON + --no-upgrade-check + Disable automatic upgrade check - [env: FABRO_JSON=] - - --debug - Enable DEBUG-level logging (default is INFO) - - [env: FABRO_DEBUG=] + [env: FABRO_NO_UPGRADE_CHECK=true] -o, --output Output file path (defaults to stdout) @@ -46,11 +56,6 @@ fn help() { - lr: Left to right - tb: Top to bottom - --no-upgrade-check - Disable automatic upgrade check - - [env: FABRO_NO_UPGRADE_CHECK=true] - --quiet Suppress non-essential output @@ -61,11 +66,6 @@ fn help() { [env: FABRO_VERBOSE=] - --storage-dir - Storage directory (default: ~/.fabro) - - [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] - -h, --help Print help (see a summary with '-h') ----- stderr ----- diff --git a/lib/crates/fabro-cli/tests/it/cmd/inspect.rs b/lib/crates/fabro-cli/tests/it/cmd/inspect.rs index 1c3c86fa8..f5c4a2860 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/inspect.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/inspect.rs @@ -1,10 +1,9 @@ +use fabro_test::{fabro_snapshot, test_context}; use insta::assert_snapshot; -use fabro_test::{fabro_snapshot, test_context}; - use super::support::{ - compact_git_inspect, compact_inspect, run_success, setup_completed_dry_run, - setup_created_dry_run, setup_git_backed_changed_run, + compact_git_inspect, compact_inspect, run_success, setup_completed_fast_dry_run, + setup_created_fast_dry_run, setup_git_backed_changed_run, }; #[test] @@ -24,13 +23,13 @@ fn help() { Run ID prefix or workflow name (most recent run) Options: - --json Output as JSON [env: FABRO_JSON=] - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --quiet Suppress non-essential output [env: FABRO_QUIET=] - --verbose Enable verbose output [env: FABRO_VERBOSE=] - --storage-dir Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] - -h, --help Print help + --json Output as JSON [env: FABRO_JSON=] + --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --quiet Suppress non-essential output [env: FABRO_QUIET=] + --verbose Enable verbose output [env: FABRO_VERBOSE=] + -h, --help Print help ----- stderr ----- "); } @@ -38,7 +37,7 @@ fn help() { #[test] fn inspect_created_run_shows_run_record_without_start_or_conclusion() { let context = test_context!(); - let run = setup_created_dry_run(&context); + let run = setup_created_fast_dry_run(&context); let output = run_success(&context, &["inspect", &run.run_id]); assert_snapshot!(serde_json::to_string_pretty(&compact_inspect(&output)).unwrap(), @r###" @@ -51,7 +50,13 @@ fn inspect_created_run_shows_run_record_without_start_or_conclusion() { "workflow_name": "Simple", "workflow_slug": "simple", "sandbox_provider": "local", - "dry_run": true + "dry_run": true, + "provenance": { + "server_version": "[VERSION]", + "client_name": "fabro-cli", + "client_version": "[VERSION]", + "subject_auth_method": "disabled" + } }, "start_record": null, "conclusion": null, @@ -65,10 +70,10 @@ fn inspect_created_run_shows_run_record_without_start_or_conclusion() { #[test] fn inspect_completed_run_shows_run_start_conclusion_checkpoint() { let context = test_context!(); - let run = setup_completed_dry_run(&context); + let run = setup_completed_fast_dry_run(&context); let output = run_success(&context, &["inspect", &run.run_id]); - assert_snapshot!(serde_json::to_string_pretty(&compact_inspect(&output)).unwrap(), @r###" + assert_snapshot!(serde_json::to_string_pretty(&compact_inspect(&output)).unwrap(), @r#" [ { "run_id": "[ULID]", @@ -78,7 +83,13 @@ fn inspect_completed_run_shows_run_start_conclusion_checkpoint() { "workflow_name": "Simple", "workflow_slug": "simple", "sandbox_provider": "local", - "dry_run": true + "dry_run": true, + "provenance": { + "server_version": "[VERSION]", + "client_name": "fabro-cli", + "client_version": "[VERSION]", + "subject_auth_method": "disabled" + } }, "start_record": { "has_start_time": true @@ -86,7 +97,7 @@ fn inspect_completed_run_shows_run_start_conclusion_checkpoint() { "conclusion": { "status": "success", "duration_ms": "[DURATION_MS]", - "stage_count": 3 + "stage_count": null }, "checkpoint": { "current_node": "report", @@ -102,7 +113,73 @@ fn inspect_completed_run_shows_run_start_conclusion_checkpoint() { } } ] - "###); + "#); +} + +#[test] +fn inspect_json_omits_run_dir() { + let context = test_context!(); + let run = setup_completed_fast_dry_run(&context); + let output = run_success(&context, &["inspect", &run.run_id]); + let items: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("inspect output should parse"); + let first = items + .as_array() + .and_then(|items| items.first()) + .expect("inspect output should contain one item"); + assert!( + first.get("run_dir").is_none(), + "inspect JSON should not expose run_dir" + ); +} + +#[test] +fn inspect_completed_run_reads_store_without_disk_metadata_files() { + let context = test_context!(); + let run = setup_completed_fast_dry_run(&context); + let output = run_success(&context, &["inspect", &run.run_id]); + + assert_snapshot!(serde_json::to_string_pretty(&compact_inspect(&output)).unwrap(), @r#" + [ + { + "run_id": "[ULID]", + "status": "succeeded", + "run_record": { + "goal": "Run tests and report results", + "workflow_name": "Simple", + "workflow_slug": "simple", + "sandbox_provider": "local", + "dry_run": true, + "provenance": { + "server_version": "[VERSION]", + "client_name": "fabro-cli", + "client_version": "[VERSION]", + "subject_auth_method": "disabled" + } + }, + "start_record": { + "has_start_time": true + }, + "conclusion": { + "status": "success", + "duration_ms": "[DURATION_MS]", + "stage_count": null + }, + "checkpoint": { + "current_node": "report", + "completed_nodes": [ + "start", + "run_tests", + "report" + ], + "next_node_id": "exit" + }, + "sandbox": { + "provider": "local" + } + } + ] + "#); } #[test] @@ -113,7 +190,7 @@ fn inspect_git_backed_run_exposes_checkpoint_and_sandbox_state() { assert_snapshot!( serde_json::to_string_pretty(&compact_git_inspect(&output)).unwrap(), - @r###" + @r#" [ { "run_id": "[ULID]", @@ -123,7 +200,13 @@ fn inspect_git_backed_run_exposes_checkpoint_and_sandbox_state() { "workflow_name": "Flow", "workflow_slug": "flow", "llm_provider": "openai", - "sandbox_provider": "local" + "sandbox_provider": "local", + "provenance": { + "server_version": "[VERSION]", + "client_name": "fabro-cli", + "client_version": "[VERSION]", + "subject_auth_method": "disabled" + } }, "start_record": { "has_start_time": true, @@ -134,7 +217,7 @@ fn inspect_git_backed_run_exposes_checkpoint_and_sandbox_state() { "status": "success", "duration_ms": "[DURATION_MS]", "final_git_commit_sha": "[SHA]", - "stage_count": 3 + "stage_count": null }, "checkpoint": { "current_node": "step_two", @@ -152,6 +235,6 @@ fn inspect_git_backed_run_exposes_checkpoint_and_sandbox_state() { } } ] - "### + "# ); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/install.rs b/lib/crates/fabro-cli/tests/it/cmd/install.rs index e0699f83e..78ef9db0a 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/install.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/install.rs @@ -15,12 +15,12 @@ fn help() { Options: --json Output as JSON [env: FABRO_JSON=] - --web-url Base URL for the web UI (used for OAuth callback URLs) [default: http://localhost:5173] + --storage-dir Local storage directory (default: ~/.fabro/storage) [env: FABRO_STORAGE_DIR=] --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --web-url Base URL for the web UI (used for OAuth callback URLs) [default: http://localhost:3000] --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] --quiet Suppress non-essential output [env: FABRO_QUIET=] --verbose Enable verbose output [env: FABRO_VERBOSE=] - --storage-dir Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] -h, --help Print help ----- stderr ----- "); diff --git a/lib/crates/fabro-cli/tests/it/cmd/json_global.rs b/lib/crates/fabro-cli/tests/it/cmd/json_global.rs index e3516c2a4..86383ae51 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/json_global.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/json_global.rs @@ -1,9 +1,14 @@ +#![expect( + clippy::disallowed_methods, + reason = "These CLI integration tests synchronously probe for dot before exercising JSON output paths." +)] + use std::process::Command; use fabro_test::test_context; use serde_json::Value; -use super::support::{fixture, output_stderr, output_stdout, setup_completed_dry_run}; +use super::support::{fixture, output_stderr, output_stdout, setup_completed_fast_dry_run}; fn dot_is_available() -> bool { Command::new("dot") @@ -46,11 +51,12 @@ fn settings_json_outputs_parseable_json() { #[test] fn ps_supports_global_flag_and_env_var() { let context = test_context!(); - setup_completed_dry_run(&context); + setup_completed_fast_dry_run(&context); + let test_case_label = context.test_case_label(); let global_output = context .command() - .args(["--json", "ps", "-a"]) + .args(["--json", "ps", "-a", "--label", &test_case_label]) .output() .expect("command should run"); assert!(global_output.status.success()); @@ -61,19 +67,50 @@ fn ps_supports_global_flag_and_env_var() { let env_output = context .command() .env("FABRO_JSON", "1") - .args(["ps", "-a"]) + .args(["ps", "-a", "--label", &test_case_label]) .output() .expect("command should run"); assert!(env_output.status.success()); let env_runs: Value = serde_json::from_slice(&env_output.stdout).expect("FABRO_JSON output should parse"); - assert_eq!(global_runs, env_runs); + + let normalize = |runs: &Value| { + let mut rows = runs + .as_array() + .expect("ps output should be an array") + .iter() + .map(|run| { + ( + run["run_id"] + .as_str() + .expect("run_id should be present") + .to_string(), + run["workflow_name"] + .as_str() + .expect("workflow_name should be present") + .to_string(), + run["workflow_slug"] + .as_str() + .expect("workflow_slug should be present") + .to_string(), + run["goal"] + .as_str() + .expect("goal should be present") + .to_string(), + ) + }) + .collect::>(); + rows.sort_unstable(); + rows + }; + + assert_eq!(normalize(&global_runs), normalize(&env_runs)); } #[test] fn logs_json_wins_over_pretty() { let context = test_context!(); - let run = setup_completed_dry_run(&context); + let run = setup_completed_fast_dry_run(&context); let output = context .command() diff --git a/lib/crates/fabro-cli/tests/it/cmd/llm.rs b/lib/crates/fabro-cli/tests/it/cmd/llm.rs deleted file mode 100644 index 1f8e7f2cc..000000000 --- a/lib/crates/fabro-cli/tests/it/cmd/llm.rs +++ /dev/null @@ -1,390 +0,0 @@ -use std::process::Output; - -use fabro_test::{TwinScenario, TwinScenarios, fabro_snapshot, test_context, twin_openai}; -use predicates::prelude::*; - -async fn run_success_output(mut cmd: assert_cmd::Command) -> Output { - tokio::task::spawn_blocking(move || cmd.assert().success().get_output().clone()) - .await - .expect("blocking command task should complete") -} - -#[test] -fn prompt_bad_option() { - let context = test_context!(); - let mut cmd = context.llm(); - cmd.args(["prompt", "-o", "bad_option", "hello"]); - fabro_snapshot!(context.filters(), cmd, @" - success: false - exit_code: 2 - ----- stdout ----- - ----- stderr ----- - error: invalid value 'bad_option' for '--option