feat(workbench): Vault route — fail-closed epistemic memory evidence (Wave R2)
Replaces VaultRoutePlaceholder with the real route over the R2-B /vault read substrate (#712). - TS mirrors VaultSummary/VaultEntry; both removed from NOT_YET_MIRRORED. Also fixes a latent ErrorCode drift: adds 'evidence_unavailable' to the TS union (present in Python schemas, missing in TS) — the route branches on it. - fail-closed is the design: a missing/unpersisted vault (the 501 evidence_unavailable signal, or persisted=false / entry_count=0) renders the honest absence card as the PRIMARY state, naming the opt-in RuntimeConfig.persist_session_state pointer — not a generic error, no skeleton theater - with data: summary strip (entry_count, store_count, reproject_interval, max_entries, source_path) + VirtualizedList of entries with epistemic_status / epistemic_state pills and versor_digest DigestBadge - exact-recall doctrine: renders only backend fields; never computes or displays a similarity/relevance proxy (runtime recall is exact cga_inner) - selection publishes the vault_entry subject (inspect-param only, via setSubject + setInspectorOpen; EvidenceUrlSync writes ?inspect=vault:N); a URL-restored identity-only subject is hydrated once entries load - Vault row added to routeConformance MOUNT_ROUTES — the fail-closed absence asserted as the primary contract - tests: fail-closed card (not error), summary+entries, no-similarity-score doctrine, vault_entry subject publication, j/k spine Token-only styling (hexScan green).
This commit is contained in:
parent
19f8d5e3bd
commit
ebb95a0541
9 changed files with 484 additions and 15 deletions
|
|
@ -15,6 +15,8 @@ import type {
|
||||||
RunDetail,
|
RunDetail,
|
||||||
PackSummary,
|
PackSummary,
|
||||||
PackDetail,
|
PackDetail,
|
||||||
|
VaultSummary,
|
||||||
|
VaultEntry,
|
||||||
TurnJournalEntry,
|
TurnJournalEntry,
|
||||||
TurnJournalSummary,
|
TurnJournalSummary,
|
||||||
MathProposalSummary,
|
MathProposalSummary,
|
||||||
|
|
@ -224,3 +226,18 @@ export async function fetchPacks(limit?: number, offset?: number): Promise<PackS
|
||||||
export async function fetchPack(packId: string): Promise<PackDetail> {
|
export async function fetchPack(packId: string): Promise<PackDetail> {
|
||||||
return apiFetch<PackDetail>(`/packs/${encodeURIComponent(packId)}`);
|
return apiFetch<PackDetail>(`/packs/${encodeURIComponent(packId)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchVaultSummary(): Promise<VaultSummary> {
|
||||||
|
return apiFetch<VaultSummary>("/vault/summary");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchVaultEntries(limit?: number, offset?: number): Promise<VaultEntry[]> {
|
||||||
|
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<ItemsEnvelope<VaultEntry>>(
|
||||||
|
query ? `/vault/entries?${query}` : "/vault/entries",
|
||||||
|
);
|
||||||
|
return envelope.items;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ import {
|
||||||
fetchRun,
|
fetchRun,
|
||||||
fetchPacks,
|
fetchPacks,
|
||||||
fetchPack,
|
fetchPack,
|
||||||
|
fetchVaultSummary,
|
||||||
|
fetchVaultEntries,
|
||||||
fetchTraceTurn,
|
fetchTraceTurn,
|
||||||
fetchTraceTurns,
|
fetchTraceTurns,
|
||||||
fetchMathProposals,
|
fetchMathProposals,
|
||||||
|
|
@ -39,6 +41,8 @@ import type {
|
||||||
RunDetail,
|
RunDetail,
|
||||||
PackSummary,
|
PackSummary,
|
||||||
PackDetail,
|
PackDetail,
|
||||||
|
VaultSummary,
|
||||||
|
VaultEntry,
|
||||||
TurnJournalEntry,
|
TurnJournalEntry,
|
||||||
TurnJournalSummary,
|
TurnJournalSummary,
|
||||||
EvalLaneSummary,
|
EvalLaneSummary,
|
||||||
|
|
@ -295,3 +299,24 @@ export function usePack(packId?: string | null) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useVaultSummary() {
|
||||||
|
return useQuery<VaultSummary, WorkbenchApiError>({
|
||||||
|
queryKey: ["api", "vault", "summary"],
|
||||||
|
queryFn: () => fetchVaultSummary(),
|
||||||
|
retry: false,
|
||||||
|
staleTime: 30_000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVaultEntries(enabled: boolean, limit?: number, offset?: number) {
|
||||||
|
return useQuery<VaultEntry[], WorkbenchApiError>({
|
||||||
|
queryKey: ["api", "vault", "entries", limit ?? null, offset ?? null],
|
||||||
|
queryFn: () => fetchVaultEntries(limit, offset),
|
||||||
|
enabled,
|
||||||
|
retry: false,
|
||||||
|
staleTime: 30_000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import { ReplayRoute } from "./replay/ReplayRoute";
|
||||||
import { EvalsRoute } from "./evals/EvalsRoute";
|
import { EvalsRoute } from "./evals/EvalsRoute";
|
||||||
import { RunsRoute } from "./runs/RunsRoute";
|
import { RunsRoute } from "./runs/RunsRoute";
|
||||||
import { PacksRoute } from "./packs/PacksRoute";
|
import { PacksRoute } from "./packs/PacksRoute";
|
||||||
import { VaultRoutePlaceholder } from "../routes/VaultRoutePlaceholder";
|
import { VaultRoute } from "./vault/VaultRoute";
|
||||||
import { SettingsRoutePlaceholder } from "../routes/SettingsRoutePlaceholder";
|
import { SettingsRoutePlaceholder } from "../routes/SettingsRoutePlaceholder";
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
|
|
@ -28,7 +28,7 @@ export function App() {
|
||||||
<Route path="evals/:laneId?" element={<EvalsRoute />} />
|
<Route path="evals/:laneId?" element={<EvalsRoute />} />
|
||||||
<Route path="runs/:sessionId?" element={<RunsRoute />} />
|
<Route path="runs/:sessionId?" element={<RunsRoute />} />
|
||||||
<Route path="packs/:packId?" element={<PacksRoute />} />
|
<Route path="packs/:packId?" element={<PacksRoute />} />
|
||||||
<Route path="vault" element={<VaultRoutePlaceholder />} />
|
<Route path="vault" element={<VaultRoute />} />
|
||||||
<Route path="audit" element={<AuditRoute />} />
|
<Route path="audit" element={<AuditRoute />} />
|
||||||
<Route path="settings" element={<SettingsRoutePlaceholder />} />
|
<Route path="settings" element={<SettingsRoutePlaceholder />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { EvalsRoute } from "./evals/EvalsRoute";
|
||||||
import { ReplayRoute } from "./replay/ReplayRoute";
|
import { ReplayRoute } from "./replay/ReplayRoute";
|
||||||
import { RunsRoute } from "./runs/RunsRoute";
|
import { RunsRoute } from "./runs/RunsRoute";
|
||||||
import { PacksRoute } from "./packs/PacksRoute";
|
import { PacksRoute } from "./packs/PacksRoute";
|
||||||
|
import { VaultRoute } from "./vault/VaultRoute";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ADR-0162 §6 route conformance — executable, not aspirational.
|
* ADR-0162 §6 route conformance — executable, not aspirational.
|
||||||
|
|
@ -171,6 +172,17 @@ const MOUNT_ROUTES: MountRouteSpec[] = [
|
||||||
emptyStatement: "No packs discovered.",
|
emptyStatement: "No packs discovered.",
|
||||||
emptyCommand: "core pack validate <path>",
|
emptyCommand: "core pack validate <path>",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Vault",
|
||||||
|
element: <VaultRoute />,
|
||||||
|
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) => {
|
describe.each(MOUNT_ROUTES)("route conformance: $name", (spec) => {
|
||||||
|
|
|
||||||
185
workbench-ui/src/app/vault/VaultRoute.test.tsx
Normal file
185
workbench-ui/src/app/vault/VaultRoute.test.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<span data-testid="subject">
|
||||||
|
{subject.kind === "vault_entry" ? `vault_entry:${subject.entryIndex}` : subject.kind}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRoute() {
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={createTestQueryClient()}>
|
||||||
|
<MemoryRouter initialEntries={["/vault"]}>
|
||||||
|
<EvidenceProvider>
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/vault"
|
||||||
|
element={
|
||||||
|
<>
|
||||||
|
<VaultRoute />
|
||||||
|
<SubjectProbe />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</EvidenceProvider>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
225
workbench-ui/src/app/vault/VaultRoute.tsx
Normal file
225
workbench-ui/src/app/vault/VaultRoute.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<span className="inline-flex h-6 items-center rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface-inset)] px-2 text-xs text-[var(--color-text-secondary)]">
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VaultRow({
|
||||||
|
entry,
|
||||||
|
selected,
|
||||||
|
focused,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
entry: VaultEntry;
|
||||||
|
selected: boolean;
|
||||||
|
focused: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
}) {
|
||||||
|
const digest = digestPayload(entry.versor_digest);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-current={selected ? "true" : undefined}
|
||||||
|
onClick={onSelect}
|
||||||
|
className={`grid w-full grid-cols-[minmax(0,1fr)_auto] items-start gap-3 border-b border-[var(--color-border-subtle)] px-3 py-2 text-left transition-colors hover:bg-[var(--color-surface-inset)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-inset focus-visible:outline-[var(--color-focus-ring)] ${
|
||||||
|
selected ? "bg-[var(--color-selected-bg)]" : ""
|
||||||
|
} ${
|
||||||
|
selected
|
||||||
|
? "border-l-2 border-l-[var(--color-selected-border)] pl-[10px]"
|
||||||
|
: focused
|
||||||
|
? "border-l-2 border-l-[var(--color-focus-ring)] pl-[10px]"
|
||||||
|
: "border-l-2 border-l-transparent pl-[10px]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block font-mono text-xs text-[var(--color-text-muted)]">
|
||||||
|
#{entry.entry_index}
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 flex flex-wrap items-center gap-2">
|
||||||
|
<StatusPill>{entry.epistemic_status}</StatusPill>
|
||||||
|
<StatusPill>{entry.epistemic_state}</StatusPill>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="justify-self-end">
|
||||||
|
{digest ? (
|
||||||
|
<DigestBadge digest={digest} truncate={12} />
|
||||||
|
) : (
|
||||||
|
<span className="font-mono text-xs text-[var(--color-text-muted)]">no versor</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<MetadataTable
|
||||||
|
rows={[
|
||||||
|
{ key: "entry_count", value: String(summary.entry_count), mono: true },
|
||||||
|
{ key: "store_count", value: String(summary.store_count), mono: true },
|
||||||
|
{ key: "reproject_interval", value: String(summary.reproject_interval), mono: true },
|
||||||
|
{
|
||||||
|
key: "max_entries",
|
||||||
|
value: summary.max_entries === null ? "unbounded" : String(summary.max_entries),
|
||||||
|
mono: true,
|
||||||
|
},
|
||||||
|
{ key: "source_path", value: summary.source_path, mono: true },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FailClosed() {
|
||||||
|
return (
|
||||||
|
<EmptyState statement={FAIL_CLOSED_STATEMENT} nextAction={FAIL_CLOSED_ACTION} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <LoadingState label="Loading vault..." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fail-closed: no persisted vault is the expected primary state, not an error.
|
||||||
|
if (summaryQuery.isError) {
|
||||||
|
if (summaryQuery.error.code === "evidence_unavailable") {
|
||||||
|
return <FailClosed />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ErrorState
|
||||||
|
whatFailed={errorMessage(summaryQuery.error)}
|
||||||
|
mutationStatus="No vault mutation occurred."
|
||||||
|
reproducer="curl /vault/summary"
|
||||||
|
retrySafety="Retry: safe"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasEntries) {
|
||||||
|
return <FailClosed />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel
|
||||||
|
title="Vault"
|
||||||
|
toolbar={
|
||||||
|
<span className="font-mono text-xs text-[var(--color-text-muted)]">
|
||||||
|
{summary.entry_count} entries
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="grid min-h-0 gap-3">
|
||||||
|
<VaultSummaryStrip summary={summary} />
|
||||||
|
<SearchInput
|
||||||
|
placeholder="Filter by epistemic status, state, or index"
|
||||||
|
value={search}
|
||||||
|
onChange={setSearch}
|
||||||
|
/>
|
||||||
|
{entriesQuery.isLoading ? (
|
||||||
|
<LoadingState label="Loading vault entries..." />
|
||||||
|
) : entriesQuery.isError ? (
|
||||||
|
<ErrorState
|
||||||
|
whatFailed={errorMessage(entriesQuery.error)}
|
||||||
|
mutationStatus="No vault mutation occurred."
|
||||||
|
reproducer="curl /vault/entries"
|
||||||
|
retrySafety="Retry: safe"
|
||||||
|
/>
|
||||||
|
) : filteredEntries.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
statement="No vault entries match this filter."
|
||||||
|
nextAction={FAIL_CLOSED_ACTION}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<VirtualizedList
|
||||||
|
ariaLabel="Vault entries"
|
||||||
|
estimateSize={72}
|
||||||
|
getKey={(entry) => String(entry.entry_index)}
|
||||||
|
height="calc(100vh - 18rem)"
|
||||||
|
initialRect={{ width: 480, height: 560 }}
|
||||||
|
items={filteredEntries}
|
||||||
|
onActivate={(entry) => selectEntry(entry)}
|
||||||
|
renderItem={(entry, _index, focused) => (
|
||||||
|
<VaultRow
|
||||||
|
entry={entry}
|
||||||
|
selected={entry.entry_index === selectedIndex}
|
||||||
|
focused={focused}
|
||||||
|
onSelect={() => selectEntry(entry)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -20,9 +20,6 @@ import { describe, expect, it } from "vitest";
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const NOT_YET_MIRRORED = new Set([
|
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
|
// Wave R3 sealed turn replay backend — TS mirrors land with the frontend
|
||||||
// Replay Moment PR (which also retires the W-026 artifact-keyed
|
// Replay Moment PR (which also retires the W-026 artifact-keyed
|
||||||
// ReplayComparison/ReplayDivergence pair on both sides):
|
// ReplayComparison/ReplayDivergence pair on both sides):
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import { EmptyState } from "../design/components/states/EmptyState";
|
|
||||||
|
|
||||||
export function VaultRoutePlaceholder() {
|
|
||||||
return (
|
|
||||||
<EmptyState
|
|
||||||
statement="Vault — no data loaded yet."
|
|
||||||
nextAction="Pending W-029"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
|
|
||||||
export type ErrorCode =
|
export type ErrorCode =
|
||||||
| "bad_request"
|
| "bad_request"
|
||||||
|
| "evidence_unavailable"
|
||||||
| "not_found"
|
| "not_found"
|
||||||
| "unsupported"
|
| "unsupported"
|
||||||
| "read_error"
|
| "read_error"
|
||||||
|
|
@ -263,6 +264,23 @@ export interface ReplayComparison {
|
||||||
divergences: ReplayDivergence[];
|
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<string, unknown>;
|
||||||
|
versor_digest: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
// API envelope types
|
// API envelope types
|
||||||
export interface ApiOk<T> {
|
export interface ApiOk<T> {
|
||||||
ok: true;
|
ok: true;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue