Commit graph

1680 commits

Author SHA1 Message Date
Bryan Helmkamp
83743de293
chore(clippy): add unwrap-used baseline
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.
2026-04-19 20:43:23 -04:00
Bryan Helmkamp
72924ba611
Merge remote-tracking branch 'origin/main'
# Conflicts:
#	lib/crates/fabro-agent/src/cli.rs
#	lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs
#	lib/crates/fabro-cli/tests/it/cmd/exec.rs
#	lib/crates/fabro-cli/tests/it/scenario/lifecycle.rs
#	lib/crates/fabro-telemetry/src/spawn.rs
#	lib/crates/fabro-workflow/tests/it/attractor_compat.rs
#	lib/crates/fabro-workflow/tests/it/cp_integration.rs
2026-04-19 20:32:02 -04:00
Bryan Helmkamp
ad0d532691
chore(clippy): require reasons on allow attributes
Enable clippy::allow_attributes_without_reason at the workspace level.
Add concise, callsite-specific reasons to existing allow attributes, including generated code paths.
2026-04-19 20:24:24 -04:00
Bryan Helmkamp
f31007f988
feat(server): move batch run selector resolution to the server
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}.
2026-04-19 20:12:23 -04:00
Bryan Helmkamp
cd9ff8fafe
Merge remote-tracking branch 'origin/main'
# Conflicts:
#	lib/crates/fabro-server/src/server.rs
2026-04-19 19:54:30 -04:00
Bryan Helmkamp
64cf35cc7d
fix(lint): restore clippy cleanliness after async ripple from std::fs work
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>
2026-04-19 19:41:47 -04:00
Bryan Helmkamp
3435c02dca
fix(web): cover new archived RunStatus in Files empty-state taxonomy
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>
2026-04-19 19:41:03 -04:00
Bryan Helmkamp
a2b77b1a30
Merge remote-tracking branch 'origin/main'
# Conflicts:
#	lib/crates/fabro-cli/src/commands/run/rewind.rs
#	lib/crates/fabro-cli/src/logging.rs
2026-04-19 19:33:40 -04:00
Bryan Helmkamp
26db764e6c
feat(server): resolve run selectors on the server
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.
2026-04-19 19:31:13 -04:00
Bryan Helmkamp
32b88d7833
fix(async-fs): move remaining filesystem work off Tokio paths
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.
2026-04-19 19:31:03 -04:00
Bryan Helmkamp
1f6eb5b336
Merge origin/main into Run Files Changed feature branch
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>
2026-04-19 19:30:36 -04:00
Bryan Helmkamp
4b3835b8ee
test(server): cover resolve_head_sha_and_time parser + two-phase fetch
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>
2026-04-19 19:21:19 -04:00
Bryan Helmkamp
048fa37716
refactor: simplify pass on Run Files feature
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>
2026-04-19 19:05:09 -04:00
Bryan Helmkamp
4959778ea6
Merge origin/main into archived-run-status feature branch
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>
2026-04-19 18:57:07 -04:00
Bryan Helmkamp
a21ab24521
fix(store): harden archive/unarchive projection apply against corrupt logs
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.
2026-04-19 18:40:21 -04:00
Bryan Helmkamp
c44c93ff32
fix(web): complete lifecycle-status coverage + loader integration tests
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>
2026-04-19 18:37:41 -04:00
Bryan Helmkamp
23df53766f
fix(web): restore R5 error taxonomy in initial-load path
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>
2026-04-19 18:33:00 -04:00
Bryan Helmkamp
ef0cff2b31
fix(web,server): address three P2 gaps from review
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>
2026-04-19 18:18:54 -04:00
Bryan Helmkamp
48eb5efed6
refactor(archive): consolidate helpers after review
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.
2026-04-19 18:14:16 -04:00
Bryan Helmkamp
e2d258089e
fix(server,workflow): close denylist-evasion gaps from security review
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>
2026-04-19 18:04:49 -04:00
Bryan Helmkamp
828a09636e
test(server): cover submit_answer, artifact, sandbox, blob guards
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.
2026-04-19 17:59:32 -04:00
Bryan Helmkamp
0c77a184ab
fix(web,server): pierre 1.1 API fit + unicode-safe path normalize
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>
2026-04-19 17:55:20 -04:00
Bryan Helmkamp
7fdcf3156b
fix(cli): correct archive/unarchive bulk failure grammar
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.
2026-04-19 17:53:10 -04:00
Bryan Helmkamp
244b0eccce
feat(web): Run Files full UX polish + extraction (P2-3..P2-8, P3-3, P3-1)
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>
2026-04-19 17:43:34 -04:00
Bryan Helmkamp
ac10cb8016
polish: clarify -a hint, test archived status in web UI, tighten rewind input
- 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.
2026-04-19 17:43:00 -04:00
Bryan Helmkamp
3ae46b37c1
test(server): verify R14 contract and tighten rewind guard
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).
2026-04-19 17:42:50 -04:00
Bryan Helmkamp
05743050ec
test(cli): add end-to-end archive lifecycle scenario
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).
2026-04-19 17:42:43 -04:00
Bryan Helmkamp
13a501ec1d
test(cli): add archive and unarchive command integration tests
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.
2026-04-19 17:42:37 -04:00
Bryan Helmkamp
83a21e5d37
test(server): Run Files HTTP integration suite (P2-9)
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>
2026-04-19 17:40:14 -04:00
Bryan Helmkamp
5c248fc885
fix(clippy): clean up nightly-clippy findings on merged CLI work
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>
2026-04-19 17:39:06 -04:00
Bryan Helmkamp
fd9596883a
test(server): tracing allowlist + coalescer cancellation (P2-11, P2-13)
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>
2026-04-19 17:37:44 -04:00
Bryan Helmkamp
1ef72fb6bc
refactor(server): extract run_files_security with globset denylist (P3-2)
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>
2026-04-19 17:35:26 -04:00
Bryan Helmkamp
f0ee17b5b8
fix(server): capture to_sha_committed_at (P2-1)
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>
2026-04-19 17:31:04 -04:00
Bryan Helmkamp
948d59062a
Merge origin/main into UI cleanup branch
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.
2026-04-19 17:30:22 -04:00
Bryan Helmkamp
611772535f
fix(server): P1 correctness fixes for Run Files handler
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>
2026-04-19 17:29:02 -04:00
Bryan Helmkamp
9b5bc8ede4
style(run-stages): copy affordance, collapsed long output, sticky header
- 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>
2026-04-19 17:27:25 -04:00
Bryan Helmkamp
54867d29ed
feat(web): unify empty/error/loading states in one primitive
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>
2026-04-19 17:22:57 -04:00
Bryan Helmkamp
b87265867a
fix(archive): close mutation gaps and correct error mapping
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.
2026-04-19 17:22:56 -04:00
Bryan Helmkamp
19939c5f07
lint(clippy): disallow blocking std::fs on Tokio paths
Phase 2/3 of the std::fs lint initiative (Phase 1 refactors landed in
commit 9d1c0d98c).

