feat(workbench): Audit route — event timeline with mutation-boundary weighting (Wave R2-A)

This commit is contained in:
Shay 2026-06-12 16:53:03 -07:00
parent bd52d6b38a
commit d48d040467
12 changed files with 582 additions and 15 deletions

View file

@ -10,6 +10,7 @@ import type {
ProposalDetail,
ProposalState,
ProposalSummary,
AuditEvent,
TurnJournalEntry,
TurnJournalSummary,
MathProposalSummary,
@ -127,6 +128,17 @@ export async function fetchTraceTurn(turnId: number): Promise<TurnJournalEntry>
return apiFetch<TurnJournalEntry>(`/trace/${encodeURIComponent(String(turnId))}`);
}
export async function fetchAuditEvents(
limit?: number,
offset?: number,
): Promise<ItemsEnvelope<AuditEvent>> {
const params = new URLSearchParams();
if (limit !== undefined) params.set("limit", String(limit));
if (offset !== undefined) params.set("offset", String(offset));
const query = params.toString();
return apiFetch<ItemsEnvelope<AuditEvent>>(query ? `/audit/events?${query}` : "/audit/events");
}
export async function fetchMathProposals(): Promise<MathProposalSummary[]> {
const envelope = await apiFetch<ItemsEnvelope<MathProposalSummary>>("/math-proposals");
return envelope.items;
@ -173,4 +185,3 @@ export async function deferMathProposal(
},
);
}

View file

