feat(workbench): Audit route — event timeline with mutation-boundary weighting (Wave R2-A)
This commit is contained in:
parent
bd52d6b38a
commit
d48d040467
12 changed files with 582 additions and 15 deletions
|
|
@ -10,6 +10,7 @@ import type {
|
||||||
ProposalDetail,
|
ProposalDetail,
|
||||||
ProposalState,
|
ProposalState,
|
||||||
ProposalSummary,
|
ProposalSummary,
|
||||||
|
AuditEvent,
|
||||||
TurnJournalEntry,
|
TurnJournalEntry,
|
||||||
TurnJournalSummary,
|
TurnJournalSummary,
|
||||||
MathProposalSummary,
|
MathProposalSummary,
|
||||||
|
|
@ -127,6 +128,17 @@ export async function fetchTraceTurn(turnId: number): Promise<TurnJournalEntry>
|
||||||
return apiFetch<TurnJournalEntry>(`/trace/${encodeURIComponent(String(turnId))}`);
|
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[]> {
|
export async function fetchMathProposals(): Promise<MathProposalSummary[]> {
|
||||||
const envelope = await apiFetch<ItemsEnvelope<MathProposalSummary>>("/math-proposals");
|
const envelope = await apiFetch<ItemsEnvelope<MathProposalSummary>>("/math-proposals");
|
||||||
return envelope.items;
|
return envelope.items;
|
||||||
|
|
@ -173,4 +185,3 @@ export async function deferMathProposal(
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
fetchReplayComparison,
|
fetchReplayComparison,
|
||||||
fetchProposalDetail,
|
fetchProposalDetail,
|
||||||
fetchProposals,
|
fetchProposals,
|
||||||
|
fetchAuditEvents,
|
||||||
fetchTraceTurn,
|
fetchTraceTurn,
|
||||||
fetchTraceTurns,
|
fetchTraceTurns,
|
||||||
fetchMathProposals,
|
fetchMathProposals,
|
||||||
|
|
@ -29,6 +30,7 @@ import type {
|
||||||
ArtifactDetail,
|
ArtifactDetail,
|
||||||
ProposalSummary,
|
ProposalSummary,
|
||||||
ProposalDetail,
|
ProposalDetail,
|
||||||
|
AuditEvent,
|
||||||
TurnJournalEntry,
|
TurnJournalEntry,
|
||||||
TurnJournalSummary,
|
TurnJournalSummary,
|
||||||
EvalLaneSummary,
|
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() {
|
export function useEvalLanes() {
|
||||||
return useQuery<EvalLaneSummary[]>({
|
return useQuery<EvalLaneSummary[]>({
|
||||||
queryKey: ["api", "evals"],
|
queryKey: ["api", "evals"],
|
||||||
|
|
@ -238,4 +249,3 @@ export function useMathDefer() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,12 @@ import { PreviewPage } from "../preview/PreviewPage";
|
||||||
import { ChatRoute } from "../routes/ChatRoute";
|
import { ChatRoute } from "../routes/ChatRoute";
|
||||||
import { ProposalsRoute } from "./proposals/ProposalsRoute";
|
import { ProposalsRoute } from "./proposals/ProposalsRoute";
|
||||||
import { TraceRoute } from "./trace/TraceRoute";
|
import { TraceRoute } from "./trace/TraceRoute";
|
||||||
|
import { AuditRoute } from "./audit/AuditRoute";
|
||||||
import { ReplayRoute } from "./replay/ReplayRoute";
|
import { ReplayRoute } from "./replay/ReplayRoute";
|
||||||
import { EvalsRoute } from "./evals/EvalsRoute";
|
import { EvalsRoute } from "./evals/EvalsRoute";
|
||||||
import { RunsRoutePlaceholder } from "../routes/RunsRoutePlaceholder";
|
import { RunsRoutePlaceholder } from "../routes/RunsRoutePlaceholder";
|
||||||
import { PacksRoutePlaceholder } from "../routes/PacksRoutePlaceholder";
|
import { PacksRoutePlaceholder } from "../routes/PacksRoutePlaceholder";
|
||||||
import { VaultRoutePlaceholder } from "../routes/VaultRoutePlaceholder";
|
import { VaultRoutePlaceholder } from "../routes/VaultRoutePlaceholder";
|
||||||
import { AuditRoutePlaceholder } from "../routes/AuditRoutePlaceholder";
|
|
||||||
import { SettingsRoutePlaceholder } from "../routes/SettingsRoutePlaceholder";
|
import { SettingsRoutePlaceholder } from "../routes/SettingsRoutePlaceholder";
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
|
|
@ -29,7 +29,7 @@ export function App() {
|
||||||
<Route path="runs" element={<RunsRoutePlaceholder />} />
|
<Route path="runs" element={<RunsRoutePlaceholder />} />
|
||||||
<Route path="packs" element={<PacksRoutePlaceholder />} />
|
<Route path="packs" element={<PacksRoutePlaceholder />} />
|
||||||
<Route path="vault" element={<VaultRoutePlaceholder />} />
|
<Route path="vault" element={<VaultRoutePlaceholder />} />
|
||||||
<Route path="audit" element={<AuditRoutePlaceholder />} />
|
<Route path="audit" element={<AuditRoute />} />
|
||||||
<Route path="settings" element={<SettingsRoutePlaceholder />} />
|
<Route path="settings" element={<SettingsRoutePlaceholder />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/preview" element={<PreviewPage />} />
|
<Route path="/preview" element={<PreviewPage />} />
|
||||||
|
|
|
||||||
192
workbench-ui/src/app/audit/AuditRoute.test.tsx
Normal file
192
workbench-ui/src/app/audit/AuditRoute.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
134
workbench-ui/src/app/audit/AuditRoute.tsx
Normal file
134
workbench-ui/src/app/audit/AuditRoute.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ import { EvidenceProvider } from "./evidenceContext";
|
||||||
import { ChatRoute } from "../routes/ChatRoute";
|
import { ChatRoute } from "../routes/ChatRoute";
|
||||||
import { ProposalsRoute } from "./proposals/ProposalsRoute";
|
import { ProposalsRoute } from "./proposals/ProposalsRoute";
|
||||||
import { TraceRoute } from "./trace/TraceRoute";
|
import { TraceRoute } from "./trace/TraceRoute";
|
||||||
|
import { AuditRoute } from "./audit/AuditRoute";
|
||||||
import { EvalsRoute } from "./evals/EvalsRoute";
|
import { EvalsRoute } from "./evals/EvalsRoute";
|
||||||
import { ReplayRoute } from "./replay/ReplayRoute";
|
import { ReplayRoute } from "./replay/ReplayRoute";
|
||||||
|
|
||||||
|
|
@ -105,6 +106,15 @@ interface MountRouteSpec {
|
||||||
}
|
}
|
||||||
|
|
||||||
const MOUNT_ROUTES: 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",
|
name: "Trace",
|
||||||
element: <TraceRoute />,
|
element: <TraceRoute />,
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
108
workbench-ui/src/design/components/Timeline/Timeline.tsx
Normal file
108
workbench-ui/src/design/components/Timeline/Timeline.tsx
Normal 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)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
workbench-ui/src/design/components/Timeline/index.ts
Normal file
1
workbench-ui/src/design/components/Timeline/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { Timeline, TIMELINE_PREVIEW_ENTRY, type TimelineEntry, type TimelineProps } from "./Timeline";
|
||||||
|
|
@ -23,7 +23,6 @@ const NOT_YET_MIRRORED = new Set([
|
||||||
// R2-B backend read substrate (#712) — TS mirrors land with each R2 route:
|
// R2-B backend read substrate (#712) — TS mirrors land with each R2 route:
|
||||||
"PackSummary",
|
"PackSummary",
|
||||||
"PackDetail",
|
"PackDetail",
|
||||||
"AuditEvent",
|
|
||||||
"RunSummary",
|
"RunSummary",
|
||||||
"RunTurnRef",
|
"RunTurnRef",
|
||||||
"RunDetail",
|
"RunDetail",
|
||||||
|
|
|
||||||
|
|
@ -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" }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -32,6 +32,12 @@ export type EpistemicState =
|
||||||
| "epistemic_state_needed";
|
| "epistemic_state_needed";
|
||||||
export type NormativeClearance = "cleared" | "violated" | "unassessable" | "suppressed";
|
export type NormativeClearance = "cleared" | "violated" | "unassessable" | "suppressed";
|
||||||
export type TurnVerdictOutcome = "cleared" | "violated" | "unassessable";
|
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 {
|
export interface RuntimeStatus {
|
||||||
backend: Backend;
|
backend: Backend;
|
||||||
|
|
@ -107,6 +113,19 @@ export interface TurnJournalEntry {
|
||||||
|
|
||||||
export type TurnEvidence = ChatTurnResult | 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 =
|
export type ArtifactKind =
|
||||||
| "trace"
|
| "trace"
|
||||||
| "eval_result"
|
| "eval_result"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue