Merge pull request #718 from AssetOverflow/feat/wb-r2-runs-route
feat(workbench): Runs route — session evidence with trace cross-links (Wave R2)
This commit is contained in:
commit
1086415b36
9 changed files with 710 additions and 15 deletions
|
|
@ -11,6 +11,8 @@ import type {
|
|||
ProposalState,
|
||||
ProposalSummary,
|
||||
AuditEvent,
|
||||
RunSummary,
|
||||
RunDetail,
|
||||
TurnJournalEntry,
|
||||
TurnJournalSummary,
|
||||
MathProposalSummary,
|
||||
|
|
@ -139,6 +141,28 @@ export async function fetchAuditEvents(
|
|||
return apiFetch<ItemsEnvelope<AuditEvent>>(query ? `/audit/events?${query}` : "/audit/events");
|
||||
}
|
||||
|
||||
export async function fetchRuns(limit?: number, offset?: number): Promise<RunSummary[]> {
|
||||
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<ItemsEnvelope<RunSummary>>(query ? `/runs?${query}` : "/runs");
|
||||
return envelope.items;
|
||||
}
|
||||
|
||||
export async function fetchRun(
|
||||
sessionId: string,
|
||||
turnLimit?: number,
|
||||
turnOffset?: number,
|
||||
): Promise<RunDetail> {
|
||||
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<RunDetail>(query ? `${path}?${query}` : path);
|
||||
}
|
||||
|
||||
export async function fetchMathProposals(): Promise<MathProposalSummary[]> {
|
||||
const envelope = await apiFetch<ItemsEnvelope<MathProposalSummary>>("/math-proposals");
|
||||
return envelope.items;
|
||||
|
|
|
|||
|
|
@ -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<RunSummary[], WorkbenchApiError>({
|
||||
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<RunDetail, WorkbenchApiError>({
|
||||
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<EvalLaneSummary[]>({
|
||||
queryKey: ["api", "evals"],
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<Route path="replay/:artifactId?" element={<ReplayRoute />} />
|
||||
<Route path="proposals/:proposalId?" element={<ProposalsRoute />} />
|
||||
<Route path="evals/:laneId?" element={<EvalsRoute />} />
|
||||
<Route path="runs/:sessionId?" element={<RunsRoutePlaceholder />} />
|
||||
<Route path="runs/:sessionId?" element={<RunsRoute />} />
|
||||
<Route path="packs/:packId?" element={<PacksRoutePlaceholder />} />
|
||||
<Route path="vault" element={<VaultRoutePlaceholder />} />
|
||||
<Route path="audit" element={<AuditRoute />} />
|
||||
|
|
|
|||
|
|
@ -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: <RunsRoute />,
|
||||
path: "/runs/:sessionId?",
|
||||
initialEntry: "/runs",
|
||||
loadingLabel: "Loading runs...",
|
||||
emptyStatement: "No runs recorded yet. Use Chat to create evidence.",
|
||||
emptyCommand: "core chat",
|
||||
},
|
||||
{
|
||||
name: "Replay",
|
||||
element: <ReplayRoute />,
|
||||
|
|
|
|||
246
workbench-ui/src/app/runs/RunsRoute.test.tsx
Normal file
246
workbench-ui/src/app/runs/RunsRoute.test.tsx
Normal file
|
|
@ -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 (
|
||||
<>
|
||||
<span data-testid="location">{`${location.pathname}${location.search}`}</span>
|
||||
<span data-testid="nav-type">{navigationType}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderRoute(initialEntry = "/runs") {
|
||||
return render(
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
<MemoryRouter initialEntries={[initialEntry]}>
|
||||
<EvidenceProvider>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/runs/:sessionId?"
|
||||
element={
|
||||
<>
|
||||
<RunsRoute />
|
||||
<LocationProbe />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Route path="/trace/:turnId?" element={<LocationProbe />} />
|
||||
</Routes>
|
||||
</EvidenceProvider>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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/<sessionId> 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/<turn_id> — 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");
|
||||
});
|
||||
});
|
||||
378
workbench-ui/src/app/runs/RunsRoute.tsx
Normal file
378
workbench-ui/src/app/runs/RunsRoute.tsx
Normal file
|
|
@ -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<string, string> = {
|
||||
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 (
|
||||
<span className="inline-flex h-6 items-center rounded-md border border-[var(--color-border-subtle)] px-2 text-xs text-[var(--color-text-muted)]">
|
||||
no checkpoint
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex h-6 items-center gap-1 rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface-inset)] px-2 text-xs text-[var(--color-text-secondary)]">
|
||||
checkpoint
|
||||
{revision ? (
|
||||
<span className="font-mono text-[var(--color-text-muted)]">{revision}</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function RunRow({
|
||||
run,
|
||||
selected,
|
||||
focused,
|
||||
onSelect,
|
||||
}: {
|
||||
run: RunSummary;
|
||||
selected: boolean;
|
||||
focused: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
aria-current={selected ? "true" : undefined}
|
||||
onClick={onSelect}
|
||||
className={`grid w-full grid-cols-[minmax(0,1fr)_auto] items-start gap-3 border-b border-[var(--color-border-subtle)] px-3 py-2 text-left transition-colors hover:bg-[var(--color-surface-inset)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-inset focus-visible:outline-[var(--color-focus-ring)] ${
|
||||
selected ? "bg-[var(--color-selected-bg)]" : ""
|
||||
} ${
|
||||
selected
|
||||
? "border-l-2 border-l-[var(--color-selected-border)] pl-[10px]"
|
||||
: focused
|
||||
? "border-l-2 border-l-[var(--color-focus-ring)] pl-[10px]"
|
||||
: "border-l-2 border-l-transparent pl-[10px]"
|
||||
}`}
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm text-[var(--color-text-primary)]">
|
||||
{sourceLabel(run.source)}
|
||||
</span>
|
||||
<span className="mt-1 block truncate font-mono text-xs text-[var(--color-text-muted)]">
|
||||
{run.session_id}
|
||||
</span>
|
||||
<span className="mt-1 flex items-center gap-2 text-xs text-[var(--color-text-secondary)]">
|
||||
<span>{run.turn_count} turns</span>
|
||||
{run.updated_at ? (
|
||||
<>
|
||||
<span aria-hidden className="text-[var(--color-text-muted)]">·</span>
|
||||
<Timestamp iso={run.updated_at} format="relative" />
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
{run.evidence_gap ? (
|
||||
<span className="mt-1 block text-xs text-[var(--color-state-warning-text)]">
|
||||
evidence gap: {run.evidence_gap}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="justify-self-end">
|
||||
<CheckpointBadge present={run.checkpoint_present} revision={run.checkpoint_revision} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TurnRefRow({ turn }: { turn: RunTurnRef }) {
|
||||
const digest = digestPayload(turn.trace_hash);
|
||||
return (
|
||||
<Link
|
||||
to={turn.trace_path}
|
||||
className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3 rounded-md border border-[var(--color-border-subtle)] px-3 py-2 no-underline transition-colors hover:bg-[var(--color-surface-inset)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="flex items-center gap-1 text-sm text-[var(--color-text-primary)]">
|
||||
Turn #{turn.turn_id}
|
||||
<ArrowUpRight size={13} aria-hidden className="text-[var(--color-text-muted)]" />
|
||||
</span>
|
||||
<span className="mt-1 block text-xs text-[var(--color-text-secondary)]">
|
||||
<Timestamp iso={turn.timestamp} format="relative" />
|
||||
</span>
|
||||
<span className="mt-1 block truncate text-xs text-[var(--color-text-muted)]">
|
||||
{turn.surface_excerpt || "Not recorded."}
|
||||
</span>
|
||||
</span>
|
||||
<span className="justify-self-end">
|
||||
{digest ? (
|
||||
<DigestBadge digest={digest} truncate={12} />
|
||||
) : (
|
||||
<span className="font-mono text-xs text-[var(--color-text-muted)]">no hash</span>
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function TurnsTab({
|
||||
detail,
|
||||
onLoadMore,
|
||||
canLoadMore,
|
||||
}: {
|
||||
detail: RunDetail;
|
||||
onLoadMore: () => void;
|
||||
canLoadMore: boolean;
|
||||
}) {
|
||||
if (detail.turns.length === 0) {
|
||||
return (
|
||||
<p className="m-0 text-sm text-[var(--color-text-secondary)]">
|
||||
This run records no cross-linkable turns. Engine-state checkpoints
|
||||
expose their evidence under Manifest.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
{detail.turns.map((turn) => (
|
||||
<TurnRefRow key={turn.turn_id} turn={turn} />
|
||||
))}
|
||||
{canLoadMore ? (
|
||||
<Button type="button" variant="quiet" onClick={onLoadMore}>
|
||||
Load more turns
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ManifestTab({ detail }: { detail: RunDetail }) {
|
||||
if (!detail.manifest) {
|
||||
return (
|
||||
<p className="m-0 text-sm text-[var(--color-text-secondary)]">
|
||||
No engine-state manifest recorded for this run.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return <StableJsonViewer source={JSON.stringify(detail.manifest, null, 2)} />;
|
||||
}
|
||||
|
||||
function RawTab({ detail }: { detail: RunDetail }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
return expanded ? (
|
||||
<StableJsonViewer source={JSON.stringify(detail, null, 2)} />
|
||||
) : (
|
||||
<div className="grid justify-items-start gap-2">
|
||||
<p className="m-0 text-sm text-[var(--color-text-secondary)]">
|
||||
Raw run JSON is collapsed by default.
|
||||
</p>
|
||||
<Button type="button" variant="quiet" onClick={() => setExpanded(true)}>
|
||||
<Eye size={14} aria-hidden />
|
||||
Expand raw JSON
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RunDetailPanel({
|
||||
detail,
|
||||
onLoadMore,
|
||||
canLoadMore,
|
||||
}: {
|
||||
detail: RunDetail;
|
||||
onLoadMore: () => void;
|
||||
canLoadMore: boolean;
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState("turns");
|
||||
return (
|
||||
<Panel
|
||||
title={sourceLabel(detail.source)}
|
||||
toolbar={
|
||||
<CheckpointBadge
|
||||
present={detail.checkpoint_present}
|
||||
revision={detail.checkpoint_revision}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<TabBar tabs={RUN_TABS} activeTab={activeTab} onTabChange={setActiveTab}>
|
||||
{activeTab === "turns" ? (
|
||||
<TurnsTab detail={detail} onLoadMore={onLoadMore} canLoadMore={canLoadMore} />
|
||||
) : null}
|
||||
{activeTab === "manifest" ? <ManifestTab detail={detail} /> : null}
|
||||
{activeTab === "raw" ? <RawTab detail={detail} /> : null}
|
||||
</TabBar>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
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 <LoadingState label="Loading runs..." />;
|
||||
}
|
||||
|
||||
if (runsQuery.isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
whatFailed={errorMessage(runsQuery.error)}
|
||||
mutationStatus="No runs mutation occurred."
|
||||
reproducer="curl /runs"
|
||||
retrySafety="Retry: safe"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (runs.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
statement="No runs recorded yet. Use Chat to create evidence."
|
||||
nextAction={{ kind: "cli", command: "core chat" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const canLoadMore = !!runQuery.data && runQuery.data.turns.length === turnLimit;
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0">
|
||||
<SplitPane direction="horizontal" id="runs" defaultSplit={38} minSize={320}>
|
||||
<Panel title="Sessions">
|
||||
<div className="grid min-h-0 gap-3">
|
||||
<SearchInput
|
||||
placeholder="Filter by session or source"
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
/>
|
||||
{filteredRuns.length === 0 ? (
|
||||
<EmptyState
|
||||
statement="No runs match this filter."
|
||||
nextAction={{ kind: "cli", command: "core chat" }}
|
||||
/>
|
||||
) : (
|
||||
<VirtualizedList
|
||||
ariaLabel="Runs"
|
||||
estimateSize={92}
|
||||
getKey={(run) => run.session_id}
|
||||
height="calc(100vh - 14rem)"
|
||||
initialRect={{ width: 480, height: 560 }}
|
||||
items={filteredRuns}
|
||||
onActivate={(run) => selectRun(run)}
|
||||
renderItem={(run, _index, focused) => (
|
||||
<RunRow
|
||||
run={run}
|
||||
selected={run.session_id === selectedSessionId}
|
||||
focused={focused}
|
||||
onSelect={() => selectRun(run)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<section className="h-full min-h-0 overflow-y-auto pl-3">
|
||||
{selectedSessionId === null ? (
|
||||
<EmptyState
|
||||
statement="Select a session to inspect its turns, manifest, and checkpoint."
|
||||
nextAction={{ kind: "cli", command: "core chat" }}
|
||||
/>
|
||||
) : runQuery.isLoading ? (
|
||||
<LoadingState label="Loading run detail..." />
|
||||
) : runQuery.isError ? (
|
||||
<ErrorState
|
||||
whatFailed={errorMessage(runQuery.error)}
|
||||
mutationStatus="No runs mutation occurred."
|
||||
reproducer={`curl /runs/${selectedSessionId}`}
|
||||
retrySafety="Retry: safe"
|
||||
/>
|
||||
) : runQuery.data ? (
|
||||
<RunDetailPanel
|
||||
detail={runQuery.data}
|
||||
onLoadMore={() => setTurnLimit((n) => n + TURN_PAGE)}
|
||||
canLoadMore={canLoadMore}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
</SplitPane>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,10 +0,0 @@
|
|||
import { EmptyState } from "../design/components/states/EmptyState";
|
||||
|
||||
export function RunsRoutePlaceholder() {
|
||||
return (
|
||||
<EmptyState
|
||||
statement="Runs — no data loaded yet."
|
||||
nextAction="Pending W-030"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface AuditEvent {
|
||||
event_id: string;
|
||||
source: AuditSource;
|
||||
|
|
|
|||
Loading…
Reference in a new issue