diff --git a/CHANGELOG.md b/CHANGELOG.md index 478a3c0..58e2ccd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,13 @@ - 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 +- 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). - 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..f514451 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`. @@ -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 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. 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. - 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..31c618a --- /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 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()))); + 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..a74a57a 100644 --- a/scripts/ui-check-space-board.mjs +++ b/scripts/ui-check-space-board.mjs @@ -1,4 +1,5 @@ // #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"; @@ -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..3de2c84 100644 --- a/scripts/ui-check-space-flow.mjs +++ b/scripts/ui-check-space-flow.mjs @@ -1,4 +1,5 @@ // #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"; @@ -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(() => { @@ -140,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]) { @@ -166,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 c142d5b..3287401 100644 --- a/scripts/ui-check-space-foundation.mjs +++ b/scripts/ui-check-space-foundation.mjs @@ -1,4 +1,5 @@ // #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 }) { @@ -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..4f89248 100644 --- a/scripts/ui-check.mjs +++ b/scripts/ui-check.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node // 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"; @@ -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..ac907a1 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"]]; @@ -795,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; } @@ -806,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(); @@ -817,7 +818,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: 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) }); @@ -905,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])]); @@ -1006,40 +1009,59 @@ 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(); + // 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 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 ? { "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 + 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); 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 && r.data.ok !== 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 +1589,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 +1598,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 ? { "X-Acon-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")) { @@ -1608,8 +1634,11 @@ 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) { - p.banner.hidden = false; + } 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" })); } } @@ -1886,26 +1915,31 @@ 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 ? { "X-Acon-Known": entry.version } : {}); entry.busy = false; + 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) { + 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(); - entry.scene = res.data.scene; - entry.dropped = res.data.dropped ?? { actors: 0, cards: 0, links: 0 }; + 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"] ?? ""; + // SWR grace is healthy reuse, not a failed refresh. Only unsuccessful replies fail a source. entry.failed = false; - } else entry.failed = true; + } else { entry.failed = true; entry.version = ""; } })); renderSpace(); } @@ -2234,7 +2268,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 +2276,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 +2303,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 +2322,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/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 9de6e9a..cbe1aa9 100644 --- a/shell/static.js +++ b/shell/static.js @@ -1,4 +1,5 @@ // 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 loopback = (host) => host === 'localhost' || host === '[::1]' || /^127\.\d+\.\d+\.\d+$/.test(host); @@ -25,17 +26,21 @@ 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 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]; + // 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); - 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 { - 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) { @@ -43,15 +48,16 @@ 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 } }; } + 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)) { 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: 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) { @@ -82,15 +88,15 @@ 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) { + async post(path, body, refs, headers = {}) { 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 } }; - return request(route.node, route.path, body); + 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, headers); }, resource(path, refs) { const route = resolve(path, refs); diff --git a/src/server.ts b/src/server.ts index b4e1191..bc2ea53 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 }; @@ -182,7 +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) { + 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); @@ -201,11 +203,16 @@ export function startServer(options: ServerOptions) { 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`); @@ -323,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") ?? ""); @@ -445,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", @@ -462,7 +469,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/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/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..a8f101a --- /dev/null +++ b/test/shell-polling.test.ts @@ -0,0 +1,276 @@ +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; 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; } }, + 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, 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() {}, + 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]!.headers["X-Acon-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 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(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.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(102000); expect(entry.failed).toBe(true); + 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; 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, 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(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)!.headers["X-Acon-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); +}); + + +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); +}); + +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); + } +}); + +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); +}); + +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({}); +}); + +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); +}); diff --git a/test/snapshot-cache.test.ts b/test/snapshot-cache.test.ts index d98387c..0c4bfe5 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); @@ -164,9 +165,9 @@ 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, 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,88 @@ test("explicit panels own their TTL before reserved capabilities", () => { expect(snapshotMaxAge(manifest, "space")).toBe(2000); expect(snapshotMaxAge(manifest, "inbox")).toBe(2000); }); + + +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 }, { "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 }, { "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 }, { "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); + 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 }, { "X-Acon-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" }, { "X-Acon-Known": version! }); + expect(second.status).toBe(200); expect(await second.json()).toEqual(payload); + expect(fixture.calls).toBe(2); +}); + +test("malformed known headers are rejected before provider dispatch", async () => { + const fixture = await httpFixture({ notes: [] }), post = await fixture.viewer(); + 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); +}); + + +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 }, { "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/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-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 f8310fe..5f84fba 100644 --- a/test/static-transport.test.ts +++ b/test/static-transport.test.ts @@ -2,18 +2,19 @@ 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; - 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; } 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; @@ -81,7 +82,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 +227,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 +258,79 @@ 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 response headers and send X-Acon-Known without reserving body fields", 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 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 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, + "x-acon-cache": "stale", + "x-acon-cache-age-ms": "2500", + "x-acon-sampled-at": "2026-11-10T00:00:00.000Z", + }); +}); + +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) => { + 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" }); + } +}); 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(/