Skip to content

Merge wrightful/main upstream (trace viewer, MCP, pnpm 11 toolchain)#14

Merged
bumper-joe-fairburn merged 75 commits into
mainfrom
astana
Jul 24, 2026
Merged

Merge wrightful/main upstream (trace viewer, MCP, pnpm 11 toolchain)#14
bumper-joe-fairburn merged 75 commits into
mainfrom
astana

Conversation

@bumper-joe-fairburn

@bumper-joe-fairburn bumper-joe-fairburn commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

What & why

This fork (gitasf/bumper-playwright-dashboard) had drifted ~75 commits behind its wrightful upstream, and along the way it independently re-ported some of the same features, so main and upstream had genuinely diverged. This PR merges wrightful/main into astana to converge the fork back onto upstream — done as two merges (the feature/refactor backlog, then the pnpm 11 dependency sweep joefairburn#71), resolving every conflict in favour of upstream as the source of truth.

This is a sync, not net-new work. There is no feature authored in this PR — nearly all 632 changed files come verbatim from upstream. The diff is large only because main was far behind.

What actually lands in main

Capabilities/refactors that upstream has and this fork's main currently lacks, now brought in:

  • Self-hosted trace viewer ("Test Replay"): the whole src/trace-viewer/ engine + vendored Playwright bits, so trace bytes stay on the dashboard origin instead of trace.playwright.dev.
  • Built-in MCP server: routes/api/mcp + src/lib/mcp/* (flaky-diagnosis + test-history tools) and OAuth-discovery middleware.
  • GitHub surfaces: sticky PR summary comment + checks (github-pr-comment.ts, github-checks.ts, github-surface-post.ts), installation settings, actor avatars.
  • Design-system pass: token/typography consolidation in styles.css, shared status/meta pills, underline tab bars, SSR-first avatars.
  • Platform hardening: Workers-cache / defensive-headers / rate-limit middleware, atomic usage accounting, keyset pagination.
  • Structural refactors: components/run/* and lib/{runs,artifacts,db,hooks}/* folder grouping; pg-integration.test.ts split into a pg-integration/ suite.
  • Toolchain bump: pnpm 10.33 → 11.16, void 0.10.4 → 0.10.10, better-auth 1.6.24, lucide-react v1 (icon renames), react-email v6, plus 6 new committed migrations and a reporter changeset.

What stays fork-specific (intentionally not converged)

  • pnpm-11 config lives in pnpm-workspace.yaml (upstream landed the same layout, so this is now aligned).
  • Root deploy script name is kept (upstream calls it deploy:void).
  • Bumper's integration brief (docs/integrations/wrightful-reporter.md) + porting worklogs remain.

How verified

  • pnpm check passes (format + lint + type-check) — 0 errors
  • Relevant tests pass (pnpm test) — dashboard 698 + workers 1388 + reporter 304 = 2390 passing
  • pnpm test:e2e — not run locally this session (green upstream)
  • Migrations: 6 new committed migrations came in from upstream (no regeneration needed)
  • Changeset: reporter changeset included from upstream

Notes for reviewers

The upstream commits are already reviewed; the only things authored here are the conflict resolutions, which is where review effort is best spent:

  1. package.json / pnpm-workspace.yaml — took upstream pnpm@11.16; removed a duplicate patchedDependencies key the auto-merge silently produced (astana's stale void@0.10.4 block would have broken pnpm install).
  2. Orphan deletions — removed astana's flat-path files superseded by upstream's reorg (components/run-*components/run/*, lib/{run,artifact,db}-* → grouped folders, old pg-integration.test.ts, dead dev-trigger.ts). Nothing in the merged tree imports them; typecheck + tests confirm.
  3. PULL_REQUEST_TEMPLATE.md case-collision resolved to upstream's uppercase name.

joefairburn and others added 30 commits July 2, 2026 15:42
Runs with more tests than the loader's 200-row seed showed truncated
filter counts (chips counted only loaded rows) and made the overflow
tests unbrowsable. Wire the existing GET /results back-paginator into
useRunRoom: page the tail in after mount and merge existing-wins under
the live accumulator, so the Tests list + per-status counts cover the
whole run.
A single retryable infra error (sandbox unavailable, container boot
timeout) emailed "down" then a spurious "recovered" on the retry.
Gate safeAlert on !result.infraError in runMonitorJob, and skip the
monitor lastStatus/lastRunAt bump in recordExecutionResult for infra
errors so the retry classifies against the true prior health baseline
(mirrors the stale-execution reaper's policy). The execution row still
records the error for the timeline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A same-run idempotent re-registration whose bytes changed reused the
existing row verbatim, so the upload guard (contentLength === sizeBytes)
rejected the new bytes with lengthMismatch. planArtifactRegistration now
emits row refreshes for reused rows whose stored size/type differ, and
registerArtifacts applies them in-batch and meters the signed byte delta
(shrinks never blocked; artifactCount counts only inserts).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ta (P1-1, P2-createdAt, P2-delta)

buildResultInsertStatements now folds the per-existing-result UPDATE +
child DELETEs into one multi-row INSERT ... ON CONFLICT (runId,testId)
DO UPDATE plus three IN-list child DELETEs, so a flush is a handful of
statements instead of ~4 per prefilled result (~20k serial round-trips
for a 5000-result flush).

createdAt is now insert-only (omitted from DO UPDATE); a new nullable
updatedAt column carries the write time — fixes the old UPDATE path that
rewrote createdAt to flush time and skewed usage/analytics/retention.

appendRunResults takes a run-row FOR UPDATE lock and reads prev-status
under it (mirrors completeShardedRun), so concurrent duplicate flushes
can't double-apply the additive counter delta and the upsert id-mapping
stays race-safe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…drain budget (P1-4)

sweepRetention selected projects with no ORDER BY, so the budget-bounded
drain always started at the same head and the tail beyond the chunk
budget was swept late or never every 6h pass. Add ORDER BY random() and
charge the chunk budget only for PRODUCTIVE chunks (idle projects cost
only their probe SELECTs, bounded by the wall-clock deadline), so the
productive budget is reserved for projects with eligible rows and every
project is eventually reached.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…(P1-5)

The ⌘K test search matches title/file with a leading-wildcard ILIKE that
no b-tree can accelerate, so it full-scanned the project's testResults
partition. Add two gin_trgm_ops GIN indexes (planner BitmapOrs them) so
the ILIKE becomes a Bitmap Index Scan; the migration hand-adds
CREATE EXTENSION IF NOT EXISTS pg_trgm (drizzle-kit omits it). No query
change — the client already debounces. Verified on real Postgres 16:
EXPLAIN shows BitmapOr over both trgm indexes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three loaders (run-detail, test-detail, runs-list) returned the full
runs row — including idempotencyKey, the write-reopen credential — into
browser SSR props (runs-list leaked a whole page). Project the new
RUN_PUBLIC_COLUMNS allowlist (every runs column except idempotencyKey)
instead of a bare .select(); a regression test forbids re-adding the key
and forces a conscious decision for any future runs column.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…mment sweep (P3)

Correct ARCHITECTURE.md (direct-R2 path, 7 crons incl reconcile-billing,
billing env) and SELF-HOSTING.md (retention default 200->1000 + new
knobs, Polar billing section); qualify root CLAUDE.md dashboard-only
commands. Add SECURITY.md (private vuln reporting), CONTRIBUTING.md,
CODE_OF_CONDUCT.md, PR + issue templates. Fix misdirecting comments:
schema.ts nonexistent src/live.ts pointer, artifacts.ts stale D1/SQLite
refs + false 'no presign', keys.ts backwards SQLite-LIKE note. Delete
stale apps/dashboard/todo.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rter)

Clamp title (hard-rejected identity field → 400s the run) to MAX_TITLE
and error message/stack/annotation text to MAX_MESSAGE/MAX_STACK so an
oversized value can't 400/413 a whole run non-retryably; caps mirror the
dashboard MAX (pinned in contract.test). Re-raise SIGTERM after the
best-effort /complete so the reporter doesn't linger until SIGKILL
(Playwright <=1.61 ignores SIGTERM and our listener suppressed the
default); SIGINT still defers to Playwright. Fix the batcher's phantom
'fallback file' docstring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… the test title

TestRow only stripped the `${file} > ` title prefix when the title started
with the file, which fails once a project name leads Playwright's titlePath
(`project > file > describe… > test`). Multi-project runs therefore showed the
project + file inside the `>` chain AND repeated the project in a fixed 60px
column with no truncate — long names ("Google Chrome for Android") wrapped and
grew the row past min-h-8.

Parse the title with the existing parseTitleSegments() (strips project + file)
so the row shows a clean `describe > test`, and render the complementary axis
(project when grouped by file, file basename when grouped by project) as a
compact truncating pill. Pill is omitted for single-project runs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ackfill

fix(runs): back-paginate run-detail tests beyond the SSR seed window
Move the alert-recipients picker off the monitor detail page and into the
monitor edit form, and render the edit surface as a Base UI Dialog driven by
the existing ?edit=1 flag. Config + recipients now persist atomically in a
single updateMonitor UPDATE; the standalone setAlertRecipients action and
setMonitorAlertTargets repo helper were removed. alertTargets flows as the
domain AlertTargets type through UpdateMonitorInput with serialization owned
by the repo. E2E page objects + the recipients round-trip spec drive the
modal flow.

See docs/worklog/2026-07-02-monitor-edit-modal-recipients.md.
…fairburn#36)

Threads a new per-test `shardIndex` end-to-end (reporter wire type ->
Zod ingest schema -> testResults column -> SSR/paginate -> realtime row
-> client grouping engine) so the run-detail Tests tab can group rows by
shard alongside file / Playwright project. 1-based, nullable ("Unsharded"
for non-sharded runs). Additive + optional, so no protocol bump and
version skew parses clean.

Also adds a `seed:sharded` local seeder that injects one genuinely
sharded run into the dev dashboard (opens/appends/completes once per
shard under a shared idempotencyKey), plus the additive migration
(ALTER TABLE testResults ADD COLUMN shardIndex integer).
…burn#37)

* perf(db): drop redundant testOwners_project_testId_idx index

Implements PlanetScale schema recommendation #1 (duplicate_index,
left_prefix). The standalone (projectId, testId) index is a strict
left-prefix of the unique index testOwners_project_testId_owner_idx on
(projectId, testId, owner), which already serves the same access pattern
(the resolveTestOwners per-test lookup). Dropping it removes write
amplification and storage for zero read cost.

* style(worklog): oxfmt markdown table alignment
…joefairburn#38)

Rolls out the defer()/skeleton streaming pattern across the dashboard and
upgrades Void 0.9.2 -> 0.10.4. Each converted page keeps its cheap
shell/404-gate/realtime-seed eager and streams heavy reads behind a
CLS-matched skeleton (Cache-Control: private, no-store).

Pages: insights/index, insights/run-duration, insights/suite-size,
insights/slowest-tests, flaky, runs/[runId] (+ history chart),
runs/[runId]/tests/[testResultId], runs/[runId]/diff, tests catalog, settings
audit log. tests/[testId], project index, and monitors/[monitorId] stay eager
by design.

- run-diff.ts split additively (resolveRunDiffTargets + computeRunDiff); JSON
  API unchanged.
- run-history-chart.tsx: shared RunHistoryChartFrame co-locates the chart
  skeleton (no drift-prone measured height).
- Shared TablePaginationFooterSkeleton + BottlenecksTableHead; removed unused
  ListRowsSkeleton.
- Fixed: defer() incompatible with defineHandler.withValidator (collapses the
  Deferred prop at runtime) -> tests + audit parse searchParams manually.
- Void 0.10.4 upgrade + re-authored pnpm patches. wrangler held at 4.94.0
  (4.107.0 removes unstable_getWorkerNameFromProject, which
  @cloudflare/vite-plugin@1.38.0 needs for the dev server).
- Docs-only worklog recording the declined fate data-layer spike.

Verified: pnpm check (0 errors), unit tests (dashboard 233 + 1140, reporter
281), build clean, full CI green (incl. Postgres Integration + both E2E suites).
…n#41)

Deploy failed at db:migrate:remote because 20260703211031_massive_justice.sql
does a DROP INDEX (redundant testOwners_project_testId_idx from joefairburn#37), which Void
refuses to run without an explicit opt-in. Add the -- void:allow-destructive
pragma so the migration applies on deploy.
joefairburn#39)

* feat(schema): tests catalog, jsonb columns, integrity FKs, NOT NULL tightening

First-principles schema pass (docs/schema-rework-plan.md) — stacked forward-only
migrations with in-migration backfills:

- New per-project `tests` identity catalog; the ⌘K palette search and the
  tests/slowest-tests `q` filter now resolve against it, so the trigram GIN
  indexes are bounded by live suite size instead of retained-result history.
- text→jsonb for monitors.config/alertTargets/retryConfig,
  monitorExecutions.resultDetail, and auditLog.metadata — objects are stored
  directly (no JSON.stringify) and read back already-parsed.
- Real FKs with onDelete:"set null" for runs.monitorId and
  monitorExecutions.runId (the cycle rationale was disproven); openRun recovers
  a monitor deleted mid-open via FK-violation retry.
- NOT NULL tightening on runs.lastActivityAt and testResults.updatedAt
  (backfilled), dropping the coalesce fallbacks in staleRunFilter et al.
- Drop the half-alive usageCounters.testResultsCount — derived-on-read
  (countTeamTestResults) is now the single source.
- User-teardown for the Better Auth deleteUser gap: sole-owner guard
  (beforeDelete) + logical-FK row sweep (afterDelete).
- Retention-window CHECK on teams (artifact window ≤ testResults window).

Verified: pnpm check (0 errors), dashboard vitest (node 244 pass/4 skip,
workers 1138 pass).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(e2e): de-flake login specs — wait out Void's hydration re-navigation

`auth.spec.ts:23 (invalid sign-in credentials)` flaked in the E2E UI job (on
main too): `getByRole('alert')` never appeared, call log showing
"3× waiting for /login navigation to finish".

Root cause: Void's client performs one client-side History re-navigation to the
current route on hydration, which remounts the login island and resets its local
React state (email/password/error). Sign-in is fully client-side, so a fill or
submit that races ahead of that re-nav is silently wiped and the error alert
never renders. The existing enabled-button gate mostly-but-not-reliably dodged
it; under CI's 3-worker shared-dev-server load the timing slips and it flakes.

Fix: `gotoSignIn`/`gotoSignUp` now wait for `networkidle` after `goto`, so the
re-navigation settles before any spec touches the form. Measured against a
booted dashboard: interacting before the settle fails 0/8; after it, 8/8.
CI-faithful run (workers:3, retries:2, repeat-each=3): 24 passed, 0 flaky.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a StatusGlyph to each collapsible group header (file / project / shard)
on the run-detail Tests tab, rolling up the group's worst status
(failed → flaky → passed → skipped; skipped only when the group is entirely
skipped) so a collapsed group reads at a glance. Also shrink the run header
summary tiles to text-[11px] so they no longer read oversized next to the bar.
…t-feel nav

Close the gap to CSR-instant navigation across the dashboard:

- Add a <Link> wrapper (src/components/ui/link.tsx) defaulting prefetch="hover";
  migrate all internal nav to it. Prefetch resolves the full page (incl defer()
  bodies) server-side into the client cache, so a warmed click paints instantly.
  Tier cacheFor: PREFETCH_STABLE (SWR) for heavy non-realtime pages,
  PREFETCH_REALTIME (short) for realtime-seeded pages.
- Re-enable Better Auth session.cookieCache (maxAge 60) — lost in the
  rwsdk->void/auth migration; drops a per-nav Postgres session read.
- Parallelize the run-detail, runs-list, and monitor-detail loaders with
  better-all (dependency-aware all()).
- Lazy-load CodeMirror off the monitor pages and the CommandMenu off the layout.
- defer() the test-detail, settings usage/billing, and monitor-detail loaders
  behind skeletons. The monitors LIST stays eager (verified realtime seed).

Worklogs: docs/worklog/2026-07-04-link-prefetch.md,
docs/worklog/2026-07-04-nav-speed-followups.md
Follow-up to b2f9697 (nav-perf) from a code-quality review of that commit.

- Apply PREFETCH_REALTIME ("5s") to every inbound link to a realtime-seeded
  page — sidebar Runs/Monitors, the "View full report" popover, and the
  runs-list/run-detail breadcrumbs — so a hover-prefetch can't commit a stale
  room seed. Rooms have no replay and useFeedRoom only refreshes on WS re-open
  (no initial-mount reconcile), so the 30s default could show a completed run
  as still-running or hide a brand-new run. The commit had applied 5s only to
  run-list rows.
- Wrap the new React.lazy CodeMirror + CommandMenu mounts in an error boundary
  (reusing DeferErrorBoundary) so a post-deploy chunk-load 404 degrades to the
  textarea / no-op instead of throwing past Suspense and blanking the form/app.
- Fix a <div>-in-<p> skeleton that triggered a hydration warning on test detail.
- Correct two stale worklog claims and add a follow-up worklog.

Verified: pnpm check (0 errors), tsgo typecheck clean, dashboard tests
244 + 1150 passed.
The remote deploy failed at db:migrate:remote — Void's migrator refuses
destructive statements without an explicit opt-in. These two migrations
from the joefairburn#39 schema rework are intentionally destructive:

- unique_vapor: drops the testResults trigram indexes (recreated on the
  new tests catalog table)
- mixed_the_fury: drops runs_project_monitor_created_at_idx and the
  usageCounters.testResultsCount column

Add `-- void:allow-destructive` to each so the deploy migrate step passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…fairburn#42)

* feat(runs): paginate run tests by group

* style(worklog): fix markdown formatting for CI

* docs(worklog): add group-header status-icons entry

* refactor(run-detail): paginate-by-group follow-ups + code-quality pass

Lands the outstanding Tests-tab paginate-by-group refinements (deferred
client-loaded group list, Recommended filter, keyset-paginated group headers)
together with a strict maintainability pass over the diff.

Quality pass (behavior-preserving except the recommended ordering fix):
- Split run-progress.tsx (721 -> 353) into run-progress{,-group,-row,-skeletons}.tsx
- Extract mergeGroupRows/dedupeGroups to the tested pure layer (group-tests-by-file)
- Fix recommended-view page ordering: server orders failed-before-flaky via a
  CASE bucket rank + rank-carrying keyset cursor, so a large group no longer
  reorders rows across the scroll boundary
- Server emits hasFailingGroup; client drops the re-derived hasBadGroup
- Extract useInfiniteScrollSentinel; reuse useDebouncedValue; single-source the
  failed*4+flaky*2 severity weight
- De-footgun skipOwnershipCheck (fix stale docs + wire the CSV export loop);
  drop the redundant file-axis key ternary in the /results route

See docs/worklog/2026-07-04-run-tests-paginate-by-group.md.

Verification: pnpm check 0 errors; dashboard tests 253 (node) + 1153 (workers) pass.

* style(worklog): fix markdown formatting for CI

* fix(e2e): adapt Tests-tab e2e to paginate-by-group UX

The Tests tab now loads its group list client-side and renders test rows only
inside expanded groups (only failing groups auto-expand), so the flat-list
assumptions in the e2e suites broke:

- run-progress-group: add data-testid + aria-expanded to the group disclosure
  button (a11y improvement + stable test hook).
- run-detail POM: add `expandTestGroups()` (waits for the client-loaded list,
  opens each collapsed group); `clickFirstTest()` expands first.
- run-detail / test-detail / realtime specs: expand groups before locating
  `a[href*="/tests/"]` rows.
- vitest e2e: the run-detail SSR HTML no longer contains file names (client-
  loaded), so assert the `.spec` files via the grouped-read API the island calls.
runRows/runRow gain an optional `tags` arg that appends a SQLCommenter
key='value' comment (application/service/environment/source/feature/
route/release_sha) so PlanetScale Query Insights can attribute raw
queries to a code path and deploy. No tags → the query is unchanged;
builder queries stay untagged (cheap indexed lookups, no clean hook).
Adopted at the test-owners and monitor-uptime raw reads. release_sha
reads a build-time VITE_RELEASE_SHA (omitted when unset).
Under run_worker_first:["/**"] every /assets/*.js chunk fetch runs the full
middleware stack. 01.context resolved the tenant bundle (a Postgres read) for
those requests too, and a DB/Hyperdrive blip made it throw — which 00.errors'
guard-less catch arm rewrote to the HTML /oops page, so the .js chunk answered
500 and client-side navigation (Settings was first to hit it) broke with
"Failed to fetch dynamically imported module".

- 01.context: short-circuit static assets + error pages to a stub bundle before
  the DB read, like the /api/* branch.
- 00.errors: catch arm mirrors the post-next() static-asset guard (pass a thrown
  Response through, else log + 503, never HTML).

Reproduced + validated against a production build (vp preview on workerd) with
Postgres stopped: existing chunks 500 -> 200, missing 500 -> 404. Adds a 6-case
middleware regression test + worklog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cuts per-nav Postgres session reads for active users to ~one per 5 min
(from one per 60s window), trading a slightly longer cross-device revocation
lag — fine for a CI dashboard. Complements the asset-serving fix in the prior
commit (static chunks no longer run a tenant DB read at all).

`refreshCache` was considered and deliberately NOT set: Better Auth
force-disables it when a database adapter is configured (it re-signs cached
data without a DB read, so it's for stateless setups) and otherwise only logs
a warning on every auth-context creation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
joefairburn and others added 25 commits July 10, 2026 00:15
…efairburn#49)

A four-theme presentational pass plus a strict code-quality review of the
result. No data-flow or behaviour changes beyond loading/animation timing.

- SSR-first avatars: AvatarImage ships a native <img> in server HTML so the
  fetch starts on first paint (fixes late-loading GitHub avatars); adds
  https://github.com to CSP img-src; avatar drops the now-vestigial Base UI
  primitive for plain spans and keys the load-failure state to src.
- Motion: origin-aware enter/exit on popover/menu/combobox/autocomplete,
  copy-to-clipboard pop, progressive-enhancement pending submit buttons,
  animated monitor exec-row disclosures, and a visual-diff drag slider.
- Accessibility triad: one global layer for prefers-reduced-motion /
  prefers-reduced-transparency / prefers-contrast.
- De-drift: single-sourced ease-out-strong token, a spin theme override
  (all spinners 0.6s), type-ramp tracking, scoped width-bar transitions,
  and a root TooltipProvider arming the shared skip-delay.

See docs/worklog/2026-07-09-*.md for the full rationale and verification.
joefairburn#50)

* feat(dashboard): architecture deepening + security & DB perf hardening

Consolidated `review` branch work (see docs/worklog/2026-07-12-review-branch-consolidated.md):

- 9 shallow->deep module refactors (canonical run read-model, artifact-serve
  seam, typed form-flash slots, keyset-cursor codec, monitor-badge projection
  bug fix, offset-pagination helper, capability gates).
- H1 security: verify GitHub App installation ownership in the setup callback
  (closes confused-deputy / installation-takeover hole).
- DB review: convention-parity WHERE-scoping on runs writes, set-based
  reconcileUsage, new indexes (runs_team_createdAt, trigram GINs), runs-list
  OFFSET->keyset pagination, deferred heavy KPI aggregates.
- Split pg-integration.test.ts into a domain directory (69/69 preserved),
  Playwright best-practices pass, dropped unnecessary use-effects.

Migrations 20260711160102 (githubCheckClaimedAt) + 20260711220029 (indexes),
both additive.

* docs(worklog): fold perf/e2e/useEffect worklogs into consolidated entry
…burn#51)

Rename the 7-step type ramp from px-literal token names (text-11…text-26)
to role names (text-micro…text-display), and fix typography gaps found by a
whole-dashboard review: off-ramp heading sizes snapped to the ramp, iOS input
zoom, underline offsets, deliberate text wrapping, and font-smoothing/synthesis
parity. Registers the new names in cn.ts (tailwind-merge classGroup) and adds a
token-conventions guard banning the legacy numeric names.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: refresh agent guidance

* docs: clarify artifact upload paths
…t SW engine (joefairburn#52)

* feat(dashboard): native trace viewer — own Replay workbench on the vendored Playwright SW engine

Replace the Replay modal's iframe of the official Playwright trace viewer
with Wrightful's own React workbench (src/trace-viewer/), built from the
dashboard's component library. The hard parts stay upstream: DOM-snapshot
rendering, trace.zip range-reading, and the format modernizer remain in
Playwright's compiled service worker, which our UI drives over its HTTP
contract (contexts / snapshot/<pageId> / sha1). A hidden inline bridge
iframe under the SW scope loads the model and relays it via postMessage;
snapshot documents render as SW-served navigation iframes directly from
the dashboard tree.

- Vendor the trace-model SOURCE (Apache-2.0, tag v1.61.1) into
  src/trace-viewer/vendor/ with provenance headers; nothing is published
  on npm (upstream is private) — copies are the maintainer-sanctioned path.
- Drift guards: vendor/version.ts + a unit test that fails when
  playwright-core moves past the synced tag; the replay e2e drives a real
  trace through the real SW as the runtime contract test.
- Workbench: action tree (status, duration, console badges, keyboard nav),
  Before/Action/After snapshot scrubber with click-pointer overlay,
  Log/Errors/Console/Network/Attachments/Metadata tabs, bespoke split panes.
- Dialog keeps ?replay= deep-linking; official viewer stays vendored as a
  new-tab fallback ("Official viewer"), public-viewer link unchanged.

Verified: pnpm check 0 errors (baseline warnings); node lane 290 passed
(10 new model tests + vendor guard), workers lane 1222 passed; isolated
dashboard e2e test-replay.spec.ts 3/3; manual Chromium run against a real
ingested trace (action list, snapshot render, tabs, Escape) screenshotted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(trace-viewer): bridge fetch-proxy + filmstrip, Source tab, previews, attempts, scoping, warm, vendor sync

The one architectural addition is the bridge fetch-proxy: bridge.html gains
a postMessage RPC that fetches snapshotInfo/* and sha1/* from inside the
SW-controlled client (the only place the SW answers) and returns JSON/Blobs
to the dashboard. use-trace-model exposes it as a stable TraceBridge;
use-object-url turns proxied blobs into managed object URLs. That unlocks,
in one pass:

- Filmstrip/timeline above the workbench: proxied screencast thumbnails,
  selected-action window overlay, hover time cursor, click/drag-to-seek
  (impossible without the proxy — sha1 <img> URLs 404 from the dashboard).
- Source tab: sha1/src@<SHA-1(raw stack-frame path)>.txt (mechanism verified
  against upstream v1.61.1 AND empirically against a real trace), target
  line highlighted + scrolled, error lines annotated, per-file tabs.
- Inline image attachment previews (proxied blob / data-URL thumbnails).
- Snapshot URL bar + exact per-snapshot viewport via snapshotInfo (fixes
  mid-run setViewportSize; falls back to the context viewport).

Plus the review follow-up list:
- No-flash snapshot tabs: Before/Action/After iframes stay mounted, stacked,
  visibility-toggled (e2e locator updated for the new per-tab titles).
- Action-group chips: route/getter/configuration hidden by default (official
  filteredActions semantics), toggleable with counts, persisted.
- Attempt switcher: /replay returns per-attempt signed URLs; the modal shows
  a SegmentedControl for retried tests (default: last attempt).
- Console/Network crosshair toggle: filter to the selected action's window
  instead of highlight-only.
- TRACE_TOKEN_TTL_SECONDS (8h) for trace tokens everywhere they're minted —
  the SW range-reads lazily, so 1h tokens failed quietly mid-scrub.
- warmTraceViewer(): SW pre-warm on Replay-button hover (register-only on
  rows, full model prefetch on the rail where the URL is known at SSR).
- scripts/sync-trace-vendor.mjs (+ sync:trace-vendor script): automated
  vendor re-sync from the installed playwright-core's tag with import
  rewrites, header preservation, --dry-run and --pr modes; dry-run at the
  current pin round-trips every verbatim file byte-identically.

Server-side step extraction is scoped (docs/step-extraction-plan.md) and
deliberately deferred to its own ingest-touching change.

Verified: pnpm check 0 errors (baseline warnings); node 291 / workers 1222
passed; isolated dashboard e2e replay spec 3/3; real-browser drive against
seeded traces exercised every feature (screenshots) including a 3-attempt
flaky test for the switcher.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(trace-viewer): official-viewer parity pass — Call tab, network drawer, search, timeline bars/preview, popout, previews, copy-prompt

- Call tab (default when no errors): params, return value, timing, page, error
- Network detail drawer: General / stacked HAR timing bar / headers / body
  previews via the bridge proxy
- Action-list search (SearchFilterInput; ancestor-preserving tree filter)
- Timeline: per-action bars lane + hover screencast preview card
- Snapshot popout (vendored snapshot.html shell) + persisted
  canvas-from-screenshot toggle
- Attachment text/JSON inline previews; Copy-prompt button on errors (1.51
  parity, useCopiedFlag)
- Source tab: stack-frames pane (click to switch file + highlight line);
  CodeMirror highlighting attempted and REVERTED (@codemirror/state
  dual-instance breakage under Vite dev pre-bundling — documented)
- bridge.html: on load failure, probe the trace URL and surface the real
  HTTP status + hint (fixes the misleading "Local Network Access" copy when
  local artifact bytes are missing — diagnosed live from a 404)

Verified: pnpm check 0 errors; node 291 passed; real-browser screenshots of
every feature; isolated dashboard e2e replay spec 3/3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(trace-viewer): extensive coverage — fixture, protocol, and component suites

Nine new node-lane suites + a shared synthetic-trace fixture (runs through
the real vendored MultiTraceModel; TraceBridge fake keyed by path prefix).
Node lane 291 -> 375 tests.

- Pure: format helpers (bytes/offsets)
- Protocol: the full bridge postMessage contract — origin/source checks,
  model/error/progress, fetchJson round-trips, 30s timeout, unmount
  rejection — happy-dom satisfies event.source === iframe.contentWindow, so
  the checks are tested as-is; warm-iframe dedupe
- Components: action-list (chips/persistence, search incl. ancestors,
  collapse, keyboard, selection), detail-tabs (default-tab, counts,
  hasSource gating, scope toggle), call/errors/metadata (params, result,
  copy-prompt clipboard contract), console/network/attachments (ANSI strip,
  scope filtering, drawer sections + body preview, visibility/expansion),
  snapshot-pane (tab derivation, point params, URL bar, canvas toggle,
  popout, empty state), timeline (bars, thumbs via bridge, seek math,
  null render)
- e2e spec additionally asserts the Call tab + action searchbox

Worklog documents the coverage map, the happy-dom shims, and the honest
gaps (bridge.html inline script e2e-only, split-pane drag, no live
network-bearing fixture trace yet).

Verified: pnpm check 0 errors; node 375 passed; workers 1222 passed;
dashboard e2e replay spec 3/3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(trace-viewer): playback controls, collapse defaults, popover clamp, source highlighting

- Timeline gains the official viewer's playback cluster (prev/play-pause/stop/
  next/speed 0.5-2x): rAF clock, nearest-action selection, stop-at-end,
  restart-at-end semantics matched to the vendored v1.61.1 bundle. Playback,
  stepping, and click-seek walk filteredActions([]) so hidden noise-group
  actions are never selected into a row-less void.
- Action-list groups start collapsed unless their subtree holds a failing
  action; manual toggles are XOR overrides that survive chip toggles; external
  selection (seek/playback/deep link) auto-expands its ancestor chain.
- Timeline hover preview flips below the strip when the viewport lacks
  headroom (it always clipped inside the overflow-hidden replay dialog).
- Source tab: pure-lezer syntax highlighting (@lezer/javascript + classHighlighter),
  no @codemirror imports so the reverted dual-instance trap cannot recur;
  scoped .trace-source tok-* palette in styles.css, light+dark.

Tests: timeline 4->14, action-list 13->19, new source-tab suite (6).
Verified: pnpm check 0 errors; node 397 passed; workers 1222 passed;
e2e replay 3/3; 15/15 live-browser checks against a seeded failed run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(trace-viewer): bigger timeline hover preview, above the panes

PREVIEW_HEIGHT 140 -> 220 (clearance derived), and z-50 on the card: when
flipped below the strip it overlays the workbench panes, which come later
in DOM order and painted over the un-z-indexed card (tabs + snapshot iframe).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(trace-viewer): native Replay workbench + inline attachments, quality pass

Replaces the official Playwright trace-viewer iframe with Wrightful's own React
workbench over the vendored Playwright service-worker engine, adds inline
attachment previews (image/video lightbox), network filters, timeline hover
action captions, and an in-place attempt switch. Includes a structural
quality pass (timeline/playback split, shared detail-tab primitives), an
expanded seed suite with console/network traces, and broadened test coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(trace-viewer): use semantic type-ramp tokens instead of legacy numeric names

The type-ramp rename (joefairburn#51) that landed on main replaced numeric font-size
tokens (text-11…text-26) with semantic names; the trace-viewer components
still used the old ones, tripping the token-conventions guardrail test in CI.
Remap to text-micro/caption/body/body-lg/heading/title/display.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(trace-viewer): move playback controls into snapshot nav, drop paint-canvas toggle

Move the prev/play/stop/next/speed cluster out of the timeline strip into the
snapshot pane's Before/Action/After nav (right side, before the popout link).
The strip's moving Playhead and the control cluster are now siblings, so the
usePlayback controller is lifted up to the Workbench and shared with both; the
timeline strip spans full width. Keep the nav row height constant when an action
captured no snapshot via an invisible tab-height placeholder.

Remove the paint-<canvas>-from-screenshot toggle button as unhelpful, along with
its now-dead plumbing: the usePersistedFlag hook (deleted, no other consumer),
the CANVAS_FROM_SCREENSHOT_KEY localStorage key, and the
populateCanvasFromScreenshot option on snapshotIframeUrl.

Also consolidates the trace-viewer worklogs into a single entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(trace-viewer): span snapshot nav divider full width

The rule under the Before/Action/After row was the TabBar's own border-b, which
spans only the flex-1 TabBar and stopped where the playback/popout controls
begin. Move the divider onto the whole header row and drop the TabBar's rule so
a single 1px line spans full width; the active-tab underline still overhangs it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(trace-viewer): align action-list and snapshot-pane header dividers

The action-list filter header (h-8 input + py-1.5) was taller than the snapshot
pane's Before/Action/After nav (tabs), so the two panes' bottom dividers didn't
line up across the split. Give both header rows a shared h-9 (36px): the nav
keeps its bottom-aligned tabs, the filter header centers its input.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(trace-viewer): borderless filter input as the action-list header

Rework the "Filter actions" field into the borderless, full-width command-menu
style (matching ComboboxFilterPopup's search row): the input itself is the
pane's top edge (h-9 w-full bg-transparent, no box) and the wrapper carries the
hairline divider. The h-9 matches the snapshot pane's Before/Action/After nav so
both dividers align across the split, and the boxed input border is gone. Drops
SearchFilterInput from this pane; keeps type="search" for the searchbox role.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(trace-viewer): icon actions in Replay dialog header, drop official viewer

Replace the Replay dialog header's three text buttons with icon-only buttons
(tooltip on hover): a Share2 icon opening the public Playwright viewer
(trace.playwright.dev) and a Download icon. Remove the "Official viewer" link —
the public viewer serves the same purpose for the user. traceViewerUrl stays on
the wire contract as TraceViewerDialog's availability gate but is no longer
rendered as a link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(trace-viewer): use styled Tooltip for Replay dialog icon actions

Wrap the share (public viewer) and download icon buttons in the app's Base UI
Tooltip component (ui/tooltip) instead of the native title attribute, matching
the rest of the app's icon-button tooltips.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(trace-viewer): hover preview, row toggles, source/errors/timeline polish

- Action rows with children toggle their accordion on click (chevron still
  toggles without selecting)
- Hovering an action row previews it in the snapshot canvas and Source tab
  (highlightedAction || selectedAction, upstream parity), reverting on unhover;
  other tabs/timeline/playback stay on the real selection
- Filter Playwright's synthetic project#<id> fixture-pool location out of the
  Source tab (tabs, default file pick, frame availability)
- Errors tab count rendered as the COSS destructive Badge
- Source target line highlighted with bg-running-soft + inset accent bar
- Timeline filmstrip no longer flashes on attempt swaps: slots keyed by index
  + useObjectUrl keepPrevious mode (revoke-after-replace)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(trace-viewer): activeAction in the tab contract; Call/Log follow hover

Promote the hover-coalesced action (hoveredAction ?? selectedAction) from an
ad-hoc SourceTab prop override into TraceTabProps itself, so each detail tab
declares which action it keys on. Call, Log and Source now follow hover
(upstream viewer parity); Errors/Console/Network/Attachments/Metadata stay on
the real selection so a hover sweep can't yank scoping filters or scroll.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(trace-viewer): timeline range selection — play a section, scope the action list

Drag on the timeline strip to select a time window (a plain click still
seeks; a press becomes a drag after 4px of travel). While a selection is
active, Play starts inside the window and pauses when the playhead reaches
its end; the action list shows only intersecting actions with a
"Timeline selection / Show all" bar to clear it; and stepping, seeks, and
hover captions all walk the selection-scoped playable set. The selection is
model-keyed workbench state, reset on attempt swap like selection/hover.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(trace-viewer): timeline click dismisses the range selection

A dragless click on the strip now seeks to that exact point and clears any
active selection window on release — matching the native viewer. Click-seeks
and hover captions resolve against a new seekActions prop (the full
default-visible set) rather than the selection-scoped playable set, so a
click outside the window lands on the true action at that time instead of
clamping to the window's nearest; the scoped set stays in place for the
playhead and stepping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(trace-viewer): timeline selection filters Console, Network, and Log

The drag-selected time window now scopes the time-windowed detail tabs, not
just the action list: Console rows (by event time) and Network requests (by
request start time) outside the window are hidden, the Console/Network
tab-label counts narrow to match, and the Log tab shows only the active
action's in-window entries. The selection travels on TraceTabProps (with a
shared timeInRange predicate in model.ts) and composes with the existing
crosshair scope-to-action toggle, which further narrows within the window.
Each tab gets a selection-specific empty message.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(trace-viewer): thermo-nuclear review fixes — policy blocker, code-judo dedup, convention

BLOCKER: re-consolidate artifact-serving into serveArtifactBytes and move the
presign TTL cap into it — the download route had re-inlined the fork and the two
copies diverged on the cap (an 8h trace token could mint an 8h anonymous-read R2
URL via the orphaned helper the policy test still exercised).

Code-judo: extract useModelScopedState (4 dup sites), a shared BridgeBodyPreview
+ mime.ts classifiers (network/attachments), rebuild useSnapshotInfo on
useBridgeFetch + un-fork useObjectUrl into useObjectUrl/useBufferedObjectUrl, a
timeScale value object for the timeline geometry, shared console/network window
selectors, a basename helper, isWithinSelectedAction->timeInRange, and
actionsCount->atStart/atEnd on the playback controller.

Convention/boundary: Field/Section now cn()-merge (delete MetaField + the
className="" reset; add a Field variant + formatWallClock); restore react-query
in ReplayModalHost; drop the never-read traceViewerUrl from the replay attempts
contract; add warm.ts releaseWarmedTrace(); fix snapshot-pane location.replace
doc-rot; add aria-labels the tooltip pass dropped.

Verify: pnpm check 0 errors; full node (512) + workers (1314) lanes green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(mcp): hand out our self-hosted trace viewer, not trace.playwright.dev

MCP's get_artifact was returning a trace.playwright.dev viewer link, shipping
trace bytes to a third party — the exact leak the vendored same-origin viewer
exists to avoid. Unify the two divergent helpers (signedTraceViewerUrl, rail;
traceViewerUrlFor, MCP/public) into one selfHostedTraceViewerUrl(absoluteUrl)
and point MCP at it, so an agent's viewer link keeps the trace on this
dashboard. The native React viewer can't back an MCP link (tenant-scoped,
auth-gated dialog); the self-hosted SPA works cold from a signed URL.

Verify: pnpm check 0 errors; artifact-tokens/test-artifact-actions/mcp-server
suites green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(trace-viewer): decompose network-tab + snapshot-pane

Pure, behavior-preserving splits. network-tab.tsx 737 -> 339: pure column/sort/
classification helpers to network-columns.ts (now unit-testable, no React) and
the request detail panel subtree to network-detail-panel.tsx. snapshot-pane.tsx
456 -> 249: the scale + double-buffer iframe stage to snapshot-stage.tsx.

Verify: pnpm check 0 errors; network/detail-tabs/snapshot suites green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(trace-viewer): extract useActionTreeCollapse + split playback-controls

Pure, behavior-preserving. action-list.tsx 489 -> 351: the collapse/override/
auto-reveal state machine + the visible-row walk lift into use-action-tree-
collapse.ts (one scope, one dependency set — the walk and the collapse check can
no longer drift). playback-controls.tsx 426 -> 119: the engine + time-search
primitives move to use-playback.ts and the rAF line to playhead.tsx, so the
"controls" file holds only the button cluster.

Verify: pnpm check 0 errors; action-list/timeline/snapshot/shell suites green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(trace-viewer): consolidate empty states + action-list micro-cleanups

ScopedEmpty now owns the scoped-vs-range message choice the Console/Network call
sites duplicated, and a shared TabEmpty replaces the hand-rolled <Empty> blocks
in errors/attachments. In action-list, the ActionRow chevron renders once
(wrapped interactively or not) instead of duplicating the icon across a 3-way
branch, and localStorage group persistence folds into one usePersistentGroupSet
hook so its read and write can't drift.

Verify: pnpm check 0 errors; console/network/errors/attachments/action-list green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(trace-viewer): lift timeline pointer machine into useTimelineSeek

The strip's click-seek + drag-to-select-range state machine + hover cursor
(pointer handlers, the drag threshold/latch in a ref, pointer-capture
bookkeeping) move into a useTimelineSeek hook, so the Timeline render body reads
measure -> scale -> seek-machine -> derive -> compose instead of interleaving
imperative pointer math with the overlay JSX. The draggingRef/hover ref-vs-state
split is preserved (a drag must not re-render per move). Worklog updated to
record the full decomposition + MCP follow-ups.

Verify: pnpm check 0 errors; trace-viewer node (196) + workers (1314) lanes green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(e2e): assert the shipped replay contract, not the deleted traceViewerUrl

The replay spec still asserted attempts[].traceViewerUrl — removed from the
route's contract in 81eff2f — so the suite was guaranteed red (typeof null).
Assert downloadHref against signedDownloadHref's real shape instead, keep the
third-party-leak guard, and rewrite the stale comments describing the deleted
field. Statically verified against the route (no Postgres in this sandbox);
run the dashboard e2e suite before merge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(dashboard): drop the write-only traceViewerUrl channel; scope dialog attempt state

The UI artifact contract minted a signed self-hosted viewer URL per trace row
that was only ever consumed as a truthiness gate equivalent to
type === "trace" — gate on the type, keep selfHostedTraceViewerUrl for its one
real consumer (MCP). That orphaned the origin argument end-to-end:
signArtifactRows and loadAttemptArtifactGroups lose the param, the test-detail
loader loses its resolvePublicOrigin computation, and resolvePublicOrigin
itself (zero production callers, kept alive only by its own tests) is deleted.

Also: key TestReplayContent by the replay id — with staleTime:Infinity a
cached deep-link swap carried one test's attempt selection into another test
(regression test pre-seeds the query cache for two ids); and repoint
artifact-tokens' two TTL docstrings at serveArtifactBytes, the single presign-
cap home since the round-1 blocker fix (they still described the pre-fix
world — the exact stale-pointer failure mode behind the original regression).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(trace-viewer): thermo-nuclear round 2 — semantic fixes, contract judo, ui-wrapper adoption

Semantic fixes (approved behavior changes):
- defaultSelectedActionId delegates to vendor failedAction() — the INNERMOST
  failed action for nested test.step failures, restoring official-viewer
  parity (was find-first, selecting the outer step wrapper)
- the 30s bridge load timeout is a true silence watchdog — re-arms on every
  message from the loading iframe instead of killing actively-progressing
  loads with a misleading "SW may be blocked" error
- source-tab's fetch key encodes the trace URL (the useBridgeFetch key
  invariant — key must encode every loader input — is now documented), fixing
  stale text served across attempt swaps with recurring file paths
- the register-only warm iframe removes itself on the bridge's warm ack
  (10s fallback) instead of leaking one hidden iframe per session

Contract simplifications (behavior-preserving):
- TraceBridge carries traceUrl; the redundant {bridge, traceUrl} pair is gone
  from TraceTabProps and ~10 leaf signatures; TraceTabProps moved from
  model.ts to its owner detail-tabs.tsx; pure modules name vendor TraceModel
- detail-tabs: one TabConfig registry replaces the triple tab enumeration;
  LogTab extracted to log-tab.tsx sharing the offset grid with Console;
  props are Omit<TraceTabProps,"scopeToSelected">; honest ConsoleRow type;
  dead exports and always-passed-optional props dropped
- playback: PlaybackControls takes the controller (was 9 unpacked props);
  the playable set lives on the controller; usePlayback takes selectedAction
  and model — playing is model-scoped state, deleting the workbench's
  effect-based pause-on-swap and its one-frame stale-playing gap;
  TimelineAction is a real Pick, not an identity alias
- timeline: HoverOverlay receives a narrowed non-null hover (the ??-fallback
  papering is gone); TimeScale gains percentAt({clamp})/spanPercent and all
  hand-rolled fraction*100 sites route through it
- attachments: shared PreviewPre/useSha1PreviewText re-unify the re-drifted
  text preview with body-preview; attachmentDataUrl/useAttachmentMediaUrl
  collapse the triplicated base64 fork; isVideoMime closes the mime hole
- shortUrl rebuilt on lib/basename (with a trailing-slash parity fix over
  the naive swap); BRIDGE_PATH derives from TRACE_VIEWER_SCOPE; AnsiPre
  gains an inline variant making its single-owner claim true; the sync
  script's half-automated --pr mode is deleted; use-trace-model's duplicated
  reject-all-pending loop hoisted

UI conventions: PlaybackButton + snapshot popout on ui/button (render-prop
anchor arm), both progressbars on one TraceProgress over ui/progress, network
sort headers mapped from a column table, filter input consumes the new
FILTER_INPUT_CLASSES export instead of a byte-copied string.

Tests: cast-free makeResource/makeConsoleEvent/makePageErrorEvent fixture
builders restore the compile-time link to vendor/har.ts; renderDetailTabs
replaces 13 hand-written prop blocks; new direct characterization suites for
network-columns (31), mime (22), and the extracted source-highlight.ts (24,
pinning the tokenizer's line-count invariant); brittle class-string
assertions retargeted at data attributes.

See docs/worklog/2026-07-15-trace-viewer-thermo-nuclear-round-2.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(worklog): trace viewer thermo-nuclear review round 2

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(trace-viewer): address maintainability review

* test(e2e): expect self-hosted trace viewer

* refactor(trace-viewer): streamline replay artifacts

* fix: enforce trace replay artifact policy

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…rburn#55)

void 0.10.4 does not apply void.json routing.rewrites in the deployed
Cloudflare worker, so MCP OAuth discovery 404'd in production: the root
/.well-known/oauth-* paths reached the Hono router unrewritten and fell
through to the not-found page, while their /api/auth/.well-known/* targets
returned 200. vp dev simulates the edge rewrite so the e2e OAuth dance
passed while prod discovery was dead.

Add middleware/00.oauth-discovery.ts to perform the four-path rewrite
in-worker via c.rewrite() (the mechanism 00.errors.ts already uses),
covered by a workers-lane test that exercises the actual worker. Keep the
void.json rewrites as a redundant edge path.
* Harden dashboard Playwright suite against flakes

* Address Playwright review feedback

* Run E2E against the production preview

* Allow direct R2 artifact sources in CSP

* Isolate global monitor scheduler E2E

* Address CodeRabbit review feedback

- Guard the stale-lease reclaim against deleting a lock another worker
  just rewrote: re-read and only rm when the owner token is unchanged.
- Wait for hydration after the monitor-create redirect so chained
  detail-page clicks can't be swallowed mid-hydration.
- Retry + time-bound the production migration request in the boot
  fixture, mirroring the signup bounded-retry pattern.
- Give both groups form-submit assertions the cohort's explicit 15s
  timeout (the flagged duplicate-name alert plus its create() sibling).

* Address post-push review feedback (Codex + CodeRabbit)

- Reclaim stale scheduler locks atomically: compare-then-rm still let two
  waiters both pass the compare before either deleted, so the loser could
  remove a lease written in between. rename() gives exactly one contender
  custody of the lock file; an accidentally captured fresh lease is
  restored via non-clobbering link().
- Scope monitor URL assertions to the active tenant: submitCreate() now
  matches a detail URL built from this.listPath, and openEdit() pins its
  ?edit=1 check to the current detail pathname.
* feat(mcp): add flaky-test diagnosis and test-history tools

diagnose_flaky_tests returns explicit counters, normalized error-signature
groups, representative result ids, same-run co-failures, and latest-run
health on top of the shared rankFlakyTests ordering. get_test_history
resolves exactly one selector (test_id / file / query) through the tests
catalog into a commit-to-attempt timeline. Reads are bounded on every axis
(rows per test, co-failure runs and rows, 2 KB error heads); error
fingerprinting lives in the pure src/lib/error-signature.ts leaf.

* fix(mcp): cap signature groups per test; update e2e tool list

Codex review: a test whose 500 window failures normalize to hundreds of
distinct signatures could blow the diagnosis payload to multi-MB; return
the top 10 by count with distinctSignatures reporting the uncapped total.
Also add the two new tools to the e2e MCP tool-list assertion (CI fail).

* fix(mcp): trim history selector value; expose analyzedRows sampling bound

CodeRabbit review: a padded test_id/file passed validation but was
forwarded untrimmed, exact-matching nothing; and signature/co-failure
breakdowns are computed from at most 500 window rows per test while the
counters cover the full window — analyzedRows makes that visible.

* fix(mcp): full-window representatives, per-test co-failure budget, truncation flags

Codex P2s: representatives (latest flaky/hard-fail/passed ids + lastFlakyAt)
now come from a dedicated latest-per-(test,status) distinct-on read so a
high-volume test's newest flaky occurrence cannot fall out of the 500-row
sample while retryPasses stays positive; the 200-run co-failure budget is
allocated per test (newest first) with coFailureRunsAnalyzed disclosed; and
get_test_history reports matchedTestsTruncated when a file/query selector
matches more than 50 catalog tests. The file selector deliberately matches
the current cataloged path (documented in the arg description) — resolving
historical paths would need unindexed fact-table scans; query covers fuzzy.

* fix(mcp): aggregate window-row budget, per-run co-failure budget, blank-message stack fallback

Codex round 2: a max-size diagnosis (50 tests x 500 rows x 2KB heads) could
hold ~50M chars of error text in Worker memory — the 5,000-row window budget
is now split across the selected tests (full 500/test at the default limit);
the co-failure row budget is split per run so one mass-failure run cannot
starve the other budgeted runs out of failuresByRun; and errorHead treats a
blank/whitespace errorMessage as absent (nullif(trim(...), '')) so a useful
stack is not shadowed.

* fix(mcp): exclude queued prefills from the diagnosis window sample

Codex: queued placeholder rows matched the ne(skipped) predicate, inflating
analyzedRows past samples and spending the per-test row budget on rows that
carry no error; the window now selects exactly the counter statuses
(passed/flaky/failed/timedout), matching the representatives read.
…rn#60)

* feat(dashboard): sticky GitHub PR comment summarizing runs

Upsert a single App-posted PR comment per (project, repo, prNumber) on run
completion, alongside the existing check run: summary table, failures split
new-vs-known against the branch's previous terminal run, flaky detections,
and deep links into the dashboard. New githubPrComments table carries the
comment id plus the check-run-style claim CAS (no duplicate comments under
concurrent completions) and a ULID stale-run guard (a late watchdog finalize
can't overwrite a newer run's summary). Wired into completeRun,
completeShardedRun, and finalizeStaleRun as a best-effort step; requires the
App's Pull requests: Read & write permission (docs updated).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(dashboard): dedupe GitHub check-run and PR-comment surfaces

Extract shared run-context resolution (github-run-context.ts), shared
rendering (github-run-render.ts), claim-before-POST mechanics
(github-surface-post.ts), and a single postGithubRunSurfaces entry point
(github-run-surfaces.ts) so ingest finalize paths resolve context and
mint the installation token once for both surfaces.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dashboard): serialize sticky PR-comment writes across runs

Review pass on PR joefairburn#60:

- Codex P1: the sticky comment is one GitHub resource shared by every run
  on a PR, but only the first POST was claim-protected. Concurrent
  completions of two runs could PATCH concurrently (older body landing
  last at GitHub despite the DB runId CAS), and a newer run losing the
  first-comment POST claim returned without retrying. All comment writes
  now serialize through postWithWriteMutex: claim -> re-read -> write ->
  persist {commentId, runId} -> release, with a bounded wait-and-retry
  for claim losers and the persisted runId doubling as the monotonic
  skip guard. The check-run surface keeps postWithClaimedSlot (per-run
  resource; concurrent posters render identical content).
- CodeRabbit: release a held claim when GitHub's 2xx response carries no
  id, instead of leaking it for the 120s TTL (both flows).
- CodeRabbit: formatDuration rounds to the displayed tenth before the
  sub-minute comparison, so 59.96s renders "1m 0s" instead of "60.0s".
- CodeRabbit nitpicks: sub-minute rounding-boundary test; @Schema alias
  imports in the claim tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dashboard): scope PR-comment diff baseline to the PR

Codex P2 on PR joefairburn#60: branch names are not unique across PRs (two fork
PRs can both report head ref "fix" while run.repo stays the target
repository), so the PR comment's branch-only resolveBaseRun could adopt
an unrelated PR's terminal run as its baseline and mislabel failures as
known-vs-new. resolveBaseRun grows an optional pr: { repo, prNumber }
predicate; the PR-comment path passes it, the diff page keeps the
branch-wide default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dashboard): recover the sticky comment after a lost initial POST

Codex P2 on PR joefairburn#60: if GitHub accepts the initial comment POST but the
2xx response is lost in transit, the comment exists with no persisted
id, and the next completed run's fresh POST duplicates the marker
comment. Before any fresh POST the surface now scans one page of the
PR's issue comments for the project-scoped marker and PATCHes a hit
instead of creating a new comment (mirroring the reporter's
findExistingComment, but failing closed on a broken listing — the
claim releases and the next run retries).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dashboard): sanitize untrusted text in PR-comment code spans

Codex P2 on PR joefairburn#60: test filenames (and commit shas) were interpolated
into the sticky comment's Markdown code spans raw. Git permits
backticks and newlines in path components, so a hostile filename could
close the span and inject arbitrary Markdown - including @-mentions -
into the App-posted comment. mdCodeSpanText strips backticks (backslash
escapes don't work inside code spans) and collapses whitespace runs;
shas sanitize before the 7-char slice. Titles were already escaped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…tallations (joefairburn#64)

The setup callback proved a user administers a GitHub App installation
solely via `GET /user/installations` with the user's sign-in OAuth token.
A minimal-scope (`user:email`) sign-in token can be refused by that
endpoint, so linking any *personal* installation failed with "Could not
verify your access to this GitHub installation" — the confused-deputy (H1)
check erred out for every user, not just one.

Add a personal-account fast path in the setup callback: resolve the
installation's owner via the App JWT (trusted, server-side) and, for a
`User`-type install, authorize when it matches the signed-in user's own
login from `GET /user` (which any valid user token can call regardless of
scope). Org installs still defer to `/user/installations`. An attacker can
only ever fast-path an install on THEIR OWN account, so the H1 boundary
holds.

Also surface the previously-swallowed GitHub HTTP status on the
verification failure paths via `logger.warn`, so org-install failures can
be diagnosed (401 token vs 403 scope).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…airburn#62)

Pure move: git mv the fourteen run-*/runs-* components in
apps/dashboard/src/components/ into src/components/run/ (dropping the
redundant filename prefix, following the existing analytics/ / monitors/ /
settings/ / ui/ convention) and rewrite the @/components/run-<name> and
@/components/runs-filter-bar specifiers to @/components/run/<name>. No logic
changes.

running-spinner.tsx is deliberately excluded: it only shares the "run"
prefix and is a generic primitive unrelated to this component family. No
index.ts barrel was added, matching the sibling subfolders. Doc-comment
references to the old filenames (in meta-pills.tsx, use-run-summary.ts,
status-registry.workers.test.ts, the runs page, and a Playwright spec) were
updated to match; docs/worklog/ and the historical HTML review under
docs/reviews/ were left untouched as point-in-time records.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Harden platform boundaries and background workflows

* docs: clarify bootstrap and record verification

* Address PR review findings

* Address second-round PR review threads

- ingest: reset stale shard metadata on a terminal run's duplicate open —
  an unsharded re-run clears expectedShards/shardExpectedTests and stale
  runShards rows; a changed-total re-run restarts the expected-tests map
  and replaces the total (mid-flight opens keep coalesce semantics)
- trace viewer: hold the snapshot iframe off the first origin-less render
  so separate-origin snapshots load with the right sandbox from the start
- docs: clarify pnpm deploy:void wraps void deploy; distinguish the PR's
  index-only migration from "no logical schema changes" in the 07-18 worklog

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address third-round PR review threads

A terminal sharded re-run that kept the SAME shard total retained the
previous run's runShards completion rows, so its first new shard
/complete saw a full count and finalized against dead sibling results.
applyShardExpectedTests now latches the reset on the terminal status
alone: under a FOR UPDATE lock on the run row it drops every previous
completion row, restarts the expected-tests map, and re-arms the run
(status='running', completedAt=null) — the status flip makes the reset
exactly-once under racing sibling opens.

Also documents the deliberate ORDER BY random() trade-off in billing
reconcile (bounded by paying-customer cardinality; keyset cursor is the
evolution path) and qualifies the round-2 worklog's no-migration note.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address fourth-round PR review threads

- artifacts: allow the configured separate trace-viewer origin in the
  download route's CORS allow-list so bridge.html on the cookieless host
  can fetch signed trace URLs; document the matching R2 bucket CORS step
- ingest: enforce WRIGHTFUL_MAX_TEST_RESULTS_PER_RUN at open — a fresh
  run whose planned-test set exceeds the ceiling is refused (413) before
  the prefill persists any rows
- docs: record the Vite+ commands behind the pnpm validation scripts in
  the round-3 worklog

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: consolidate the architecture-review worklogs into one file

Replaces the six per-workstream 2026-07-16 worklogs, the integration
verification pass, and the four PR-review round logs with a single
subsystem-organized record of the final state, kept decisions, and
deferred items.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Keep scripts disabled on dashboard-hosted trace snapshots

The strict script-src 'none' snapshot CSP was gated on
!isSeparateTraceViewerOrigin(pageOrigin), which is true exactly on the
dashboard origin once a separate viewer origin is configured — so the
session origin served snapshot HTML under the relaxed scripting CSP.
New isTraceViewerHost asks the server-side question directly: the strict
policy now applies on every origin except the configured cookieless
viewer host itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(dashboard): sort test catalog and simplify pagination

* refactor(dashboard): co-locate catalog sort SQL contract

The tests-catalog sort spread each column's SQL knowledge across three
ternaries in tests.server.ts plus a separate switch in the sort lib, coupled
by an unenforced cross-file alias contract (a projected alias like "n" produced
in one file, consumed by ORDER BY in the other). The lib also imported `sql`
from void/db, which the client page bundles transitively -> browser build
failed with MISSING_EXPORT.

Collapse all of it into testsCatalogSortSql(), returning each key's
{ projection, join, group, orderBy } as pure strings from the closed
TestsSortKey/Direction unions. Alias and ORDER BY now sit adjacent and can't
drift; the lib no longer imports void/db, fixing the client build. Server
splices the constant fragments via sql.raw (URL value is parsed, never
interpolated). Also pin the testId pagination tiebreaker to asc uniformly (the
test column used to flip it with direction) and document why the new
SortableHead stays separate from the trace-viewer's.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
joefairburn#70)

Switching filters quickly on list pages (tests catalog and the other
analytics pages) could start a second router.visit() before the first
committed, aborting the pending navigation and rejecting its still-loading
deferred props — surfacing as an uncaught "Navigation disposed before
deferred props resolved" error.

Add a `useIsNavigating()` hook (backed by @void/react's `useNavigation`)
and a `NavBusyGuard` wrapper, then make the header filters inert/disabled
while a visit is in flight: the range/group segmented groups, the branch
combobox, the sort column headers, and the tag chips.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…fairburn#69)

* chore(security): enable security-guidance plugin with repo rules

Enable the security-guidance@claude-plugins-official plugin in checked-in
settings and add .claude/claude-security-guidance.md describing this repo's
tenancy, auth, artifact, and secret-handling threat model for the plugin's
model-backed reviews.

* docs(security): exempt unique-ID lookups from two-column run scoping

Clarify that projectId-alone scoping is correct for globally-unique ULID
lookups (runByIdWhere, childByIdWhere); only list/aggregate runs queries AND
teamId. Prevents the reviewer plugin from flagging the blessed predicates.

* docs(security): carve out trusted crons and middleware-authed routes

Address two P2 false-positive risks: (1) fleet-wide maintenance scans
(staleRunFilter sweep, dueMonitorsWhere scheduler) legitimately SELECT across
projects then scope per-row, and (2) Bearer ingest/query handlers rely on
02.api-auth.ts centrally, so they carry no handler-local auth check. Tell the
reviewer to verify the cron/queue context and the middleware route matcher
rather than flag these as leaks.
…ers (joefairburn#63)

Pure move: git mv the run-*, db-run, db-batch, artifact-tokens,
trace-artifacts, artifacts, use-search-param, and use-copied-flag modules
in apps/dashboard/src/lib/ into runs/, db/, artifacts/, and hooks/
folders, and rewrite every @/lib/<old> specifier (plus the handful of
relative imports and doc-comment references) to match. No logic changes.

- run-columns.ts, run-diff.ts, run-outcome.ts, run-read-model.ts,
  run-groups-page.ts, run-results-page.ts, runs-filters.ts,
  runs-filters-where.ts, db-run.ts -> runs/{columns,diff,outcome,
  read-model,groups-page,results-page,filters,filters-where,db}.ts
- db-batch.ts -> db/batch.ts
- artifact-tokens.ts, trace-artifacts.ts, artifacts.ts ->
  artifacts/{tokens,trace,store}.ts
- use-search-param.ts, use-copied-flag.ts -> hooks/use-{search-param,
  copied-flag}.ts

Deliberately no index.ts barrel files in runs/, db/, artifacts/, or
hooks/, matching the repo's existing no-barrel convention.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Add GitHub installation repository controls

* Address GitHub settings review feedback
astana is a downstream fork that independently re-ported features that
also landed (more completely) upstream in wrightful/main, so nearly every
conflict was "astana re-port vs. wrightful original". Resolution policy:

- App/feature code, tests, docs, migrations, dashboard package.json,
  void.json, CI: took wrightful/main (upstream is canonical + did the
  folder-grouping refactors joefairburn#62/joefairburn#63; mixing sides would break imports).
  astana added zero unique migrations and no unique app logic.
- Root package.json: kept astana's side (pnpm@11.10.0, patchedDependencies
  relocated to pnpm-workspace.yaml).
- Removed astana's flat-path orphans superseded by wrightful's reorg
  (components/run/*, lib/{runs,artifacts,db}/*, pg-integration/*) plus the
  dead e2e dev-trigger.ts helper.
- Resolved the PULL_REQUEST_TEMPLATE.md case-collision to the upstream
  uppercase name.
- astana's one genuinely-unique behavior (WS reconnect guard in
  use-feed-room.ts) merged cleanly and is preserved.

Verified: pnpm check (0 errors), pnpm test (2390 passing), pnpm build
(dashboard + reporter) all green.
…sweep (joefairburn#71)

* chore(deps): pnpm 11 + Vite+/void toolchain bump and dependency sweep

Upgrade the package manager (pnpm 10.33 -> 11.16) and core toolchain
(vite-plus/void/vitest), reconcile the local patch set against fixes that
shipped in void 0.10.10, and sweep every remaining outdated direct dependency
(including majors) to latest. Notable knock-on changes: better-auth aligned to
1.6.24 to dedupe with void; lucide-react v1 icon renames + a shared GithubIcon
component (v1 dropped brand icons), also adopted in login.tsx; react-email v6
consolidation onto the top-level package; and GitHub Actions major bumps.

Also send an Origin header on the MCP OAuth consent POSTs in the e2e suite:
better-auth 1.6.24 now rejects consent POSTs missing an Origin
(MISSING_OR_NULL_ORIGIN), which a real browser button already sends.

Verified: pnpm check (0 errors), pnpm test (dashboard/workers/reporter), and
the full pnpm test:e2e harness (29/29) all pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci: disable checkout credential persistence in CI jobs

These jobs only install/test/build and never push, so the checkout token
doesn't need to persist. Addresses the CodeRabbit/zizmor nitpick. release.yml
is left untouched because its Changesets step needs write credentials.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…joefairburn#71)

Pulls upstream joefairburn#71: pnpm 10.33 -> 11.16, void 0.10.4 -> 0.10.10,
@cloudflare/vite-plugin 1.38 -> 1.46 (old @void/react patch dropped),
vite-plus/vitest catalog bump, better-auth 1.6.24, lucide-react v1
(icon renames + shared GithubIcon), react-email v6, and GH Actions bumps.

Conflict resolution:
- package.json: took upstream packageManager (pnpm@11.16.0); astana's
  earlier pnpm@11.10.0 is superseded. astana's local `deploy` script
  rename is preserved.
- pnpm-workspace.yaml: git left a duplicate `patchedDependencies` key
  (astana's stale void@0.10.4/1.38.0 block + upstream's 0.10.10/1.46.0).
  Removed the stale block; the referenced old patch files no longer exist.
- Lockfile reconciled via `pnpm install` under pnpm 11.16.

Verified: pnpm check (0 errors), pnpm test (2390 passing), pnpm build
(dashboard + reporter) all green.
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
bumper-e2e-dashboard 03ea0f9 Jul 23 2026, 03:48 PM

@bumper-joe-fairburn bumper-joe-fairburn changed the title Merge wrightful/main upstream into astana (trace viewer, MCP, pnpm 11 toolchain) Merge wrightful/main upstream (trace viewer, MCP, pnpm 11 toolchain) Jul 23, 2026
@bumper-joe-fairburn
bumper-joe-fairburn merged commit eccdb22 into main Jul 24, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants