diff --git a/workbench-ui/src/api/client.ts b/workbench-ui/src/api/client.ts index 2332e1d1..f6fa8058 100644 --- a/workbench-ui/src/api/client.ts +++ b/workbench-ui/src/api/client.ts @@ -15,6 +15,8 @@ import type { RunDetail, PackSummary, PackDetail, + VaultSummary, + VaultEntry, TurnJournalEntry, TurnJournalSummary, MathProposalSummary, @@ -224,3 +226,18 @@ export async function fetchPacks(limit?: number, offset?: number): Promise { return apiFetch(`/packs/${encodeURIComponent(packId)}`); } + +export async function fetchVaultSummary(): Promise { + return apiFetch("/vault/summary"); +} + +export async function fetchVaultEntries(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(); + const envelope = await apiFetch>( + query ? `/vault/entries?${query}` : "/vault/entries", + ); + return envelope.items; +} diff --git a/workbench-ui/src/api/queries.ts b/workbench-ui/src/api/queries.ts index 968bf0ca..0fc9c0d1 100644 --- a/workbench-ui/src/api/queries.ts +++ b/workbench-ui/src/api/queries.ts @@ -18,6 +18,8 @@ import { fetchRun, fetchPacks, fetchPack, + fetchVaultSummary, + fetchVaultEntries, fetchTraceTurn, fetchTraceTurns, fetchMathProposals, @@ -39,6 +41,8 @@ import type { RunDetail, PackSummary, PackDetail, + VaultSummary, + VaultEntry, TurnJournalEntry, TurnJournalSummary, EvalLaneSummary, @@ -295,3 +299,24 @@ export function usePack(packId?: string | null) { }); } +export function useVaultSummary() { + return useQuery({ + queryKey: ["api", "vault", "summary"], + queryFn: () => fetchVaultSummary(), + retry: false, + staleTime: 30_000, + refetchOnWindowFocus: false, + }); +} + +export function useVaultEntries(enabled: boolean, limit?: number, offset?: number) { + return useQuery({ + queryKey: ["api", "vault", "entries", limit ?? null, offset ?? null], + queryFn: () => fetchVaultEntries(limit, offset), + enabled, + retry: false, + staleTime: 30_000, + refetchOnWindowFocus: false, + }); +} + diff --git a/workbench-ui/src/app/App.tsx b/workbench-ui/src/app/App.tsx index 4a43103a..06565738 100644 --- a/workbench-ui/src/app/App.tsx +++ b/workbench-ui/src/app/App.tsx @@ -11,7 +11,7 @@ import { ReplayRoute } from "./replay/ReplayRoute"; import { EvalsRoute } from "./evals/EvalsRoute"; import { RunsRoute } from "./runs/RunsRoute"; import { PacksRoute } from "./packs/PacksRoute"; -import { VaultRoutePlaceholder } from "../routes/VaultRoutePlaceholder"; +import { VaultRoute } from "./vault/VaultRoute"; import { SettingsRoutePlaceholder } from "../routes/SettingsRoutePlaceholder"; export function App() { @@ -28,7 +28,7 @@ export function App() { } /> } /> } /> - } /> + } /> } /> } /> diff --git a/workbench-ui/src/app/routeConformance.test.tsx b/workbench-ui/src/app/routeConformance.test.tsx index c56bff82..dfad7348 100644 --- a/workbench-ui/src/app/routeConformance.test.tsx +++ b/workbench-ui/src/app/routeConformance.test.tsx @@ -14,6 +14,7 @@ import { EvalsRoute } from "./evals/EvalsRoute"; import { ReplayRoute } from "./replay/ReplayRoute"; import { RunsRoute } from "./runs/RunsRoute"; import { PacksRoute } from "./packs/PacksRoute"; +import { VaultRoute } from "./vault/VaultRoute"; /** * ADR-0162 §6 route conformance — executable, not aspirational. @@ -171,6 +172,17 @@ const MOUNT_ROUTES: MountRouteSpec[] = [ emptyStatement: "No packs discovered.", emptyCommand: "core pack validate ", }, + { + name: "Vault", + element: , + path: "/vault", + initialEntry: "/vault", + loadingLabel: "Loading vault...", + // Fail-closed: absence of a persisted vault is the primary contract. + emptyStatement: + "No persisted vault. Session memory is held in-process and discarded on exit; persistence is opt-in via RuntimeConfig.persist_session_state.", + emptyCommand: "Set RuntimeConfig.persist_session_state = true", + }, ]; describe.each(MOUNT_ROUTES)("route conformance: $name", (spec) => { diff --git a/workbench-ui/src/app/vault/VaultRoute.test.tsx b/workbench-ui/src/app/vault/VaultRoute.test.tsx new file mode 100644 index 00000000..17bc9fa3 --- /dev/null +++ b/workbench-ui/src/app/vault/VaultRoute.test.tsx @@ -0,0 +1,185 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createTestQueryClient } from "../../test/createTestQueryClient"; +import type { VaultEntry, VaultSummary } from "../../types/api"; +import { EvidenceProvider, useEvidenceSubject } from "../evidenceContext"; +import { VaultRoute } from "./VaultRoute"; + +const summary: VaultSummary = { + source_path: "engine_state/session_state.json", + entry_count: 2, + store_count: 1, + reproject_interval: 64, + max_entries: 4096, + persisted: true, +}; + +const entries: VaultEntry[] = [ + { + entry_index: 0, + epistemic_status: "coherent", + epistemic_state: "verified", + metadata: { concept: "truth" }, + versor_digest: "sha256:aaaaaaaaaaaaaaaaaaaa", + }, + { + entry_index: 1, + epistemic_status: "speculative", + epistemic_state: "inferred", + metadata: { concept: "beauty" }, + versor_digest: "sha256:bbbbbbbbbbbbbbbbbbbb", + }, +]; + +function SubjectProbe() { + const { subject } = useEvidenceSubject(); + return ( + + {subject.kind === "vault_entry" ? `vault_entry:${subject.entryIndex}` : subject.kind} + + ); +} + +function renderRoute() { + return render( + + + + + + + + + } + /> + + + + , + ); +} + +function okBody(data: unknown) { + return { ok: true, generated_at: "now", data }; +} + +function evidenceUnavailable(message: string) { + return { ok: false, generated_at: "now", error: { code: "evidence_unavailable", message } }; +} + +function stubVault({ persisted = true }: { persisted?: boolean } = {}) { + vi.stubGlobal( + "fetch", + vi.fn((input: unknown) => { + const path = new URL(String(input)).pathname; + if (path === "/vault/summary") { + return persisted + ? Promise.resolve({ json: () => Promise.resolve(okBody(summary)) }) + : Promise.resolve({ + json: () => + Promise.resolve(evidenceUnavailable("vault evidence unavailable: not persisted")), + }); + } + if (path === "/vault/entries") { + return Promise.resolve({ json: () => Promise.resolve(okBody({ items: entries })) }); + } + return Promise.resolve({ + json: () => Promise.resolve(evidenceUnavailable(`unexpected ${path}`)), + }); + }), + ); +} + +const offsetDescriptors = { + offsetHeight: Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight"), + offsetWidth: Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetWidth"), +}; + +describe("VaultRoute", () => { + beforeEach(() => { + Object.defineProperty(HTMLElement.prototype, "offsetHeight", { + configurable: true, + get: () => 560, + }); + Object.defineProperty(HTMLElement.prototype, "offsetWidth", { + configurable: true, + get: () => 480, + }); + }); + + afterEach(() => { + if (offsetDescriptors.offsetHeight) { + Object.defineProperty(HTMLElement.prototype, "offsetHeight", offsetDescriptors.offsetHeight); + } + if (offsetDescriptors.offsetWidth) { + Object.defineProperty(HTMLElement.prototype, "offsetWidth", offsetDescriptors.offsetWidth); + } + vi.restoreAllMocks(); + localStorage.clear(); + }); + + it("fail-closed: an unpersisted vault renders the honest absence card, not an error", async () => { + stubVault({ persisted: false }); + renderRoute(); + + expect( + await screen.findByText(/No persisted vault\./), + ).toBeInTheDocument(); + // both the statement and the config-pointer action name the opt-in flag + expect( + screen.getAllByText(/RuntimeConfig\.persist_session_state/).length, + ).toBeGreaterThan(0); + // it is NOT the generic error contract + expect(screen.queryByText("What failed")).not.toBeInTheDocument(); + }); + + it("renders the summary strip and entries when persisted", async () => { + stubVault(); + renderRoute(); + + expect(await screen.findByText("entry_count")).toBeInTheDocument(); + expect(screen.getByText("reproject_interval")).toBeInTheDocument(); + expect(screen.getByText("coherent")).toBeInTheDocument(); + expect(screen.getByText("verified")).toBeInTheDocument(); + expect(screen.getByText("speculative")).toBeInTheDocument(); + }); + + it("never invents a similarity / relevance score (exact-recall doctrine)", async () => { + stubVault(); + renderRoute(); + + await screen.findByText("entry_count"); + expect(screen.queryByText(/similarity|relevance|score/i)).not.toBeInTheDocument(); + }); + + it("selecting an entry publishes the vault_entry subject", async () => { + stubVault(); + const user = userEvent.setup(); + renderRoute(); + + await user.click(await screen.findByText("speculative")); + + await waitFor(() => + expect(screen.getByTestId("subject")).toHaveTextContent("vault_entry:1"), + ); + }); + + it("moves entry focus with j/k through the VirtualizedList spine", async () => { + stubVault(); + const user = userEvent.setup(); + renderRoute(); + + const list = await screen.findByRole("listbox", { name: "Vault entries" }); + list.focus(); + expect(screen.getAllByRole("option")[0]).toHaveAttribute("aria-selected", "true"); + + await user.keyboard("j"); + expect(screen.getAllByRole("option")[1]).toHaveAttribute("aria-selected", "true"); + }); +}); diff --git a/workbench-ui/src/app/vault/VaultRoute.tsx b/workbench-ui/src/app/vault/VaultRoute.tsx new file mode 100644 index 00000000..c9c7af15 --- /dev/null +++ b/workbench-ui/src/app/vault/VaultRoute.tsx @@ -0,0 +1,225 @@ +import { useEffect, useMemo, useState } from "react"; +import { WorkbenchApiError } from "../../api/client"; +import { useVaultEntries, useVaultSummary } from "../../api/queries"; +import { DigestBadge } from "../../design/components/DigestBadge/DigestBadge"; +import { MetadataTable } from "../../design/components/MetadataTable/MetadataTable"; +import { Panel } from "../../design/components/Panel/Panel"; +import { SearchInput } from "../../design/components/SearchInput/SearchInput"; +import { VirtualizedList } from "../../design/components/VirtualizedList/VirtualizedList"; +import { EmptyState } from "../../design/components/states/EmptyState"; +import { ErrorState } from "../../design/components/states/ErrorState"; +import { LoadingState } from "../../design/components/states/LoadingState"; +import type { VaultEntry, VaultSummary } from "../../types/api"; +import { useEvidenceSubject } from "../evidenceContext"; + +// Vault persistence is opt-in; absence is the common, honest state. This is +// the fail-closed primary contract, not an error. +const FAIL_CLOSED_STATEMENT = + "No persisted vault. Session memory is held in-process and discarded on exit; persistence is opt-in via RuntimeConfig.persist_session_state."; +const FAIL_CLOSED_ACTION = "Set RuntimeConfig.persist_session_state = true"; + +function errorMessage(error: unknown) { + return error instanceof WorkbenchApiError ? error.message : "Vault request failed."; +} + +function digestPayload(value: string | null | undefined): string | null { + if (!value) return null; + return value.replace(/^sha256:/, ""); +} + +function StatusPill({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function VaultRow({ + entry, + selected, + focused, + onSelect, +}: { + entry: VaultEntry; + selected: boolean; + focused: boolean; + onSelect: () => void; +}) { + const digest = digestPayload(entry.versor_digest); + return ( +
+ + + #{entry.entry_index} + + + {entry.epistemic_status} + {entry.epistemic_state} + + + + {digest ? ( + + ) : ( + no versor + )} + +
+ ); +} + +function VaultSummaryStrip({ summary }: { summary: VaultSummary }) { + // Render only fields the backend returns. Recall is exact CGA (cga_inner); + // the UI must never invent an approximate similarity/relevance proxy. + return ( + + ); +} + +function FailClosed() { + return ( + + ); +} + +export function VaultRoute() { + const { subject, setSubject, setInspectorOpen } = useEvidenceSubject(); + const [search, setSearch] = useState(""); + + const summaryQuery = useVaultSummary(); + const summary = summaryQuery.data; + const hasEntries = !!summary && summary.persisted && summary.entry_count > 0; + const entriesQuery = useVaultEntries(hasEntries); + + const selectedIndex = + subject.kind === "vault_entry" ? subject.entryIndex : null; + + const entries = entriesQuery.data ?? []; + const filteredEntries = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return entries; + return entries.filter( + (entry) => + entry.epistemic_status.toLowerCase().includes(q) || + entry.epistemic_state.toLowerCase().includes(q) || + String(entry.entry_index).includes(q), + ); + }, [search, entries]); + + // Hydrate a URL-restored (identity-only) vault_entry subject with its data. + useEffect(() => { + if (subject.kind !== "vault_entry" || subject.data) return; + const match = entries.find((entry) => entry.entry_index === subject.entryIndex); + if (match) { + setSubject({ kind: "vault_entry", entryIndex: match.entry_index, data: match }); + } + }, [subject, entries, setSubject]); + + function selectEntry(entry: VaultEntry) { + setSubject({ kind: "vault_entry", entryIndex: entry.entry_index, data: entry }); + setInspectorOpen(true); + } + + if (summaryQuery.isLoading) { + return ; + } + + // Fail-closed: no persisted vault is the expected primary state, not an error. + if (summaryQuery.isError) { + if (summaryQuery.error.code === "evidence_unavailable") { + return ; + } + return ( + + ); + } + + if (!hasEntries) { + return ; + } + + return ( + + {summary.entry_count} entries + + } + > +
+ + + {entriesQuery.isLoading ? ( + + ) : entriesQuery.isError ? ( + + ) : filteredEntries.length === 0 ? ( + + ) : ( + String(entry.entry_index)} + height="calc(100vh - 18rem)" + initialRect={{ width: 480, height: 560 }} + items={filteredEntries} + onActivate={(entry) => selectEntry(entry)} + renderItem={(entry, _index, focused) => ( + selectEntry(entry)} + /> + )} + /> + )} +
+
+ ); +} diff --git a/workbench-ui/src/design/doctrine/schemaDrift.test.ts b/workbench-ui/src/design/doctrine/schemaDrift.test.ts index 13404089..72103300 100644 --- a/workbench-ui/src/design/doctrine/schemaDrift.test.ts +++ b/workbench-ui/src/design/doctrine/schemaDrift.test.ts @@ -20,9 +20,6 @@ import { describe, expect, it } from "vitest"; */ const NOT_YET_MIRRORED = new Set([ - // R2-B backend read substrate (#712) — TS mirrors land with each R2 route: - "VaultSummary", - "VaultEntry", // Wave R3 sealed turn replay backend — TS mirrors land with the frontend // Replay Moment PR (which also retires the W-026 artifact-keyed // ReplayComparison/ReplayDivergence pair on both sides): diff --git a/workbench-ui/src/routes/VaultRoutePlaceholder.tsx b/workbench-ui/src/routes/VaultRoutePlaceholder.tsx deleted file mode 100644 index 68e300a2..00000000 --- a/workbench-ui/src/routes/VaultRoutePlaceholder.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { EmptyState } from "../design/components/states/EmptyState"; - -export function VaultRoutePlaceholder() { - return ( - - ); -} diff --git a/workbench-ui/src/types/api.ts b/workbench-ui/src/types/api.ts index 8c2e108d..8f819486 100644 --- a/workbench-ui/src/types/api.ts +++ b/workbench-ui/src/types/api.ts @@ -3,6 +3,7 @@ export type ErrorCode = | "bad_request" + | "evidence_unavailable" | "not_found" | "unsupported" | "read_error" @@ -263,6 +264,23 @@ export interface ReplayComparison { divergences: ReplayDivergence[]; } +export interface VaultSummary { + source_path: string; + entry_count: number; + store_count: number; + reproject_interval: number; + max_entries: number | null; + persisted: boolean; +} + +export interface VaultEntry { + entry_index: number; + epistemic_status: string; + epistemic_state: string; + metadata: Record; + versor_digest: string | null; +} + // API envelope types export interface ApiOk { ok: true;