From 22376ccd0c3fa2a0550b05464bc631687828e2f6 Mon Sep 17 00:00:00 2001 From: Young Joon Lee Date: Tue, 10 Nov 2026 22:45:31 +0900 Subject: [PATCH 1/5] feat(snapshot): add conditional replies and adaptive shell polling --- CHANGELOG.md | 2 + README.md | 4 + scripts/ui-check-snapshot-fixture.mjs | 12 ++ scripts/ui-check-space-board.mjs | 5 +- scripts/ui-check-space-flow.mjs | 5 +- scripts/ui-check-space-foundation.mjs | 5 +- scripts/ui-check.mjs | 9 +- shell/shell.js | 98 +++++++++++------ shell/static.js | 21 ++-- src/server.ts | 18 ++- src/snapshot-cache.ts | 8 +- test/lineage-shell.test.ts | 2 +- test/shell-polling.test.ts | 151 ++++++++++++++++++++++++++ test/snapshot-cache.test.ts | 71 +++++++++++- test/static-transport.test.ts | 52 ++++++++- 15 files changed, 401 insertions(+), 62 deletions(-) create mode 100644 scripts/ui-check-snapshot-fixture.mjs create mode 100644 test/shell-polling.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 478a3c0..35c3ec6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ ### Added +- Add header-only snapshot versions and reserved `known` conditional requests, preserving plugin cursor replies and forwarding version/freshness headers through the static transport. Adapt polling to request duration, pause hidden panel/Space polls with immediate visible refresh, progressively render inbox sources, and retain stale Space sample times and actor labels (#105). + - Add the Space Board view with per-group zone columns, producer-linked cards, owner detail controls, and collapsed Done history showing the last five supplied cards. Keep horizontal scrolling within each board row, preserve focus/disclosures, and cover the responsive matrix with a Board probe (#101). - 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). diff --git a/README.md b/README.md index f3e9797..a6f3315 100644 --- a/README.md +++ b/README.md @@ -303,6 +303,10 @@ During one extra TTL of grace, a cached reply can be served immediately while on 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. +Conditional snapshots (#105): each successful snapshot carries an `X-Acon-Version` content hash. A shell can send its last hash as `known`; the server removes this reserved transport field before building the cache key or calling the plugin. A matching successful value returns `{changed: false}` with the version header. Plugin `after` cursors and producer `changed: false` replies take precedence, including their cursor fields. Version and freshness metadata stay in headers, so producer body fields are unchanged by this protocol. The H2 transport forwards `known` and exposes the same version/cache/sample-time headers to the shell; nodes without those headers keep their existing full-reply behavior. + +Panel and Space polling use `max(pollMs, 3 × last request duration)`, capped at 60 seconds, with `pollMs` clamped to 2-60 seconds. The interval recovers when requests become faster. Hidden tabs start no panel or Space polls, and becoming visible triggers one immediate refresh; existing Mini/Picture-in-Picture inbox behavior is preserved. Inbox sources render as each tool finishes instead of waiting for the slowest source. A Space reply marked `X-Acon-Cache: stale` keeps the `X-Acon-Sampled-At` time and marks its actors stale, including on an unchanged conditional reply. + 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. diff --git a/scripts/ui-check-snapshot-fixture.mjs b/scripts/ui-check-snapshot-fixture.mjs new file mode 100644 index 0000000..4d7c74b --- /dev/null +++ b/scripts/ui-check-snapshot-fixture.mjs @@ -0,0 +1,12 @@ +// Probes that rewrite a snapshot body act as legacy fixture providers: an upstream +// hash/sample time no longer describes their synthetic scene. Request the full +// source and remove that metadata, while protocol behavior is tested separately. +const metadata = new Set(["x-acon-version", "x-acon-cache", "x-acon-cache-age-ms", "x-acon-sampled-at", "content-length"]); +export async function fetchFullSnapshot(route) { + const { known: _known, ...input } = route.request().postDataJSON(); + return route.fetch({ postData: JSON.stringify(input) }); +} +export async function fulfillSnapshotFixture(route, response, body) { + const headers = Object.fromEntries(Object.entries(response.headers()).filter(([name]) => !metadata.has(name.toLowerCase()))); + await route.fulfill({ response, headers, json: body }); +} diff --git a/scripts/ui-check-space-board.mjs b/scripts/ui-check-space-board.mjs index c63c0b9..3bf35d3 100644 --- a/scripts/ui-check-space-board.mjs +++ b/scripts/ui-check-space-board.mjs @@ -1,3 +1,4 @@ +import { fetchFullSnapshot, fulfillSnapshotFixture } from "./ui-check-snapshot-fixture.mjs"; // #101 Delivery 4: per-project Board, Done disclosure and producer-card navigation. import assert from "node:assert/strict"; import { join } from "node:path"; @@ -17,7 +18,7 @@ async function installDoneFixture(page) { let input; try { input = route.request().postDataJSON(); } catch { await route.continue(); return; } if (input?.panel !== "space") { await route.continue(); return; } - const response = await route.fetch(); + const response = await fetchFullSnapshot(route); const body = await response.json(); if (body.scene) { const zone = body.scene.zones.find((item) => item.kind === "done" && item.group === "delivery"); @@ -30,7 +31,7 @@ async function installDoneFixture(page) { }); zone.count = 20; // The producer total deliberately exceeds the seven supplied cards. } - await route.fulfill({ response, json: body }); + await fulfillSnapshotFixture(route, response, body); }); } diff --git a/scripts/ui-check-space-flow.mjs b/scripts/ui-check-space-flow.mjs index b80c55d..272713e 100644 --- a/scripts/ui-check-space-flow.mjs +++ b/scripts/ui-check-space-flow.mjs @@ -1,3 +1,4 @@ +import { fetchFullSnapshot, fulfillSnapshotFixture } from "./ui-check-snapshot-fixture.mjs"; // #101 Delivery 3: public-link observations, Flow layout and detail return paths. import assert from "node:assert/strict"; import { join } from "node:path"; @@ -88,14 +89,14 @@ async function changedLinkProbe({ browser, ticket, report }) { try { input = route.request().postDataJSON(); } catch { await route.continue(); return; } if (input?.panel !== "space") { await route.continue(); return; } const observedStage = stage; - const response = await route.fetch(); + const response = await fetchFullSnapshot(route); const body = await response.json(); if (body.scene) { if (observedStage >= 1) body.scene.links.push({ kind: "message", from: "claude", to: "codex", label: "Flow changed-link probe", at }); if (observedStage >= 2) body.scene.actors.find((actor) => actor.id === "local").name = "Flow rerender probe"; if (observedStage >= 3) body.scene.links.push({ kind: "message", from: "codex", to: "local", label: "Flow hidden-link probe", at }); } - await route.fulfill({ response, json: body }); + await fulfillSnapshotFixture(route, response, body); }); await enterFlow(page, ticket, 100); await page.evaluate(() => { diff --git a/scripts/ui-check-space-foundation.mjs b/scripts/ui-check-space-foundation.mjs index c142d5b..cbd4f7e 100644 --- a/scripts/ui-check-space-foundation.mjs +++ b/scripts/ui-check-space-foundation.mjs @@ -1,3 +1,4 @@ +import { fetchFullSnapshot, fulfillSnapshotFixture } from "./ui-check-snapshot-fixture.mjs"; // #101 Delivery 1: routing/layout and raised-cap performance, synthetic scenes only. import assert from "node:assert/strict"; import { join } from "node:path"; @@ -50,7 +51,7 @@ export async function runSpaceFoundation({ browser, ticket, report, output }) { try { const page = await context.newPage(); let calls = 0, change = false; await page.route("**/p/*/snapshot", async (route) => { - const response = await route.fetch(); const data = await response.json(); + const response = await fetchFullSnapshot(route); const data = await response.json(); if (route.request().postDataJSON()?.panel !== "space" || !data.scene) { await route.fulfill({ response }); return; } if (route.request().url().includes("/p/echo/")) data.scene = { actors: [], zones: [], cards: [], links: [] }; else { @@ -60,7 +61,7 @@ export async function runSpaceFoundation({ browser, ticket, report, output }) { scene.cards = Array.from({ length: 200 }, (_, i) => ({ ...card, id: `card-${i}`, owner: `actor-${i % 96}`, group: "delivery" })); scene.links = Array.from({ length: 128 }, (_, i) => ({ kind: "cohort", from: `actor-${i % 96}`, to: `actor-${(i + 1) % 96}`, label: `Public relation ${i}` })); } - await route.fulfill({ response, json: data }); + await fulfillSnapshotFixture(route, response, data); }); await page.goto((await ticket()).url); await page.evaluate(() => { location.hash = "#/local/space?view=room"; }); await page.waitForFunction(() => document.querySelectorAll(".space-actor").length === 96 && document.querySelectorAll(".space-card").length === 200); diff --git a/scripts/ui-check.mjs b/scripts/ui-check.mjs index 59dd8d2..fc3934f 100644 --- a/scripts/ui-check.mjs +++ b/scripts/ui-check.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { fetchFullSnapshot, fulfillSnapshotFixture } from "./ui-check-snapshot-fixture.mjs"; // Agent-run browser probe. Playwright is discovered at runtime, never a package dependency. import { spawn } from "node:child_process"; import { createRequire } from "node:module"; @@ -846,7 +847,7 @@ async function runSpaceMotion(ticket) { const currentStage = stage; const gate = currentStage === 3 ? gapGate : null; const tool = new URL(route.request().url()).pathname.split("/")[2]; - const response = await route.fetch(); + const response = await fetchFullSnapshot(route); const body = await response.json(); if (tool === "demo" && body.scene) { const { scene } = body; @@ -882,7 +883,7 @@ async function runSpaceMotion(ticket) { if (gate.pending.has("demo") && gate.pending.has("echo")) gate.markReady(); await gate.promise; } - await route.fulfill({ response, json: body }); + await fulfillSnapshotFixture(route, response, body); }); try { await page.goto((await ticket()).url, { waitUntil: "networkidle" }); @@ -1170,9 +1171,9 @@ async function runSpaceViews(ticket) { if (width === 1440 && theme === "light" && textPercent === 100) { const beforeBurst = await page.locator(".space-webgl").evaluate((el) => Number(el.dataset.renderCount)); await page.route("**/p/demo/snapshot", async (route) => { - const response = await route.fetch(), body = await response.json(); + const response = await fetchFullSnapshot(route), body = await response.json(); if (body.scene) body.scene.actors[0].state = "blocked"; - await route.fulfill({ response, json: body }); + await fulfillSnapshotFixture(route, response, body); }); findings.push(...await waitSpacePoll(page)); await page.locator(".space-webgl[data-pending-frames='0']").waitFor(); diff --git a/shell/shell.js b/shell/shell.js index 82c95ca..66ac12f 100644 --- a/shell/shell.js +++ b/shell/shell.js @@ -817,7 +817,8 @@ async function post(path, body = {}) { let data = null; try { data = await res.json(); } catch { /* non-JSON error body */ } if (res.status === 401 && path !== "/session") endSession("This console session ended."); - return { status: res.status, data: data ?? { ok: false, error: `HTTP ${res.status}` } }; + return { status: res.status, data: data ?? { ok: false, error: `HTTP ${res.status}` }, + headers: Object.fromEntries(["x-acon-version", "x-acon-cache", "x-acon-cache-age-ms", "x-acon-sampled-at"].flatMap((name) => { const value = res.headers?.get?.(name); return value === null || value === undefined ? [] : [[name, value]]; })) }; } function clientError(tool, where, error) { state.clientErrors.unshift({ at: new Date().toISOString(), tool, where, message: String(error?.message ?? error).split("\n")[0].slice(0, 200) }); @@ -1006,40 +1007,55 @@ function validItem(i) { && (i.seen === undefined || Boolean(i.seen && str(i.seen.action))); } let inboxSeq = 0; +function adaptivePollMs(pollMs, duration = 0) { + return Math.min(60000, Math.max(2000, pollMs ?? 5000, Math.max(0, duration) * 3)); +} const inboxApplied = new Map(); // #60: at most one inbox snapshot in flight per tool. A tick shares a slow tool's pending request; an explicit refresh // waits for it, then polls once (shared by every caller waiting on the same request). const inboxFlight = new Map(); function inboxOnce(t, fresh) { const flight = inboxFlight.get(t.tool); - if (flight) return fresh ? (flight.follow ??= flight.done.catch(() => {}).then(() => inboxOnce(t, false))) : flight.done; + if (flight) return fresh ? (flight.follow ??= flight.done.catch(() => {}).then(() => inboxOnce(t, true))) : flight.done; + if (!fresh && Date.now() < (state.inbox.get(t.tool)?.next ?? 0)) return Promise.resolve(); const seq = ++inboxSeq; const done = inboxFetch(t, seq).finally(() => inboxFlight.delete(t.tool)); inboxFlight.set(t.tool, { done }); return done; } +function renderInboxSources() { + renderRail(); renderMini(); + if (state.route.area === "inbox") renderInbox(); + if (state.peekKey?.startsWith("item:")) renderPeek(); +} async function pollInbox(fresh = true) { const tools = readyTools().filter((t) => t.capabilities?.inbox); + let removed = false; + for (const tool of [...state.inbox.keys()]) if (!tools.some((t) => t.tool === tool)) { state.inbox.delete(tool); removed = true; } + if (removed) renderInboxSources(); await Promise.all(tools.map((t) => inboxOnce(t, fresh))); - for (const tool of [...state.inbox.keys()]) if (!tools.some((t) => t.tool === tool)) state.inbox.delete(tool); - renderRail(); - renderMini(); - if (state.route.area === "inbox") renderInbox(); - if (state.peekKey?.startsWith("item:")) renderPeek(); } async function inboxFetch(t, seq) { - const r = await post(`/p/${t.tool}/snapshot`, { after: 0, panel: "inbox" }); + const previous = state.inbox.get(t.tool); + const startedAt = Date.now(); + const r = await post(`/p/${t.tool}/snapshot`, { after: 0, panel: "inbox", ...(previous?.version ? { known: previous.version } : {}) }); // An older response that arrives late never overwrites a newer one. if ((inboxApplied.get(t.tool) ?? 0) > seq) return; inboxApplied.set(t.tool, seq); + const next = startedAt + adaptivePollMs(t.pollMs ?? INBOX_MS, Date.now() - startedAt); + const version = r.headers?.["x-acon-version"] ?? ""; if (r.status === 200 && Array.isArray(r.data.items)) { const items = r.data.items.filter(validItem); if (items.length !== r.data.items.length) clientError(t.tool, "inbox", new Error(`${r.data.items.length - items.length} malformed inbox items were skipped`)); - state.inbox.set(t.tool, { items, failed: false }); + state.inbox.set(t.tool, { items, failed: false, next, version }); + } else if (r.status === 200 && r.data.changed === false && previous) { + state.inbox.set(t.tool, { ...previous, failed: false, next, version }); } else { - const old = state.inbox.get(t.tool); - state.inbox.set(t.tool, { items: old?.items ?? [], failed: true }); + state.inbox.set(t.tool, { items: previous?.items ?? [], failed: true, next, version: "" }); } + // A fast tool becomes usable without waiting for the slowest provider; known replies write nothing. + const current = state.inbox.get(t.tool); + if (!previous || previous.failed !== current.failed || JSON.stringify(previous.items) !== JSON.stringify(current.items)) renderInboxSources(); } function sortedItems() { const items = visibleItems(); @@ -1567,7 +1583,7 @@ async function showTool(r) { await p.loading; if (state.route.area !== "tool" || state.route.tool !== tool.tool || !p.instance) return; const view = JSON.stringify([r.panel, r.params]); - if (p.view !== view) { p.cursor = 0; p.lastData = null; p.view = view; p.gen += 1; } + if (p.view !== view) { p.cursor = 0; p.version = ""; p.lastData = null; p.view = view; p.gen += 1; } p.panel = r.panel; p.lastSig = ""; safe(tool.tool, "show", () => p.instance.show?.(r.panel, r.params)); @@ -1576,21 +1592,25 @@ async function showTool(r) { } async function pollPanel() { const r = state.route; - if (r.area !== "tool") return; + if (r.area !== "tool" || document.visibilityState !== "visible") return; const p = state.panels.get(r.tool); if (!p?.instance || p.busy || Date.now() < p.next) return; const tool = toolById(r.tool); p.busy = true; const gen = p.gen; const after = p.cursor; - const res = await post(`/p/${r.tool}/snapshot`, { ...r.params, after, panel: r.panel }); + const startedAt = Date.now(); + const res = await post(`/p/${r.tool}/snapshot`, { ...r.params, after, panel: r.panel, ...(p.version ? { known: p.version } : {}) }); p.busy = false; - p.next = Date.now() + Math.min(60000, Math.max(2000, tool?.pollMs ?? 5000)); + p.next = startedAt + adaptivePollMs(tool?.pollMs, Date.now() - startedAt); + const resume = p.resume; p.resume = false; + if (resume && document.visibilityState === "visible") { p.next = 0; queueMicrotask(() => pollPanel()); } // A response for an earlier panel or item never reaches the view now shown. if (state.route.tool !== r.tool) return; if (gen !== p.gen) { p.next = 0; pollPanel(); return; } if (res.status === 200) { - p.banner.hidden = true; + p.version = res.headers?.["x-acon-version"] ?? ""; + if (!p.banner.hidden) p.banner.hidden = true; if (typeof res.data.cursor === "number") p.cursor = res.data.cursor; let data = res.data; if (data.changed === false && Object.keys(data).every((key) => key === "changed" || key === "cursor")) { @@ -1609,7 +1629,8 @@ async function pollPanel() { p.lastData = data; for (const cb of p.snapListeners) safe(r.tool, "onSnapshot", () => cb(data)); } else if (res.status) { - p.banner.hidden = false; + p.version = ""; + if (p.banner.hidden) p.banner.hidden = false; p.banner.replaceChildren(icon("alert"), h("span", { text: `${tool?.title ?? r.tool} could not refresh. ` }), h("a", { href: hashFor("system"), text: "Details in System" })); } } @@ -1886,26 +1907,28 @@ function renderSpace(force = false) { spaceRenderer.update(spaceModule.mergeSpaceScenes(state.space), force); } async function pollSpace() { - if (state.route.area !== "space" || !spaceRenderer || state.ended) return; + if (state.route.area !== "space" || !spaceRenderer || state.ended || document.visibilityState !== "visible") return; await Promise.all(spaceTools().map(async (tool) => { let entry = state.space.get(tool.tool); if (!entry) { entry = { scene: null, cursor: 0, next: 0, busy: false, failed: false }; state.space.set(tool.tool, entry); } if (entry.busy || Date.now() < entry.next) return; const startedAt = Date.now(); - const interval = Math.min(60000, Math.max(2000, tool.pollMs ?? 5000)); - const due = entry.next || startedAt; - entry.next = due + (Math.floor((startedAt - due) / interval) + 1) * interval; entry.busy = true; - const res = await post(`/p/${tool.tool}/snapshot`, { panel: "space", after: entry.cursor }); + const res = await post(`/p/${tool.tool}/snapshot`, { panel: "space", after: entry.cursor, ...(entry.version ? { known: entry.version } : {}) }); entry.busy = false; + entry.next = startedAt + adaptivePollMs(tool.pollMs, Date.now() - startedAt); + const resume = entry.resume; entry.resume = false; + if (resume && document.visibilityState === "visible") { entry.next = 0; queueMicrotask(() => pollSpace()); } entry.reported = true; - if (res.status === 200 && res.data.scene) { - entry.sampledAt = Date.now(); - entry.scene = res.data.scene; - entry.dropped = res.data.dropped ?? { actors: 0, cards: 0, links: 0 }; + if (res.status === 200 && (res.data.scene || (entry.scene && res.data.changed === false))) { + const stale = res.headers?.["x-acon-cache"] === "stale"; + const sampledAt = Date.parse(res.headers?.["x-acon-sampled-at"] ?? ""); + entry.sampledAt = Number.isFinite(sampledAt) ? sampledAt : stale ? (entry.sampledAt ?? Date.now()) : Date.now(); + if (res.data.scene) { entry.scene = res.data.scene; entry.dropped = res.data.dropped ?? { actors: 0, cards: 0, links: 0 }; } if (Number.isSafeInteger(res.data.cursor)) entry.cursor = res.data.cursor; - entry.failed = false; - } else entry.failed = true; + entry.version = res.headers?.["x-acon-version"] ?? ""; + entry.failed = stale; + } else { entry.failed = true; entry.version = ""; } })); renderSpace(); } @@ -2234,7 +2257,7 @@ function tick() { // A Mini in Picture-in-Picture stays live while this tab is hidden. if (document.visibilityState === "visible" || pip) { renderLive(); - if (now >= state.nextInbox) { state.nextInbox = now + INBOX_MS; pollInbox(false).catch((error) => clientError("acon", "inbox", error)); } + if (now >= state.nextInbox) { state.nextInbox = now + 1000; pollInbox(false).catch((error) => clientError("acon", "inbox", error)); } // #60: a slow /tools is not stacked by the next tick. if (now >= state.nextTools && !state.toolsBusy) { state.nextTools = now + TOOLS_MS; state.toolsBusy = true; loadTools().finally(() => { state.toolsBusy = false; }); } pollPanel(); @@ -2242,6 +2265,19 @@ function tick() { } else if (now - state.lastRequest >= PING_MS) post("/ping"); } +function resumeVisiblePolling() { + if (state.ended || document.visibilityState !== "visible") return; + if (state.route.area === "tool") { + const panel = state.panels.get(state.route.tool); + if (panel) { if (panel.busy) panel.resume = true; else panel.next = 0; } + void pollPanel(); + } else if (state.route.area === "space") { + for (const entry of state.space.values()) { if (entry.busy) entry.resume = true; else entry.next = 0; } + void pollSpace(); + } +} +document.addEventListener("visibilitychange", resumeVisiblePolling); + async function boot() { applyTheme(); if (document.documentElement.dataset.static === "true") { @@ -2256,7 +2292,7 @@ async function boot() { if (!readyTools().length) location.hash = hashFor("system"); route(); state.nextTools = Date.now() + TOOLS_MS; - state.nextInbox = Date.now() + INBOX_MS; + state.nextInbox = Date.now() + 1000; setInterval(tick, 1000); await pollInbox(); renderLive(); @@ -2275,7 +2311,7 @@ async function boot() { if (!(await loadTools())) { if (!state.ended) endSession("The console could not start."); return; } state.nextTools = Date.now() + TOOLS_MS; route(); - state.nextInbox = Date.now() + INBOX_MS; + state.nextInbox = Date.now() + 1000; setInterval(tick, 1000); try { await pollInbox(); } catch (error) { clientError("acon", "inbox", error); } renderLive(); diff --git a/shell/static.js b/shell/static.js index 9de6e9a..d7686eb 100644 --- a/shell/static.js +++ b/shell/static.js @@ -1,6 +1,11 @@ // H2 transport. Credentials live only in this tab; the static host serves no node data. const NODE_ID = /^[a-z][a-z0-9-]{0,31}$/; const TOOL_ID = /^[a-z][a-z0-9-]{1,15}$/; +const SNAPSHOT_HEADERS = ['x-acon-version', 'x-acon-cache', 'x-acon-cache-age-ms', 'x-acon-sampled-at']; +const responseHeaders = (response) => Object.fromEntries(SNAPSHOT_HEADERS.flatMap((name) => { + const value = response.headers?.get?.(name); + return typeof value === 'string' ? [[name, value]] : []; +})); const loopback = (host) => host === 'localhost' || host === '[::1]' || /^127\.\d+\.\d+\.\d+$/.test(host); export function createStaticTransport(config, onStatus = () => {}) { @@ -25,13 +30,13 @@ export function createStaticTransport(config, onStatus = () => {}) { await fetch(`${node.url}/healthz`, { mode: 'no-cors', credentials: 'omit', cache: 'no-store', signal: AbortSignal.timeout(3000) }); notify(node, 'refused (origin)', 'Node reachable but browser access refused; add this shell origin to acon serve --origin and check the Access CORS settings.'); } catch { notify(node, 'offline', 'Node is unreachable; start acon serve and check the node URL and network.'); } - return { status: 0, data: { ok: false, error: node.detail } }; + return { status: 0, headers: {}, data: { ok: false, error: node.detail } }; }; const request = async (node, path, body) => { const action = body !== undefined && /^\/p\/[^/]+\/action$/.test(path); const deadline = action ? 35000 : 8000; const signal = AbortSignal.timeout(deadline); - const uncertain = () => ({ status: 0, data: { ok: false, uncertain: true, error: 'The action outcome is unknown because its response was interrupted or invalid; check the node System log and current result before repeating the action.' } }); + const uncertain = () => ({ status: 0, headers: {}, data: { ok: false, uncertain: true, error: 'The action outcome is unknown because its response was interrupted or invalid; check the node System log and current result before repeating the action.' } }); let response; let data = null; try { @@ -43,7 +48,7 @@ export function createStaticTransport(config, onStatus = () => {}) { // Our own deadline is a busy node, not a refused origin: skip the CORS probe and keep the node usable (#59). if (signal.aborted && (error?.name === 'TimeoutError' || error?.name === 'AbortError')) { notify(node, 'slow', `${path} took over ${deadline / 1000} s; the node is reachable but busy. Its tools retry on their next poll.`); - return { status: 0, data: { ok: false, error: node.detail } }; + return { status: 0, headers: {}, data: { ok: false, error: node.detail } }; } return failed(node); } @@ -51,7 +56,7 @@ export function createStaticTransport(config, onStatus = () => {}) { node.grant = ''; notify(node, 'unauthorized', 'Authentication refused; enter this node\'s bearer token or sign in to its Access application, then reconnect.'); } else notify(node, 'online', 'Connected'); - return { status: response.status, data: data ?? { ok: false, error: `Node answered HTTP ${response.status} without JSON; check its System log.` } }; + return { status: response.status, headers: responseHeaders(response), data: data ?? { ok: false, error: `Node answered HTTP ${response.status} without JSON; check its System log.` } }; }; const connect = async (node) => { if (!node.grant || Date.now() >= node.grantUntil) { @@ -82,14 +87,14 @@ export function createStaticTransport(config, onStatus = () => {}) { // One bounded refresh per node, with no nested retry loop or repeated auth prompt. await Promise.all(nodes.map((node) => node.busy ??= connect(node).finally(() => { node.busy = null; }))); const tools = nodes.flatMap((node) => (node.data?.tools ?? []).filter((tool) => TOOL_ID.test(tool?.tool)).map((tool) => ({ ...tool, tool: `${node.id}:${tool.tool}`, rawTool: tool.tool, node: node.id, title: `${node.id}: ${tool.title ?? tool.tool}`, readOnly: node.data.readOnly === true, ...(node.status === 'online' || node.status === 'slow' ? {} : { status: 'offline', reason: node.detail }) }))); - return { status: 200, data: { tools, pets: [], assetPacks: [], node: { id: 'static', version: 'H2' }, errors: [], readOnly: false } }; + return { status: 200, headers: {}, data: { tools, pets: [], assetPacks: [], node: { id: 'static', version: 'H2' }, errors: [], readOnly: false } }; }, async post(path, body, refs) { if (path === '/tools') return this.tools(); - if (path === '/ping') return { status: 200, data: { ok: true } }; + if (path === '/ping') return { status: 200, headers: {}, data: { ok: true } }; const route = resolve(path, refs); - if (!route) return { status: 404, data: { ok: false, error: 'Unknown node/tool route; reload the static console.' } }; - if (route.node.status !== "online" && route.node.status !== "slow") return { status: 0, data: { ok: false, error: route.node.detail } }; + if (!route) return { status: 404, headers: {}, data: { ok: false, error: 'Unknown node/tool route; reload the static console.' } }; + if (route.node.status !== "online" && route.node.status !== "slow") return { status: 0, headers: {}, data: { ok: false, error: route.node.detail } }; return request(route.node, route.path, body); }, resource(path, refs) { diff --git a/src/server.ts b/src/server.ts index b4e1191..c5ee612 100644 --- a/src/server.ts +++ b/src/server.ts @@ -183,6 +183,9 @@ export function startServer(options: ServerOptions) { : { tool: p.tool, status: p.status, reason: p.reason, file: p.file }); async function snapshot(entry: PluginEntry, input: Record) { + // Transport control is never provider input or part of the canonical snapshot cache key. + const { known, ...pluginInput } = input; + if (known !== undefined && (typeof known !== "string" || !/^[0-9a-f]{64}$/.test(known))) return reject(400, "known must be a SHA-256 version from X-Acon-Version"); const caps = entry.manifest!.capabilities ?? {}; const panel = input.panel; const allowed = declaresPanel(entry.manifest!, panel); @@ -195,17 +198,22 @@ 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 request = { ...input, after, panel }; + const request = { ...pluginInput, 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 cursor = Number.isSafeInteger(result.cursor) && (result.cursor as number) >= 0 ? { cursor: result.cursor } : {}; + // A Space cursor-only reply carries no scene to project. Keep its bounded transport fields. + if (result.changed === false && result.scene === undefined) return { changed: false, ...cursor }; const clean = sanitizeSpaceScene(result.scene); - return { ok: true, ...clean, ...(Number.isSafeInteger(result.cursor) && (result.cursor as number) >= 0 ? { cursor: result.cursor } : {}) }; + return { ok: true, ...clean, ...(result.changed === false ? { changed: false } : {}), ...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() }); + // A producer cursor/changed:false reply is authoritative, including legacy full replies. + const unchanged = known === reply.version && reply.value.changed !== false && reply.value.ok !== false; + return json(unchanged ? { changed: false } : 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(), "X-Acon-Version": reply.version }); } catch (error) { record(entry.tool, `snapshot ${panel}`, error); return reject(502, `${entry.tool} could not refresh; details are in System`); @@ -462,7 +470,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"); + response.headers.set("Access-Control-Expose-Headers", "X-Acon-Cache, X-Acon-Cache-Age-Ms, X-Acon-Sampled-At, X-Acon-Version"); } } return response; diff --git a/src/snapshot-cache.ts b/src/snapshot-cache.ts index 3455baa..bf8061a 100644 --- a/src/snapshot-cache.ts +++ b/src/snapshot-cache.ts @@ -9,6 +9,7 @@ export interface SnapshotCacheReply { status: "hit" | "miss" | "stale"; sampledAt: number; ageMs: number; + version: string; } interface ReadInput { provider: object; tool: string; panel: string; namespace: SnapshotNamespace; @@ -16,7 +17,7 @@ interface ReadInput { load: () => Promise>; } interface Value { - provider: object; tool: string; json: string; sampledAt: number; bytes: number; + provider: object; tool: string; json: string; sampledAt: number; bytes: number; version: string; } interface Metric { tool: string; panel: string; namespace: SnapshotNamespace; @@ -66,7 +67,7 @@ export class SnapshotCache { } 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 }; + return { value: JSON.parse(value.json), sampledAt: value.sampledAt, ageMs: Math.max(0, this.now() - value.sampledAt), version: value.version, status }; } private refresh(key: string, input: ReadInput, metric: Metric): Promise { const existing = this.flights.get(key); @@ -78,7 +79,8 @@ export class SnapshotCache { 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) }; + const version = createHash("sha256").update(json).digest("hex"); + const value = { provider: input.provider, tool: input.tool, json, version, sampledAt: this.now(), bytes: Buffer.byteLength(key) + Buffer.byteLength(json) + version.length }; // 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); diff --git a/test/lineage-shell.test.ts b/test/lineage-shell.test.ts index d14785e..985c555 100644 --- a/test/lineage-shell.test.ts +++ b/test/lineage-shell.test.ts @@ -9,7 +9,7 @@ function pollHarness(post: (path: string, input: Record) => Pro const p = { instance: { show: () => {} }, busy: false, next: 0, gen: 1, cursor: 7, banner: { hidden: false }, lastSig: "", lastData: null as Record | null, view: JSON.stringify(["lineage", { rootId: "old" }]), mount: { hidden: true }, loading: Promise.resolve(), snapListeners: [(value: Record) => { delivered.push(value); }] }; const state = { route: { area: "tool", tool: "a:ares", panel: "lineage", params: { rootId: "old" } }, panels: new Map([["a:ares", p]]) }; const body = source.slice(source.indexOf("async function showTool("), source.indexOf("// ---------- actions and confirm")); - const poll = runInNewContext(`${body}\n({poll: pollPanel, show: showTool})`, { state, post, toolById: (id: string) => ({ tool: id, pollMs: 2000 }), safe: (_tool: string, _where: string, fn: () => void) => fn(), Date, JSON }) as { poll: () => Promise; show: (r: unknown) => Promise }; + const poll = runInNewContext(`${source.slice(source.indexOf("function adaptivePollMs("), source.indexOf("const inboxApplied"))}\n${body}\n({poll: pollPanel, show: showTool})`, { state, post, document: { visibilityState: "visible" }, toolById: (id: string) => ({ tool: id, pollMs: 2000 }), safe: (_tool: string, _where: string, fn: () => void) => fn(), Date, JSON }) as { poll: () => Promise; show: (r: unknown) => Promise }; return { p, state, delivered, poll: poll.poll, show: poll.show }; } diff --git a/test/shell-polling.test.ts b/test/shell-polling.test.ts new file mode 100644 index 0000000..f9eec46 --- /dev/null +++ b/test/shell-polling.test.ts @@ -0,0 +1,151 @@ +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { runInNewContext } from "node:vm"; + +const source = readFileSync(new URL("../shell/shell.js", import.meta.url), "utf8"); +const adaptive = source.slice(source.indexOf("function adaptivePollMs("), source.indexOf("const inboxApplied")); +const panelSource = source.slice(source.indexOf("async function pollPanel()"), source.indexOf("// ---------- actions and confirm")); +const spaceSource = source.slice(source.indexOf("async function pollSpace()"), source.indexOf("// ---------- pet and Mini")); +const visibilitySource = source.slice(source.indexOf("function resumeVisiblePolling()"), source.indexOf('document.addEventListener("visibilitychange"')); +const tickSource = source.slice(source.indexOf("function tick()"), source.indexOf("function resumeVisiblePolling()")); +const inboxSource = source.slice(source.indexOf("let inboxSeq = 0;"), source.indexOf("function sortedItems()")); +const VERSION = "a".repeat(64); +const flush = async () => { for (let i = 0; i < 8; i++) await Promise.resolve(); }; + +function pollingHarness() { + let clock = 0, writes = 0; + const requests: { url: string; body: any }[] = []; + const document = { visibilityState: "visible" }; + const panel = { instance: {}, busy: false, next: 0, gen: 0, cursor: 0, lastSig: "", lastData: null, version: "", resume: false, + banner: { hiddenValue: true, get hidden() { return this.hiddenValue; }, set hidden(value: boolean) { writes++; this.hiddenValue = value; } }, + snapListeners: [() => { writes++; }] }; + const state = { route: { area: "tool", tool: "demo", panel: "notes", params: {} }, panels: new Map([["demo", panel]]), space: new Map(), ended: false, + nextInbox: 999999, nextTools: 999999, lastRequest: 0, toolsBusy: false }; + let response = async (_url: string, _body: any): Promise => ({ status: 200, data: { notes: [1], cursor: 1 }, headers: { "x-acon-version": VERSION } }); + let renders = 0, live = 0, inboxPolls = 0, pip: object | null = null; + const api = runInNewContext(`${adaptive}\n${panelSource}\n${spaceSource}\n${tickSource}\n${visibilitySource}\n({pollPanel,pollSpace,resumeVisiblePolling,tick,adaptivePollMs})`, { + state, document, Date: class extends Date { static now() { return clock; } }, queueMicrotask, + post: (url: string, body: any) => { requests.push({ url, body }); return response(url, body); }, + toolById: () => ({ pollMs: 2000 }), spaceTools: () => [{ tool: "demo", pollMs: 2000 }], + spaceRenderer: {}, renderSpace: () => { renders++; }, safe: (_tool: string, _name: string, fn: () => void) => fn(), + renderLive: () => { live++; }, pollInbox: async () => { inboxPolls++; }, loadTools: async () => {}, clientError() {}, + get pip() { return pip; }, PING_MS: 60000, TOOLS_MS: 30000, + }); + return { state, panel, document, requests, api, setResponse(fn: typeof response) { response = fn; }, setClock(value: number) { clock = value; }, setPip(value: object | null) { pip = value; }, + get clock() { return clock; }, get writes() { return writes; }, get renders() { return renders; }, get inboxPolls() { return inboxPolls; }, get live() { return live; } }; +} + +test("panel polling stretches from start time, caps and recovers with faster calls", async () => { + const h = pollingHarness(); + h.setResponse(async () => { h.setClock(3000); return { status: 200, data: { notes: [1], cursor: 1 }, headers: { "x-acon-version": VERSION } }; }); + await h.api.pollPanel(); expect(h.panel.next).toBe(9000); + h.setClock(8999); await h.api.pollPanel(); expect(h.requests).toHaveLength(1); + h.setClock(9000); h.setResponse(async () => { h.setClock(9100); return { status: 200, data: { changed: false }, headers: { "x-acon-version": VERSION } }; }); + const before = h.writes; + await h.api.pollPanel(); expect(h.panel.next).toBe(11000); expect(h.writes).toBe(before); + expect(h.requests[1]!.body.known).toBe(VERSION); expect(h.panel.cursor).toBe(1); + expect(h.api.adaptivePollMs(2000, 40000)).toBe(60000); +}); + +test("hidden panels and Space pause even with PiP; visibility resumes one immediate request", async () => { + for (const area of ["tool", "space"]) { + const h = pollingHarness(); h.state.route.area = area; h.document.visibilityState = "hidden"; + h.setPip({}); h.api.tick(); await flush(); + expect(h.requests).toHaveLength(0); expect(h.live).toBe(1); + h.document.visibilityState = "visible"; + h.api.resumeVisiblePolling(); await flush(); + expect(h.requests).toHaveLength(1); + h.api.tick(); await flush(); expect(h.requests).toHaveLength(1); + } +}); + +test("a visibility return during an in-flight panel queues only one immediate follow-up", async () => { + const h = pollingHarness(); let release!: (response: any) => void; + h.setResponse(() => new Promise((resolve) => { release = resolve; })); + const first = h.api.pollPanel(); + h.document.visibilityState = "hidden"; h.api.resumeVisiblePolling(); + h.document.visibilityState = "visible"; h.api.resumeVisiblePolling(); + expect(h.requests).toHaveLength(1); + h.setResponse(async () => ({ status: 200, data: { changed: false }, headers: { "x-acon-version": VERSION } })); + release({ status: 200, data: { notes: [1], cursor: 1 }, headers: { "x-acon-version": VERSION } }); + await first; await flush(); expect(h.requests).toHaveLength(2); +}); + +test("Space reads stale headers on known replies without replacing scene or advancing sample time", async () => { + const h = pollingHarness(); h.state.route.area = "space"; + const scene = { actors: [{ id: "one" }], zones: [], cards: [] }; + h.setClock(100000); + h.setResponse(async () => ({ status: 200, data: { scene, cursor: 7 }, headers: { "x-acon-version": VERSION, "x-acon-cache": "hit", "x-acon-sampled-at": new Date(1000).toISOString() } })); + await h.api.pollSpace(); + const entry = h.state.space.get("demo"); expect(entry.sampledAt).toBe(1000); expect(entry.failed).toBe(false); + h.setClock(entry.next); + h.setResponse(async () => ({ status: 200, data: { changed: false }, headers: { "x-acon-version": VERSION, "x-acon-cache": "stale", "x-acon-sampled-at": new Date(1000).toISOString() } })); + await h.api.pollSpace(); + expect(h.requests[1]!.body).toMatchObject({ after: 7, known: VERSION }); + expect(entry.scene).toEqual(scene); expect(entry.sampledAt).toBe(1000); expect(entry.failed).toBe(true); + const mergeSource = readFileSync(new URL("../shell/space.js", import.meta.url), "utf8").replace(/^import .*;$/gm, "").replace(/^export /gm, ""); + const merge = runInNewContext(`${mergeSource}\nmergeSpaceScenes`); + entry.scene = { ...scene, actors: [{ id: "one", name: "One", state: "working", link: { panel: "notes" } }] }; + expect(merge(h.state.space).actors[0].stale).toBe(true); +}); + +test("Space legacy nodes and cursor-only replies keep scene/cursor and recover the interval", async () => { + const h = pollingHarness(); h.state.route.area = "space"; + const scene = { actors: [], zones: [], cards: [] }; + h.setResponse(async () => { h.setClock(4000); return { status: 200, data: { scene, cursor: 2 } }; }); + await h.api.pollSpace(); const entry = h.state.space.get("demo"); expect(entry.next).toBe(12000); + h.setClock(12000); + h.setResponse(async () => ({ status: 200, data: { changed: false, cursor: 3 } })); + await h.api.pollSpace(); expect(entry.next).toBe(14000); expect(entry.cursor).toBe(3); expect(entry.scene).toEqual(scene); expect(entry.failed).toBe(false); +}); + +test("panel known replies write nothing, and producer cursor replies still advance", async () => { + const h = pollingHarness(); await h.api.pollPanel(); + h.setClock(h.panel.next); + h.setResponse(async () => ({ status: 200, data: { changed: false }, headers: { "x-acon-version": VERSION } })); + const before = h.writes; await h.api.pollPanel(); expect(h.writes).toBe(before); + h.setClock(h.panel.next); h.setResponse(async () => ({ status: 200, data: { changed: false, cursor: 8 } })); + await h.api.pollPanel(); expect(h.writes).toBe(before); expect(h.panel.cursor).toBe(8); +}); + +test("progressive inbox renders fast replies before slow ones and has per-tool adaptive deadlines", async () => { + let now = 0, release!: (response: any) => void; + const state = { inbox: new Map(), route: { area: "inbox" }, peekKey: null }; + const calls: { tool: string; input: any }[] = [], renders: string[][] = []; + const poll = runInNewContext(`${inboxSource}\npollInbox`, { + state, INBOX_MS: 5000, Date: class extends Date { static now() { return now; } }, + readyTools: () => [{ tool: "slow", pollMs: 2000, capabilities: { inbox: true } }, { tool: "fast", pollMs: 2000, capabilities: { inbox: true } }], + validItem: () => true, clientError() {}, renderRail() {}, renderMini() {}, renderPeek() {}, renderInbox: () => renders.push([...state.inbox.keys()]), + post: (url: string, input: any) => { const tool = url.split("/")[2]!; calls.push({ tool, input }); + return tool === "slow" ? new Promise((resolve) => { release = resolve; }) : Promise.resolve({ status: 200, data: calls.filter((call) => call.tool === "fast").length === 1 ? { items: [{ id: "fast" }] } : { changed: false }, headers: { "x-acon-version": VERSION } }); }, + }); + const pending = poll(false); await flush(); + expect(renders).toEqual([["fast"]]); expect(state.inbox.get("fast").next).toBe(2000); + now = 6000; release({ status: 200, data: { items: [] } }); await pending; + expect(state.inbox.get("slow").next).toBe(18000); + const before = renders.length; + await poll(false); expect(calls.filter((call) => call.tool === "slow")).toHaveLength(1); + expect(calls.filter((call) => call.tool === "fast")).toHaveLength(2); + expect(calls.at(-1)!.input.known).toBe(VERSION); expect(renders).toHaveLength(before); +}); + + +test("hidden PiP keeps inbox polling while the ordinary hidden-tab rule remains unchanged", async () => { + const h = pollingHarness(); h.document.visibilityState = "hidden"; h.state.nextInbox = 0; + h.api.tick(); await flush(); expect(h.inboxPolls).toBe(0); + h.setPip({}); h.api.tick(); await flush(); expect(h.inboxPolls).toBe(1); + expect(h.requests).toHaveLength(0); +}); + + +test("a re-hidden pending refresh does not leave a second resume queued", async () => { + const h = pollingHarness(); let release!: (response: any) => void; + h.setResponse(() => new Promise((resolve) => { release = resolve; })); + const first = h.api.pollPanel(); + h.api.resumeVisiblePolling(); h.document.visibilityState = "hidden"; + release({ status: 200, data: { notes: [1] }, headers: { "x-acon-version": VERSION } }); + await first; + h.document.visibilityState = "visible"; + h.setResponse(async () => ({ status: 200, data: { changed: false }, headers: { "x-acon-version": VERSION } })); + h.api.resumeVisiblePolling(); await flush(); expect(h.requests).toHaveLength(2); +}); diff --git a/test/snapshot-cache.test.ts b/test/snapshot-cache.test.ts index d98387c..f6b81be 100644 --- a/test/snapshot-cache.test.ts +++ b/test/snapshot-cache.test.ts @@ -156,7 +156,8 @@ async function httpFixture(reply?: Record) { 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 inputs: Record[] = []; + provider.snapshot = async (input) => { inputs.push({ ...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); @@ -166,7 +167,7 @@ async function httpFixture(reply?: Record) { 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; } }; + return { server, viewer, inputs, 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 () => { @@ -290,3 +291,69 @@ test("explicit panels own their TTL before reserved capabilities", () => { expect(snapshotMaxAge(manifest, "space")).toBe(2000); expect(snapshotMaxAge(manifest, "inbox")).toBe(2000); }); + + +test("HTTP known versions skip unchanged bodies without reaching provider input or fragmenting keys", async () => { + const payload = { notes: ["same"], cursor: 3, version: "producer-owned" }; + const fixture = await httpFixture(payload), post = await fixture.viewer(); + const first = await post("/p/demo/snapshot", { panel: "notes", after: 0 }); + const version = first.headers.get("x-acon-version"); + expect(version).toMatch(/^[0-9a-f]{64}$/); + expect(await first.json()).toEqual(payload); + const second = await post("/p/demo/snapshot", { panel: "notes", after: 0, known: version }); + expect(await second.json()).toEqual({ changed: false }); + expect(second.headers.get("x-acon-version")).toBe(version); + expect(second.headers.get("x-acon-cache")).toBe("hit"); + const unknown = await post("/p/demo/snapshot", { panel: "notes", after: 0, known: "0".repeat(64) }); + expect(await unknown.json()).toEqual(payload); + expect(fixture.calls).toBe(1); + expect(fixture.inputs).toEqual([{ panel: "notes", after: 0 }]); + const tools = await (await post("/tools", {})).json(); + expect(tools.snapshotCache.entries).toBe(1); + fixture.advance(4000); + const refreshed = await post("/p/demo/snapshot", { panel: "notes", after: 0, known: version }); + expect(refreshed.headers.get("x-acon-version")).toBe(version); + expect(await refreshed.json()).toEqual({ changed: false }); + expect(fixture.calls).toBe(2); + expect(fixture.inputs[1]).toEqual({ panel: "notes", after: 0 }); +}); + +test("plugin cursor and legacy full changed:false replies take precedence over known", async () => { + for (const payload of [{ cursor: 7, changed: false }, { cursor: 7, changed: false, notes: ["legacy full reply"] }]) { + const fixture = await httpFixture(payload), post = await fixture.viewer(); + const first = await post("/p/demo/snapshot", { panel: "notes", after: 7 }); + const version = first.headers.get("x-acon-version"); + const second = await post("/p/demo/snapshot", { panel: "notes", after: 7, known: version }); + expect(await second.json()).toEqual(payload); + expect(fixture.calls).toBe(1); + } +}); + +test("known never suppresses or retains producer error replies", async () => { + const payload = { ok: false, error: "project not running", version: "producer error" }; + const fixture = await httpFixture(payload), post = await fixture.viewer(); + const first = await post("/p/demo/snapshot", { panel: "notes" }); + const version = first.headers.get("x-acon-version"); + const second = await post("/p/demo/snapshot", { panel: "notes", known: version }); + expect(second.status).toBe(200); expect(await second.json()).toEqual(payload); + expect(fixture.calls).toBe(2); +}); + +test("malformed known versions are rejected before provider dispatch", async () => { + const fixture = await httpFixture({ notes: [] }), post = await fixture.viewer(); + for (const known of [null, 0, "", "not-a-hash", "a".repeat(65), "A".repeat(64)]) { + expect((await post("/p/demo/snapshot", { panel: "notes", known })).status).toBe(400); + } + expect(fixture.calls).toBe(0); +}); + + +test("Space cursor-only unchanged replies preserve their cursor without admitting private top-level fields", async () => { + const fixture = await httpFixture({ cursor: 7, changed: false, privateField: "not public" }), post = await fixture.viewer(); + const first = await post("/p/demo/snapshot", { panel: "space", after: 7 }); + const version = first.headers.get("x-acon-version"); + expect(await first.json()).toEqual({ changed: false, cursor: 7 }); + const second = await post("/p/demo/snapshot", { panel: "space", after: 7, known: version }); + expect(await second.json()).toEqual({ changed: false, cursor: 7 }); + expect(fixture.calls).toBe(1); +}); diff --git a/test/static-transport.test.ts b/test/static-transport.test.ts index f8310fe..3469ac8 100644 --- a/test/static-transport.test.ts +++ b/test/static-transport.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { runInNewContext } from "node:vm"; -interface Result { status: number; data: Record } +interface Result { status: number; headers: Record; data: Record } interface Transport { nodes: { id: string; status: string }[]; tools(): Promise; @@ -81,7 +81,7 @@ describe("H2 browser transport", () => { const client = make({ api: 1, nodes: [config.nodes[0]] }); await client.tools(); const stale = await client.post("/p/a:demo/action", { action: "delete", confirmToken: "expired" }); - expect(stale).toEqual({ status: 409, data: { ok: false, stale: true } }); + expect(stale).toEqual({ status: 409, headers: {}, data: { ok: false, stale: true } }); expect(client.nodes[0]!.status).toBe("online"); expect((await client.post("/p/a:demo/action", { action: "delete" })).data.needsConfirm).toBe(true); for (const status of [400, 502]) { @@ -226,6 +226,7 @@ test("inbox polling keeps one snapshot in flight per tool; an explicit refresh w const started: string[] = []; let inFlight = 0, peak = 0; const ctx = { + INBOX_MS: 5000, state: { inbox: new Map(), route: { area: "system" }, peekKey: null }, readyTools: () => [{ tool: "slow", capabilities: { inbox: true } }, { tool: "fast", capabilities: { inbox: true } }], validItem: () => true, clientError: () => {}, renderRail: () => {}, renderMini: () => {}, renderInbox: () => {}, renderPeek: () => {}, @@ -256,3 +257,50 @@ test("inbox polling keeps one snapshot in flight per tool; an explicit refresh w expect(peak).toBe(1); expect(started.filter((t) => t === "slow").length).toBe(2); }); + + +test("static snapshots retain exposed cache/version headers and forward known without changing the body", async () => { + const known = "1".repeat(64); + const snapshotHeaders = { + "X-Acon-Version": known, + "X-Acon-Cache": "stale", + "X-Acon-Cache-Age-Ms": "2500", + "X-Acon-Sampled-At": "2026-11-10T00:00:00.000Z", + "X-Private-Diagnostic": "do not forward", + }; + const sent: Record[] = []; + const { make } = transport((async (input, init) => { + const path = new URL(String(input)).pathname; + if (path === "/session") return Response.json({ resourceToken: "a".repeat(64), expiresIn: 3600000 }); + if (path === "/tools") return Response.json({ tools: [] }); + sent.push(JSON.parse(String(init?.body))); + return Response.json({ changed: false }, { headers: snapshotHeaders }); + }) as typeof fetch); + const client = make({ api: 1, nodes: [config.nodes[0]] }); + await client.tools(); + const result = await client.post("/p/a:demo/snapshot", { panel: "space", after: 3, known }); + expect(sent).toEqual([{ panel: "space", after: 3, known }]); + expect(result.data).toEqual({ changed: false }); + expect(result.headers).toEqual({ + "x-acon-version": known, + "x-acon-cache": "stale", + "x-acon-cache-age-ms": "2500", + "x-acon-sampled-at": "2026-11-10T00:00:00.000Z", + }); +}); + +test("static transport accepts older nodes with no cache headers or response-header interface", async () => { + for (const noHeadersInterface of [false, true]) { + const { make } = transport((async (input) => { + const path = new URL(String(input)).pathname; + const data = path === "/session" ? { resourceToken: "a".repeat(64), expiresIn: 3600000 } + : path === "/tools" ? { tools: [] } : { scene: { actors: [] }, sampledAt: "producer-owned" }; + return noHeadersInterface ? { status: 200, json: async () => data } as Response : Response.json(data); + }) as typeof fetch); + const client = make({ api: 1, nodes: [config.nodes[0]] }); + await client.tools(); + const result = await client.post("/p/a:demo/snapshot", { panel: "space", after: 0 }); + expect(result.headers).toEqual({}); + expect(result.data).toEqual({ scene: { actors: [] }, sampledAt: "producer-owned" }); + } +}); From e4736de961a34674cfb893f4a520a332c58e84e0 Mon Sep 17 00:00:00 2001 From: Young Joon Lee Date: Tue, 10 Nov 2026 22:57:54 +0900 Subject: [PATCH 2/5] fix(snapshot): distinguish SWR grace and refresh expired snoozes --- CHANGELOG.md | 4 ++- README.md | 2 +- scripts/ui-check-space-board.mjs | 2 +- scripts/ui-check-space-flow.mjs | 33 ++++++++++++++++- scripts/ui-check-space-foundation.mjs | 2 +- scripts/ui-check.mjs | 2 +- shell/shell.js | 12 +++++-- shell/snapshot-headers.js | 8 +++++ shell/static.js | 8 ++--- src/server.ts | 2 +- src/static.ts | 2 +- test/shell-polling.test.ts | 51 +++++++++++++++++++++++++-- test/space-server.test.ts | 1 + test/static-transport.test.ts | 5 +-- test/static.test.ts | 2 +- 15 files changed, 113 insertions(+), 23 deletions(-) create mode 100644 shell/snapshot-headers.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 35c3ec6..c87f8e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Compatibility +- `known` is now a reserved snapshot request field. Plugin route params and `host.snapshot` extras named `known` are consumed by the console transport and no longer reach provider input (#105). + - Planned 0.2.0 N-1 support targets 0.1.8, the latest published patch of 0.1.x, and remains additive on API 1: - Plugin API: verified by a directional type bridge (old plugin values accepted by the current console; current console values satisfy old plugins; historical optional keys are retained) and the exact published 0.1.8 conformance kit against a 0.1.8-shaped provider opened by the current loader. New principal capabilities remain opt-in. - Routes: verified for the legacy browser session, tool listing, ping, panel snapshots, actions, confirmation-token exchange and a sample plugin asset through both tool-id and load-reference paths. Other Access/bearer and static-shell routes remain supported by policy but are not exercised by the added historical probe. @@ -11,7 +13,7 @@ ### Added -- Add header-only snapshot versions and reserved `known` conditional requests, preserving plugin cursor replies and forwarding version/freshness headers through the static transport. Adapt polling to request duration, pause hidden panel/Space polls with immediate visible refresh, progressively render inbox sources, and retain stale Space sample times and actor labels (#105). +- Add header-only snapshot versions and reserved `known` conditional requests, preserving plugin cursor replies and forwarding version/freshness headers through the static transport. Adapt polling to request duration, pause hidden panel/Space polls with immediate visible refresh, progressively render inbox sources, refresh expired snoozes on unchanged rounds, and retain SWR sample times without marking healthy Space sources failed (#105). - Add the Space Board view with per-group zone columns, producer-linked cards, owner detail controls, and collapsed Done history showing the last five supplied cards. Keep horizontal scrolling within each board row, preserve focus/disclosures, and cover the responsive matrix with a Board probe (#101). diff --git a/README.md b/README.md index a6f3315..8462114 100644 --- a/README.md +++ b/README.md @@ -305,7 +305,7 @@ The cache holds at most 256 entries and 4 MiB, evicts least recently used entrie Conditional snapshots (#105): each successful snapshot carries an `X-Acon-Version` content hash. A shell can send its last hash as `known`; the server removes this reserved transport field before building the cache key or calling the plugin. A matching successful value returns `{changed: false}` with the version header. Plugin `after` cursors and producer `changed: false` replies take precedence, including their cursor fields. Version and freshness metadata stay in headers, so producer body fields are unchanged by this protocol. The H2 transport forwards `known` and exposes the same version/cache/sample-time headers to the shell; nodes without those headers keep their existing full-reply behavior. -Panel and Space polling use `max(pollMs, 3 × last request duration)`, capped at 60 seconds, with `pollMs` clamped to 2-60 seconds. The interval recovers when requests become faster. Hidden tabs start no panel or Space polls, and becoming visible triggers one immediate refresh; existing Mini/Picture-in-Picture inbox behavior is preserved. Inbox sources render as each tool finishes instead of waiting for the slowest source. A Space reply marked `X-Acon-Cache: stale` keeps the `X-Acon-Sampled-At` time and marks its actors stale, including on an unchanged conditional reply. +Panel, Space and per-tool inbox polling use `max(pollMs, 3 × last request duration)`, capped at 60 seconds, with `pollMs` clamped to 2-60 seconds and inbox using `INBOX_MS` (5 seconds) when the tool omits `pollMs`. The interval recovers when requests become faster. Hidden tabs start no panel or Space polls, and becoming visible triggers one immediate refresh; existing Mini/Picture-in-Picture inbox behavior is preserved. Inbox sources render as each tool finishes instead of waiting for the slowest source. Each inbox round also rechecks time-dependent snoozes through signature-checked renderers, even when no provider data changes. A Space reply marked `X-Acon-Cache: stale` keeps the `X-Acon-Sampled-At` time without marking the source failed, including on an unchanged conditional reply. Routine SWR reuse does not idle pets or show a refresh-failure warning; only unsuccessful replies mark the source failed. 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. diff --git a/scripts/ui-check-space-board.mjs b/scripts/ui-check-space-board.mjs index 3bf35d3..a74a57a 100644 --- a/scripts/ui-check-space-board.mjs +++ b/scripts/ui-check-space-board.mjs @@ -1,5 +1,5 @@ -import { fetchFullSnapshot, fulfillSnapshotFixture } from "./ui-check-snapshot-fixture.mjs"; // #101 Delivery 4: per-project Board, Done disclosure and producer-card navigation. +import { fetchFullSnapshot, fulfillSnapshotFixture } from "./ui-check-snapshot-fixture.mjs"; import assert from "node:assert/strict"; import { join } from "node:path"; diff --git a/scripts/ui-check-space-flow.mjs b/scripts/ui-check-space-flow.mjs index 272713e..3de2c84 100644 --- a/scripts/ui-check-space-flow.mjs +++ b/scripts/ui-check-space-flow.mjs @@ -1,5 +1,5 @@ -import { fetchFullSnapshot, fulfillSnapshotFixture } from "./ui-check-snapshot-fixture.mjs"; // #101 Delivery 3: public-link observations, Flow layout and detail return paths. +import { fetchFullSnapshot, fulfillSnapshotFixture } from "./ui-check-snapshot-fixture.mjs"; import assert from "node:assert/strict"; import { join } from "node:path"; @@ -141,6 +141,36 @@ async function changedLinkProbe({ browser, ticket, report }) { } finally { await context.close(); } } +// Preserve SWR headers in this protocol case. Body-rewriting motion fixtures +// intentionally remove upstream metadata because it no longer describes their body. +async function staleGraceProbe({ browser, ticket, report, output }) { + const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, colorScheme: "light", reducedMotion: "reduce" }); + const page = await context.newPage(); + page.setDefaultTimeout(12000); + const sampledAt = new Date(Date.now() - 10000).toISOString(); + let replies = 0; + try { + await page.route("**/p/*/snapshot", async (route) => { + if (route.request().postDataJSON()?.panel !== "space") { await route.continue(); return; } + const response = await fetchFullSnapshot(route); + replies++; + // No body mutation: the upstream version remains valid. SWR reuses a good sample. + await route.fulfill({ response, headers: { ...response.headers(), "x-acon-cache": "stale", "x-acon-sampled-at": sampledAt } }); + }); + await enterFlow(page, ticket, 100); + assert.equal(await page.locator(".space-flow-actor").filter({ hasText: /\(stale\)/ }).count(), 0, "Routine SWR marked healthy actors stale"); + assert.equal(await page.locator("#view-space > .notice").filter({ hasText: "could not refresh" }).isVisible(), false, "Routine SWR showed a failure banner"); + const writes = await unchangedWrites(page); + assert(replies >= 4, "SWR probe did not preserve headers across both producers and a repeat poll"); + const screenshot = "space-swr-grace.png"; + await page.screenshot({ path: join(output, screenshot), fullPage: true }); + report.captures.push({ key: "space-swr-grace", screenshot, width: 1440, theme: "light", textPercent: 100, view: "space-flow" }); + report.spaceCacheFreshness = { cacheHeader: "stale", sampledAt, replies, failureBanner: false, staleActors: 0, unchangedWrites: writes }; + } catch (error) { + report.findings.push({ kind: "space-swr-grace", detail: error.message }); + } finally { await context.close(); } +} + export async function runSpaceFlow({ browser, ticket, report, output }) { report.spaceFlow = []; for (const width of [375, 768, 1024, 1440]) for (const theme of ["light", "dark"]) for (const textPercent of [100, 200]) { @@ -167,4 +197,5 @@ export async function runSpaceFlow({ browser, ticket, report, output }) { } finally { await context.close(); } } await changedLinkProbe({ browser, ticket, report }); + await staleGraceProbe({ browser, ticket, report, output }); } diff --git a/scripts/ui-check-space-foundation.mjs b/scripts/ui-check-space-foundation.mjs index cbd4f7e..3287401 100644 --- a/scripts/ui-check-space-foundation.mjs +++ b/scripts/ui-check-space-foundation.mjs @@ -1,5 +1,5 @@ -import { fetchFullSnapshot, fulfillSnapshotFixture } from "./ui-check-snapshot-fixture.mjs"; // #101 Delivery 1: routing/layout and raised-cap performance, synthetic scenes only. +import { fetchFullSnapshot, fulfillSnapshotFixture } from "./ui-check-snapshot-fixture.mjs"; import assert from "node:assert/strict"; import { join } from "node:path"; export async function runSpaceFoundation({ browser, ticket, report, output }) { diff --git a/scripts/ui-check.mjs b/scripts/ui-check.mjs index fc3934f..4f89248 100644 --- a/scripts/ui-check.mjs +++ b/scripts/ui-check.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { fetchFullSnapshot, fulfillSnapshotFixture } from "./ui-check-snapshot-fixture.mjs"; // Agent-run browser probe. Playwright is discovered at runtime, never a package dependency. +import { fetchFullSnapshot, fulfillSnapshotFixture } from "./ui-check-snapshot-fixture.mjs"; import { spawn } from "node:child_process"; import { createRequire } from "node:module"; import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; diff --git a/shell/shell.js b/shell/shell.js index 66ac12f..bacaf09 100644 --- a/shell/shell.js +++ b/shell/shell.js @@ -1,4 +1,5 @@ // acon shell (H0). Vanilla ES module under a strict CSP: no inline code, and data is rendered as text, never as markup. +import { snapshotHeaders } from "./snapshot-headers.js"; import { ICONS as TABLER } from "./vendor/icons/icons.js"; const NODE = "local"; const GROUPS = [["decision", "Needs your decision"], ["review", "To review"], ["result", "Results"], ["candidate", "Candidates"], ["alert", "Alerts"]]; @@ -818,7 +819,7 @@ async function post(path, body = {}) { try { data = await res.json(); } catch { /* non-JSON error body */ } if (res.status === 401 && path !== "/session") endSession("This console session ended."); return { status: res.status, data: data ?? { ok: false, error: `HTTP ${res.status}` }, - headers: Object.fromEntries(["x-acon-version", "x-acon-cache", "x-acon-cache-age-ms", "x-acon-sampled-at"].flatMap((name) => { const value = res.headers?.get?.(name); return value === null || value === undefined ? [] : [[name, value]]; })) }; + headers: snapshotHeaders(res) }; } function clientError(tool, where, error) { state.clientErrors.unshift({ at: new Date().toISOString(), tool, where, message: String(error?.message ?? error).split("\n")[0].slice(0, 200) }); @@ -906,7 +907,8 @@ let railSig = ""; function renderRail() { const r = state.route; const count = needsMeCount(); - document.title = count ? `acon (${count})` : "acon"; + const title = count ? `acon (${count})` : "acon"; + if (document.title !== title) document.title = title; renderPet(); const issues = state.serverErrors.length + state.clientErrors.length + state.tools.filter((t) => t.status !== "ready").length + (state.node?.pluginsError ? 1 : 0); const sig = JSON.stringify([r.area, r.tool, count, issues, spaceTools().length > 0, readyTools().map((t) => [t.tool, t.title, t.icon])]); @@ -1034,6 +1036,9 @@ async function pollInbox(fresh = true) { for (const tool of [...state.inbox.keys()]) if (!tools.some((t) => t.tool === tool)) { state.inbox.delete(tool); removed = true; } if (removed) renderInboxSources(); await Promise.all(tools.map((t) => inboxOnce(t, fresh))); + // Time-dependent snoozes can expire even when every provider result is unchanged. + renderRail(); renderMini(); + if (state.route.area === "inbox") renderInbox(); } async function inboxFetch(t, seq) { const previous = state.inbox.get(t.tool); @@ -1927,7 +1932,8 @@ async function pollSpace() { if (res.data.scene) { entry.scene = res.data.scene; entry.dropped = res.data.dropped ?? { actors: 0, cards: 0, links: 0 }; } if (Number.isSafeInteger(res.data.cursor)) entry.cursor = res.data.cursor; entry.version = res.headers?.["x-acon-version"] ?? ""; - entry.failed = stale; + // SWR grace is healthy reuse, not a failed refresh. Only unsuccessful replies fail a source. + entry.failed = false; } else { entry.failed = true; entry.version = ""; } })); renderSpace(); diff --git a/shell/snapshot-headers.js b/shell/snapshot-headers.js new file mode 100644 index 0000000..9fad1bc --- /dev/null +++ b/shell/snapshot-headers.js @@ -0,0 +1,8 @@ +// Snapshot transport metadata is separate from every producer's open-record body. +const SNAPSHOT_HEADERS = ["x-acon-version", "x-acon-cache", "x-acon-cache-age-ms", "x-acon-sampled-at"]; +export function snapshotHeaders(response) { + return Object.fromEntries(SNAPSHOT_HEADERS.flatMap((name) => { + const value = response.headers?.get?.(name); + return typeof value === "string" ? [[name, value]] : []; + })); +} diff --git a/shell/static.js b/shell/static.js index d7686eb..ad82b6e 100644 --- a/shell/static.js +++ b/shell/static.js @@ -1,11 +1,7 @@ // H2 transport. Credentials live only in this tab; the static host serves no node data. +import { snapshotHeaders } from "./snapshot-headers.js"; const NODE_ID = /^[a-z][a-z0-9-]{0,31}$/; const TOOL_ID = /^[a-z][a-z0-9-]{1,15}$/; -const SNAPSHOT_HEADERS = ['x-acon-version', 'x-acon-cache', 'x-acon-cache-age-ms', 'x-acon-sampled-at']; -const responseHeaders = (response) => Object.fromEntries(SNAPSHOT_HEADERS.flatMap((name) => { - const value = response.headers?.get?.(name); - return typeof value === 'string' ? [[name, value]] : []; -})); const loopback = (host) => host === 'localhost' || host === '[::1]' || /^127\.\d+\.\d+\.\d+$/.test(host); export function createStaticTransport(config, onStatus = () => {}) { @@ -56,7 +52,7 @@ export function createStaticTransport(config, onStatus = () => {}) { node.grant = ''; notify(node, 'unauthorized', 'Authentication refused; enter this node\'s bearer token or sign in to its Access application, then reconnect.'); } else notify(node, 'online', 'Connected'); - return { status: response.status, headers: responseHeaders(response), data: data ?? { ok: false, error: `Node answered HTTP ${response.status} without JSON; check its System log.` } }; + return { status: response.status, headers: snapshotHeaders(response), data: data ?? { ok: false, error: `Node answered HTTP ${response.status} without JSON; check its System log.` } }; }; const connect = async (node) => { if (!node.grant || Date.now() >= node.grantUntil) { diff --git a/src/server.ts b/src/server.ts index c5ee612..49230ff 100644 --- a/src/server.ts +++ b/src/server.ts @@ -102,7 +102,7 @@ export function startServer(options: ServerOptions) { const log = options.log ?? ((line: string) => console.error(`acon: ${line}`)); const startedAt = new Date(now()).toISOString(); const serve = options.serve; - const shell = { "/": ["index.html", "text/html; charset=utf-8"], "/shell.js": ["shell.js", "text/javascript; charset=utf-8"], "/space.js": ["space.js", "text/javascript; charset=utf-8"], "/space-team.js": ["space-team.js", "text/javascript; charset=utf-8"], "/space-board.js": ["space-board.js", "text/javascript; charset=utf-8"], "/space-flow.js": ["space-flow.js", "text/javascript; charset=utf-8"], "/space-view.js": ["space-view.js", "text/javascript; charset=utf-8"], "/space3d.js": ["space3d.js", "text/javascript; charset=utf-8"], "/shell.css": ["shell.css", "text/css; charset=utf-8"], "/icon.svg": ["icon.svg", "image/svg+xml"] } as const; + const shell = { "/": ["index.html", "text/html; charset=utf-8"], "/shell.js": ["shell.js", "text/javascript; charset=utf-8"], "/snapshot-headers.js": ["snapshot-headers.js", "text/javascript; charset=utf-8"], "/space.js": ["space.js", "text/javascript; charset=utf-8"], "/space-team.js": ["space-team.js", "text/javascript; charset=utf-8"], "/space-board.js": ["space-board.js", "text/javascript; charset=utf-8"], "/space-flow.js": ["space-flow.js", "text/javascript; charset=utf-8"], "/space-view.js": ["space-view.js", "text/javascript; charset=utf-8"], "/space3d.js": ["space3d.js", "text/javascript; charset=utf-8"], "/shell.css": ["shell.css", "text/css; charset=utf-8"], "/icon.svg": ["icon.svg", "image/svg+xml"] } as const; const files: Record = Object.fromEntries(Object.entries(shell).map(([path, [name, type]]) => [path, { body: asset(name), type }])); // Sandboxed visual frame: fixed files only, served with FRAME_CSP and framable by the console itself. files["/visual/frame"] = { body: asset("visual-frame.html"), type: "text/html; charset=utf-8", frame: true }; diff --git a/src/static.ts b/src/static.ts index 692674a..9cedff1 100644 --- a/src/static.ts +++ b/src/static.ts @@ -39,7 +39,7 @@ export function staticCsp(config: StaticConfig): string { } const FILES: [string, string][] = [ - ...["shell.js", "static.js", "shell.css", "space.js", "space-team.js", "space-board.js", "space-flow.js", "space-view.js", "space3d.js", "icon.svg", "asset-viewer.html", "asset-viewer.js", "asset-viewer.css"].map((file): [string, string] => [file, file]), + ...["shell.js", "static.js", "snapshot-headers.js", "shell.css", "space.js", "space-team.js", "space-board.js", "space-flow.js", "space-view.js", "space3d.js", "icon.svg", "asset-viewer.html", "asset-viewer.js", "asset-viewer.css"].map((file): [string, string] => [file, file]), ...["three.module.js", "three.core.js", "GLTFLoader.js", "BufferGeometryUtils.js", "SkeletonUtils.js", "NOTICE", "manifest.json"].map((file): [string, string] => [`vendor/${file}`, `vendor/${file}`]), ...["icons.js", "NOTICE", "manifest.json"].map((file): [string, string] => [`vendor/icons/${file}`, `vendor/icons/${file}`]), ["visual-frame.html", "visual/frame.html"], ["visual-frame.css", "visual/frame.css"], ["visual-frame.js", "visual/frame.js"], diff --git a/test/shell-polling.test.ts b/test/shell-polling.test.ts index f9eec46..8e29b51 100644 --- a/test/shell-polling.test.ts +++ b/test/shell-polling.test.ts @@ -71,7 +71,7 @@ test("a visibility return during an in-flight panel queues only one immediate fo await first; await flush(); expect(h.requests).toHaveLength(2); }); -test("Space reads stale headers on known replies without replacing scene or advancing sample time", async () => { +test("Space SWR headers keep the original sample without failing healthy actors", async () => { const h = pollingHarness(); h.state.route.area = "space"; const scene = { actors: [{ id: "one" }], zones: [], cards: [] }; h.setClock(100000); @@ -82,10 +82,13 @@ test("Space reads stale headers on known replies without replacing scene or adva h.setResponse(async () => ({ status: 200, data: { changed: false }, headers: { "x-acon-version": VERSION, "x-acon-cache": "stale", "x-acon-sampled-at": new Date(1000).toISOString() } })); await h.api.pollSpace(); expect(h.requests[1]!.body).toMatchObject({ after: 7, known: VERSION }); - expect(entry.scene).toEqual(scene); expect(entry.sampledAt).toBe(1000); expect(entry.failed).toBe(true); + expect(entry.scene).toEqual(scene); expect(entry.sampledAt).toBe(1000); expect(entry.failed).toBe(false); const mergeSource = readFileSync(new URL("../shell/space.js", import.meta.url), "utf8").replace(/^import .*;$/gm, "").replace(/^export /gm, ""); const merge = runInNewContext(`${mergeSource}\nmergeSpaceScenes`); entry.scene = { ...scene, actors: [{ id: "one", name: "One", state: "working", link: { panel: "notes" } }] }; + expect(merge(h.state.space).actors[0].stale).toBe(false); + h.setClock(entry.next); h.setResponse(async () => ({ status: 502, data: { ok: false } })); + await h.api.pollSpace(); expect(entry.sampledAt).toBe(1000); expect(entry.failed).toBe(true); expect(merge(h.state.space).actors[0].stale).toBe(true); }); @@ -112,10 +115,11 @@ test("progressive inbox renders fast replies before slow ones and has per-tool a let now = 0, release!: (response: any) => void; const state = { inbox: new Map(), route: { area: "inbox" }, peekKey: null }; const calls: { tool: string; input: any }[] = [], renders: string[][] = []; + let rendered = ""; const poll = runInNewContext(`${inboxSource}\npollInbox`, { state, INBOX_MS: 5000, Date: class extends Date { static now() { return now; } }, readyTools: () => [{ tool: "slow", pollMs: 2000, capabilities: { inbox: true } }, { tool: "fast", pollMs: 2000, capabilities: { inbox: true } }], - validItem: () => true, clientError() {}, renderRail() {}, renderMini() {}, renderPeek() {}, renderInbox: () => renders.push([...state.inbox.keys()]), + validItem: () => true, clientError() {}, renderRail() {}, renderMini() {}, renderPeek() {}, renderInbox: () => { const signature = JSON.stringify([...state.inbox].map(([tool, entry]) => [tool, entry.items, entry.failed])); if (signature !== rendered) { rendered = signature; renders.push([...state.inbox.keys()]); } }, post: (url: string, input: any) => { const tool = url.split("/")[2]!; calls.push({ tool, input }); return tool === "slow" ? new Promise((resolve) => { release = resolve; }) : Promise.resolve({ status: 200, data: calls.filter((call) => call.tool === "fast").length === 1 ? { items: [{ id: "fast" }] } : { changed: false }, headers: { "x-acon-version": VERSION } }); }, }); @@ -149,3 +153,44 @@ test("a re-hidden pending refresh does not leave a second resume queued", async h.setResponse(async () => ({ status: 200, data: { changed: false }, headers: { "x-acon-version": VERSION } })); h.api.resumeVisiblePolling(); await flush(); expect(h.requests).toHaveLength(2); }); + + +test("snooze expiry refreshes Inbox, Mini/PiP and pet pose without changed provider data", async () => { + let now = 0, calls = 0; + const state = { inbox: new Map(), route: { area: "inbox" }, peekKey: null }; + const at = "1970-01-01T00:00:00.000Z"; + const snoozes = { "demo:item": { at, until: 100 } }; + const item = { id: "item", title: "Decision", at, group: "decision", verbs: [], link: { panel: "notes" } }; + const visibleSource = source.slice(source.indexOf("function snoozes()"), source.indexOf("function validItem(")); + const poseSource = source.slice(source.indexOf("function petState()"), source.indexOf("// Oldest first across groups")); + const observed: Record = { inbox: [], mini: [], pet: [] }; + let api: any; + const record = (surface: string, value: string) => { const values = observed[surface]!; if (values.at(-1) !== value) values.push(value); }; + api = runInNewContext(`${visibleSource}\n${poseSource}\n${inboxSource}\n({pollInbox,visibleItems,petState})`, { + state, INBOX_MS: 5000, Date: class extends Date { static now() { return now; } }, store: () => snoozes, + readyTools: () => [{ tool: "demo", capabilities: { inbox: true } }], validItem: () => true, clientError() {}, renderPeek() {}, + renderRail: () => record("pet", api.petState()), + renderMini: () => record("mini", api.visibleItems().map((item: any) => item.id).join(",")), + renderInbox: () => record("inbox", api.visibleItems().map((item: any) => item.id).join(",")), + post: async () => { calls++; return { status: 200, data: { items: [item] }, headers: { "x-acon-version": VERSION } }; }, + }); + await api.pollInbox(false); + expect(observed).toEqual({ inbox: [""], mini: [""], pet: ["idle"] }); + now = 101; await api.pollInbox(false); // Before the provider deadline: only snooze time changed. + expect(calls).toBe(1); + expect(observed).toEqual({ inbox: ["", "item"], mini: ["", "item"], pet: ["idle", "waiting"] }); + now = 102; await api.pollInbox(false); + expect(observed.inbox).toHaveLength(2); expect(observed.mini).toHaveLength(2); expect(observed.pet).toHaveLength(2); +}); + +test("signature-unchanged rail refreshes do not rewrite the document title", () => { + let titleWrites = 0; + const railSource = source.slice(source.indexOf("function renderRail()"), source.indexOf("function renderCrumbs()")); + const signature = JSON.stringify(["inbox", undefined, 0, 0, false, []]); + const render = runInNewContext(`let railSig = ${JSON.stringify(signature)}; ${railSource}\nrenderRail`, { + state: { route: { area: "inbox" }, tools: [], serverErrors: [], clientErrors: [] }, + document: { get title() { return "acon"; }, set title(_value: string) { titleWrites++; } }, + needsMeCount: () => 0, renderPet() {}, spaceTools: () => [], readyTools: () => [], + }); + render(); render(); expect(titleWrites).toBe(0); +}); diff --git a/test/space-server.test.ts b/test/space-server.test.ts index db7a4c7..4d1c901 100644 --- a/test/space-server.test.ts +++ b/test/space-server.test.ts @@ -51,6 +51,7 @@ test("space capability authorizes its snapshot and transport drops private field plugin.manifest.capabilities!.space = false; expect((await post("/p/demo/snapshot", { panel: "space" })).status).toBe(400); expect(await (await get("/space-flow.js")).text()).toContain("createSpaceFlow"); + expect((await get("/snapshot-headers.js")).headers.get("content-type")).toContain("javascript"); expect((await get("/space-board.js")).headers.get("content-type")).toContain("javascript"); expect((await get("/space-team.js")).headers.get("content-type")).toContain("javascript"); expect((await get("/space.js")).headers.get("content-type")).toContain("javascript"); diff --git a/test/static-transport.test.ts b/test/static-transport.test.ts index 3469ac8..d5868f7 100644 --- a/test/static-transport.test.ts +++ b/test/static-transport.test.ts @@ -11,9 +11,10 @@ interface Transport { setToken(id: string, token: string): void; } function transport(fetchImpl: typeof fetch, signals: { timeout(milliseconds: number): AbortSignal } = AbortSignal) { - const source = readFileSync(new URL("../shell/static.js", import.meta.url), "utf8").replace("export function createStaticTransport", "function createStaticTransport"); + const source = readFileSync(new URL("../shell/static.js", import.meta.url), "utf8").replace(/^import .*;$/gm, "").replace("export function createStaticTransport", "function createStaticTransport"); + const metadata = readFileSync(new URL("../shell/snapshot-headers.js", import.meta.url), "utf8").replace(/^export /gm, ""); const storage = new Map(); - const make = runInNewContext(`${source}\ncreateStaticTransport`, { + const make = runInNewContext(`${metadata}\n${source}\ncreateStaticTransport`, { URL, AbortSignal: signals, fetch: fetchImpl, sessionStorage: { getItem: (key: string) => storage.get(key), setItem: (key: string, value: string) => storage.set(key, value), removeItem: (key: string) => storage.delete(key) }, }) as (config: unknown) => Transport; diff --git a/test/static.test.ts b/test/static.test.ts index f2f7ea9..de7e101 100644 --- a/test/static.test.ts +++ b/test/static.test.ts @@ -48,7 +48,7 @@ describe("H2 static build (#46)", () => { expect(html).toContain(`content="${staticCsp(result.config).replace("; frame-ancestors 'none'", "")}"`); expect(html).not.toMatch(/]*>\s*[^\s<]/i); expect(html).not.toMatch(/ Date: Tue, 10 Nov 2026 23:28:38 +0900 Subject: [PATCH 3/5] fix(snapshot): preserve API 1 inputs and Room observation cadence --- CHANGELOG.md | 4 +- README.md | 4 +- scripts/ui-check-snapshot-fixture.mjs | 4 +- shell/shell.js | 24 ++++++---- shell/static.js | 11 +++-- src/server.ts | 15 +++--- test/shell-polling.test.ts | 68 ++++++++++++++++++++++----- test/snapshot-cache.test.ts | 41 +++++++++++----- test/static-node.test.ts | 19 ++++++++ test/static-transport.test.ts | 14 ++++-- 10 files changed, 149 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c87f8e4..9b14d81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,6 @@ ### Compatibility -- `known` is now a reserved snapshot request field. Plugin route params and `host.snapshot` extras named `known` are consumed by the console transport and no longer reach provider input (#105). - - Planned 0.2.0 N-1 support targets 0.1.8, the latest published patch of 0.1.x, and remains additive on API 1: - Plugin API: verified by a directional type bridge (old plugin values accepted by the current console; current console values satisfy old plugins; historical optional keys are retained) and the exact published 0.1.8 conformance kit against a 0.1.8-shaped provider opened by the current loader. New principal capabilities remain opt-in. - Routes: verified for the legacy browser session, tool listing, ping, panel snapshots, actions, confirmation-token exchange and a sample plugin asset through both tool-id and load-reference paths. Other Access/bearer and static-shell routes remain supported by policy but are not exercised by the added historical probe. @@ -13,7 +11,7 @@ ### Added -- Add header-only snapshot versions and reserved `known` conditional requests, preserving plugin cursor replies and forwarding version/freshness headers through the static transport. Adapt polling to request duration, pause hidden panel/Space polls with immediate visible refresh, progressively render inbox sources, refresh expired snoozes on unchanged rounds, and retain SWR sample times without marking healthy Space sources failed (#105). +- Add header-only snapshot versions and `X-Acon-Known` conditional request headers, preserving plugin cursor replies and forwarding version/freshness headers through the static transport. Adapt polling to request duration while keeping the inbox's 5-second minimum, pause hidden panel/Space polls with immediate visible refresh, progressively render inbox sources, refresh expired snoozes on unchanged rounds, and retain server data sample times separately from the Room observation clock without marking healthy SWR sources failed (#105). - Add the Space Board view with per-group zone columns, producer-linked cards, owner detail controls, and collapsed Done history showing the last five supplied cards. Keep horizontal scrolling within each board row, preserve focus/disclosures, and cover the responsive matrix with a Board probe (#101). diff --git a/README.md b/README.md index 8462114..a4cb94d 100644 --- a/README.md +++ b/README.md @@ -303,9 +303,9 @@ During one extra TTL of grace, a cached reply can be served immediately while on 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. -Conditional snapshots (#105): each successful snapshot carries an `X-Acon-Version` content hash. A shell can send its last hash as `known`; the server removes this reserved transport field before building the cache key or calling the plugin. A matching successful value returns `{changed: false}` with the version header. Plugin `after` cursors and producer `changed: false` replies take precedence, including their cursor fields. Version and freshness metadata stay in headers, so producer body fields are unchanged by this protocol. The H2 transport forwards `known` and exposes the same version/cache/sample-time headers to the shell; nodes without those headers keep their existing full-reply behavior. +Conditional snapshots (#105): each successful snapshot carries an `X-Acon-Version` content hash. A shell can send its last hash in the `X-Acon-Known` request header. Conditional control stays outside the snapshot body and cache key, so producer input remains unchanged, including any producer-owned `known` field. A matching successful value returns `{changed: false}` with the version header. Plugin `after` cursors and producer `changed: false` replies take precedence, including their cursor fields. Version and freshness metadata stay in headers, so producer body fields are unchanged by this protocol. Configured-origin preflight allows `X-Acon-Known`; the H2 transport forwards that request header and exposes the same version/cache/sample-time response headers to the shell; nodes without those headers keep their existing full-reply behavior. -Panel, Space and per-tool inbox polling use `max(pollMs, 3 × last request duration)`, capped at 60 seconds, with `pollMs` clamped to 2-60 seconds and inbox using `INBOX_MS` (5 seconds) when the tool omits `pollMs`. The interval recovers when requests become faster. Hidden tabs start no panel or Space polls, and becoming visible triggers one immediate refresh; existing Mini/Picture-in-Picture inbox behavior is preserved. Inbox sources render as each tool finishes instead of waiting for the slowest source. Each inbox round also rechecks time-dependent snoozes through signature-checked renderers, even when no provider data changes. A Space reply marked `X-Acon-Cache: stale` keeps the `X-Acon-Sampled-At` time without marking the source failed, including on an unchanged conditional reply. Routine SWR reuse does not idle pets or show a refresh-failure warning; only unsuccessful replies mark the source failed. +Panel and Space polling use `max(pollMs, 3 * last request duration)`, capped at 60 seconds, with `pollMs` clamped to 2-60 seconds. Per-tool inbox polling uses the same adaptive interval with an `INBOX_MS` (5-second) floor, so it never polls faster than the previous inbox cadence; an omitted tool `pollMs` also uses that fallback. The interval recovers when requests become faster. Hidden tabs start no panel or Space polls, and becoming visible triggers one immediate refresh; existing Mini/Picture-in-Picture inbox behavior is preserved. Inbox sources render as each tool finishes instead of waiting for the slowest source. Each inbox round also rechecks time-dependent snoozes through signature-checked renderers, even when no provider data changes. Space keeps the server's `X-Acon-Sampled-At` value separately as its data sample time, including on a stale or unchanged reply. Room motion uses the client observation time, so repeated cached sample times cannot create false source gaps. Routine SWR reuse remains healthy and does not idle pets or show a refresh-failure warning; only unsuccessful replies mark the source failed. 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. diff --git a/scripts/ui-check-snapshot-fixture.mjs b/scripts/ui-check-snapshot-fixture.mjs index 4d7c74b..31c618a 100644 --- a/scripts/ui-check-snapshot-fixture.mjs +++ b/scripts/ui-check-snapshot-fixture.mjs @@ -3,8 +3,8 @@ // source and remove that metadata, while protocol behavior is tested separately. const metadata = new Set(["x-acon-version", "x-acon-cache", "x-acon-cache-age-ms", "x-acon-sampled-at", "content-length"]); export async function fetchFullSnapshot(route) { - const { known: _known, ...input } = route.request().postDataJSON(); - return route.fetch({ postData: JSON.stringify(input) }); + const headers = Object.fromEntries(Object.entries(route.request().headers()).filter(([name]) => name.toLowerCase() !== "x-acon-known")); + return route.fetch({ headers }); } export async function fulfillSnapshotFixture(route, response, body) { const headers = Object.fromEntries(Object.entries(response.headers()).filter(([name]) => !metadata.has(name.toLowerCase()))); diff --git a/shell/shell.js b/shell/shell.js index bacaf09..a27d110 100644 --- a/shell/shell.js +++ b/shell/shell.js @@ -796,10 +796,10 @@ let staticTransport = null; const pinnedRefs = new Map(); const pluginPath = (path) => staticTransport ? staticTransport.resource(path, pinnedRefs) : path.replace(/^\/p\/([a-z][a-z0-9-]{1,15})\//, (whole, id) => (pinnedRefs.has(id) ? `/p/${pinnedRefs.get(id)}/` : whole)); -async function post(path, body = {}) { +async function post(path, body = {}, requestHeaders = {}) { if (staticTransport) { state.lastRequest = Date.now(); - const response = await staticTransport.post(path, body, pinnedRefs); + const response = await staticTransport.post(path, body, pinnedRefs, requestHeaders); if (response.status === 200) state.lastOk = Date.now(); return response; } @@ -807,7 +807,7 @@ async function post(path, body = {}) { state.lastRequest = Date.now(); let res; try { - res = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), credentials: "same-origin", cache: "no-store" }); + res = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json", ...requestHeaders }, body: JSON.stringify(body), credentials: "same-origin", cache: "no-store" }); } catch { state.offline = true; renderLive(); @@ -1043,11 +1043,11 @@ async function pollInbox(fresh = true) { async function inboxFetch(t, seq) { const previous = state.inbox.get(t.tool); const startedAt = Date.now(); - const r = await post(`/p/${t.tool}/snapshot`, { after: 0, panel: "inbox", ...(previous?.version ? { known: previous.version } : {}) }); + const r = await post(`/p/${t.tool}/snapshot`, { after: 0, panel: "inbox" }, previous?.version ? { "X-Acon-Known": previous.version } : {}); // An older response that arrives late never overwrites a newer one. if ((inboxApplied.get(t.tool) ?? 0) > seq) return; inboxApplied.set(t.tool, seq); - const next = startedAt + adaptivePollMs(t.pollMs ?? INBOX_MS, Date.now() - startedAt); + const next = startedAt + Math.max(INBOX_MS, adaptivePollMs(t.pollMs ?? INBOX_MS, Date.now() - startedAt)); const version = r.headers?.["x-acon-version"] ?? ""; if (r.status === 200 && Array.isArray(r.data.items)) { const items = r.data.items.filter(validItem); @@ -1605,7 +1605,7 @@ async function pollPanel() { const gen = p.gen; const after = p.cursor; const startedAt = Date.now(); - const res = await post(`/p/${r.tool}/snapshot`, { ...r.params, after, panel: r.panel, ...(p.version ? { known: p.version } : {}) }); + const res = await post(`/p/${r.tool}/snapshot`, { ...r.params, after, panel: r.panel }, p.version ? { "X-Acon-Known": p.version } : {}); p.busy = false; p.next = startedAt + adaptivePollMs(tool?.pollMs, Date.now() - startedAt); const resume = p.resume; p.resume = false; @@ -1918,17 +1918,23 @@ async function pollSpace() { if (!entry) { entry = { scene: null, cursor: 0, next: 0, busy: false, failed: false }; state.space.set(tool.tool, entry); } if (entry.busy || Date.now() < entry.next) return; const startedAt = Date.now(); + const due = entry.next || startedAt; entry.busy = true; - const res = await post(`/p/${tool.tool}/snapshot`, { panel: "space", after: entry.cursor, ...(entry.version ? { known: entry.version } : {}) }); + const res = await post(`/p/${tool.tool}/snapshot`, { panel: "space", after: entry.cursor }, entry.version ? { "X-Acon-Known": entry.version } : {}); entry.busy = false; - entry.next = startedAt + adaptivePollMs(tool.pollMs, Date.now() - startedAt); + const interval = adaptivePollMs(tool.pollMs, Date.now() - startedAt); + // Preserve cadence across 1 s tick jitter instead of drifting into a skipped poll. + entry.next = due + (Math.floor((startedAt - due) / interval) + 1) * interval; const resume = entry.resume; entry.resume = false; if (resume && document.visibilityState === "visible") { entry.next = 0; queueMicrotask(() => pollSpace()); } entry.reported = true; if (res.status === 200 && (res.data.scene || (entry.scene && res.data.changed === false))) { const stale = res.headers?.["x-acon-cache"] === "stale"; const sampledAt = Date.parse(res.headers?.["x-acon-sampled-at"] ?? ""); - entry.sampledAt = Number.isFinite(sampledAt) ? sampledAt : stale ? (entry.sampledAt ?? Date.now()) : Date.now(); + // Motion measures observations, not repeated/jumping cache sample timestamps. + entry.sampledAt = Date.now(); + if (Number.isFinite(sampledAt)) entry.serverSampledAt = sampledAt; + else if (!stale) delete entry.serverSampledAt; if (res.data.scene) { entry.scene = res.data.scene; entry.dropped = res.data.dropped ?? { actors: 0, cards: 0, links: 0 }; } if (Number.isSafeInteger(res.data.cursor)) entry.cursor = res.data.cursor; entry.version = res.headers?.["x-acon-version"] ?? ""; diff --git a/shell/static.js b/shell/static.js index ad82b6e..992fecc 100644 --- a/shell/static.js +++ b/shell/static.js @@ -28,7 +28,10 @@ export function createStaticTransport(config, onStatus = () => {}) { } catch { notify(node, 'offline', 'Node is unreachable; start acon serve and check the node URL and network.'); } return { status: 0, headers: {}, data: { ok: false, error: node.detail } }; }; - const request = async (node, path, body) => { + const request = async (node, path, body, extraHeaders = {}) => { + const headers = headersFor(node, body !== undefined); + const known = Object.entries(extraHeaders).find(([name]) => name.toLowerCase() === "x-acon-known")?.[1]; + if (known !== undefined) headers["X-Acon-Known"] = known; const action = body !== undefined && /^\/p\/[^/]+\/action$/.test(path); const deadline = action ? 35000 : 8000; const signal = AbortSignal.timeout(deadline); @@ -36,7 +39,7 @@ export function createStaticTransport(config, onStatus = () => {}) { let response; let data = null; try { - response = await fetch(`${node.url}${path}`, { method: body === undefined ? 'GET' : 'POST', headers: headersFor(node, body !== undefined), ...(body === undefined ? {} : { body: JSON.stringify(body) }), credentials: 'include', cache: 'no-store', signal, redirect: 'error' }); + response = await fetch(`${node.url}${path}`, { method: body === undefined ? 'GET' : 'POST', headers, ...(body === undefined ? {} : { body: JSON.stringify(body) }), credentials: 'include', cache: 'no-store', signal, redirect: 'error' }); try { data = await response.json(); } catch { if (action && response.status !== 401 && response.status !== 403) return uncertain(); } } catch (error) { @@ -85,13 +88,13 @@ export function createStaticTransport(config, onStatus = () => {}) { const tools = nodes.flatMap((node) => (node.data?.tools ?? []).filter((tool) => TOOL_ID.test(tool?.tool)).map((tool) => ({ ...tool, tool: `${node.id}:${tool.tool}`, rawTool: tool.tool, node: node.id, title: `${node.id}: ${tool.title ?? tool.tool}`, readOnly: node.data.readOnly === true, ...(node.status === 'online' || node.status === 'slow' ? {} : { status: 'offline', reason: node.detail }) }))); return { status: 200, headers: {}, data: { tools, pets: [], assetPacks: [], node: { id: 'static', version: 'H2' }, errors: [], readOnly: false } }; }, - async post(path, body, refs) { + async post(path, body, refs, headers = {}) { if (path === '/tools') return this.tools(); if (path === '/ping') return { status: 200, headers: {}, data: { ok: true } }; const route = resolve(path, refs); if (!route) return { status: 404, headers: {}, data: { ok: false, error: 'Unknown node/tool route; reload the static console.' } }; if (route.node.status !== "online" && route.node.status !== "slow") return { status: 0, headers: {}, data: { ok: false, error: route.node.detail } }; - return request(route.node, route.path, body); + return request(route.node, route.path, body, headers); }, resource(path, refs) { const route = resolve(path, refs); diff --git a/src/server.ts b/src/server.ts index 49230ff..bc2ea53 100644 --- a/src/server.ts +++ b/src/server.ts @@ -182,10 +182,9 @@ export function startServer(options: ServerOptions) { ? { tool: p.tool, ref: p.ref ?? p.tool, status: p.status, title: p.manifest.title, version: p.manifest.version, icon: p.manifest.icon, panels: p.manifest.panels, pollMs: p.manifest.pollMs, commands: p.manifest.commands ?? [], capabilities: p.manifest.capabilities ?? {} } : { tool: p.tool, status: p.status, reason: p.reason, file: p.file }); - async function snapshot(entry: PluginEntry, input: Record) { - // Transport control is never provider input or part of the canonical snapshot cache key. - const { known, ...pluginInput } = input; - if (known !== undefined && (typeof known !== "string" || !/^[0-9a-f]{64}$/.test(known))) return reject(400, "known must be a SHA-256 version from X-Acon-Version"); + async function snapshot(entry: PluginEntry, input: Record, known: string | null) { + // Conditional transport is header-only; every body field remains plugin input. + if (known !== null && !/^[0-9a-f]{64}$/.test(known)) return reject(400, "X-Acon-Known must be a SHA-256 version from X-Acon-Version"); const caps = entry.manifest!.capabilities ?? {}; const panel = input.panel; const allowed = declaresPanel(entry.manifest!, panel); @@ -198,7 +197,7 @@ 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 request = { ...pluginInput, after, panel }; + 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)); @@ -331,8 +330,8 @@ export function startServer(options: ServerOptions) { if (serve && req.method === "OPTIONS") { const method = req.headers.get("access-control-request-method"); const headers = (req.headers.get("access-control-request-headers") ?? "").toLowerCase().split(",").map((h) => h.trim()).filter(Boolean); - if (!originOk || !["GET", "POST"].includes(method ?? "") || headers.some((h) => !["authorization", "content-type"].includes(h))) return reject(403, "CORS preflight refused; use a configured --origin and GET or POST with authorization/content-type only"); - return new Response(null, { status: 204, headers: { ...HEADERS, "Access-Control-Allow-Methods": "GET, POST", "Access-Control-Allow-Headers": "Authorization, Content-Type", "Access-Control-Max-Age": "300" } }); + if (!originOk || !["GET", "POST"].includes(method ?? "") || headers.some((h) => !["authorization", "content-type", "x-acon-known"].includes(h))) return reject(403, "CORS preflight refused; use a configured --origin and GET or POST with authorization, content-type or x-acon-known"); + return new Response(null, { status: 204, headers: { ...HEADERS, "Access-Control-Allow-Methods": "GET, POST", "Access-Control-Allow-Headers": "Authorization, Content-Type, X-Acon-Known", "Access-Control-Max-Age": "300" } }); } prune(resources); const grant = resources.get(url.searchParams.get("acon_resource") ?? ""); @@ -453,7 +452,7 @@ export function startServer(options: ServerOptions) { } const entry = pluginEntry(); if (!entry) return unknownTool(); - return plugin![2] === "snapshot" ? snapshot(entry, input) : action(entry, cookie!, input, { identity: serve ? principal! : "local", node: "local", via: "console" }); + return plugin![2] === "snapshot" ? snapshot(entry, input, req.headers.get("x-acon-known")) : action(entry, cookie!, input, { identity: serve ? principal! : "local", node: "local", via: "console" }); } const server = Bun.serve({ hostname: serve ? serve.hostname.replace(/^\[(.*)\]$/, "$1") : "127.0.0.1", diff --git a/test/shell-polling.test.ts b/test/shell-polling.test.ts index 8e29b51..a52ab3c 100644 --- a/test/shell-polling.test.ts +++ b/test/shell-polling.test.ts @@ -14,7 +14,7 @@ const flush = async () => { for (let i = 0; i < 8; i++) await Promise.resolve(); function pollingHarness() { let clock = 0, writes = 0; - const requests: { url: string; body: any }[] = []; + const requests: { url: string; body: any; headers: any }[] = []; const document = { visibilityState: "visible" }; const panel = { instance: {}, busy: false, next: 0, gen: 0, cursor: 0, lastSig: "", lastData: null, version: "", resume: false, banner: { hiddenValue: true, get hidden() { return this.hiddenValue; }, set hidden(value: boolean) { writes++; this.hiddenValue = value; } }, @@ -25,7 +25,7 @@ function pollingHarness() { let renders = 0, live = 0, inboxPolls = 0, pip: object | null = null; const api = runInNewContext(`${adaptive}\n${panelSource}\n${spaceSource}\n${tickSource}\n${visibilitySource}\n({pollPanel,pollSpace,resumeVisiblePolling,tick,adaptivePollMs})`, { state, document, Date: class extends Date { static now() { return clock; } }, queueMicrotask, - post: (url: string, body: any) => { requests.push({ url, body }); return response(url, body); }, + post: (url: string, body: any, headers: any = {}) => { requests.push({ url, body, headers }); return response(url, body); }, toolById: () => ({ pollMs: 2000 }), spaceTools: () => [{ tool: "demo", pollMs: 2000 }], spaceRenderer: {}, renderSpace: () => { renders++; }, safe: (_tool: string, _name: string, fn: () => void) => fn(), renderLive: () => { live++; }, pollInbox: async () => { inboxPolls++; }, loadTools: async () => {}, clientError() {}, @@ -43,7 +43,7 @@ test("panel polling stretches from start time, caps and recovers with faster cal h.setClock(9000); h.setResponse(async () => { h.setClock(9100); return { status: 200, data: { changed: false }, headers: { "x-acon-version": VERSION } }; }); const before = h.writes; await h.api.pollPanel(); expect(h.panel.next).toBe(11000); expect(h.writes).toBe(before); - expect(h.requests[1]!.body.known).toBe(VERSION); expect(h.panel.cursor).toBe(1); + expect(h.requests[1]!.headers["X-Acon-Known"]).toBe(VERSION); expect(h.panel.cursor).toBe(1); expect(h.api.adaptivePollMs(2000, 40000)).toBe(60000); }); @@ -71,24 +71,25 @@ test("a visibility return during an in-flight panel queues only one immediate fo await first; await flush(); expect(h.requests).toHaveLength(2); }); -test("Space SWR headers keep the original sample without failing healthy actors", async () => { +test("Space keeps server samples separately from the healthy motion observation clock", async () => { const h = pollingHarness(); h.state.route.area = "space"; const scene = { actors: [{ id: "one" }], zones: [], cards: [] }; h.setClock(100000); h.setResponse(async () => ({ status: 200, data: { scene, cursor: 7 }, headers: { "x-acon-version": VERSION, "x-acon-cache": "hit", "x-acon-sampled-at": new Date(1000).toISOString() } })); await h.api.pollSpace(); - const entry = h.state.space.get("demo"); expect(entry.sampledAt).toBe(1000); expect(entry.failed).toBe(false); + const entry = h.state.space.get("demo"); expect(entry.serverSampledAt).toBe(1000); expect(entry.sampledAt).toBe(100000); expect(entry.failed).toBe(false); h.setClock(entry.next); h.setResponse(async () => ({ status: 200, data: { changed: false }, headers: { "x-acon-version": VERSION, "x-acon-cache": "stale", "x-acon-sampled-at": new Date(1000).toISOString() } })); await h.api.pollSpace(); - expect(h.requests[1]!.body).toMatchObject({ after: 7, known: VERSION }); - expect(entry.scene).toEqual(scene); expect(entry.sampledAt).toBe(1000); expect(entry.failed).toBe(false); + expect(h.requests[1]!.body).toEqual({ panel: "space", after: 7 }); + expect(h.requests[1]!.headers["X-Acon-Known"]).toBe(VERSION); + expect(entry.scene).toEqual(scene); expect(entry.serverSampledAt).toBe(1000); expect(entry.sampledAt).toBe(102000); expect(entry.failed).toBe(false); const mergeSource = readFileSync(new URL("../shell/space.js", import.meta.url), "utf8").replace(/^import .*;$/gm, "").replace(/^export /gm, ""); const merge = runInNewContext(`${mergeSource}\nmergeSpaceScenes`); entry.scene = { ...scene, actors: [{ id: "one", name: "One", state: "working", link: { panel: "notes" } }] }; expect(merge(h.state.space).actors[0].stale).toBe(false); h.setClock(entry.next); h.setResponse(async () => ({ status: 502, data: { ok: false } })); - await h.api.pollSpace(); expect(entry.sampledAt).toBe(1000); expect(entry.failed).toBe(true); + await h.api.pollSpace(); expect(entry.serverSampledAt).toBe(1000); expect(entry.sampledAt).toBe(102000); expect(entry.failed).toBe(true); expect(merge(h.state.space).actors[0].stale).toBe(true); }); @@ -114,23 +115,23 @@ test("panel known replies write nothing, and producer cursor replies still advan test("progressive inbox renders fast replies before slow ones and has per-tool adaptive deadlines", async () => { let now = 0, release!: (response: any) => void; const state = { inbox: new Map(), route: { area: "inbox" }, peekKey: null }; - const calls: { tool: string; input: any }[] = [], renders: string[][] = []; + const calls: { tool: string; input: any; headers: any }[] = [], renders: string[][] = []; let rendered = ""; const poll = runInNewContext(`${inboxSource}\npollInbox`, { state, INBOX_MS: 5000, Date: class extends Date { static now() { return now; } }, readyTools: () => [{ tool: "slow", pollMs: 2000, capabilities: { inbox: true } }, { tool: "fast", pollMs: 2000, capabilities: { inbox: true } }], validItem: () => true, clientError() {}, renderRail() {}, renderMini() {}, renderPeek() {}, renderInbox: () => { const signature = JSON.stringify([...state.inbox].map(([tool, entry]) => [tool, entry.items, entry.failed])); if (signature !== rendered) { rendered = signature; renders.push([...state.inbox.keys()]); } }, - post: (url: string, input: any) => { const tool = url.split("/")[2]!; calls.push({ tool, input }); + post: (url: string, input: any, headers: any = {}) => { const tool = url.split("/")[2]!; calls.push({ tool, input, headers }); return tool === "slow" ? new Promise((resolve) => { release = resolve; }) : Promise.resolve({ status: 200, data: calls.filter((call) => call.tool === "fast").length === 1 ? { items: [{ id: "fast" }] } : { changed: false }, headers: { "x-acon-version": VERSION } }); }, }); const pending = poll(false); await flush(); - expect(renders).toEqual([["fast"]]); expect(state.inbox.get("fast").next).toBe(2000); + expect(renders).toEqual([["fast"]]); expect(state.inbox.get("fast").next).toBe(5000); now = 6000; release({ status: 200, data: { items: [] } }); await pending; expect(state.inbox.get("slow").next).toBe(18000); const before = renders.length; await poll(false); expect(calls.filter((call) => call.tool === "slow")).toHaveLength(1); expect(calls.filter((call) => call.tool === "fast")).toHaveLength(2); - expect(calls.at(-1)!.input.known).toBe(VERSION); expect(renders).toHaveLength(before); + expect(calls.at(-1)!.headers["X-Acon-Known"]).toBe(VERSION); expect(renders).toHaveLength(before); }); @@ -194,3 +195,46 @@ test("signature-unchanged rail refreshes do not rewrite the document title", () }); render(); render(); expect(titleWrites).toBe(0); }); + +test("repeated or jumping server samples do not create a Room observation sourceGap", async () => { + const h = pollingHarness(); h.state.route.area = "space"; + const space = readFileSync(new URL("../shell/space.js", import.meta.url), "utf8"); + const merge = runInNewContext(`${space.replace(/^import .*;$/gm, "").replace(/^export /gm, "")}\nmergeSpaceScenes`); + const begin = space.indexOf(" let sourceGap = false, firstSource = false;"); + const end = space.indexOf(" const sig =", begin); + const sourceGapBody = space.slice(begin, end); + const samples = new Map(); + const scene = { actors: [{ id: "one", name: "One", state: "working", link: { panel: "notes" } }], zones: [], cards: [] }; + for (const [index, sample] of [1000, 1000, 60000, 60000].entries()) { + h.setClock(100000 + index * 2000); + h.setResponse(async () => ({ status: 200, data: index ? { changed: false } : { scene }, headers: { "x-acon-version": VERSION, "x-acon-cache": "stale", "x-acon-sampled-at": new Date(sample).toISOString() } })); + await h.api.pollSpace(); + const model = merge(h.state.space); + const result = runInNewContext(`${sourceGapBody}\n({sourceGap,firstSource})`, { samples, receivedSamples: model.samples }); + expect(result.sourceGap).toBe(false); + expect(h.state.space.get("demo").sampledAt).toBe(100000 + index * 2000); + expect(h.state.space.get("demo").serverSampledAt).toBe(sample); + } +}); + +test("a producer body known field survives independently of the conditional header", async () => { + const h = pollingHarness(); h.state.route.params = { known: { provider: "value" } }; + await h.api.pollPanel(); h.setClock(h.panel.next); await h.api.pollPanel(); + expect(h.requests[1]!.body.known).toEqual({ provider: "value" }); + expect(h.requests[1]!.headers["X-Acon-Known"]).toBe(VERSION); +}); + + +test("Space cadence survives timer jitter without a false observation gap", async () => { + const h = pollingHarness(); h.state.route.area = "space"; + const scene = { actors: [], zones: [], cards: [] }; + h.setResponse(async () => { h.setClock(h.clock + 10); return { status: 200, data: { scene }, headers: { "x-acon-sampled-at": new Date(1000).toISOString() } }; }); + let previous = 0; + for (const tick of [100000, 102001, 104000, 105002, 106001]) { + h.setClock(tick); await h.api.pollSpace(); + const observed = h.state.space.get("demo").sampledAt; + if (previous && observed !== previous) expect(observed - previous).toBeLessThanOrEqual(3000); + previous = observed; + } + expect(h.requests).toHaveLength(4); +}); diff --git a/test/snapshot-cache.test.ts b/test/snapshot-cache.test.ts index f6b81be..0c4bfe5 100644 --- a/test/snapshot-cache.test.ts +++ b/test/snapshot-cache.test.ts @@ -165,7 +165,7 @@ async function httpFixture(reply?: Record) { 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 (route: string, body: Record, headers: Record = {}) => fetch(`${server.origin}${route}`, { method: "POST", headers: { Origin: server.origin, "Content-Type": "application/json", Cookie: cookie, ...headers }, body: JSON.stringify(body) }); }; return { server, viewer, inputs, get calls() { return calls; }, advance(value: number) { now = value; }, fail() { fail = true; } }; } @@ -293,25 +293,25 @@ test("explicit panels own their TTL before reserved capabilities", () => { }); -test("HTTP known versions skip unchanged bodies without reaching provider input or fragmenting keys", async () => { +test("HTTP known headers skip unchanged bodies without reaching provider input or fragmenting keys", async () => { const payload = { notes: ["same"], cursor: 3, version: "producer-owned" }; const fixture = await httpFixture(payload), post = await fixture.viewer(); const first = await post("/p/demo/snapshot", { panel: "notes", after: 0 }); const version = first.headers.get("x-acon-version"); expect(version).toMatch(/^[0-9a-f]{64}$/); expect(await first.json()).toEqual(payload); - const second = await post("/p/demo/snapshot", { panel: "notes", after: 0, known: version }); + const second = await post("/p/demo/snapshot", { panel: "notes", after: 0 }, { "X-Acon-Known": version! }); expect(await second.json()).toEqual({ changed: false }); expect(second.headers.get("x-acon-version")).toBe(version); expect(second.headers.get("x-acon-cache")).toBe("hit"); - const unknown = await post("/p/demo/snapshot", { panel: "notes", after: 0, known: "0".repeat(64) }); + const unknown = await post("/p/demo/snapshot", { panel: "notes", after: 0 }, { "X-Acon-Known": "0".repeat(64) }); expect(await unknown.json()).toEqual(payload); expect(fixture.calls).toBe(1); expect(fixture.inputs).toEqual([{ panel: "notes", after: 0 }]); const tools = await (await post("/tools", {})).json(); expect(tools.snapshotCache.entries).toBe(1); fixture.advance(4000); - const refreshed = await post("/p/demo/snapshot", { panel: "notes", after: 0, known: version }); + const refreshed = await post("/p/demo/snapshot", { panel: "notes", after: 0 }, { "X-Acon-Known": version! }); expect(refreshed.headers.get("x-acon-version")).toBe(version); expect(await refreshed.json()).toEqual({ changed: false }); expect(fixture.calls).toBe(2); @@ -323,7 +323,7 @@ test("plugin cursor and legacy full changed:false replies take precedence over k const fixture = await httpFixture(payload), post = await fixture.viewer(); const first = await post("/p/demo/snapshot", { panel: "notes", after: 7 }); const version = first.headers.get("x-acon-version"); - const second = await post("/p/demo/snapshot", { panel: "notes", after: 7, known: version }); + const second = await post("/p/demo/snapshot", { panel: "notes", after: 7 }, { "X-Acon-Known": version! }); expect(await second.json()).toEqual(payload); expect(fixture.calls).toBe(1); } @@ -334,15 +334,15 @@ test("known never suppresses or retains producer error replies", async () => { const fixture = await httpFixture(payload), post = await fixture.viewer(); const first = await post("/p/demo/snapshot", { panel: "notes" }); const version = first.headers.get("x-acon-version"); - const second = await post("/p/demo/snapshot", { panel: "notes", known: version }); + const second = await post("/p/demo/snapshot", { panel: "notes" }, { "X-Acon-Known": version! }); expect(second.status).toBe(200); expect(await second.json()).toEqual(payload); expect(fixture.calls).toBe(2); }); -test("malformed known versions are rejected before provider dispatch", async () => { +test("malformed known headers are rejected before provider dispatch", async () => { const fixture = await httpFixture({ notes: [] }), post = await fixture.viewer(); - for (const known of [null, 0, "", "not-a-hash", "a".repeat(65), "A".repeat(64)]) { - expect((await post("/p/demo/snapshot", { panel: "notes", known })).status).toBe(400); + for (const known of ["", "not-a-hash", "a".repeat(65), "A".repeat(64)]) { + expect((await post("/p/demo/snapshot", { panel: "notes" }, { "X-Acon-Known": known })).status).toBe(400); } expect(fixture.calls).toBe(0); }); @@ -353,7 +353,26 @@ test("Space cursor-only unchanged replies preserve their cursor without admittin const first = await post("/p/demo/snapshot", { panel: "space", after: 7 }); const version = first.headers.get("x-acon-version"); expect(await first.json()).toEqual({ changed: false, cursor: 7 }); - const second = await post("/p/demo/snapshot", { panel: "space", after: 7, known: version }); + const second = await post("/p/demo/snapshot", { panel: "space", after: 7 }, { "X-Acon-Known": version! }); expect(await second.json()).toEqual({ changed: false, cursor: 7 }); expect(fixture.calls).toBe(1); }); + + +test("producer body known remains unrestricted input and part of the canonical cache key", async () => { + const fixture = await httpFixture({ notes: [] }), post = await fixture.viewer(); + const values = [null, 0, "not-a-hash", { record: "producer" }, ["producer"]]; + for (const known of values) { + const response = await post("/p/demo/snapshot", { panel: "notes", known }); + expect(response.status).toBe(200); + expect(response.headers.get("x-acon-cache")).toBe("miss"); + const version = response.headers.get("x-acon-version")!; + const unchanged = await post("/p/demo/snapshot", { panel: "notes", known }, { "X-Acon-Known": version }); + expect(unchanged.status).toBe(200); + expect(await unchanged.json()).toEqual({ changed: false }); + } + expect(fixture.calls).toBe(values.length); + expect(fixture.inputs.map((input) => input.known)).toEqual(values); + const tools = await (await post("/tools", {})).json(); + expect(tools.snapshotCache.entries).toBe(values.length); +}); diff --git a/test/static-node.test.ts b/test/static-node.test.ts index fc7dfcc..492b7f4 100644 --- a/test/static-node.test.ts +++ b/test/static-node.test.ts @@ -170,3 +170,22 @@ describe("H2 resource grants and node isolation (#46 AC4)", () => { expect(await own.json()).toEqual(expect.objectContaining({ ok: true })); }); }); + + +test("configured-origin snapshot preflight permits X-Acon-Known and the conditional request succeeds", async () => { + const server = await node(); + const preflight = await call(server, "/p/demo/snapshot", { method: "OPTIONS", headers: { + "Access-Control-Request-Method": "POST", "Access-Control-Request-Headers": "authorization, content-type, x-acon-known", + } }); + expect(preflight.status).toBe(204); + cors(preflight); + const allowed = preflight.headers.get("access-control-allow-headers")!.toLowerCase().split(",").map((name) => name.trim()); + expect(allowed).toContain("x-acon-known"); + const first = await call(server, "/p/demo/snapshot", { auth: true, body: { panel: "notes" } }); + const version = first.headers.get("x-acon-version")!; + expect(version).toMatch(/^[0-9a-f]{64}$/); + const conditional = await call(server, "/p/demo/snapshot", { auth: true, body: { panel: "notes" }, headers: { "X-Acon-Known": version } }); + expect(conditional.status).toBe(200); + cors(conditional); + expect(await conditional.json()).toEqual({ changed: false }); +}); diff --git a/test/static-transport.test.ts b/test/static-transport.test.ts index d5868f7..cee2ffc 100644 --- a/test/static-transport.test.ts +++ b/test/static-transport.test.ts @@ -6,7 +6,7 @@ interface Result { status: number; headers: Record; data: Record interface Transport { nodes: { id: string; status: string }[]; tools(): Promise; - post(path: string, body: object, refs?: Map): Promise; + post(path: string, body: object, refs?: Map, headers?: Record): Promise; resource(path: string, refs?: Map): string; setToken(id: string, token: string): void; } @@ -260,7 +260,7 @@ test("inbox polling keeps one snapshot in flight per tool; an explicit refresh w }); -test("static snapshots retain exposed cache/version headers and forward known without changing the body", async () => { +test("static snapshots retain response headers and send X-Acon-Known without reserving body fields", async () => { const known = "1".repeat(64); const snapshotHeaders = { "X-Acon-Version": known, @@ -270,17 +270,23 @@ test("static snapshots retain exposed cache/version headers and forward known wi "X-Private-Diagnostic": "do not forward", }; const sent: Record[] = []; + const requestHeaders: Headers[] = []; const { make } = transport((async (input, init) => { const path = new URL(String(input)).pathname; if (path === "/session") return Response.json({ resourceToken: "a".repeat(64), expiresIn: 3600000 }); if (path === "/tools") return Response.json({ tools: [] }); sent.push(JSON.parse(String(init?.body))); + requestHeaders.push(new Headers(init?.headers)); return Response.json({ changed: false }, { headers: snapshotHeaders }); }) as typeof fetch); const client = make({ api: 1, nodes: [config.nodes[0]] }); await client.tools(); - const result = await client.post("/p/a:demo/snapshot", { panel: "space", after: 3, known }); - expect(sent).toEqual([{ panel: "space", after: 3, known }]); + const body = { panel: "space", after: 3, known: { producerOwned: "not a hash" } }; + const result = await client.post("/p/a:demo/snapshot", body, undefined, { "X-Acon-Known": known, "Content-Type": "unsafe", Authorization: "unsafe" }); + expect(sent).toEqual([body]); + expect(requestHeaders[0]!.get("X-Acon-Known")).toBe(known); + expect(requestHeaders[0]!.get("Content-Type")).toBe("application/json"); + expect(requestHeaders[0]!.get("Authorization")).toBeNull(); expect(result.data).toEqual({ changed: false }); expect(result.headers).toEqual({ "x-acon-version": known, From 835e009e2f5836e63a47f386bba37550e2b2b5d6 Mon Sep 17 00:00:00 2001 From: Young Joon Lee Date: Tue, 10 Nov 2026 23:44:08 +0900 Subject: [PATCH 4/5] fix(snapshot): recover from refused X-Acon-Known and clear versions on any failure - Clear the stored panel version on any failed reply, status 0 included, so a reconnect asks for a full reply. - Static transport: when a request carrying X-Acon-Known fails in transport (e.g. an Access CORS policy refusing the header in preflight), retry it plain and stop sending the header to that node for the page. - Remove the unread serverSampledAt state. - README: Access CORS must allow X-Acon-Known for conditional replies. - CHANGELOG: note the Space cursor-only reply passthrough for 0.1.8 shells. --- CHANGELOG.md | 1 + README.md | 2 +- shell/shell.js | 8 +++----- shell/static.js | 4 +++- test/shell-polling.test.ts | 16 ++++++++++++---- test/static-transport.test.ts | 23 +++++++++++++++++++++++ 6 files changed, 43 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b14d81..cbcbbb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Planned 0.2.0 N-1 support targets 0.1.8, the latest published patch of 0.1.x, and remains additive on API 1: - Plugin API: verified by a directional type bridge (old plugin values accepted by the current console; current console values satisfy old plugins; historical optional keys are retained) and the exact published 0.1.8 conformance kit against a 0.1.8-shaped provider opened by the current loader. New principal capabilities remain opt-in. - Routes: verified for the legacy browser session, tool listing, ping, panel snapshots, actions, confirmation-token exchange and a sample plugin asset through both tool-id and load-reference paths. Other Access/bearer and static-shell routes remain supported by policy but are not exercised by the added historical probe. + - Space route: a producer Space reply `{changed: false, cursor}` without a scene now passes through unchanged instead of being projected to an empty scene. A 0.1.8 static shell treats it as a failed source for that poll instead of blanking the scene; current shells keep the last scene. - Manifest files: verified for unversioned and versioned API 1 plugin registrations with module/hash validation. Existing pet/asset pack manifests and static node configuration remain supported by policy but are not covered by this N-1 probe; the principal registry is an additive new surface. ### Added diff --git a/README.md b/README.md index a4cb94d..ded1ab0 100644 --- a/README.md +++ b/README.md @@ -250,7 +250,7 @@ Build one static shell only over equally trusted nodes (such as your own nodes). The output contains fixed shell/vendor files, public `config.json`, `_headers`, `vercel.json`, an assets-only `wrangler.jsonc`, `.assetsignore` and `.vercelignore` (each host skips the other's configuration). It contains no plugin data or credentials. Upload the complete directory to a preview with the supplied header configuration. The first `vercel deploy` of a new project can become a production deployment on a public alias even with `--target preview`: link the project first (`vercel link`), then check that `vercel inspect` shows `target preview` before sharing a URL. On Workers, `wrangler versions upload` needs an existing Worker, so the first `wrangler deploy` is a production deployment on workers.dev and needs the same authorization as any production shell. Main-document CSP permits connections, scripts and images only from self and the configured node origins; plugin styles are fetched with node auth into constructed stylesheets, and attachments use a local sandboxed viewer. The sandboxed visual/attachment documents have separate header policies. -In System, each node has its own bearer-token form and Access sign-in/reconnect controls. Bearer tokens stay in that tab's `sessionStorage`; Access uses that node's cookies with credentialed requests. Access CORS/cookie policy must also permit the shell origin. Authenticated nodes issue short-lived GET-only resource grants for panel modules and attachments because native module/image requests cannot carry bearer headers. These runtime grants are node-specific, contain no bearer token and never authorize actions. Keep node access logs private, since runtime resource URLs contain these grants. The "Open attachment" link also includes a GET-only grant and can leave it in browser history for its remaining lifetime (at most 1 hour); keep that history private too. +In System, each node has its own bearer-token form and Access sign-in/reconnect controls. Bearer tokens stay in that tab's `sessionStorage`; Access uses that node's cookies with credentialed requests. Access CORS/cookie policy must also permit the shell origin. Authenticated nodes issue short-lived GET-only resource grants for panel modules and attachments because native module/image requests cannot carry bearer headers. These runtime grants are node-specific, contain no bearer token and never authorize actions. Keep node access logs private, since runtime resource URLs contain these grants. The "Open attachment" link also includes a GET-only grant and can leave it in browser history for its remaining lifetime (at most 1 hour); keep that history private too. For conditional snapshots the Access CORS policy must also allow the `X-Acon-Known` request header; if a node refuses it in preflight, the shell stops sending it to that node for the rest of the page and falls back to full replies. Tools are namespaced `node:tool`; panel `host.node` identifies the owner and `host.tool` keeps its original tool id. All actions, confirmations and read-only restrictions stay on that owner. System distinguishes online, offline, unauthorized and `refused (origin)` nodes. A bounded opaque health probe distinguishes an unreachable node from a reachable node blocked by CORS; persistent failures refresh at most once per tool-list interval, without repeated prompts. Use System to reconnect after fixing a URL, token or `--origin`. diff --git a/shell/shell.js b/shell/shell.js index a27d110..ba621a9 100644 --- a/shell/shell.js +++ b/shell/shell.js @@ -1633,8 +1633,10 @@ async function pollPanel() { p.lastSig = sig; p.lastData = data; for (const cb of p.snapListeners) safe(r.tool, "onSnapshot", () => cb(data)); - } else if (res.status) { + } else { + // Any failed reply, a transport failure (status 0) included, drops the conditional version. p.version = ""; + if (!res.status) return; if (p.banner.hidden) p.banner.hidden = false; p.banner.replaceChildren(icon("alert"), h("span", { text: `${tool?.title ?? r.tool} could not refresh. ` }), h("a", { href: hashFor("system"), text: "Details in System" })); } @@ -1929,12 +1931,8 @@ async function pollSpace() { if (resume && document.visibilityState === "visible") { entry.next = 0; queueMicrotask(() => pollSpace()); } entry.reported = true; if (res.status === 200 && (res.data.scene || (entry.scene && res.data.changed === false))) { - const stale = res.headers?.["x-acon-cache"] === "stale"; - const sampledAt = Date.parse(res.headers?.["x-acon-sampled-at"] ?? ""); // Motion measures observations, not repeated/jumping cache sample timestamps. entry.sampledAt = Date.now(); - if (Number.isFinite(sampledAt)) entry.serverSampledAt = sampledAt; - else if (!stale) delete entry.serverSampledAt; if (res.data.scene) { entry.scene = res.data.scene; entry.dropped = res.data.dropped ?? { actors: 0, cards: 0, links: 0 }; } if (Number.isSafeInteger(res.data.cursor)) entry.cursor = res.data.cursor; entry.version = res.headers?.["x-acon-version"] ?? ""; diff --git a/shell/static.js b/shell/static.js index 992fecc..cbe1aa9 100644 --- a/shell/static.js +++ b/shell/static.js @@ -31,7 +31,8 @@ export function createStaticTransport(config, onStatus = () => {}) { const request = async (node, path, body, extraHeaders = {}) => { const headers = headersFor(node, body !== undefined); const known = Object.entries(extraHeaders).find(([name]) => name.toLowerCase() === "x-acon-known")?.[1]; - if (known !== undefined) headers["X-Acon-Known"] = known; + // A node (or its Access CORS policy) may refuse the custom header in preflight; after one such failure send plain requests. + if (known !== undefined && !node.knownOff) headers["X-Acon-Known"] = known; const action = body !== undefined && /^\/p\/[^/]+\/action$/.test(path); const deadline = action ? 35000 : 8000; const signal = AbortSignal.timeout(deadline); @@ -49,6 +50,7 @@ export function createStaticTransport(config, onStatus = () => {}) { notify(node, 'slow', `${path} took over ${deadline / 1000} s; the node is reachable but busy. Its tools retry on their next poll.`); return { status: 0, headers: {}, data: { ok: false, error: node.detail } }; } + if (headers["X-Acon-Known"] !== undefined) { node.knownOff = true; return request(node, path, body, {}); } return failed(node); } if (response.status === 401 || (response.status === 403 && data?.readOnly !== true)) { diff --git a/test/shell-polling.test.ts b/test/shell-polling.test.ts index a52ab3c..b6288a8 100644 --- a/test/shell-polling.test.ts +++ b/test/shell-polling.test.ts @@ -77,19 +77,19 @@ test("Space keeps server samples separately from the healthy motion observation h.setClock(100000); h.setResponse(async () => ({ status: 200, data: { scene, cursor: 7 }, headers: { "x-acon-version": VERSION, "x-acon-cache": "hit", "x-acon-sampled-at": new Date(1000).toISOString() } })); await h.api.pollSpace(); - const entry = h.state.space.get("demo"); expect(entry.serverSampledAt).toBe(1000); expect(entry.sampledAt).toBe(100000); expect(entry.failed).toBe(false); + const entry = h.state.space.get("demo"); expect(entry.sampledAt).toBe(100000); expect(entry.failed).toBe(false); h.setClock(entry.next); h.setResponse(async () => ({ status: 200, data: { changed: false }, headers: { "x-acon-version": VERSION, "x-acon-cache": "stale", "x-acon-sampled-at": new Date(1000).toISOString() } })); await h.api.pollSpace(); expect(h.requests[1]!.body).toEqual({ panel: "space", after: 7 }); expect(h.requests[1]!.headers["X-Acon-Known"]).toBe(VERSION); - expect(entry.scene).toEqual(scene); expect(entry.serverSampledAt).toBe(1000); expect(entry.sampledAt).toBe(102000); expect(entry.failed).toBe(false); + expect(entry.scene).toEqual(scene); expect(entry.sampledAt).toBe(102000); expect(entry.failed).toBe(false); const mergeSource = readFileSync(new URL("../shell/space.js", import.meta.url), "utf8").replace(/^import .*;$/gm, "").replace(/^export /gm, ""); const merge = runInNewContext(`${mergeSource}\nmergeSpaceScenes`); entry.scene = { ...scene, actors: [{ id: "one", name: "One", state: "working", link: { panel: "notes" } }] }; expect(merge(h.state.space).actors[0].stale).toBe(false); h.setClock(entry.next); h.setResponse(async () => ({ status: 502, data: { ok: false } })); - await h.api.pollSpace(); expect(entry.serverSampledAt).toBe(1000); expect(entry.sampledAt).toBe(102000); expect(entry.failed).toBe(true); + await h.api.pollSpace(); expect(entry.sampledAt).toBe(102000); expect(entry.failed).toBe(true); expect(merge(h.state.space).actors[0].stale).toBe(true); }); @@ -213,7 +213,6 @@ test("repeated or jumping server samples do not create a Room observation source const result = runInNewContext(`${sourceGapBody}\n({sourceGap,firstSource})`, { samples, receivedSamples: model.samples }); expect(result.sourceGap).toBe(false); expect(h.state.space.get("demo").sampledAt).toBe(100000 + index * 2000); - expect(h.state.space.get("demo").serverSampledAt).toBe(sample); } }); @@ -238,3 +237,12 @@ test("Space cadence survives timer jitter without a false observation gap", asyn } expect(h.requests).toHaveLength(4); }); + +test("a transport failure (status 0) clears the panel version so the next poll asks for a full reply", async () => { + const h = pollingHarness(); await h.api.pollPanel(); + expect(h.panel.version).toBe(VERSION); + h.setClock(h.panel.next); h.setResponse(async () => ({ status: 0, data: { ok: false, error: "offline" }, headers: {} })); + await h.api.pollPanel(); expect(h.panel.version).toBe(""); + h.setClock(h.panel.next); h.setResponse(async () => ({ status: 200, data: { notes: [2], cursor: 2 }, headers: { "x-acon-version": VERSION } })); + await h.api.pollPanel(); expect(h.requests.at(-1)!.headers).toEqual({}); +}); diff --git a/test/static-transport.test.ts b/test/static-transport.test.ts index cee2ffc..5f84fba 100644 --- a/test/static-transport.test.ts +++ b/test/static-transport.test.ts @@ -296,6 +296,29 @@ test("static snapshots retain response headers and send X-Acon-Known without res }); }); +test("a node that refuses X-Acon-Known in preflight gets a plain retry and no header afterwards", async () => { + const known = "2".repeat(64); + const seen: (string | null)[] = []; + const { make } = transport((async (input, init) => { + const path = new URL(String(input)).pathname; + if (path === "/session") return Response.json({ resourceToken: "a".repeat(64), expiresIn: 3600000 }); + if (path === "/tools") return Response.json({ tools: [] }); + const header = new Headers(init?.headers).get("X-Acon-Known"); + seen.push(header); + // A CORS preflight refusal surfaces to fetch as a TypeError. + if (header !== null) throw new TypeError("Failed to fetch"); + return Response.json({ ok: true, rows: [1] }); + }) as typeof fetch); + const client = make({ api: 1, nodes: [config.nodes[0]] }); + await client.tools(); + const first = await client.post("/p/a:demo/snapshot", { panel: "notes", after: 0 }, undefined, { "X-Acon-Known": known }); + expect(first.status).toBe(200); + expect(first.data).toEqual({ ok: true, rows: [1] }); + const second = await client.post("/p/a:demo/snapshot", { panel: "notes", after: 0 }, undefined, { "X-Acon-Known": known }); + expect(second.status).toBe(200); + expect(seen).toEqual([known, null, null]); +}); + test("static transport accepts older nodes with no cache headers or response-header interface", async () => { for (const noHeadersInterface of [false, true]) { const { make } = transport((async (input) => { From e1f7ba74986d93fda7331020a6d4695bbaf54fe6 Mon Sep 17 00:00:00 2001 From: Young Joon Lee Date: Wed, 11 Nov 2026 00:05:16 +0900 Subject: [PATCH 5/5] fix(snapshot): keep producer errors failing and refresh open peeks - Inbox and Space treat an ok:false reply as a failure even when it also says changed:false, so the failure banner and stale actors still show. - Each inbox round re-renders an open item peek, so a snooze that expires without a data change updates the peek too. - Docs no longer claim a separately retained server sample time; Room motion uses the client observation clock. --- CHANGELOG.md | 2 +- README.md | 2 +- shell/shell.js | 5 +++-- test/shell-polling.test.ts | 28 ++++++++++++++++++++++++++++ 4 files changed, 33 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cbcbbb0..58e2ccd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ ### Added -- Add header-only snapshot versions and `X-Acon-Known` conditional request headers, preserving plugin cursor replies and forwarding version/freshness headers through the static transport. Adapt polling to request duration while keeping the inbox's 5-second minimum, pause hidden panel/Space polls with immediate visible refresh, progressively render inbox sources, refresh expired snoozes on unchanged rounds, and retain server data sample times separately from the Room observation clock without marking healthy SWR sources failed (#105). +- Add header-only snapshot versions and `X-Acon-Known` conditional request headers, preserving plugin cursor replies and forwarding version/freshness headers through the static transport. Adapt polling to request duration while keeping the inbox's 5-second minimum, pause hidden panel/Space polls with immediate visible refresh, progressively render inbox sources, refresh expired snoozes on unchanged rounds, and drive Room motion from the client observation clock without marking healthy SWR sources failed (#105). - Add the Space Board view with per-group zone columns, producer-linked cards, owner detail controls, and collapsed Done history showing the last five supplied cards. Keep horizontal scrolling within each board row, preserve focus/disclosures, and cover the responsive matrix with a Board probe (#101). diff --git a/README.md b/README.md index ded1ab0..f514451 100644 --- a/README.md +++ b/README.md @@ -305,7 +305,7 @@ The cache holds at most 256 entries and 4 MiB, evicts least recently used entrie Conditional snapshots (#105): each successful snapshot carries an `X-Acon-Version` content hash. A shell can send its last hash in the `X-Acon-Known` request header. Conditional control stays outside the snapshot body and cache key, so producer input remains unchanged, including any producer-owned `known` field. A matching successful value returns `{changed: false}` with the version header. Plugin `after` cursors and producer `changed: false` replies take precedence, including their cursor fields. Version and freshness metadata stay in headers, so producer body fields are unchanged by this protocol. Configured-origin preflight allows `X-Acon-Known`; the H2 transport forwards that request header and exposes the same version/cache/sample-time response headers to the shell; nodes without those headers keep their existing full-reply behavior. -Panel and Space polling use `max(pollMs, 3 * last request duration)`, capped at 60 seconds, with `pollMs` clamped to 2-60 seconds. Per-tool inbox polling uses the same adaptive interval with an `INBOX_MS` (5-second) floor, so it never polls faster than the previous inbox cadence; an omitted tool `pollMs` also uses that fallback. The interval recovers when requests become faster. Hidden tabs start no panel or Space polls, and becoming visible triggers one immediate refresh; existing Mini/Picture-in-Picture inbox behavior is preserved. Inbox sources render as each tool finishes instead of waiting for the slowest source. Each inbox round also rechecks time-dependent snoozes through signature-checked renderers, even when no provider data changes. Space keeps the server's `X-Acon-Sampled-At` value separately as its data sample time, including on a stale or unchanged reply. Room motion uses the client observation time, so repeated cached sample times cannot create false source gaps. Routine SWR reuse remains healthy and does not idle pets or show a refresh-failure warning; only unsuccessful replies mark the source failed. +Panel and Space polling use `max(pollMs, 3 * last request duration)`, capped at 60 seconds, with `pollMs` clamped to 2-60 seconds. Per-tool inbox polling uses the same adaptive interval with an `INBOX_MS` (5-second) floor, so it never polls faster than the previous inbox cadence; an omitted tool `pollMs` also uses that fallback. The interval recovers when requests become faster. Hidden tabs start no panel or Space polls, and becoming visible triggers one immediate refresh; existing Mini/Picture-in-Picture inbox behavior is preserved. Inbox sources render as each tool finishes instead of waiting for the slowest source. Each inbox round also rechecks time-dependent snoozes through signature-checked renderers, even when no provider data changes. Room motion uses the client observation time, so repeated cached sample times cannot create false source gaps. Routine SWR reuse remains healthy and does not idle pets or show a refresh-failure warning; only unsuccessful replies mark the source failed. 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. diff --git a/shell/shell.js b/shell/shell.js index ba621a9..ac907a1 100644 --- a/shell/shell.js +++ b/shell/shell.js @@ -1039,6 +1039,7 @@ async function pollInbox(fresh = true) { // Time-dependent snoozes can expire even when every provider result is unchanged. renderRail(); renderMini(); if (state.route.area === "inbox") renderInbox(); + if (state.peekKey?.startsWith("item:")) renderPeek(); } async function inboxFetch(t, seq) { const previous = state.inbox.get(t.tool); @@ -1053,7 +1054,7 @@ async function inboxFetch(t, seq) { const items = r.data.items.filter(validItem); if (items.length !== r.data.items.length) clientError(t.tool, "inbox", new Error(`${r.data.items.length - items.length} malformed inbox items were skipped`)); state.inbox.set(t.tool, { items, failed: false, next, version }); - } else if (r.status === 200 && r.data.changed === false && previous) { + } else if (r.status === 200 && r.data.changed === false && r.data.ok !== false && previous) { state.inbox.set(t.tool, { ...previous, failed: false, next, version }); } else { state.inbox.set(t.tool, { items: previous?.items ?? [], failed: true, next, version: "" }); @@ -1930,7 +1931,7 @@ async function pollSpace() { const resume = entry.resume; entry.resume = false; if (resume && document.visibilityState === "visible") { entry.next = 0; queueMicrotask(() => pollSpace()); } entry.reported = true; - if (res.status === 200 && (res.data.scene || (entry.scene && res.data.changed === false))) { + if (res.status === 200 && res.data.ok !== false && (res.data.scene || (entry.scene && res.data.changed === false))) { // Motion measures observations, not repeated/jumping cache sample timestamps. entry.sampledAt = Date.now(); if (res.data.scene) { entry.scene = res.data.scene; entry.dropped = res.data.dropped ?? { actors: 0, cards: 0, links: 0 }; } diff --git a/test/shell-polling.test.ts b/test/shell-polling.test.ts index b6288a8..a8f101a 100644 --- a/test/shell-polling.test.ts +++ b/test/shell-polling.test.ts @@ -246,3 +246,31 @@ test("a transport failure (status 0) clears the panel version so the next poll a h.setClock(h.panel.next); h.setResponse(async () => ({ status: 200, data: { notes: [2], cursor: 2 }, headers: { "x-acon-version": VERSION } })); await h.api.pollPanel(); expect(h.requests.at(-1)!.headers).toEqual({}); }); + +test("an open item peek is refreshed after every inbox round, including snooze-only changes", async () => { + let peeks = 0; + const state = { inbox: new Map(), route: { area: "inbox" }, peekKey: "item:demo:item" }; + const item = { id: "item", title: "Decision", at: "1970-01-01T00:00:00.000Z", group: "decision", verbs: [], link: { panel: "notes" } }; + const api = runInNewContext(`${inboxSource}\n({pollInbox})`, { + state, INBOX_MS: 5000, Date, readyTools: () => [{ tool: "demo", capabilities: { inbox: true } }], validItem: () => true, clientError() {}, + renderRail() {}, renderMini() {}, renderInbox() {}, renderPeek: () => { peeks++; }, + post: async () => ({ status: 200, data: { items: [item] }, headers: { "x-acon-version": VERSION } }), + }); + await api.pollInbox(false); const after = peeks; + await api.pollInbox(false); expect(peeks).toBeGreaterThan(after); +}); + +test("producer ok:false with changed:false keeps failure handling for inbox and Space", async () => { + const state = { inbox: new Map([["demo", { items: [{ id: "x" }], failed: false, next: 0, version: VERSION }]]), route: { area: "inbox" }, peekKey: null }; + const api = runInNewContext(`${inboxSource}\n({pollInbox})`, { + state, INBOX_MS: 5000, Date, readyTools: () => [{ tool: "demo", capabilities: { inbox: true } }], validItem: () => true, clientError() {}, + renderRail() {}, renderMini() {}, renderInbox() {}, renderPeek() {}, + post: async () => ({ status: 200, data: { ok: false, changed: false, error: "producer failed" }, headers: {} }), + }); + await api.pollInbox(true); expect(state.inbox.get("demo").failed).toBe(true); + const h = pollingHarness(); h.state.route.area = "space"; + h.setResponse(async () => ({ status: 200, data: { scene: { actors: [], zones: [], cards: [], links: [] } }, headers: { "x-acon-version": VERSION } })); + await h.api.pollSpace(); const entry = h.state.space.get("demo"); expect(entry.failed).toBe(false); + h.setClock(entry.next); h.setResponse(async () => ({ status: 200, data: { ok: false, changed: false, error: "producer failed" }, headers: {} })); + await h.api.pollSpace(); expect(entry.failed).toBe(true); +});