workbench(vault): frame empty and unavailable vault states honestly (#760)
This commit is contained in:
parent
8aa18760e5
commit
4522e5b1cf
5 changed files with 168 additions and 49 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)]">
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue