diff --git a/workbench-ui/src/api/client.ts b/workbench-ui/src/api/client.ts index 093e712d..f286cab9 100644 --- a/workbench-ui/src/api/client.ts +++ b/workbench-ui/src/api/client.ts @@ -10,6 +10,7 @@ import type { ProposalDetail, ProposalState, ProposalSummary, + AuditEvent, TurnJournalEntry, TurnJournalSummary, MathProposalSummary, @@ -127,6 +128,17 @@ export async function fetchTraceTurn(turnId: number): Promise return apiFetch(`/trace/${encodeURIComponent(String(turnId))}`); } +export async function fetchAuditEvents( + limit?: number, + offset?: number, +): Promise> { + const params = new URLSearchParams(); + if (limit !== undefined) params.set("limit", String(limit)); + if (offset !== undefined) params.set("offset", String(offset)); + const query = params.toString(); + return apiFetch>(query ? `/audit/events?${query}` : "/audit/events"); +} + export async function fetchMathProposals(): Promise { const envelope = await apiFetch>("/math-proposals"); return envelope.items; @@ -173,4 +185,3 @@ export async function deferMathProposal( }, ); } - diff --git a/workbench-ui/src/api/queries.ts b/workbench-ui/src/api/queries.ts index 5838505d..b31d2915 100644 --- a/workbench-ui/src/api/queries.ts +++ b/workbench-ui/src/api/queries.ts @@ -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({ queryKey: ["api", "evals"], @@ -238,4 +249,3 @@ export function useMathDefer() { }); } - diff --git a/workbench-ui/src/app/App.tsx b/workbench-ui/src/app/App.tsx index 4bb53d4b..2fafabf8 100644 --- a/workbench-ui/src/app/App.tsx +++ b/workbench-ui/src/app/App.tsx @@ -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() { } /> } /> } /> - } /> + } /> } /> } /> diff --git a/workbench-ui/src/app/audit/AuditRoute.test.tsx b/workbench-ui/src/app/audit/AuditRoute.test.tsx new file mode 100644 index 00000000..b408f120 --- /dev/null +++ b/workbench-ui/src/app/audit/AuditRoute.test.tsx @@ -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 = { "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( + + + , + ); +} + +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(() => {}))); + renderRoute(); + + expect(await screen.findByText("Loading audit events...")).toBeInTheDocument(); + expect(screen.queryByText(/thinking/i)).not.toBeInTheDocument(); + }); +}); diff --git a/workbench-ui/src/app/audit/AuditRoute.tsx b/workbench-ui/src/app/audit/AuditRoute.tsx new file mode 100644 index 00000000..900bde17 --- /dev/null +++ b/workbench-ui/src/app/audit/AuditRoute.tsx @@ -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([]); + const [search, setSearch] = useState(""); + const [selectedEventId, setSelectedEventId] = useState(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 ; + } + + if (eventsQuery.isError) { + return ( + + ); + } + + if (events.length === 0) { + return ( + + ); + } + + return ( + + {events.length} events + + } + > +
+ + {timelineEntries.length === 0 ? ( + + ) : ( + setSelectedEventId(entry.id)} + /> + )} + {hasMore ? ( +
+ +
+ ) : null} +
+
+ ); +} diff --git a/workbench-ui/src/app/routeConformance.test.tsx b/workbench-ui/src/app/routeConformance.test.tsx index 8e5edf51..5b6f38af 100644 --- a/workbench-ui/src/app/routeConformance.test.tsx +++ b/workbench-ui/src/app/routeConformance.test.tsx @@ -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: , + path: "/audit", + initialEntry: "/audit", + loadingLabel: "Loading audit events...", + emptyStatement: "No audit events recorded.", + emptyCommand: "core audit events", + }, { name: "Trace", element: , diff --git a/workbench-ui/src/design/components/Timeline/Timeline.test.tsx b/workbench-ui/src/design/components/Timeline/Timeline.test.tsx new file mode 100644 index 00000000..b94531f3 --- /dev/null +++ b/workbench-ui/src/design/components/Timeline/Timeline.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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( + , + ); + + const list = screen.getByRole("listbox", { name: "Audit timeline" }); + list.focus(); + await user.keyboard("j{Enter}"); + + expect(onSelect).toHaveBeenCalledWith(entries[1]); + }); +}); diff --git a/workbench-ui/src/design/components/Timeline/Timeline.tsx b/workbench-ui/src/design/components/Timeline/Timeline.tsx new file mode 100644 index 00000000..2ec49c2e --- /dev/null +++ b/workbench-ui/src/design/components/Timeline/Timeline.tsx @@ -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 { + entries: readonly T[]; + selectedId?: string | null; + onSelect?: (entry: T) => void; + height: number | string; + ariaLabel: string; + estimateSize?: number; + initialRect?: { width: number; height: number }; +} + +function TimelineRow({ + 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 ( +
+
+ {entry.timestamp ? ( + + ) : ( + No timestamp + )} + + {entry.source} + + {weighted ? ( + + Mutation boundary + + ) : null} +
+

+ {entry.summary} +

+
+ ); +} + +export function Timeline({ + entries, + selectedId, + onSelect, + height, + ariaLabel, + estimateSize = 84, + initialRect, +}: TimelineProps) { + return ( + entry.id} + height={height} + initialRect={initialRect} + items={entries} + onActivate={(entry) => onSelect?.(entry)} + renderItem={(entry, _index, focused) => ( + onSelect?.(entry)} + /> + )} + /> + ); +} diff --git a/workbench-ui/src/design/components/Timeline/index.ts b/workbench-ui/src/design/components/Timeline/index.ts new file mode 100644 index 00000000..fe2b035a --- /dev/null +++ b/workbench-ui/src/design/components/Timeline/index.ts @@ -0,0 +1 @@ +export { Timeline, TIMELINE_PREVIEW_ENTRY, type TimelineEntry, type TimelineProps } from "./Timeline"; diff --git a/workbench-ui/src/design/doctrine/schemaDrift.test.ts b/workbench-ui/src/design/doctrine/schemaDrift.test.ts index 9afa75cf..eb69b71e 100644 --- a/workbench-ui/src/design/doctrine/schemaDrift.test.ts +++ b/workbench-ui/src/design/doctrine/schemaDrift.test.ts @@ -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", diff --git a/workbench-ui/src/routes/AuditRoutePlaceholder.tsx b/workbench-ui/src/routes/AuditRoutePlaceholder.tsx deleted file mode 100644 index a2a9fdc1..00000000 --- a/workbench-ui/src/routes/AuditRoutePlaceholder.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { EmptyState } from "../design/components/states/EmptyState"; - -export function AuditRoutePlaceholder() { - return ( - - ); -} diff --git a/workbench-ui/src/types/api.ts b/workbench-ui/src/types/api.ts index e4693ca8..d75af89b 100644 --- a/workbench-ui/src/types/api.ts +++ b/workbench-ui/src/types/api.ts @@ -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"