summaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-12 16:28:07 +0900
committerAdam Malczewski <[email protected]>2026-06-12 16:28:07 +0900
commit4001274e3ba25a3946df1e9f2dc82ca6781cd2bf (patch)
tree24af95e69bda5c38ab7eefd6b71d55b4c247040a /src
parente6f6bd86eab07954d8f06e740659969c3dfecc7f (diff)
downloaddispatch-web-4001274e3ba25a3946df1e9f2dc82ca6781cd2bf.tar.gz
dispatch-web-4001274e3ba25a3946df1e9f2dc82ca6781cd2bf.zip
feat(cache-warming): consume CR-4 lifecycle — tab-close cancel + scope-aware subscriptions
- closeTab now POSTs /conversations/:id/close (abort in-flight turn + stop/disable warming server-side); disconnect still leaves both running ([email protected]) - syncSubscriptions honors catalog scope ([email protected]): global surfaces are not re-subscribed on conversation switch - fix(ws): the surface-message parser dropped the conversationId echo (CR-4d was ours, not the backend's) — preserved + unit-tested - secondsUntilNext: 3s stale guard — a past nextWarmAt renders as waiting, not 0s - re-pinned + re-mirrored [email protected] / [email protected] - scripts/probe-cache-warming.ts: live CR-4 probe (default-off, future nextWarmAt, repeated warms, mid-turn close abort, idempotent re-close) — 17/17 against bin/up
Diffstat (limited to 'src')
-rw-r--r--src/adapters/ws/logic.test.ts23
-rw-r--r--src/adapters/ws/logic.ts10
-rw-r--r--src/app/store.svelte.ts29
-rw-r--r--src/app/store.test.ts72
-rw-r--r--src/features/cache-warming/logic/view-model.test.ts10
-rw-r--r--src/features/cache-warming/logic/view-model.ts14
6 files changed, 153 insertions, 5 deletions
diff --git a/src/adapters/ws/logic.test.ts b/src/adapters/ws/logic.test.ts
index 546afe1..2784295 100644
--- a/src/adapters/ws/logic.test.ts
+++ b/src/adapters/ws/logic.test.ts
@@ -64,6 +64,29 @@ describe("parseServerMessage", () => {
});
});
+ it("preserves the conversationId echo on a scoped surface message", () => {
+ const data = JSON.stringify({
+ type: "surface",
+ spec: { id: "s1", region: "r", title: "S1", fields: [] },
+ conversationId: "c1",
+ });
+ const result = parseServerMessage(data);
+ expect(result).toEqual({
+ type: "surface",
+ spec: { id: "s1", region: "r", title: "S1", fields: [] },
+ conversationId: "c1",
+ });
+ });
+
+ it("rejects a surface message with a non-string conversationId", () => {
+ const data = JSON.stringify({
+ type: "surface",
+ spec: { id: "s1", region: "r", title: "S1", fields: [] },
+ conversationId: 42,
+ });
+ expect(parseServerMessage(data)).toBeNull();
+ });
+
it("parses an update message", () => {
const data = JSON.stringify({
type: "update",
diff --git a/src/adapters/ws/logic.ts b/src/adapters/ws/logic.ts
index 6592f1b..17e3951 100644
--- a/src/adapters/ws/logic.ts
+++ b/src/adapters/ws/logic.ts
@@ -59,7 +59,15 @@ export function parseServerMessage(data: string): WsServerMessage | null {
if (typeof spec.region !== "string") return null;
if (typeof spec.title !== "string") return null;
if (!Array.isArray(spec.fields)) return null;
- return { type: "surface", spec: spec as unknown as SurfaceMessage["spec"] };
+ // Preserve the conversationId echo (a conversation-scoped surface's initial
+ // reply carries it) — dropping it would defeat the protocol reducer's
+ // stale-scope filtering on a fast conversation switch.
+ const conversationId = parsed.conversationId;
+ if (conversationId !== undefined && typeof conversationId !== "string") return null;
+ const surfaceSpec = spec as unknown as SurfaceMessage["spec"];
+ return conversationId !== undefined
+ ? { type: "surface", spec: surfaceSpec, conversationId }
+ : { type: "surface", spec: surfaceSpec };
}
case "update": {
const update = parsed.update;
diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts
index df92b31..2837bb5 100644
--- a/src/app/store.svelte.ts
+++ b/src/app/store.svelte.ts
@@ -256,6 +256,23 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
socket?.send({ type: "chat.unsubscribe", conversationId });
}
+ /**
+ * Tell the backend the user EXPLICITLY closed this conversation's tab
+ * (`POST /conversations/:id/close`): aborts any in-flight turn (it seals with
+ * `reason: "aborted"`) and stops + DISABLES its cache-warming (persisted OFF).
+ * Distinct from a disconnect / `chat.unsubscribe`, which deliberately leave
+ * both running. Fire-and-forget: a failure is non-fatal (worst case the
+ * warming keeps running until a later close/toggle), and the endpoint is
+ * idempotent server-side.
+ */
+ function closeConversation(conversationId: string): void {
+ void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(conversationId)}/close`, {
+ method: "POST",
+ }).catch(() => {
+ // Non-fatal — see doc comment.
+ });
+ }
+
/** The conversation the surfaces should scope to (undefined for a draft). */
function focusedConversationId(): string | undefined {
return tabsStore.activeConversationId ?? undefined;
@@ -289,7 +306,12 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
function syncSubscriptions(): void {
const cid = focusedConversationId();
for (const entry of protocol.catalog) {
- const result = protocolSubscribe(protocol, entry.id, cid);
+ // A GLOBAL surface ignores conversation scope — subscribe it WITHOUT an id
+ // so a conversation switch doesn't churn a redundant unsubscribe+subscribe
+ // round trip ([email protected] catalog `scope`; ABSENT = assume
+ // conversation-scoped, the conservative pre-0.2.0 policy).
+ const scoped = entry.scope === "global" ? undefined : cid;
+ const result = protocolSubscribe(protocol, entry.id, scoped);
protocol = result.state;
for (const msg of result.outgoing) {
socket?.send(msg);
@@ -489,7 +511,10 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
closeTab(conversationId: string): void {
tabsStore.closeTab(conversationId);
- // Stop watching the closed conversation's turns (does NOT stop the turn).
+ // The user is DONE with this chat for now: abort any in-flight turn and
+ // stop + disable its cache-warming, server-side.
+ closeConversation(conversationId);
+ // Stop watching the closed conversation's turns.
unsubscribeChat(conversationId);
const store = chatStores.get(conversationId);
if (store !== undefined) {
diff --git a/src/app/store.test.ts b/src/app/store.test.ts
index 803d7dc..f4b5a0f 100644
--- a/src/app/store.test.ts
+++ b/src/app/store.test.ts
@@ -674,6 +674,78 @@ describe("createAppStore", () => {
store.dispose();
});
+ it("closing a tab POSTs /conversations/:id/close (abort turn + stop warming)", async () => {
+ const calls: { url: string; method: string }[] = [];
+ const base = fakeFetchImpl();
+ const fetchImpl: typeof fetch = async (input, init) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ calls.push({ url, method: init?.method ?? "GET" });
+ if (url.endsWith("/close")) {
+ return new Response(
+ JSON.stringify({ conversationId: url.split("/").at(-2), abortedTurn: false }),
+ { status: 200 },
+ );
+ }
+ return base(input, init);
+ };
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ store.send("first");
+ const convId = activeConversationId(store);
+ store.closeTab(convId);
+ await Promise.resolve(); // flush the fire-and-forget fetch
+
+ const close = calls.find((c) => c.url.endsWith(`/conversations/${convId}/close`));
+ expect(close).toBeDefined();
+ expect(close?.method).toBe("POST");
+
+ store.dispose();
+ });
+
+ it("does NOT re-scope a scope:'global' surface on conversation switch (no churn)", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ ws.feedSurfaceMessage({
+ type: "catalog",
+ catalog: [
+ { id: "s-global", region: "side", title: "Global", scope: "global" },
+ { id: "s-conv", region: "side", title: "Scoped", scope: "conversation" },
+ ],
+ });
+
+ ws.sent.length = 0;
+ store.send("promote the draft"); // draft → real conversation: surfaces re-scope
+ const convId = activeConversationId(store);
+
+ const surfaceMsgs = parseSent(ws).filter(
+ (p): p is { type: string; surfaceId: string; conversationId?: string } =>
+ (p as { type: string }).type === "subscribe" ||
+ (p as { type: string }).type === "unsubscribe",
+ );
+ // The conversation-scoped surface re-scopes: unsubscribe old + subscribe new id.
+ expect(
+ surfaceMsgs.some(
+ (m) => m.type === "subscribe" && m.surfaceId === "s-conv" && m.conversationId === convId,
+ ),
+ ).toBe(true);
+ // The global surface is untouched — no redundant unsubscribe+subscribe round trip.
+ expect(surfaceMsgs.some((m) => m.surfaceId === "s-global")).toBe(false);
+
+ store.dispose();
+ });
+
it("tabs persist to the injected storage and restore on a new store", () => {
const ws = fakeSocket();
const storage = createFakeStorage();
diff --git a/src/features/cache-warming/logic/view-model.test.ts b/src/features/cache-warming/logic/view-model.test.ts
index 3d6f6d0..d5ea901 100644
--- a/src/features/cache-warming/logic/view-model.test.ts
+++ b/src/features/cache-warming/logic/view-model.test.ts
@@ -215,6 +215,14 @@ describe("secondsUntilNext (authoritative, from nextWarmAt)", () => {
expect(secondsUntilNext(10_000, 10_000)).toBe(0);
expect(secondsUntilNext(250_000, 10_000)).toBe(240);
expect(secondsUntilNext(70_000, 10_000)).toBe(60);
- expect(secondsUntilNext(5_000, 999_999)).toBe(0); // already past
+ });
+
+ it("treats a nextWarmAt past the stale grace as not scheduled (belt-and-braces)", () => {
+ // Within the 3s grace an on-time warm may briefly read "0s"…
+ expect(secondsUntilNext(10_000, 11_000)).toBe(0);
+ expect(secondsUntilNext(10_000, 13_000)).toBe(0);
+ // …but beyond it the value is stale → null (the "waiting…" state).
+ expect(secondsUntilNext(10_000, 13_001)).toBeNull();
+ expect(secondsUntilNext(5_000, 999_999)).toBeNull();
});
});
diff --git a/src/features/cache-warming/logic/view-model.ts b/src/features/cache-warming/logic/view-model.ts
index f7740d7..eb105f6 100644
--- a/src/features/cache-warming/logic/view-model.ts
+++ b/src/features/cache-warming/logic/view-model.ts
@@ -232,11 +232,23 @@ export function observeWarm(
}
/**
+ * Grace before a PAST `nextWarmAt` is treated as "not scheduled" (→ the
+ * "waiting…" state instead of a perpetual "0s"). The backend pushes the FUTURE
+ * `nextWarmAt` after every warm (CR-4b) and `null` while generating/disabled, so
+ * this is a belt-and-braces guard that should never trigger — it only matters
+ * against a stale/buggy emitter, and the small window lets an on-time warm show
+ * "0s" for the second it takes to complete.
+ */
+const STALE_NEXT_WARM_MS = 3000;
+
+/**
* Seconds until the next automatic warm, AUTHORITATIVE: derived straight from the
* backend's `nextWarmAt` epoch-ms (never FE-anchored/guessed). `null` when nothing
- * is scheduled (disabled, or a turn is generating so the timer is cancelled).
+ * is scheduled (disabled, or a turn is generating so the timer is cancelled) — or
+ * when `nextWarmAt` is stale (further than the grace into the past).
*/
export function secondsUntilNext(nextWarmAt: number | null, now: number): number | null {
if (nextWarmAt === null) return null;
+ if (now - nextWarmAt > STALE_NEXT_WARM_MS) return null;
return Math.max(0, Math.ceil((nextWarmAt - now) / 1000));
}