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.
Extends the OpenAPI spec with POST /api/v1/runs/{id}/archive and /unarchive
operations, adds `archived` to the RunStatus enum, and adds an
`include_archived` query param to listRuns. Regenerates the progenitor-built
Rust types and the typescript-axios client. Implements `archive_run` and
`unarchive_run` handlers via `operations::archive/unarchive`, and extends
`list_runs` to filter archived runs unless opted in. Archived runs continue
to map through `api_status_from_workflow` and bypass the board column.
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.
Centralizes the terminal-only precondition, idempotent behavior, and
event emission for archiving and unarchiving runs. Both operations
return typed outcomes distinguishing a real transition from an idempotent
no-op. `unarchive` reads prior_status from the projection (populated by
the RunArchived apply arm) rather than scanning the event log, so replay
stays pure append-and-apply.
Adds `RunArchived` and `RunUnarchived` events end-to-end through the engine.
Internal `Event` carries `actor` (and `restored_status` on unarchive); wire
`EventBody` serializes as `run.archived`/`run.unarchived` with typed props.
Projection gains `prior_status: Option<RunStatus>` — `RunArchived` captures
the current status before switching to Archived; `RunUnarchived` applies the
event's `restored_status` payload (authoritative) and clears `prior_status`.
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.
CodeQL's rust/uncontrolled-allocation-size alert flagged `paginate_items`
and the models list handler because `PaginationParams.offset: u32` was
cast to `usize` without an upper bound and handed to `Iterator::skip`.
In practice the underlying stores are bounded and `skip` on a Vec
iterator is O(1), so the existing callers couldn't be coerced into
allocating arbitrary memory, but an unbounded `offset` still takes an
unbounded time to walk past and CodeQL had no way to see that.
Clamp `offset` to `MAX_PAGE_OFFSET = 1_000_000` (beyond our largest
expected run count by several orders of magnitude) in both the shared
`paginate_items` helper and the models list handler that rolls its own
pagination. `limit` was already clamped to 100.
Closes code-scanning alert #27.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CodeQL's Rust SSRF detector flagged the GitHub-and-provider HTTP calls in
install mode because the `base_url` values flow through `pub` test-only
setters (`with_github_api_base_url`, `with_provider_base_url`) that the
analyzer treats as external entry points. In production these values are
always the hardcoded `DEFAULT_*` constants, so the flagged paths are
unreachable, but the fix also hardens the real request sites.
Route every upstream URL through `parse_install_upstream_url`, which
- parses the URL,
- requires the scheme to be `http` or `https`, and
- requires a host.
Build request endpoints via `install_upstream_endpoint(base, &[segments])`
so each segment is percent-encoded by `url`; a caller cannot inject
extra path components, host overrides, or scheme changes via a path
segment. GitHub's manifest `code` (from the browser callback) is also
checked against the short base64url character set it uses.
Closes code-scanning alerts #28 and #29.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rebuild the bundled SPA via scripts/refresh-fabro-spa.sh so the Rust server
embeds the current install-wizard sources (OpenAI-compatible removed,
GitHub error banner consolidated into a single effect).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lets a smoke-test harness pick its own image tag without racing the default
fabro:latest, and points future agent sessions at bin/dev/docker-build.sh
so they don't hand-roll a throwaway Dockerfile.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## 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>
\`items_after_statements\` flagged the static declaration. Move it to
the top of \`cached_install_mode_shell\` — same behavior, same caching
semantics, one less lint to carry forward.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI runs nightly rustfmt and flags this untouched for-loop header.
Pre-existing on the branch; clearing it here so the install-wizard
cleanup commits pass fmt --check cleanly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two wins here. First, the `session`/`loadingSession`/`sessionError`
triple is replaced with a single `SessionState` discriminated union, so
the component can switch on `.status` instead of juggling three
correlated flags. Second, the seven flat `useState` calls for the
GitHub step are grouped into `githubStrategy` + `tokenForm` + `appForm`,
with `appForm.owner` typed as the generated `InstallGithubAppOwner`
tagged object. Invalid states like "token flow but org slug set" simply
stop existing.
\`buildInstallGithubAppOwner\` is deleted (unused) — form handlers build
the tagged object in place, which is small enough to stay readable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Run \`bun run generate\` inside lib/packages/fabro-api-client to pick up
the new install schemas. Swap install-api.ts from hand-written
interfaces to re-exports from @qltysh/fabro-api-client and drop the
last duplicated type surface for the install wizard.
Keeps the \`installFetch\` wrapper and \`readInstallError\` helper so the
session-storage token handling and our custom error parser stay local
to the wizard. The generated Axios client is available as a future
migration if we decide to drop the wrapper.
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>
The install GitHub App manifest shape encoded owner as `"personal"` or
`"org:<slug>"` - a magic string parsed in install-app.tsx, built by
install-api.ts, and reparsed server-side. Replace with a tagged object
`{ kind: "personal" } | { kind: "org", slug }` in the OpenAPI spec, the
progenitor-generated Rust types, and the frontend.
Server-side, the internal `GitHubAppOwner` enum keeps its semantic
shape but gains a `TryFrom<GithubAppOwnerInput>` conversion and emits
the tagged JSON via `as_session_value`.
Frontend drops `buildGithubOwnerValue` in favor of
`buildInstallGithubAppOwner`, and the ready-screen renders the owner
through a small helper instead of string concatenation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Install handlers returned `{"error": "..."}` while the OpenAPI paths
referenced the repo-wide `ErrorResponse` schema
(`{"errors":[{status,title,detail}]}`). Funnel the install helper through
`ApiError::into_response`, switch the invalid-token 401 and the
persistence-failure INTERNAL_SERVER_ERROR to the same shape, and update
the TS `readInstallError` helper + test fixtures to read
`body.errors[0].detail`.
The install-finish failure path still carries `leftover_env_keys`
alongside the error envelope so the rollback integration tests retain
their diagnostic field.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Harden the remaining install flow regressions and add the missing
coverage for startup dispatch, finish-time shutdown behavior, and
partial-state persistence after vault failures.
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>
Restrict the browser install flow to Anthropic, OpenAI, and Gemini,
remove the unused install-time base URL surface, and reject
openai_compatible with a stable 422 response.
Also fix the finishing health poller so it only redirects after the
server comes back healthy outside install mode instead of jumping early
on transient restart failures.
Paginate board-eligible summaries before enriching them from run state,
add safety caps to paginated web fetches, and make demo run summaries
follow the production title and status-reason normalization rules.
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.
Complete the /runs and /boards/runs canonicalization work by fixing the
run-detail response shape, preserving lifecycle status separately from board
columns, loading all board pages in the web client, and aligning the shared
status_reason typing.
Collapses duplicated helpers in tests/it/api/tcp.rs introduced with
the TLS-removal test suite (single start_tcp_server, single
wait_for_health), uses ServerState::env_path() in write_test_config,
and replaces the manual SystemTime-based unique-socket path with a
tempdir. Also removes a narrative comment in settings_view that the
module docstring already covers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Remove server-side TLS listener support so Fabro only binds plain TCP
or Unix sockets, and update docs/tests around proxy-terminated HTTPS.
This also drops the removed [server.listen.tls] config shape and the
inbound TLS-specific diagnostics, fixtures, and integration coverage.
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.
The release workflow now builds musl artifacts with cargo-zigbuild, but
x86_64 musl tests still run through plain cargo test via nextest. Restore
musl-tools and the target-specific compiler/linker env for that test path
so fabro-proc's build.rs can compile its C helper again.
Keep GitHub /meta cache state under the resolved server storage tree by
adding a storage cache accessor and wiring the resolver to use
<storage_root>/cache.
Reuse `IpAllowEntry::parse_literal` instead of duplicating `IpNet`
parsing in the resolver, and drop the unreachable defensive branch
in `expand_ip_allow_entries` that called `unwrap_or_default` on a
value that is always `Some` once an entry needs GitHub hooks.
Adds a middleware test covering X-Forwarded-For routing with a
non-zero trusted proxy count, which previously relied on
`extract_client_ip` unit tests alone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Validate the effective GitHub webhook overlay for Unix listeners,
reuse cached GitHub /meta hook ranges when refresh fails, and
propagate webhook allowlist resolution errors during startup instead of
silently skipping the listener.
Introduces a configurable IP allowlist applied to the main API router
and the GitHub webhook listener. Supports CIDR literals plus a
`github_meta_hooks` keyword that resolves live against GitHub's meta
API for the webhooks override. Adds trusted-proxy handling for
X-Forwarded-For, validation that rejects Unix socket listeners without
a trusted proxy count, and deep-merge logic for the new
server.ip_allowlist and per-integration override layers.
Two fixes:
P0 — Container packaging blocks install mode. The published Dockerfile
bakes /etc/fabro/settings.toml and sets FABRO_CONFIG, which under the
explicit-config carveout means containers would never enter install
mode. v1 must change the Dockerfile: drop the baked settings file,
drop FABRO_CONFIG, set FABRO_STORAGE_DIR=/storage, and persist
~/.fabro across container restarts (recommendation: move FABRO_HOME
into a subdirectory of the /storage volume so one mount covers both
config and data). Spelled out as load-bearing v1 implementation work
under Orchestration config updates. New decision-log row #24.
P2 — Force-foreground decision was not carried through to all sections.
Two leftover references to a `__serve` daemon child contradicted the
"install mode never daemonizes" decision — one in the local-lifecycle
prose, one in the manual smoke test. Updated both to describe the
foreground process exiting cleanly. Decision-log row for #21 also
updated to reflect that the install process IS the operator's `fabro
server start` invocation under foreground mode.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>