CI's TypeScript Build job upgraded bun to 1.3.13 (via setup-bun@v2.2.0
pulling the latest release), which produces a different content-hashed
entry CSS than the bundle committed under bun 1.3.10. The drift was
caught by the widened path filter in c8b807f30 and failed the
git diff --exit-code check on lib/crates/fabro-spa/assets.
Rebuilds with bun 1.3.13 so the embedded SPA matches the build CI
reproduces.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Prevents silent bun.lock drift across CI runs (root cause of the
nightly fabro-spa staleness failure) and re-runs typescript.yml when
nightly.yml itself changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Enable clippy's unwrap_used lint at warn level, document the long-term
policy carveouts for tests and LockResult, and localize the generated
OpenAPI client exemption so the remaining warning surface is real repo
code.
Enable clippy::allow_attributes_without_reason at the workspace level.
Add concise, callsite-specific reasons to existing allow attributes, including generated code paths.
Resolve rm/archive/unarchive selectors through the server-owned
runs/resolve endpoint instead of CLI-side summary matching, and move
active-run delete force semantics into DELETE /runs/{id}.
Reverts the `single_node_ack` acknowledgment flag and all the cross-node /
multi-node qualifier text introduced in the previous two commits. fabro-server
is single-node by design; there is no multi-node deployment model to design
against. The in-process per-hash and per-code mutexes in Units 9 and 10
provide the full atomicity guarantees R3 requires. R12 restored to the
original web-enabled + SESSION_SECRET-length check. No ack flag, no
config-surface EULA, no cross-node tests, no multi-node risks-table row.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds `server.auth.github.single_node_ack: bool` (default `false`) as a new
config field required by startup validation when `github` is in
`auth.methods`. Without the flag, the server refuses to boot. This turns the
previously documentation-only single-node constraint into a fail-closed
startup check — an operator can still misdeploy to multi-node after setting
the flag, but they must affirmatively acknowledge the tradeoff first.
Alternative (auto-detect via SlateDB boot-heartbeat) deferred as future
work; explicit acknowledgment is lower-complexity and avoids rolling-deploy
false positives. Enforcement lives in Unit 5 startup validation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a 22-unit implementation plan for `fabro auth login`/`logout`/`status`
with GitHub OAuth + PKCE, HS256 JWT access tokens, rotating opaque refresh
tokens, and a typed `IdpIdentity` flowing across fabro-types / fabro-store /
fabro-server. v1 is scoped to single-node deployments with `github` in
`auth.methods` (distributed refresh-token rotation coordination is deferred).
Origin spec: docs/superpowers/specs/2026-04-19-cli-auth-login-design.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two follow-ups the workspace lint now catches:
- fabro-server tests/it/api/install.rs: a newer install-router integration
test was missing the `.await` after `build_install_router(...)` -- the
fn became async when the devcontainer/install-mode resolver was
converted to tokio::fs in commit 19939c5f0.
- fabro-cli main.rs: add #[expect(clippy::disallowed_methods)] to the
#[cfg(test)] module whose write_test_settings helper uses sync
std::fs::write to stage CLI settings fixtures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
origin/main introduced a new `archived` lifecycle status as a terminal
state reached by explicit user action on a previously terminal run.
deriveEmptyKind didn't know about it — archived runs with files would
have rendered "The diff for this run is no longer available. If you
expect files here, please report it.", which is wrong; the diff was
captured normally, the run was just archived later.
Adds `archived` to the terminal-success branch so archived runs show
the correct empty-state copy (R4b or R4c2) based on total_changed,
same as a succeeded run.
The regression-guard test is also tightened: it now iterates over
`RunStatus` from @qltysh/fabro-api-client rather than a hand-
maintained list, so any future addition to the OpenAPI spec fails
this test until the decision table grows a branch. This exact class
of silent-regression is what made me miss archived in the first
place.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a server-native run selector endpoint and migrate CLI single-run flows to
use it instead of local workflow-store heuristics. This also moves store dump
export assembly into the CLI, removes the production CLI dependency on
fabro_workflow run lookup and dump helpers, and records the remaining
cli-to-workflow coupling in an audit document.
Replace the remaining blocking filesystem touches in shared async code with
Tokio-native I/O or explicit blocking boundaries. This keeps provider file
loading, workflow metadata rebuilds, and related export paths compatible with
the stricter clippy async-fs rules without changing their external behavior.
Integrates 39 commits from origin/main (archive/unarchive feature, UI
unification, theme/light-mode polish, Settings nav promotion, server
and CLI hardening).
Conflict resolutions:
- apps/fabro-web/app/routes/run-detail.tsx: origin removed the
`broken` field from the tab config; local added the Files Changed
tab. Kept the Files Changed tab, dropped the broken field per
origin's shape.
- lib/crates/fabro-store/src/run_state.rs: both sides added tests
in the same region. Kept local's two final_patch tests and all
four of origin's archive/unarchive tests.
- lib/crates/fabro-spa/assets/: embedded SPA bundle rebuilt from
the merged web source.
- lib/crates/fabro-workflow/src/operations/archive.rs: origin's new
archive tests construct Event::WorkflowRunFailed{..}; added the
final_patch: None field that local's lifecycle change introduced.
Workspace verification after merge: 4247 Rust tests + 95 web tests
all pass; clippy clean; fmt clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two coverage gaps closed:
1. `parse_head_show_output` — extracted from `resolve_head_sha_and_time`
as a pure function so it can be tested without a sandbox. Six tests
cover: well-formed sha+iso line, non-UTC timezone normalization,
sha-only output (missing %cI), malformed date (parser tolerates
and returns sha with None date), empty-input rejection, and
surrounding-whitespace tolerance. New code from the simplify pass,
previously unverified.
2. `fetch_blob_table` two-phase error isolation — `ScriptedBlobSandbox`
(hand-written minimal Sandbox impl) returns different exec responses
for `cat-file --batch-check` vs `cat-file --batch`. The phase-2
failure test proves that a malformed --batch parse outcome doesn't
corrupt phase-1-classified oversized entries — the doc-comment's
promise that the two phases are isolated now has a regression test
behind it. The phase-1-skip test enforces the
METADATA_PHASE_SHA_THRESHOLD contract by making phase 1's
batch-check response an error: if the threshold logic regressed
and phase 1 ran, the test would fail with a 503.
Also adds `Debug` to `ApiError` (required by `Result::expect` in the
new tests) and adds `async-trait`/`tokio-util` as dev-dependencies
plus the `test-support` feature on fabro-sandbox.
Total workspace test count: 4173 -> 4180.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Applied fixes from three parallel reviews (reuse, quality, efficiency):
Server
- Delete dead `sandbox_git_env()` in run_files_security.rs — duplicated
`sandbox_git.rs::sandbox_git_hardening_env` but had no callers.
- Combine `resolve_head_sha` + `resolve_commit_time` into one
`resolve_head_sha_and_time` using `git show -s --format=%H\ %cI HEAD`
— saves ~100ms per request (one fewer sandbox round-trip).
- Parallelize `list_changed_files_raw` + `list_binary_paths` with
`tokio::join!` — both are mutually independent once `to_sha` is
known, saves another ~100ms per request.
- Skip phase-1 `cat-file --batch-check` for SHA lists below 10 entries.
Phase-2 size-caps per-blob anyway; the pre-filter earned its cost
only for large batches where a single malformed blob could poison
the parse. Saves another ~100ms on small diffs.
- Extract `transient_503(op, message)` helper — dedupes three identical
`DiffError::Transient => ApiError::new(503, ...)` arms.
- Strip plan-referencing comments ("§ Unit 5", "P1-X", "P2-Y regression")
from production code and tests. The R4/R5 taxonomy labels are kept
where they anchor semantic intent.
Web
- Dedupe `extractRequestId`: one canonical parser in `run-files.tsx`
(consumed by the loader), one ErrorBoundary-only variant in
`states.tsx::extractRequestIdFromUnknown`. Both share the same logic;
separated only so each source can pick its own type discipline.
- Extract `renderStatusError({status, requestId, onRetry})` shared
between the loader's inline-error path and `RunFilesErrorBoundary`.
One canonical source of R5 copy.
- Gate the `useFreshness` 10s interval on `hasLabel` — previously it
ticked every 10s even when `meta == null` and there was no label to
refresh, re-rendering the whole route for nothing. Now the interval
only runs while there's actually a timestamp label mounted.
- Fix render-time ref mutation (`lastGoodDataRef.current = result.data`
in the render body) — violates React render purity. Moved into the
`useEffect` that watches `result?.data`. Also collapsed
`previousDataLengthRef` and `lastToShaRef` into single reads off
`lastGoodDataRef.current` — both were derivable from the cached
last-good payload.
- Type `DegradedBanner.reason` and `bannerCopyForReason` as
`RunFilesMetaDegradedReasonEnum` instead of raw `string`.
Tests: 4172 Rust + 94 web, clippy clean, fmt clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Origin brought 21 commits of UI/install/test-helpers work that lived in
parallel with the archive feature. Only the SPA build outputs conflicted
(old bundle hashes on both sides). Resolution: accept origin's
resolution on the deleted files, then re-run scripts/refresh-fabro-spa.sh
from the merged source so the embedded bundle reflects both sides —
origin's Settings-nav/theme/stage-sidebar work plus this branch's
archived-status TypeScript changes in apps/fabro-web/app/data/runs.ts.
Verification:
- cargo build --workspace: clean
- cargo nextest run --workspace: 4198 passed, 182 skipped
- cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings: clean
- cargo +nightly-2026-04-14 fmt --check --all: clean
- apps/fabro-web bun run typecheck: clean
- apps/fabro-web bun test app/data/runs.test.ts: 8 pass
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adversarial review surfaced that both RunArchived and RunUnarchived apply
arms were naive — any out-of-spec event in the log (concurrent double
archive, tampered import, replayed retry) would permanently corrupt the
projection:
- RunArchived unconditionally captured current status into prior_status.
A second RunArchived would set prior_status=Some(Archived). Unarchive
would then emit restored_status=Archived, the apply arm would set
status=Archived and clear prior_status, and the run would be unrecoverable.
- RunUnarchived trusted restored_status unconditionally. An imported
event with restored_status=Running produced a projection reporting
status=Running with no RunRunning event in the log — breaking
is_active/is_terminal invariants.
Both arms now require a sensible pre-state before mutating:
- RunArchived only transitions from Succeeded|Failed|Dead.
- RunUnarchived only runs from Archived with a terminal restored_status.
Adds three regression tests:
- double_archive_preserves_prior_status
- run_unarchived_with_non_terminal_restored_status_is_ignored
- run_archived_on_non_terminal_projection_is_ignored
The operations layer (archive/unarchive in fabro-workflow) still validates
at emit time; the projection guards are a defensive second line for
replay, imports, and any future code path that double-writes.
Two follow-ups from internal review:
1. deriveEmptyKind was incomplete. The full RunStatus enum (per
fabro-types/src/status.rs and apps/fabro-web/app/data/runs.ts) has
ten values — submitted, queued, starting, running, blocked,
paused, removing, succeeded, failed, dead. My decision table
covered only six and incorrectly included "partialsuccess" which
is a stage status, not a run status. Unhandled statuses
(blocked, paused, removing, dead) silently fell through to the
"diff_lost" branch, which showed users the alarmist "the diff for
this run is no longer available" copy for runs that are merely
paused or being torn down.
New table:
- submitted / queued / starting → R4(a) "starting"
- running / blocked / paused → R4(b) "no_changes" (yet — user
can refresh)
- failed / dead → R4(c1) "failed before checkpoint"
(R4b-equivalent when a degraded
patch did survive)
- succeeded / removing → R4(c2) "diff_lost" if
total_changed > 0, else R4(b)
- unknown future status → R4 "unknown" fallback
Test suite now drives each documented status through a regression
guard that asserts no known status collapses to "unknown" when a
more-specific kind should apply.
2. Loader integration tests. The `extractRequestId` unit test covers
only the extractor; nothing exercised the full fetch → body-read
→ requestId → error chain. Added 8 loader tests covering:
- 200 OK returns the parsed envelope
- 404 / 501 collapse to the empty-envelope signal (null + null)
- 500 with `request_id` in errors[0] populates error.requestId
- 500 without a request_id leaves it null
- 500 with non-JSON body still surfaces the status
- 503 populates error without requestId
- 401 surfaces as an error (no in-loader redirect — that concern
lives in apiFetch, which the Files loader deliberately bypasses
to preserve error bodies)
The tests stub globalThis.fetch; the loader already accepts the
cancellation-signal-only `request` object.
Refs docs/plans/2026-04-19-002-feat-run-files-changed-tab-plan.md §
Unit 11 R4/R5 taxonomies.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Earlier refactor to a discriminated-union loader accidentally
discarded the plan's R5 error taxonomy. `apiJsonOrNull` throws a
body-less Response on non-ok statuses, so the loader's try/catch had
no way to recover the server's error envelope or the request_id for
500s. The initial-error render then collapsed all statuses into
either `<EmptyState kind="unknown">` (401/403) or a generic
InlineErrorBanner — losing the plan-specified copy for access denied,
transient failures, and 500 with request ID.
Fixes:
- Loader now uses `fetch` directly against the API path so the
response body is preserved on non-ok statuses.
- 404/501 still collapse to `{data: null, error: null}` (the empty-
envelope signal the UI maps to R4).
- Any other non-ok parses the body as JSON, extracts request_id from
either the top-level `request_id` field or the uniform error
envelope (`errors[0].request_id` or parsed out of
`errors[0].detail`), and threads it through `error.requestId`.
- Component's `initialError` branch now applies the full R5 taxonomy:
R5(c) access denied for 401/403 with the specific copy, R5(a)
retry banner for 429/503, R5(d) "Something went wrong. Request ID:
<id>. Contact support." for 500s, and a generic retryable banner
for any other 4xx.
Adds run-files.test.ts covering extractRequestId across the three
locations request_id can show up in a server error body (top-level,
errors[0].request_id, errors[0].detail regex).
The RunFilesErrorBoundary export stays in place as defense-in-depth
for React render crashes — the loader no longer throws, but ensuring
the route always has a fallback is cheap.
Refs docs/plans/2026-04-19-002-feat-run-files-changed-tab-plan.md §
Unit 11 R5 taxonomy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1. Empty-state taxonomy was reading the wrong field. The parent Run
Detail loader returns status as `run.lifecycleStatus`, not
`run.status` (apps/fabro-web/app/data/runs.ts:86). resolveRunStatus
looked for `status` and always fell back to `unknown`, so R4(a)
starting / R4(c1) failed_before_checkpoint / R4(c2) diff_lost were
unreachable in the real route. Fixed to read `lifecycleStatus`.
2. Revalidation error state was dead code — the UI rendered
InlineErrorBanner from `revalidationError` but nothing ever set it
to non-null. Fixed by changing the loader contract to a
discriminated union `{ data, error }` that catches Response throws
and returns them in-band. This lets both initial-load and
revalidation errors flow through the same render path:
- Initial load with error + no prior data → inline error render
(no unmount, no ErrorBoundary trip)
- Revalidation error with prior data → keep prior data mounted,
show InlineErrorBanner + Retry
The plan's intent (§ Unit 11) was specifically "prior content stays
mounted" on mid-session failures; this finally implements it.
3. Live diff path skipped the planned stream_blob_metadata phase. A
single malformed blob in --batch output was collapsing the whole
fetch to an empty map and flagging every file in the response as
truncated. Two-phase fetch:
- Phase 1: stream_blob_metadata to identify oversized blobs by
size before any content fetch.
- Phase 2: stream_blobs on only the remaining under-cap SHAs.
A phase-2 parse error now only affects its own SHAs;
phase-1-classified oversized entries keep their correct
classification rather than all flipping to undifferentiated
truncated placeholders.
Refs plan docs/plans/2026-04-19-002-feat-run-files-changed-tab-plan.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three cleanups from `/simplify` review:
- Promote `archived_rejection_message` and `ensure_not_archived` to `pub`
via operations/mod.rs and reuse them from `resume`, the CLI rewind
caller, and the server's `reject_if_archived` guard so the canonical
error string lives in exactly one place.
- Tighten `RewindInput.current_status` from `Option<RunStatus>` to
`RunStatus`. The runtime check for None was enforcing a compile-time
invariant. CLI callers already load the projection and now surface a
clean error up-front if it's missing. Drop the None-branch test that
existed only to cover the removed runtime check.
- Collapse `archive_run` / `unarchive_run` HTTP handlers into a shared
`run_archive_action` body with an `ArchiveAction` enum, mirroring the
CLI pattern. Removes ~20 lines of copy-paste and unifies error-mapping.
Also drop narrative comments that referenced plan unit numbers in the
scenario tests, and clean up the convoluted `ps_runs` helper pattern
that built an empty-slot arg vec before filling it in.
No behavior change. Full workspace: 4185 tests pass, clippy clean.
Two fixes from the Run Files security review
(docs/agent/reviews/2026-04-19-run-files-security-review.md):
Medium — Add `-c core.quotePath=false` to git invocations that feed
the denylist.
- git_diff_with_timeout (produces final_patch for the degraded
fallback) — without this, a tracked file with non-ASCII chars,
tabs, quotes, or backslashes in its name makes git emit a
header like `diff --git "a/…" "b/…"`. The Run Files server's
strip_denylisted_sections parser only recognizes unquoted
`a/<old> b/<new>` forms and would let the sensitive section pass
through unfiltered.
- GIT_HARDENED (the raw-diff / cat-file prefix used by the Run
Files enumerator) — applied for symmetry so any future consumer
parsing these invocations' output can't be tripped by the same
quoted-path divergence.
Low — is_sensitive path normalization switches to ASCII-only case
fold. Full Unicode `to_lowercase()` can expand a codepoint into
multiple chars (e.g. `İ` -> `i\u{307}`), which then silently fails
to match an ASCII glob like `id_rsa`. ASCII-only folding makes the
homoglyphic-path failure mode explicit — a path a reviewer can see
is homoglyphic just doesn't match — rather than disguising it
behind an opaque lowercase routine. All denylist globs are ASCII by
design.
Other findings in the review (denylist policy coverage gaps around
id_rsa_backup / .netrc / .npmrc / etc.) are policy decisions, not
matcher bugs, and are deferred.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends `archived_runs_reject_mutations_with_actionable_body` to assert
the archive guard fires on the four write surfaces the Unit 4 audit
guarded but the scenario skipped: POST /questions/{qid}/answer, POST
/stages/{stage_id}/artifacts, PUT /sandbox/file, POST /blobs. Synthetic
stage/question/filename values are fine — `reject_if_archived` runs
before each endpoint's state-specific lookups.
Three follow-ups from verification against the actual @pierre/diffs
1.1.15 type definitions:
1. Deep-link expand uses `options.expandUnchanged: true` on the
targeted MultiFileDiff rather than firing `el.click()` on the outer
wrapper. Pierre 1.1.x exposes no imperative expand API — click on
the row container was a no-op. Per-file expansion now fires on
mount when the file name matches the URL hash.
2. Enter/Space binding removed from useFileKeyboardNav — click on the
outer row doesn't trigger anything in pierre's model, and binding
it just delayed default browser scroll behavior on Space. j/k
focus navigation remains the working keyboard affordance. When a
pierre imperative expand API appears, Enter/Space can be re-added
to call it.
3. normalize_for_match strip loop now iterates to a fixed point
against the fully-lowercased string so repeated `./` / `../` / `/`
prefixes are all stripped. Added Windows-path and Unicode-uppercase
regression tests for is_sensitive to verify basename matching
survives both.
Virtualizer usage verified against the 1.1.x type definitions: the
`{ children: ReactNode }` signature accepts the wrapped file list
directly with no Virtualizer.Item wrapper needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bulk error messages now read 'could not be archived' / 'could not be
unarchived' instead of the broken 'could not be archive'. Caught by
manual smoke: the previous `verb_ing()` helper returned the base verb
for both forms. Dropped `verb_ing()` and reused the already-correct
`past()` helper.
Adds four test files for the Run Files route:
- placeholders.test.tsx: validates pickPlaceholder priority order
(sensitive > binary > symlink/submodule > truncated) and
bannerCopyForReason copy distinctness + unknown-reason fallback
- states.test.tsx: full deriveEmptyKind decision table (R4a
starting, R4b no_changes, R4c1 failed_before_checkpoint, R4c2
diff_lost, unknown) plus component rendering assertions for
EmptyState, LoadingSkeleton, InlineErrorBanner onRetry wiring,
and Toast aria-live
- keyboard.test.ts: isEditableElement correctness across
input/textarea/select/contenteditable/null/case variants
- pierre-smoke.test.tsx: asserts MultiFileDiff, PatchDiff, and
Virtualizer remain exported as callable components after the 1.0
-> 1.1 upgrade (a full mount-under-test hits pierre's
useLayoutEffect teardown path that's incompatible with
react-test-renderer under React 19; functional mount coverage
lives in the dev-server smoke flow)
All rendering tests wrap TestRenderer.create in TestRenderer.act to
keep React 19 from synchronously unmounting before assertions run.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extracts inline components into apps/fabro-web/app/routes/run-files/:
- placeholders.tsx — sensitive/binary/symlink/submodule/truncated +
DegradedBanner + pickPlaceholder priority resolver
- states.tsx — EmptyState, LoadingSkeleton, InlineErrorBanner, Toast,
RunFilesErrorBoundary, emptyStateCopy, deriveEmptyKind
- toolbar.tsx — Toolbar with freshness + Refresh + Split/Unified
toggle, 44×44 touch targets
- keyboard.ts — useFileKeyboardNav with j/k nav + Enter/Space click
Adds:
- P2-3: consumes parent runStatus via useMatches to derive the 4-
variant R4 empty-state taxonomy (starting / no_changes /
failed_before_checkpoint / diff_lost) plus an "unknown" fallback
when the loader returned null.
- P2-4: RunFilesErrorBoundary handles 401/403 (access denied),
429/503 (inline retry affordance), 500 (parses request_id out of
the response body and surfaces it in the copy so users can cite
it when contacting support).
- P2-5: Refresh button now disables when the server reports the
same to_sha as the last successful fetch — no new checkpoint, no
point firing another request.
- P2-6: InlineErrorBanner for mid-session revalidation failures so
the user doesn't unmount to the route ErrorBoundary on a transient
SSE-triggered revalidation blip.
- P2-7: "No changes in this run" toast when a revalidation empties
the previously-populated list (files reverted upstream).
- P2-8: @pierre/diffs Virtualizer wraps file lists > 20 entries so
large runs don't synchronously mount every diff.
- P2-2: Split/Unified toggle with localStorage persistence
(fabro.run-files.diff-style). Below md (<768px) the toggle shows
the forced "unified" state but doesn't overwrite the persisted
desktop preference.
- P3-1: Enter/Space on a focused file row fires a click so
@pierre/diffs expand handlers (if any) take over, and the deep-
link handler now clicks the resolved row after scrolling to
trigger the same expand.
Refs plan docs/plans/2026-04-19-002-feat-run-files-changed-tab-plan.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Empty-list hint in `fabro ps` now mentions archived explicitly so users
discover the new surface (plan Unit 7 follow-up).
- apps/fabro-web runs.test.ts gains an `isRunStatus('archived')` +
`runStatusDisplay` assertion so the web UI type stays in lockstep with
the Rust enum.
- `operations::rewind` now requires callers to pass `current_status`
rather than silently skipping the archived guard when absent, closing a
silent-bypass hole.
Scenario coverage for the plan's R14 read-only-on-archived contract over
HTTP:
- archived_runs_reject_mutations_with_actionable_body drives a run to
succeeded, archives it, then asserts 409 on /cancel, /pause, /unpause,
/start, and /events with the actionable 'fabro unarchive' body.
- appending_run_archived_event_directly_is_rejected covers the widened
denylist on append_run_event.
- archive_returns_404_for_unknown_run proves the RunNotFound mapping.
- list_runs_respects_include_archived_flag exercises Unit 5's listing
filter.
Also adds inline server.rs tests that pin the spec/router behavior at
the unit layer and documents that rewind.rs now requires callers to
pass current_status (already threaded through from the CLI and scenario
tests).
Single #[test] that exercises the full CLI archive flow: run a dry-run to
succeeded, verify ps -a shows it, archive, verify default ps hides it and
ps -a shows archived, unarchive, verify the prior terminal status is
restored, then re-archive and rm to confirm archived runs remain
delete-able (plan Scope Boundaries).
Adds the CLI-layer integration coverage the archived-run plan called for
in its Unit 6 test scenarios but never landed: help snapshots, required-arg
handling, happy paths (including ps/ps -a visibility switching), precondition
errors (archive on active runs, unarchive on not-archived runs), unknown-id
errors, idempotent no-ops, JSON output shape, and mixed-batch per-id error
aggregation. 15 new tests across archive.rs and unarchive.rs mirror rm.rs's
fabro_snapshot style.
Adds lib/crates/fabro-server/tests/it/api/run_files.rs covering the
HTTP-level plumbing branches of GET /api/v1/runs/{id}/files:
- Invalid run_id path returns 400
- Unknown run returns 404 (IDOR-safe; same status as missing-run case)
- Malformed from_sha / to_sha query params return 400 before any work
- Non-default from_sha value returns 400 even when hex-well-formed
(v1 reserves the parameter for a future version)
- Submitted run with no sandbox record returns empty envelope
- Demo mode (X-Fabro-Demo: 1) returns the 3-entry fixture without
touching the run store, with at least one populated-content entry
- Response envelope shape matches PaginatedRunFileList contract:
data: FileDiff[], meta: { truncated, total_changed, ... } with
correct field types
Sandbox-path happy case (live diff) and degraded-fallback scenarios
stay covered by unit tests on stitch_file_diff, build_fallback_response,
and the sandbox_git helpers, since integration-level scheduler setup
for terminal-run tests is flaky without broader harness scaffolding.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Nothing behavioral — each change is what clippy asked for:
- fabro-test: wrap the three polling-helper thread::sleep calls in a
single poll_sleep() with an #[expect(clippy::disallowed_methods,
reason = …)] since the helpers are deliberately blocking
- fabro-test: server_log_files now uses Path::extension() with
eq_ignore_ascii_case("log") instead of a case-sensitive ends_with
- fabro-workflow: import default_storage_dir rather than calling it
through its full module path
- fabro-cli/server/record: same absolute_paths fix
- fabro-cli/main tests: use a `use tokio::runtime::Runtime` to stop
referencing `tokio::runtime::Runtime` by full path
- fabro-cli/tests: replace three `as u32` casts on as_u64() results
with u32::try_from(...).expect(…)
- fabro-cli/tests: six `format!("...", var)` assertions switched to
the inline `{var}` form clippy prefers
Full verification passes: fmt, clippy, cargo nextest (4141 tests),
bun typecheck, bun test (40 tests), bun build, SPA embed diff clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
P2-11: Assert RunFilesMetrics::emit writes ONLY the allowlisted field
set (run_id, file_count, bytes_total, duration_ms, truncated,
binary_count, sensitive_count, symlink_count, submodule_count, message).
Uses a tracing-subscriber Layer with a Visit impl that captures every
field name emitted under the run_files target; fails the test if any
non-allowlisted field appears. Catches future refactors that might add
paths/contents to the log line.
P2-13: Assert that when the first coalesce caller is cancelled mid-
materialization, the spawned task continues to completion and a
subsequent caller still receives the shared result. Proves the
tokio::spawn-based design survives request dropout without
re-materializing the diff.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Moves the sensitive-path denylist, sandbox-git env helper, and metrics
emitter into a dedicated run_files_security module so the Run Files
Changed endpoint has a single, testable surface for security controls.
Denylist upgrades to globset::GlobSet with two explicit lists:
- Basename globs: .env, .env.*, *.pem, id_rsa, id_rsa.*, id_ed25519*,
*.p12, *.keystore, *.key
- Path-suffix globs: .aws/credentials, .git/config, .ssh/**
Matching semantics explicitly pinned:
- Case-insensitive via lowercased normalization
- Path traversal (`../`, `./`, leading `/`) stripped before match
- Basename globs match the final segment only — prevents
`log/.env_audit/data.txt` from matching `.env.*`
- Empty/pathological paths fail closed (sensitive=true safe default)
Also ships:
- sandbox_git_env() returning the env-hardening map
- RunFilesMetrics struct + emit() so tracing never leaks paths/contents
Handler migrates to consume the new module; inline denylist and inline
info!() call removed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sandbox path: issue a best-effort `git show -s --format=%cI <to_sha>`
against the reconnected sandbox to resolve the commit time of HEAD,
parsed into chrono::DateTime<Utc>. Failures (command error, non-zero
exit, unparseable output) return None so the handler still succeeds;
the client simply won't show a "Checkpoint Xm ago" label.
Degraded path: populate meta.to_sha_committed_at from
projection.conclusion.timestamp (the run-end time). The patch was
captured then, so it's a reasonable proxy for "captured X ago" in the
UI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brings in the CLI storage/logging refactor (858e8e127), bootstrap
helper collapse (a256c14a7), and shared server-lifecycle test helpers
(f8c560a9a). No overlap with the web UI work on this branch.
Three P1 bugs from code review:
P1-1: Modified and renamed files now return real before/after contents.
Previously the handler fetched only each entry's new_blob and duplicated
that single blob onto both sides, so every modified file rendered as a
no-op diff in MultiFileDiff. The fetch path now collects both old_blob
and new_blob, deduplicated, into a single batched `cat-file --batch`
call and stitches contents back via a SHA->contents table. Added
regression tests for modify and rename.
P1-2: Degraded-patch denylist now matches both `a/<old>` and `b/<new>`
sides of each `diff --git` header. A sensitive file renamed to a benign
path was leaking its patch body through the fallback branch. Added
regression test with `.env.production -> docs/NOTES.md`.
P1-3: Sensitive classification now runs BEFORE the 200-file cap, per
the plan's R31-before-R27 ordering. Sensitive entries no longer evict
real changes when the cap is hit. Replaced the (fetch, prebuilt) Vec
pair with a single ClassifiedEntry-ordered list so response ordering
matches git diff --raw output.
Refs plan docs/plans/2026-04-19-002-feat-run-files-changed-tab-plan.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- extract a shared CopyButton into components/ui.tsx and drop the
install wizard's local duplicate
- sticky stage header at the top of the turn stream so users always
know which stage they're reading as they scroll
- copy-to-clipboard button on System, Assistant, and Command blocks;
revealed on hover/focus
- stdout/stderr longer than 20 lines collapse to the last 20 with a
"Show N earlier lines" expander
- bump the [10px] labels in tool-use.tsx to [11px] for readability
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds apps/fabro-web/app/components/state.tsx exposing EmptyState,
ErrorState, and LoadingState on a shared StatePanel chrome so every
"the content isn't ready" surface looks like the same app.
Swaps in place of bare <p> tags and ad-hoc bordered divs:
- run-detail: "Run not found" is now an ErrorState
- run-stages: "No stages yet" is an EmptyState
- run-overview: empty-graph panel is an EmptyState
- run-billing: empty-billing panel is an EmptyState
- runs: filtered-empty ("no matching runs") now renders an EmptyState
(the branded landing empty is preserved as RunsLandingEmpty)
- install-app: session-loading StatusPanel replaced by LoadingState
No change to the root ErrorBoundary — full-page crashes stay there.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>