Four fixes from post-merge review:
- Freeze archived runs on the remaining write surfaces the Unit 4 audit
missed: `put_stage_artifact`, `put_sandbox_file`, and `write_run_blob`
now all call `reject_if_archived` so a client cannot mutate artifacts,
sandbox files, or blobs on an archived run.
- Widen the `append_run_event` lifecycle denylist to cover every event
with a dedicated operation endpoint: archive, unarchive, and the three
control-request events (cancel/pause/unpause). Worker-emitted lifecycle
transitions and rewind's `RunRewound` / `RunSubmitted` replay still flow
through the endpoint as before.
- Map `fabro_store::Error::RunNotFound` to a distinct `Error::RunNotFound`
at the operations layer so the archive and unarchive HTTP handlers return
a 404 on unknown run ids instead of collapsing into a generic 500.
- Centralize the archived-run guard in `operations::rewind` by threading
`current_status` through `RewindInput` and calling the new
`ensure_not_archived` helper alongside a shared canonical error message.
The CLI caller drops its ad-hoc string comparison in favor of the typed
status it already loads from the server.
The CLI's store-run lookup now passes `include_archived=true` so resolve
and bulk operations (archive, unarchive, rm, inspect, rewind) can still find
archived runs. The web UI's hand-maintained `RunStatus` union and display
map learn `archived` with a gray style so archived runs render correctly.
Default `fabro ps` continues to hide archived via `is_active()`; `-a`
shows everything including archived.
Two new top-level commands mirror `fabro rm`'s bulk-by-ID shape: positional
run identifiers, per-ID success/error aggregation, and a final non-zero exit
if any item failed. Calls the new server endpoints from Unit 5. Shared bulk
loop covers both directions and emits structured JSON with an `archived` or
`unarchived` list alongside `errors`. Top-level help snapshot updated.
Adds an `archived → unarchive first` guard to every mutation entry point
that could otherwise hit an opaque 409 or confusing 404 on an archived run:
start, cancel, pause, unpause, submit_answer, and append_run_event server
handlers; the resume operation; and the rewind CLI command. append_run_event
also rejects client-injected `run.archived` and `run.unarchived` bodies so
lifecycle transitions cannot bypass the operations layer. Worker-emitted
run.completed / run.failed events still flow through as before. Fork reads
from the source's metadata branch only — no source mutation — so no guard
is needed there.
Adds `RunStatus::Archived` variant and splits the overloaded `is_terminal()`
into `is_terminal()` (reached terminal outcome) and `is_immutable()` (cannot
transition outbound). `can_transition_to()` now allows Succeeded|Failed|Dead to
and from Archived, preserving the `* -> Dead` escape hatch. Downstream
exhaustive matches in the CLI and server are updated with conservative Archived
arms; the server's public-enum mapping and board-column placement carry TODOs
for the OpenAPI update in a later unit.
## Summary
Stacked cleanup of the `canonicalize blocked run status` work (local
commit `d13cdf374`) plus reconciliation with origin's `canonicalize
paginated run list responses` (origin commit `8ab689da7`). Both efforts
ran in parallel and diverged on the column name (`blocked` vs `waiting`)
and on how the board response is shaped — this PR converges them,
keeping `blocked` as the canonical column id while adopting origin's
`column` field on `RunListItem` and `StoreRunSummary` shape.
Also fixes a production-worker regression introduced by the
canonicalization: the worker's start-precondition only accepted
`Submitted | Starting`, so once runs started transitioning through
`Queued` on the way to `Starting`, every subprocess-worker run failed
with `Precondition failed: cannot start run: status is Queued`. That
cascaded into ~90 failing CLI/server integration tests locally.
## Commits
1. `f65843168` refactor(runs): simplify blocked status follow-ups
2. `1492d956c` chore: resolve clippy warnings
3. `676fd9f44` first merge of origin/main
4. `23fc92a2f` **fix(runs): allow Queued status in start precondition**
← the cascade-fix
5. `36b507a83` refactor: simplify pause/unpause + dedupe web status
tables
6. `8d8d27748` refactor(workflow): encapsulate BlockedStateTracker
inside HumanHandler
7. `1c17fda35` second merge of origin/main — resolves waiting vs blocked
8. `4cd3ef7b1` refactor(workflow): Mutex<usize> → AtomicUsize
9. `2e5a58e8a` fix(demo): align run-4 lifecycle status with Blocked
board column
## Test plan
- [x] fmt, clippy, build, doctests all clean
- [x] `cargo nextest run --workspace` — **4092/4092 pass**
- [x] `bun test` — **26/26 pass**, typecheck + production build clean
- [x] Manual CLI repro of the Queued-precondition fix
- [x] Browser smoke test: all 5 columns render with correct
labels/colors, demo run-4 appears in Blocked lane with question text
intact
## Known follow-up (not blocking)
A "paused-while-blocked" run (status `Paused` + `blocked_reason: Some`)
lands in the `running` column because the visible status chooses
`Paused` over `Blocked`. The pending question is not prominent on the
board. Addressing it would require `board_column()` to branch on
`(status, blocked_reason)` rather than just `status` — worth a separate
ticket.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The web-install feature was carrying nine pedantic-tier clippy errors
from its initial commit. Fix them in place:
- \`install.rs\` \`InstallAppState\` switches \`install_token\`,
\`storage_dir\`, and \`config_path\` from \`Arc<String>/Arc<PathBuf>\` to
\`Arc<str>/Arc<Path>\` so we stop heap-duplicating buffers.
- Bring \`Infallible\`, \`axum::middleware\`, \`axum::extract::Request\`,
and \`fabro_types::settings::SettingsLayer\` into scope instead of
using absolute paths inline.
- Replace \`Duration::from_secs(10 * 60)\` with \`Duration::from_mins(10)\`.
- \`generate_ephemeral_secret\` never returns \`Err\`; drop the \`Result\`.
- \`server/start.rs ensure_storage_server_autostart_allowed\` takes
\`Option<&OsStr>\` instead of consuming an \`OsString\` it only reads.
- \`server/mod.rs\` storage_dir fallback uses \`map_or_else\` to satisfy
\`map_unwrap_or\`.
CI now passes \`cargo +nightly-2026-04-14 clippy --workspace
--all-targets -- -D warnings\` cleanly and the 892-test suite still
passes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The `fabro-install` crate was introduced for the web wizard but the CLI
kept its own copies of the same JWT keypair generation, TOML merging,
and GitHub auth settings helpers. Delete the duplicates and route the
CLI through `fabro_install::*`. The CLI keeps a thin
`merge_server_settings` wrapper because it only ever binds TCP and
derives the authority from `--web-url`.
Also tighten `persist_install_outputs_direct` to take its
`PendingSettingsWrite` argument by reference (satisfies
`needless_pass_by_value`) and pull the remaining absolute paths in the
crate's test module into `use` statements, clearing the nightly clippy
warnings that this branch was carrying.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tighten the browser-based install flow after correctness and adversarial
review, without changing the external wizard shape.
- Persist the actual bind in server.listen, not the canonical URL
- Reject concurrent /install/finish and rapid GitHub App retries
- Keep the prior GitHub Token strategy until App callback succeeds
- Recover from poisoned install locks instead of propagating panics
- Rollback both settings and vault on failed persistence
- Redirect GitHub callback errors back into the wizard UI
- Validate LLM keys via /models probe instead of a billed generate()
- Reject canonical URLs with trailing slash, path, query, or fragment
- Accept any valid install-token source, not just the first present one
- Redact the install token in structured logs
- Assert install-mode SPA marker injection at startup
- Warn on suspected concurrent operators via UA + X-Forwarded-For
- Add component-level test for the GitHub callback error banner
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implement the web-first install experience across the server, CLI, API spec,
web app, and packaged SPA assets.
This also removes test-side process env mutation by pushing env-dependent
decision points behind explicit helpers and test wiring.
Unify /api/v1/runs and /api/v1/boards/runs around a shared
paginated summary contract with additive convenience fields.
Update the server, demo data, generated clients, CLI pagination,
and web consumers so board views become a thin projection over the
canonical run summary surface.
Non-release builds now append the profile to `fabro --version`
(`x.y (sha date debug)`), `fabro version`, and `fabro system info`,
so users can tell a local build apart from a shipped release. The
API's `SystemInfoResponse` gains a `profile` field so the client
can render the server's build profile too.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When stderr is a TTY and text output is used, print a yellow `warning:`
line on stderr if the server reports a version that differs from the
client. JSON output and non-interactive contexts stay silent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extend the release matrix to two statically-linked musl variants so
Alpine and other musl-based Linux hosts can install without glibc.
Homebrew and the Docker image remain glibc-only.
- release.yml: add x86_64-unknown-linux-musl (ubuntu-24.04) and
aarch64-unknown-linux-musl (ubuntu-24.04-arm) matrix rows with
musl-tools, CC_*_musl, CARGO_TARGET_*_LINKER, and LIBZ_SYS_STATIC
- Cargo.toml: enable git2 vendored-libgit2 so libgit2 compiles from
source for every target (needed because musl cannot link against
Ubuntu's glibc-built libgit2-dev)
- install.sh: check `ldd --version` for "musl" and rewrite the target
from -gnu to -musl so Alpine users get the right tarball
- upgrade.rs: add detect_linux_libc() / parse_ldd_libc() helper and
route detect_target() Linux arms through it, with unit tests
covering glibc, musl, empty, and unknown output
- tests/it: extend target regex in the dry-run snapshot filter
Ubuntu 24.04 is required for the musl runner: 22.04 ships musl 1.2.2
which SIGSEGVs statically-linked x86_64 test binaries at startup.
Confirmed against graphviz-sys CI before landing here.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Detect Homebrew-managed installs from the canonicalized executable path
(Cellar/fabro[-nightly]/...) and branch both the background nag and
`fabro upgrade` accordingly.
- Background check fetches the tap's versions.json (raw.githubusercontent)
for the matching channel instead of GitHub's latest release, so the nag
tracks what `brew update` can satisfy.
- Cache payload gains an install_source tag; entries from a different
source (or legacy entries without the field) are ignored, so users who
switch between fabro/fabro-nightly/tarball don't see stale versions.
- `fabro upgrade` on a brew install refuses to overwrite the
Homebrew-managed binary and prints `brew upgrade fabro[-nightly]`.
--dry-run prints the command and exits 0; --version/--prerelease/--force
are rejected with a clear Homebrew-managed error.
- Manifest/exe-resolve failures on brew installs skip the notice rather
than falling back to GitHub latest (which would reintroduce the
tap-lag false positives this change is meant to remove).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A recent CI flake surfaced as bare "error: No such file or directory
(os error 2)" with no chain, because the failing operation lived behind
a raw `?` on a `std::fs::` / `File::create` / `Command::spawn` call. The
error had no verb, no path, no hint at which step in server startup
broke. Retry loops were explicitly rejected -- the goal is to diagnose
the next occurrence, not mask it.
Wraps 50+ such sites across fabro-cli, fabro-server, fabro-workflow,
fabro-util, fabro-vault, fabro-telemetry, fabro-interview, fabro-llm,
and fabro-devcontainer with `.with_context(|| format!("<verb> {path}"))`
so anyhow's error chain carries both the operation and the path when
an io error escapes.
Where the enclosing function returns `io::Result` (fabro-util run_log,
fabro-interview recording, fabro-llm attachment loader), the error is
re-wrapped via `io::Error::new` to keep the signature stable. Where a
crate uses its own thiserror enum, either a new `io_context` helper
was added (fabro-vault) or the path was folded into the existing
`Error::Io(String)` message (fabro-workflow).
No retry loops. No behavior changes. Skipped sites documented:
`.ok()`-swallowed, `match ErrorKind::NotFound`, `let _ = ...`, typed
error variants that already carry the path, and test modules.
Verified: cargo build --workspace, cargo +nightly clippy --workspace
--all-targets -- -D warnings, cargo nextest run --workspace (3991/3991
pass).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Surfaces the docs website shortcut alongside the other onboarding
commands so new users can find the web docs without hunting through
`fabro help`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Replace the three-line "help along the way" trio with a Discord
callout in the style of qlty's landing: one line pointing at per-
command --help, one inviting users to the Fabro Discord.
- New final line uses qlty's dim/cyan split: "For a full list of
commands, run `fabro help`."
- Drop `sandbox cp` and `sandbox preview` from the curated list;
`sandbox ssh` stays. Both are still reachable via `fabro help`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Moves `server start` and `secret set` into Set up so the first section
covers everything a user does once before they have a working install.
Drops `secret list` from the landing — `fabro --help` still surfaces it.
Merges the run-inspection commands (`logs`, `sandbox ssh`, `sandbox
preview`, `sandbox cp`) under a single "Inspect runs" heading, since
they all answer "what happened in this run?".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Running `fabro` with no subcommand now prints a short, colorized guide
highlighting the most important commands (install, doctor, repo init,
validate, preflight, run, logs, server start, secret set/list, sandbox
ssh/preview/cp) instead of clap's full --help dump.
`fabro --help` and `fabro help` still render clap's comprehensive
reference unchanged. The CLI makes the root subcommand optional and
intercepts the None case in main_inner before telemetry or logging
init; the empty command name also suppresses the "CLI Executed"
tracking event for this pseudo-command.
Includes an inline-snapshot IT test covering the full landing body and
updates four pre-existing usage-line snapshots (<COMMAND> → [COMMAND])
that reflect the now-optional subcommand.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
In release builds with SEGMENT_WRITE_KEY baked in (i.e. CI), the
post-command telemetry flush calls spawn_fabro_subcommand, which in turn
does create_dir_all(~/.fabro/tmp) and writes a JSONL event file. That
silently undoes the directory removal that `fabro uninstall --yes` just
performed — leaving a stray ~/.fabro/tmp/ behind and breaking the
uninstall integration tests on CI release runs.
Fixes:
- run_uninstall calls fabro_telemetry::shutdown() before removal so the
buffered "CLI Executed" track in main() can't be queued or flushed,
and the background thread can't spawn the sender subprocess.
- TestContext::command() exports FABRO_TELEMETRY=off so test subprocesses
never initialise telemetry at all, as a belt-and-suspenders guard.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Widens the candidate set to include prereleases, picks max semver across
stable + prereleases. Falls back to /releases/latest if no parseable
non-draft tag is returned. Conflicts with --version. Background
auto-upgrade notice remains stable-only.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- rust.yml: move clippy to nightly-2026-04-14 (was stable); also pin
fmt to the same nightly date for consistency. Both jobs now use the
dated nightly and the run-step uses `cargo +nightly-2026-04-14 ...`.
- AGENTS.md: update developer commands to match CI.
- Duration constructors: replace `Duration::from_secs(N * 60)` /
`Duration::from_millis(N * 1000)` with `from_mins` / `from_secs` /
`from_hours` across the workspace to satisfy clippy's new
`duration_suboptimal_units` lint. std::time::Duration only — custom
`settings::duration::Duration` sites kept on `from_secs`.
- map/unwrap_or cleanup: `.map(f).unwrap_or(v)` → `.map_or(v, f)`,
`.map(f).unwrap_or(false)` on Result → `.is_ok_and(f)`, per
`clippy::map_unwrap_or`.
- Misc lints: collapse nested `if` into match guard in
handler/llm/api.rs and run_state.rs; replace `columns.len() > 0`
with `!columns.is_empty()`; switch a pair of `sort_by` calls to
`sort_by_key`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- serve.rs: annotate debug-only `bun run dev` spawn with
#[expect(clippy::disallowed_methods, ...)] and add the missing
watch_web field to three ServeArgs test fixtures.
- install.rs: replace absolute `fabro_server::serve::DEFAULT_TCP_PORT`
path with `serve::DEFAULT_TCP_PORT` (use is already imported) to
satisfy clippy::absolute_paths.
- pagination test: request an explicit page[limit]=100 for the
"fits in one page" case instead of relying on the server default,
so the test stays robust as the built-in model catalog grows.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stops any running server then starts a fresh one, passing through all
the same flags as `server start` (--watch-web, --foreground, etc.).
Works even if no server is currently running.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Spawns `bun run dev` in apps/fabro-web as a child of the server process,
so a single command starts both the API server and the web asset watcher.
The flag is gated behind #[cfg(debug_assertions)] and does not exist in
release builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
--web-url defaulted to http://localhost:3000 but merge_server_settings
hardcoded http://127.0.0.1:32276, causing GitHub OAuth redirect_uri
mismatch. Now both derive from the same --web-url flag (default:
http://127.0.0.1:32276 via DEFAULT_TCP_PORT).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Print the HTTP URL (cyan) and enabled auth methods after server start,
so users can see at a glance how to access the server and what login
methods are available.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Derive configured providers from env and vault when choosing default
models during run creation and materialization, and thread the resolved
run provider through execution handlers instead of recomputing it.
Also return a user-facing error when fabro-agent cannot infer a default
model for the selected provider.
When `disk_cache = true` in `[server.slatedb]`, Fabro enables SlateDB's
object-store cache at `<storage_root>/cache/slatedb`, caching raw S3
bytes on local disk to reduce read latency. All cache parameters use
SlateDB defaults (16 GB max, 4 MB parts). A warning is emitted if
enabled with `provider = "local"` since the cache adds overhead when
the object store is already on the local filesystem.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
`fabro secret set` now supports three ways to provide the value: as a
positional arg (existing), piped via --value-stdin, or interactively
when stdin is a TTY (obscured with dialoguer::Password). Diagnostics
remediation messages drop the <value> placeholder to encourage
interactive input.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add `fabro install github` for reconfiguring GitHub auth on an
existing install without rerunning full setup.
Ensure app/token switches replace stale settings and secrets, and
cover the new flow with CLI and integration tests.
Restart the local server at the end of fabro install so new config and
server.env values take effect immediately. Skip fabro doctor when the
restart fails, and keep targeted unit coverage around the restart and
secret-persistence lifecycle.
Adds `#[command(alias = "ls")]` to workflow, pr, and artifact list
subcommands for consistency with secret list which already had it.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The server used option_env!() for FABRO_GIT_SHA and FABRO_BUILD_DATE,
but no build.rs set them — so `fabro version` always showed "unknown".
Add a build.rs to fabro-server (matching fabro-cli's) and remove the
Sandbox line from `fabro system info`.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When the gh CLI is not installed or not authenticated, the install
wizard now shows "Personal Access Token" (without the gh reference)
and prompts the user to enter their token directly instead of failing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move the vendored Graphviz FFI crate to its own repo so it can be
reused independently and reduce this repo's footprint (~250 C/H files).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Run Graphviz through an internal fabro subprocess so renderer failures no
longer share process fate with the server. Keep expected DOT parse failures
on the 400 path via an explicit stdout protocol, and treat child crashes or
protocol violations as 500s.
Add a server-targeted `fabro version` command for checking client and
server build identity without reading local storage directly.
This also removes version data from `/health`, moves doctor parity checks
to diagnostics, and updates the API spec, docs, generated client, and
coverage for the new contract.