@ -13,6 +13,7 @@ import {
fetchReplayComparison,
fetchProposalDetail,
fetchProposals,
fetchAuditEvents,
fetchTraceTurn,
fetchTraceTurns,
fetchMathProposals,
@ -29,6 +30,7 @@ import type {
ArtifactDetail,
ProposalSummary,
ProposalDetail,
AuditEvent,
TurnJournalEntry,
TurnJournalSummary,
EvalLaneSummary,
@ -131,6 +133,15 @@ export function useTraceTurn(turnId?: number | null) {
});
}
export function useAuditEvents(limit?: number, offset?: number) {
return useQuery<{ items: AuditEvent[] }, WorkbenchApiError>({
queryKey: ["api", "audit", "events", limit ?? null, offset ?? null],
queryFn: () => fetchAuditEvents(limit, offset),
staleTime: 30_000,
refetchOnWindowFocus: false,
});
}
export function useEvalLanes() {
return useQuery<EvalLaneSummary[]>({
queryKey: ["api", "evals"],
@ -238,4 +249,3 @@ export function useMathDefer() {
});
}

View file

@ -6,12 +6,12 @@ import { PreviewPage } from "../preview/PreviewPage";
import { ChatRoute } from "../routes/ChatRoute";
import { ProposalsRoute } from "./proposals/ProposalsRoute";
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 { PacksRoutePlaceholder } from "../routes/PacksRoutePlaceholder";
import { VaultRoutePlaceholder } from "../routes/VaultRoutePlaceholder";
import { AuditRoutePlaceholder } from "../routes/AuditRoutePlaceholder";
import { SettingsRoutePlaceholder } from "../routes/SettingsRoutePlaceholder";
export function App() {
@ -29,7 +29,7 @@ export function App() {
<Route path="runs" element={<RunsRoutePlaceholder />} />
<Route path="packs" element={<PacksRoutePlaceholder />} />
<Route path="vault" element={<VaultRoutePlaceholder />} />
<Route path="audit" element={<AuditRoutePlaceholder />} />
<Route path="audit" element={<AuditRoute />} />
<Route path="settings" element={<SettingsRoutePlaceholder />} />
</Route>
<Route path="/preview" element={<PreviewPage />} />

View file

@ -0,0 +1,192 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createTestQueryClient } from "../../test/createTestQueryClient";
import type { AuditEvent } from "../../types/api";
import { AuditRoute } from "./AuditRoute";
const events: AuditEvent[] = [
{
event_id: "audit-1",
source: "operator_telemetry",
source_path: "engine_state/telemetry.jsonl",
timestamp: "2026-06-12T18:00:00Z",
event_type: "telemetry_recorded",
mutation_boundary: false,
summary: "Operator telemetry recorded.",
ref_id: "telemetry-1",
payload_digest: "sha256:111111111111abcdef",
payload: { value: 1 },
},
{
event_id: "audit-2",
source: "teaching_proposal_log",
source_path: "teaching/proposals.jsonl",
timestamp: "2026-06-12T18:01:00Z",
event_type: "teaching_reviewed",
mutation_boundary: true,
summary: "Reviewed teaching proposal reached mutation boundary.",
ref_id: "proposal-1",
payload_digest: "sha256:222222222222abcdef",
payload: { value: 2 },
},
];
const nextPage: AuditEvent[] = [
{
event_id: "audit-next-1",
source: "math_proposal_log",
source_path: "math/proposals.jsonl",
timestamp: "2026-06-12T18:02:00Z",
event_type: "math_proposal_recorded",
mutation_boundary: false,
summary: "Math proposal recorded for review.",
ref_id: "math-1",
payload_digest: "sha256:333333333333abcdef",
payload: { value: 3 },
},
];
const offsetDescriptors = {
offsetHeight: Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight"),
offsetWidth: Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetWidth"),
};
function okEnvelope(items: AuditEvent[]) {
return {
ok: true,
generated_at: "2026-06-12T18:00:00Z",
data: { items, limit: 50, offset: 0 },
};
}
function stubAuditFetch(pages: Record<string, AuditEvent[]> = { "0": events }) {
const fetchMock = vi.fn((input: unknown) => {
const url = new URL(String(input));
const offset = url.searchParams.get("offset") ?? "0";
return Promise.resolve({
json: () => Promise.resolve(okEnvelope(pages[offset] ?? [])),
});
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
function renderRoute() {
return render(
<QueryClientProvider client={createTestQueryClient()}>
<AuditRoute />
</QueryClientProvider>,
);
}
describe("AuditRoute", () => {
beforeEach(() => {
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
configurable: true,
get: () => 560,
});
Object.defineProperty(HTMLElement.prototype, "offsetWidth", {
configurable: true,
get: () => 720,
});
});
afterEach(() => {
if (offsetDescriptors.offsetHeight) {
Object.defineProperty(HTMLElement.prototype, "offsetHeight", offsetDescriptors.offsetHeight);
}
if (offsetDescriptors.offsetWidth) {
Object.defineProperty(HTMLElement.prototype, "offsetWidth", offsetDescriptors.offsetWidth);
}
vi.restoreAllMocks();
});
it("renders audit events in API order", async () => {
stubAuditFetch();
renderRoute();
expect(await screen.findByText("Operator telemetry recorded.")).toBeInTheDocument();
const options = screen.getAllByRole("option");
expect(options[0]).toHaveTextContent("Operator telemetry recorded.");
expect(options[1]).toHaveTextContent("Reviewed teaching proposal reached mutation boundary.");
});
it("filters by source or summary", async () => {
stubAuditFetch();
const user = userEvent.setup();
renderRoute();
await screen.findByText("Operator telemetry recorded.");
await user.type(screen.getByLabelText("Filter by source or summary"), "teaching");
await waitFor(() => {
expect(screen.queryByText("Operator telemetry recorded.")).not.toBeInTheDocument();
});
expect(screen.getByText("Reviewed teaching proposal reached mutation boundary.")).toBeInTheDocument();
});
it("weights mutation-boundary events with a visible label", async () => {
stubAuditFetch();
renderRoute();
const label = await screen.findByText("Mutation boundary");
expect(label.closest("article")).toHaveClass("border-l-[var(--color-selected-border)]");
});
it("loads another API page without re-sorting existing events", async () => {
const firstPage = Array.from({ length: 50 }, (_, index): AuditEvent => ({
...events[index % events.length],
event_id: `audit-${index}`,
summary: `Event ${index}`,
}));
const fetchMock = stubAuditFetch({ "0": firstPage, "50": nextPage });
const user = userEvent.setup();
renderRoute();
await user.click(await screen.findByRole("button", { name: "Load more" }));
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
expect(String(fetchMock.mock.calls[1][0])).toContain("offset=50");
await waitFor(() => expect(screen.getByText(/51 events/)).toBeInTheDocument());
expect(screen.getAllByRole("option")[0]).toHaveTextContent("Event 0");
});
it("renders the empty next action", async () => {
stubAuditFetch({ "0": [] });
renderRoute();
expect(await screen.findByText("No audit events recorded.")).toBeInTheDocument();
expect(screen.getByText("core audit events")).toBeInTheDocument();
});
it("renders the error contract", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() =>
Promise.resolve({
json: () =>
Promise.resolve({
ok: false,
generated_at: "now",
error: { code: "read_error", message: "synthetic audit failure" },
}),
}),
),
);
renderRoute();
expect(await screen.findByText("What failed")).toBeInTheDocument();
expect(screen.getByText("No audit mutation occurred.")).toBeInTheDocument();
expect(screen.getByText("curl /audit/events")).toBeInTheDocument();
});
it("renders the specific loading state", async () => {
vi.stubGlobal("fetch", vi.fn(() => new Promise<never>(() => {})));
renderRoute();
expect(await screen.findByText("Loading audit events...")).toBeInTheDocument();
expect(screen.queryByText(/thinking/i)).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,134 @@
import { useEffect, useMemo, useState } from "react";
import { WorkbenchApiError } from "../../api/client";
import { useAuditEvents } from "../../api/queries";
import { Panel } from "../../design/components/Panel/Panel";
import { SearchInput } from "../../design/components/SearchInput/SearchInput";
import { Timeline, type TimelineEntry } from "../../design/components/Timeline";
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 { AuditEvent } from "../../types/api";
const PAGE_SIZE = 50;
function errorMessage(error: unknown) {
return error instanceof WorkbenchApiError ? error.message : "Audit event request failed.";
}
function toTimelineEntry(event: AuditEvent): TimelineEntry {
return {
id: event.event_id,
timestamp: event.timestamp,
source: event.source,
summary: event.summary,
mutationBoundary: event.mutation_boundary,
};
}
function eventMatches(event: AuditEvent, query: string): boolean {
const q = query.trim().toLowerCase();
if (!q) return true;
return event.source.toLowerCase().includes(q) || event.summary.toLowerCase().includes(q);
}
export function AuditRoute() {
const [offset, setOffset] = useState(0);
const [events, setEvents] = useState<AuditEvent[]>([]);
const [search, setSearch] = useState("");
const [selectedEventId, setSelectedEventId] = useState<string | null>(null);
const eventsQuery = useAuditEvents(PAGE_SIZE, offset);
useEffect(() => {
const page = eventsQuery.data?.items;
if (!page) return;
setEvents((current) => {
if (offset === 0) return page;
const seen = new Set(current.map((event) => event.event_id));
return [...current, ...page.filter((event) => !seen.has(event.event_id))];
});
}, [eventsQuery.data, offset]);
const filteredEvents = useMemo(
() => events.filter((event) => eventMatches(event, search)),
[events, search],
);
const timelineEntries = useMemo(
() => filteredEvents.map(toTimelineEntry),
[filteredEvents],
);
const hasMore = (eventsQuery.data?.items.length ?? 0) === PAGE_SIZE;
const isInitialLoading = events.length === 0 && eventsQuery.isLoading;
if (isInitialLoading) {
return <LoadingState label="Loading audit events..." />;
}
if (eventsQuery.isError) {
return (
<ErrorState
whatFailed={errorMessage(eventsQuery.error)}
mutationStatus="No audit mutation occurred."
reproducer="curl /audit/events"
retrySafety="Retry: safe"
/>
);
}
if (events.length === 0) {
return (
<EmptyState
statement="No audit events recorded."
nextAction={{ kind: "cli", command: "core audit events" }}
/>
);
}
return (
<Panel
title="Audit Timeline"
toolbar={
<span className="font-mono text-xs text-[var(--color-text-muted)]">
{events.length} events
</span>
}
>
<div className="grid min-h-0 gap-3">
<SearchInput
placeholder="Filter by source or summary"
value={search}
onChange={setSearch}
/>
{timelineEntries.length === 0 ? (
<EmptyState
statement="No audit events match this filter."
nextAction={{ kind: "cli", command: "core audit events" }}
/>
) : (
<Timeline
ariaLabel="Audit events"
entries={timelineEntries}
height="calc(100vh - 14rem)"
initialRect={{ width: 720, height: 560 }}
selectedId={selectedEventId}
onSelect={(entry) => setSelectedEventId(entry.id)}
/>
)}
{hasMore ? (
<div className="flex justify-start">
<Button
type="button"
variant="quiet"
disabled={eventsQuery.isFetching}
onClick={() => setOffset((current) => current + PAGE_SIZE)}
>
{eventsQuery.isFetching ? "Loading more..." : "Load more"}
</Button>
</div>
) : null}
</div>
</Panel>
);
}

View file

@ -9,6 +9,7 @@ import { EvidenceProvider } from "./evidenceContext";
import { ChatRoute } from "../routes/ChatRoute";
import { ProposalsRoute } from "./proposals/ProposalsRoute";
import { TraceRoute } from "./trace/TraceRoute";
import { AuditRoute } from "./audit/AuditRoute";
import { EvalsRoute } from "./evals/EvalsRoute";
import { ReplayRoute } from "./replay/ReplayRoute";
@ -105,6 +106,15 @@ interface MountRouteSpec {
}
const MOUNT_ROUTES: MountRouteSpec[] = [
{
name: "Audit",
element: <AuditRoute />,
path: "/audit",
initialEntry: "/audit",
loadingLabel: "Loading audit events...",
emptyStatement: "No audit events recorded.",
emptyCommand: "core audit events",
},
{
name: "Trace",
element: <TraceRoute />,

View file

@ -0,0 +1,93 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TIMELINE_PREVIEW_ENTRY, Timeline, type TimelineEntry } from "./Timeline";
const entries: TimelineEntry[] = [
TIMELINE_PREVIEW_ENTRY,
{
id: "mutation-1",
timestamp: "2026-06-12T18:01:00Z",
source: "teaching_proposal_log",
summary: "Reviewed teaching proposal crossed a mutation boundary.",
mutationBoundary: true,
},
];
const offsetDescriptors = {
offsetHeight: Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight"),
offsetWidth: Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetWidth"),
};
describe("Timeline", () => {
beforeEach(() => {
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
configurable: true,
get: () => 360,
});
Object.defineProperty(HTMLElement.prototype, "offsetWidth", {
configurable: true,
get: () => 520,
});
});
afterEach(() => {
if (offsetDescriptors.offsetHeight) {
Object.defineProperty(HTMLElement.prototype, "offsetHeight", offsetDescriptors.offsetHeight);
}
if (offsetDescriptors.offsetWidth) {
Object.defineProperty(HTMLElement.prototype, "offsetWidth", offsetDescriptors.offsetWidth);
}
vi.restoreAllMocks();
});
it("renders entries in the delivered order", () => {
render(
<Timeline
ariaLabel="Audit timeline"
entries={entries}
height={320}
initialRect={{ width: 520, height: 360 }}
/>,
);
const options = screen.getAllByRole("option");
expect(options[0]).toHaveTextContent("Operator telemetry recorded without mutation.");
expect(options[1]).toHaveTextContent("Reviewed teaching proposal crossed a mutation boundary.");
});
it("labels mutation-boundary entries with selected-token border weight", () => {
render(
<Timeline
ariaLabel="Audit timeline"
entries={entries}
height={320}
initialRect={{ width: 520, height: 360 }}
/>,
);
const boundary = screen.getByText("Mutation boundary");
expect(boundary).toBeInTheDocument();
expect(boundary.closest("article")).toHaveClass("border-l-[var(--color-selected-border)]");
});
it("selects through the virtualized keyboard spine", async () => {
const onSelect = vi.fn();
const user = userEvent.setup();
render(
<Timeline
ariaLabel="Audit timeline"
entries={entries}
height={320}
initialRect={{ width: 520, height: 360 }}
onSelect={onSelect}
/>,
);
const list = screen.getByRole("listbox", { name: "Audit timeline" });
list.focus();
await user.keyboard("j{Enter}");
expect(onSelect).toHaveBeenCalledWith(entries[1]);
});
});

View file

@ -0,0 +1,108 @@
import { Timestamp } from "../Timestamp/Timestamp";
import { VirtualizedList } from "../VirtualizedList/VirtualizedList";
export interface TimelineEntry {
id: string;
timestamp: string | null;
source: string;
summary: string;
mutationBoundary?: boolean;
}
export const TIMELINE_PREVIEW_ENTRY: TimelineEntry = {
id: "preview-audit-event",
timestamp: "2026-06-12T18:00:00Z",
source: "operator_telemetry",
summary: "Operator telemetry recorded without mutation.",
mutationBoundary: false,
};
export interface TimelineProps<T extends TimelineEntry> {
entries: readonly T[];
selectedId?: string | null;
onSelect?: (entry: T) => void;
height: number | string;
ariaLabel: string;
estimateSize?: number;
initialRect?: { width: number; height: number };
}
function TimelineRow<T extends TimelineEntry>({
entry,
selected,
focused,
onSelect,
}: {
entry: T;
selected: boolean;
focused: boolean;
onSelect: () => void;
}) {
const weighted = !!entry.mutationBoundary;
const borderClass = weighted || selected
? "border-l-[var(--color-selected-border)]"
: focused
? "border-l-[var(--color-focus-ring)]"
: "border-l-transparent";
return (
<article
role="button"
tabIndex={-1}
aria-current={selected ? "true" : undefined}
onClick={onSelect}
className={`grid gap-2 border-b border-l-2 border-b-[var(--color-border-subtle)] px-3 py-3 text-left transition-colors hover:bg-[var(--color-surface-inset)] ${borderClass} ${
selected ? "bg-[var(--color-selected-bg)]" : ""
}`}
>
<div className="flex flex-wrap items-center gap-2">
{entry.timestamp ? (
<Timestamp iso={entry.timestamp} format="both" />
) : (
<span className="text-xs text-[var(--color-text-muted)]">No timestamp</span>
)}
<span className="rounded border border-[var(--color-border-subtle)] bg-[var(--color-surface-inset)] px-1.5 py-0.5 font-mono text-[10px] text-[var(--color-text-secondary)]">
{entry.source}
</span>
{weighted ? (
<span className="rounded border border-[var(--color-selected-border)] bg-[var(--color-selected-bg)] px-1.5 py-0.5 text-[10px] font-semibold text-[var(--color-text-primary)]">
Mutation boundary
</span>
) : null}
</div>
<p className="m-0 text-sm leading-5 text-[var(--color-text-primary)]">
{entry.summary}
</p>
</article>
);
}
export function Timeline<T extends TimelineEntry>({
entries,
selectedId,
onSelect,
height,
ariaLabel,
estimateSize = 84,
initialRect,
}: TimelineProps<T>) {
return (
<VirtualizedList
ariaLabel={ariaLabel}
estimateSize={estimateSize}
getKey={(entry) => entry.id}
height={height}
initialRect={initialRect}
items={entries}
onActivate={(entry) => onSelect?.(entry)}
renderItem={(entry, _index, focused) => (
<TimelineRow
entry={entry}
selected={entry.id === selectedId}
focused={focused}
onSelect={() => onSelect?.(entry)}
/>
)}
/>
);
}

View file

@ -0,0 +1 @@
export { Timeline, TIMELINE_PREVIEW_ENTRY, type TimelineEntry, type TimelineProps } from "./Timeline";

View file

@ -23,7 +23,6 @@ const NOT_YET_MIRRORED = new Set([
// R2-B backend read substrate (#712) — TS mirrors land with each R2 route:
"PackSummary",
"PackDetail",
"AuditEvent",
"RunSummary",
"RunTurnRef",
"RunDetail",

View file

@ -1,10 +0,0 @@
import { EmptyState } from "../design/components/states/EmptyState";
export function AuditRoutePlaceholder() {
return (
<EmptyState
statement="Audit — no data loaded yet."
nextAction={{ kind: "cli", command: "teaching/proposals/proposals.jsonl" }}
/>
);
}

View file

@ -32,6 +32,12 @@ export type EpistemicState =
| "epistemic_state_needed";
export type NormativeClearance = "cleared" | "violated" | "unassessable" | "suppressed";
export type TurnVerdictOutcome = "cleared" | "violated" | "unassessable";
export type AuditSource =
| "engine_state_manifest"
| "math_proposal_log"
| "operator_telemetry"
| "reboot_telemetry"
| "teaching_proposal_log";
export interface RuntimeStatus {
backend: Backend;
@ -107,6 +113,19 @@ export interface TurnJournalEntry {
export type TurnEvidence = ChatTurnResult | TurnJournalEntry;
export interface AuditEvent {
event_id: string;
source: AuditSource;
source_path: string;
timestamp: string | null;
event_type: string;
mutation_boundary: boolean;
summary: string;
ref_id: string | null;
payload_digest: string;
payload: unknown;
}
export type ArtifactKind =
| "trace"
| "eval_result"