From 2b13feab24a603c3c4765370bf8665a383061f28 Mon Sep 17 00:00:00 2001 From: Shay Date: Fri, 12 Jun 2026 20:58:55 -0700 Subject: [PATCH] =?UTF-8?q?feat(workbench):=20Runs=20route=20=E2=80=94=20s?= =?UTF-8?q?ession=20evidence=20with=20trace=20cross-links=20(Wave=20R2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the RunsRoutePlaceholder with the real triad route over the R2-B /runs read substrate (#712). - TS mirrors RunSource/RunSummary/RunTurnRef/RunDetail; all three classes removed from NOT_YET_MIRRORED (drift gate now enforces them) - list pane: VirtualizedList + useListNavigation (j/k) + SearchInput; rows show source, session_id, turn_count, relative timestamp, a checkpoint badge (present + revision vs 'no checkpoint'), and any evidence_gap rendered honestly in-row (never hidden) - detail pane: Panel + TabBar (Turns / Manifest / Raw); every turn row is an anchor to /trace/ (backend-provided trace_path) with the trace_hash as a DigestBadge — the cross-link is the point of this route; turn list pages via turn_limit ('Load more turns') - selection publishes the run subject (R2-S) and records /runs/ in the URL (replace), pushRecentItem on visit - Runs row added to routeConformance MOUNT_ROUTES (loading 'Loading runs...', error 'No runs mutation occurred.', empty 'No runs recorded yet.' + core chat) - RunsRoute.test.tsx: list evidence, in-row gap, replace-mode URL select, trace cross-link hrefs, manifest tab, j/k keyboard spine Token-only styling (hexScan green); full vitest 339 green. --- workbench-ui/src/api/client.ts | 24 ++ workbench-ui/src/api/queries.ts | 23 ++ workbench-ui/src/app/App.tsx | 4 +- .../src/app/routeConformance.test.tsx | 10 + workbench-ui/src/app/runs/RunsRoute.test.tsx | 246 ++++++++++++ workbench-ui/src/app/runs/RunsRoute.tsx | 378 ++++++++++++++++++ .../src/design/doctrine/schemaDrift.test.ts | 3 - .../src/routes/RunsRoutePlaceholder.tsx | 10 - workbench-ui/src/types/api.ts | 27 ++ 9 files changed, 710 insertions(+), 15 deletions(-) create mode 100644 workbench-ui/src/app/runs/RunsRoute.test.tsx create mode 100644 workbench-ui/src/app/runs/RunsRoute.tsx delete mode 100644 workbench-ui/src/routes/RunsRoutePlaceholder.tsx diff --git a/workbench-ui/src/api/client.ts b/workbench-ui/src/api/client.ts index f286cab9..b59cc05e 100644 --- a/workbench-ui/src/api/client.ts +++ b/workbench-ui/src/api/client.ts @@ -11,6 +11,8 @@ import type { ProposalState, ProposalSummary, AuditEvent, + RunSummary, + RunDetail, TurnJournalEntry, TurnJournalSummary, MathProposalSummary, @@ -139,6 +141,28 @@ export async function fetchAuditEvents( return apiFetch>(query ? `/audit/events?${query}` : "/audit/events"); } +export async function fetchRuns(limit?: number, offset?: number): Promise { + const params = new URLSearchParams(); + if (limit !== undefined) params.set("limit", String(limit)); + if (offset !== undefined) params.set("offset", String(offset)); + const query = params.toString(); + const envelope = await apiFetch>(query ? `/runs?${query}` : "/runs"); + return envelope.items; +} + +export async function fetchRun( + sessionId: string, + turnLimit?: number, + turnOffset?: number, +): Promise { + const params = new URLSearchParams(); + if (turnLimit !== undefined) params.set("limit", String(turnLimit)); + if (turnOffset !== undefined) params.set("offset", String(turnOffset)); + const query = params.toString(); + const path = `/runs/${encodeURIComponent(sessionId)}`; + return apiFetch(query ? `${path}?${query}` : path); +} + export async function fetchMathProposals(): Promise { const envelope = await apiFetch>("/math-proposals"); return envelope.items; diff --git a/workbench-ui/src/api/queries.ts b/workbench-ui/src/api/queries.ts index b31d2915..55ddc2d0 100644 --- a/workbench-ui/src/api/queries.ts +++ b/workbench-ui/src/api/queries.ts @@ -14,6 +14,8 @@ import { fetchProposalDetail, fetchProposals, fetchAuditEvents, + fetchRuns, + fetchRun, fetchTraceTurn, fetchTraceTurns, fetchMathProposals, @@ -31,6 +33,8 @@ import type { ProposalSummary, ProposalDetail, AuditEvent, + RunSummary, + RunDetail, TurnJournalEntry, TurnJournalSummary, EvalLaneSummary, @@ -142,6 +146,25 @@ export function useAuditEvents(limit?: number, offset?: number) { }); } +export function useRuns(limit?: number, offset?: number) { + return useQuery({ + queryKey: ["api", "runs", limit ?? null, offset ?? null], + queryFn: () => fetchRuns(limit, offset), + staleTime: 30_000, + refetchOnWindowFocus: false, + }); +} + +export function useRun(sessionId?: string | null, turnLimit?: number) { + return useQuery({ + queryKey: ["api", "run", sessionId ?? null, turnLimit ?? null], + queryFn: () => fetchRun(sessionId as string, turnLimit), + enabled: typeof sessionId === "string" && sessionId.length > 0, + staleTime: 30_000, + refetchOnWindowFocus: false, + }); +} + export function useEvalLanes() { return useQuery({ queryKey: ["api", "evals"], diff --git a/workbench-ui/src/app/App.tsx b/workbench-ui/src/app/App.tsx index 10218ba0..f6c69005 100644 --- a/workbench-ui/src/app/App.tsx +++ b/workbench-ui/src/app/App.tsx @@ -9,7 +9,7 @@ import { TraceRoute } from "./trace/TraceRoute"; import { AuditRoute } from "./audit/AuditRoute"; import { ReplayRoute } from "./replay/ReplayRoute"; import { EvalsRoute } from "./evals/EvalsRoute"; -import { RunsRoutePlaceholder } from "../routes/RunsRoutePlaceholder"; +import { RunsRoute } from "./runs/RunsRoute"; import { PacksRoutePlaceholder } from "../routes/PacksRoutePlaceholder"; import { VaultRoutePlaceholder } from "../routes/VaultRoutePlaceholder"; import { SettingsRoutePlaceholder } from "../routes/SettingsRoutePlaceholder"; @@ -26,7 +26,7 @@ export function App() { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/workbench-ui/src/app/routeConformance.test.tsx b/workbench-ui/src/app/routeConformance.test.tsx index 5b6f38af..5c353b94 100644 --- a/workbench-ui/src/app/routeConformance.test.tsx +++ b/workbench-ui/src/app/routeConformance.test.tsx @@ -12,6 +12,7 @@ import { TraceRoute } from "./trace/TraceRoute"; import { AuditRoute } from "./audit/AuditRoute"; import { EvalsRoute } from "./evals/EvalsRoute"; import { ReplayRoute } from "./replay/ReplayRoute"; +import { RunsRoute } from "./runs/RunsRoute"; /** * ADR-0162 §6 route conformance — executable, not aspirational. @@ -142,6 +143,15 @@ const MOUNT_ROUTES: MountRouteSpec[] = [ emptyStatement: "No eval lanes discovered.", emptyCommand: "core eval --list", }, + { + name: "Runs", + element: , + path: "/runs/:sessionId?", + initialEntry: "/runs", + loadingLabel: "Loading runs...", + emptyStatement: "No runs recorded yet. Use Chat to create evidence.", + emptyCommand: "core chat", + }, { name: "Replay", element: , diff --git a/workbench-ui/src/app/runs/RunsRoute.test.tsx b/workbench-ui/src/app/runs/RunsRoute.test.tsx new file mode 100644 index 00000000..24ce5d5c --- /dev/null +++ b/workbench-ui/src/app/runs/RunsRoute.test.tsx @@ -0,0 +1,246 @@ +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 { RunDetail, RunSummary } from "../../types/api"; +import { EvidenceProvider } from "../evidenceContext"; +import { RunsRoute } from "./RunsRoute"; + +const JOURNAL_RUN = "workbench_turn_journal"; +const ENGINE_RUN = "engine_state_checkpoint"; + +const summaries: RunSummary[] = [ + { + session_id: JOURNAL_RUN, + source: "turn_journal", + turn_count: 2, + started_at: "2026-06-12T18:00:00Z", + updated_at: "2026-06-12T18:05:00Z", + checkpoint_present: false, + checkpoint_revision: null, + artifact_refs: [], + evidence_gap: null, + }, + { + session_id: ENGINE_RUN, + source: "engine_state_manifest", + turn_count: 0, + started_at: null, + updated_at: "2026-06-12T17:00:00Z", + checkpoint_present: true, + checkpoint_revision: "rev-abc123", + artifact_refs: [], + evidence_gap: "turn journal not found alongside checkpoint", + }, +]; + +function detailFor(sessionId: string): RunDetail { + const summary = summaries.find((s) => s.session_id === sessionId) ?? summaries[0]; + if (sessionId === JOURNAL_RUN) { + return { + ...summary, + turns: [ + { + turn_id: 1, + trace_hash: "sha256:111111111111abcdef", + timestamp: "2026-06-12T18:00:00Z", + trace_path: "/trace/1", + surface_excerpt: "First response", + }, + { + turn_id: 2, + trace_hash: "sha256:222222222222abcdef", + timestamp: "2026-06-12T18:05:00Z", + trace_path: "/trace/2", + surface_excerpt: "Second response", + }, + ], + manifest: null, + }; + } + return { + ...summary, + turns: [], + manifest: { schema_version: 2, checkpoint_revision: "rev-abc123" }, + }; +} + +function LocationProbe() { + const location = useLocation(); + const navigationType = useNavigationType(); + return ( + <> + {`${location.pathname}${location.search}`} + {navigationType} + + ); +} + +function renderRoute(initialEntry = "/runs") { + return render( + + + + + + + + + } + /> + } /> + + + + , + ); +} + +function stubRunsFetch(items: RunSummary[] = summaries) { + const fetchMock = vi.fn((input: unknown) => { + const path = new URL(String(input)).pathname; + if (path === "/runs") { + return Promise.resolve({ + json: () => Promise.resolve({ ok: true, generated_at: "now", data: { items } }), + }); + } + const match = path.match(/^\/runs\/(.+)$/); + if (match) { + return Promise.resolve({ + json: () => + Promise.resolve({ + ok: true, + generated_at: "now", + data: detailFor(decodeURIComponent(match[1])), + }), + }); + } + return Promise.resolve({ + json: () => + Promise.resolve({ + ok: false, + generated_at: "now", + error: { code: "not_found", message: `unexpected path ${path}` }, + }), + }); + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +const offsetDescriptors = { + offsetHeight: Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight"), + offsetWidth: Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetWidth"), +}; + +describe("RunsRoute", () => { + 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("renders the session list with checkpoint and turn-count evidence", async () => { + stubRunsFetch(); + renderRoute(); + + expect(await screen.findByText("Turn journal")).toBeInTheDocument(); + expect(screen.getByText("Engine-state checkpoint")).toBeInTheDocument(); + expect(screen.getByText(JOURNAL_RUN)).toBeInTheDocument(); + expect(screen.getByText("2 turns")).toBeInTheDocument(); + // checkpoint present vs absent are visually distinct + expect(screen.getByText("no checkpoint")).toBeInTheDocument(); + expect(screen.getByText("rev-abc123")).toBeInTheDocument(); + }); + + it("renders an evidence gap honestly in-row, never hidden", async () => { + stubRunsFetch(); + renderRoute(); + + expect( + await screen.findByText(/evidence gap: turn journal not found alongside checkpoint/), + ).toBeInTheDocument(); + }); + + it("selecting a run writes /runs/ with replace and shows detail", async () => { + stubRunsFetch(); + const user = userEvent.setup(); + renderRoute(); + + await user.click(await screen.findByText("Turn journal")); + + await waitFor(() => + expect(screen.getByTestId("location")).toHaveTextContent(`/runs/${JOURNAL_RUN}`), + ); + expect(screen.getByTestId("nav-type")).toHaveTextContent("REPLACE"); + }); + + it("every turn row cross-links to /trace/ — the point of this route", async () => { + stubRunsFetch(); + renderRoute(`/runs/${JOURNAL_RUN}`); + + const link1 = await screen.findByRole("link", { name: /Turn #1/ }); + expect(link1).toHaveAttribute("href", "/trace/1"); + const link2 = screen.getByRole("link", { name: /Turn #2/ }); + expect(link2).toHaveAttribute("href", "/trace/2"); + // the trace hash is shown as a digest badge on each turn row + expect(screen.getByText("sha256:111111111111...")).toBeInTheDocument(); + }); + + it("shows the engine-state manifest under the Manifest tab", async () => { + stubRunsFetch(); + const user = userEvent.setup(); + renderRoute(`/runs/${ENGINE_RUN}`); + + // engine-state run has no cross-linkable turns + expect( + await screen.findByText(/records no cross-linkable turns/), + ).toBeInTheDocument(); + + await user.click(screen.getByRole("tab", { name: "Manifest" })); + expect(await screen.findByText(/schema_version/)).toBeInTheDocument(); + }); + + it("moves the session list focus with j/k through the VirtualizedList spine", async () => { + stubRunsFetch(); + const user = userEvent.setup(); + renderRoute(); + + const list = await screen.findByRole("listbox", { name: "Runs" }); + list.focus(); + expect(screen.getAllByRole("option")[0]).toHaveAttribute("aria-selected", "true"); + + await user.keyboard("j"); + expect(screen.getAllByRole("option")[1]).toHaveAttribute("aria-selected", "true"); + + await user.keyboard("k"); + expect(screen.getAllByRole("option")[0]).toHaveAttribute("aria-selected", "true"); + }); +}); diff --git a/workbench-ui/src/app/runs/RunsRoute.tsx b/workbench-ui/src/app/runs/RunsRoute.tsx new file mode 100644 index 00000000..5b479e7d --- /dev/null +++ b/workbench-ui/src/app/runs/RunsRoute.tsx @@ -0,0 +1,378 @@ +import { useEffect, useMemo, useState } from "react"; +import { ArrowUpRight, Eye } from "lucide-react"; +import { Link, useNavigate, useParams } from "react-router-dom"; +import { WorkbenchApiError } from "../../api/client"; +import { useRun, useRuns } 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 { StableJsonViewer } from "../../design/components/StableJsonViewer"; +import { TabBar, type Tab } from "../../design/components/TabBar/TabBar"; +import { Timestamp } from "../../design/components/Timestamp/Timestamp"; +import { VirtualizedList } from "../../design/components/VirtualizedList/VirtualizedList"; +import { Button } from "../../design/components/primitives/Button"; +import { EmptyState } from "../../design/components/states/EmptyState"; +import { ErrorState } from "../../design/components/states/ErrorState"; +import { LoadingState } from "../../design/components/states/LoadingState"; +import type { RunDetail, RunSummary, RunTurnRef } from "../../types/api"; +import { pushRecentItem } from "../commandRegistry"; +import { subjectToUrl } from "../evidenceAddress"; +import { useEvidenceSubject } from "../evidenceContext"; + +const RUN_TABS: readonly Tab[] = [ + { id: "turns", label: "Turns" }, + { id: "manifest", label: "Manifest" }, + { id: "raw", label: "Raw" }, +]; + +// read_run() pages turns by limit/offset; the list grows by this step. +const TURN_PAGE = 100; + +const SOURCE_LABEL: Record = { + turn_journal: "Turn journal", + engine_state_manifest: "Engine-state checkpoint", +}; + +function sourceLabel(source: string): string { + return SOURCE_LABEL[source] ?? source; +} + +function errorMessage(error: unknown) { + return error instanceof WorkbenchApiError ? error.message : "Runs request failed."; +} + +function digestPayload(value: string | null | undefined): string | null { + if (!value) return null; + return value.replace(/^sha256:/, ""); +} + +function CheckpointBadge({ + present, + revision, +}: { + present: boolean; + revision: string | null; +}) { + if (!present) { + return ( + + no checkpoint + + ); + } + return ( + + checkpoint + {revision ? ( + {revision} + ) : null} + + ); +} + +function RunRow({ + run, + selected, + focused, + onSelect, +}: { + run: RunSummary; + selected: boolean; + focused: boolean; + onSelect: () => void; +}) { + return ( +
+ + + {sourceLabel(run.source)} + + + {run.session_id} + + + {run.turn_count} turns + {run.updated_at ? ( + <> + · + + + ) : null} + + {run.evidence_gap ? ( + + evidence gap: {run.evidence_gap} + + ) : null} + + + + +
+ ); +} + +function TurnRefRow({ turn }: { turn: RunTurnRef }) { + const digest = digestPayload(turn.trace_hash); + return ( + + + + Turn #{turn.turn_id} + + + + + + + {turn.surface_excerpt || "Not recorded."} + + + + {digest ? ( + + ) : ( + no hash + )} + + + ); +} + +function TurnsTab({ + detail, + onLoadMore, + canLoadMore, +}: { + detail: RunDetail; + onLoadMore: () => void; + canLoadMore: boolean; +}) { + if (detail.turns.length === 0) { + return ( +

+ This run records no cross-linkable turns. Engine-state checkpoints + expose their evidence under Manifest. +

+ ); + } + return ( +
+ {detail.turns.map((turn) => ( + + ))} + {canLoadMore ? ( + + ) : null} +
+ ); +} + +function ManifestTab({ detail }: { detail: RunDetail }) { + if (!detail.manifest) { + return ( +

+ No engine-state manifest recorded for this run. +

+ ); + } + return ; +} + +function RawTab({ detail }: { detail: RunDetail }) { + const [expanded, setExpanded] = useState(false); + return expanded ? ( + + ) : ( +
+

+ Raw run JSON is collapsed by default. +

+ +
+ ); +} + +function RunDetailPanel({ + detail, + onLoadMore, + canLoadMore, +}: { + detail: RunDetail; + onLoadMore: () => void; + canLoadMore: boolean; +}) { + const [activeTab, setActiveTab] = useState("turns"); + return ( + + } + > + + {activeTab === "turns" ? ( + + ) : null} + {activeTab === "manifest" ? : null} + {activeTab === "raw" ? : null} + + + ); +} + +export function RunsRoute() { + const { sessionId } = useParams(); + const selectedSessionId = sessionId && sessionId.length > 0 ? sessionId : null; + const navigate = useNavigate(); + const { setSubject } = useEvidenceSubject(); + const [search, setSearch] = useState(""); + const [turnLimit, setTurnLimit] = useState(TURN_PAGE); + + const runsQuery = useRuns(); + const runQuery = useRun(selectedSessionId, turnLimit); + + const runs = runsQuery.data ?? []; + const filteredRuns = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return runs; + return runs.filter( + (run) => + run.session_id.toLowerCase().includes(q) || + sourceLabel(run.source).toLowerCase().includes(q), + ); + }, [search, runs]); + + // Reset the turn window when the selected run changes. + useEffect(() => { + setTurnLimit(TURN_PAGE); + }, [selectedSessionId]); + + useEffect(() => { + if (selectedSessionId === null) return; + setSubject({ kind: "run", sessionId: selectedSessionId, data: runQuery.data }); + }, [selectedSessionId, setSubject, runQuery.data]); + + function selectRun(run: RunSummary) { + const subject = { kind: "run" as const, sessionId: run.session_id }; + const path = subjectToUrl(subject); + navigate(path, { replace: true }); + pushRecentItem({ label: sourceLabel(run.source), path }); + } + + if (runsQuery.isLoading) { + return ; + } + + if (runsQuery.isError) { + return ( + + ); + } + + if (runs.length === 0) { + return ( + + ); + } + + const canLoadMore = !!runQuery.data && runQuery.data.turns.length === turnLimit; + + return ( +
+ + +
+ + {filteredRuns.length === 0 ? ( + + ) : ( + run.session_id} + height="calc(100vh - 14rem)" + initialRect={{ width: 480, height: 560 }} + items={filteredRuns} + onActivate={(run) => selectRun(run)} + renderItem={(run, _index, focused) => ( + selectRun(run)} + /> + )} + /> + )} +
+
+ +
+ {selectedSessionId === null ? ( + + ) : runQuery.isLoading ? ( + + ) : runQuery.isError ? ( + + ) : runQuery.data ? ( + setTurnLimit((n) => n + TURN_PAGE)} + canLoadMore={canLoadMore} + /> + ) : null} +
+
+
+ ); +} diff --git a/workbench-ui/src/design/doctrine/schemaDrift.test.ts b/workbench-ui/src/design/doctrine/schemaDrift.test.ts index ceae7dd1..237d4d4a 100644 --- a/workbench-ui/src/design/doctrine/schemaDrift.test.ts +++ b/workbench-ui/src/design/doctrine/schemaDrift.test.ts @@ -23,9 +23,6 @@ const NOT_YET_MIRRORED = new Set([ // R2-B backend read substrate (#712) — TS mirrors land with each R2 route: "PackSummary", "PackDetail", - "RunSummary", - "RunTurnRef", - "RunDetail", "VaultSummary", "VaultEntry", // Wave R3 sealed turn replay backend — TS mirrors land with the frontend diff --git a/workbench-ui/src/routes/RunsRoutePlaceholder.tsx b/workbench-ui/src/routes/RunsRoutePlaceholder.tsx deleted file mode 100644 index 505ad9bf..00000000 --- a/workbench-ui/src/routes/RunsRoutePlaceholder.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { EmptyState } from "../design/components/states/EmptyState"; - -export function RunsRoutePlaceholder() { - return ( - - ); -} diff --git a/workbench-ui/src/types/api.ts b/workbench-ui/src/types/api.ts index d75af89b..2b138fac 100644 --- a/workbench-ui/src/types/api.ts +++ b/workbench-ui/src/types/api.ts @@ -113,6 +113,33 @@ export interface TurnJournalEntry { export type TurnEvidence = ChatTurnResult | TurnJournalEntry; +export type RunSource = "engine_state_manifest" | "turn_journal"; + +export interface RunSummary { + session_id: string; + source: RunSource; + turn_count: number; + started_at: string | null; + updated_at: string | null; + checkpoint_present: boolean; + checkpoint_revision: string | null; + artifact_refs: ArtifactRef[]; + evidence_gap: string | null; +} + +export interface RunTurnRef { + turn_id: number; + trace_hash: string | null; + timestamp: string; + trace_path: string; + surface_excerpt: string; +} + +export interface RunDetail extends RunSummary { + turns: RunTurnRef[]; + manifest: Record | null; +} + export interface AuditEvent { event_id: string; source: AuditSource;