feat(workbench): add trace route journal timeline
This commit is contained in:
parent
41755c001f
commit
aa6fb8ba90
12 changed files with 772 additions and 21 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
|||
);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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 />,
|
||||
|
|
|
|||
224
workbench-ui/src/app/trace/TraceRoute.test.tsx
Normal file
224
workbench-ui/src/app/trace/TraceRoute.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
388
workbench-ui/src/app/trace/TraceRoute.tsx
Normal file
388
workbench-ui/src/app/trace/TraceRoute.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue