Commit graph

2499 commits

Author SHA1 Message Date
Bryan Helmkamp
86bb88cdca
refactor(server): tidy webhook wiring and test-helper context
- Distinguish the two GitHub webhook auth-failure warn messages (missing
  signature header vs. HMAC mismatch) so logs can tell them apart.
- Route update_github_app_webhook through fabro_github::github_api_base_url()
  so GITHUB_BASE_URL overrides the webhook config endpoint too.
- Drop a narrative shutdown comment that restated the next two lines.
- Replace concat!(file!(), ":", line!()) inside local test-helper wrappers;
  those macros expand at the wrapper definition site, so every panic
  reported the same phantom location. Pass the wrapper name instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 08:27:50 -04:00
Bryan Helmkamp
6829935649
test(http): satisfy clippy in response assert helpers
Align the shared HTTP assertion helper with repo lint rules so the
full workspace verification stays green after the new diagnostics
coverage landed.
2026-04-20 08:18:12 -04:00
Bryan Helmkamp
8e3f1524f8
Merge remote-tracking branch 'origin/main' 2026-04-20 08:07:03 -04:00
Bryan Helmkamp
9c3c66c59a
test(http): improve HTTP test failure diagnostics
Add shared axum/reqwest response assertion helpers in fabro-test,
migrate the Rust HTTP test surface to use them, and document the
new rule in the testing strategy.
2026-04-20 08:06:14 -04:00
Bryan Helmkamp
773307eec0
chore(spa): refresh embedded bundle to match bun 1.3.13
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>
2026-04-20 07:48:33 -04:00
Bryan Helmkamp
1ef10b46ce
refactor(server): simplify github webhook wiring
- Capture webhook secret at route mount time via Arc<[u8]> router
  state so the handler drops its per-request server_secret lookup
  and the dead NOT_FOUND fallback.
- Extract WEBHOOK_ROUTE and WEBHOOK_SECRET_ENV constants; apply
  across serve.rs, server.rs, and TailscaleFunnelManager so the
  mounted route and the URLs pushed to GitHub cannot drift.
- Flatten the seven-level nested webhook startup match in serve.rs
  into a single start_webhook_strategy helper with early returns,
  short-circuiting when the secret is absent and replacing the
  server.api.url .expect with a propagated error.
- Share compute_signature and a new read_repo_file helper across
  tests; delete the duplicated webhook_signature, TestHmacSha256,
  and read_doc/repo_root copies.
- Replace the nested for-loops in the new webhook auth tests with
  five flat #[tokio::test] cases per CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:32:38 -04:00
Bryan Helmkamp
c8b807f303
ci: freeze bun lockfile and widen typescript path filter
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>
2026-04-20 07:32:24 -04:00
Bryan Helmkamp
0686b82bce
fix(api): correct GitHub webhook OpenAPI contract
Model the GitHub webhook request body as JSON so the generated TypeScript
client exposes a coherent request shape, and add explicit conformance
coverage for the secret-gated webhook route.
2026-04-20 07:13:49 -04:00
Bryan Helmkamp
c8cc9f20b0
feat(server): add explicit GitHub webhook strategies
Move GitHub webhook intake onto the main API router, add explicit
server_url and tailscale_funnel strategies, and validate strategy
requirements at config resolution. This also updates the API contract,
generated client, and operator docs to match the new webhook model.
2026-04-19 22:53:22 -04:00
Bryan Helmkamp
b8d93603f8
chore(clippy): deny unwrap_used
Some checks are pending
Rust / Format (push) Waiting to run
Rust / Clippy (push) Waiting to run
Rust / Test (Linux) (push) Waiting to run
Rust / Test (macOS) (push) Waiting to run
TypeScript / Typecheck (push) Waiting to run
TypeScript / Test (push) Waiting to run
TypeScript / Build (push) Waiting to run
2026-04-19 21:12:06 -04:00
Bryan Helmkamp
aa3903f8a3
test(unwrap): clean cli fixture helpers 2026-04-19 21:09:28 -04:00
Bryan Helmkamp
2ec1fb2987
test(unwrap): clean server integration helpers 2026-04-19 21:06:36 -04:00
Bryan Helmkamp
0b91c71514
test(unwrap): clean shared support and workflow fixtures 2026-04-19 21:01:25 -04:00
Bryan Helmkamp
e0b2d71668
refactor(unwrap): clear non-test workspace warnings 2026-04-19 20:56:38 -04:00
Bryan Helmkamp
bd0d47ba4d
refactor(unwrap): clean runtime hotspot call sites 2026-04-19 20:48:44 -04:00
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
a62d06da4c
docs(plan): revert invented multi-node constraint; fabro-server is single-node
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>
2026-04-19 19:51:19 -04:00
Bryan Helmkamp
83d712db24
docs(plan): enforce v1 single-node constraint via required acknowledgment flag
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>
2026-04-19 19:51:19 -04:00
Bryan Helmkamp
e08e0163f8
docs(plan): add CLI auth login (GitHub OAuth + JWT) implementation plan
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>
2026-04-19 19:51:19 -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
695a6ca536
test(web): Run Files UI + pierre smoke test coverage (P2-10, P2-12)
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>
2026-04-19 17:48:20 -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