Merge remote-tracking branch 'origin/main' into pr-176

# Conflicts:
#	lib/crates/fabro-cli/src/manifest_builder.rs
#	lib/crates/fabro-server/src/run_manifest.rs
This commit is contained in:
Bryan Helmkamp 2026-04-29 12:45:33 -04:00
commit cebdf8dee5
No known key found for this signature in database
986 changed files with 18156 additions and 13124 deletions

View file

@ -1,6 +1,6 @@
---
name: changelog
description: Generate and update the product changelog in Mintlify docs. Use when the user asks to update the changelog, add a changelog entry, document recent changes, or write release notes. Reads git history on main, filters to user-facing changes, and writes dated MDX files to docs/changelog/.
description: Generate and update the product changelog in Mintlify docs. Use when the user asks to update the changelog, add a changelog entry, document recent changes, or write release notes. Reads git history on main, filters to user-facing changes, and writes dated MDX files to docs/public/changelog/.
---
# Changelog
@ -43,13 +43,13 @@ If there are no user-facing changes in the entire range, tell the user and stop.
### 4. Write changelog entries
Create one file per date at `docs/changelog/YYYY-MM-DD.mdx`, using the commit date (not today's date). If a file already exists for a date, regenerate it with the full set of commits for that day (not just new ones). Follow the references linked above for format, writing style, and hero vs. accordion decisions.
Create one file per date at `docs/public/changelog/YYYY-MM-DD.mdx`, using the commit date (not today's date). If a file already exists for a date, regenerate it with the full set of commits for that day (not just new ones). Follow the references linked above for format, writing style, and hero vs. accordion decisions.
- **Batch related commits** into a single feature section (e.g., multiple hook-related commits become one "Lifecycle hooks" section)
### 5. Update docs/docs.json
### 5. Update docs/public/docs.json
Add all new pages to the Changelog tab's pages array in `docs/docs.json`. List entries most recent first. The page path is `changelog/YYYY-MM-DD` (no `.mdx` extension).
Add all new pages to the Changelog tab's pages array in `docs/public/docs.json`. List entries most recent first. The page path is `changelog/YYYY-MM-DD` (no `.mdx` extension).
### 6. Write watermark
@ -57,4 +57,4 @@ Write the output of `git rev-parse HEAD` to `.claude/skills/changelog/watermark`
### 7. Clean up legacy single-file changelog
If `docs/changelog.mdx` still exists as the old single-file changelog, delete it and remove its reference from `docs/docs.json`.
If `docs/public/changelog.mdx` still exists as the old single-file changelog, delete it and remove its reference from `docs/public/docs.json`.

View file

@ -1,6 +1,6 @@
# Mintlify Changelog MDX Format
Each changelog entry is a separate `.mdx` file in `docs/changelog/`.
Each changelog entry is a separate `.mdx` file in `docs/public/changelog/`.
## Template

View file

@ -1 +1 @@
6d97de0d9948f26d544e7a2bc35a99f53c55cbf8
cb0c39ee915896c5a3e8873180092a8bc95bbf36

View file

@ -1,6 +1,6 @@
---
name: update-docs
description: Update documentation in docs/ based on recent code changes. Reads git history since a watermark commit, maps changed files to doc pages, and makes surgical edits to keep docs in sync with code.
description: Update documentation in docs/public/ based on recent code changes. Reads git history since a watermark commit, maps changed files to doc pages, and makes surgical edits to keep docs in sync with code.
---
# Update Docs
@ -8,7 +8,7 @@ description: Update documentation in docs/ based on recent code changes. Reads g
Detect code changes since the last run and update affected documentation pages.
- [references/mapping.md](references/mapping.md) — code-to-doc page mapping
- Follow `docs/CONTRIBUTING.md` and `docs/AGENTS.md` for writing style
- Follow `CONTRIBUTING.md` and `AGENTS.md` (repo root) for writing style
## Workflow
@ -50,7 +50,7 @@ Surgical edits only — change only affected sections. Preserve existing voice,
- Insert rows into reference tables in logical position
- Add new sections for entirely new capabilities
- Update existing descriptions when behavior changes
- Never edit `docs/api-reference/fabro-api.yaml` — that is the API workflow's source of truth
- Never edit `docs/public/api-reference/fabro-api.yaml` — that is the API workflow's source of truth
### 6. Validate DOT examples

View file

@ -4,33 +4,33 @@ Which source files affect which doc pages. Use this as guidance — also apply j
| Source | Docs |
|--------|------|
| `lib/crates/fabro-cli/src/main.rs`, `lib/crates/fabro-workflow/src/cli/mod.rs`, `lib/crates/fabro-workflow/src/cli/run.rs` | `docs/reference/cli.mdx` |
| `lib/crates/fabro-cli/src/cli_config.rs` | `docs/reference/cli-configuration.mdx` |
| `lib/crates/fabro-llm/src/cli.rs` | `docs/reference/cli.mdx` |
| `lib/crates/fabro-api/src/serve.rs` | `docs/reference/cli.mdx` |
| `lib/crates/fabro-workflow/src/parser/*.rs` | `docs/reference/dot-language.mdx` |
| `lib/crates/fabro-workflow/src/condition.rs` | `docs/reference/dot-language.mdx` |
| `lib/crates/fabro-workflow/src/cli/validate.rs` | `docs/reference/dot-language.mdx` |
| `lib/crates/fabro-workflow/src/stylesheet.rs` | `docs/workflows/stylesheets.mdx` |
| `lib/crates/fabro-workflow/src/transform.rs` | `docs/workflows/variables.mdx` |
| `lib/crates/fabro-workflow/src/handler/*.rs` | `docs/workflows/stages-and-nodes.mdx`, `docs/reference/dot-language.mdx` |
| `lib/crates/fabro-workflow/src/handler/human.rs` | `docs/workflows/human-in-the-loop.mdx` |
| `lib/crates/fabro-workflow/src/cli/run_config.rs` | `docs/execution/run-configuration.mdx` |
| `lib/crates/fabro-workflow/src/engine.rs` | `docs/core-concepts/how-arc-works.mdx` |
| `lib/crates/fabro-workflow/src/context/*.rs` | `docs/execution/context.mdx` |
| `lib/crates/fabro-workflow/src/checkpoint.rs` | `docs/execution/checkpoints.mdx` |
| `lib/crates/fabro-workflow/src/retro.rs`, `lib/crates/fabro-workflow/src/retro_agent.rs` | `docs/execution/retros.mdx` |
| `lib/crates/fabro-workflow/src/interviewer/*.rs` | `docs/execution/interviews.mdx` |
| `lib/crates/fabro-workflow/src/hook/*.rs` | `docs/agents/hooks.mdx` |
| `lib/crates/fabro-workflow/src/daytona_sandbox.rs` | `docs/integrations/daytona.mdx`, `docs/execution/environments.mdx` |
| `lib/crates/fabro-agent/src/tools.rs`, `lib/crates/fabro-agent/src/tool_registry.rs`, `lib/crates/fabro-agent/src/tool_execution.rs` | `docs/agents/tools.mdx` |
| `lib/crates/fabro-agent/src/v4a_patch.rs` | `docs/agents/tools.mdx` |
| `lib/crates/fabro-agent/src/cli.rs` | `docs/agents/permissions.mdx` |
| `lib/crates/fabro-agent/src/subagent.rs` | `docs/agents/subagents.mdx` |
| `lib/crates/fabro-agent/src/mcp_integration.rs` | `docs/agents/mcp.mdx` |
| `lib/crates/fabro-llm/src/catalog.rs`, `lib/crates/fabro-llm/src/providers/*.rs` | `docs/core-concepts/models.mdx` |
| `lib/crates/fabro-devcontainer/src/*.rs` | `docs/execution/devcontainers.mdx` |
| `lib/crates/fabro-slack/src/*.rs` | `docs/integrations/slack.mdx` |
| `lib/crates/fabro-mcp/src/*.rs` | `docs/agents/mcp.mdx` |
| `lib/crates/fabro-api/src/*.rs` | `docs/api-reference/overview.mdx`, `docs/api-reference/demo-mode.mdx` |
| `lib/crates/fabro-api/src/server_config.rs` | `docs/administration/server-configuration.mdx` |
| `lib/crates/fabro-cli/src/main.rs`, `lib/crates/fabro-workflow/src/cli/mod.rs`, `lib/crates/fabro-workflow/src/cli/run.rs` | `docs/public/reference/cli.mdx` |
| `lib/crates/fabro-cli/src/cli_config.rs` | `docs/public/reference/cli-configuration.mdx` |
| `lib/crates/fabro-llm/src/cli.rs` | `docs/public/reference/cli.mdx` |
| `lib/crates/fabro-api/src/serve.rs` | `docs/public/reference/cli.mdx` |
| `lib/crates/fabro-workflow/src/parser/*.rs` | `docs/public/reference/dot-language.mdx` |
| `lib/crates/fabro-workflow/src/condition.rs` | `docs/public/reference/dot-language.mdx` |
| `lib/crates/fabro-workflow/src/cli/validate.rs` | `docs/public/reference/dot-language.mdx` |
| `lib/crates/fabro-workflow/src/stylesheet.rs` | `docs/public/workflows/stylesheets.mdx` |
| `lib/crates/fabro-workflow/src/transform.rs` | `docs/public/workflows/variables.mdx` |
| `lib/crates/fabro-workflow/src/handler/*.rs` | `docs/public/workflows/stages-and-nodes.mdx`, `docs/public/reference/dot-language.mdx` |
| `lib/crates/fabro-workflow/src/handler/human.rs` | `docs/public/workflows/human-in-the-loop.mdx` |
| `lib/crates/fabro-workflow/src/cli/run_config.rs` | `docs/public/execution/run-configuration.mdx` |
| `lib/crates/fabro-workflow/src/engine.rs` | `docs/public/core-concepts/how-arc-works.mdx` |
| `lib/crates/fabro-workflow/src/context/*.rs` | `docs/public/execution/context.mdx` |
| `lib/crates/fabro-workflow/src/checkpoint.rs` | `docs/public/execution/checkpoints.mdx` |
| `lib/crates/fabro-workflow/src/retro.rs`, `lib/crates/fabro-workflow/src/retro_agent.rs` | `docs/public/execution/retros.mdx` |
| `lib/crates/fabro-workflow/src/interviewer/*.rs` | `docs/public/execution/interviews.mdx` |
| `lib/crates/fabro-workflow/src/hook/*.rs` | `docs/public/agents/hooks.mdx` |
| `lib/crates/fabro-workflow/src/daytona_sandbox.rs` | `docs/public/integrations/daytona.mdx`, `docs/public/execution/environments.mdx` |
| `lib/crates/fabro-agent/src/tools.rs`, `lib/crates/fabro-agent/src/tool_registry.rs`, `lib/crates/fabro-agent/src/tool_execution.rs` | `docs/public/agents/tools.mdx` |
| `lib/crates/fabro-agent/src/v4a_patch.rs` | `docs/public/agents/tools.mdx` |
| `lib/crates/fabro-agent/src/cli.rs` | `docs/public/agents/permissions.mdx` |
| `lib/crates/fabro-agent/src/subagent.rs` | `docs/public/agents/subagents.mdx` |
| `lib/crates/fabro-agent/src/mcp_integration.rs` | `docs/public/agents/mcp.mdx` |
| `lib/crates/fabro-llm/src/catalog.rs`, `lib/crates/fabro-llm/src/providers/*.rs` | `docs/public/core-concepts/models.mdx` |
| `lib/crates/fabro-devcontainer/src/*.rs` | `docs/public/execution/devcontainers.mdx` |
| `lib/crates/fabro-slack/src/*.rs` | `docs/public/integrations/slack.mdx` |
| `lib/crates/fabro-mcp/src/*.rs` | `docs/public/agents/mcp.mdx` |
| `lib/crates/fabro-api/src/*.rs` | `docs/public/api-reference/overview.mdx`, `docs/public/api-reference/demo-mode.mdx` |
| `lib/crates/fabro-api/src/server_config.rs` | `docs/public/administration/server-configuration.mdx` |

View file

@ -1 +1 @@
533785cd4c107cee673825847b1f8fe3d8d14dfe
d2cc37c615894d56ef672d00004ce13ce3b328c2

View file

@ -1,39 +0,0 @@
---
status: ready
priority: p1
issue_id: "001"
tags: [rust, clippy, async-io, std-fs]
dependencies: []
---
## Problem Statement
Several Rust crates still contain `FOLLOW-UP:` markers related to blocking `std::fs` or sync I/O on async paths. The requested work is to execute the implementation plan in `~/.claude/plans/we-ll-feal-with-std-fs-jaunty-feigenbaum.md` and finish the refactors or tighten the remaining sync justifications.
## Findings
- The repo is currently on `main`, and the user explicitly approved proceeding there.
- `docs/solutions/` is not present, so there are no repo learnings to consult for this task.
- The current code matches the plan buckets across `fabro-agent`, `fabro-devcontainer`, `fabro-llm`, and `fabro-workflow`.
## Proposed Solutions
- Execute the plan in bucket order, using targeted failing checks before each production change where feasible.
- Prefer async propagation for truly async paths and `spawn_blocking` only at natural async boundaries.
- Remove or narrow `#[expect(clippy::disallowed_methods)]` annotations once the production sites are fixed.
## Recommended Action
Implement the plan directly, verify each bucket with crate-level tests or lint checks, then run the final formatting, clippy, workspace tests, and `FOLLOW-UP` sweep.
## Acceptance Criteria
- All `FOLLOW-UP:` markers under `lib/crates/` are removed.
- The planned async refactors and `spawn_blocking` boundary changes are implemented.
- Formatting and workspace clippy pass.
- Relevant crate tests pass during incremental verification.
## Work Log
- 2026-04-19: Created execution todo, confirmed branch choice with the user, and started inspecting the planned call sites.

View file

@ -1,4 +1,4 @@
*
!docker/entrypoint.sh
!docker/settings.toml
!docker-context/**
!tmp/docker-context/**

View file

@ -8,9 +8,6 @@ MINIMAX_API_KEY=
OPENAI_API_KEY=
ZAI_API_KEY=
FABRO_JWT_PRIVATE_KEY=
FABRO_JWT_PUBLIC_KEY=
SESSION_SECRET=
GITHUB_APP_CLIENT_SECRET=
GITHUB_APP_WEBHOOK_SECRET=

View file

@ -4,10 +4,6 @@ _version = 1
enabled = true
draft = false
[run.sandbox]
provider = "daytona"
preserve = true
[run.sandbox.daytona]
auto_stop_interval = 30

View file

@ -1,10 +1,4 @@
_version = 1
[workflow]
graph = "workflow.fabro"
[run.sandbox]
provider = "local"
[run.sandbox.local]
worktree_mode = "always"
graph = "workflow.fabro"

View file

@ -67,4 +67,4 @@ jobs:
git remote set-url origin \
"https://x-access-token:${release_token}@github.com/${GITHUB_REPOSITORY}.git"
unset release_token
cargo dev release nightly
cargo dev release --nightly

View file

@ -13,26 +13,8 @@ env:
SEGMENT_WRITE_KEY: ${{ secrets.SEGMENT_WRITE_KEY }}
jobs:
verify-spa:
name: Verify SPA assets
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
no-cache: true
- run: bun install
- uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
- run: cargo dev refresh-spa
- run: git diff --exit-code -- lib/crates/fabro-spa/assets
compile:
name: Compile (${{ matrix.target }})
needs: verify-spa
runs-on: ${{ matrix.runner }}
permissions:
contents: read
@ -61,6 +43,17 @@ jobs:
with:
persist-credentials: false
- name: Install unzip (ARM Linux runner image lacks it; setup-bun needs it)
if: runner.os == 'Linux' && runner.arch == 'ARM64'
run: sudo apt-get update && sudo apt-get install -y unzip
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
no-cache: true
- name: Install bun deps
run: bun install --frozen-lockfile
- name: Install Linux build tools
if: runner.os == 'Linux'
run: |
@ -90,6 +83,9 @@ jobs:
- uses: taiki-e/install-action@773334c0e05d7e699e4d78234494308223f3a2cf # nextest
- name: Refresh embedded SPA
run: cargo dev spa refresh
- name: Test (x86_64-musl)
# nextest still shells through cargo test for this target, so
# build.rs C code needs an explicit musl compiler/linker.
@ -188,9 +184,9 @@ jobs:
x86_64-*) arch=amd64 ;;
aarch64-*) arch=arm64 ;;
esac
mkdir -p "docker-context/$arch"
mkdir -p "tmp/docker-context/$arch"
tar -xzf "target/distrib/fabro-${target}.tar.gz" -C target/distrib
cp "target/distrib/fabro-${target}/fabro" "docker-context/$arch/fabro"
cp "target/distrib/fabro-${target}/fabro" "tmp/docker-context/$arch/fabro"
done
- uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0

View file

@ -11,8 +11,8 @@ on:
- ".cargo/**"
- ".config/**"
- "bin/dev/**"
- "docs/reference/cli.mdx"
- "docs/reference/user-configuration.mdx"
- "docs/public/reference/cli.mdx"
- "docs/public/reference/user-configuration.mdx"
- "openapi/**"
- ".github/workflows/rust.yml"
pull_request:
@ -25,8 +25,8 @@ on:
- ".cargo/**"
- ".config/**"
- "bin/dev/**"
- "docs/reference/cli.mdx"
- "docs/reference/user-configuration.mdx"
- "docs/public/reference/cli.mdx"
- "docs/public/reference/user-configuration.mdx"
- "openapi/**"
- ".github/workflows/rust.yml"
workflow_dispatch:
@ -87,8 +87,7 @@ jobs:
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
with:
cache-on-failure: true
- run: cargo dev generate-cli-reference --check
- run: cargo dev generate-options-reference --check
- run: cargo dev docs check
test:
name: Test (Linux)

View file

@ -72,8 +72,5 @@ jobs:
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
- run: bun install --frozen-lockfile
- uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
- run: cargo dev refresh-spa
- run: git diff --exit-code -- lib/crates/fabro-spa/assets
- run: cargo dev check-spa-budgets
- run: cargo build -p fabro-cli --release
- run: cargo dev build -- -p fabro-cli --release
- run: wc -c < target/release/fabro

4
.gitignore vendored
View file

@ -1,9 +1,10 @@
target
docker-context/
.env
.entire
node_modules
apps/fabro-web/dist/
lib/crates/fabro-spa/assets/*
!lib/crates/fabro-spa/assets/.gitkeep
tmp
evals/swe-bench/repos/
evals/swe-bench/results/
@ -18,3 +19,4 @@ __pycache__
.fabro/workflows/implement-plan/prompts/
.claude/scheduled_tasks.lock
.mcp.json
docs/internal/agent

View file

@ -22,14 +22,18 @@ macOS note: if `cargo nextest run` fails with `Too many open files (os error 24)
- `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 (writes to `apps/fabro-web/dist/` only; does NOT update the bundled SPA that ships in the Rust binary)
- `cargo dev refresh-spa` — **run this before committing any TypeScript change in `apps/fabro-web/` or `lib/packages/fabro-api-client/`**. It runs the production build and then copies `dist/` into `lib/crates/fabro-spa/assets/` (which is tracked in git). CI's TypeScript `Build` job reruns this command and then `git diff --exit-code -- lib/crates/fabro-spa/assets` — if the committed bundle drifts from source (e.g. content-hashed filenames like `entry-<hash>.js` change), the check fails. `bun run build` on its own is not enough.
- `cargo dev build [-- <cargo args>]` — refreshes the embedded SPA assets from the production build, verifies SPA asset budgets, and then runs `cargo build` with forwarded args. The embedded assets are gitignored except for `.gitkeep`; use this when building a Rust binary that should include a populated SPA bundle. `bun run dev` for local development is unchanged because debug builds prefer `apps/fabro-web/dist/` on disk via the server fallback.
### Docker image
- `cargo dev docker-build` — builds the local Docker image from the current tree using the release pipeline's cargo-zigbuild approach. Honors `--arch amd64|arm64`, `--tag <name>` (default `fabro`), `--compile-only` (stages `docker-context/<arch>/fabro` without `docker build`), and `--dry-run` (prints the Docker commands without running them). Prefer this over writing a throwaway Dockerfile; the release pipeline, `Dockerfile`, and this command share the same binary layout.
- Refresh the embedded SPA before rebuilding the image after any `apps/fabro-web` change: `cargo dev refresh-spa` runs the bun build and copies `dist/` into `lib/crates/fabro-spa/assets/`. Skipping this step produces a Docker image whose Rust binary embeds a stale SPA bundle.
- `cargo dev docker-build` — builds the local Docker image from the current tree using the release pipeline's cargo-zigbuild approach. Honors `--arch amd64|arm64`, `--tag <name>` (default `fabro-sh/fabro`), `--compile-only` (stages `tmp/docker-context/<arch>/fabro` without `docker build`), and `--dry-run` (prints the Docker commands without running them). Prefer this over writing a throwaway Dockerfile; the release pipeline, `Dockerfile`, and this command share the same binary layout.
### Docker sandbox provider
- Docker is the default runtime sandbox provider from `defaults.toml`. The Fabro process must have a working Docker client environment (`DOCKER_HOST`, socket access, Docker Desktop behavior, TLS settings, groups/permissions, and any remote daemon policy are operator responsibilities).
- The packaged compose service mounts `/var/run/docker.sock` so the server can create sibling run containers on the host daemon. This is host-root-equivalent under Docker's security model; only use it in the trusted, single-tenant deployment model described by the sandbox code/docs.
- Docker and Daytona are clone-based providers. When a run manifest has a GitHub origin, they clone it into the provider workspace. Present non-GitHub origins fail unless the provider has `skip_clone = true`; absent origins or `skip_clone = true` create an empty workspace without repository files.
### Release automation
- `cargo dev release [nightly]` — creates the next stable release or nightly prerelease tag. Use `--dry-run` to print planned commands without mutating git or running Cargo, `--skip-tests` only after running the release-mode smoke yourself, and `--release-date YYYY-MM-DD` or `FABRO_RELEASE_DATE` for deterministic version computation.
- `cargo dev release` — creates the next stable release tag. Use `cargo dev release --nightly` for a nightly prerelease. Use `--dry-run` to print planned commands without mutating git or running Cargo, `--skip-tests` only after running the release-mode smoke yourself, and `--release-date YYYY-MM-DD` or `FABRO_RELEASE_DATE` for deterministic version computation.
### Marketing site (apps/marketing)
- `cd apps/marketing && bun run dev` — start Astro dev server
@ -41,16 +45,16 @@ macOS note: if `cargo nextest run` fails with `Too many open files (os error 24)
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 \
docker run --rm -d -p 3333:3333 -v $(pwd)/docs/public:/docs -w /docs --name mintlify-dev node:22-slim \
bash -c "npx mintlify dev --host 0.0.0.0 --port 3333"
```
Then open http://localhost:3333. Stop with `docker stop mintlify-dev`.
## API workflow
The OpenAPI spec at `docs/api-reference/fabro-api.yaml` is the source of truth for the fabro-api HTTP interface.
The OpenAPI spec at `docs/public/api-reference/fabro-api.yaml` is the source of truth for the fabro-api HTTP interface.
1. Edit `docs/api-reference/fabro-api.yaml`
1. Edit `docs/public/api-reference/fabro-api.yaml`
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
@ -75,6 +79,7 @@ Fabro is an AI-powered workflow orchestration platform. Workflows are defined as
- **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-sandbox** — Local, Docker, and Daytona sandbox providers. Docker is the default runtime provider and creates clone-based `/workspace` containers through the operator's Docker daemon; Daytona uses the same GitHub-only clone-source contract. Docker daemon access is host-root-equivalent and assumes trusted callers/payloads.
- **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** — Auto-generated Rust types and reqwest HTTP client from OpenAPI spec (build.rs + progenitor)
@ -91,7 +96,7 @@ Fabro is an AI-powered workflow orchestration platform. Workflows are defined as
- **lib/packages/fabro-api-client** — Auto-generated TypeScript Axios client from OpenAPI spec
### Key design patterns
- **Sandbox trait** — Uniform interface for local, Docker, and Daytona execution environments
- **Sandbox trait** — Uniform interface for local, Docker, and Daytona execution environments. Clone-based providers use run-spec GitHub origin metadata rather than worker process cwd detection.
- **Graphviz graph workflows** — Stages and transitions defined as Graphviz graph attributes
- **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
@ -100,10 +105,10 @@ 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 `Event` variants, touching `Emitter`/`emit()`, changing `progress.jsonl` output, or adding new workflow stage types
- **`files-internal/testing-strategy.md`** — read when adding or reorganizing tests, choosing between unit vs `tests/it`, deciding whether a test belongs in `cmd` vs `workflow` vs `scenario`, or deciding how to structure snapshots and fixtures
- **`docs-internal/server-secrets-strategy.md`** — read when adding or changing server-level secrets, startup validation, install-time secret persistence, or subprocess env inheritance/scrubbing
- **`docs/internal/logging-strategy.md`** — read when adding `tracing` calls (`info!`, `debug!`, `warn!`, `error!`), working on error handling paths, or adding new operations that should be observable
- **`docs/internal/events-strategy.md`** — read when adding or modifying `Event` variants, touching `Emitter`/`emit()`, changing `progress.jsonl` output, or adding new workflow stage types
- **`docs/internal/testing-strategy.md`** — read when adding or reorganizing tests, choosing between unit vs `tests/it`, deciding whether a test belongs in `cmd` vs `workflow` vs `scenario`, or deciding how to structure snapshots and fixtures
- **`docs/internal/server-secrets-strategy.md`** — read when adding or changing server-level secrets, startup validation, install-time secret persistence, or subprocess env inheritance/scrubbing
## Shell quoting in sandbox code

128
Cargo.lock generated
View file

@ -1045,6 +1045,27 @@ dependencies = [
"typenum",
]
[[package]]
name = "csv"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938"
dependencies = [
"csv-core",
"itoa",
"ryu",
"serde_core",
]
[[package]]
name = "csv-core"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782"
dependencies = [
"memchr",
]
[[package]]
name = "ctr"
version = "0.9.2"
@ -1514,7 +1535,7 @@ dependencies = [
[[package]]
name = "fabro-agent"
version = "0.214.0-nightly.1"
version = "0.218.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -1553,7 +1574,7 @@ dependencies = [
[[package]]
name = "fabro-api"
version = "0.214.0-nightly.1"
version = "0.218.0-nightly.0"
dependencies = [
"chrono",
"fabro-config",
@ -1573,7 +1594,7 @@ dependencies = [
[[package]]
name = "fabro-auth"
version = "0.214.0-nightly.1"
version = "0.218.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -1596,7 +1617,7 @@ dependencies = [
[[package]]
name = "fabro-checkpoint"
version = "0.214.0-nightly.1"
version = "0.218.0-nightly.0"
dependencies = [
"chrono",
"fabro-config",
@ -1612,7 +1633,7 @@ dependencies = [
[[package]]
name = "fabro-cli"
version = "0.214.0-nightly.1"
version = "0.218.0-nightly.0"
dependencies = [
"anyhow",
"assert_cmd",
@ -1690,11 +1711,13 @@ dependencies = [
"serde_yaml",
"sha2",
"shlex",
"temp-env",
"tempfile",
"thiserror 2.0.18",
"tokio",
"tokio-util",
"toml 0.8.23",
"toml_edit",
"tracing",
"tracing-appender",
"tracing-subscriber",
@ -1705,7 +1728,7 @@ dependencies = [
[[package]]
name = "fabro-client"
version = "0.214.0-nightly.1"
version = "0.218.0-nightly.0"
dependencies = [
"anyhow",
"bytes",
@ -1734,7 +1757,7 @@ dependencies = [
[[package]]
name = "fabro-config"
version = "0.214.0-nightly.1"
version = "0.218.0-nightly.0"
dependencies = [
"anyhow",
"chrono",
@ -1755,12 +1778,13 @@ dependencies = [
"thiserror 2.0.18",
"toml 0.8.23",
"tracing",
"tracing-subscriber",
"ulid",
]
[[package]]
name = "fabro-core"
version = "0.214.0-nightly.1"
version = "0.218.0-nightly.0"
dependencies = [
"async-trait",
"fabro-types",
@ -1775,15 +1799,17 @@ dependencies = [
[[package]]
name = "fabro-dev"
version = "0.214.0-nightly.1"
version = "0.218.0-nightly.0"
dependencies = [
"anyhow",
"assert_cmd",
"chrono",
"clap",
"csv",
"fabro-cli",
"fabro-config",
"fabro-options-metadata",
"quick-xml 0.36.2",
"shlex",
"tempfile",
"toml_edit",
@ -1793,7 +1819,7 @@ dependencies = [
[[package]]
name = "fabro-devcontainer"
version = "0.214.0-nightly.1"
version = "0.218.0-nightly.0"
dependencies = [
"fabro-http",
"fabro-static",
@ -1810,7 +1836,7 @@ dependencies = [
[[package]]
name = "fabro-github"
version = "0.214.0-nightly.1"
version = "0.218.0-nightly.0"
dependencies = [
"base64",
"chrono",
@ -1831,7 +1857,7 @@ dependencies = [
[[package]]
name = "fabro-graphviz"
version = "0.214.0-nightly.1"
version = "0.218.0-nightly.0"
dependencies = [
"anyhow",
"fabro-types",
@ -1845,7 +1871,7 @@ dependencies = [
[[package]]
name = "fabro-hooks"
version = "0.214.0-nightly.1"
version = "0.218.0-nightly.0"
dependencies = [
"async-trait",
"fabro-agent",
@ -1869,7 +1895,7 @@ dependencies = [
[[package]]
name = "fabro-http"
version = "0.214.0-nightly.1"
version = "0.218.0-nightly.0"
dependencies = [
"fabro-static",
"http",
@ -1879,7 +1905,7 @@ dependencies = [
[[package]]
name = "fabro-install"
version = "0.214.0-nightly.1"
version = "0.218.0-nightly.0"
dependencies = [
"anyhow",
"base64",
@ -1894,7 +1920,7 @@ dependencies = [
[[package]]
name = "fabro-interview"
version = "0.214.0-nightly.1"
version = "0.218.0-nightly.0"
dependencies = [
"async-trait",
"dialoguer",
@ -1908,7 +1934,7 @@ dependencies = [
[[package]]
name = "fabro-llm"
version = "0.214.0-nightly.1"
version = "0.218.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -1940,7 +1966,7 @@ dependencies = [
[[package]]
name = "fabro-macros"
version = "0.214.0-nightly.1"
version = "0.218.0-nightly.0"
dependencies = [
"clap",
"fabro-options-metadata",
@ -1951,7 +1977,7 @@ dependencies = [
[[package]]
name = "fabro-mcp"
version = "0.214.0-nightly.1"
version = "0.218.0-nightly.0"
dependencies = [
"anyhow",
"fabro-config",
@ -1967,7 +1993,7 @@ dependencies = [
[[package]]
name = "fabro-model"
version = "0.214.0-nightly.1"
version = "0.218.0-nightly.0"
dependencies = [
"fabro-static",
"insta",
@ -1978,7 +2004,7 @@ dependencies = [
[[package]]
name = "fabro-oauth"
version = "0.214.0-nightly.1"
version = "0.218.0-nightly.0"
dependencies = [
"axum",
"base64",
@ -1999,7 +2025,7 @@ dependencies = [
[[package]]
name = "fabro-options-metadata"
version = "0.214.0-nightly.1"
version = "0.218.0-nightly.0"
dependencies = [
"serde",
"serde_json",
@ -2007,7 +2033,7 @@ dependencies = [
[[package]]
name = "fabro-proc"
version = "0.214.0-nightly.1"
version = "0.218.0-nightly.0"
dependencies = [
"cc",
"libc",
@ -2016,7 +2042,7 @@ dependencies = [
[[package]]
name = "fabro-redact"
version = "0.214.0-nightly.1"
version = "0.218.0-nightly.0"
dependencies = [
"aho-corasick",
"ref-cast",
@ -2032,7 +2058,7 @@ dependencies = [
[[package]]
name = "fabro-retro"
version = "0.214.0-nightly.1"
version = "0.218.0-nightly.0"
dependencies = [
"anyhow",
"chrono",
@ -2050,7 +2076,7 @@ dependencies = [
[[package]]
name = "fabro-sandbox"
version = "0.214.0-nightly.1"
version = "0.218.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -2062,8 +2088,10 @@ dependencies = [
"fabro-config",
"fabro-github",
"fabro-proc",
"fabro-redact",
"fabro-static",
"fabro-types",
"fabro-util",
"futures",
"git2",
"glob",
@ -2074,6 +2102,7 @@ dependencies = [
"strum",
"tar",
"tempfile",
"thiserror 2.0.18",
"tokio",
"tokio-util",
"toml 0.8.23",
@ -2083,7 +2112,7 @@ dependencies = [
[[package]]
name = "fabro-server"
version = "0.214.0-nightly.1"
version = "0.218.0-nightly.0"
dependencies = [
"anyhow",
"async-trait",
@ -2106,6 +2135,7 @@ dependencies = [
"fabro-install",
"fabro-interview",
"fabro-llm",
"fabro-macros",
"fabro-model",
"fabro-proc",
"fabro-redact",
@ -2163,7 +2193,7 @@ dependencies = [
[[package]]
name = "fabro-slack"
version = "0.214.0-nightly.1"
version = "0.218.0-nightly.0"
dependencies = [
"fabro-http",
"fabro-interview",
@ -2183,24 +2213,25 @@ dependencies = [
[[package]]
name = "fabro-spa"
version = "0.214.0-nightly.1"
version = "0.218.0-nightly.0"
dependencies = [
"rust-embed",
]
[[package]]
name = "fabro-static"
version = "0.214.0-nightly.1"
version = "0.218.0-nightly.0"
[[package]]
name = "fabro-store"
version = "0.214.0-nightly.1"
version = "0.218.0-nightly.0"
dependencies = [
"async-trait",
"bytes",
"chrono",
"dashmap",
"fabro-types",
"fabro-util",
"futures",
"hex",
"insta",
@ -2220,7 +2251,7 @@ dependencies = [
[[package]]
name = "fabro-telemetry"
version = "0.214.0-nightly.1"
version = "0.218.0-nightly.0"
dependencies = [
"anyhow",
"base64",
@ -2246,7 +2277,7 @@ dependencies = [
[[package]]
name = "fabro-template"
version = "0.214.0-nightly.1"
version = "0.218.0-nightly.0"
dependencies = [
"anyhow",
"fabro-util",
@ -2258,7 +2289,7 @@ dependencies = [
[[package]]
name = "fabro-test"
version = "0.214.0-nightly.1"
version = "0.218.0-nightly.0"
dependencies = [
"assert_cmd",
"axum",
@ -2281,7 +2312,7 @@ dependencies = [
[[package]]
name = "fabro-tracker"
version = "0.214.0-nightly.1"
version = "0.218.0-nightly.0"
dependencies = [
"async-trait",
"fabro-github",
@ -2294,7 +2325,7 @@ dependencies = [
[[package]]
name = "fabro-types"
version = "0.214.0-nightly.1"
version = "0.218.0-nightly.0"
dependencies = [
"chrono",
"clap",
@ -2310,11 +2341,12 @@ dependencies = [
"tempfile",
"toml 0.8.23",
"ulid",
"url",
]
[[package]]
name = "fabro-util"
version = "0.214.0-nightly.1"
version = "0.218.0-nightly.0"
dependencies = [
"anyhow",
"console 0.15.11",
@ -2334,7 +2366,7 @@ dependencies = [
[[package]]
name = "fabro-validate"
version = "0.214.0-nightly.1"
version = "0.218.0-nightly.0"
dependencies = [
"fabro-graphviz",
"fabro-model",
@ -2344,9 +2376,10 @@ dependencies = [
[[package]]
name = "fabro-vault"
version = "0.214.0-nightly.1"
version = "0.218.0-nightly.0"
dependencies = [
"chrono",
"fabro-types",
"serde",
"serde_json",
"tempfile",
@ -2355,7 +2388,7 @@ dependencies = [
[[package]]
name = "fabro-workflow"
version = "0.214.0-nightly.1"
version = "0.218.0-nightly.0"
dependencies = [
"anyhow",
"assert_cmd",
@ -4520,7 +4553,7 @@ dependencies = [
"md-5",
"parking_lot",
"percent-encoding",
"quick-xml",
"quick-xml 0.38.4",
"rand 0.9.4",
"reqwest 0.12.28",
"ring",
@ -5154,6 +5187,15 @@ dependencies = [
"psl-types",
]
[[package]]
name = "quick-xml"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe"
dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.38.4"
@ -7086,7 +7128,7 @@ dependencies = [
[[package]]
name = "twin-github"
version = "0.214.0-nightly.1"
version = "0.218.0-nightly.0"
dependencies = [
"axum",
"base64",
@ -7105,7 +7147,7 @@ dependencies = [
[[package]]
name = "twin-openai"
version = "0.214.0-nightly.1"
version = "0.218.0-nightly.0"
dependencies = [
"anyhow",
"async-stream",

View file

@ -5,7 +5,7 @@ resolver = "2"
[workspace.package]
edition = "2021"
version = "0.214.0-nightly.1"
version = "0.218.0-nightly.0"
license = "MIT"
[workspace.dependencies]
@ -130,6 +130,14 @@ absolute_paths = "warn"
lto = "thin"
strip = true
[profile.dev]
debug = "line-tables-only"
split-debuginfo = "off"
[profile.test]
debug = "line-tables-only"
split-debuginfo = "off"
[profile.dev.package."*"]
debug = false # Disable debug info for all dependencies
opt-level = 1 # Shrinks monomorphized generics, reducing test binary size

View file

@ -3,8 +3,8 @@
# Runtime image for the Fabro server.
#
# Binaries are supplied pre-built via the release workflow:
# docker-context/amd64/fabro (x86_64-unknown-linux-musl)
# docker-context/arm64/fabro (aarch64-unknown-linux-musl)
# tmp/docker-context/amd64/fabro (x86_64-unknown-linux-musl)
# tmp/docker-context/arm64/fabro (aarch64-unknown-linux-musl)
#
# The image serves the HTTP API (with embedded web UI) on $PORT (default
# 32276), persists state to /storage, and runs as the unprivileged `fabro`
@ -26,12 +26,13 @@ RUN apk add --no-cache \
&& adduser -S -u 1000 -G fabro -h /var/fabro -s /sbin/nologin fabro \
&& install -d -o fabro -g fabro -m 0755 /var/fabro /storage
COPY --chmod=0755 docker-context/${TARGETARCH}/fabro /usr/local/bin/fabro
COPY --chmod=0755 tmp/docker-context/${TARGETARCH}/fabro /usr/local/bin/fabro
COPY --chmod=0755 docker/entrypoint.sh /usr/local/bin/fabro-entrypoint
ENV FABRO_HOME=/storage/.home \
FABRO_STORAGE_DIR=/storage
FABRO_STORAGE_DIR=/storage \
FABRO_LOG_DESTINATION=stdout
VOLUME ["/storage"]
EXPOSE 32276

View file

@ -1,5 +1,5 @@
<div align="left" id="top">
<a href="https://docs.fabro.sh"><img alt="Fabro" src="docs/logo/dark.svg" height="75"></a>
<a href="https://docs.fabro.sh"><img alt="Fabro" src="docs/public/logo/dark.svg" height="75"></a>
</div>
## The open source dark software factory for expert engineers
@ -27,7 +27,7 @@ curl -fsSL https://fabro.sh/install.sh | bash
Then run `fabro server start` to finish setup in your browser. The server opens a web wizard, exits when the wizard completes, and starts in configured mode the next time you run it.
<img src="docs/images/runs-board.png" alt="Fabro Runs board showing workflows across Working, Pending, Verify, and Merge stages" />
<img src="docs/public/images/runs-board.png" alt="Fabro Runs board showing workflows across Working, Pending, Verify, and Merge stages" />
---
@ -67,7 +67,7 @@ Then run `fabro server start` to finish setup in your browser. The server opens
A plan-approve-implement workflow where a human reviews the plan before the agent writes code:
<img src="docs/images/plan-implement-readme.svg" alt="Plan-Implement workflow graph showing Start → Plan → Approve Plan → Implement → Simplify → Exit with a Revise loop" />
<img src="docs/public/images/plan-implement-readme.svg" alt="Plan-Implement workflow graph showing Start → Plan → Approve Plan → Implement → Simplify → Exit with a Revise loop" />
```dot
digraph PlanImplement {

View file

@ -7,7 +7,7 @@ import {
PauseCircleIcon,
XCircleIcon,
} from "@heroicons/react/24/solid";
import { DocumentTextIcon, MapIcon } from "@heroicons/react/24/outline";
import { Bars3BottomLeftIcon, DocumentTextIcon, MapIcon } from "@heroicons/react/24/outline";
import { formatDurationSecs } from "../lib/format";
export type StageStatus = "completed" | "running" | "pending" | "failed" | "cancelled";
@ -32,7 +32,7 @@ interface StageSidebarProps {
stages: Stage[];
runId: string;
selectedStageId?: string;
activeLink?: "settings" | "graph";
activeLink?: "settings" | "graph" | "logs";
}
export function StageSidebar({ stages, runId, selectedStageId, activeLink }: StageSidebarProps) {
@ -135,6 +135,19 @@ export function StageSidebar({ stages, runId, selectedStageId, activeLink }: Sta
Workflow Graph
</Link>
</li>
<li>
<Link
to={`/runs/${runId}/logs`}
className={`${linkBase} ${
activeLink === "logs"
? "bg-overlay text-fg"
: "text-fg-3 hover:bg-overlay hover:text-fg"
}`}
>
<Bars3BottomLeftIcon className="size-4 shrink-0 text-fg-muted" />
Run Logs
</Link>
</li>
</ul>
</div>
</nav>

View file

@ -16,7 +16,7 @@ describe("mapRunListItem", () => {
title: "Server supplied title",
workflow_slug: "fix_build",
workflow_name: "Fix Build",
host_repo_path: "/home/user/myrepo",
source_directory: "/home/user/myrepo",
repository: { name: "myrepo" },
status: { kind: "paused", prior_block: null },
labels: {},
@ -33,6 +33,7 @@ describe("mapRunListItem", () => {
expect(item.title).toBe("Server supplied title");
expect(item.workflow).toBe("fix_build");
expect(item.repo).toBe("myrepo");
expect(item.sourceDirectory).toBe("/home/user/myrepo");
expect(item.elapsed).toBeDefined();
expect(item.column).toBe("running");
expect(item.lifecycleStatus).toBe("paused");
@ -45,7 +46,7 @@ describe("mapRunListItem", () => {
title: "",
workflow_slug: "fix_build",
workflow_name: "Fix Build",
host_repo_path: "/home/user/myrepo",
source_directory: "/home/user/myrepo",
repository: { name: "myrepo" },
status: { kind: "running" },
labels: {},
@ -70,7 +71,7 @@ describe("mapRunSummaryToRunItem", () => {
title: "Fix the build",
workflow_slug: "fix_build",
workflow_name: "Fix Build",
host_repo_path: "/home/user/myrepo",
source_directory: "/home/user/myrepo",
repository: { name: "myrepo" },
status: { kind: "running" },
duration_ms: 65000,
@ -86,6 +87,7 @@ describe("mapRunSummaryToRunItem", () => {
expect(item.title).toBe("Fix the build");
expect(item.workflow).toBe("fix_build");
expect(item.repo).toBe("myrepo");
expect(item.sourceDirectory).toBe("/home/user/myrepo");
expect(item.elapsed).toBeDefined();
expect(item.lifecycleStatus).toBe("running");
});
@ -97,7 +99,7 @@ describe("mapRunSummaryToRunItem", () => {
title: "",
workflow_slug: null,
workflow_name: null,
host_repo_path: null,
source_directory: null,
repository: { name: "unknown" },
status: { kind: "submitted" },
duration_ms: null,
@ -113,6 +115,7 @@ describe("mapRunSummaryToRunItem", () => {
expect(item.title).toBe("Untitled run");
expect(item.workflow).toBe("unknown");
expect(item.repo).toBe("unknown");
expect(item.sourceDirectory).toBeUndefined();
});
test("recognizes canonical blocked and queued run statuses", () => {

View file

@ -2,7 +2,7 @@ import { formatElapsedSecs, formatDurationSecs } from "../lib/format";
import type {
RunListItem,
RunStatus as ApiRunStatus,
StoreRunSummary,
RunSummary,
} from "@qltysh/fabro-api-client";
export type CiStatus = "passing" | "failing" | "pending";
@ -33,6 +33,8 @@ export interface RunItem {
comments?: number;
question?: string;
sandboxId?: string;
sandboxWorkingDirectory?: string;
sourceDirectory?: string;
}
export type ColumnStatus = "initializing" | "running" | "blocked" | "succeeded" | "failed";
@ -80,13 +82,15 @@ export function mapRunListItem(item: RunListItem): RunItem {
resources: item.sandbox?.resources ? `${item.sandbox.resources.cpu} CPU / ${item.sandbox.resources.memory} GB` : undefined,
comments: item.pull_request?.comments,
question: item.question?.text,
sandboxId: item.sandbox?.id,
sandboxId: item.sandbox?.id ?? undefined,
sandboxWorkingDirectory: item.sandbox?.working_directory ?? undefined,
sourceDirectory: item.source_directory ?? undefined,
};
}
export type RunSummaryResponse = StoreRunSummary;
export type { RunSummary };
export function mapRunSummaryToRunItem(summary: RunSummaryResponse): RunItem {
export function mapRunSummaryToRunItem(summary: RunSummary): RunItem {
const lifecycleStatus = runStatusKind(summary.status);
return {
id: summary.run_id,
@ -95,6 +99,7 @@ export function mapRunSummaryToRunItem(summary: RunSummaryResponse): RunItem {
workflow: summary.workflow_slug ?? summary.workflow_name ?? "unknown",
lifecycleStatus,
lifecycleStatusLabel: lifecycleStatusLabel(summary.status),
sourceDirectory: summary.source_directory ?? undefined,
elapsed:
summary.elapsed_secs != null
? formatElapsedSecs(summary.elapsed_secs)

View file

@ -2,8 +2,10 @@ import { describe, expect, test } from "bun:test";
import {
putInstallObjectStore,
putInstallSandbox,
readInstallError,
testInstallObjectStore,
testInstallSandbox,
} from "./install-api";
describe("readInstallError", () => {
@ -92,3 +94,47 @@ describe("install object-store requests", () => {
).rejects.toThrow("Bucket is required.");
});
});
describe("install sandbox requests", () => {
test("testInstallSandbox posts the install payload to the validation endpoint", async () => {
const calls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
calls.push({ input, init });
return Promise.resolve(new Response(JSON.stringify({ ok: true }), { status: 200 }));
}) as typeof fetch;
await testInstallSandbox("test-install-token", {
provider: "daytona",
api_key: "dtn_test",
});
expect(calls).toHaveLength(1);
expect(String(calls[0]!.input)).toBe("/install/sandbox/test");
expect(calls[0]!.init?.method).toBe("POST");
expect(calls[0]!.init?.body).toBe(
JSON.stringify({ provider: "daytona", api_key: "dtn_test" }),
);
});
test("putInstallSandbox surfaces structured API errors", async () => {
globalThis.fetch = (() =>
Promise.resolve(
new Response(
JSON.stringify({
errors: [
{
status: "422",
title: "Unprocessable Entity",
detail: "api_key is required for daytona",
},
],
}),
{ status: 422, headers: { "Content-Type": "application/json" } },
),
)) as typeof fetch;
await expect(
putInstallSandbox("test-install-token", { provider: "daytona" }),
).rejects.toThrow("api_key is required for daytona");
});
});

View file

@ -6,6 +6,8 @@ import type {
InstallLlmProviderInput,
InstallObjectStoreInput,
InstallObjectStoreSummary,
InstallSandboxInput,
InstallSandboxSummary,
InstallSessionResponse,
} from "@qltysh/fabro-api-client";
@ -17,6 +19,8 @@ export type {
InstallLlmProviderInput,
InstallObjectStoreInput,
InstallObjectStoreSummary,
InstallSandboxInput,
InstallSandboxSummary,
InstallSessionResponse,
};
@ -157,6 +161,30 @@ export async function putInstallObjectStore(
});
}
export async function testInstallSandbox(
token: string,
input: InstallSandboxInput,
): Promise<void> {
await installRequest(token, {
path: "/install/sandbox/test",
method: "POST",
body: input,
errorFallback: "install sandbox validation failed",
});
}
export async function putInstallSandbox(
token: string,
input: InstallSandboxInput,
): Promise<void> {
await installRequest(token, {
path: "/install/sandbox",
method: "PUT",
body: input,
errorFallback: "install sandbox request failed",
});
}
export async function testInstallGithubToken(
token: string,
githubToken: string,

View file

@ -102,6 +102,25 @@ function renderTreeText(
return (node.children ?? []).map(renderTreeText).join("");
}
function findOptionButton(
renderer: TestRenderer.ReactTestRenderer,
title: string,
): TestRenderer.ReactTestInstance {
const button = renderer.root.findAll((node) => {
if (node.type !== "button" || node.props["aria-pressed"] === undefined) {
return false;
}
const titleSpan = node.findAll(
(child) => child.type === "span" && child.children[0] === title,
);
return titleSpan.length > 0;
})[0];
if (!button) {
throw new Error(`option button "${title}" not found`);
}
return button;
}
async function waitFor(assertion: () => void, timeoutMs = 1000): Promise<void> {
const deadline = Date.now() + timeoutMs;
let lastError: unknown;
@ -249,7 +268,7 @@ describe("InstallApp", () => {
}
});
test("saves local disk object-store settings and advances to the LLM step", async () => {
test("saves local disk object-store settings and advances to the sandbox step", async () => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const originalConsoleError = console.error;
console.error = ((...args: unknown[]) => {
@ -335,7 +354,7 @@ describe("InstallApp", () => {
});
await waitFor(() => {
expect(renderTreeText(renderer!.toJSON())).toContain("Add your LLM credentials");
expect(renderTreeText(renderer!.toJSON())).toContain("Choose the sandbox runtime");
});
const backLink = renderer!.root.findAll(
(node) =>
@ -510,4 +529,346 @@ describe("InstallApp", () => {
console.error = originalConsoleError;
}
});
test("shows the sandbox provider on the review step", async () => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const originalConsoleError = console.error;
console.error = ((...args: unknown[]) => {
if (
typeof args[0] === "string" &&
args[0].startsWith("react-test-renderer is deprecated")
) {
return;
}
originalConsoleError(...args);
}) as typeof console.error;
try {
const fetchMock = mock(() =>
Promise.resolve(
new Response(
JSON.stringify({
completed_steps: ["server", "object_store", "sandbox", "llm", "github"],
llm: { providers: [{ provider: "anthropic" }] },
server: { canonical_url: "https://fabro.example.com" },
object_store: { provider: "local" },
sandbox: { provider: "daytona", api_key_saved: true },
github: { strategy: "token", username: "octocat" },
prefill: INSTALL_PREFILL,
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
),
);
globalThis.fetch = fetchMock as typeof fetch;
const testWindow = createTestWindow("https://fabro.example.com/install/review");
testWindow.sessionStorage.setItem("fabro-install-token", "test-install-token");
(globalThis as { window?: unknown }).window = testWindow;
let renderer: TestRenderer.ReactTestRenderer | null = null;
await act(async () => {
renderer = TestRenderer.create(
<MemoryRouter initialEntries={["/install/review"]}>
<Routes>
<Route path="/install/*" element={<InstallApp />} />
</Routes>
</MemoryRouter>,
);
});
await waitFor(() => {
const text = renderTreeText(renderer!.toJSON());
expect(text).toContain("Daytona");
expect(text).toContain("Saved");
});
await act(async () => {
renderer?.unmount();
});
} finally {
console.error = originalConsoleError;
}
});
test("validates Daytona key, saves sandbox, and advances to the LLM step", async () => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const originalConsoleError = console.error;
console.error = ((...args: unknown[]) => {
if (
typeof args[0] === "string" &&
args[0].startsWith("react-test-renderer is deprecated")
) {
return;
}
originalConsoleError(...args);
}) as typeof console.error;
try {
const fetchCalls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
const fetchMock = mock((input: RequestInfo | URL, init?: RequestInit) => {
fetchCalls.push({ input, init });
if (String(input) === "/install/session" && fetchCalls.length === 1) {
return Promise.resolve(
new Response(
JSON.stringify({
completed_steps: ["server", "object_store"],
llm: null,
server: { canonical_url: "https://fabro.example.com" },
object_store: { provider: "local" },
sandbox: null,
github: null,
prefill: INSTALL_PREFILL,
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
);
}
if (
String(input) === "/install/sandbox/test"
|| String(input) === "/install/sandbox"
) {
return Promise.resolve(
new Response(JSON.stringify({ ok: true }), {
status: String(input).endsWith("/test") ? 200 : 204,
}),
);
}
if (String(input) === "/install/session") {
return Promise.resolve(
new Response(
JSON.stringify({
completed_steps: ["server", "object_store", "sandbox"],
llm: null,
server: { canonical_url: "https://fabro.example.com" },
object_store: { provider: "local" },
sandbox: { provider: "daytona", api_key_saved: true },
github: null,
prefill: INSTALL_PREFILL,
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
);
}
throw new Error(`unexpected fetch: ${String(input)}`);
});
globalThis.fetch = fetchMock as typeof fetch;
const testWindow = createTestWindow("https://fabro.example.com/install/sandbox");
testWindow.sessionStorage.setItem("fabro-install-token", "test-install-token");
(globalThis as { window?: unknown }).window = testWindow;
let renderer: TestRenderer.ReactTestRenderer | null = null;
await act(async () => {
renderer = TestRenderer.create(
<MemoryRouter initialEntries={["/install/sandbox"]}>
<Routes>
<Route path="/install/*" element={<InstallApp />} />
</Routes>
</MemoryRouter>,
);
});
await waitFor(() => {
expect(renderTreeText(renderer!.toJSON())).toContain("Choose the sandbox runtime");
});
const daytonaButton = findOptionButton(renderer!, "Daytona");
await act(async () => {
daytonaButton.props.onClick();
});
const apiKeyInput = renderer!.root.findByProps({ name: "sandbox_api_key" });
await act(async () => {
apiKeyInput.props.onChange({ target: { value: "dtn_secret" } });
});
const form = renderer!.root.findByType("form");
await act(async () => {
form.props.onSubmit({ preventDefault() {} });
});
await waitFor(() => {
expect(renderTreeText(renderer!.toJSON())).toContain("Add your LLM credentials");
});
const calls = fetchCalls.map((call) => String(call.input));
const testIdx = calls.indexOf("/install/sandbox/test");
const putIdx = calls.indexOf("/install/sandbox");
expect(testIdx).toBeGreaterThanOrEqual(0);
expect(putIdx).toBeGreaterThan(testIdx);
const sandboxTestCall = fetchCalls[testIdx];
expect(sandboxTestCall?.init?.body).toBe(
JSON.stringify({ provider: "daytona", api_key: "dtn_secret" }),
);
await act(async () => {
renderer?.unmount();
});
} finally {
console.error = originalConsoleError;
}
});
test("blocks Daytona save when the API key is missing", async () => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const originalConsoleError = console.error;
console.error = ((...args: unknown[]) => {
if (
typeof args[0] === "string" &&
args[0].startsWith("react-test-renderer is deprecated")
) {
return;
}
originalConsoleError(...args);
}) as typeof console.error;
try {
const fetchCalls: Array<{ input: RequestInfo | URL }> = [];
const fetchMock = mock((input: RequestInfo | URL) => {
fetchCalls.push({ input });
if (String(input) === "/install/session") {
return Promise.resolve(
new Response(
JSON.stringify({
completed_steps: ["server", "object_store"],
llm: null,
server: { canonical_url: "https://fabro.example.com" },
object_store: { provider: "local" },
sandbox: null,
github: null,
prefill: INSTALL_PREFILL,
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
);
}
throw new Error(`unexpected fetch: ${String(input)}`);
});
globalThis.fetch = fetchMock as typeof fetch;
const testWindow = createTestWindow("https://fabro.example.com/install/sandbox");
testWindow.sessionStorage.setItem("fabro-install-token", "test-install-token");
(globalThis as { window?: unknown }).window = testWindow;
let renderer: TestRenderer.ReactTestRenderer | null = null;
await act(async () => {
renderer = TestRenderer.create(
<MemoryRouter initialEntries={["/install/sandbox"]}>
<Routes>
<Route path="/install/*" element={<InstallApp />} />
</Routes>
</MemoryRouter>,
);
});
await waitFor(() => {
expect(renderTreeText(renderer!.toJSON())).toContain("Choose the sandbox runtime");
});
const daytonaButton = findOptionButton(renderer!, "Daytona");
await act(async () => {
daytonaButton.props.onClick();
});
const form = renderer!.root.findByType("form");
await act(async () => {
form.props.onSubmit({ preventDefault() {} });
});
await waitFor(() => {
expect(renderTreeText(renderer!.toJSON())).toContain(
"Enter the Daytona API key before continuing.",
);
});
expect(fetchCalls.map((call) => String(call.input))).toEqual([
"/install/session",
]);
await act(async () => {
renderer?.unmount();
});
} finally {
console.error = originalConsoleError;
}
});
test("shows the GitHub App callback URL on the review step", async () => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const originalConsoleError = console.error;
console.error = ((...args: unknown[]) => {
if (
typeof args[0] === "string" &&
args[0].startsWith("react-test-renderer is deprecated")
) {
return;
}
originalConsoleError(...args);
}) as typeof console.error;
try {
const fetchMock = mock((input: RequestInfo | URL) => {
expect(String(input)).toBe("/install/session");
return Promise.resolve(
new Response(
JSON.stringify({
completed_steps: ["server", "object_store", "llm", "github"],
llm: {
providers: [{ provider: "anthropic" }],
},
server: { canonical_url: "https://fabro.example.com" },
object_store: { provider: "local" },
github: {
strategy: "app",
owner: { kind: "personal" },
app_name: "octocat-fabro",
slug: "octocat-fabro",
allowed_username: "octocat",
},
prefill: INSTALL_PREFILL,
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
);
});
globalThis.fetch = fetchMock as typeof fetch;
const testWindow = createTestWindow("https://fabro.example.com/install/review");
testWindow.sessionStorage.setItem("fabro-install-token", "test-install-token");
(globalThis as { window?: unknown }).window = testWindow;
let renderer: TestRenderer.ReactTestRenderer | null = null;
await act(async () => {
renderer = TestRenderer.create(
<MemoryRouter initialEntries={["/install/review"]}>
<Routes>
<Route path="/install/*" element={<InstallApp />} />
</Routes>
</MemoryRouter>,
);
});
await waitFor(() => {
const text = renderTreeText(renderer!.toJSON());
expect(text).toContain("GitHub callback URL");
expect(text).toContain("https://fabro.example.com/auth/callback/github");
});
await act(async () => {
renderer?.unmount();
});
} finally {
console.error = originalConsoleError;
}
});
});

View file

@ -19,6 +19,7 @@ import {
type InstallGithubAppOwner,
type InstallLlmProviderInput,
type InstallObjectStoreInput,
type InstallSandboxInput,
type InstallSessionResponse,
createInstallGithubAppManifest,
finishInstall,
@ -27,11 +28,13 @@ import {
putInstallGithubToken,
putInstallLlm,
putInstallObjectStore,
putInstallSandbox,
putInstallServer,
readStoredInstallToken,
testInstallGithubToken,
testInstallLlm,
testInstallObjectStore,
testInstallSandbox,
} from "./install-api";
import { INSTALL_PROVIDERS } from "./install-config";
import { shouldRedirectAfterHealthPoll } from "./install-flow";
@ -52,7 +55,8 @@ import { LoadingState } from "./components/state";
const INSTALL_STEPS = [
{ id: "welcome", label: "Welcome", href: "/install/welcome" },
{ id: "server", label: "Server", href: "/install/server" },
{ id: "object_store", label: "Object store", href: "/install/object-store" },
{ id: "object_store", label: "Storage", href: "/install/object-store" },
{ id: "sandbox", label: "Sandbox", href: "/install/sandbox" },
{ id: "llm", label: "LLMs", href: "/install/llm" },
{ id: "github", label: "GitHub", href: "/install/github" },
{ id: "review", label: "Review", href: "/install/review" },
@ -92,6 +96,12 @@ type ObjectStoreForm = {
secretAccessKey: string;
manualCredentialsSaved: boolean;
};
type SandboxProvider = NonNullable<InstallSandboxInput["provider"]>;
type SandboxForm = {
provider: SandboxProvider;
apiKey: string;
apiKeySaved: boolean;
};
export default function InstallApp() {
const navigate = useNavigate();
@ -108,6 +118,9 @@ export default function InstallApp() {
const [objectStoreForm, setObjectStoreForm] = useState<ObjectStoreForm>(() =>
defaultObjectStoreForm(),
);
const [sandboxForm, setSandboxForm] = useState<SandboxForm>(() =>
defaultSandboxForm(),
);
const [canonicalUrl, setCanonicalUrl] = useState("");
const [githubStrategy, setGithubStrategy] = useState<GithubStrategy>("token");
const [tokenForm, setTokenForm] = useState<TokenForm>({ token: "", username: "" });
@ -126,6 +139,7 @@ export default function InstallApp() {
const regionInputRef = useRef<HTMLInputElement>(null);
const accessKeyIdInputRef = useRef<HTMLInputElement>(null);
const secretAccessKeyInputRef = useRef<HTMLInputElement>(null);
const sandboxApiKeyInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const { token, sanitizedUrl } = consumeInstallTokenFromUrl(window.location.href);
@ -164,6 +178,7 @@ export default function InstallApp() {
current || nextSession.server?.canonical_url || nextSession.prefill.canonical_url,
);
setObjectStoreForm(hydrateObjectStoreForm(nextSession));
setSandboxForm((current) => hydrateSandboxForm(current, nextSession));
setLlmSelection((current) =>
hydrateProviderSelection(current, nextSession),
);
@ -296,6 +311,7 @@ export default function InstallApp() {
if (args.next) {
const nextSession = await getInstallSession(installToken);
setSessionState({ status: "ready", data: nextSession });
setSandboxForm((current) => hydrateSandboxForm(current, nextSession));
navigate(args.next);
}
} catch (error) {
@ -346,7 +362,7 @@ export default function InstallApp() {
description="Each key you enter is validated before it's saved. Skip a provider by leaving it blank."
error={saveError}
submitting={submitting}
backHref="/install/object-store"
backHref="/install/sandbox"
onSubmit={async () => {
const providers = INSTALL_PROVIDERS.map(({ id }) => {
const current = llmSelection[id] ?? { apiKey: "" };
@ -469,7 +485,7 @@ export default function InstallApp() {
await putInstallObjectStore(installToken, payload);
},
fallback: "Failed to save object-store settings.",
next: "/install/llm",
next: "/install/sandbox",
});
}}
>
@ -611,6 +627,88 @@ export default function InstallApp() {
</div>
)}
</StepPanel>
) : location.pathname === "/install/sandbox" ? (
<StepPanel
title="Choose the sandbox runtime"
description="Workflows run inside this sandbox. Docker uses the host daemon; Daytona runs each sandbox in its cloud."
error={saveError}
submitting={submitting}
submittingLabel={
sandboxForm.provider === "daytona" ? "Checking access..." : "Saving..."
}
backHref="/install/object-store"
onSubmit={async () => {
if (sandboxForm.provider === "daytona") {
const apiKey = sandboxForm.apiKey.trim();
const keepStoredKey = sandboxForm.apiKeySaved && !apiKey;
if (!keepStoredKey && !apiKey) {
setSaveError("Enter the Daytona API key before continuing.");
focusInput(sandboxApiKeyInputRef);
return;
}
}
const payload = buildSandboxPayload(sandboxForm);
await runStepSubmit({
action: async () => {
if (sandboxForm.provider === "daytona") {
await testInstallSandbox(installToken, payload);
}
await putInstallSandbox(installToken, payload);
},
fallback: "Failed to save sandbox settings.",
next: "/install/llm",
});
}}
>
<CardPicker
legend="Sandbox runtime"
options={SANDBOX_PROVIDER_OPTIONS}
value={sandboxForm.provider}
onChange={(provider) => {
setSandboxForm((current) => ({ ...current, provider }));
if (provider === "daytona") {
focusInput(sandboxApiKeyInputRef);
}
}}
/>
{sandboxForm.provider === "daytona" ? (
<div className="space-y-5">
<Field
label="Daytona API key"
hint={
sandboxForm.apiKeySaved
? "A key is already saved. Leave blank to keep using it."
: "Stored in the vault and exported to workflows as DAYTONA_API_KEY."
}
>
<input
ref={sandboxApiKeyInputRef}
type="password"
name="sandbox_api_key"
value={sandboxForm.apiKey}
onChange={(event) =>
setSandboxForm((current) => ({
...current,
apiKey: event.target.value,
}))
}
className={`${INPUT_CLASS} font-mono`}
placeholder={
sandboxForm.apiKeySaved ? "•••• (saved)" : "dtn_..."
}
autoComplete="off"
spellCheck={false}
/>
</Field>
</div>
) : (
<p className="rounded-lg bg-overlay px-4 py-3 text-sm/6 text-fg-3 outline-1 -outline-offset-1 outline-white/10">
Fabro will use the host Docker daemon. Make sure the server has
access to <code className="font-mono text-fg-2">/var/run/docker.sock</code>.
</p>
)}
</StepPanel>
) : location.pathname === "/install/github/done" ? (
<GithubAppDoneScreen github={session?.github} />
) : location.pathname === "/install/github" ? (
@ -1018,8 +1116,8 @@ function WelcomeScreen() {
</h1>
<p className="mt-4 max-w-[56ch] text-base/7 text-fg-3 text-pretty sm:text-[0.9375rem]/7">
A short walkthrough to confirm the public server URL, choose the shared
object store, validate your LLM credentials, and connect GitHub. When
you finish, Fabro restarts into normal mode.
object store and sandbox runtime, validate your LLM credentials, and
connect GitHub. When you finish, Fabro restarts into normal mode.
</p>
<ol role="list" className="mt-10 divide-y divide-line border-y border-line">
{[
@ -1028,6 +1126,7 @@ function WelcomeScreen() {
"Object store",
"Choose local disk or AWS S3 for SlateDB and artifacts.",
],
["Sandbox", "Choose Docker or Daytona for workflow execution."],
["LLMs", "Validate API keys for Anthropic, OpenAI, or Gemini."],
["GitHub", "Choose a personal access token or a GitHub App."],
["Review", "Double-check the plan, then write the files."],
@ -1165,8 +1264,9 @@ function ReviewScreen({
action={<CopyButton value={serverUrl} label="Copy server URL" />}
/>
{renderObjectStoreSummaryRows(session?.object_store)}
{renderSandboxSummaryRows(session?.sandbox)}
<SummaryRow label="LLM providers" value={providers || "Not configured"} />
{renderGithubSummaryRows(session?.github)}
{renderGithubSummaryRows(session?.github, serverUrl)}
</dl>
{error ? <ErrorMessage message={error} /> : null}
<div className="flex items-center justify-between gap-3 pt-2">
@ -1361,6 +1461,19 @@ const OBJECT_STORE_CREDENTIAL_MODE_OPTIONS: ReadonlyArray<
},
];
const SANDBOX_PROVIDER_OPTIONS: ReadonlyArray<CardOption<SandboxProvider>> = [
{
id: "docker",
title: "Docker",
body: "Default. Uses the host Docker daemon to run sandbox containers.",
},
{
id: "daytona",
title: "Daytona",
body: "Each run gets a managed Daytona cloud sandbox. Requires an API key.",
},
];
const GITHUB_OWNER_OPTIONS: ReadonlyArray<CardOption<GithubOwnerKind>> = [
{
id: "personal",
@ -1716,6 +1829,40 @@ function buildObjectStorePayload(form: ObjectStoreForm): InstallObjectStoreInput
return payload;
}
function defaultSandboxForm(): SandboxForm {
return { provider: "docker", apiKey: "", apiKeySaved: false };
}
function hydrateSandboxForm(
current: SandboxForm,
session: InstallSessionResponse,
): SandboxForm {
const summary = session.sandbox;
if (!summary) {
return current.apiKey ? { ...current, apiKeySaved: false } : defaultSandboxForm();
}
if (current.apiKey) {
return { ...current, apiKeySaved: Boolean(summary.api_key_saved) };
}
return {
provider: summary.provider === "daytona" ? "daytona" : "docker",
apiKey: "",
apiKeySaved: Boolean(summary.api_key_saved),
};
}
function buildSandboxPayload(form: SandboxForm): InstallSandboxInput {
if (form.provider === "docker") {
return { provider: "docker" };
}
const apiKey = form.apiKey.trim();
const payload: InstallSandboxInput = { provider: "daytona" };
if (apiKey) {
payload.api_key = apiKey;
}
return payload;
}
function focusInput(ref: { current: HTMLInputElement | null }): void {
window.setTimeout(() => ref.current?.focus(), 0);
}
@ -1727,6 +1874,7 @@ function describeProvider(id: string): string {
function renderGithubSummaryRows(
github: InstallSessionResponse["github"],
serverUrl: string,
): ReactNode {
if (!github) {
return <SummaryRow label="GitHub" value="Not configured" />;
@ -1741,6 +1889,11 @@ function renderGithubSummaryRows(
value={github.allowed_username ? `@${github.allowed_username}` : "Not set"}
mono={Boolean(github.allowed_username)}
/>
<SummaryRow
label="GitHub callback URL"
value={githubCallbackUrl(serverUrl)}
mono
/>
</>
);
}
@ -1756,6 +1909,10 @@ function renderGithubSummaryRows(
);
}
function githubCallbackUrl(serverUrl: string): string {
return `${serverUrl.replace(/\/+$/, "")}/auth/callback/github`;
}
function renderObjectStoreSummaryRows(
objectStore: InstallSessionResponse["object_store"],
): ReactNode {
@ -1788,6 +1945,26 @@ function renderObjectStoreSummaryRows(
);
}
function renderSandboxSummaryRows(
sandbox: InstallSessionResponse["sandbox"],
): ReactNode {
if (!sandbox) {
return <SummaryRow label="Sandbox" value="Not configured" />;
}
if (sandbox.provider === "daytona") {
return (
<>
<SummaryRow label="Sandbox" value="Daytona" />
<SummaryRow
label="Daytona API key"
value={sandbox.api_key_saved ? "Saved" : "Not set"}
/>
</>
);
}
return <SummaryRow label="Sandbox" value="Docker" />;
}
function describeGithubAppOwner(
owner: InstallGithubAppOwner | undefined,
): string {

View file

@ -19,6 +19,16 @@ export function formatElapsedSecs(secs: number): string {
return remainHrs > 0 ? `${days}d ${remainHrs}h` : `${days}d`;
}
/**
* Format a byte count for display (e.g., "1.23 MB", "247.32 KB", "742 B").
*/
export function formatBytes(bytes: number): string {
if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(2)} GB`;
if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(2)} MB`;
if (bytes >= 1e3) return `${(bytes / 1e3).toFixed(2)} KB`;
return `${bytes} B`;
}
/**
* Format seconds into a duration string for display (e.g., "1m 12s", "23s").
*/

View file

@ -7,7 +7,10 @@ import type {
PaginatedRunStageList,
PaginatedStageTurnList,
RunBilling,
RunProjection,
ServerSettings,
RunSummary,
SystemInfoResponse,
} from "@qltysh/fabro-api-client";
import type { PaginatedWorkflowListResponse, WorkflowDetailResponse } from "./workflow-api";
@ -20,7 +23,6 @@ import {
type PaginatedEnvelope,
} from "./api-client";
import { queryKeys } from "./query-keys";
import type { RunSummaryResponse } from "../data/runs";
const immutableOptions: SWRConfiguration = {
revalidateIfStale: false,
@ -47,7 +49,7 @@ export function useAuthMe() {
}
export function useSystemInfo() {
return useSWR<{ features: { session_sandboxes: boolean; retros: boolean } }>(
return useSWR<SystemInfoResponse>(
queryKeys.system.info(),
apiFetcher,
immutableOptions,
@ -63,12 +65,19 @@ export function useBoardsRuns() {
}
export function useRun(id: string | undefined) {
return useSWR<RunSummaryResponse | null>(
return useSWR<RunSummary | null>(
id ? queryKeys.runs.detail(id) : null,
apiNullableFetcher,
);
}
export function useRunState(id: string | undefined) {
return useSWR<RunProjection | null>(
id ? queryKeys.runs.state(id) : null,
apiNullableFetcher,
);
}
export function useRunFiles(id: string | undefined) {
return useSWR<PaginatedRunFileList | null>(
id ? queryKeys.runs.files(id) : null,
@ -91,6 +100,21 @@ export function useRunGraph(id: string | undefined, direction?: "LR" | "TB") {
);
}
export function useRunGraphSource(id: string | undefined, enabled: boolean) {
return useSWR<string | null>(
id && enabled ? queryKeys.runs.graphSource(id) : null,
apiNullableTextFetcher,
);
}
export function useRunLogs(id: string | undefined, refreshInterval?: number) {
return useSWR<string | null>(
id ? queryKeys.runs.logs(id) : null,
apiNullableTextFetcher,
refreshInterval ? { refreshInterval } : undefined,
);
}
export function useRunSettings<T = Record<string, unknown>>(id: string | undefined) {
return useSWR<T>(
id ? queryKeys.runs.settings(id) : null,

View file

@ -28,11 +28,14 @@ export const queryKeys = {
},
runs: {
detail: (id: string) => `/api/v1/runs/${pathSegment(id)}`,
state: (id: string) => `/api/v1/runs/${pathSegment(id)}/state`,
files: (id: string) => `/api/v1/runs/${pathSegment(id)}/files`,
stages: (id: string) => `/api/v1/runs/${pathSegment(id)}/stages`,
graph: (id: string, direction?: "LR" | "TB") =>
withQuery(`/api/v1/runs/${pathSegment(id)}/graph`, { direction }),
graphSource: (id: string) => `/api/v1/runs/${pathSegment(id)}/graph/source`,
settings: (id: string) => `/api/v1/runs/${pathSegment(id)}/settings`,
logs: (id: string) => `/api/v1/runs/${pathSegment(id)}/logs`,
billing: (id: string) => `/api/v1/runs/${pathSegment(id)}/billing`,
questions: (id: string, limit = 1, offset = 0) =>
withQuery(`/api/v1/runs/${pathSegment(id)}/questions`, {

View file

@ -17,6 +17,7 @@ import * as RunOverview from "./routes/run-overview";
import * as RunStages from "./routes/run-stages";
import * as RunSettings from "./routes/run-settings";
import * as RunGraph from "./routes/run-graph";
import * as RunLogs from "./routes/run-logs";
import * as RunFiles from "./routes/run-files";
import * as RunBilling from "./routes/run-billing";
import * as Insights from "./routes/insights";
@ -92,6 +93,7 @@ export const routes: RouteObject[] = [
route("stages/:stageId", RunStages),
route("settings", RunSettings),
route("graph", RunGraph),
route("logs", RunLogs),
route("files", RunFiles),
route("billing", RunBilling),
],

View file

@ -15,6 +15,7 @@ import {
ArrowPathIcon,
PencilIcon,
} from "@heroicons/react/24/outline";
import { formatBytes } from "../lib/format";
// ── Types ──
@ -95,13 +96,6 @@ function generateMockResult(sql: string): QueryResult {
// ── Formatting helpers ──
function formatBytes(bytes: number): string {
if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(2)} GB`;
if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(2)} MB`;
if (bytes >= 1e3) return `${(bytes / 1e3).toFixed(2)} KB`;
return `${bytes} B`;
}
function formatNumber(n: number): string {
return n.toLocaleString();
}

View file

@ -97,7 +97,6 @@ describe("handleLifecycleToastResult", () => {
test("replaying the same archive success result does not enqueue a duplicate toast", () => {
const { pushed, dismissed, api } = makeToastApi();
let unarchiveClicks = 0;
const result: RunDetailActionResult = {
intent: "archive",
ok: true,
@ -111,20 +110,12 @@ describe("handleLifecycleToastResult", () => {
},
};
const firstState = handleLifecycleToastResult("archive", result, initialState, api, () => {
unarchiveClicks += 1;
});
const firstState = handleLifecycleToastResult("archive", result, initialState, api);
expect(pushed).toHaveLength(1);
expect(pushed[0]?.message).toBe("Run archived.");
expect(pushed[0]?.action?.label).toBe("Unarchive");
pushed[0]?.action?.onClick();
expect(unarchiveClicks).toBe(1);
expect(pushed).toEqual([{ message: "Run archived." }]);
expect(firstState.activeArchiveToastId).toBe("toast-1");
const replayedState = handleLifecycleToastResult("archive", result, firstState, api, () => {
unarchiveClicks += 1;
});
const replayedState = handleLifecycleToastResult("archive", result, firstState, api);
expect(pushed).toHaveLength(1);
expect(replayedState).toBe(firstState);

View file

@ -10,7 +10,7 @@ import {
isRunStatus,
mapRunSummaryToRunItem,
runStatusDisplay,
type RunSummaryResponse,
type RunSummary,
} from "../data/runs";
import { useDemoMode } from "../lib/demo-mode";
import {
@ -22,7 +22,7 @@ import {
type PreviewMutationResult,
} from "../lib/mutations";
import { useRunEvents } from "../lib/run-events";
import { useRun, useRunQuestionText } from "../lib/queries";
import { useRun, useRunQuestionText, useRunState } from "../lib/queries";
import {
canArchive,
canCancel,
@ -78,7 +78,7 @@ export function lifecycleActionVisibility(status: string | null | undefined) {
};
}
function buildRunDetailRun(summary: RunSummaryResponse): RunDetailRun {
function buildRunDetailRun(summary: RunSummary): RunDetailRun {
const item = mapRunSummaryToRunItem(summary);
const rawStatus = summary.status;
const statusKind = rawStatus.kind;
@ -94,13 +94,22 @@ function buildRunDetailRun(summary: RunSummaryResponse): RunDetailRun {
};
}
function sandboxWorkingDirectoryFromState(
state: { sandbox?: { working_directory?: unknown } | null } | null | undefined,
): string | undefined {
const value = state?.sandbox?.working_directory;
return typeof value === "string" && value.trim() ? value : undefined;
}
export function meta({ data }: any) {
const run = data?.run;
return [{ title: run ? `${run.title} — Fabro` : "Run — Fabro" }];
}
export default function RunDetail({ params }: { params: { id: string } }) {
const demoMode = useDemoMode();
const runQuery = useRun(params.id);
const runStateQuery = useRunState(demoMode ? undefined : params.id);
const run = runQuery.data ? buildRunDetailRun(runQuery.data) : null;
const statusKind = runQuery.data?.status?.kind;
const blockedQuestion = useRunQuestionText(params.id, statusKind === "blocked");
@ -111,7 +120,6 @@ export default function RunDetail({ params }: { params: { id: string } }) {
const archiveMutation = useArchiveRun(params.id);
const unarchiveMutation = useUnarchiveRun(params.id);
const { push, dismiss } = useToast();
const demoMode = useDemoMode();
const tabs = allTabs.filter((t) => !t.demoOnly || demoMode);
const lifecycleToastStateRef = useRef<LifecycleToastState>(INITIAL_LIFECYCLE_TOAST_STATE);
@ -138,9 +146,8 @@ export default function RunDetail({ params }: { params: { id: string } }) {
archiveMutation.data,
lifecycleToastStateRef.current,
{ push, dismiss },
() => void unarchiveMutation.trigger(),
);
}, [archiveMutation.data, dismiss, push, unarchiveMutation]);
}, [archiveMutation.data, dismiss, push]);
useEffect(() => {
lifecycleToastStateRef.current = handleLifecycleToastResult(
@ -171,6 +178,8 @@ export default function RunDetail({ params }: { params: { id: string } }) {
const cancelPending = cancelMutation.isMutating;
const archivePending = archiveMutation.isMutating;
const unarchivePending = unarchiveMutation.isMutating;
const sandboxWorkingDirectory =
run.sandboxWorkingDirectory ?? sandboxWorkingDirectoryFromState(runStateQuery.data);
return (
<div>
@ -201,6 +210,22 @@ export default function RunDetail({ params }: { params: { id: string } }) {
<span className="font-mono text-xs text-fg-muted">{run.elapsed}</span>
)}
</div>
{(run.sourceDirectory || sandboxWorkingDirectory) && (
<div className="mt-2 flex min-w-0 flex-wrap gap-x-4 gap-y-1 text-xs text-fg-muted">
{run.sourceDirectory && (
<span className="min-w-0 max-w-full">
<span className="text-fg-3">Source</span>{" "}
<code className="font-mono break-all">{run.sourceDirectory}</code>
</span>
)}
{sandboxWorkingDirectory && (
<span className="min-w-0 max-w-full">
<span className="text-fg-3">Sandbox</span>{" "}
<code className="font-mono break-all">{sandboxWorkingDirectory}</code>
</span>
)}
</div>
)}
</div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
@ -322,7 +347,6 @@ export function handleLifecycleToastResult(
result: RunDetailActionResult | undefined,
state: LifecycleToastState,
toastApi: ToastApi,
onUnarchive?: () => void,
): LifecycleToastState {
if (!result || result.intent !== intent) return state;
if (state.lastProcessed[intent] === result) return state;
@ -349,15 +373,9 @@ export function handleLifecycleToastResult(
}
if (intent === "archive") {
const archiveToast: Parameters<ToastApi["push"]>[0] = {
message: "Run archived.",
};
if (onUnarchive) {
archiveToast.action = { label: "Unarchive", onClick: onUnarchive };
}
return {
...nextState,
activeArchiveToastId: toastApi.push(archiveToast),
activeArchiveToastId: toastApi.push({ message: "Run archived." }),
};
}

View file

@ -1,13 +1,17 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "react-router";
import type { BundledLanguage } from "@pierre/diffs";
import { graphTheme } from "../lib/graph-theme";
import { useRunGraph, useRunStages } from "../lib/queries";
import { useRunGraph, useRunGraphSource, useRunStages } from "../lib/queries";
import { LoadingState } from "../components/state";
import { StageSidebar } from "../components/stage-sidebar";
import {
GRAPH_DEFAULT_ZOOM_INDEX,
GRAPH_ZOOM_STEPS,
GraphToolbar,
} from "../components/graph-toolbar";
import { CollapsibleFile } from "../components/collapsible-file";
import { registerDotLanguage } from "../data/register-dot-language";
import { mapRunStagesToSidebarStages } from "../lib/stage-sidebar";
export const handle = { wide: true };
@ -69,11 +73,15 @@ function stripGraphTitle(svg: SVGSVGElement) {
title.remove();
}
type View = "graph" | "source";
export default function RunGraph() {
const { id } = useParams();
const [direction, setDirection] = useState<Direction>("LR");
const [view, setView] = useState<View>("graph");
const stagesQuery = useRunStages(id);
const graphQuery = useRunGraph(id, direction);
const sourceQuery = useRunGraphSource(id, view === "source");
const stages = useMemo(
() => mapRunStagesToSidebarStages(stagesQuery.data),
[stagesQuery.data],
@ -85,6 +93,17 @@ export default function RunGraph() {
const [error, setError] = useState<string | null>(null);
const [zoomIndex, setZoomIndex] = useState(GRAPH_DEFAULT_ZOOM_INDEX);
const [pan, setPan] = useState({ x: 0, y: 0 });
const [dotReady, setDotReady] = useState(false);
useEffect(() => {
let cancelled = false;
registerDotLanguage().then(() => {
if (!cancelled) setDotReady(true);
});
return () => {
cancelled = true;
};
}, []);
const dragState = useRef<{ startX: number; startY: number; startPanX: number; startPanY: number } | null>(null);
const zoom = GRAPH_ZOOM_STEPS[zoomIndex];
@ -184,8 +203,15 @@ export default function RunGraph() {
<div className="flex gap-6">
<StageSidebar stages={stages} runId={id!} activeLink="graph" />
<div className="min-w-0 flex-1">
<div className="graph-svg relative rounded-md border border-line bg-panel-alt">
<div className="min-w-0 flex-1 space-y-3">
<div className="flex justify-end">
<ViewToggle view={view} setView={setView} />
</div>
<div
className="graph-svg relative rounded-md border border-line bg-panel-alt"
hidden={view !== "graph"}
>
<GraphToolbar
direction={direction}
setDirection={setDirection}
@ -212,7 +238,70 @@ export default function RunGraph() {
</div>
</div>
</div>
{view === "source" && (
<SourcePanel
source={sourceQuery.data}
loading={sourceQuery.data === undefined && !sourceQuery.error}
dotReady={dotReady}
/>
)}
</div>
</div>
);
}
function ViewToggle({ view, setView }: { view: View; setView: (v: View) => void }) {
const btn =
"rounded px-3 py-1.5 text-xs font-medium transition-colors";
return (
<div role="group" aria-label="Graph view" className="inline-flex rounded-md border border-line bg-panel/80 p-0.5">
<button
type="button"
onClick={() => setView("graph")}
aria-pressed={view === "graph"}
className={`${btn} ${view === "graph" ? "bg-overlay text-teal-500" : "text-fg-muted hover:text-fg-3"}`}
>
Graph
</button>
<button
type="button"
onClick={() => setView("source")}
aria-pressed={view === "source"}
className={`${btn} ${view === "source" ? "bg-overlay text-teal-500" : "text-fg-muted hover:text-fg-3"}`}
>
Source
</button>
</div>
);
}
function SourcePanel({
source,
loading,
dotReady,
}: {
source: string | null | undefined;
loading: boolean;
dotReady: boolean;
}) {
if (loading || !dotReady) {
return (
<div className="rounded-md border border-line bg-panel-alt p-4">
<LoadingState label="Loading graph source…" />
</div>
);
}
if (!source) {
return (
<div className="rounded-md border border-line bg-panel-alt p-4">
<p className="text-sm text-fg-muted">No graph source available for this run.</p>
</div>
);
}
return (
<CollapsibleFile
file={{ name: "workflow.fabro", contents: source, lang: "dot" as BundledLanguage }}
/>
);
}

View file

@ -0,0 +1,129 @@
import { useMemo } from "react";
import { useParams } from "react-router";
import { EmptyState, ErrorState, LoadingState } from "../components/state";
import { StageSidebar } from "../components/stage-sidebar";
import { CopyButton } from "../components/ui";
import { formatBytes } from "../lib/format";
import { useRun, useRunLogs, useRunStages } from "../lib/queries";
import { mapRunStagesToSidebarStages } from "../lib/stage-sidebar";
export const handle = { wide: true };
const LIVE_REFRESH_MS = 5000;
export default function RunLogs() {
const { id } = useParams();
const runQuery = useRun(id);
const stagesQuery = useRunStages(id);
const isLive = runQuery.data?.status?.kind === "running";
const logsQuery = useRunLogs(id, isLive ? LIVE_REFRESH_MS : undefined);
const stages = useMemo(
() => mapRunStagesToSidebarStages(stagesQuery.data),
[stagesQuery.data],
);
return (
<div className="flex gap-6">
<StageSidebar stages={stages} runId={id!} activeLink="logs" />
<div className="min-w-0 flex-1">{renderBody(logsQuery)}</div>
</div>
);
}
function renderBody(logsQuery: ReturnType<typeof useRunLogs>) {
if (logsQuery.error) {
return (
<ErrorState
title="Couldn't load run log"
description={errorMessage(logsQuery.error)}
onRetry={() => void logsQuery.mutate()}
/>
);
}
if (logsQuery.data === undefined) {
return <LoadingState label="Loading log…" />;
}
if (logsQuery.data === null) {
return (
<EmptyState
title="No run log yet"
description="The worker hasn't written any tracing output for this run."
/>
);
}
return <LogPanel text={logsQuery.data} />;
}
function LogPanel({ text }: { text: string }) {
const byteCount = new Blob([text]).size;
const lines = useMemo(() => text.split("\n"), [text]);
return (
<div className="rounded-md border border-line bg-panel-alt">
<div className="flex items-center justify-between gap-3 border-b border-line px-3 py-2">
<span className="font-mono text-xs text-fg-muted">runtime/server.log</span>
<div className="flex items-center gap-3">
<span className="text-xs tabular-nums text-fg-muted">{formatBytes(byteCount)}</span>
<CopyButton value={text} label="Copy run log" />
</div>
</div>
<pre className="max-h-[70vh] overflow-auto whitespace-pre p-4 font-mono text-xs leading-5 text-fg-2">
{lines.map((line, i) => (
<LogLine key={i} line={line} trailingNewline={i < lines.length - 1} />
))}
</pre>
</div>
);
}
const LOG_LINE_RE =
/^(\S+)(\s+)(TRACE|DEBUG|INFO|WARN|ERROR)(\s+)(.*)$/;
type LogLevel = "TRACE" | "DEBUG" | "INFO" | "WARN" | "ERROR";
const LEVEL_COLOR: Record<LogLevel, string> = {
ERROR: "text-coral",
WARN: "text-amber",
INFO: "text-teal-500",
DEBUG: "text-fg-3",
TRACE: "text-fg-muted",
};
function LogLine({ line, trailingNewline }: { line: string; trailingNewline: boolean }) {
const newline = trailingNewline ? "\n" : "";
const match = LOG_LINE_RE.exec(line);
if (!match) {
return <span>{line}{newline}</span>;
}
const [, timestamp, gap1, level, gap2, rest] = match;
return (
<span>
<span className="text-fg-muted">{timestamp}</span>
{gap1}
<span className={`font-semibold ${LEVEL_COLOR[level as LogLevel]}`}>{level}</span>
{gap2}
<LogRest text={rest} />
{newline}
</span>
);
}
const LOG_REST_RE = /^([a-zA-Z_][\w:]*):(\s+)(.*)$/;
function LogRest({ text }: { text: string }) {
const match = LOG_REST_RE.exec(text);
if (!match) return <>{text}</>;
const [, target, gap, message] = match;
return (
<>
<span className="text-fg-3">{target}</span>
<span className="text-fg-muted">:</span>
{gap}
<span>{message}</span>
</>
);
}
function errorMessage(error: unknown): string | undefined {
return error instanceof Error ? error.message : undefined;
}

View file

@ -1,7 +1,11 @@
import { describe, expect, test } from "bun:test";
import type { BoardColumn, RunListItem } from "@qltysh/fabro-api-client";
import { buildBoardColumns, shouldRefreshBoardForEvent } from "./runs";
import {
buildBoardColumns,
runsQuickStartCommands,
shouldRefreshBoardForEvent,
} from "./runs";
function boardRun(id: string, column: BoardColumn, questionText?: string): RunListItem {
return {
@ -49,4 +53,19 @@ describe("runs route board mapping", () => {
expect(shouldRefreshBoardForEvent("interview.completed")).toBe(true);
expect(shouldRefreshBoardForEvent("run.created")).toBe(false);
});
test("includes the configured server argument for GitHub-auth quick starts", () => {
expect(runsQuickStartCommands(true, "http://127.0.0.1:32276")).toEqual([
"fabro auth login --server http://127.0.0.1:32276",
"fabro repo init",
"fabro run hello",
]);
});
test("does not show a placeholder server when system info is unavailable", () => {
expect(runsQuickStartCommands(true)).toEqual([
"fabro repo init",
"fabro run hello",
]);
});
});

View file

@ -22,7 +22,8 @@ import { ciConfig, columnStatusDisplay, deriveCiStatus, mapRunListItem } from ".
import type { CiStatus, CheckRun, CheckStatus, RunItem, RunWithStatus, ColumnStatus } from "../data/runs";
import { EmptyState } from "../components/state";
import { shouldRefreshBoardForEvent, useBoardEvents } from "../lib/board-events";
import { useAuthConfig, useBoardsRuns } from "../lib/queries";
import { useDemoMode } from "../lib/demo-mode";
import { useAuthConfig, useBoardsRuns, useSystemInfo } from "../lib/queries";
import type { PaginatedBoardRunList } from "@qltysh/fabro-api-client";
export { shouldRefreshBoardForEvent };
@ -90,6 +91,7 @@ export function buildBoardColumns(response: BoardRunsResponse): Column[] {
function boardLifecycleStatusLabel(run: Pick<RunItem, "column" | "lifecycleStatusLabel">): string | null {
if (run.lifecycleStatusLabel == null) return null;
if (run.column === "initializing") return null;
if (run.column != null && columnStatusDisplay[run.column]?.label === run.lifecycleStatusLabel) {
return null;
}
@ -431,6 +433,10 @@ function SortablePrCard({
function BoardColumn({ column }: { column: Column }) {
const Icon = iconMap[column.iconType];
const demoMode = useDemoMode();
const actions = demoMode
? column.actions
: column.actions.filter((label) => label !== "Steer");
return (
<div className="flex min-w-0 flex-col">
<div className="mb-3 flex items-center gap-3">
@ -451,7 +457,7 @@ function BoardColumn({ column }: { column: Column }) {
pr={pr}
icon={Icon}
iconColor={column.text}
actions={column.actions}
actions={actions}
/>
))}
</div>
@ -579,12 +585,25 @@ function CopyButton({ text }: { text: string }) {
);
}
function RunsLandingEmpty({ hasGitHubAuth }: { hasGitHubAuth: boolean }) {
const quickStartCommands = [
hasGitHubAuth ? "fabro auth login" : null,
export function runsQuickStartCommands(
hasGitHubAuth: boolean,
serverUrl?: string,
) {
return [
hasGitHubAuth && serverUrl ? `fabro auth login --server ${serverUrl}` : null,
"fabro repo init",
"fabro run hello",
].filter((command): command is string => command !== null);
}
function RunsLandingEmpty({
hasGitHubAuth,
serverUrl,
}: {
hasGitHubAuth: boolean;
serverUrl?: string;
}) {
const quickStartCommands = runsQuickStartCommands(hasGitHubAuth, serverUrl);
return (
<div className="mt-4 flex flex-col items-center">
<div className="w-full max-w-lg space-y-5">
@ -648,11 +667,13 @@ function RunsLandingEmpty({ hasGitHubAuth }: { hasGitHubAuth: boolean }) {
export default function Runs() {
const boardRuns = useBoardsRuns();
const authConfig = useAuthConfig();
const systemInfo = useSystemInfo();
const initialColumns = useMemo(
() => boardRuns.data ? buildBoardColumns(boardRuns.data) : [],
[boardRuns.data],
);
const hasGitHubAuth = authConfig.data?.methods.includes("github") === true;
const serverUrl = systemInfo.data?.server_url;
const allRepos = [
...new Set(
initialColumns.flatMap((col: Column) => col.items.map((item: RunItem) => String(item.repo))),
@ -775,7 +796,10 @@ export default function Runs() {
))}
</div>
{totalRuns === 0 ? (
<RunsLandingEmpty hasGitHubAuth={hasGitHubAuth} />
<RunsLandingEmpty
hasGitHubAuth={hasGitHubAuth}
serverUrl={serverUrl}
/>
) : filteredRuns === 0 ? (
<div className="py-8">
<EmptyState
@ -827,7 +851,10 @@ export default function Runs() {
})}
</div>
{totalRuns === 0 ? (
<RunsLandingEmpty hasGitHubAuth={hasGitHubAuth} />
<RunsLandingEmpty
hasGitHubAuth={hasGitHubAuth}
serverUrl={serverUrl}
/>
) : filteredRuns === 0 ? (
<div className="py-8">
<EmptyState

View file

@ -1,23 +1,27 @@
import type { ReactNode } from "react";
import { useState, type ReactNode } from "react";
import type {
ObjectStoreSettings,
ServerListenSettings,
ServerSettings,
} from "@qltysh/fabro-api-client";
import { useServerSettings } from "../lib/queries";
import { CollapsibleFile } from "../components/collapsible-file";
export function meta({}: any) {
return [{ title: "Settings — Fabro" }];
}
type View = "settings" | "json";
export default function Settings() {
const settingsQuery = useServerSettings();
const settings = settingsQuery.data;
const [view, setView] = useState<View>("settings");
if (!settings) {
return (
<div className="space-y-6">
<PageIntro />
<PageIntro view={view} setView={setView} />
<PanelSkeleton />
<PanelSkeleton />
<PanelSkeleton />
@ -27,21 +31,64 @@ export default function Settings() {
return (
<div className="space-y-6">
<PageIntro />
<ServerPanel settings={settings} />
<AccessPanel settings={settings} />
<IntegrationsPanel settings={settings} />
<PageIntro view={view} setView={setView} />
{view === "settings" ? (
<>
<ServerPanel settings={settings} />
<AccessPanel settings={settings} />
<IntegrationsPanel settings={settings} />
</>
) : (
<CollapsibleFile
file={{
name: "server-settings.json",
contents: JSON.stringify(settings, null, 2),
lang: "json",
}}
/>
)}
</div>
);
}
function PageIntro() {
function PageIntro({ view, setView }: { view: View; setView: (v: View) => void }) {
return (
<p className="max-w-[64ch] text-sm/6 text-fg-3 text-pretty">
Snapshot of the server configuration. Edit via{" "}
<code className="font-mono text-fg-2">settings.toml</code>; changes take
effect on the next server restart.
</p>
<div className="flex items-start justify-between gap-6">
<p className="max-w-[64ch] text-sm/6 text-fg-3 text-pretty">
Snapshot of the server configuration. Edit via{" "}
<code className="font-mono text-fg-2">settings.toml</code>; changes take
effect on the next server restart.
</p>
<ViewToggle view={view} setView={setView} />
</div>
);
}
function ViewToggle({ view, setView }: { view: View; setView: (v: View) => void }) {
const btn = "rounded px-3 py-1.5 text-xs font-medium transition-colors";
return (
<div
role="group"
aria-label="Settings view"
className="inline-flex shrink-0 rounded-md border border-line bg-panel/80 p-0.5"
>
<button
type="button"
onClick={() => setView("settings")}
aria-pressed={view === "settings"}
className={`${btn} ${view === "settings" ? "bg-overlay text-teal-500" : "text-fg-muted hover:text-fg-3"}`}
>
Settings
</button>
<button
type="button"
onClick={() => setView("json")}
aria-pressed={view === "json"}
className={`${btn} ${view === "json" ? "bg-overlay text-teal-500" : "text-fg-muted hover:text-fg-3"}`}
>
JSON
</button>
</div>
);
}

View file

@ -0,0 +1,20 @@
import { describe, expect, test } from "bun:test";
import { setupContentForSearch } from "./setup";
describe("setupContentForSearch", () => {
test("explains GitHub App installation returns separately from first-time setup", () => {
const content = setupContentForSearch(
"?installation_id=128003036&setup_action=install",
);
expect(content.title).toBe("GitHub App installed");
expect(content.description).toContain(
"GitHub finished installing the app",
);
expect(content.steps.map((step) => step.title)).toEqual([
"Return to Fabro",
"Retry the run",
]);
});
});

View file

@ -1,7 +1,9 @@
import { useLocation } from "react-router";
import { AuthLayout } from "../components/auth-layout";
import { PRIMARY_BUTTON_CLASS } from "../components/ui";
const steps = [
const firstTimeSteps = [
{
title: "Open a terminal on the server host",
body: (
@ -31,21 +33,68 @@ const steps = [
},
];
const githubInstallReturnSteps = [
{
title: "Return to Fabro",
body: (
<p className="text-sm/6 text-fg-3">
The GitHub App is installed for the selected account or repositories.
No local reinstall is needed.
</p>
),
},
{
title: "Retry the run",
body: (
<p className="text-sm/6 text-fg-3">
Start the run or preflight again so Fabro can clone the repository and
push checkpoint branches with the new installation.
</p>
),
},
];
export function setupContentForSearch(search: string) {
const params = new URLSearchParams(search);
if (params.has("installation_id") || params.get("setup_action") === "install") {
return {
footer:
"GitHub redirected here after installing the app. Fabro is already configured locally.",
title: "GitHub App installed",
description:
"GitHub finished installing the app. Fabro can now request repository-scoped tokens for runs that use that installation.",
steps: githubInstallReturnSteps,
cta: "Continue to sign in",
};
}
return {
footer: "GitHub App setup is managed from the terminal, not the browser.",
title: "Set up Fabro",
description:
"Run the installer on the server host to register a GitHub App and write local configuration.",
steps: firstTimeSteps,
cta: "Continue to sign in",
};
}
export default function Setup() {
const { search } = useLocation();
const content = setupContentForSearch(search);
return (
<AuthLayout footer="GitHub App setup is managed from the terminal, not the browser.">
<AuthLayout footer={content.footer}>
<h1 className="text-center text-2xl font-semibold tracking-tight text-fg text-balance sm:text-[1.75rem]">
Set up Fabro
{content.title}
</h1>
<p className="mt-3 text-center text-sm/6 text-fg-3 text-pretty">
Run the installer on the server host to register a GitHub App and write
local configuration.
{content.description}
</p>
<ol
role="list"
className="mt-8 divide-y divide-line border-y border-line"
>
{steps.map((step, index) => (
{content.steps.map((step, index) => (
<li key={step.title} className="flex items-start gap-4 py-4">
<span
className="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-overlay text-xs font-semibold tabular-nums text-fg-2 outline-1 -outline-offset-1 outline-white/10"
@ -61,7 +110,7 @@ export default function Setup() {
))}
</ol>
<a href="/login" className={`${PRIMARY_BUTTON_CLASS} mt-8 w-full`}>
Continue to sign in
{content.cta}
</a>
</AuthLayout>
);

View file

@ -1,15 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
openssl genpkey -algorithm Ed25519 -out fabro-jwt-private.pem
openssl pkey -in fabro-jwt-private.pem -pubout -out fabro-jwt-public.pem
echo ""
echo "Generated:"
echo " fabro-jwt-private.pem (private key — for fabro-web / FABRO_JWT_PRIVATE_KEY)"
echo " fabro-jwt-public.pem (public key — for fabro-workflow / FABRO_JWT_PUBLIC_KEY)"
echo ""
echo "Set env vars with the PEM contents (including header/footer lines):"
echo ""
echo ' export FABRO_JWT_PRIVATE_KEY="$(cat fabro-jwt-private.pem)"'
echo ' export FABRO_JWT_PUBLIC_KEY="$(cat fabro-jwt-public.pem)"'

View file

@ -20,8 +20,8 @@ disallowed-methods = [
{ path = "std::fs::File::create", reason = "Blocking open; prefer tokio::fs::File::create on Tokio paths. Document intentional sync I/O with #[expect(clippy::disallowed_methods, reason = \"...\")]" },
{ path = "std::fs::File::create_new", reason = "Blocking open; prefer tokio::fs::File::create_new on Tokio paths. Document intentional sync I/O with #[expect(clippy::disallowed_methods, reason = \"...\")]" },
{ path = "std::fs::OpenOptions::open", reason = "Blocking open; prefer tokio::fs::OpenOptions::open on Tokio paths. OS file-lock semantics may require spawn_blocking instead. Document intentional sync I/O with #[expect(clippy::disallowed_methods, reason = \"...\")]" },
{ path = "std::env::set_var", reason = "Server/process env must be injected at construction or child-process spawn time, not mutated globally. See docs-internal/server-secrets-strategy.md" },
{ path = "std::env::remove_var", reason = "Server/process env must be injected at construction or child-process spawn time, not mutated globally. See docs-internal/server-secrets-strategy.md" },
{ path = "std::env::set_var", reason = "Server/process env must be injected at construction or child-process spawn time, not mutated globally. See docs/internal/server-secrets-strategy.md" },
{ path = "std::env::remove_var", reason = "Server/process env must be injected at construction or child-process spawn time, not mutated globally. See docs/internal/server-secrets-strategy.md" },
{ path = "std::env::var", reason = "Use fabro_static::EnvVars for fixed environment variable names; document intentional process-env lookup facades with #[expect(clippy::disallowed_methods, reason = \"...\")]", allow-invalid = true },
{ path = "std::env::var_os", reason = "Use fabro_static::EnvVars for fixed environment variable names; document intentional process-env lookup facades with #[expect(clippy::disallowed_methods, reason = \"...\")]", allow-invalid = true },
{ path = "std::env::vars", reason = "Snapshotting the ambient process env must be limited to documented subprocess/test/bootstrap facades.", allow-invalid = true },

14
docker-compose.local.yaml Normal file
View file

@ -0,0 +1,14 @@
services:
fabro:
image: fabro-sh/fabro
restart: unless-stopped
ports:
- "32276:32276"
environment:
FABRO_LOG: debug
volumes:
- fabro-storage:/storage
- /var/run/docker.sock:/var/run/docker.sock
volumes:
fabro-storage:

View file

@ -8,7 +8,7 @@ services:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- ./docker/Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
depends_on:

View file

@ -1,12 +1,12 @@
services:
fabro:
image: ghcr.io/fabro-sh/fabro:nightly
platform: linux/amd64
restart: unless-stopped
ports:
- "32276:32276"
volumes:
- fabro-storage:/storage
- /var/run/docker.sock:/var/run/docker.sock
volumes:
fabro-storage:

View file

@ -1,9 +1,38 @@
#!/bin/sh
set -eu
docker_socket_path() {
case "${DOCKER_HOST:-}" in
"") printf '%s\n' /var/run/docker.sock ;;
unix://*) printf '%s\n' "${DOCKER_HOST#unix://}" ;;
*) return 1 ;;
esac
}
ensure_docker_socket_group() {
socket_path="$(docker_socket_path)" || return 0
[ -S "$socket_path" ] || return 0
socket_gid="$(stat -c '%g' "$socket_path")"
case " $(id -G fabro) " in
*" $socket_gid "*) return 0 ;;
esac
socket_group="$(
awk -F: -v gid="$socket_gid" '$3 == gid { print $1; exit }' /etc/group || true
)"
if [ -z "$socket_group" ]; then
socket_group="docker-sock-$socket_gid"
addgroup -S -g "$socket_gid" "$socket_group"
fi
addgroup fabro "$socket_group"
}
# When started as root (the default), ensure the storage volume is writable
# by the unprivileged fabro user, then drop privileges.
if [ "$(id -u)" = 0 ]; then
ensure_docker_socket_group
mkdir -p "${FABRO_HOME:-/storage/.home}"
chown fabro:fabro /storage
chown -R fabro:fabro "${FABRO_HOME:-/storage/.home}"

View file

@ -0,0 +1,107 @@
---
date: 2026-04-25
topic: cargo-dev-ux-cleanup
---
# `cargo dev` UX Cleanup
## Problem Frame
`cargo dev` was created in April 2026 to consolidate five shell scripts into one Rust binary, modeled after `uv`'s ergonomics. Six commands shipped:
`docker-build`, `refresh-spa`, `check-spa-budgets`, `generate-cli-reference`, `generate-options-reference`, `release`.
A few rough edges have surfaced now that the surface is in regular use by humans, agents, and CI:
- `refresh-spa` is mandatory before any TypeScript commit, but CI enforces it via a separate `git diff --exit-code` step rather than a first-class check mode. The dirty-check lives in CI YAML, not in the dev tool.
- `check-spa-budgets` is a sibling command instead of part of the same SPA verification pass, so contributors need to remember two commands to "verify the SPA is shippable."
- Word order is mixed — `docker-build` is noun-verb, `refresh-spa` and `check-spa-budgets` are verb-noun, the two `generate-*-reference` commands are verb-noun-noun. There is no rule a contributor can apply to guess a command name.
- `release [nightly]` uses a positional with a single accepted value (`nightly`), reading awkwardly compared with `--nightly`.
This brainstorm captures the rename + consolidation pass to fix word order, fold SPA verification into one command, and switch the release prerelease to a flag.
## Surface Comparison
| Today | After |
|---|---|
| `cargo dev docker-build` | `cargo dev docker-build` (unchanged) |
| `cargo dev refresh-spa` | `cargo dev spa refresh` |
| `cargo dev check-spa-budgets` + CI `git diff --exit-code` | `cargo dev spa check` (folds both) |
| `cargo dev generate-cli-reference` | `cargo dev docs refresh` (regenerates both artifacts) |
| `cargo dev generate-cli-reference --check` | `cargo dev docs check` (verifies both artifacts) |
| `cargo dev generate-options-reference` | `cargo dev docs refresh` |
| `cargo dev generate-options-reference --check` | `cargo dev docs check` |
| `cargo dev release` | `cargo dev release` (unchanged) |
| `cargo dev release nightly` | `cargo dev release --nightly` |
| — | `cargo dev spa` prints SPA command help |
| — | `cargo dev docs` prints docs command help |
## Requirements
### Naming convention
- R1. Adopt noun-verb word order (uv style): the group/subject comes first, the action second. `docker-build` is grandfathered as a single hyphenated noun-verb name because it has no siblings; `release` stays as a single verb because it has no group.
- R2. Group-only invocation is non-mutating help: `cargo dev spa` prints the SPA subcommand help and exits successfully; `cargo dev docs` prints the docs subcommand help and exits successfully. Mutating commands always require an explicit verb (`refresh`).
### SPA group
- R3. `cargo dev spa refresh` rebuilds the SPA via `bun run build`, verifies the built `dist/` output against the asset budget gate, and only then replaces `lib/crates/fabro-spa/assets/`. On budget violation, it exits non-zero and leaves the committed asset directory untouched. This extends today's `refresh-spa` behavior with the budget gate so contributors can't ship an oversized bundle by accident without creating a dirty tracked tree on failed refresh.
- R4. `cargo dev spa check` is non-mutating with respect to tracked files: it must not rewrite `lib/crates/fabro-spa/assets/`, and it must leave the tracked working tree unchanged whether it passes or fails. It may use ignored temporary output, but it must clean up any temporary directory it creates. It must fail (non-zero exit) when **either** (a) the SPA bundle in `lib/crates/fabro-spa/assets/` does not match what `bun run build` would produce now (given the current `bun.lock` and source tree), **or** (b) the bundle exceeds asset budgets. This subsumes both today's `check-spa-budgets` and the CI `git diff --exit-code -- lib/crates/fabro-spa/assets` step. The bit-exact comparison relies on `bun run build` being reproducible from a frozen lockfile; CI is the authoritative environment because `bun install --frozen-lockfile` runs there. If a contributor sees a `spa check` failure locally that they can't explain, the resolution is to run `cargo dev spa refresh` and commit the result — CI's `spa check` then arbitrates.
- R5. Both `.github/workflows/typescript.yml` (Build job) and `.github/workflows/release.yml` (`verify-spa` job, lines 2931) call `cargo dev spa check` instead of `cargo dev refresh-spa` + `git diff --exit-code -- lib/crates/fabro-spa/assets`. The dirty-check no longer lives in workflow YAML. Both CI workflows must run `bun install --frozen-lockfile` before `cargo dev spa check`; this preserves the TypeScript workflow's existing setup and tightens the release workflow, which currently uses plain `bun install`.
### Docs group
- R6. `cargo dev docs refresh` regenerates both `docs/reference/cli.mdx` and `docs/reference/user-configuration.mdx` in one pass. Replaces the two `generate-*-reference` commands' default mode.
- R7. `cargo dev docs check` verifies both MDX artifacts are up to date without rewriting them. Replaces the two commands' `--check` mode. Non-zero exit on drift in either file.
### Release
- R8. `cargo dev release` cuts a stable release; `cargo dev release --nightly` cuts a nightly prerelease. The `[nightly]` positional is removed. Other release-mode flags (`--dry-run`, `--skip-tests`, `--release-date`) are unchanged.
### Migration
- R9. Hard cut: the old names (`refresh-spa`, `check-spa-budgets`, `generate-cli-reference`, `generate-options-reference`, `release nightly` positional) are removed in the same PR. No deprecated aliases.
- R10. All in-repo callers update in the same PR. Scope explicitly includes:
- CI workflows under `.github/workflows/` (including `nightly.yml`'s `cargo dev release nightly` invocation and `release.yml`'s `verify-spa` job).
- `AGENTS.md` (including its release-mode usage line that shows `[nightly]`) and `CLAUDE.md`.
- Any live `docs/` references that show the user-facing command (historical artifacts under `docs/plans/` and `docs/brainstorms/` are left alone).
- The `lib/crates/fabro-dev/` source crate itself: the subprocess invocation in `src/commands/release.rs` (currently `cargo dev refresh-spa`), `bail!` and help strings inside `src/commands/*.rs` that name the old commands, and integration tests under `tests/it/` that invoke subcommands by name. These must rename in lockstep with the clap subcommand declarations or `cargo nextest run -p fabro-dev` will fail.
## Success Criteria
- `cargo dev --help` lists `docker-build`, `spa`, `docs`, `release` (four entries) with consistent noun-verb naming.
- A contributor can answer "how do I verify the SPA before pushing?" with one command (`cargo dev spa check`) instead of two plus a `git diff`.
- CI's TypeScript Build job, the release pipeline's `verify-spa` job, and the docs-reference checks each call exactly one `cargo dev <group> check` command; the workflow YAML no longer contains a `git diff --exit-code` line for SPA assets, and both SPA-checking workflows use `bun install --frozen-lockfile`.
- `rg "refresh-spa|check-spa-budgets|generate-cli-reference|generate-options-reference|release nightly|release \\[nightly\\]" .github CLAUDE.md AGENTS.md docs/ lib/crates/fabro-dev/ -g '!docs/plans/**' -g '!docs/brainstorms/**'` returns no live references after the PR merges. Historical matches inside `docs/plans/` and `docs/brainstorms/` are acceptable.
- `cargo nextest run -p fabro-dev` passes after the rename, and no `bail!` or help text inside `lib/crates/fabro-dev/src/` references the removed command names.
## Scope Boundaries
- Out of scope: `cargo dev verify` aggregate command, `cargo dev doctor` environment check, `--json` output for agents, `--watch` mode for `spa refresh`, pre-commit hook installation. These were considered and deferred — worth a separate brainstorm if footgun-reduction proves still painful after this cleanup.
- Out of scope: any change to what `docker-build` does or its flag surface.
- Out of scope: extending the release prerelease vocabulary beyond `nightly` (`--prerelease <kind>` style). The single `--nightly` flag is enough for the current need; switch to `--prerelease` if/when a second prerelease kind is introduced.
## Key Decisions
- **Noun-verb word order** (uv style) wins over verb-noun (cargo style). Rationale: `cargo dev` was already modeled after `uv` per docs/plans/2026-04-24-001-refactor-adopt-uv-patterns-plan.md, and the user picked `spa refresh` directly.
- **One docs command pair, not per-artifact.** `docs refresh` regenerates both MDX files; `docs check` verifies both. Rationale: both regenerations are fast and idempotent, granular targeting has no real use case, and the simpler pair mirrors `spa refresh` / `spa check`.
- **`spa check` owns both the diff and the budget gate.** Rationale: contributors think of "is the SPA ready to ship?" as one question; splitting it across two commands and a CI YAML step is what produced today's friction.
- **Group-only commands are help-only.** Rationale: `cargo dev spa` and `cargo dev docs` are easy commands for humans and agents to discover; making them mutate the working tree by default is too easy to trigger accidentally. Explicit verbs keep the command surface predictable.
- **`spa refresh` also enforces the budget gate before replacing committed assets.** Rationale: a successful refresh that quietly produces an over-budget bundle is a footgun, but a failed refresh should not leave tracked assets dirty. Validate the built output first, then mirror it into `lib/crates/fabro-spa/assets/` only on success.
- **`spa check`'s bit-exact comparison relies on `bun install --frozen-lockfile`.** Rationale: CI is the authoritative environment because both SPA-checking workflows run `bun install --frozen-lockfile` before `spa check`; locally, contributors run `spa refresh` to make their working tree match what their bun produces, then trust CI's `spa check` to arbitrate cross-machine drift.
- **Hard cut, no deprecated aliases.** Rationale: the tool is internal-only with all callers in this repo; the user's standing preference is "simplest change, don't care about migration."
## Outstanding Questions
### Resolve Before Planning
(none)
### Deferred to Planning
- [Affects R4][Technical] Implementation strategy for `spa check`'s diff: build into a temporary output directory and compare it against `lib/crates/fabro-spa/assets/`, or rebuild `apps/fabro-web/dist/` and clean it up after comparison. Whichever path is chosen must leave tracked files untouched and must not require a clean working tree outside `lib/crates/fabro-spa/assets/`.
- [Affects R10][Needs research] Confirm the explicit caller list is exhaustive via `rg "refresh-spa|check-spa-budgets|generate-cli-reference|generate-options-reference|release nightly|release \\[nightly\\]" . -g '!target/**' -g '!docs/plans/**' -g '!docs/brainstorms/**'` immediately before merging.
## Next Steps
`/ce:plan` for structured implementation planning

View file

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 82 KiB

View file

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

View file

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

View file

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

View file

Before

Width:  |  Height:  |  Size: 608 B

After

Width:  |  Height:  |  Size: 608 B

View file

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

View file

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

View file

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

View file

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View file

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

View file

Before

Width:  |  Height:  |  Size: 623 KiB

After

Width:  |  Height:  |  Size: 623 KiB

View file

Before

Width:  |  Height:  |  Size: 624 KiB

After

Width:  |  Height:  |  Size: 624 KiB

View file

@ -0,0 +1,280 @@
# Reduce Cargo Target APFS Churn Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make day-to-day Cargo builds and cleanup less painful on macOS/APFS by reducing the number and size of debug artifacts written under `target/`.
**Architecture:** Keep the change local to Cargo profile configuration first, because the observed slow path was filesystem metadata deletion in `target/debug/deps`, not Rust compilation logic. Prefer reversible profile tuning over moving build output or disabling incremental compilation until measurements prove those stronger options are needed.
**Tech Stack:** Rust Cargo profiles, macOS/APFS, `cargo clean`, `cargo nextest`, pinned nightly formatting/lint commands.
---
## Background
On 2026-04-28, `cargo clean` in this repo spent several minutes deleting `target/debug/deps`. A process sample showed the active work was mostly `unlink(2)`, with smaller time in `lstat` and directory traversal. Activity Monitor reported very low write throughput because this workload is metadata-bound: many small file deletions and APFS journal updates, not large sequential writes.
The repo already has:
```toml
[profile.dev.package."*"]
debug = false # Disable debug info for all dependencies
opt-level = 1 # Shrinks monomorphized generics, reducing test binary size
```
That reduces dependency debug info, but workspace crates can still emit full debug info. The sampled clippy command included `-C debuginfo=2 -C split-debuginfo=unpacked` for a Fabro crate, which can create more debug artifacts and cleanup work on macOS.
## Proposed First Change
Add lower-debug Cargo profile settings for local dev and tests:
```toml
[profile.dev]
debug = "line-tables-only"
split-debuginfo = "off"
[profile.test]
debug = "line-tables-only"
split-debuginfo = "off"
```
Expected impact:
- Keeps source file and line information for backtraces.
- Usually keeps breakpoints and source stepping usable.
- Reduces or removes rich local-variable inspection in `lldb` and IDE debuggers.
- Reduces debug metadata and split-debug filesystem artifacts.
- Does not change debug assertions, overflow checks, runtime behavior, or optimization level.
- Causes one rebuild after the profile settings change.
## Task 1: Baseline Current Target Cost
**Files:**
- Read: `Cargo.toml`
- No code changes
- [ ] **Step 1: Confirm no `cargo clean` is running**
Run:
```bash
ps -axo pid,ppid,stat,etime,pcpu,command | rg -i 'cargo clean' || true
```
Expected: no active `cargo clean` process, or wait for the existing cleanup to finish.
- [ ] **Step 2: Build the workspace using current settings**
Run:
```bash
cargo build --workspace
```
Expected: build succeeds.
- [ ] **Step 3: Record current target size**
Run:
```bash
du -sh target target/debug target/debug/deps 2>/dev/null
```
Expected: record the three size values in this file under "Results".
- [ ] **Step 4: Record current entry pressure**
Run:
```bash
find target/debug/deps -maxdepth 1 -mindepth 1 | wc -l
```
Expected: record the entry count in this file under "Results".
## Task 2: Apply Lower-Debug Profile Settings
**Files:**
- Modify: `Cargo.toml`
- [ ] **Step 1: Add profile settings**
Edit `Cargo.toml` so the release profile section is followed by:
```toml
[profile.dev]
debug = "line-tables-only"
split-debuginfo = "off"
[profile.test]
debug = "line-tables-only"
split-debuginfo = "off"
```
Keep the existing dependency-specific dev profile below it:
```toml
[profile.dev.package."*"]
debug = false # Disable debug info for all dependencies
opt-level = 1 # Shrinks monomorphized generics, reducing test binary size
```
- [ ] **Step 2: Format if Cargo.toml style changes**
No formatter is required for this simple TOML change. Keep comments and spacing consistent with the surrounding file.
## Task 3: Verify Builds and Tests Still Work
**Files:**
- Read: `Cargo.toml`
- No additional code changes
- [ ] **Step 1: Build the workspace**
Run:
```bash
cargo build --workspace
```
Expected: build succeeds. The first build after changing profile settings may rebuild many crates.
- [ ] **Step 2: Run the core test suite**
Run:
```bash
cargo nextest run --workspace
```
Expected: tests pass. If macOS reports `Too many open files (os error 24)`, rerun with:
```bash
ulimit -n 4096 && cargo nextest run --workspace
```
- [ ] **Step 3: Run clippy with the pinned nightly toolchain**
Run:
```bash
cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings
```
Expected: clippy passes.
## Task 4: Measure Impact
**Files:**
- Read: `Cargo.toml`
- Update: this plan's "Results" section
- [ ] **Step 1: Record new target size**
Run:
```bash
du -sh target target/debug target/debug/deps 2>/dev/null
```
Expected: compare against Task 1.
- [ ] **Step 2: Record new entry pressure**
Run:
```bash
find target/debug/deps -maxdepth 1 -mindepth 1 | wc -l
```
Expected: compare against Task 1. The size reduction may be more meaningful than entry-count reduction, depending on how much split debug info was previously generated.
- [ ] **Step 3: Time a clean operation when convenient**
Run only when a full cleanup is acceptable:
```bash
time cargo clean
```
Expected: compare wall-clock time against the prior observed multi-minute cleanup. Activity Monitor may still show low MB/s because deletion remains metadata-bound.
## Task 5: Decide Whether to Stop or Tune Further
**Files:**
- Modify only if needed: `Cargo.toml`
- Optional local-only config: `.cargo/config.toml`
- [ ] **Step 1: Stop if the result is acceptable**
If `target/` size and cleanup time are acceptable, keep the lower-debug profile settings and do not add more tuning.
- [ ] **Step 2: Consider disabling incremental only if cleanup is still too costly**
Use this stronger setting only if the cleanup pain still outweighs slower edit-compile cycles:
```toml
[profile.dev]
debug = "line-tables-only"
split-debuginfo = "off"
incremental = false
[profile.test]
debug = "line-tables-only"
split-debuginfo = "off"
incremental = false
```
Expected tradeoff: fewer incremental artifacts, but slower repeated local builds.
- [ ] **Step 3: Consider a local target directory only if repo-local `target/` remains disruptive**
For a personal-only setup, use `.cargo/config.toml` if it is intentionally untracked, or a global Cargo config outside the repo:
```toml
[build]
target-dir = "/Users/bhelmkamp/.cache/cargo-targets/fabro"
```
Expected tradeoff: build output is isolated from the repo and easier to exclude from Spotlight/backups, but all developers do not automatically share the same behavior unless the config is committed.
## Rollback
To restore full debug info for normal dev/test builds, remove these settings:
```toml
[profile.dev]
debug = "line-tables-only"
split-debuginfo = "off"
[profile.test]
debug = "line-tables-only"
split-debuginfo = "off"
```
For a one-off debugger session without changing `Cargo.toml`, run:
```bash
CARGO_PROFILE_DEV_DEBUG=2 CARGO_PROFILE_DEV_SPLIT_DEBUGINFO=unpacked cargo build
```
## Results
Fill this in when the plan is executed:
```text
Before:
- target size:
- target/debug size:
- target/debug/deps size:
- target/debug/deps entries:
- cargo clean wall-clock time:
After:
- target size:
- target/debug size:
- target/debug/deps size:
- target/debug/deps entries:
- cargo clean wall-clock time:
```

View file

@ -10,13 +10,12 @@
- Removed the production CLI dependency on `fabro_workflow::run_lookup`.
- Removed the production CLI dependency on `fabro_workflow::run_dump`.
- Added server-owned selector resolution via `GET /api/v1/runs/resolve` and migrated single-run selector flows to it.
- Migrated user-facing fork/rewind timeline inspection and execution to server APIs.
## Remaining Production Couplings
| Path | Direct dependency | Why it still exists | Required remediation track |
| --- | --- | --- | --- |
| `lib/crates/fabro-cli/src/commands/run/fork.rs` | `operations::{ForkRunInput, RewindTarget, build_timeline_or_rebuild, fork}` | User-facing CLI still reconstructs run timelines and mutates rewind/fork metadata locally. | Replace with a server API for timeline inspection and fork execution. |
| `lib/crates/fabro-cli/src/commands/run/rewind.rs` | `git::MetadataStore`, `operations::{RewindInput, RewindTarget, RunTimeline, TimelineEntry, build_timeline_or_rebuild, rewind}` | User-facing CLI still performs rewind timeline resolution and metadata mutation locally. | Replace with a server API for rewind preview and rewind execution. |
| `lib/crates/fabro-cli/src/commands/pr/create.rs` | `outcome::StageStatus`, `pull_request::maybe_open_pull_request` | CLI still reconstructs store state and runs PR creation logic from the workflow pipeline directly. | Replace with a server API, or extract PR orchestration into a non-engine shared service crate plus API. |
| `lib/crates/fabro-cli/src/commands/run/runner.rs` | `artifact_snapshot::CapturedArtifactInfo`, `artifact_upload::{ArtifactSink, StageArtifactUploader}`, `event::{Emitter, RunEventSink}`, `operations::{self, StartServices}`, `run_control::RunControlState`, `runtime_store::{RunStoreBackend, RunStoreHandle}` | Hidden worker subprocess path still lives inside the CLI crate and embeds the workflow engine directly. | Re-home worker/runtime code outside the user CLI surface, ideally into a dedicated worker crate or binary. |
| `lib/crates/fabro-cli/src/manifest_builder.rs` | `git::{GitSyncStatus, head_sha, sync_status}` | Manifest submission still relies on git helper logic that happens to live in `fabro_workflow`. | Extract git-sync inspection helpers into a non-workflow shared crate/module. |
@ -44,7 +43,7 @@
## Follow-Up Order
1. Design server APIs for rewind/fork and PR creation so user-facing CLI commands stop importing workflow operations directly.
1. Design a server API for PR creation so user-facing CLI commands stop importing workflow PR operations directly.
2. Decide whether the hidden worker path should move to a dedicated worker crate/binary or remain a CLI-internal implementation detail with a stricter boundary.
3. Extract shared status, conclusion, notice, and artifact snapshot types/helpers out of `fabro_workflow`.
4. Extract git sync helpers from `fabro_workflow` so manifest building no longer depends on engine code.

View file

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

View file

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View file

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

View file

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View file

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

View file

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View file

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View file

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View file

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View file

Before

Width:  |  Height:  |  Size: 5 KiB

After

Width:  |  Height:  |  Size: 5 KiB

View file

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

View file

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

View file

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View file

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

View file

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View file

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

View file

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

View file

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View file

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View file

Before

Width:  |  Height:  |  Size: 5 KiB

After

Width:  |  Height:  |  Size: 5 KiB

View file

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

View file

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Some files were not shown because too many files have changed in this diff Show more