From 4d8aa1dac947de13cba95921ec8bb4076f9fdf97 Mon Sep 17 00:00:00 2001 From: Shay Date: Fri, 12 Jun 2026 22:07:34 -0700 Subject: [PATCH] =?UTF-8?q?feat(workbench):=20the=20Replay=20Moment=20?= =?UTF-8?q?=E2=80=94=20turn-keyed=20hero=20+=20retire=20W-026=20artifact?= =?UTF-8?q?=20replay=20(Wave=20R3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First R3 (Theater) piece. Reworks the stale artifact-keyed ReplayRoute into the turn-keyed hero over the #716 sealed-replay backend, and fully retires the dead W-026 artifact-keyed machinery on both sides (the NOT_YET_MIRRORED comment anticipated this). The hero makes determinism *felt*: pick a journaled turn, CORE re-executes its prompt in a sealed fresh runtime, and the result renders as a hash verdict (≡ equivalent / ≠ diverged) + the original/replay DigestBadges + a leaf diff (critical weighted above informational, each with a ≠ glyph). The 'What this proves' card surfaces comparison_basis + origin_state and states the honest limit: a divergence means nondeterminism OR origin-state influence, never rendered on its own as a determinism failure. Retirement (verified zero serving uses): - Python schemas.py: removed ReplayComparison/ReplayDivergence/ReplayStatus/ ReplayDivergenceSeverity; scripts/dump-enums.py drops the two replay enums; both snapshots regenerated (deterministic: dump == committed) - TS: removed ReplayComparison/ReplayDivergence/ReplayDivergenceSeverity; added TurnReplayComparison/TurnReplayDivergence (+ basis/origin/severity unions); NOT_YET_MIRRORED now empty (every engine schema mirrored) - badges: removed ReplayStatusBadge + ReplayDivergenceSeverityBadge + meta/enums + their enumCoverage cases (hero renders severity inline) - api: fetchReplayComparison/useReplayComparison -> fetchTurnReplay/ useTurnReplay (/replay/) - deleted ArtifactList, ReplayComparisonPanel, ReplayDiffViewer, ReplayMetadataTable, old replay.test.tsx - App route /replay/:artifactId? -> /replay/:turnId?; conformance row turn-keyed (loading 'Loading turns...', empty 'No turns recorded yet.') Tests: ReplayRoute.test.tsx (equivalence hero + honesty card, diverged critical leaf, informational-only label, replace-mode select, j/k spine). Full vitest 358 green; workbench Python 34 green; both snapshots deterministic. Follow-up flagged (not in this PR to keep it focused): the artifact query hooks (useArtifacts/useArtifactDetail/fetchArtifacts*) are now orphaned but entangled with the still-live artifact EvidenceSubject kind — a separate cleanup once an Artifacts route or that subject's fate is decided. Brief: docs/handoff/wave-R3-briefs-2026-06-13.md (all four R3 pieces). --- docs/handoff/wave-R3-briefs-2026-06-13.md | 112 +++++ scripts/dump-enums.py | 2 - workbench-ui/enum-snapshot.json | 11 - workbench-ui/schema-snapshot.json | 13 - workbench-ui/src/api/client.ts | 6 +- workbench-ui/src/api/queries.ts | 17 +- workbench-ui/src/app/App.tsx | 2 +- workbench-ui/src/app/replay/ArtifactList.tsx | 135 ----- .../src/app/replay/ReplayComparisonPanel.tsx | 84 ---- .../src/app/replay/ReplayDiffViewer.tsx | 92 ---- .../src/app/replay/ReplayMetadataTable.tsx | 89 ---- .../src/app/replay/ReplayRoute.test.tsx | 220 ++++++++ workbench-ui/src/app/replay/ReplayRoute.tsx | 471 ++++++++++++------ workbench-ui/src/app/replay/replay.test.tsx | 414 --------------- .../src/app/routeConformance.test.tsx | 8 +- .../components/badges/enumCoverage.test.ts | 18 - .../src/design/components/badges/index.tsx | 14 - .../src/design/components/badges/mappings.ts | 15 - .../src/design/components/badges/types.ts | 13 - .../src/design/doctrine/schemaDrift.test.ts | 11 +- workbench-ui/src/types/api.ts | 23 +- workbench/schemas.py | 21 - 22 files changed, 678 insertions(+), 1113 deletions(-) create mode 100644 docs/handoff/wave-R3-briefs-2026-06-13.md delete mode 100644 workbench-ui/src/app/replay/ArtifactList.tsx delete mode 100644 workbench-ui/src/app/replay/ReplayComparisonPanel.tsx delete mode 100644 workbench-ui/src/app/replay/ReplayDiffViewer.tsx delete mode 100644 workbench-ui/src/app/replay/ReplayMetadataTable.tsx create mode 100644 workbench-ui/src/app/replay/ReplayRoute.test.tsx delete mode 100644 workbench-ui/src/app/replay/replay.test.tsx diff --git a/docs/handoff/wave-R3-briefs-2026-06-13.md b/docs/handoff/wave-R3-briefs-2026-06-13.md new file mode 100644 index 00000000..5d2beb7c --- /dev/null +++ b/docs/handoff/wave-R3-briefs-2026-06-13.md @@ -0,0 +1,112 @@ +# Wave R3 Briefs — Theater (the wow, honest) + +Date: 2026-06-13 +Plan: `docs/workbench/wave-R-mastery-revamp.md` § Wave R3. +Predecessor: Wave R2 complete (#714–#721; all six routes real). Replay +backend already merged (#716, `GET /replay/{turn_id}` → `TurnReplayComparison`). + +Four pieces. The Replay Moment is first (its backend exists and the old +artifact-keyed `ReplayRoute` is now stale against it). The others are +independent and can be authored after. + +## Standing constraints (all briefs) + +Same as Wave R2: worktree off fresh `origin/main`; green-local before push +(`pnpm build && pnpm test`); **STOP after checks green, Shay merges**; +token-only styling (hexScan); shrink-only `NOT_YET_MIRRORED`; conformance +rows (ADR-0162 §6); no new mutation endpoints; no invented data; no +force-directed / nondeterministic layout — ever. + +--- + +## Brief R3-Replay — The Replay Moment (first; reworks + retires) + +The hero flow. Turn-keyed sealed replay made *felt*: same prompt, same +genesis substrate → bit-identical envelope, rendered as hash≡hash with an +honest leaf diff. Consumes the merged #716 backend. + +**This PR also retires the dead W-026 artifact-keyed replay machinery** +(confirmed zero serving uses) on both sides — the `NOT_YET_MIRRORED` comment +already anticipates it. + +### Retirement surface (verified) + +- **Python** (`workbench/schemas.py`): delete `ReplayDivergenceSeverity`, + `ReplayStatus`, `ReplayDivergence`, `ReplayComparison` (the artifact-keyed + block). Keep the `TurnReplay*` block. Zero word-boundary uses elsewhere. +- **Snapshots**: regenerate both — `pnpm schema:snapshot` (drops + `ReplayComparison`/`ReplayDivergence`) and `pnpm enum:snapshot` (drops + `ReplayStatus`/`ReplayDivergenceSeverity`). +- **TS** (`types/api.ts`): remove `ReplayComparison`/`ReplayDivergence`/ + `ReplayDivergenceSeverity`; add `TurnReplayComparison`/`TurnReplayDivergence` + (+ the `TurnReplayDivergenceSeverity`/`TurnReplayBasis`/`TurnReplayOriginState` + unions). Remove both `TurnReplay*` entries from `NOT_YET_MIRRORED`. +- **Badges** (`design/components/badges/{types,mappings,index}.ts*`): remove + `ReplayStatusBadge` + `ReplayDivergenceSeverityBadge` + their meta/types; + drop their two cases from `enumCoverage.test.ts`. The hero renders the new + severity (`critical`/`informational`) inline with a `≠` glyph — no new + enum-tracked badge. +- **API** (`client.ts`/`queries.ts`): remove `fetchReplayComparison`/ + `useReplayComparison`; add `fetchTurnReplay(turnId)` → `/replay/` and + `useTurnReplay(turnId)`. +- **Route dir** (`app/replay/`): delete `ArtifactList`, `ReplayComparisonPanel`, + `ReplayDiffViewer`, `ReplayMetadataTable`, and the old `replay.test.tsx`. + +### New ReplayRoute (turn-keyed hero) + +- List journaled turns (reuse `useTraceTurns`) in the left pane (VirtualizedList + + useListNavigation + SearchInput), same as Trace. +- Select → navigate `/replay/` (replace) → `useTurnReplay(turnId)`. +- Hero: `original_trace_hash` vs `replay_trace_hash` rendered big; a clear + `≡ equivalent` (when `equivalent`) or `≠ diverged` verdict. **Honesty card** + states `comparison_basis` (`sealed_fresh_runtime_single_turn`) and + `origin_state` (`unrecorded`) — a divergence means nondeterminism OR + origin-state influence, never disambiguated; never render divergence as a + determinism-failure verdict. +- Leaf diff: each `divergence` as a row with `path`, original vs replay, and a + `≠` glyph; `critical` weighted above `informational`; informational + divergences (timestamp/cost/digest) explicitly labeled as expected. +- Publishes the `turn` subject for the inspector (identity → detail), like Trace. +- App route `path="replay/:turnId?"`; conformance row turn-keyed + (loading "Loading turns...", empty "No turns recorded yet. Use Chat to + create evidence." + `core chat`). +- Tests: hero equivalence (hash≡hash), a tampered-leaf divergence renders `≠` at + the right path, informational-only divergence still reads equivalent, the + honesty fields render, j/k spine, replace-mode URL selection. + +### Verification + +```bash +cd workbench-ui && pnpm build && pnpm test +# plus the Python lane the snapshots feed: +uv run python scripts/dump-schemas.py | diff - workbench-ui/schema-snapshot.json +uv run python scripts/dump-enums.py | diff - workbench-ui/enum-snapshot.json +``` + +--- + +## Brief R3-DAG — Deterministic DAG viewer + +One hand-rolled component (`design/components/Dag/`): longest-path layering, +lexicographic tie-break, ~150 lines, **golden-file layout tests**, no graph +dependency (force-directed = doctrine violation). Pan/zoom/click-node → +inspector. Consumers: proposal chains, the 8 PCCP proof-promotion scenarios +(`demos/proof_carrying_promotion`), entailment traces. Pure layout function; +golden tests pin node coordinates. + +--- + +## Brief R3-Demo — Demo Theater route + +`GET /demos`, `POST /demos/{id}/run` (new read + a scoped run endpoint). +Scenario results with evidence-class badges and "what this proves / what this +does not prove" honesty cards; proposer-was-wrong scenarios visually +highlighted. Backend brief first (Python), then the route. + +--- + +## Brief R3-Ledger — wrong=0 ledger view + +Evals renders the correct/refused/wrong triplet as the PRIMARY visualization; +refusal reasons inspectable; failures-first ordering. Additive to the existing +EvalsRoute — no retirement, lowest risk of the four. diff --git a/scripts/dump-enums.py b/scripts/dump-enums.py index f2e8fc74..f0dda58c 100644 --- a/scripts/dump-enums.py +++ b/scripts/dump-enums.py @@ -55,8 +55,6 @@ snapshot = { "GroundingSource": literal_values(ROOT / "core" / "epistemic_state.py", "GroundingSource"), "NormativeClearance": enum_values(ROOT / "core" / "epistemic_state.py", "NormativeClearance"), "ReviewState": literal_values(ROOT / "teaching" / "proposals.py", "ReviewState"), - "ReplayDivergenceSeverity": literal_values(ROOT / "workbench" / "schemas.py", "ReplayDivergenceSeverity"), - "ReplayStatus": literal_values(ROOT / "workbench" / "schemas.py", "ReplayStatus"), } print(json.dumps(snapshot, indent=2, sort_keys=True)) diff --git a/workbench-ui/enum-snapshot.json b/workbench-ui/enum-snapshot.json index 5c4906b2..7d2e04a9 100644 --- a/workbench-ui/enum-snapshot.json +++ b/workbench-ui/enum-snapshot.json @@ -30,17 +30,6 @@ "unassessable", "suppressed" ], - "ReplayDivergenceSeverity": [ - "info", - "warning", - "failure" - ], - "ReplayStatus": [ - "equivalent", - "not_yet_replayed", - "diverged", - "evidence_unavailable" - ], "ReviewState": [ "pending", "accepted", diff --git a/workbench-ui/schema-snapshot.json b/workbench-ui/schema-snapshot.json index b6f97ed1..3d5c7baa 100644 --- a/workbench-ui/schema-snapshot.json +++ b/workbench-ui/schema-snapshot.json @@ -130,19 +130,6 @@ "created_at", "downstream_effect" ], - "ReplayComparison": [ - "artifact_id", - "original_hash", - "replay_hash", - "equivalent", - "divergences" - ], - "ReplayDivergence": [ - "path", - "original", - "replay", - "severity" - ], "RunDetail": [ "turns", "manifest" diff --git a/workbench-ui/src/api/client.ts b/workbench-ui/src/api/client.ts index f6fa8058..ab8cf3fb 100644 --- a/workbench-ui/src/api/client.ts +++ b/workbench-ui/src/api/client.ts @@ -6,7 +6,7 @@ import type { EvalRunResult, ArtifactRef, ArtifactDetail, - ReplayComparison, + TurnReplayComparison, ProposalDetail, ProposalState, ProposalSummary, @@ -92,8 +92,8 @@ export async function fetchArtifactDetail(artifactId: string): Promise(`/artifacts/${artifactId}`); } -export async function fetchReplayComparison(artifactId: string): Promise { - return apiFetch(`/replay/${artifactId}`); +export async function fetchTurnReplay(turnId: number): Promise { + return apiFetch(`/replay/${encodeURIComponent(String(turnId))}`); } export type ProposalStateFilter = ProposalState | "all"; diff --git a/workbench-ui/src/api/queries.ts b/workbench-ui/src/api/queries.ts index 0fc9c0d1..91030091 100644 --- a/workbench-ui/src/api/queries.ts +++ b/workbench-ui/src/api/queries.ts @@ -10,7 +10,7 @@ import { runEvalLane, fetchArtifacts, fetchArtifactDetail, - fetchReplayComparison, + fetchTurnReplay, fetchProposalDetail, fetchProposals, fetchAuditEvents, @@ -49,7 +49,7 @@ import type { EvalRunResult, ChatTurnResult, EvalRunRequest, - ReplayComparison, + TurnReplayComparison, MathProposalSummary, MathProposalDetail, MathRatifyResult, @@ -99,11 +99,14 @@ export function useArtifactDetail(artifactId: string) { }); } -export function useReplayComparison(artifactId: string) { - return useQuery({ - queryKey: ["api", "replay", artifactId], - queryFn: () => fetchReplayComparison(artifactId), - enabled: !!artifactId, +export function useTurnReplay(turnId?: number | null) { + return useQuery({ + queryKey: ["api", "replay", "turn", turnId ?? null], + queryFn: () => fetchTurnReplay(turnId as number), + enabled: typeof turnId === "number", + retry: false, + staleTime: 30_000, + refetchOnWindowFocus: false, }); } diff --git a/workbench-ui/src/app/App.tsx b/workbench-ui/src/app/App.tsx index 7247e2f5..6a85bb04 100644 --- a/workbench-ui/src/app/App.tsx +++ b/workbench-ui/src/app/App.tsx @@ -24,7 +24,7 @@ export function App() { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/workbench-ui/src/app/replay/ArtifactList.tsx b/workbench-ui/src/app/replay/ArtifactList.tsx deleted file mode 100644 index e482aa1f..00000000 --- a/workbench-ui/src/app/replay/ArtifactList.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import type { ArtifactRef } from "../../types/api"; -import { cn } from "../../design/lib"; -import { EmptyState } from "../../design/components/states/EmptyState"; -import { useListNavigation } from "../../design/hooks/useListNavigation"; - -interface ArtifactListProps { - artifacts: ArtifactRef[]; - selectedId: string | null; - onSelect: (id: string) => void; -} - -const KINDS = [ - "trace", - "eval_result", - "proposal", - "contemplation_report", - "telemetry", - "engine_state_manifest", - "unknown", -] as const; - -type ArtifactKind = (typeof KINDS)[number]; - -export function ArtifactList({ artifacts, selectedId, onSelect }: ArtifactListProps) { - // Initialize grouped structure - const grouped: Record = { - trace: [], - eval_result: [], - proposal: [], - contemplation_report: [], - telemetry: [], - engine_state_manifest: [], - unknown: [], - }; - - artifacts.forEach((art) => { - const kind = KINDS.includes(art.kind as ArtifactKind) - ? (art.kind as ArtifactKind) - : "unknown"; - grouped[kind].push(art); - }); - - // Sort each group - KINDS.forEach((kind) => { - grouped[kind].sort((a, b) => { - if (a.created_at && b.created_at) { - return b.created_at.localeCompare(a.created_at); - } - if (a.created_at) return -1; - if (b.created_at) return 1; - return a.artifact_id.localeCompare(b.artifact_id); - }); - }); - - // Flat traversal order across groups for the keyboard spine (R0d). - const flatOrder = KINDS.flatMap((kind) => grouped[kind]); - const flatIndexById = new Map(flatOrder.map((a, i) => [a.artifact_id, i])); - const { listProps, itemProps } = useListNavigation({ - itemCount: flatOrder.length, - onActivate: (index) => { - const art = flatOrder[index]; - if (art) onSelect(art.artifact_id); - }, - }); - - if (artifacts.length === 0) { - return ( -
- -
- ); - } - - return ( - - ); -} diff --git a/workbench-ui/src/app/replay/ReplayComparisonPanel.tsx b/workbench-ui/src/app/replay/ReplayComparisonPanel.tsx deleted file mode 100644 index 05a3d7fe..00000000 --- a/workbench-ui/src/app/replay/ReplayComparisonPanel.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import type { ArtifactDetail, ReplayComparison } from "../../types/api"; -import { ReplayStatusBadge, TraceHashBadge, ReplayStatus } from "../../design/components/badges"; -import { EmptyState } from "../../design/components/states/EmptyState"; -import { ReplayDiffViewer } from "./ReplayDiffViewer"; -import { ReplayMetadataTable } from "./ReplayMetadataTable"; - -interface ReplayComparisonPanelProps { - artifact: ArtifactDetail; - comparison?: ReplayComparison | null; - status: ReplayStatus; -} - -export function ReplayComparisonPanel({ - artifact, - comparison, - status, -}: ReplayComparisonPanelProps) { - const finalComparison: ReplayComparison = comparison || { - artifact_id: artifact.artifact_id, - original_hash: artifact.digest, - replay_hash: null, - equivalent: false, - divergences: [], - }; - - return ( -
-
-

Replay Evidence

- -
- -
-
- - Original Hash - -
- {finalComparison.original_hash ? ( - - ) : ( - not_available - )} -
-
-
- - Replay Hash - -
- {finalComparison.replay_hash ? ( - - ) : ( - not_available - )} -
-
-
- -
- {status === "evidence_unavailable" ? ( - - ) : status === "equivalent" ? ( - - ) : status === "not_yet_replayed" ? ( - - ) : ( - - )} -
- - -
- ); -} diff --git a/workbench-ui/src/app/replay/ReplayDiffViewer.tsx b/workbench-ui/src/app/replay/ReplayDiffViewer.tsx deleted file mode 100644 index df0e2927..00000000 --- a/workbench-ui/src/app/replay/ReplayDiffViewer.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import type { ReplayDivergence } from "../../types/api"; -import { ReplayDivergenceSeverityBadge, ReplayDivergenceSeverity } from "../../design/components/badges"; -import { StableJsonViewer } from "../../design/components/StableJsonViewer"; -import { Copy } from "lucide-react"; -import { Button } from "../../design/components/primitives/Button"; - -interface ReplayDiffViewerProps { - divergences: ReplayDivergence[]; -} - -const severityOrder: Record<"info" | "warning" | "failure", number> = { - failure: 3, - warning: 2, - info: 1, -}; - -function serializeValue(val: unknown): string { - if (val === undefined) return ""; - try { - return JSON.stringify(val); - } catch { - return String(val); - } -} - -export function ReplayDiffViewer({ divergences }: ReplayDiffViewerProps) { - if (divergences.length === 0) { - return null; - } - - const sortedDivergences = [...divergences].sort((a, b) => { - const diff = severityOrder[b.severity] - severityOrder[a.severity]; - if (diff !== 0) return diff; - return a.path.localeCompare(b.path); - }); - - const copyPath = (path: string) => { - navigator.clipboard.writeText(path).catch(() => { - // fail silently - }); - }; - - return ( -
-

Replay Divergences

-
- {sortedDivergences.map((div) => { - const key = `${div.severity}-${div.path}`; - return ( -
-
-
- - - {div.path} - -
- -
- -
-
-
- Original Value -
- -
-
-
- Replay Value -
- -
-
-
- ); - })} -
-
- ); -} diff --git a/workbench-ui/src/app/replay/ReplayMetadataTable.tsx b/workbench-ui/src/app/replay/ReplayMetadataTable.tsx deleted file mode 100644 index 4a3b0093..00000000 --- a/workbench-ui/src/app/replay/ReplayMetadataTable.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import type { ArtifactDetail, ReplayComparison } from "../../types/api"; -import { TraceHashBadge } from "../../design/components/badges"; - -interface ReplayMetadataTableProps { - artifact: ArtifactDetail; - comparison: ReplayComparison; -} - -export function ReplayMetadataTable({ artifact, comparison }: ReplayMetadataTableProps) { - const divergenceCounts = comparison.divergences.reduce( - (acc, div) => { - acc[div.severity] = (acc[div.severity] || 0) + 1; - return acc; - }, - { info: 0, warning: 0, failure: 0 } as Record<"info" | "warning" | "failure", number> - ); - - return ( -
-

Metadata Audit Details

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
PropertyValue
Artifact ID{artifact.artifact_id}
Kind{artifact.kind}
Path - {artifact.path} -
Original Hash - {comparison.original_hash ? ( - - ) : ( - None - )} -
Replay Hash - {comparison.replay_hash ? ( - - ) : ( - None - )} -
Divergences - - - Failure: {divergenceCounts.failure} - - - Warning: {divergenceCounts.warning} - - - Info: {divergenceCounts.info} - - -
Content Type{artifact.content_type}
-
-
- ); -} diff --git a/workbench-ui/src/app/replay/ReplayRoute.test.tsx b/workbench-ui/src/app/replay/ReplayRoute.test.tsx new file mode 100644 index 00000000..9d96fd20 --- /dev/null +++ b/workbench-ui/src/app/replay/ReplayRoute.test.tsx @@ -0,0 +1,220 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { + MemoryRouter, + Route, + Routes, + useLocation, + useNavigationType, +} from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createTestQueryClient } from "../../test/createTestQueryClient"; +import type { TurnJournalSummary, TurnReplayComparison } from "../../types/api"; +import { EvidenceProvider } from "../evidenceContext"; +import { ReplayRoute } from "./ReplayRoute"; + +const summaries: TurnJournalSummary[] = [ + { + turn_id: 1, + timestamp: "2026-06-12T18:00:00Z", + prompt_excerpt: "What is truth?", + surface_excerpt: "Truth is coherent.", + trace_hash: "sha256:111111111111aaaa", + grounding_source: "pack", + }, + { + turn_id: 2, + timestamp: "2026-06-12T18:01:00Z", + prompt_excerpt: "What is beauty?", + surface_excerpt: "Beauty is form.", + trace_hash: "sha256:222222222222bbbb", + grounding_source: "teaching", + }, +]; + +function comparisonFor(turnId: number): TurnReplayComparison { + if (turnId === 1) { + // equivalent, only an informational (wall-clock) divergence + return { + turn_id: 1, + comparison_basis: "sealed_fresh_runtime_single_turn", + origin_state: "unrecorded", + original_trace_hash: "sha256:111111111111aaaa", + replay_trace_hash: "sha256:111111111111aaaa", + equivalent: true, + replay_turn_cost_ms: 412, + divergences: [ + { + path: "timestamp", + original: "2026-06-12T18:00:00Z", + replay: "2026-06-13T00:00:00Z", + severity: "informational", + }, + ], + }; + } + // diverged: a critical surface divergence + return { + turn_id: 2, + comparison_basis: "sealed_fresh_runtime_single_turn", + origin_state: "unrecorded", + original_trace_hash: "sha256:222222222222bbbb", + replay_trace_hash: "sha256:999999999999cccc", + equivalent: false, + replay_turn_cost_ms: 401, + divergences: [ + { + path: "surface", + original: "Beauty is form.", + replay: "Beauty is symmetry.", + severity: "critical", + }, + ], + }; +} + +function LocationProbe() { + const location = useLocation(); + const navigationType = useNavigationType(); + return ( + <> + {`${location.pathname}${location.search}`} + {navigationType} + + ); +} + +function renderRoute(initialEntry = "/replay") { + return render( + + + + + + + + + } + /> + + + + , + ); +} + +function stubReplayFetch(items: TurnJournalSummary[] = summaries) { + const fetchMock = vi.fn((input: unknown) => { + const path = new URL(String(input)).pathname; + if (path === "/trace/turns") { + return Promise.resolve({ + json: () => Promise.resolve({ ok: true, generated_at: "now", data: { items } }), + }); + } + const match = path.match(/^\/replay\/(\d+)$/); + if (match) { + return Promise.resolve({ + json: () => + Promise.resolve({ ok: true, generated_at: "now", data: comparisonFor(Number(match[1])) }), + }); + } + return Promise.resolve({ + json: () => + Promise.resolve({ + ok: false, + generated_at: "now", + error: { code: "not_found", message: `unexpected ${path}` }, + }), + }); + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +const offsetDescriptors = { + offsetHeight: Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight"), + offsetWidth: Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetWidth"), +}; + +describe("ReplayRoute", () => { + beforeEach(() => { + Object.defineProperty(HTMLElement.prototype, "offsetHeight", { + configurable: true, + get: () => 560, + }); + Object.defineProperty(HTMLElement.prototype, "offsetWidth", { + configurable: true, + get: () => 480, + }); + }); + + afterEach(() => { + if (offsetDescriptors.offsetHeight) { + Object.defineProperty(HTMLElement.prototype, "offsetHeight", offsetDescriptors.offsetHeight); + } + if (offsetDescriptors.offsetWidth) { + Object.defineProperty(HTMLElement.prototype, "offsetWidth", offsetDescriptors.offsetWidth); + } + vi.restoreAllMocks(); + localStorage.clear(); + }); + + it("lists journaled turns to replay", async () => { + stubReplayFetch(); + renderRoute(); + expect(await screen.findByText("What is truth?")).toBeInTheDocument(); + expect(screen.getByText("What is beauty?")).toBeInTheDocument(); + }); + + it("renders the equivalence hero with the honesty card for a matching replay", async () => { + stubReplayFetch(); + renderRoute("/replay/1"); + + expect(await screen.findByText(/Replay equivalent/)).toBeInTheDocument(); + // honesty fields are surfaced, not hidden + expect(screen.getByText("sealed_fresh_runtime_single_turn")).toBeInTheDocument(); + expect(screen.getByText("unrecorded")).toBeInTheDocument(); + // an equivalent replay with only wall-clock drift is labeled as such + expect(screen.getByText(/all informational/)).toBeInTheDocument(); + }); + + it("renders a diverged verdict with the critical leaf for a tampered replay", async () => { + stubReplayFetch(); + renderRoute("/replay/2"); + + expect(await screen.findByText("Replay diverged")).toBeInTheDocument(); + // the critical divergence is shown at its exact leaf path + expect(screen.getByText("surface")).toBeInTheDocument(); + expect(screen.getByText("Beauty is symmetry.")).toBeInTheDocument(); + expect(screen.getAllByText("critical").length).toBeGreaterThan(0); + }); + + it("selecting a turn writes /replay/ with replace", async () => { + stubReplayFetch(); + const user = userEvent.setup(); + renderRoute(); + + await user.click(await screen.findByText("What is beauty?")); + + await waitFor(() => expect(screen.getByTestId("location")).toHaveTextContent("/replay/2")); + expect(screen.getByTestId("nav-type")).toHaveTextContent("REPLACE"); + expect(await screen.findByText("Replay diverged")).toBeInTheDocument(); + }); + + it("moves the turn list focus with j/k through the VirtualizedList spine", async () => { + stubReplayFetch(); + const user = userEvent.setup(); + renderRoute(); + + const list = await screen.findByRole("listbox", { name: "Replayable turns" }); + list.focus(); + expect(screen.getAllByRole("option")[0]).toHaveAttribute("aria-selected", "true"); + + await user.keyboard("j"); + expect(screen.getAllByRole("option")[1]).toHaveAttribute("aria-selected", "true"); + }); +}); diff --git a/workbench-ui/src/app/replay/ReplayRoute.tsx b/workbench-ui/src/app/replay/ReplayRoute.tsx index f68c400c..aadbdaf7 100644 --- a/workbench-ui/src/app/replay/ReplayRoute.tsx +++ b/workbench-ui/src/app/replay/ReplayRoute.tsx @@ -1,174 +1,321 @@ -import { useEffect, useState } from "react"; -import { useNavigate, useParams, useSearchParams } from "react-router-dom"; -import { useEvidenceSubject } from "../evidenceContext"; -import { subjectToUrl } from "../evidenceAddress"; -import { useArtifacts, useArtifactDetail, useReplayComparison } from "../../api/queries"; -import { ArtifactList } from "./ArtifactList"; -import { ReplayComparisonPanel } from "./ReplayComparisonPanel"; -import { LoadingState } from "../../design/components/states/LoadingState"; -import { ErrorState } from "../../design/components/states/ErrorState"; -import { EmptyState } from "../../design/components/states/EmptyState"; -import { SearchInput } from "../../design/components/SearchInput/SearchInput"; +import { useEffect, useMemo, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; import { WorkbenchApiError } from "../../api/client"; -import { ReplayStatus } from "../../design/components/badges"; +import { useTraceTurns, useTurnReplay } from "../../api/queries"; +import { DigestBadge } from "../../design/components/DigestBadge/DigestBadge"; +import { Panel } from "../../design/components/Panel/Panel"; +import { SearchInput } from "../../design/components/SearchInput/SearchInput"; +import { SplitPane } from "../../design/components/SplitPane/SplitPane"; +import { Timestamp } from "../../design/components/Timestamp/Timestamp"; +import { VirtualizedList } from "../../design/components/VirtualizedList/VirtualizedList"; +import { EmptyState } from "../../design/components/states/EmptyState"; +import { ErrorState } from "../../design/components/states/ErrorState"; +import { LoadingState } from "../../design/components/states/LoadingState"; +import type { + TurnJournalSummary, + TurnReplayComparison, + TurnReplayDivergence, +} from "../../types/api"; +import { pushRecentItem } from "../commandRegistry"; +import { useEvidenceSubject } from "../evidenceContext"; -export function ReplayRoute() { - const { artifactId } = useParams(); - const navigate = useNavigate(); - const [searchParams] = useSearchParams(); - const { setSubject } = useEvidenceSubject(); - const selectedId = artifactId ?? null; - const [search, setSearch] = useState(""); +function parseTurnId(raw: string | undefined): number | null { + if (!raw || !/^\d+$/.test(raw)) return null; + const value = Number(raw); + return Number.isSafeInteger(value) ? value : null; +} - const artifactsQuery = useArtifacts(); - const detailQuery = useArtifactDetail(selectedId || ""); - const comparisonQuery = useReplayComparison(selectedId || ""); +function errorMessage(error: unknown) { + return error instanceof WorkbenchApiError ? error.message : "Replay request failed."; +} - // Publish the selected artifact as the evidence subject: identity - // immediately, detail once the query resolves. - const detailData = detailQuery.data; - useEffect(() => { - if (!selectedId) return; - setSubject({ kind: "artifact", artifactId: selectedId, data: detailData }); - }, [selectedId, detailData, setSubject]); +function digestPayload(value: string | null | undefined): string | null { + if (!value) return null; + return value.replace(/^sha256:/, ""); +} - function handleSelect(id: string) { - const search = searchParams.toString(); - const path = subjectToUrl({ kind: "artifact", artifactId: id }); - // Selection churn must not pollute history: replace, never push. - navigate(search ? `${path}?${search}` : path, { replace: true }); - } - - // Handle loading states - const isLoadingArtifacts = artifactsQuery.isPending; - const isLoadingDetail = selectedId ? detailQuery.isPending : false; - const isLoadingComparison = selectedId ? comparisonQuery.isPending : false; - - // Determine if comparison error is the unsupported (evidence_unavailable) case - const isUnsupportedError = - comparisonQuery.error instanceof WorkbenchApiError && - comparisonQuery.error.code === "unsupported"; - - // Check for genuine API errors - const artifactsError = artifactsQuery.error; - const detailError = detailQuery.error; - const comparisonError = comparisonQuery.error; - - const hasGenuineError = - artifactsQuery.isError || - detailQuery.isError || - (comparisonQuery.isError && !isUnsupportedError); - - const getGenuineErrorDetails = () => { - if (artifactsQuery.isError && artifactsError) { - return { - message: - artifactsError instanceof WorkbenchApiError - ? artifactsError.message - : "Failed to load artifacts.", - reproducer: "curl -X GET /artifacts", - }; - } - if (detailQuery.isError && detailError) { - return { - message: - detailError instanceof WorkbenchApiError - ? detailError.message - : "Failed to load artifact details.", - reproducer: `curl -X GET /artifacts/${selectedId || ""}`, - }; - } - if (comparisonQuery.isError && comparisonError && !isUnsupportedError) { - return { - message: - comparisonError instanceof WorkbenchApiError - ? comparisonError.message - : "Failed to load replay comparison.", - reproducer: `curl -X GET /replay/${selectedId || ""}`, - }; - } - return null; - }; - - const errorDetails = getGenuineErrorDetails(); +function stringify(value: unknown): string { + if (typeof value === "string") return value; + return JSON.stringify(value); +} +function TurnRow({ + turn, + selected, + focused, + onSelect, +}: { + turn: TurnJournalSummary; + selected: boolean; + focused: boolean; + onSelect: () => void; +}) { return (
- {/* Left Pane: Artifact list */} -
-
- -
- {isLoadingArtifacts ? ( - - ) : artifactsQuery.isError ? ( - - ) : ( - { - const q = search.trim().toLowerCase(); - if (!q) return true; - return ( - a.artifact_id.toLowerCase().includes(q) || - a.kind.toLowerCase().includes(q) - ); - })} - selectedId={selectedId} - onSelect={handleSelect} - /> - )} -
- - {/* Right Pane: Replay detail comparison */} -
- {hasGenuineError && errorDetails ? ( - - ) : isLoadingDetail || isLoadingComparison ? ( - - ) : !selectedId ? ( - - ) : detailQuery.data ? ( - - ) : null} -
+ + + + + + {turn.prompt_excerpt || `Turn #${turn.turn_id}`} + + + + #{turn.turn_id} + +
+ ); +} + +function HashPair({ comparison }: { comparison: TurnReplayComparison }) { + const original = digestPayload(comparison.original_trace_hash); + const replay = digestPayload(comparison.replay_trace_hash); + return ( +
+ original + {original ? : "not recorded"} + replay + {replay ? : "not recorded"} +
+ ); +} + +function Verdict({ equivalent }: { equivalent: boolean }) { + return ( +
+ + {equivalent ? "≡" : "≠"} + + + {equivalent ? "Replay equivalent — bit-identical envelope" : "Replay diverged"} + +
+ ); +} + +function DivergenceRow({ divergence }: { divergence: TurnReplayDivergence }) { + const critical = divergence.severity === "critical"; + return ( +
  • + + + ≠ + + {divergence.path} + + {divergence.severity} + + + + original + + {stringify(divergence.original)} + + replay + + {stringify(divergence.replay)} + + +
  • + ); +} + +function ReplayHero({ comparison }: { comparison: TurnReplayComparison }) { + // critical first, then informational; stable within a severity by path. + const ordered = useMemo(() => { + const weight = (s: string) => (s === "critical" ? 0 : 1); + return [...comparison.divergences].sort( + (a, b) => weight(a.severity) - weight(b.severity) || a.path.localeCompare(b.path), + ); + }, [comparison.divergences]); + const informationalOnly = + comparison.divergences.length > 0 && + comparison.divergences.every((d) => d.severity === "informational"); + + return ( + +
    + + + +
    +

    + What this proves +

    +

    + Basis: {comparison.comparison_basis}. The prompt was + re-executed in a sealed fresh runtime. Origin state is{" "} + {comparison.origin_state} — the journal does not + record whether the original turn loaded a checkpoint, so a divergence means + nondeterminism or origin-state influence; it is not, on its own, a determinism + failure. +

    +
    + + {comparison.divergences.length === 0 ? ( +

    + No divergences — every leaf of the envelope matched. +

    + ) : ( +
    +

    + Leaf divergences ({comparison.divergences.length}) + {informationalOnly ? " — all informational (wall-clock; expected)" : ""} +

    +
      + {ordered.map((divergence) => ( + + ))} +
    +
    + )} +
    +
    + ); +} + +export function ReplayRoute() { + const { turnId } = useParams(); + const selectedTurnId = parseTurnId(turnId); + const navigate = useNavigate(); + const { setSubject } = useEvidenceSubject(); + const [search, setSearch] = useState(""); + + const turnsQuery = useTraceTurns(); + const replayQuery = useTurnReplay(selectedTurnId); + + const turns = turnsQuery.data ?? []; + const filteredTurns = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return turns; + return turns.filter( + (turn) => + turn.prompt_excerpt.toLowerCase().includes(q) || String(turn.turn_id).includes(q), + ); + }, [search, turns]); + + // Publish the turn subject for the inspector. + useEffect(() => { + if (selectedTurnId === null) return; + setSubject({ kind: "turn", turnId: selectedTurnId }); + }, [selectedTurnId, setSubject]); + + function selectTurn(turn: TurnJournalSummary) { + navigate(`/replay/${turn.turn_id}`, { replace: true }); + pushRecentItem({ label: `Replay #${turn.turn_id}`, path: `/replay/${turn.turn_id}` }); + } + + if (turnsQuery.isLoading) { + return ; + } + + if (turnsQuery.isError) { + return ( + + ); + } + + if (turns.length === 0) { + return ( + + ); + } + + return ( +
    + + +
    + + {filteredTurns.length === 0 ? ( + + ) : ( + String(turn.turn_id)} + height="calc(100vh - 14rem)" + initialRect={{ width: 480, height: 560 }} + items={filteredTurns} + onActivate={(turn) => selectTurn(turn)} + renderItem={(turn, _index, focused) => ( + selectTurn(turn)} + /> + )} + /> + )} +
    +
    + +
    + {selectedTurnId === null ? ( + + ) : replayQuery.isLoading ? ( + + ) : replayQuery.isError ? ( + + ) : replayQuery.data ? ( + + ) : null} +
    +
    ); } diff --git a/workbench-ui/src/app/replay/replay.test.tsx b/workbench-ui/src/app/replay/replay.test.tsx deleted file mode 100644 index bc848e41..00000000 --- a/workbench-ui/src/app/replay/replay.test.tsx +++ /dev/null @@ -1,414 +0,0 @@ -import { QueryClientProvider } from "@tanstack/react-query"; -import { createTestQueryClient } from "../../test/createTestQueryClient"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; -import { MemoryRouter, Route, Routes } from "react-router-dom"; -import { describe, expect, it, vi, afterEach } from "vitest"; -import { EvidenceProvider } from "../evidenceContext"; -import { ArtifactList } from "./ArtifactList"; -import { ReplayStatusBadge, ReplayStatus } from "../../design/components/badges"; -import { ReplayComparisonPanel } from "./ReplayComparisonPanel"; -import { ReplayDiffViewer } from "./ReplayDiffViewer"; -import { ReplayMetadataTable } from "./ReplayMetadataTable"; -import { ReplayRoute } from "./ReplayRoute"; -import type { ArtifactRef, ArtifactDetail, ReplayComparison } from "../../types/api"; -import * as fs from "fs"; -import * as path from "path"; - -// Mock globals -vi.stubGlobal("navigator", { - clipboard: { - writeText: vi.fn().mockResolvedValue(undefined), - }, -}); - -const mockArtifacts: ArtifactRef[] = [ - { - artifact_id: "art-trace-1", - kind: "trace", - path: "traces/1.json", - digest: "sha256:1", - created_at: "2026-05-26T12:00:00Z", - }, - { - artifact_id: "art-eval-1", - kind: "eval_result", - path: "evals/1.json", - digest: "sha256:2", - created_at: "2026-05-26T12:01:00Z", - }, - { - artifact_id: "art-prop-1", - kind: "proposal", - path: "proposals/1.json", - digest: "sha256:3", - created_at: "2026-05-26T12:02:00Z", - }, - { - artifact_id: "art-rep-1", - kind: "contemplation_report", - path: "reports/1.json", - digest: "sha256:4", - created_at: "2026-05-26T12:03:00Z", - }, - { - artifact_id: "art-tel-1", - kind: "telemetry", - path: "telemetry/1.jsonl", - digest: "sha256:5", - created_at: "2026-05-26T12:04:00Z", - }, - { - artifact_id: "art-state-1", - kind: "engine_state_manifest", - path: "state/1.json", - digest: "sha256:6", - created_at: "2026-05-26T12:05:00Z", - }, - { - artifact_id: "art-unk-1", - kind: "unknown", - path: "unknown/1.json", - digest: "sha256:7", - created_at: "2026-05-26T12:06:00Z", - }, -]; - -const mockArtifactDetail: ArtifactDetail = { - artifact_id: "art-trace-1", - kind: "trace", - path: "traces/1.json", - digest: "sha256:1", - created_at: "2026-05-26T12:00:00Z", - content_type: "json", - content: { hello: "world" }, -}; - -const mockReplayComparison: ReplayComparison = { - artifact_id: "art-trace-1", - original_hash: "sha256:orig-1234", - replay_hash: "sha256:repl-5678", - equivalent: false, - divergences: [ - { path: "/a", original: 1, replay: 2, severity: "warning" }, - { path: "/b", original: true, replay: false, severity: "failure" }, - { path: "/c", original: "x", replay: "y", severity: "info" }, - ], -}; - -function renderWithProviders(ui: React.ReactElement, initialEntries = ["/"]) { - const queryClient = createTestQueryClient(); - return render( - - {ui} - - ); -} - -// ReplayRoute reads its selection from the :artifactId path param and -// publishes the selected artifact as the evidence subject, so it needs the -// real route declaration and an EvidenceProvider. -function renderReplayRoute(initialEntry: string) { - return renderWithProviders( - - - } /> - - , - [initialEntry], - ); -} - -describe("W-031 Replay Theater Tests", () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe("ArtifactList", () => { - it("renders empty state when API returns []", () => { - render( - {}} /> - ); - expect(screen.getByTestId("artifact-list-empty")).toBeInTheDocument(); - expect(screen.getByText("No artifacts available.")).toBeInTheDocument(); - }); - - it("groups by kind correctly with a fixture covering all 7 kind values", () => { - render( - {}} - /> - ); - - // Verify that all 7 group headings are rendered - expect(screen.getByText("trace")).toBeInTheDocument(); - expect(screen.getByText("eval result")).toBeInTheDocument(); - expect(screen.getByText("proposal")).toBeInTheDocument(); - expect(screen.getByText("contemplation report")).toBeInTheDocument(); - expect(screen.getByText("telemetry")).toBeInTheDocument(); - expect(screen.getByText("engine state manifest")).toBeInTheDocument(); - expect(screen.getByText("unknown")).toBeInTheDocument(); - - // Verify artifact IDs are present - mockArtifacts.forEach((art) => { - expect(screen.getByText(art.artifact_id)).toBeInTheDocument(); - }); - }); - - it("selecting a row invokes onSelect with the artifact id", () => { - // URL writing is ReplayRoute's job (path param, replace) — covered by - // the route tests below. ArtifactList only reports the selection. - const onSelect = vi.fn(); - render( - - ); - - fireEvent.click(screen.getByTestId("artifact-art-trace-1")); - expect(onSelect).toHaveBeenCalledWith("art-trace-1"); - }); - }); - - describe("ReplayStatusBadge", () => { - it("all four enum states render with the right label and color semantics", () => { - const states = [ - { value: ReplayStatus.EQUIVALENT, label: "equivalent" }, - { value: ReplayStatus.NOT_YET_REPLAYED, label: "not_yet_replayed" }, - { value: ReplayStatus.DIVERGED, label: "diverged" }, - { value: ReplayStatus.EVIDENCE_UNAVAILABLE, label: "evidence_unavailable" }, - ]; - - states.forEach(({ value, label }) => { - const { unmount } = render(); - expect(screen.getByRole("button", { name: label })).toBeInTheDocument(); - unmount(); - }); - }); - }); - - describe("ReplayComparisonPanel", () => { - it("equivalent=true renders the calm empty-state for divergences, NOT a celebratory affordance", () => { - const eqComparison: ReplayComparison = { - artifact_id: "art-trace-1", - original_hash: "sha256:1", - replay_hash: "sha256:1", - equivalent: true, - divergences: [], - }; - - render( - - ); - - // Check for calm empty state text - expect(screen.getByText("Replay evidence intact — no divergences.")).toBeInTheDocument(); - - // Assert static text contains no celebratory affect - const containerText = document.body.innerHTML; - const forbiddenWords = ["success", "celebrate", "congratulations", "🎉", "✅", "🏆", "🌟"]; - forbiddenWords.forEach((word) => { - expect(containerText.toLowerCase()).not.toContain(word); - }); - }); - - it("verifies the component source code files contain no celebratory words or emojis", () => { - const dir = __dirname; - const files = [ - "ReplayRoute.tsx", - "ReplayComparisonPanel.tsx", - "ReplayDiffViewer.tsx", - "ReplayMetadataTable.tsx", - "ArtifactList.tsx", - ]; - - files.forEach((file) => { - const filePath = path.join(dir, file); - const code = fs.readFileSync(filePath, "utf-8"); - const forbiddenWords = ["success", "celebrate", "congratulations", "🎉", "✅"]; - forbiddenWords.forEach((word) => { - expect( - code.toLowerCase(), - `File ${file} contains forbidden positive-affect word: ${word}` - ).not.toContain(word); - }); - }); - }); - }); - - describe("ReplayDiffViewer", () => { - it("renders divergences ordered failure -> warning -> info", () => { - render(); - - const badges = screen.getAllByRole("button", { name: /(failure|warning|info)/i }); - expect(badges).toHaveLength(3); - expect(badges[0].textContent).toBe("failure"); - expect(badges[1].textContent).toBe("warning"); - expect(badges[2].textContent).toBe("info"); - }); - - it("renders nothing (null) with 0 divergences", () => { - const { container } = render(); - expect(container.firstChild).toBeNull(); - }); - }); - - describe("ReplayMetadataTable", () => { - it("copyable digests work", () => { - render( - - ); - - // Check presence of digest badges (which are copyable InfoBadges) - const origBadge = screen.getByRole("button", { name: "sha256:orig-" }); - const replBadge = screen.getByRole("button", { name: "sha256:repl-" }); - - expect(origBadge).toBeInTheDocument(); - expect(replBadge).toBeInTheDocument(); - }); - - it("rendered path is text-only, not an anchor/link element", () => { - render( - - ); - - const pathEl = screen.getByTestId("artifact-path-text"); - expect(pathEl.tagName.toLowerCase()).not.toBe("a"); - expect(pathEl.closest("a")).toBeNull(); - expect(pathEl.textContent).toBe(mockArtifactDetail.path); - }); - }); - - describe("ReplayRoute", () => { - it("full happy-path with fixture artifact + fixture comparison", async () => { - const fetchMock = vi.fn().mockImplementation((url: string) => { - if (url.endsWith("/artifacts")) { - return Promise.resolve({ - json: () => Promise.resolve({ ok: true, generated_at: "now", data: { items: mockArtifacts } }), - }); - } - if (url.endsWith("/artifacts/art-trace-1")) { - return Promise.resolve({ - json: () => Promise.resolve({ ok: true, generated_at: "now", data: mockArtifactDetail }), - }); - } - if (url.endsWith("/replay/art-trace-1")) { - return Promise.resolve({ - json: () => Promise.resolve({ ok: true, generated_at: "now", data: mockReplayComparison }), - }); - } - return Promise.reject(new Error("Unknown route")); - }); - vi.stubGlobal("fetch", fetchMock); - - renderReplayRoute("/replay/art-trace-1"); - - // Should load list, detail, and comparison - expect(await screen.findByText("Replay Evidence")).toBeInTheDocument(); - expect(screen.getAllByText("art-trace-1").length).toBeGreaterThanOrEqual(1); - expect(screen.getByText("Replay Divergences")).toBeInTheDocument(); - }); - - it("evidence_unavailable state when backend returns unsupported", async () => { - const fetchMock = vi.fn().mockImplementation((url: string) => { - if (url.endsWith("/artifacts")) { - return Promise.resolve({ - json: () => Promise.resolve({ ok: true, generated_at: "now", data: { items: mockArtifacts } }), - }); - } - if (url.endsWith("/artifacts/art-trace-1")) { - return Promise.resolve({ - json: () => Promise.resolve({ ok: true, generated_at: "now", data: mockArtifactDetail }), - }); - } - if (url.endsWith("/replay/art-trace-1")) { - // Return unsupported error code - return Promise.resolve({ - json: () => Promise.resolve({ - ok: false, - generated_at: "now", - error: { code: "unsupported", message: "route is unsupported" }, - }), - }); - } - return Promise.reject(new Error("Unknown route")); - }); - vi.stubGlobal("fetch", fetchMock); - - renderReplayRoute("/replay/art-trace-1"); - - expect(await screen.findByText("evidence_unavailable")).toBeInTheDocument(); - expect(screen.getByText("Replay evidence is not available on the backend for this artifact kind.")).toBeInTheDocument(); - }); - - it("ErrorState only for genuine API errors (not for unsupported)", async () => { - const fetchMock = vi.fn().mockImplementation((url: string) => { - if (url.endsWith("/artifacts")) { - return Promise.resolve({ - json: () => Promise.resolve({ ok: true, generated_at: "now", data: { items: mockArtifacts } }), - }); - } - if (url.endsWith("/artifacts/art-trace-1")) { - return Promise.resolve({ - json: () => Promise.resolve({ ok: true, generated_at: "now", data: mockArtifactDetail }), - }); - } - if (url.endsWith("/replay/art-trace-1")) { - // Return a genuine read_error - return Promise.resolve({ - json: () => Promise.resolve({ - ok: false, - generated_at: "now", - error: { code: "read_error", message: "disk read error" }, - }), - }); - } - return Promise.reject(new Error("Unknown route")); - }); - vi.stubGlobal("fetch", fetchMock); - - renderReplayRoute("/replay/art-trace-1"); - - expect(await screen.findByText("What failed")).toBeInTheDocument(); - expect(screen.getByText("disk read error")).toBeInTheDocument(); - }); - }); - - describe("Anti-motion & Animation Constraints", () => { - it("verifies the component files contain no animation/motion triggers like transition, animate, fade", () => { - const dir = __dirname; - const files = [ - "ReplayRoute.tsx", - "ReplayComparisonPanel.tsx", - "ReplayDiffViewer.tsx", - "ReplayMetadataTable.tsx", - "ArtifactList.tsx", - ]; - - files.forEach((file) => { - const filePath = path.join(dir, file); - const code = fs.readFileSync(filePath, "utf-8"); - const forbiddenMotion = ["transition", "animate", "fade"]; - forbiddenMotion.forEach((trigger) => { - expect( - code.toLowerCase(), - `File ${file} contains forbidden animation keyword: ${trigger}` - ).not.toContain(trigger); - }); - }); - }); - }); -}); diff --git a/workbench-ui/src/app/routeConformance.test.tsx b/workbench-ui/src/app/routeConformance.test.tsx index 0f8ce0b7..132a1727 100644 --- a/workbench-ui/src/app/routeConformance.test.tsx +++ b/workbench-ui/src/app/routeConformance.test.tsx @@ -158,11 +158,11 @@ const MOUNT_ROUTES: MountRouteSpec[] = [ { name: "Replay", element: , - path: "/replay/:artifactId?", + path: "/replay/:turnId?", initialEntry: "/replay", - loadingLabel: "Loading artifacts...", - emptyStatement: "No artifacts available.", - emptyCommand: "core eval cognition", + loadingLabel: "Loading turns...", + emptyStatement: "No turns recorded yet. Use Chat to create evidence.", + emptyCommand: "core chat", }, { name: "Packs", diff --git a/workbench-ui/src/design/components/badges/enumCoverage.test.ts b/workbench-ui/src/design/components/badges/enumCoverage.test.ts index 39ba518a..52d1ae8b 100644 --- a/workbench-ui/src/design/components/badges/enumCoverage.test.ts +++ b/workbench-ui/src/design/components/badges/enumCoverage.test.ts @@ -5,8 +5,6 @@ import { groundingSourceMeta, normativeClearanceMeta, reviewStateMeta, - replayDivergenceSeverityMeta, - replayStatusMeta, } from "./mappings"; function expectExactCoverage(name: string, engineValues: string[], uiValues: string[]) { @@ -45,20 +43,4 @@ describe("build-time enum coverage", () => { Object.keys(groundingSourceMeta), ); }); - - it("tracks every ratified ReplayDivergenceSeverity value exactly once", () => { - expectExactCoverage( - "ReplayDivergenceSeverity", - snapshot.ReplayDivergenceSeverity, - Object.keys(replayDivergenceSeverityMeta), - ); - }); - - it("tracks every ratified ReplayStatus value exactly once", () => { - expectExactCoverage( - "ReplayStatus", - snapshot.ReplayStatus, - Object.keys(replayStatusMeta), - ); - }); }); diff --git a/workbench-ui/src/design/components/badges/index.tsx b/workbench-ui/src/design/components/badges/index.tsx index 2a79c262..32237818 100644 --- a/workbench-ui/src/design/components/badges/index.tsx +++ b/workbench-ui/src/design/components/badges/index.tsx @@ -3,8 +3,6 @@ import { groundingSourceMeta, normativeClearanceMeta, reviewStateMeta, - replayDivergenceSeverityMeta, - replayStatusMeta, } from "./mappings"; import { InfoBadge } from "./Badge"; import { @@ -12,8 +10,6 @@ import { GroundingSource, NormativeClearance, ReviewState, - ReplayDivergenceSeverity, - ReplayStatus, } from "./types"; export { @@ -21,8 +17,6 @@ export { GroundingSource, NormativeClearance, ReviewState, - ReplayDivergenceSeverity, - ReplayStatus, }; export function EpistemicStateBadge({ value }: { value: EpistemicState }) { @@ -41,14 +35,6 @@ export function GroundingSourceBadge({ value }: { value: GroundingSource }) { return ; } -export function ReplayDivergenceSeverityBadge({ value }: { value: ReplayDivergenceSeverity }) { - return ; -} - -export function ReplayStatusBadge({ value }: { value: ReplayStatus }) { - return ; -} - export function TraceHashBadge({ value, truncate = 12, diff --git a/workbench-ui/src/design/components/badges/mappings.ts b/workbench-ui/src/design/components/badges/mappings.ts index 955c88d2..2f7f1a4d 100644 --- a/workbench-ui/src/design/components/badges/mappings.ts +++ b/workbench-ui/src/design/components/badges/mappings.ts @@ -3,8 +3,6 @@ import { GroundingSource, NormativeClearance, ReviewState, - ReplayDivergenceSeverity, - ReplayStatus, type BadgeMeta, } from "./types"; @@ -48,16 +46,3 @@ export const groundingSourceMeta = { [GroundingSource.OOV]: { label: "OOV", colorToken: "--color-grounding-oov", meaning: "Out-of-vocabulary grounding was encountered.", adr: "ADR-0160 / ADR-0162", evidence: "grounding_source is oov." }, [GroundingSource.NONE]: { label: "None", colorToken: "--color-grounding-none", meaning: "No grounding source was present.", adr: "ADR-0160 / ADR-0162", evidence: "grounding_source is none." }, } satisfies BadgeMeta; - -export const replayDivergenceSeverityMeta = { - [ReplayDivergenceSeverity.INFO]: { label: "info", colorToken: "--color-grounding-vault", meaning: "Informational divergence, non-critical deviation.", adr: "ADR-0160 / ADR-0162", evidence: "divergence severity is info." }, - [ReplayDivergenceSeverity.WARNING]: { label: "warning", colorToken: "--color-review-pending", meaning: "Warning divergence, requires attention.", adr: "ADR-0160 / ADR-0162", evidence: "divergence severity is warning." }, - [ReplayDivergenceSeverity.FAILURE]: { label: "failure", colorToken: "--color-review-rejected", meaning: "Failure divergence, critical replay violation.", adr: "ADR-0160 / ADR-0162", evidence: "divergence severity is failure." }, -} satisfies BadgeMeta; - -export const replayStatusMeta = { - [ReplayStatus.EQUIVALENT]: { label: "equivalent", colorToken: "--color-review-accepted", meaning: "Replay evidence intact — no divergences.", adr: "ADR-0160 / ADR-0162", evidence: "equivalent is true and replay_hash is present." }, - [ReplayStatus.NOT_YET_REPLAYED]: { label: "not_yet_replayed", colorToken: "--color-review-pending", meaning: "No replay has been attempted for this artifact yet.", adr: "ADR-0160 / ADR-0162", evidence: "equivalent is false and replay_hash is null." }, - [ReplayStatus.DIVERGED]: { label: "diverged", colorToken: "--color-review-rejected", meaning: "Replay completed but generated different state/output.", adr: "ADR-0160 / ADR-0162", evidence: "equivalent is false and replay_hash is present." }, - [ReplayStatus.EVIDENCE_UNAVAILABLE]: { label: "evidence_unavailable", colorToken: "--color-review-withdrawn", meaning: "Replay evidence is not available on the backend for this kind.", adr: "ADR-0160 / ADR-0162", evidence: "API returned unsupported error code." }, -} satisfies BadgeMeta; diff --git a/workbench-ui/src/design/components/badges/types.ts b/workbench-ui/src/design/components/badges/types.ts index a8a41f7f..7b895820 100644 --- a/workbench-ui/src/design/components/badges/types.ts +++ b/workbench-ui/src/design/components/badges/types.ts @@ -39,19 +39,6 @@ export enum GroundingSource { NONE = "none", } -export enum ReplayDivergenceSeverity { - INFO = "info", - WARNING = "warning", - FAILURE = "failure", -} - -export enum ReplayStatus { - EQUIVALENT = "equivalent", - NOT_YET_REPLAYED = "not_yet_replayed", - DIVERGED = "diverged", - EVIDENCE_UNAVAILABLE = "evidence_unavailable", -} - export type BadgeMeta = Record< T, { diff --git a/workbench-ui/src/design/doctrine/schemaDrift.test.ts b/workbench-ui/src/design/doctrine/schemaDrift.test.ts index 72103300..f9eadb74 100644 --- a/workbench-ui/src/design/doctrine/schemaDrift.test.ts +++ b/workbench-ui/src/design/doctrine/schemaDrift.test.ts @@ -19,13 +19,10 @@ import { describe, expect, it } from "vitest"; * only shrink. */ -const NOT_YET_MIRRORED = new Set([ - // Wave R3 sealed turn replay backend — TS mirrors land with the frontend - // Replay Moment PR (which also retires the W-026 artifact-keyed - // ReplayComparison/ReplayDivergence pair on both sides): - "TurnReplayComparison", - "TurnReplayDivergence", -]); +// Empty: every engine schema is now mirrored. New unmirrored schemas may be +// allowlisted here as shrink-only debt (a class gaining a mirror while listed +// fails the gate). +const NOT_YET_MIRRORED = new Set([]); const UI_ROOT = join(__dirname, "..", "..", ".."); const snapshot: Record = JSON.parse( diff --git a/workbench-ui/src/types/api.ts b/workbench-ui/src/types/api.ts index 8f819486..b84d854d 100644 --- a/workbench-ui/src/types/api.ts +++ b/workbench-ui/src/types/api.ts @@ -247,21 +247,28 @@ export interface EvalRunResult { source_digest: string | null; } -export type ReplayDivergenceSeverity = "info" | "warning" | "failure"; +// Wave R3 — sealed single-turn replay (turn-keyed; supersedes the W-026 +// artifact-keyed ReplayComparison, now retired on both sides). +export type TurnReplayDivergenceSeverity = "critical" | "informational"; +export type TurnReplayBasis = "sealed_fresh_runtime_single_turn"; +export type TurnReplayOriginState = "unrecorded"; -export interface ReplayDivergence { +export interface TurnReplayDivergence { path: string; original: unknown; replay: unknown; - severity: ReplayDivergenceSeverity; + severity: TurnReplayDivergenceSeverity; } -export interface ReplayComparison { - artifact_id: string; - original_hash: string | null; - replay_hash: string | null; +export interface TurnReplayComparison { + turn_id: number; + comparison_basis: TurnReplayBasis; + origin_state: TurnReplayOriginState; + original_trace_hash: string | null; + replay_trace_hash: string | null; equivalent: boolean; - divergences: ReplayDivergence[]; + replay_turn_cost_ms: number; + divergences: TurnReplayDivergence[]; } export interface VaultSummary { diff --git a/workbench/schemas.py b/workbench/schemas.py index 5ba612c2..cf9ad559 100644 --- a/workbench/schemas.py +++ b/workbench/schemas.py @@ -210,27 +210,6 @@ class EvalRunResult: source_digest: str | None = None -ReplayDivergenceSeverity = Literal["info", "warning", "failure"] -ReplayStatus = Literal["equivalent", "not_yet_replayed", "diverged", "evidence_unavailable"] - - -@dataclass(frozen=True, slots=True) -class ReplayDivergence: - path: str - original: Any - replay: Any - severity: ReplayDivergenceSeverity - - -@dataclass(frozen=True, slots=True) -class ReplayComparison: - artifact_id: str - original_hash: str | None - replay_hash: str | None - equivalent: bool - divergences: list[ReplayDivergence] = field(default_factory=list) - - # --------------------------------------------------------------------------- # Wave R3 — sealed single-turn replay over the turn journal. # Scoping: docs/analysis/replay-moment-backend-scoping-2026-06-12.md.