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,
|
||||
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(
|
|||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
});
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
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 { 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 />,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"PackSummary",
|
||||
"PackDetail",
|
||||
"AuditEvent",
|
||||
"RunSummary",
|
||||
"RunTurnRef",
|
||||
"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";
|
||||
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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue