workbench(vault): frame empty and unavailable vault states honestly (#760)

This commit is contained in:
Shay 2026-06-15 01:05:50 -07:00 committed by GitHub
parent 8aa18760e5
commit 4522e5b1cf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 168 additions and 49 deletions

View file

@ -23,7 +23,7 @@ import {
} from "./lived-life/LivedLifeRoute";
import { PacksRoute } from "./packs/PacksRoute";
import { LogosRoute } from "./logos/LogosRoute";
import { VaultRoute } from "./vault/VaultRoute";
import { VaultRoute, VAULT_ABSENCE_STATEMENT, VAULT_ABSENCE_ACTION } from "./vault/VaultRoute";
import { CalibrationRoute } from "./calibration/CalibrationRoute";
import { SettingsRoute } from "./settings/SettingsRoute";
@ -227,10 +227,10 @@ const MOUNT_ROUTES: MountRouteSpec[] = [
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",
// Fail-closed: absence of a persisted vault snapshot is the primary contract.
// The next action is the real daemon command, not a fake config-flag button.
emptyStatement: VAULT_ABSENCE_STATEMENT,
emptyCommand: VAULT_ABSENCE_ACTION,
},
{
name: "Calibration",

View file

@ -96,6 +96,26 @@ function stubVault({ persisted = true }: { persisted?: boolean } = {}) {
);
}
// A persisted snapshot that holds zero entries — distinct from absence. The
// entries query stays disabled (entry_count === 0), so /vault/entries is never
// hit; an unexpected hit fails the stub loudly.
function stubVaultPersistedEmpty() {
vi.stubGlobal(
"fetch",
vi.fn((input: unknown) => {
const path = new URL(String(input)).pathname;
if (path === "/vault/summary") {
return Promise.resolve({
json: () => Promise.resolve(okBody({ ...summary, entry_count: 0 })),
});
}
return Promise.resolve({
json: () => Promise.resolve(evidenceUnavailable(`unexpected ${path}`)),
});
}),
);
}
const offsetDescriptors = {
offsetHeight: Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight"),
offsetWidth: Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetWidth"),
@ -124,21 +144,40 @@ describe("VaultRoute", () => {
localStorage.clear();
});
it("fail-closed: an unpersisted vault renders the honest absence card, not an error", async () => {
it("fail-closed: an absent vault renders the honest absence card inside Vault chrome, not an error", async () => {
stubVault({ persisted: false });
renderRoute();
expect(
await screen.findByText(/No persisted vault\./),
await screen.findByText(/No persisted vault snapshot is available\./),
).toBeInTheDocument();
// both the statement and the config-pointer action name the opt-in flag
expect(
screen.getAllByText(/RuntimeConfig\.persist_session_state/).length,
).toBeGreaterThan(0);
// framed as the Vault route, not a context-free card floating in a blank
// surface (the "nothing comes up" symptom)
expect(screen.getByRole("heading", { name: "Vault" })).toBeInTheDocument();
// the statement still names the opt-in flag
expect(screen.getByText(/RuntimeConfig\.persist_session_state/)).toBeInTheDocument();
// the next action is a real, runnable command (copyable), not a dead button
expect(screen.getByText("core always-on")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /copy/i })).toBeInTheDocument();
// it is NOT the generic error contract
expect(screen.queryByText("What failed")).not.toBeInTheDocument();
});
it("persisted-but-empty: distinguishes 'no entries yet' from absence, still framed as Vault", async () => {
stubVaultPersistedEmpty();
renderRoute();
expect(
await screen.findByText(/Vault snapshot exists, but no entries have been stored yet\./),
).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Vault" })).toBeInTheDocument();
// it is a DIFFERENT statement from absence, and not the generic error
expect(
screen.queryByText(/No persisted vault snapshot is available\./),
).not.toBeInTheDocument();
expect(screen.queryByText("What failed")).not.toBeInTheDocument();
});
it("renders the summary strip and entries when persisted", async () => {
stubVault();
renderRoute();

View file

@ -12,11 +12,30 @@ 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";
// Vault persistence is opt-in; absence of a snapshot is the common, honest
// primary state — the fail-closed contract, not an error.
//
// The next action must be TRUE and runnable: `core always-on` is the daemon
// that forces persist_session_state=True (chat/always_on_daemon.py) and writes
// engine_state/session_state.json. There is no `core chat --persist-session-state`
// flag today, so we do not invent one — that would be the same dishonesty as a
// dead button. Exported so route-conformance asserts the exact contract string.
export const VAULT_ABSENCE_STATEMENT =
"No persisted vault snapshot is available. Session memory is held in-process " +
"and discarded on exit; persistence is opt-in (RuntimeConfig.persist_session_state). " +
"The always-on daemon writes the snapshot.";
export const VAULT_ABSENCE_ACTION = "core always-on";
const VAULT_PERSIST_CLI = { kind: "cli", command: VAULT_ABSENCE_ACTION } as const;
// A persisted snapshot that holds zero entries is a DIFFERENT honest state from
// absence: the vault exists, it just has not stored anything yet.
const VAULT_EMPTY_STATEMENT =
"Vault snapshot exists, but no entries have been stored yet.";
// Filtering is the operator's own action; the remedy is to relax the filter,
// not to enable persistence. Static guidance, not a command.
const VAULT_FILTER_EMPTY_STATEMENT = "No vault entries match this filter.";
const VAULT_FILTER_EMPTY_ACTION = "Clear the filter to see all entries.";
function errorMessage(error: unknown) {
return error instanceof WorkbenchApiError ? error.message : "Vault request failed.";
@ -103,9 +122,29 @@ function VaultSummaryStrip({ summary }: { summary: VaultSummary }) {
);
}
function FailClosed() {
// Route identity must survive every data state. The success path was the only
// branch that painted "Vault" chrome, so loading/absent/empty/error read as a
// context-free card floating in a blank surface ("nothing comes up"). Wrapping
// every branch in the same Panel keeps the route legible regardless of state.
function VaultFrame({
toolbar,
children,
}: {
toolbar?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<EmptyState statement={FAIL_CLOSED_STATEMENT} nextAction={FAIL_CLOSED_ACTION} />
<Panel title="Vault" toolbar={toolbar}>
{children}
</Panel>
);
}
function EntryCountToolbar({ count }: { count: number }) {
return (
<span className="font-mono text-xs text-[var(--color-text-muted)]">
{count} entries
</span>
);
}
@ -148,37 +187,69 @@ export function VaultRoute() {
}
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"
/>
<VaultFrame>
<LoadingState label="Loading vault..." />
</VaultFrame>
);
}
if (!hasEntries) {
return <FailClosed />;
// Fail-closed: no persisted vault snapshot is the expected primary state, not
// an error. The summary reader returns evidence_unavailable when the snapshot
// is absent (persistence is opt-in).
if (summaryQuery.isError) {
if (summaryQuery.error.code === "evidence_unavailable") {
return (
<VaultFrame>
<EmptyState statement={VAULT_ABSENCE_STATEMENT} nextAction={VAULT_PERSIST_CLI} />
</VaultFrame>
);
}
return (
<VaultFrame>
<ErrorState
whatFailed={errorMessage(summaryQuery.error)}
mutationStatus="No vault mutation occurred."
reproducer="curl /vault/summary"
retrySafety="Retry: safe"
/>
</VaultFrame>
);
}
// react-query guarantees data is present once the query is neither loading
// nor errored, but the `summary` alias was captured before that narrowing.
// Guard explicitly — a missing summary is treated as honest absence, framed.
if (!summary) {
return (
<VaultFrame>
<EmptyState statement={VAULT_ABSENCE_STATEMENT} nextAction={VAULT_PERSIST_CLI} />
</VaultFrame>
);
}
// summary is defined past this point. A snapshot whose `persisted` flag is not
// set is treated as absence — the same honest fail-closed card.
if (!summary.persisted) {
return (
<VaultFrame>
<EmptyState statement={VAULT_ABSENCE_STATEMENT} nextAction={VAULT_PERSIST_CLI} />
</VaultFrame>
);
}
// A persisted snapshot with zero entries is a DISTINCT state from absence: the
// vault exists, it just has not stored anything yet.
if (summary.entry_count === 0) {
return (
<VaultFrame toolbar={<EntryCountToolbar count={0} />}>
<EmptyState statement={VAULT_EMPTY_STATEMENT} nextAction={VAULT_PERSIST_CLI} />
</VaultFrame>
);
}
return (
<Panel
title="Vault"
toolbar={
<span className="font-mono text-xs text-[var(--color-text-muted)]">
{summary.entry_count} entries
</span>
}
>
<VaultFrame toolbar={<EntryCountToolbar count={summary.entry_count} />}>
<div className="grid min-h-0 gap-3">
<VaultSummaryStrip summary={summary} />
<SearchInput
@ -197,8 +268,8 @@ export function VaultRoute() {
/>
) : filteredEntries.length === 0 ? (
<EmptyState
statement="No vault entries match this filter."
nextAction={FAIL_CLOSED_ACTION}
statement={VAULT_FILTER_EMPTY_STATEMENT}
nextAction={VAULT_FILTER_EMPTY_ACTION}
/>
) : (
<VirtualizedList
@ -220,6 +291,6 @@ export function VaultRoute() {
/>
)}
</div>
</Panel>
</VaultFrame>
);
}

View file

@ -33,9 +33,13 @@ export function EmptyState({
</svg>
<p className="m-0 text-sm text-[var(--color-text-primary)] [text-wrap:balance]">{statement}</p>
{typeof nextAction === "string" ? (
<Button className="mt-3" variant="quiet" type="button">
// A plain-string action is non-runnable guidance, not a command, so it
// renders as static text — never an interactive control. A click-dead
// button reads as "I clicked and nothing happened"; runnable steps use
// the { kind: "cli", command } form below (code + Copy).
<p className="mt-3 text-sm text-[var(--color-text-secondary)] [text-wrap:balance]">
{nextAction}
</Button>
</p>
) : (
<div className="mt-3 flex items-center gap-2">
<code className="flex-1 rounded bg-[var(--color-surface-sunken)] px-2 py-1 font-mono text-xs text-[var(--color-text-primary)]">

View file

@ -6,10 +6,15 @@ import { ErrorState } from "./ErrorState";
import { LoadingState } from "./LoadingState";
describe("state components", () => {
it("requires empty-state statement and next action", () => {
it("renders a string nextAction as static guidance, not a dead button", () => {
render(<EmptyState statement="No trace selected." nextAction="Select a trace." />);
expect(screen.getByText("No trace selected.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Select a trace." })).toBeInTheDocument();
// a plain-string action is guidance, not an interactive control — a
// click-dead button reads as "I clicked and nothing happened"
expect(screen.getByText("Select a trace.")).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Select a trace." }),
).not.toBeInTheDocument();
});
it("renders cli-form nextAction as mono command row with copy button", async () => {