Merge pull request #714 from AssetOverflow/feat/wb-r2-trace-route

feat(workbench): Trace route — turn evidence timeline over the journal (Wave R2-T)
This commit is contained in:
Shay 2026-06-12 16:41:39 -07:00 committed by GitHub
commit bd52d6b38a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 772 additions and 21 deletions

View file

@ -1,5 +1,12 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { apiFetch, fetchProposalDetail, fetchProposals, WorkbenchApiError } from "./client";
import {
apiFetch,
fetchProposalDetail,
fetchProposals,
fetchTraceTurn,
fetchTraceTurns,
WorkbenchApiError,
} from "./client";
import { proposalDetail, proposalSummaries } from "./__fixtures__/proposals";
describe("apiFetch", () => {
@ -102,3 +109,60 @@ describe("proposal fetchers", () => {
expect(init.method).toBeUndefined();
});
});
describe("trace fetchers", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("unwraps the /trace/turns items envelope with pagination parameters", async () => {
const items = [
{
turn_id: 7,
timestamp: "2026-06-12T00:00:00Z",
prompt_excerpt: "hello",
surface_excerpt: "world",
trace_hash: "sha256:abc",
grounding_source: "pack",
},
];
const fetchMock = vi.fn().mockResolvedValue({
json: vi.fn().mockResolvedValue({ ok: true, generated_at: "now", data: { items } }),
});
vi.stubGlobal("fetch", fetchMock);
await expect(fetchTraceTurns(25, 50)).resolves.toEqual(items);
expect(fetchMock.mock.calls[0][0]).toBe("http://127.0.0.1:8765/trace/turns?limit=25&offset=50");
});
it("fetches a trace turn detail without mutation options", async () => {
const turn = {
turn_id: 7,
timestamp: "2026-06-12T00:00:00Z",
trace_hash: "sha256:abc",
prompt: "hello",
surface: "world",
articulation_surface: "realizer",
walk_surface: "walk",
grounding_source: "pack",
epistemic_state: "evidenced",
normative_clearance: "cleared",
verdicts: {},
refusal_emitted: false,
hedge_injected: false,
proposal_candidates: [],
turn_cost_ms: 1,
checkpoint_emitted: false,
journal_digest: "sha256:def",
};
const fetchMock = vi.fn().mockResolvedValue({
json: vi.fn().mockResolvedValue({ ok: true, generated_at: "now", data: turn }),
});
vi.stubGlobal("fetch", fetchMock);
await expect(fetchTraceTurn(7)).resolves.toEqual(turn);
expect(fetchMock.mock.calls[0][0]).toBe("http://127.0.0.1:8765/trace/7");
const init = fetchMock.mock.calls[0][1] as RequestInit;
expect(init.method).toBeUndefined();
});
});

View file

@ -10,6 +10,8 @@ import type {
ProposalDetail,
ProposalState,
ProposalSummary,
TurnJournalEntry,
TurnJournalSummary,
MathProposalSummary,
MathProposalDetail,
MathRatifyResult,
@ -107,6 +109,24 @@ export async function fetchProposalDetail(proposalId: string): Promise<ProposalD
return apiFetch<ProposalDetail>(`/proposals/${encodeURIComponent(proposalId)}`);
}
export async function fetchTraceTurns(
limit?: number,
offset?: number,
): Promise<TurnJournalSummary[]> {
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<TurnJournalSummary>>(
query ? `/trace/turns?${query}` : "/trace/turns",
);
return envelope.items;
}
export async function fetchTraceTurn(turnId: number): Promise<TurnJournalEntry> {
return apiFetch<TurnJournalEntry>(`/trace/${encodeURIComponent(String(turnId))}`);
}
export async function fetchMathProposals(): Promise<MathProposalSummary[]> {
const envelope = await apiFetch<ItemsEnvelope<MathProposalSummary>>("/math-proposals");
return envelope.items;
@ -154,4 +174,3 @@ export async function deferMathProposal(
);
}