clippy.toml additions (appended to disallowed-methods):
  std::fs::read, read_to_string, write, read_dir, copy, canonicalize
  std::fs::File::open, File::create, File::create_new
  std::fs::OpenOptions::open

File::options was deliberately excluded — it returns an OpenOptions
builder with no syscall. OpenOptions::open is where the block happens.
Non-blocking std::fs items (metadata, exists, create_dir_all, remove_*,
rename, and all std::fs types) remain legal.

Annotation policy (per updated plan):
  - Mixed async/sync production source: function- or statement-scoped
    #[expect(...)] so future accidental Tokio-path regressions in the
    same file still fire.
  - Fully-sync production source, test modules, integration tests,
    build.rs: file-level #![expect(...)].
  - Every #[expect] has a specific reason identifying the sync context.

Annotations added in ~90 files across the workspace. Notable narrow
placements: fabro-server server.rs current_server_target,
build_disk_usage_response, create_test_app_state_with_session_key;
fabro-server install.rs read_to_string rollback snapshot;
fabro-sandbox local.rs list_recursive; fabro-agent cli.rs FOLLOW-UP on
the JSON-stdout writer; fabro-llm providers/common.rs FOLLOW-UP for
load_file_as_base64 (7 translator call sites; revisit if file:// URL
usage grows).

build.rs blanket allows: fabro-api/build.rs, fabro-util/build.rs.

