diff --git a/CHANGELOG.md b/CHANGELOG.md index 36e9cf0..d3580a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ ### Added +- Coalesce identical snapshots across viewers in a bounded in-memory LRU cache with per-panel TTL overrides, stale-while-revalidate, action/reload invalidation and separate principal keys. Declared readOnly principal tools preserve the cache; other principal actions and all console actions invalidate it. Keep producer bodies and error snapshots unchanged, expose freshness only through cache/age/sample-time headers and add System cache/provider timing diagnostics, and add a private-output snapshot timing probe (#105). + - Add the Space Flow view with per-project actors, directed 15-minute message counts, distinct relationship strokes and recent activity. Keep at most 200 public link events for 30 minutes in page memory, and show per-peer counts and the last 10 events in agent detail. Suspend the minute refresh while Flow is hidden (#101). - Make Team the default Space view while honoring a saved view choice in `acon.space.section`; the Space header now scrolls with the page. Add per-group attention and active-agent summaries, collapsed idle/away rows, and namespaced actor detail routes with responsive presentation and focus restoration; keep the roster in Room. Extend the browser probe for Team/detail navigation and persistence, including a reload with `view` removed (#101). diff --git a/README.md b/README.md index 2297ce7..0841661 100644 --- a/README.md +++ b/README.md @@ -295,6 +295,14 @@ Versions and upgrades (#26): - Versioned registrations: a tool may write `plugins/@.json` (semantic version) beside `plugins/.json`. Per tool the newest one that verifies and loads is used; if it fails to load, the next one is tried; the others are listed as `superseded`. - No restart needed for a new path: the console re-scans the directory at most every 5 s while a page is open. A module at a path not loaded before (a versioned install such as `releases//`) serves pages opened after it; an open page keeps the load it started with (System says when a newer one is registered), and every load stays in memory until the console restarts. A module from a package the console already imported from (changed in place, or a file first loaded as a dependency) is not hot-loaded, because the runtime keeps one module per path: the current load keeps serving and System says to restart. If a registration turns stale or disappears, the current load keeps serving and System records the problem once. +Snapshot reuse (#105): the server shares an in-process cache between viewers, keyed by tool, provider and canonical snapshot input. Identical concurrent requests share one provider call. The default TTL is `min(pollMs / 2, 2000)` ms (`pollMs` defaults to 5000). A plugin can set `panels[].maxAgeMs`, `capabilities.inboxMaxAgeMs` or `capabilities.spaceMaxAgeMs` to a finite value from 0 to 60000 ms; 0 disables reuse while preserving concurrent-call coalescing. An explicitly declared panel owns its TTL even when its id is `space`; capability TTLs apply only to undeclared reserved panels with the corresponding capability enabled. The fields are additive API 1 options, and conformance checks their bounds. + +During one extra TTL of grace, a cached reply can be served immediately while one background refresh runs. A failed refresh retains the last good value during the grace window. Cache diagnostics use `X-Acon-Cache: hit|miss|stale`, `X-Acon-Cache-Age-Ms` and `X-Acon-Sampled-At` (ISO time) response headers, also exposed to configured CORS origins. Producer snapshot bodies, including their own `stale` and `sampledAt` fields, stay unchanged. A producer `ok: false` snapshot stays a status-200 reply and is never retained as a cache entry. Every console action and each principal action not declared `readOnly` invalidate that tool's entries before returning, and a provider reload drops the old provider's entries. Principal snapshots have a separate namespace and include the authenticated principal in their canonical input. The current MCP `tools/call` route uses the principal action port; those calls remain actions rather than cached panel reads. A principal tool declared `readOnly` preserves the cache, matching its availability on a read-only node. + +The cache holds at most 256 entries and 4 MiB, evicts least recently used entries, and writes nothing to disk. System > Snapshot cache shows entry/byte totals and hit, miss and stale counts by tool, panel and namespace. Its p50/p95 provider durations use a rolling window of the last 128 calls; cache hits do not add provider timing samples. Older nodes without these diagnostics remain usable. + +Run `bun scripts/perf-snapshots.ts` to load registered plugins and print cold and warm provider timings for each declared panel, inbox and Space. It reads the standard `AGENTHUB_HOME`, calls snapshots directly to expose provider cost rather than a cache hit, and closes providers afterward. Treat the output as private operational data; do not commit personal plugin timings or results. + - Inbox: declare `capabilities.inbox` and answer `snapshot({panel: "inbox"})` with `{items: InboxItem[]}`. Verbs run through `action({action: verb.id, id})`. Keep each item's `at` the same across snapshots: snooze and oldest-first order key on it, and conformance checks it. - Reasons: a verb with `reason: "optional"` or `"required"` makes the console ask for a reason inline and send it as `reason` in the action input; a required reason cannot be sent empty. - Features (#27): `open(ctx)` receives `ctx.features` from this console: `"inbox.form"`, `"inbox.result"`, `"action.meta"` and `"principal"`. The `"principal"` feature advertises separate principal ports, public JSON projection and `meta.actor`. Send forms and result items only when the matching feature is present; an older console has no `features` and reports both as malformed. diff --git a/scripts/perf-snapshots.ts b/scripts/perf-snapshots.ts new file mode 100644 index 0000000..1a7b8a4 --- /dev/null +++ b/scripts/perf-snapshots.ts @@ -0,0 +1,52 @@ +import { familyHome, loadPlugins, scanPlugins, withTimeout } from "../src/plugins.ts"; + +/** Direct provider timings, deliberately excluding server cache hits. Never print snapshot contents. */ +async function main(): Promise { + if (process.argv.slice(2).includes("--help")) { + console.log("Usage: bun scripts/perf-snapshots.ts\nLoads registered plugins from AGENTHUB_HOME and prints cold/warm provider timings. Output is private; do not commit it."); + return 0; + } + if (process.argv.length > 2) throw new Error("unknown argument; use --help"); + const home = familyHome(); + const scan = scanPlugins(home); + if (scan.error) { console.error(`snapshot timings: ${scan.error}`); return 1; } + const plugins = await loadPlugins(scan, { home, log: (line) => console.error(`snapshot timings: ${line}`) }); + const ready = plugins.filter((entry) => entry.status === "ready" && entry.provider && entry.manifest); + let failed = plugins.some((entry) => !["ready", "superseded"].includes(entry.status)); + console.log("tool\tpanel\tcold_ms\twarm_ms"); + try { + for (const entry of ready) { + const manifest = entry.manifest!; + const panels = new Set(manifest.panels.map((panel) => panel.id)); + if (manifest.capabilities?.inbox) panels.add("inbox"); + if (manifest.capabilities?.space) panels.add("space"); + for (const panel of panels) { + const timings: number[] = []; + try { + for (let call = 0; call < 2; call++) { + const start = performance.now(); + await withTimeout(Promise.resolve().then(() => entry.provider!.snapshot({ panel, after: 0 })), 30_000, `${entry.tool}/${panel} snapshot`); + timings.push(performance.now() - start); + } + console.log(`${entry.tool}\t${panel}\t${timings[0]!.toFixed(2)}\t${timings[1]!.toFixed(2)}`); + } catch (error) { + failed = true; + console.error(`snapshot timings: ${entry.tool}/${panel}: ${error instanceof Error ? error.message : String(error)}`); + } + } + } + if (!ready.length) console.log("No ready plugins registered."); + } finally { + const providers = new Set(ready.map((entry) => entry.provider!)); + for (const provider of providers) { + try { await withTimeout(Promise.resolve().then(() => provider.close?.()), 5000, "provider close"); } + catch (error) { failed = true; console.error(`snapshot timings: ${error instanceof Error ? error.message : String(error)}`); } + } + } + return failed ? 1 : 0; +} + +process.exitCode = await main().catch((error: unknown) => { + console.error(`snapshot timings: ${error instanceof Error ? error.message : String(error)}`); + return 1; +}); diff --git a/shell/shell.js b/shell/shell.js index f39c60d..82c95ca 100644 --- a/shell/shell.js +++ b/shell/shell.js @@ -25,7 +25,7 @@ const $ = (id) => document.getElementById(id); // Module references: these move into a Picture-in-Picture document with Mini, where getElementById cannot find them. const confirmEl = $("confirm"); const state = { - tools: [], pets: [], assetPacks: [], space: new Map(), node: null, serverErrors: [], clientErrors: [], + tools: [], pets: [], assetPacks: [], space: new Map(), node: null, snapshotCache: null, serverErrors: [], clientErrors: [], route: { area: "inbox", params: {} }, inbox: new Map(), inboxSig: "", selectedKey: null, peekKey: null, panels: new Map(), @@ -2060,8 +2060,31 @@ function staticNodeCards() { }); } +function snapshotCacheCard() { + const caches = staticTransport + ? staticTransport.nodes.filter((node) => node.data?.snapshotCache).map((node) => ({ id: node.id, ...node.data.snapshotCache })) + : state.snapshotCache ? [{ id: "local", ...state.snapshotCache }] : []; + const number = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0; + const sections = caches.map((cache) => { + const metrics = Array.isArray(cache.metrics) ? cache.metrics : []; + return h("div", {}, + h("h3", { class: "card-sub", text: cache.id }), + h("p", { class: "sub", text: `${fmtNum(number(cache.entries))} entries · ${fmtNum(number(cache.bytes) / 1024)} KiB in memory` }), + metrics.length ? h("ul", { class: "list" }, metrics.map((metric) => h("li", {}, + h("strong", { text: `${str(metric.tool)} / ${str(metric.panel)}` }), + h("span", { class: "meta", text: str(metric.namespace) }), + h("span", { text: `${fmtNum(number(metric.hits))} hit · ${fmtNum(number(metric.misses))} miss · ${fmtNum(number(metric.stale))} stale` }), + h("span", { class: "meta", text: `Provider p50 ${fmtNum(number(metric.p50Ms))} ms · p95 ${fmtNum(number(metric.p95Ms))} ms · ${fmtNum(number(metric.samples))} samples` })))) + : h("p", { class: "sub", text: "No snapshot requests measured yet." })); + }); + return h("section", { class: "card wide", "data-snapshot-cache": "true" }, + h("h2", { text: "Snapshot cache" }), + h("p", { class: "sub", text: "Counts are per tool and panel. Provider timings use the latest 128 calls; cache hits do not add timing samples." }), + ...(sections.length ? sections : [h("p", { class: "sub", text: "This node does not report snapshot cache diagnostics." })])); +} + function renderSystem(force = false) { - const sig = JSON.stringify([state.node, state.tools, state.serverErrors, state.clientErrors, themeChoice(), state.offline, state.pets, petChoice()?.id ?? "off", staticTransport?.nodes.map((n) => [n.id, n.status, n.detail])]); + const sig = JSON.stringify([state.node, state.tools, state.snapshotCache, state.serverErrors, state.clientErrors, themeChoice(), state.offline, state.pets, petChoice()?.id ?? "off", staticTransport?.nodes.map((n) => [n.id, n.status, n.detail, n.data?.snapshotCache])]); if (!force && sig === systemSig && $("view-system").childElementCount) return; systemSig = sig; // Keep keyboard focus on the same control across re-renders. @@ -2092,6 +2115,7 @@ function renderSystem(force = false) { state.tools.length ? h("ul", { class: "list" }, state.tools.map((t) => h("li", {}, h("strong", { text: t.title ?? t.tool }), h("span", { class: "meta mono", text: t.tool }), statusChip(t.status), h("span", { class: "meta", text: t.status === "ready" ? `${t.version} · ${plural(t.panels.length, "panel")}${t.capabilities?.inbox ? " · inbox" : ""}${t.capabilities?.search ? " · search" : ""}${t.update ? ` · ${t.update} is registered; reload the page to use it` : ""}` : str(t.reason) })))) : h("p", { class: "sub", text: "No plugin manifests were found. A tool registers itself with its setup command." })), + snapshotCacheCard(), h("section", { class: "card wide" }, h("h2", { text: `Errors (${errors.length})` }), errors.length ? h("ul", { class: "list" }, errors.map((e) => h("li", {}, h("span", { class: "meta mono", text: new Date(e.at).toLocaleTimeString() }), h("strong", { text: e.tool }), h("span", { class: "meta", text: e.where }), h("span", { text: e.message })))) : h("p", { class: "sub", text: "No errors since the console started." })))); @@ -2115,6 +2139,7 @@ async function loadTools() { updateReadOnly(); state.pets = Array.isArray(r.data.pets) ? r.data.pets.filter((p) => p && PET_ID.test(str(p.id)) && str(p.name)) : []; state.assetPacks = Array.isArray(r.data.assetPacks) ? r.data.assetPacks.filter((p) => p && PET_ID.test(str(p.id)) && Array.isArray(p.images) && p.images.every((id) => PET_ID.test(str(id))) && Array.isArray(p.tile) && p.tile.length === 2 && p.tile.every((n) => Number.isSafeInteger(n) && n > 0)) : []; + state.snapshotCache = r.data.snapshotCache && typeof r.data.snapshotCache === "object" ? r.data.snapshotCache : null; state.serverErrors = Array.isArray(r.data.errors) ? r.data.errors : []; renderRail(); if (state.route.area === "system") renderSystem(); diff --git a/src/contract.ts b/src/contract.ts index 8ba75e5..532058c 100644 --- a/src/contract.ts +++ b/src/contract.ts @@ -21,7 +21,8 @@ export interface ConsolePanelManifest { title: string; /** Area icon name the shell knows (agents, memory, vault, ...); unknown names fall back to a generic glyph. */ icon?: string; - panels: { id: string; title: string; icon?: string; order?: number }[]; + /** maxAgeMs opts a panel into snapshot reuse for 0-60000 ms; 0 disables reuse. */ + panels: { id: string; title: string; icon?: string; order?: number; maxAgeMs?: number }[]; /** Palette entries; each runs action({...input, action}) through the same confirm path as a verb. */ commands?: ConsoleCommand[]; /** Poll interval for the visible panel; the shell clamps it to 2-60 s. */ @@ -32,10 +33,14 @@ export interface ConsolePanelManifest { multiProject?: boolean; /** snapshot({panel: "inbox", after}) returns {items: InboxItem[]}. */ inbox?: boolean; + /** Inbox snapshot reuse for 0-60000 ms; absent uses the poll-derived default. */ + inboxMaxAgeMs?: number; /** snapshot({panel: "search", q, limit}) returns {hits: SearchHit[], total}. */ search?: boolean; /** snapshot({panel: "space", after}) returns {scene: SpaceScene}, public fields only. */ space?: boolean; + /** Space snapshot reuse for 0-60000 ms; absent uses the poll-derived default. */ + spaceMaxAgeMs?: number; /** Principal-only tools; never dispatched through the person snapshot/action ports (#88). */ principal?: { tools: PrincipalTool[] }; }; diff --git a/src/plugins.ts b/src/plugins.ts index c701940..6d0d807 100644 --- a/src/plugins.ts +++ b/src/plugins.ts @@ -6,6 +6,7 @@ import { basename, dirname, isAbsolute, join } from "node:path"; import { ACTION_ID, CONSOLE_API_MAX, CONSOLE_API_MIN, CONSOLE_FEATURES, PANEL_ID, TOOL_ID, type ActionMeta, type ActionResult, type ConsoleContext, type ConsolePanelManifest, type ConsolePlugin, type ConsoleProvider } from "./contract.ts"; import { imageType } from "./assets.ts"; import { isPlainObject, projectPublicValue, PUBLIC_CAPS } from "./public.ts"; +import { snapshotMaxAge, type SnapshotCache } from "./snapshot-cache.ts"; export const familyHome = (env: Record = process.env): string => env.AGENTHUB_HOME || join(homedir(), ".agenthub"); export const pluginDir = (home: string): string => join(home, "console", "plugins"); @@ -29,6 +30,8 @@ export interface PluginEntry { module?: string; manifest?: ConsolePanelManifest; provider?: ConsoleProvider; + /** Node-owned cache, shared by this tool's console and principal ports. */ + snapshotCache?: SnapshotCache; } export interface Scan { dir: string; entries: PluginEntry[]; error?: string } @@ -203,6 +206,8 @@ function readEntry(file: string): PluginEntry { return { tool, file, version, module: real, sha256: actual, status: "verified" }; } +const validMaxAge = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 60_000; + /** Problems with a plugin manifest, empty when valid. Shared with the conformance suite. */ export function manifestProblems(manifest: unknown, tool?: string): string[] { if (!manifest || typeof manifest !== "object") return ["manifest is missing"]; @@ -222,9 +227,14 @@ export function manifestProblems(manifest: unknown, tool?: string): string[] { else if (ids.has(p.id)) out.push(`panel id ${p.id} is duplicated`); else ids.add(p.id); if (!p || typeof p.title !== "string" || !p.title) out.push("every panel needs a title"); + if (p?.maxAgeMs !== undefined && !validMaxAge(p.maxAgeMs)) out.push(`panel ${String(p.id)} maxAgeMs must be a finite number from 0 to 60000 ms`); } } if (m.pollMs !== undefined && (typeof m.pollMs !== "number" || !Number.isFinite(m.pollMs) || m.pollMs <= 0)) out.push("manifest.pollMs must be a positive number"); + for (const key of ["inboxMaxAgeMs", "spaceMaxAgeMs"] as const) { + const value = m.capabilities?.[key]; + if (value !== undefined && !validMaxAge(value)) out.push(`capabilities.${key} must be a finite number from 0 to 60000 ms`); + } const actions = m.capabilities?.actions; if (actions !== undefined && (!Array.isArray(actions) || actions.some((a) => typeof a !== "string"))) out.push("capabilities.actions must be strings"); if (m.capabilities?.space !== undefined && typeof m.capabilities.space !== "boolean") out.push("capabilities.space must be a boolean"); @@ -300,7 +310,10 @@ export async function dispatchConsoleSnapshot(entry: PluginEntry, input: { after export async function dispatchConsoleAction(entry: PluginEntry, input: { action: string } & Record, meta: ActionMeta): Promise { if (meta.actor?.kind === "principal") throw new Error("a principal cannot use console action dispatch; call dispatchPrincipalAction instead"); - return entry.provider!.action(input, { identity: meta.identity, node: meta.node, via: meta.via, actor: { kind: "person", id: meta.identity } }); + entry.snapshotCache?.invalidateTool(entry.tool); + try { + return await entry.provider!.action(input, { identity: meta.identity, node: meta.node, via: meta.via, actor: { kind: "person", id: meta.identity } }); + } finally { entry.snapshotCache?.invalidateTool(entry.tool); } } function principalProvider(entry: PluginEntry, principal: string): ConsoleProvider { @@ -322,17 +335,26 @@ export async function dispatchPrincipalSnapshot(entry: PluginEntry, input: { pri if (typeof provider.principalSnapshot !== "function") throw new Error("plugin has no principalSnapshot port; implement it before serving principal snapshots"); if (typeof input.panel !== "string" || !PANEL_ID.test(input.panel)) throw new Error("principal panel is invalid; pass a valid panel id to the principal snapshot port"); if (!declaresPanel(entry.manifest!, input.panel)) throw new Error("principal panel is not declared by this plugin; select a declared panel or panel capability"); - return principalResult(await provider.principalSnapshot({ principal: input.principal, panel: input.panel })); + const load = async () => principalResult(await provider.principalSnapshot!({ principal: input.principal, panel: input.panel })); + if (!entry.snapshotCache) return load(); + const reply = await entry.snapshotCache.read({ provider, tool: entry.tool, panel: input.panel, namespace: "principal", input, + ttlMs: snapshotMaxAge(entry.manifest!, input.panel), load }); + return reply.value; } export async function dispatchPrincipalAction(entry: PluginEntry, input: { principal: string; tool: string; input: Record }, node: string): Promise { const provider = principalProvider(entry, input.principal); - if (!entry.manifest!.capabilities!.principal!.tools.some((tool) => tool.name === input.tool)) throw new Error("principal tool is not declared by this plugin; select a tool from capabilities.principal.tools"); + const declaration = entry.manifest!.capabilities!.principal!.tools.find((tool) => tool.name === input.tool); + if (!declaration) throw new Error("principal tool is not declared by this plugin; select a tool from capabilities.principal.tools"); if (typeof provider.principalAction !== "function") throw new Error("plugin has no principalAction port; implement it before serving principal tools"); if (!isPlainObject(input.input)) throw new Error("principal tool input is not an object; pass a JSON object matching its inputSchema"); if (["confirmed", "fingerprint", "confirmToken"].some((key) => key in input.input)) throw new Error("principal input may not set confirmed, fingerprint or confirmToken; remove confirmation fields and request person approval through the console"); - const result = await provider.principalAction({ principal: input.principal, tool: input.tool, input: input.input }, - { identity: `principal:${input.principal}`, node, via: "console", actor: { kind: "principal", id: input.principal } }); + if (!declaration.readOnly) entry.snapshotCache?.invalidateTool(entry.tool); + let result: ActionResult; + try { + result = await provider.principalAction({ principal: input.principal, tool: input.tool, input: input.input }, + { identity: `principal:${input.principal}`, node, via: "console", actor: { kind: "principal", id: input.principal } }); + } finally { if (!declaration.readOnly) entry.snapshotCache?.invalidateTool(entry.tool); } const projected = principalResult(result); if (typeof projected.ok !== "boolean") throw new Error("principalAction returned no boolean ok after projection; return a JSON data property ok in the plugin ActionResult"); return projected as ActionResult; diff --git a/src/principal.ts b/src/principal.ts index 273f344..500d10d 100644 --- a/src/principal.ts +++ b/src/principal.ts @@ -273,7 +273,7 @@ export function createPrincipalEndpoint(options: PrincipalEndpointOptions): (req } attempt.outcome = "error"; return error(id, -32000, "principal tool failed and its outcome may be unknown; check the principal tool status before retrying or request person approval in the console", 502); - } + } finally { if (!selection.tool.readOnly) selection.plugin.snapshotCache?.invalidateTool(selection.plugin.tool); } } finally { if (releaseOnReturn) slot.active--; } } diff --git a/src/server.ts b/src/server.ts index 885545e..4088017 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2,6 +2,7 @@ import { randomBytes } from "node:crypto"; import { readFileSync } from "node:fs"; import { dirname } from "node:path"; import { BodyError, createPrincipalEndpoint, readBoundedJson, readBoundedText, PRINCIPAL_INPUT_MAX } from "./principal.ts"; +import { SnapshotCache, snapshotMaxAge } from "./snapshot-cache.ts"; import { canonical } from "./canonical.ts"; import { ACTION_ID, type ActionMeta, type ActionResult } from "./contract.ts"; import { ASSET_TYPES, assetResultProblems, dispatchConsoleSnapshot, dispatchConsoleAction, declaresPanel, TimeoutError, withTimeout, type PluginEntry } from "./plugins.ts"; @@ -113,11 +114,17 @@ export function startServer(options: ServerOptions) { files["/vendor/icons/icons.js"] = { body: asset("vendor/icons/icons.js"), type: "text/javascript; charset=utf-8" }; // tools: the current load per tool, for new pages. refs: every load since start, so an open page keeps its version. // A tool whose registration later goes stale or disappears keeps its current load until the console restarts. + const snapshots = new SnapshotCache({ now, onError: (tool, panel, error) => record(tool, `snapshot ${panel}`, error) }); const tools = new Map(); const refs = new Map(); let entries: PluginEntry[] = []; const adopt = (list: PluginEntry[]) => { - for (const p of list) if (p.status === "ready") { refs.set(p.ref ?? p.tool, p); tools.set(p.tool, p); } + for (const p of list) if (p.status === "ready") { + const old = tools.get(p.tool); + if (old?.provider && old.provider !== p.provider) snapshots.dropProvider(old.provider); + const current = { ...p, snapshotCache: snapshots }; + refs.set(p.ref ?? p.tool, current); tools.set(p.tool, current); + } entries = list; }; adopt(options.plugins); @@ -188,13 +195,17 @@ export function startServer(options: ServerOptions) { if (typeof limit !== "number" || !Number.isSafeInteger(limit) || limit < 1 || limit > 10) return reject(400, "limit must be 1-10"); } try { - const result = await call(entry.tool, "snapshot", () => dispatchConsoleSnapshot(entry, { ...input, after, panel })); - if (!result || typeof result !== "object" || Array.isArray(result)) throw new Error("snapshot must return an object"); - if (panel === "space" && caps.space === true) { - const clean = sanitizeSpaceScene(result.scene); - return json({ ok: true, ...clean, ...(Number.isSafeInteger(result.cursor) && (result.cursor as number) >= 0 ? { cursor: result.cursor } : {}) }); - } - return json(result); + const request = { ...input, after, panel }; + const reply = await snapshots.read({ provider: entry.provider!, tool: entry.tool, panel, namespace: "console", input: request, + ttlMs: snapshotMaxAge(entry.manifest!, panel), load: async () => { + const result = await call(entry.tool, "snapshot", () => dispatchConsoleSnapshot(entry, request)); + if (!result || typeof result !== "object" || Array.isArray(result)) throw new Error("snapshot must return an object"); + if (result.ok === false || panel !== "space" || caps.space !== true) return result; + const clean = sanitizeSpaceScene(result.scene); + return { ok: true, ...clean, ...(Number.isSafeInteger(result.cursor) && (result.cursor as number) >= 0 ? { cursor: result.cursor } : {}) }; + } }); + return json(reply.value, 200, { "X-Acon-Cache": reply.status, "X-Acon-Cache-Age-Ms": String(reply.ageMs), + "X-Acon-Sampled-At": new Date(reply.sampledAt).toISOString() }); } catch (error) { record(entry.tool, `snapshot ${panel}`, error); return reject(502, `${entry.tool} could not refresh; details are in System`); @@ -256,6 +267,7 @@ export function startServer(options: ServerOptions) { record(entry.tool, `action ${name}`, new Error(`still running after ${callMs / 1000} s; outcome unknown`)); running.then((late) => record(entry.tool, `action ${name}`, new Error(`finished after the timeout: ${late && typeof late === "object" && "ok" in late && late.ok ? "ok" : "not ok"}`)), (late) => record(entry.tool, `action ${name}`, late)); + snapshots.invalidateTool(entry.tool); return reject(504, `${entry.tool} is still working on ${name}; check System for the outcome before you retry`, { indeterminate: true }); } record(entry.tool, `action ${name}`, error); @@ -429,7 +441,7 @@ export function startServer(options: ServerOptions) { if (path === "/ping") return json({ ok: true }); if (path === "/tools") { await refresh(); - return json({ ok: true, ...(serve?.readOnly ? { readOnly: true } : {}), node: { id: "local", version: options.version, startedAt, pluginsDir: options.pluginsDir, pluginsError: options.pluginsError }, tools: toolList(), pets: pets.map(({ id, name, description }) => ({ id, name, ...(description ? { description } : {}) })), assetPacks: assetPacks.map(({ id, name, version, tile, images, models }) => ({ id, name, version, tile, images: Object.keys(images), ...(models ? { models: Object.keys(models) } : {}) })), errors }); + return json({ ok: true, ...(serve?.readOnly ? { readOnly: true } : {}), node: { id: "local", version: options.version, startedAt, pluginsDir: options.pluginsDir, pluginsError: options.pluginsError }, tools: toolList(), pets: pets.map(({ id, name, description }) => ({ id, name, ...(description ? { description } : {}) })), snapshotCache: snapshots.diagnostics(), assetPacks: assetPacks.map(({ id, name, version, tile, images, models }) => ({ id, name, version, tile, images: Object.keys(images), ...(models ? { models: Object.keys(models) } : {}) })), errors }); } const entry = pluginEntry(); if (!entry) return unknownTool(); @@ -450,6 +462,7 @@ export function startServer(options: ServerOptions) { if (requestOrigin && serve.origins.includes(requestOrigin)) { response.headers.set("Access-Control-Allow-Origin", requestOrigin); response.headers.set("Access-Control-Allow-Credentials", "true"); + response.headers.set("Access-Control-Expose-Headers", "X-Acon-Cache, X-Acon-Cache-Age-Ms, X-Acon-Sampled-At"); } } return response; diff --git a/src/snapshot-cache.ts b/src/snapshot-cache.ts new file mode 100644 index 0000000..3455baa --- /dev/null +++ b/src/snapshot-cache.ts @@ -0,0 +1,130 @@ +import { createHash } from "node:crypto"; +import { canonical } from "./canonical.ts"; +import type { ConsolePanelManifest } from "./contract.ts"; + +export const SNAPSHOT_CACHE_LIMITS = { entries: 256, bytes: 4 * 1024 * 1024, samples: 128 } as const; +export type SnapshotNamespace = "console" | "principal"; +export interface SnapshotCacheReply { + value: Record; + status: "hit" | "miss" | "stale"; + sampledAt: number; + ageMs: number; +} +interface ReadInput { + provider: object; tool: string; panel: string; namespace: SnapshotNamespace; + input: Record; ttlMs: number; + load: () => Promise>; +} +interface Value { + provider: object; tool: string; json: string; sampledAt: number; bytes: number; +} +interface Metric { + tool: string; panel: string; namespace: SnapshotNamespace; + hits: number; misses: number; stale: number; times: number[]; +} +interface Flight { provider: object; tool: string; promise: Promise } + +/** Reuse is opt-in beyond half the producer poll interval, with a 2 s default ceiling. */ +export function snapshotMaxAge(manifest: ConsolePanelManifest, panel: string): number { + const declared = manifest.panels.find((item) => item.id === panel); + const configured = declared ? declared.maxAgeMs + : panel === "inbox" && manifest.capabilities?.inbox ? manifest.capabilities.inboxMaxAgeMs + : panel === "space" && manifest.capabilities?.space === true ? manifest.capabilities.spaceMaxAgeMs : undefined; + return configured ?? Math.min((manifest.pollMs ?? 5000) / 2, 2000); +} + +/** Per-node, memory-only values. A provider identity and namespace fence every canonical input. */ +export class SnapshotCache { + private values = new Map(); + private flights = new Map(); + private providers = new WeakMap(); + private nextProvider = 0; + private metrics = new Map(); + private bytes = 0; + private now: () => number; + constructor(private options: { now?: () => number; onError?: (tool: string, panel: string, error: unknown) => void } = {}) { + this.now = options.now ?? Date.now; + } + private metric(input: ReadInput): Metric { + const key = JSON.stringify([input.namespace, input.tool, input.panel]); + let metric = this.metrics.get(key); + if (!metric) { + if (this.metrics.size >= SNAPSHOT_CACHE_LIMITS.entries) this.metrics.delete(this.metrics.keys().next().value!); + metric = { tool: input.tool, panel: input.panel, namespace: input.namespace, hits: 0, misses: 0, stale: 0, times: [] }; + this.metrics.set(key, metric); + } + return metric; + } + private key(input: ReadInput): string { + let id = this.providers.get(input.provider); + if (id === undefined) { id = ++this.nextProvider; this.providers.set(input.provider, id); } + return `${id}:${createHash("sha256").update(canonical([input.namespace, input.tool, input.input])).digest("hex")}`; + } + private remove(key: string) { + const value = this.values.get(key); + if (value) { this.bytes -= value.bytes; this.values.delete(key); } + } + private reply(value: Value, status: SnapshotCacheReply["status"]): SnapshotCacheReply { + // Callers cannot mutate the cached object (or another viewer's answer). + return { value: JSON.parse(value.json), sampledAt: value.sampledAt, ageMs: Math.max(0, this.now() - value.sampledAt), status }; + } + private refresh(key: string, input: ReadInput, metric: Metric): Promise { + const existing = this.flights.get(key); + if (existing) return existing.promise; + // Bound even pending unique keys. Never evict a flight and duplicate its provider work. + if (this.flights.size >= SNAPSHOT_CACHE_LIMITS.entries) return Promise.reject(new Error("snapshot capacity reached; retry after an active snapshot finishes")); + const started = this.now(); + const flight: Flight = { provider: input.provider, tool: input.tool, promise: undefined! }; + flight.promise = Promise.resolve().then(input.load).then((data) => { + if (!data || typeof data !== "object" || Array.isArray(data)) throw new Error("snapshot must return an object"); + const json = JSON.stringify(data); + const value = { provider: input.provider, tool: input.tool, json, sampledAt: this.now(), bytes: Buffer.byteLength(key) + Buffer.byteLength(json) }; + // Invalidation detaches a flight. Its late result must never repopulate the cache. + if (this.flights.get(key) === flight && input.ttlMs > 0 && data.ok !== false) { + this.remove(key); + if (value.bytes <= SNAPSHOT_CACHE_LIMITS.bytes) { + while (this.values.size >= SNAPSHOT_CACHE_LIMITS.entries || this.bytes + value.bytes > SNAPSHOT_CACHE_LIMITS.bytes) this.remove(this.values.keys().next().value!); + this.values.set(key, value); this.bytes += value.bytes; + } + } + return value; + }).finally(() => { + metric.times.push(Math.max(0, this.now() - started)); + if (metric.times.length > SNAPSHOT_CACHE_LIMITS.samples) metric.times.shift(); + if (this.flights.get(key) === flight) this.flights.delete(key); + }); + this.flights.set(key, flight); + return flight.promise; + } + async read(input: ReadInput): Promise { + const key = this.key(input), metric = this.metric(input), value = this.values.get(key); + if (value) { + const age = Math.max(0, this.now() - value.sampledAt); + // Promote on read, including stale reads, without extending freshness. + this.values.delete(key); this.values.set(key, value); + if (age < input.ttlMs) { metric.hits++; return this.reply(value, "hit"); } + if (age < input.ttlMs * 2) { + metric.stale++; + void this.refresh(key, input, metric).catch((error) => { try { this.options.onError?.(input.tool, input.panel, error); } catch { /* diagnostics cannot break a stale reply */ } }); + return this.reply(value, "stale"); + } + } + metric.misses++; + return this.reply(await this.refresh(key, input, metric), "miss"); + } + invalidateTool(tool: string): void { + for (const [key, value] of this.values) if (value.tool === tool) this.remove(key); + for (const [key, flight] of this.flights) if (flight.tool === tool) this.flights.delete(key); + } + dropProvider(provider: object): void { + for (const [key, value] of this.values) if (value.provider === provider) this.remove(key); + for (const [key, flight] of this.flights) if (flight.provider === provider) this.flights.delete(key); + } + diagnostics() { + return { entries: this.values.size, bytes: this.bytes, metrics: [...this.metrics.values()].map(({ times, ...metric }) => { + const sorted = [...times].sort((a, b) => a - b); + const percentile = (p: number) => sorted[Math.max(0, Math.ceil(sorted.length * p) - 1)] ?? 0; + return { ...metric, samples: times.length, p50Ms: percentile(0.5), p95Ms: percentile(0.95) }; + }) }; + } +} diff --git a/test/lineage-shell.test.ts b/test/lineage-shell.test.ts index 7445078..d14785e 100644 --- a/test/lineage-shell.test.ts +++ b/test/lineage-shell.test.ts @@ -14,6 +14,13 @@ function pollHarness(post: (path: string, input: Record) => Pro } describe("bounded lineage shell snapshot delivery", () => { + test("producer stale and sampledAt fields survive panel delivery and deduplication", async () => { + const full = { cursor: 7, stale: "producer stale", sampledAt: "producer sample", notes: [{ id: "legacy-note" }] }; + const h = pollHarness(async () => ({ status: 200, data: full })); + await h.poll(); + h.p.next = 0; await h.poll(); + expect(h.delivered).toEqual([full]); + }); test("changed:false advances the cursor without notifying or replacing the last full snapshot", async () => { const h = pollHarness(async () => ({ status: 200, data: { cursor: 8, changed: false } })); h.p.lastSig = JSON.stringify({ cursor: 7, changed: true, lineage: { status: "ok" } }); diff --git a/test/principal.test.ts b/test/principal.test.ts index d6af68d..0bbc860 100644 --- a/test/principal.test.ts +++ b/test/principal.test.ts @@ -395,3 +395,19 @@ for (const concurrent of [false, true]) test(`registry toggle uses one validated else { toggle("fixture-home", "assistant", false); expect(JSON.parse(current).principals[0].enabled).toBe(false); expect(renames).toBe(1); } expect(snapshots).toBe(1); expect(checks).toBe(1); expect(cleanups).toBe(1); }); + +test("principal readOnly calls retain snapshots, mutating calls and all console actions invalidate", async () => { + const f = await boot({ now: () => 0 }); + const personPost = (route: string, body: unknown) => fetch(`${f.server.origin}${route}`, { method: "POST", headers: { Origin: ORIGIN, Authorization: `Bearer ${PERSON}`, "Content-Type": "application/json" }, body: JSON.stringify(body) }); + const snapshot = () => personPost("/p/principal-demo/snapshot", { panel: "status" }); + const initial = await snapshot(); + expect(initial.headers.get("x-acon-cache")).toBe("miss"); + expect(initial.headers.get("access-control-expose-headers")).toContain("X-Acon-Sampled-At"); + expect((await call(f.server)).status).toBe(200); + expect((await snapshot()).headers.get("x-acon-cache")).toBe("hit"); + f.registry.principals[0]!.tools.push("principal-demo.request"); f.save(); + expect((await call(f.server, "principal-demo.request")).status).toBe(200); + expect((await snapshot()).headers.get("x-acon-cache")).toBe("miss"); + expect((await personPost("/p/principal-demo/action", { action: "status" })).status).toBe(200); + expect((await snapshot()).headers.get("x-acon-cache")).toBe("miss"); +}); diff --git a/test/snapshot-cache-manifest.test.ts b/test/snapshot-cache-manifest.test.ts new file mode 100644 index 0000000..6b36fe3 --- /dev/null +++ b/test/snapshot-cache-manifest.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test"; +import type { ConsolePanelManifest } from "../src/contract.ts"; +import { manifestProblems } from "../src/plugins.ts"; +import { createDemoPlugin } from "./fixtures/demo-plugin.ts"; + +const oldManifest = (): ConsolePanelManifest => createDemoPlugin().manifest; +const withAge = (surface: "panel" | "inbox" | "space", age: unknown): unknown => { + const manifest = oldManifest(); + return surface === "panel" + ? { ...manifest, panels: manifest.panels.map((panel, index) => index === 0 ? { ...panel, maxAgeMs: age } : panel) } + : { ...manifest, capabilities: { ...manifest.capabilities, [`${surface}MaxAgeMs`]: age } }; +}; + +describe("snapshot cache age manifest fields (#105)", () => { + test("existing manifests omit all cache options and stay valid", () => { + expect(manifestProblems(oldManifest())).toEqual([]); + }); + for (const surface of ["panel", "inbox", "space"] as const) { + test(`${surface} accepts optional finite cache ages from zero through 60 seconds`, () => { + for (const age of [undefined, 0, 0.5, 1, 2000, 60000]) { + expect(manifestProblems(withAge(surface, age))).toEqual([]); + } + }); + test(`${surface} rejects invalid cache ages with the exact field name`, () => { + for (const age of [-1, 60000.1, Number.NaN, Infinity, -Infinity, "2000", null, {}, true]) { + const problems = manifestProblems(withAge(surface, age)); + expect(problems).toHaveLength(1); + expect(problems[0]).toContain(surface === "panel" ? "maxAgeMs" : `${surface}MaxAgeMs`); + expect(problems[0]).toContain("finite number from 0 to 60000 ms"); + } + }); + } +}); diff --git a/test/snapshot-cache.test.ts b/test/snapshot-cache.test.ts new file mode 100644 index 0000000..d98387c --- /dev/null +++ b/test/snapshot-cache.test.ts @@ -0,0 +1,292 @@ +import { afterEach, expect, test } from "bun:test"; +import { SnapshotCache, snapshotMaxAge, SNAPSHOT_CACHE_LIMITS } from "../src/snapshot-cache.ts"; +import { dispatchConsoleAction, dispatchPrincipalAction, dispatchPrincipalSnapshot, type PluginEntry } from "../src/plugins.ts"; +import { startServer } from "../src/server.ts"; +import { createDemoPlugin } from "./fixtures/demo-plugin.ts"; +import { createPrincipalPlugin } from "./fixtures/principal-plugin.ts"; + +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); +const provider = {}; +function request(load: () => Promise>, input = { panel: "notes", after: 0 }) { + return { provider, tool: "demo", panel: input.panel, namespace: "console" as const, input, ttlMs: 1000, load }; +} + +test("ten identical canonical inputs share one call, then hit with isolated values", async () => { + let calls = 0, release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + const cache = new SnapshotCache({ now: () => 0 }); + const load = async () => { calls++; await gate; return { rows: [1] }; }; + const requests = Array.from({ length: 10 }, (_, i) => cache.read(request(load, i % 2 ? { after: 0, panel: "notes" } : { panel: "notes", after: 0 }))); + await tick(); expect(calls).toBe(1); release(); + const results = await Promise.all(requests); + expect(results.every((result) => result.status === "miss")).toBe(true); + results[0]!.value.rows = [99]; + expect((await cache.read(request(load))).value.rows).toEqual([1]); + expect(calls).toBe(1); + expect(cache.diagnostics().metrics[0]).toMatchObject({ hits: 1, misses: 10, samples: 1 }); +}); + +test("stale replies retain last good sampling time, revalidate once and never cache an error", async () => { + let now = 0, calls = 0, fail = false; + const failures: unknown[] = []; + const cache = new SnapshotCache({ now: () => now, onError: (_tool, _panel, error) => failures.push(error) }); + const input = request(async () => { calls++; if (fail) throw new Error("offline"); return { revision: calls }; }); + await cache.read(input); now = 1000; fail = true; + const stale = await Promise.all([cache.read(input), cache.read(input)]); + expect(stale[0]).toMatchObject({ status: "stale", sampledAt: 0, ageMs: 1000, value: { revision: 1 } }); + await tick(); expect(calls).toBe(2); expect(failures.length).toBeGreaterThan(0); + now = 2000; + await expect(cache.read(input)).rejects.toThrow("offline"); + await expect(cache.read(input)).rejects.toThrow("offline"); + expect(calls).toBe(4); + fail = false; + expect((await cache.read(input)).value.revision).toBe(5); + expect((await cache.read(input)).status).toBe("hit"); +}); + +test("explicit error objects pass through without caching; zero TTL only coalesces", async () => { + const cache = new SnapshotCache({ now: () => 0 }); + let calls = 0; + const input = request(async () => { calls++; return { ok: false, error: "offline" }; }); + expect((await cache.read(input)).value).toEqual({ ok: false, error: "offline" }); + expect((await cache.read(input)).value).toEqual({ ok: false, error: "offline" }); + expect(calls).toBe(2); expect(cache.diagnostics().entries).toBe(0); + const uncached = { ...request(async () => ({ n: ++calls })), ttlMs: 0 }; + await Promise.all([cache.read(uncached), cache.read(uncached)]); + expect(calls).toBe(3); + await cache.read(uncached); expect(calls).toBe(4); + expect(cache.diagnostics().entries).toBe(0); +}); + +test("two fake-clock viewers poll at most once per key per TTL or poll interval", async () => { + for (const ttlMs of [2000, 6000]) { + let now = 0, calls = 0; + const cache = new SnapshotCache({ now: () => now }); + const input = { ...request(async () => ({ calls: ++calls })), ttlMs }; + const pollMs = 2000; + for (now = 0; now < 60000; now += pollMs) { + await Promise.all([cache.read(input), cache.read(input)]); await tick(); + } + expect(calls).toBeLessThanOrEqual(60000 / Math.max(ttlMs, pollMs)); + } +}); + +test("LRU enforces entry and byte limits and refuses oversized retention", async () => { + const cache = new SnapshotCache({ now: () => 0 }); + let calls = 0; + const input = request(async () => ({ n: ++calls })); + for (let after = 0; after < 256; after++) await cache.read({ ...input, input: { panel: "notes", after } }); + await cache.read(input); // key 0 survives the next eviction. + await cache.read({ ...input, input: { panel: "notes", after: 256 } }); + expect((await cache.read(input)).status).toBe("hit"); + expect((await cache.read({ ...input, input: { panel: "notes", after: 1 } })).status).toBe("miss"); + expect(cache.diagnostics().entries).toBe(SNAPSHOT_CACHE_LIMITS.entries); + for (let after = 300; after < 304; after++) await cache.read({ ...input, input: { panel: "notes", after }, load: async () => ({ text: "x".repeat(1500000) }) }); + expect(cache.diagnostics().bytes).toBeLessThanOrEqual(SNAPSHOT_CACHE_LIMITS.bytes); + const oversized = { ...input, input: { panel: "notes", after: 500 }, load: async () => ({ text: "x".repeat(SNAPSHOT_CACHE_LIMITS.bytes) }) }; + await cache.read(oversized); + expect((await cache.read(oversized)).status).toBe("miss"); + expect(cache.diagnostics().bytes).toBeLessThanOrEqual(SNAPSHOT_CACHE_LIMITS.bytes); +}); + +test("action invalidation fences late flights and preserves other tools", async () => { + const cache = new SnapshotCache({ now: () => 0 }); + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + const late = cache.read(request(async () => { await gate; return { revision: "old" }; })); + const other = { ...request(async () => ({ revision: "other" })), tool: "other" }; + await cache.read(other); + const plugin = createDemoPlugin(); + const entry: PluginEntry = { tool: "demo", status: "ready", file: "fixture", manifest: plugin.manifest, + provider: await plugin.open({ home: "/fixture", log() {} }), snapshotCache: cache }; + await dispatchConsoleAction(entry, { action: "restore", id: "n1" }, { identity: "local", via: "console", node: "local" }); + release(); await late; + expect((await cache.read(request(async () => ({ revision: "new" }))))).toMatchObject({ status: "miss", value: { revision: "new" } }); + expect((await cache.read(other)).status).toBe("hit"); +}); + +test("provider replacement drops old values and fences its pending completion", async () => { + const cache = new SnapshotCache({ now: () => 0 }); + await cache.read(request(async () => ({ version: 1 }))); + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + const pending = cache.read({ ...request(async () => { await gate; return { version: 99 }; }), input: { panel: "notes", after: 1 } }); + cache.dropProvider(provider); + release(); await pending; + expect(cache.diagnostics().entries).toBe(0); + expect((await cache.read(request(async () => ({ version: 2 })))).value.version).toBe(2); + const replacement = { ...request(async () => ({ version: 3 })), provider: {} }; + expect((await cache.read(replacement)).status).toBe("miss"); +}); + +test("principal snapshots are isolated; readOnly calls preserve both ports and mutating calls invalidate them", async () => { + const cache = new SnapshotCache({ now: () => 0 }); + const plugin = createPrincipalPlugin(); + const entry: PluginEntry = { tool: plugin.manifest.tool, status: "ready", file: "fixture", manifest: plugin.manifest, + provider: await plugin.open({ home: "/fixture", log() {} }), snapshotCache: cache }; + await dispatchPrincipalSnapshot(entry, { principal: "first", panel: "status" }); + await dispatchPrincipalSnapshot(entry, { principal: "first", panel: "status" }); + await dispatchPrincipalSnapshot(entry, { principal: "second", panel: "status" }); + expect(plugin.calls.filter((call) => call.port === "principalSnapshot")).toHaveLength(2); + const person = { ...request(async () => ({ source: "person" }), { panel: "status", after: 0 }), provider: entry.provider!, tool: entry.tool }; + await cache.read(person); + await dispatchPrincipalAction(entry, { principal: "first", tool: "principal-demo.status", input: {} }, "local"); + expect((await cache.read(person)).status).toBe("hit"); + await dispatchPrincipalSnapshot(entry, { principal: "first", panel: "status" }); + expect(plugin.calls.filter((call) => call.port === "principalSnapshot")).toHaveLength(2); + await dispatchPrincipalAction(entry, { principal: "first", tool: "principal-demo.request", input: {} }, "local"); + expect((await cache.read(person)).status).toBe("miss"); + await dispatchPrincipalSnapshot(entry, { principal: "first", panel: "status" }); + expect(plugin.calls.filter((call) => call.port === "principalSnapshot")).toHaveLength(3); +}); + +test("TTL defaults and panel-specific opt-ins are additive", () => { + const manifest = createDemoPlugin().manifest; + expect(snapshotMaxAge(manifest, "notes")).toBe(2000); + manifest.pollMs = 2000; expect(snapshotMaxAge(manifest, "notes")).toBe(1000); + manifest.panels[0]!.maxAgeMs = 60000; expect(snapshotMaxAge(manifest, manifest.panels[0]!.id)).toBe(60000); + manifest.capabilities!.inboxMaxAgeMs = 0; expect(snapshotMaxAge(manifest, "inbox")).toBe(0); + manifest.capabilities!.spaceMaxAgeMs = 30000; expect(snapshotMaxAge(manifest, "space")).toBe(30000); +}); + +const servers: ReturnType[] = []; +afterEach(async () => { while (servers.length) await servers.pop()!.stop(); }); +async function httpFixture(reply?: Record) { + let now = 0, calls = 0, fail = false; + const plugin = createDemoPlugin(); + const provider = await plugin.open({ home: "/fixture", log() {} }); + const original = provider.snapshot.bind(provider); + provider.snapshot = async (input) => { calls++; if (fail) throw new Error("offline"); return reply ?? original(input); }; + const entry: PluginEntry = { tool: "demo", file: "fixture", status: "ready", manifest: plugin.manifest, provider }; + const server = startServer({ plugins: [entry], pluginsDir: "/fixture", version: "test", now: () => now, log() {} }); + servers.push(server); + const viewer = async () => { + const ticket = server.issue().split("#")[1]!; + const login = await fetch(`${server.origin}/session`, { method: "POST", headers: { Origin: server.origin, "Content-Type": "application/json" }, body: JSON.stringify({ ticket }) }); + const cookie = /acon_ui_\d+=[0-9a-f]+/.exec(login.headers.get("set-cookie") ?? "")![0]; + return (route: string, body: Record) => fetch(`${server.origin}${route}`, { method: "POST", headers: { Origin: server.origin, "Content-Type": "application/json", Cookie: cookie }, body: JSON.stringify(body) }); + }; + return { server, viewer, get calls() { return calls; }, advance(value: number) { now = value; }, fail() { fail = true; } }; +} + +test("HTTP viewers share hits, receive stale envelopes/age and System diagnostics", async () => { + const fixture = await httpFixture(), first = await fixture.viewer(), second = await fixture.viewer(); + const initial = await first("/p/demo/snapshot", { panel: "notes" }); + expect(initial.headers.get("x-acon-cache")).toBe("miss"); + const body = await initial.json(); + const sampledAt = initial.headers.get("x-acon-sampled-at"); + expect(sampledAt).toBe("1970-01-01T00:00:00.000Z"); + expect((await second("/p/demo/snapshot", { panel: "notes", after: 0 })).headers.get("x-acon-cache")).toBe("hit"); + expect(fixture.calls).toBe(1); + fixture.advance(2000); fixture.fail(); + const stale = await second("/p/demo/snapshot", { panel: "notes" }); + expect(stale.headers.get("x-acon-cache")).toBe("stale"); + expect(stale.headers.get("x-acon-cache-age-ms")).toBe("2000"); + expect(await stale.json()).toEqual(body); + expect(stale.headers.get("x-acon-sampled-at")).toBe(sampledAt); + await tick(); + const tools = await (await first("/tools", {})).json(); + expect(tools.snapshotCache.metrics[0]).toMatchObject({ tool: "demo", panel: "notes", hits: 1, misses: 1, stale: 1, samples: 2 }); + fixture.advance(4000); + expect((await first("/p/demo/snapshot", { panel: "notes" })).status).toBe(502); + expect((await first("/p/demo/snapshot", { panel: "notes" })).status).toBe(502); +}); + + +test("provider timing percentiles use only the last 128 provider calls", async () => { + let now = 0; + const cache = new SnapshotCache({ now: () => now }); + for (let i = 1; i <= 130; i++) await cache.read({ ...request(async () => { now += i; return { ok: true }; }), ttlMs: 0 }); + expect(cache.diagnostics().metrics[0]).toMatchObject({ samples: 128, p50Ms: 66, p95Ms: 124 }); +}); + +test("an action clears snapshots refilled during its execution before returning", async () => { + const cache = new SnapshotCache({ now: () => 0 }); + const plugin = createDemoPlugin(); + const provider = await plugin.open({ home: "/fixture", log() {} }); + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + provider.action = async () => { await gate; return { ok: true }; }; + const entry: PluginEntry = { tool: "demo", status: "ready", file: "fixture", manifest: plugin.manifest, provider, snapshotCache: cache }; + const pending = dispatchConsoleAction(entry, { action: "restore" }, { identity: "local", node: "local", via: "console" }); + await cache.read(request(async () => ({ state: "mid-action" }))); + release(); await pending; + expect((await cache.read(request(async () => ({ state: "after" }))))).toMatchObject({ status: "miss", value: { state: "after" } }); +}); + +test("HTTP reload drops the old provider's entries even for pinned routes", async () => { + let now = 0, oldCalls = 0, newCalls = 0; + const plugin = createDemoPlugin(); + plugin.manifest.panels[0]!.maxAgeMs = 60000; + const oldProvider = await plugin.open({ home: "/fixture", log() {} }); + const nextProvider = await plugin.open({ home: "/fixture", log() {} }); + oldProvider.snapshot = async () => ({ calls: ++oldCalls }); + nextProvider.snapshot = async () => ({ calls: ++newCalls }); + const old: PluginEntry = { tool: "demo", ref: "demo@aaaaaaaaaaaa", file: "old", status: "ready", manifest: plugin.manifest, provider: oldProvider }; + const next: PluginEntry = { ...old, ref: "demo@bbbbbbbbbbbb", file: "next", provider: nextProvider }; + const server = startServer({ plugins: [old], reload: async () => [next], pluginsDir: "/fixture", version: "test", now: () => now, log() {} }); + servers.push(server); + const post = (route: string, body: Record, cookie = "") => fetch(`${server.origin}${route}`, { method: "POST", headers: { Origin: server.origin, "Content-Type": "application/json", Cookie: cookie }, body: JSON.stringify(body) }); + const login = await post("/session", { ticket: server.issue().split("#")[1]! }); + const cookie = login.headers.get("set-cookie")!.split(";")[0]!; + await post(`/p/${old.ref}/snapshot`, { panel: plugin.manifest.panels[0]!.id }, cookie); + expect(oldCalls).toBe(1); + now = 5000; await post("/tools", {}, cookie); + const pinned = await post(`/p/${old.ref}/snapshot`, { panel: plugin.manifest.panels[0]!.id }, cookie); + expect(pinned.headers.get("x-acon-cache")).toBe("miss"); + expect(oldCalls).toBe(2); + const current = await post("/p/demo/snapshot", { panel: plugin.manifest.panels[0]!.id }, cookie); + expect(current.headers.get("x-acon-cache")).toBe("miss"); expect(newCalls).toBe(1); +}); + + +test("HTTP cache freshness headers preserve producer stale and sampledAt fields", async () => { + const payload = { stale: "producer stale", sampledAt: "producer sample", notes: [1] }; + const fixture = await httpFixture(payload), post = await fixture.viewer(); + for (const status of ["miss", "hit"]) { + const response = await post("/p/demo/snapshot", { panel: "notes" }); + expect(response.headers.get("x-acon-cache")).toBe(status); + expect(response.headers.get("x-acon-sampled-at")).toBe("1970-01-01T00:00:00.000Z"); + expect(await response.json()).toEqual(payload); + } +}); + +test("HTTP ok:false snapshots remain status 200 and verbatim, with no retained error entry", async () => { + const payload = { ok: false, error: "project is not running", sampledAt: "producer" }; + const fixture = await httpFixture(payload), post = await fixture.viewer(); + for (const panel of ["notes", "space"]) for (let i = 0; i < 2; i++) { + const response = await post("/p/demo/snapshot", { panel }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual(payload); + expect(response.headers.get("x-acon-cache")).toBe("miss"); + } + expect(fixture.calls).toBe(4); +}); + +test("principal snapshot cache preserves producer fields without transport metadata", async () => { + const plugin = createPrincipalPlugin(); + const provider = await plugin.open({ home: "/fixture", log() {} }); + let calls = 0; + const payload = { stale: "producer stale", sampledAt: "producer sample", value: 1 }; + provider.principalSnapshot = async () => { calls++; return payload; }; + const entry: PluginEntry = { tool: plugin.manifest.tool, status: "ready", file: "fixture", manifest: plugin.manifest, provider, snapshotCache: new SnapshotCache() }; + for (let i = 0; i < 2; i++) expect(await dispatchPrincipalSnapshot(entry, { principal: "first", panel: "status" })).toEqual(payload); + expect(calls).toBe(1); +}); + +test("explicit panels own their TTL before reserved capabilities", () => { + const manifest = createDemoPlugin().manifest; + manifest.capabilities!.spaceMaxAgeMs = 60000; + manifest.capabilities!.inboxMaxAgeMs = 30000; + for (const panel of ["space", "inbox"]) { + manifest.panels.push({ id: panel, title: panel, maxAgeMs: 123 }); + expect(snapshotMaxAge(manifest, panel)).toBe(123); + delete manifest.panels.at(-1)!.maxAgeMs; + expect(snapshotMaxAge(manifest, panel)).toBe(2000); + } + manifest.panels = []; + manifest.capabilities!.space = false; + manifest.capabilities!.inbox = false; + expect(snapshotMaxAge(manifest, "space")).toBe(2000); + expect(snapshotMaxAge(manifest, "inbox")).toBe(2000); +});