View file

@ -13,6 +13,8 @@ import {
fetchReplayComparison,
fetchProposalDetail,
fetchProposals,
fetchTraceTurn,
fetchTraceTurns,
fetchMathProposals,
fetchMathProposalDetail,
ratifyMathProposal,
@ -27,6 +29,8 @@ import type {
ArtifactDetail,
ProposalSummary,
ProposalDetail,
TurnJournalEntry,
TurnJournalSummary,
EvalLaneSummary,
EvalRunResult,
ChatTurnResult,
@ -108,6 +112,25 @@ export function useProposalDetail(proposalId: string) {
});
}
export function useTraceTurns(limit?: number, offset?: number) {
return useQuery<TurnJournalSummary[], WorkbenchApiError>({
queryKey: ["api", "trace", "turns", limit ?? null, offset ?? null],
queryFn: () => fetchTraceTurns(limit, offset),
staleTime: 30_000,
refetchOnWindowFocus: false,
});
}
export function useTraceTurn(turnId?: number | null) {
return useQuery<TurnJournalEntry, WorkbenchApiError>({
queryKey: ["api", "trace", "turn", turnId ?? null],
queryFn: () => fetchTraceTurn(turnId as number),
enabled: typeof turnId === "number",
staleTime: 30_000,
refetchOnWindowFocus: false,
});
}
export function useEvalLanes() {
return useQuery<EvalLaneSummary[]>({
queryKey: ["api", "evals"],
@ -216,4 +239,3 @@ export function useMathDefer() {
}

View file

@ -5,7 +5,7 @@ import { Shell } from "./Shell";
import { PreviewPage } from "../preview/PreviewPage";
import { ChatRoute } from "../routes/ChatRoute";
import { ProposalsRoute } from "./proposals/ProposalsRoute";
import { TraceRoutePlaceholder } from "../routes/TraceRoutePlaceholder";
import { TraceRoute } from "./trace/TraceRoute";
import { ReplayRoute } from "./replay/ReplayRoute";
import { EvalsRoute } from "./evals/EvalsRoute";
import { RunsRoutePlaceholder } from "../routes/RunsRoutePlaceholder";
@ -22,7 +22,7 @@ export function App() {
<Route path="/" element={<Shell />}>
<Route index element={<Navigate to="/chat" replace />} />
<Route path="chat" element={<ChatRoute />} />
<Route path="trace/:turnId?" element={<TraceRoutePlaceholder />} />
<Route path="trace/:turnId?" element={<TraceRoute />} />
<Route path="replay/:artifactId?" element={<ReplayRoute />} />
<Route path="proposals/:proposalId?" element={<ProposalsRoute />} />
<Route path="evals/:laneId?" element={<EvalsRoute />} />

View file

@ -65,7 +65,13 @@ export function deriveStages(subject: EvidenceSubject): RailStage[] | null {
"epistemic_state + normative_clearance",
),
stage("replay", d ? evidenceOf(d.trace_hash) : "hollow", "trace_hash recorded (not a verification claim)"),
stage("authority", d ? evidenceOf(d.mutation_mode) : "hollow", "mutation_mode"),
stage(
"authority",
d
? evidenceOf("mutation_mode" in d ? d.mutation_mode : d.checkpoint_emitted)
: "hollow",
"mutation_mode / checkpoint_emitted",
),
stage("action", "dim", "not applicable to a completed turn"),
];
}

View file

@ -7,6 +7,7 @@ import {
} from "react";
import type {
ChatTurnResult,
TurnJournalEntry,
ProposalDetail,
ArtifactDetail,
EvalRunResult,
@ -16,7 +17,7 @@ import type {
// until the owning route's query loads its detail. Inspectors must render
// an honest "detail not loaded" state when data is absent.
export type EvidenceSubject =
| { kind: "turn"; turnId: number; data?: ChatTurnResult }
| { kind: "turn"; turnId: number; data?: ChatTurnResult | TurnJournalEntry }
| { kind: "proposal"; proposalId: string; data?: ProposalDetail }
| { kind: "artifact"; artifactId: string; data?: ArtifactDetail }
| { kind: "eval_result"; lane: string; data?: EvalRunResult }

View file

@ -8,6 +8,7 @@ import { createTestQueryClient } from "../test/createTestQueryClient";
import { EvidenceProvider } from "./evidenceContext";
import { ChatRoute } from "../routes/ChatRoute";
import { ProposalsRoute } from "./proposals/ProposalsRoute";
import { TraceRoute } from "./trace/TraceRoute";
import { EvalsRoute } from "./evals/EvalsRoute";
import { ReplayRoute } from "./replay/ReplayRoute";
@ -104,6 +105,15 @@ interface MountRouteSpec {
}
const MOUNT_ROUTES: MountRouteSpec[] = [
{
name: "Trace",
element: <TraceRoute />,
path: "/trace/:turnId?",
initialEntry: "/trace",
loadingLabel: "Loading trace...",
emptyStatement: "No turns recorded yet. Use Chat to create evidence.",
emptyCommand: "core chat",
},
{
name: "Proposals",
element: <ProposalsRoute />,

View file

@ -0,0 +1,224 @@
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 { TurnJournalEntry, TurnJournalSummary } from "../../types/api";
import { EvidenceProvider } from "../evidenceContext";
import { TraceRoute } from "./TraceRoute";
const summaries: TurnJournalSummary[] = [
{
turn_id: 1,
timestamp: "2026-06-12T18:00:00Z",
prompt_excerpt: "First prompt\nwith more",
surface_excerpt: "First response",
trace_hash: "sha256:111111111111abcdef",
grounding_source: "pack",
},
{
turn_id: 2,
timestamp: "2026-06-12T18:01:00Z",
prompt_excerpt: "Second prompt",
surface_excerpt: "Second response",
trace_hash: "sha256:222222222222abcdef",
grounding_source: "teaching",
},
{
turn_id: 3,
timestamp: "2026-06-12T18:02:00Z",
prompt_excerpt: "Third prompt",
surface_excerpt: "Third response",
trace_hash: "sha256:333333333333abcdef",
grounding_source: "vault",
},
];
function entry(id: number): TurnJournalEntry {
const summary = summaries.find((item) => item.turn_id === id) ?? summaries[0];
return {
turn_id: summary.turn_id,
timestamp: summary.timestamp,
trace_hash: summary.trace_hash,
prompt: `${summary.prompt_excerpt} full text`,
surface: `User response for turn ${summary.turn_id}`,
articulation_surface: `Realizer surface for turn ${summary.turn_id}`,
walk_surface: `Walk evidence for turn ${summary.turn_id}`,
grounding_source: summary.grounding_source,
epistemic_state: "evidenced",
normative_clearance: "cleared",
verdicts: {
identity: { outcome: "cleared", runtime_detail: "identity ok" },
safety: { outcome: "cleared", runtime_detail: "safety ok" },
ethics: { outcome: "cleared", runtime_detail: "ethics ok" },
},
refusal_emitted: false,
hedge_injected: false,
proposal_candidates: [{ candidate_id: "candidate-1", source_kind: "discovery" }],
turn_cost_ms: 17,
checkpoint_emitted: true,
journal_digest: `sha256:journal${summary.turn_id}abcdef`,
};
}
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 = "/trace") {
return render(
<QueryClientProvider client={createTestQueryClient()}>
<MemoryRouter initialEntries={[initialEntry]}>
<EvidenceProvider>
<Routes>
<Route
path="/trace/:turnId?"
element={
<>
<TraceRoute />
<LocationProbe />
</>
}
/>
</Routes>
</EvidenceProvider>
</MemoryRouter>
</QueryClientProvider>,
);
}
function stubTraceFetch(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(/^\/trace\/(\d+)$/);
if (match) {
return Promise.resolve({
json: () =>
Promise.resolve({ ok: true, generated_at: "now", data: entry(Number(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("TraceRoute", () => {
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 timeline from journal summaries", async () => {
stubTraceFetch();
renderRoute();
expect(await screen.findByText("First prompt")).toBeInTheDocument();
expect(screen.getByText("Second prompt")).toBeInTheDocument();
expect(screen.getByText("sha256:111111111111...")).toBeInTheDocument();
});
it("selecting a turn writes /trace/<turnId> with replace and shows evidence", async () => {
stubTraceFetch();
const user = userEvent.setup();
renderRoute();
await user.click(await screen.findByText("First prompt"));
await waitFor(() => expect(screen.getByTestId("location")).toHaveTextContent("/trace/1"));
expect(screen.getByTestId("nav-type")).toHaveTextContent("REPLACE");
expect(await screen.findByText("User response for turn 1")).toBeInTheDocument();
});
it("renders the three surface labels distinctly", async () => {
stubTraceFetch();
renderRoute("/trace/2");
expect(await screen.findByText("User Surface (response)")).toBeInTheDocument();
expect(screen.getByText("Articulation Surface (realizer)")).toBeInTheDocument();
expect(screen.getByText("Walk Surface (telemetry/evidence)")).toBeInTheDocument();
});
it("keeps raw JSON collapsed by default", async () => {
stubTraceFetch();
const user = userEvent.setup();
renderRoute("/trace/2");
await user.click(await screen.findByRole("tab", { name: "Raw" }));
expect(screen.getByText("Raw journal JSON is collapsed by default.")).toBeInTheDocument();
expect(screen.queryByTestId("json-rows")).not.toBeInTheDocument();
});
it("restores selection from a deep-linked turn id", async () => {
stubTraceFetch();
renderRoute("/trace/3");
expect(await screen.findByText("User response for turn 3")).toBeInTheDocument();
expect(screen.getByText("Third prompt").closest('[aria-current="true"]')).not.toBeNull();
});
it("moves timeline focus with j/k through the VirtualizedList keyboard spine", async () => {
stubTraceFetch();
const user = userEvent.setup();
renderRoute();
const list = await screen.findByRole("listbox", { name: "Trace 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");
await user.keyboard("k");
expect(screen.getAllByRole("option")[0]).toHaveAttribute("aria-selected", "true");
});
});

View file

@ -0,0 +1,388 @@
import { useEffect, useMemo, useState } from "react";
import { Eye } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom";
import { WorkbenchApiError } from "../../api/client";
import { useTraceTurn, useTraceTurns } from "../../api/queries";
import { DigestBadge } from "../../design/components/DigestBadge/DigestBadge";
import { MetadataTable } from "../../design/components/MetadataTable/MetadataTable";
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 {
EpistemicStateBadge,
GroundingSourceBadge,
NormativeClearanceBadge,
type EpistemicState,
type GroundingSource,
type NormativeClearance,
} from "../../design/components/badges";
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 { TurnJournalEntry, TurnJournalSummary } from "../../types/api";
import { pushRecentItem } from "../commandRegistry";
import { subjectToUrl } from "../evidenceAddress";
import { useEvidenceSubject } from "../evidenceContext";
const TRACE_TABS: readonly Tab[] = [
{ id: "surfaces", label: "Surfaces" },
{ id: "grounding", label: "Grounding" },
{ id: "verdicts", label: "Verdicts" },
{ id: "metadata", label: "Metadata" },
{ id: "raw", label: "Raw" },
];
function parseTurnId(raw: string | undefined): number | null {
if (!raw || !/^\d+$/.test(raw)) return null;
const value = Number(raw);
return Number.isSafeInteger(value) ? value : null;
}
function errorMessage(error: unknown) {
return error instanceof WorkbenchApiError ? error.message : "Trace journal request failed.";
}
function digestPayload(value: string | null | undefined): string | null {
if (!value) return null;
return value.replace(/^sha256:/, "");
}
function firstLine(value: string): string {
return value.split(/\r?\n/, 1)[0] || "";
}
function surfaceText(value: string | null): string {
return value && value.trim() ? value : "Not recorded.";
}
function proposalCandidateLabel(candidate: Record<string, unknown>): string {
const id = candidate.candidate_id;
const source = candidate.source_kind;
if (typeof id === "string" && typeof source === "string") return `${id} (${source})`;
if (typeof id === "string") return id;
return JSON.stringify(candidate);
}
function asVerdict(value: unknown): { outcome: string; runtime_detail: string } | null {
if (!value || typeof value !== "object") return null;
const record = value as Record<string, unknown>;
return {
outcome: typeof record.outcome === "string" ? record.outcome : "unassessable",
runtime_detail: typeof record.runtime_detail === "string" ? record.runtime_detail : "",
};
}
function SurfaceCard({
label,
value,
}: {
label: string;
value: string | null;
}) {
return (
<section className="rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface-inset)] p-3">
<h3 className="m-0 text-xs font-semibold text-[var(--color-text-secondary)]">
{label}
</h3>
<pre className="mt-2 max-h-52 overflow-auto whitespace-pre-wrap break-words font-mono text-xs leading-5 text-[var(--color-text-primary)]">
{surfaceText(value)}
</pre>
</section>
);
}
function TraceRow({
turn,
selected,
focused,
onSelect,
}: {
turn: TurnJournalSummary;
selected: boolean;
focused: boolean;
onSelect: () => void;
}) {
const digest = digestPayload(turn.trace_hash);
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-xs text-[var(--color-text-secondary)]">
<Timestamp iso={turn.timestamp} format="relative" />
</span>
<span className="mt-1 block truncate text-sm text-[var(--color-text-primary)]">
{firstLine(turn.prompt_excerpt) || `Turn #${turn.turn_id}`}
</span>
<span className="mt-1 block truncate text-xs text-[var(--color-text-muted)]">
{turn.surface_excerpt}
</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>
</div>
);
}
function SurfacesTab({ turn }: { turn: TurnJournalEntry }) {
return (
<div className="grid gap-3">
<SurfaceCard label="User Surface (response)" value={turn.surface} />
<SurfaceCard label="Articulation Surface (realizer)" value={turn.articulation_surface} />
<SurfaceCard label="Walk Surface (telemetry/evidence)" value={turn.walk_surface} />
</div>
);
}
function GroundingTab({ turn }: { turn: TurnJournalEntry }) {
return (
<MetadataTable
rows={[
{
key: "grounding_source",
value: <GroundingSourceBadge value={turn.grounding_source as GroundingSource} />,
},
{
key: "epistemic_state",
value: <EpistemicStateBadge value={turn.epistemic_state as EpistemicState} />,
},
{
key: "normative_clearance",
value: <NormativeClearanceBadge value={turn.normative_clearance as NormativeClearance} />,
},
]}
/>
);
}
function VerdictsTab({ turn }: { turn: TurnJournalEntry }) {
const identity = asVerdict(turn.verdicts.identity);
const safety = asVerdict(turn.verdicts.safety);
const ethics = asVerdict(turn.verdicts.ethics);
return (
<MetadataTable
rows={[
{ key: "identity", value: identity ? identity.outcome : "not recorded" },
{ key: "identity_detail", value: identity?.runtime_detail || "none" },
{ key: "safety", value: safety ? safety.outcome : "not recorded" },
{ key: "safety_detail", value: safety?.runtime_detail || "none" },
{ key: "ethics", value: ethics ? ethics.outcome : "not recorded" },
{ key: "ethics_detail", value: ethics?.runtime_detail || "none" },
{ key: "refusal_emitted", value: turn.refusal_emitted ? "yes" : "no" },
{ key: "hedge_injected", value: turn.hedge_injected ? "yes" : "no" },
]}
/>
);
}
function MetadataTab({ turn }: { turn: TurnJournalEntry }) {
const traceDigest = digestPayload(turn.trace_hash);
const journalDigest = digestPayload(turn.journal_digest);
return (
<MetadataTable
rows={[
{ key: "turn_id", value: String(turn.turn_id), mono: true, copyable: true },
{ key: "timestamp", value: <Timestamp iso={turn.timestamp} /> },
{ key: "turn_cost_ms", value: `${turn.turn_cost_ms}ms`, mono: true },
{ key: "checkpoint_emitted", value: turn.checkpoint_emitted ? "yes" : "no" },
{
key: "trace_hash",
value: traceDigest ? <DigestBadge digest={traceDigest} truncate={12} /> : "not recorded",
},
{
key: "journal_digest",
value: journalDigest ? <DigestBadge digest={journalDigest} truncate={12} /> : "not recorded",
},
{
key: "proposal_candidates",
value:
turn.proposal_candidates.length > 0
? turn.proposal_candidates.map(proposalCandidateLabel).join(", ")
: "none",
},
]}
/>
);
}
function RawTab({ turn }: { turn: TurnJournalEntry }) {
const [expanded, setExpanded] = useState(false);
return expanded ? (
<StableJsonViewer source={JSON.stringify(turn, null, 2)} />
) : (
<div className="grid justify-items-start gap-2">
<p className="m-0 text-sm text-[var(--color-text-secondary)]">
Raw journal 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 TraceDetail({ turn }: { turn: TurnJournalEntry }) {
const [activeTab, setActiveTab] = useState("surfaces");
return (
<Panel
title={`Turn #${turn.turn_id}`}
toolbar={
turn.trace_hash ? (
<DigestBadge digest={digestPayload(turn.trace_hash) ?? ""} truncate={12} />
) : null
}
>
<TabBar tabs={TRACE_TABS} activeTab={activeTab} onTabChange={setActiveTab}>
{activeTab === "surfaces" ? <SurfacesTab turn={turn} /> : null}
{activeTab === "grounding" ? <GroundingTab turn={turn} /> : null}
{activeTab === "verdicts" ? <VerdictsTab turn={turn} /> : null}
{activeTab === "metadata" ? <MetadataTab turn={turn} /> : null}
{activeTab === "raw" ? <RawTab turn={turn} /> : null}
</TabBar>
</Panel>
);
}
export function TraceRoute() {
const { turnId } = useParams();
const selectedTurnId = parseTurnId(turnId);
const navigate = useNavigate();
const { setSubject } = useEvidenceSubject();
const [search, setSearch] = useState("");
const turnsQuery = useTraceTurns();
const turnQuery = useTraceTurn(selectedTurnId);
const turns = turnsQuery.data ?? [];
const filteredTurns = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return turns;
return turns.filter((turn) => {
const trace = turn.trace_hash?.replace(/^sha256:/, "").toLowerCase() ?? "";
return (
turn.prompt_excerpt.toLowerCase().includes(q) ||
trace.startsWith(q) ||
trace.includes(q)
);
});
}, [search, turns]);
useEffect(() => {
if (selectedTurnId === null) return;
setSubject({ kind: "turn", turnId: selectedTurnId, data: turnQuery.data });
}, [selectedTurnId, setSubject, turnQuery.data]);
function selectTurn(turn: TurnJournalSummary) {
const subject = { kind: "turn" as const, turnId: turn.turn_id };
const path = subjectToUrl(subject);
navigate(path, { replace: true });
pushRecentItem({ label: `Turn #${turn.turn_id}`, path });
}
if (turnsQuery.isLoading) {
return <LoadingState label="Loading trace..." />;
}
if (turnsQuery.isError) {
return (
<ErrorState
whatFailed={errorMessage(turnsQuery.error)}
mutationStatus="No trace mutation occurred."
reproducer="curl /trace/turns"
retrySafety="Retry: safe"
/>
);
}
if (turns.length === 0) {
return (
<EmptyState
statement="No turns recorded yet. Use Chat to create evidence."
nextAction={{ kind: "cli", command: "core chat" }}
/>
);
}
return (
<div className="h-full min-h-0">
<SplitPane direction="horizontal" id="trace" defaultSplit={38} minSize={320}>
<Panel title="Turn Timeline">
<div className="grid min-h-0 gap-3">
<SearchInput
placeholder="Filter by prompt or trace hash"
value={search}
onChange={setSearch}
/>
{filteredTurns.length === 0 ? (
<EmptyState
statement="No turns match this trace filter."
nextAction={{ kind: "cli", command: "core chat" }}
/>
) : (
<VirtualizedList
ariaLabel="Trace turns"
estimateSize={84}
getKey={(turn) => String(turn.turn_id)}
height="calc(100vh - 14rem)"
initialRect={{ width: 480, height: 560 }}
items={filteredTurns}
onActivate={(turn) => selectTurn(turn)}
renderItem={(turn, _index, focused) => (
<TraceRow
turn={turn}
selected={turn.turn_id === selectedTurnId}
focused={focused}
onSelect={() => selectTurn(turn)}
/>
)}
/>
)}
</div>
</Panel>
<section className="h-full min-h-0 overflow-y-auto pl-3">
{selectedTurnId === null ? (
<EmptyState
statement="Select a turn to inspect surfaces, grounding, verdicts, and metadata."
nextAction={{ kind: "cli", command: "core chat" }}
/>
) : turnQuery.isLoading ? (
<LoadingState label="Loading trace turn..." />
) : turnQuery.isError ? (
<ErrorState
whatFailed={errorMessage(turnQuery.error)}
mutationStatus="No trace mutation occurred."
reproducer={`curl /trace/${selectedTurnId}`}
retrySafety="Retry: safe"
/>
) : turnQuery.data ? (
<TraceDetail turn={turnQuery.data} />
) : null}
</section>
</SplitPane>
</div>
);
}

View file

@ -20,9 +20,6 @@ import { describe, expect, it } from "vitest";
*/
const NOT_YET_MIRRORED = new Set([
// Turn journal (R0 brief 1) — TS mirrors land with the R2 Trace route:
"TurnJournalEntrySchema",
"TurnJournalSummarySchema",
// R2-B backend read substrate (#712) — TS mirrors land with each R2 route:
"PackSummary",
"PackDetail",

View file

@ -1,10 +0,0 @@
import { EmptyState } from "../design/components/states/EmptyState";
export function TraceRoutePlaceholder() {
return (
<EmptyState
statement="Trace — no data loaded yet."
nextAction={{ kind: "cli", command: "core trace <prompt>" }}
/>
);
}

View file

@ -76,6 +76,37 @@ export interface ChatTurnResult {
checkpoint_emitted: boolean;
}
export interface TurnJournalSummary {
turn_id: number;
timestamp: string;
prompt_excerpt: string;
surface_excerpt: string;
trace_hash: string | null;
grounding_source: GroundingSource;
}
export interface TurnJournalEntry {
turn_id: number;
timestamp: string;
trace_hash: string | null;
prompt: string;
surface: string;
articulation_surface: string | null;
walk_surface: string | null;
grounding_source: GroundingSource;
epistemic_state: EpistemicState;
normative_clearance: NormativeClearance;
verdicts: Record<string, unknown>;
refusal_emitted: boolean;
hedge_injected: boolean;
proposal_candidates: Record<string, unknown>[];
turn_cost_ms: number;
checkpoint_emitted: boolean;
journal_digest: string;
}
export type TurnEvidence = ChatTurnResult | TurnJournalEntry;
export type ArtifactKind =
| "trace"
| "eval_result"
@ -226,4 +257,3 @@ export interface MathRatifyResult {
target_path: string | null;
evidence_hash: string | null;
}