Pre-existing unrelated nightly-clippy warnings fixed under scope:
fabro-sandbox sandbox_spec.rs (unused_imports, unused_async),
reconnect.rs (unused_variables, unused_async).

Verified: cargo +nightly-2026-04-14 clippy --workspace --all-targets
-- -D warnings passes; fmt clean; 4129/4131 tests pass (two known
flakes under parallel nextest load, both pass individually and are
unrelated to this change).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 17:22:21 -04:00
Bryan Helmkamp
6050b5fa4b
feat(web): Run Files tab empty/error/loading states, refresh, a11y
Completes Units 11 and 12 of the Run Files Changed plan:

- Empty-state taxonomy: distinct copy for total_changed==0 vs no
  recoverable diff
- LoadingSkeleton on initial loader navigation (shimmer respects
  prefers-reduced-motion via motion-safe:animate-pulse)
- ErrorBoundary export handling 401/403/503/429 and generic 5xx
- Refresh button + Toolbar with freshness indicator; relative
  timestamps tick every 10s
- SSE subscription to /runs/{id}/attach with a 500ms debounce that
  revalidates on checkpoint.completed, run.completed, run.failed
- After a revalidation completes, focus returns to the Refresh button
- j/k keyboard navigation over file rows, ignoring key presses while
  a text field is focused
- md (768 px) breakpoint collapses split to unified without writing
  any persisted preference
- #file=<encoded-path> deep link scrolls + focuses the matching row
  on mount; absent file surfaces a 5s toast; patch-only mode shows
  a toast explaining the limitation
- Touch targets on the Refresh button meet WCAG 2.5.5 AAA (44x44)

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>
2026-04-19 17:13:17 -04:00
Bryan Helmkamp
b448a4e246
fix(theme): contrast on primary buttons and muted text in light mode
- introduce --color-on-primary (navy-950 in dark, white in light)
  so text on bg-teal-500 reads clearly regardless of mode; swap
  hardcoded text-navy-950 occurrences on teal fills for text-on-primary
- darken --color-fg-muted in light mode from slate-400 (#94a3b8) to
  slate-500 (#64748b); slate-400 failed AA on the tinted page
- deepen page tint to #eef2f7 and strengthen line/line-strong so
  white cards have real edges, not invisible hairlines

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 17:12:01 -04:00
Bryan Helmkamp
d0e23848d8
style(theme): soften light mode so cards read as the lighter surface
Light mode had the page at pure #ffffff with panels at #f8fafc — so
panels read darker than the page, the opposite of dark mode's
hierarchy and a big source of "blinding white" fatigue.

- page tinted to #f3f6fa (cool off-white, matching the brand's navy
  palette) so it no longer glows
- panel set to #ffffff so cards, the nav, and auth panels pop
- panel-alt (#e9eef5) sits between them for recessed wells
- overlay / line colors shifted from pure black rgba to the navy tint
  so the whole system reads coherent

Dark-mode tokens unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 17:08:31 -04:00
Bryan Helmkamp
731a8cc6e0
feat(web): rewrite Run Files tab, surface it in navigation
Rewrites apps/fabro-web/app/routes/run-files.tsx to consume the real
PaginatedRunFileList response and removes the fallbackFiles fixture
and the Steer subsystem. The new component:

- Loads via apiJsonOrNull, so a 404/501 (dev without the route)
  renders the empty state instead of the root error boundary
- Branches on meta.degraded + meta.patch to render PatchDiff with a
  DegradedBanner whose copy reflects degraded_reason
- Renders per-entry placeholders for sensitive, binary, symlink/
  submodule, and truncated files with the priority order
  sensitive > binary > symlink/submodule > truncated -- security
  flags never get hidden behind a lesser placeholder
- Renders one MultiFileDiff per regular entry
- Uses role="region" + aria-label on each file row

Also unhides the Files Changed tab in run-detail.tsx by flipping
broken: true -> false. Adds missing final_patch: None to the runner
RunFailed test fixtures to match the lifecycle change from Unit 2.

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>
2026-04-19 17:06:06 -04:00
Bryan Helmkamp
74cd7fe434
fix(settings): drop inner max-w wrapper so content aligns with shell header
The page wrapped its body in mx-auto max-w-4xl, which centered the
description and JSON inside the shell's max-w-5xl column. The
shell's "Settings" header used the outer 5xl bounds, so everything
below it shifted right. Let the page inherit the shell's width.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 17:04:59 -04:00
Bryan Helmkamp
4e4937c714
docs(settings): tighten copy — point at settings.toml instead of the CLI
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 17:04:04 -04:00
Bryan Helmkamp
81903b9ba4
chore(spa): refresh embedded bundle for Settings nav item
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 17:03:28 -04:00
Bryan Helmkamp
9d1c0d98c7
fix: migrate blocking fs calls off Tokio paths
Phase 1 of the std::fs lint initiative. Refactors blocking std::fs entry
points that ran inside async contexts. Caller chains either converted to
async (using tokio::fs) or wrapped in tokio::task::spawn_blocking where
sync callers were already natural (Command builders, flock semantics).

HIGH (per-request async hot paths):
  - fabro-sandbox local.rs: wrap recursive std::fs::read_dir traversal in
    spawn_blocking. Fixes /api/runs/{id}/files stalling workers under
    concurrent or deep listings.
  - fabro-server static_files.rs: convert serve/serve_install/serve_with_mode
    and the static-asset load chain to async; use tokio::fs::read for the
    debug-only disk fallback. Cascades through install.rs build_install_router
    (now async) and ~17 test call sites.

LOW (async but not per-request):
  - fabro-workflow artifact.rs: sync_artifacts_to_env, offload_large_values
    → tokio::fs::read_to_string.
  - fabro-workflow artifact_snapshot.rs: compute_artifact_info → async +
    tokio::fs::read.
  - fabro-server ip_allowlist.rs: load_cache and store_cache → async +
    tokio::fs::{read,write,create_dir_all}.
  - fabro-server server.rs: wrap worker_command invocation in spawn_blocking
    at the async boundary in execute_run_subprocess; keep the sync
    worker_command + current_server_target signatures intact.
  - fabro-cli server/start.rs: wrap the OpenOptions::open call in
    acquire_lock in spawn_blocking; file-lock semantics require a real
    std::fs::File, and the flock polling loop stays async with time::sleep.

Deferred:
  - fabro-llm load_file_as_base64 (file:// attachment loader): 7 call sites
    across 4 providers, each inside sync translators. Left for Phase 3
    annotation with a FOLLOW-UP marker; file:// URLs are rare in practice.

Verified: workspace builds, 4131 tests pass, 182 skipped. The lint that
enforces this discipline lands in the next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 17:00:56 -04:00
Bryan Helmkamp
420e375134
build(web): upgrade @pierre/diffs to 1.1.15
Bumps @pierre/diffs from 1.0.11 to 1.1.15 to pick up the Virtualizer
component and renderHeaderPrefix/renderCustomHeader hooks the Run
Files tab relies on for large-diff performance. 1.0 -> 1.1 merged
MouseEventManager/LineSelectionManager into InteractionManager but
the public React components (MultiFileDiff, PatchDiff, FileDiff,
File) keep their existing shape, so no consumer changes are needed
yet -- Unit 10 exercises the new features.

Pins an exact version (1.1.15) rather than a caret range so bun
doesn't resolve up to 1.1.16, which was published today and would
trip the "no packages younger than 24 h" rule in the user-global
policy.

The redundant apps/fabro-web/bun.lock is removed; bun workspaces
resolve against the root bun.lock and the per-app lockfile was
drifting from it. Embedded SPA bundle (lib/crates/fabro-spa/assets/)
is refreshed to match the new build 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>
2026-04-19 17:00:50 -04:00
Bryan Helmkamp
55c396d2ff
feat(cli+web): wire archived into listing visibility
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.
2026-04-19 16:58:55 -04:00
Bryan Helmkamp
c78c1fd17b
feat(server): demo-mode stub for /runs/{id}/files
Replaces the not_implemented placeholder in the demo router with a
demo::list_run_files_stub that returns a small illustrative
three-file diff (modified, added, renamed) matching the real handler's
PaginatedRunFileList wire shape. The stub ignores run_id and state so
demo mode and real mode cannot cross-contaminate (R34).

Unit 10 (frontend rendering paths) will remove the now-obsolete
client-side fallbackFiles fixture when it rewrites run-files.tsx.

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>
2026-04-19 16:57:25 -04:00