diff options
24 files changed, 676 insertions, 81 deletions
diff --git a/frontend-cache-warming-lifecycle-handoff.md b/frontend-cache-warming-lifecycle-handoff.md new file mode 100644 index 0000000..0722e25 --- /dev/null +++ b/frontend-cache-warming-lifecycle-handoff.md @@ -0,0 +1,94 @@ +# FE handoff — CR-4 cache-warming lifecycle SHIPPED (+ CR-1 table, CR-2 scope) + +> **Courier doc** (backend → `../dispatch-web`, via the user). Response to your +> `backend-handoff-cache-warming.md` (CR-4) and the open asks CR-1 / CR-2 in +> `backend-handoff.md`. Everything below is live on `bin/up` and verified with a +> headless probe (same flow as your `scripts/probe-cache-warming.ts` — re-run it to +> confirm; default-off means Phase C's toggle-enable branch now executes). +> +> **Contract bumps to re-pin:** `@dispatch/ui-contract` **0.1.0 → 0.2.0**, +> `@dispatch/transport-contract` **0.8.0 → 0.9.0**. `wire` unchanged (0.6.0). + +## CR-4a — warming now defaults OFF ✅ +A new conversation starts `enabled: false`, `nextWarmAt: null` — no warm is scheduled +until the user opts in via the toggle. Interval default is still 240s. Bonus fix: +re-enabling restores the conversation's PERSISTED interval (not the 240s default). +One caveat (pre-existing behavior, now fail-safe): opt-in is not yet re-hydrated +across a backend RESTART — after a restart a conversation reads disabled until +toggled again. Flag it if that matters to you and we'll add boot hydration. + +## CR-4b — post-warm updates now carry the FUTURE `nextWarmAt` ✅ +Root cause was notify-before-reschedule in the warmer. Fixed; additionally: +- after every automatic warm, the pushed `cache-warming-timer` payload is + `{ nextWarmAt: <future>, lastWarmAt: <just now> }` (probe: 2 warms @5s, both FUTURE); +- after `turn-sealed` the surface now pushes the fresh post-turn schedule (this was + the "still past after a real chat turn" case in your probe); +- on `turn-start` the surface pushes `nextWarmAt: null` (nothing scheduled while + generating — render as your "waiting…" state); +- if a warm completes with warming since-disabled, the update carries + `nextWarmAt: null`, never a stale past timestamp. +Your countdown can stay authoritative off `nextWarmAt`; the cosmetic past-value guard +should now be dead code. + +## CR-4c — `POST /conversations/:id/close` ✅ (the tab-close affordance) +New endpoint (no request body), `[email protected]`: + +```ts +interface CloseConversationResponse { + conversationId: string; + abortedTurn: boolean; // true iff an in-flight turn existed and was aborted +} +``` + +Semantics — exactly the asymmetry the user wanted: +- **Aborts any in-flight turn.** The kernel stops at the next event boundary; the + partial turn is PERSISTED and the turn SEALS normally — watchers receive + `done` (with `reason: "aborted"`) then `turn-sealed`, so your stream-derived + `generating` flag clears with no special-casing. Live-verified. +- **Stops + disables cache-warming** for the conversation (persisted OFF — reopening + the conversation later does not resume warming), and pushes a surface update + (`enabled: false`, `nextWarmAt: null`) to subscribers. +- **Idempotent**: closing an idle/unknown conversation is a 200 with + `abortedTurn: false`. +- Browser/socket disconnect and `chat.unsubscribe` are UNCHANGED — they still never + touch the turn or the warming schedule (your "keep running when the window closes" + half is regression-tested). +Wire this into `store.closeTab()`; `fetch`/`sendBeacon` both fine (CORS already +allows POST). + +## CR-4d — initial `surface` echo ✅ (no backend change was needed) +HEAD already echoes `conversationId` on the initial `surface` reply (shipped in the +per-conversation-scoping commit; unit-tested). We live-probed BOTH stacks today — +:24205 and your :25205 — and the echo is present. Your probe most likely ran against +a `bin/up2` instance booted before that commit (up2 freezes code at boot). Re-run +`bin/up2` and your probe; if you still see a missing echo, send us the raw frame. + +## CR-1 — Loaded Extensions table ✅ +The surface now emits the "Loaded" count stat plus ONE custom field: + +```ts +{ kind: "custom", rendererId: "table", payload: { columns, rows } } +// columns: ["Name", "Version", "Trust", "Activation"] +// rows: one per loaded extension (ALL trust tiers), cell-for-cell aligned +``` + +Typed payload is exported as `TablePayload` (+ `TABLE_RENDERER_ID`) from +`@dispatch/surface-loaded-extensions` if you want to narrow instead of duck-typing. +Note: `Version` cells all read `0.0.0` — manifests are genuinely unversioned today +(the optional data-quality item from your handoff; not done). + +## CR-2 — catalog `scope` flag ✅ (`[email protected]`) +`SurfaceCatalogEntry` gains `scope?: "global" | "conversation"`. Emitted today: +`loaded-extensions` → `"global"`, `cache-warming` → `"conversation"`. Treat ABSENT as +conversation-scoped (conservative — your current always-send-conversationId policy +remains correct for both). You can now skip re-subscribing `scope: "global"` surfaces +on conversation switch. + +## Suggested FE follow-ups (from your own queue) +- Re-pin + re-mirror `.dispatch/{ui-contract,transport-contract}.reference.md`. +- Wire `POST /conversations/:id/close` into the tab-close path. +- Extend `probe-cache-warming.ts`: assert default-off, post-warm FUTURE `nextWarmAt`, + and (new) close → `abortedTurn` + `done.reason === "aborted"`. +- The "waiting…" guard for a past `nextWarmAt` can stay as a belt-and-braces guard + but should never trigger now; `nextWarmAt: null` while generating is the real state + to render. diff --git a/packages/cache-warming/src/extension.ts b/packages/cache-warming/src/extension.ts index 19f5130..920177f 100644 --- a/packages/cache-warming/src/extension.ts +++ b/packages/cache-warming/src/extension.ts @@ -1,6 +1,7 @@ import type { Extension, HostAPI, Manifest } from "@dispatch/kernel"; import { cacheWarmHandle, + conversationClosed, turnSettled, turnStarted, warmCompleted, @@ -81,6 +82,12 @@ export function activate(host: HostAPI): void { warmer.onWarmCompleted(payload); }); + host.on(conversationClosed, (payload) => { + // Sync part (cancel + disable) runs before the first await inside; + // only the settings persist is deferred. + void warmer.onConversationClosed(payload.conversationId); + }); + function getSpec(context?: SurfaceContext): SurfaceSpec { const convId = context?.conversationId; if (convId === undefined) { @@ -124,6 +131,7 @@ export function activate(host: HostAPI): void { id: "cache-warming", region: "side", title: "Cache Warming", + scope: "conversation", }, getSpec, invoke, diff --git a/packages/cache-warming/src/pure.test.ts b/packages/cache-warming/src/pure.test.ts index a503798..357fcb9 100644 --- a/packages/cache-warming/src/pure.test.ts +++ b/packages/cache-warming/src/pure.test.ts @@ -120,14 +120,14 @@ describe("parseSettings/serializeSettings round-trip", () => { expect(parsed).toEqual(original); }); - it("returns defaults for null input", () => { + it("returns defaults for null input (warming OFF by default — CR-4a)", () => { const parsed = parseSettings(null); - expect(parsed).toEqual({ enabled: true, intervalMs: 240_000 }); + expect(parsed).toEqual({ enabled: false, intervalMs: 240_000 }); }); - it("returns defaults for malformed JSON", () => { + it("returns defaults for malformed JSON (warming OFF by default — CR-4a)", () => { const parsed = parseSettings("not-json{{{"); - expect(parsed).toEqual({ enabled: true, intervalMs: 240_000 }); + expect(parsed).toEqual({ enabled: false, intervalMs: 240_000 }); }); it("clamps non-positive interval to MIN_INTERVAL_MS", () => { diff --git a/packages/cache-warming/src/pure.ts b/packages/cache-warming/src/pure.ts index c4cbe8a..2a2fd1b 100644 --- a/packages/cache-warming/src/pure.ts +++ b/packages/cache-warming/src/pure.ts @@ -86,16 +86,19 @@ const SETTINGS_KEY = "settings"; /** * Parse settings from a raw storage string. * Returns defaults if null or malformed. + * + * Warming defaults to OFF (CR-4a): a new conversation never schedules warms + * until the user explicitly opts in via the toggle. */ export function parseSettings(raw: string | null): ConversationSettings { - if (raw === null) return { enabled: true, intervalMs: DEFAULT_INTERVAL_MS }; + if (raw === null) return { enabled: false, intervalMs: DEFAULT_INTERVAL_MS }; try { const parsed: unknown = JSON.parse(raw); if (typeof parsed !== "object" || parsed === null) { - return { enabled: true, intervalMs: DEFAULT_INTERVAL_MS }; + return { enabled: false, intervalMs: DEFAULT_INTERVAL_MS }; } const obj = parsed as Record<string, unknown>; - const enabled = typeof obj.enabled === "boolean" ? obj.enabled : true; + const enabled = typeof obj.enabled === "boolean" ? obj.enabled : false; const rawInterval = obj.intervalMs; let intervalMs = DEFAULT_INTERVAL_MS; if (typeof rawInterval === "number" && Number.isFinite(rawInterval)) { @@ -104,7 +107,7 @@ export function parseSettings(raw: string | null): ConversationSettings { } return { enabled, intervalMs }; } catch { - return { enabled: true, intervalMs: DEFAULT_INTERVAL_MS }; + return { enabled: false, intervalMs: DEFAULT_INTERVAL_MS }; } } diff --git a/packages/cache-warming/src/warmer.test.ts b/packages/cache-warming/src/warmer.test.ts index a389ccb..98fa634 100644 --- a/packages/cache-warming/src/warmer.test.ts +++ b/packages/cache-warming/src/warmer.test.ts @@ -102,6 +102,7 @@ describe("CacheWarmer", () => { }; const warmer = createCacheWarmer(deps); + await warmer.setEnabled("conv-1", true); warmer.onTurnSettled("conv-1", {}); deps.timers.flush(); @@ -109,6 +110,24 @@ describe("CacheWarmer", () => { expect(warmCalls).toContain("conv-1"); }); + it("warming is OFF by default — a new conversation never arms on turnSettled (CR-4a)", async () => { + const deps = makeDeps(); + const warmCalls: string[] = []; + deps.warm = async (convId) => { + warmCalls.push(convId); + return WARM_RESULT; + }; + const warmer = createCacheWarmer(deps); + + expect(warmer.getState("conv-1").enabled).toBe(false); + warmer.onTurnSettled("conv-1", {}); + expect(warmer.getState("conv-1").nextWarmAt).toBeNull(); + deps.timers.flush(); + + await new Promise((r) => setTimeout(r, 10)); + expect(warmCalls).toHaveLength(0); + }); + it("cancels the timer on turnStarted (no warm while generating)", () => { const deps = makeDeps(); const warmCalls: string[] = []; @@ -152,6 +171,7 @@ describe("CacheWarmer", () => { const warmer = createCacheWarmer(deps); // Enable and settle to arm the timer + await warmer.setEnabled("conv-1", true); warmer.onTurnSettled("conv-1", {}); // Set interval to 30 seconds (30000ms) @@ -198,16 +218,30 @@ describe("CacheWarmer", () => { const deps = makeDeps(); const warmer = createCacheWarmer(deps); - // Default is enabled + // Default is DISABLED (opt-in per conversation) + expect(warmer.getState("conv-1").enabled).toBe(false); + + // Toggle on + await warmer.setEnabled("conv-1", true); expect(warmer.getState("conv-1").enabled).toBe(true); // Toggle off await warmer.setEnabled("conv-1", false); expect(warmer.getState("conv-1").enabled).toBe(false); + }); - // Toggle on - await warmer.setEnabled("conv-1", true); - expect(warmer.getState("conv-1").enabled).toBe(true); + it("re-enabling restores the PERSISTED interval into runtime state", async () => { + const deps = makeDeps(); + const warmer = createCacheWarmer(deps); + + await warmer.setIntervalMs("conv-1", 30_000); + await warmer.setEnabled("conv-1", false); + + // Fresh warmer over the same storage (simulates restart) + const warmer2 = createCacheWarmer(deps); + expect(warmer2.getState("conv-1").intervalMs).toBe(240_000); // runtime default + await warmer2.setEnabled("conv-1", true); + expect(warmer2.getState("conv-1").intervalMs).toBe(30_000); // persisted restored }); it("onSurfaceChange is called when settings change", async () => { @@ -226,7 +260,7 @@ describe("CacheWarmer", () => { expect(changeCount).toBe(2); }); - it("warmCompleted updates lastPct/lastExpectedPct/lastWarmAt and re-arms (nextWarmAt set), pushes onSurfaceChange", () => { + it("warmCompleted updates lastPct/lastExpectedPct/lastWarmAt and re-arms (nextWarmAt set), pushes onSurfaceChange", async () => { let changeCount = 0; let nowMs = 5000; const deps = makeDeps({ @@ -237,12 +271,14 @@ describe("CacheWarmer", () => { }); const warmer = createCacheWarmer(deps); + await warmer.setEnabled("conv-1", true); warmer.onTurnSettled("conv-1", {}); const stateBefore = warmer.getState("conv-1"); expect(stateBefore.lastPct).toBeNull(); expect(stateBefore.lastExpectedPct).toBeNull(); expect(stateBefore.lastWarmAt).toBeNull(); + const countBefore = changeCount; nowMs = 6000; warmer.onWarmCompleted({ conversationId: "conv-1", @@ -254,10 +290,72 @@ describe("CacheWarmer", () => { expect(state.lastExpectedPct).toBe(70); expect(state.lastWarmAt).toBe(6000); expect(state.nextWarmAt).not.toBeNull(); - expect(changeCount).toBe(1); + expect(changeCount).toBe(countBefore + 1); + }); + + it("the post-warm surface notify observes the NEW future nextWarmAt, not the consumed one (CR-4b)", async () => { + let nowMs = 5000; + const observed: (number | null)[] = []; + const deps = makeDeps({ now: () => nowMs }); + const warmer = createCacheWarmer(deps); + deps.onSurfaceChange = () => { + observed.push(warmer.getState("conv-1").nextWarmAt); + }; + + await warmer.setEnabled("conv-1", true); + warmer.onTurnSettled("conv-1", {}); + observed.length = 0; + + nowMs = 9000; + warmer.onWarmCompleted({ conversationId: "conv-1", usage: WARM_RESULT }); + + // Exactly one notify, and AT NOTIFY TIME the state already carried the + // re-armed, future fire time (lastWarmAt + interval) — never the past one. + expect(observed).toEqual([9000 + 240_000]); + }); + + it("the post-warm surface notify carries nextWarmAt: null when warming was disabled mid-flight", async () => { + let nowMs = 5000; + const observed: (number | null)[] = []; + const deps = makeDeps({ now: () => nowMs }); + const warmer = createCacheWarmer(deps); + deps.onSurfaceChange = () => { + observed.push(warmer.getState("conv-1").nextWarmAt); + }; + + await warmer.setEnabled("conv-1", true); + warmer.onTurnSettled("conv-1", {}); + await warmer.setEnabled("conv-1", false); + observed.length = 0; + + nowMs = 9000; + warmer.onWarmCompleted({ conversationId: "conv-1", usage: WARM_RESULT }); + + // Not re-armed (disabled) — the notify must NOT carry a stale past value. + expect(observed).toEqual([null]); }); - it("a warm that completes while the conversation is active is dropped (no update, no re-arm)", () => { + it("onTurnSettled pushes a surface notify carrying the fresh schedule (CR-4b post-seal path)", async () => { + let nowMs = 5000; + const observed: (number | null)[] = []; + const deps = makeDeps({ now: () => nowMs }); + const warmer = createCacheWarmer(deps); + deps.onSurfaceChange = () => { + observed.push(warmer.getState("conv-1").nextWarmAt); + }; + + await warmer.setEnabled("conv-1", true); + warmer.onTurnStarted("conv-1"); + observed.length = 0; + + nowMs = 12_000; + warmer.onTurnSettled("conv-1", {}); + + // At notify time the new schedule is already armed. + expect(observed).toEqual([12_000 + 240_000]); + }); + + it("a warm that completes while the conversation is active is dropped (no update, no re-arm)", async () => { let changeCount = 0; const deps = makeDeps({ onSurfaceChange: () => { @@ -267,9 +365,11 @@ describe("CacheWarmer", () => { }); const warmer = createCacheWarmer(deps); + await warmer.setEnabled("conv-1", true); warmer.onTurnSettled("conv-1", {}); warmer.onTurnStarted("conv-1"); + const countBefore = changeCount; warmer.onWarmCompleted({ conversationId: "conv-1", usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 800, cacheWriteTokens: 0 }, @@ -279,7 +379,7 @@ describe("CacheWarmer", () => { expect(state.lastPct).toBeNull(); expect(state.lastWarmAt).toBeNull(); expect(state.nextWarmAt).toBeNull(); - expect(changeCount).toBe(0); + expect(changeCount).toBe(countBefore); }); it("nextWarmAt is set when armed and null when disabled or active", async () => { @@ -290,7 +390,8 @@ describe("CacheWarmer", () => { // Before any event — not armed expect(warmer.getState("conv-1").nextWarmAt).toBeNull(); - // After turnSettled — armed with nextWarmAt + // After enable + turnSettled — armed with nextWarmAt + await warmer.setEnabled("conv-1", true); nowMs = 2000; warmer.onTurnSettled("conv-1", {}); const stateArmed = warmer.getState("conv-1"); @@ -301,12 +402,13 @@ describe("CacheWarmer", () => { expect(warmer.getState("conv-1").nextWarmAt).toBeNull(); // After disabling — null + await warmer.setEnabled("conv-2", true); warmer.onTurnSettled("conv-2", {}); await warmer.setEnabled("conv-2", false); expect(warmer.getState("conv-2").nextWarmAt).toBeNull(); }); - it("a manual warm (warmCompleted for a conversation) resets the timer + refreshes the surface", () => { + it("a manual warm (warmCompleted for a conversation) resets the timer + refreshes the surface", async () => { let changeCount = 0; let nowMs = 5000; const deps = makeDeps({ @@ -317,12 +419,14 @@ describe("CacheWarmer", () => { }); const warmer = createCacheWarmer(deps); - // Settle to arm the timer + // Enable + settle to arm the timer + await warmer.setEnabled("conv-1", true); warmer.onTurnSettled("conv-1", {}); const armed = warmer.getState("conv-1"); expect(armed.nextWarmAt).toBe(5000 + 240_000); // Simulate a manual warm completing at t=8000 + const countBefore = changeCount; nowMs = 8000; warmer.onWarmCompleted({ conversationId: "conv-1", @@ -335,7 +439,7 @@ describe("CacheWarmer", () => { expect(after.lastWarmAt).toBe(8000); // Timer should be re-armed with new nextWarmAt expect(after.nextWarmAt).toBe(8000 + 240_000); - expect(changeCount).toBe(1); + expect(changeCount).toBe(countBefore + 1); }); it("stores lastPct from the warmCompleted event", () => { @@ -367,11 +471,12 @@ describe("CacheWarmer", () => { expect(state.lastExpectedPct).toBe(70); }); - it("re-arms timer after warmCompleted", () => { + it("re-arms timer after warmCompleted", async () => { let nowMs = 1000; const deps = makeDeps({ now: () => nowMs }); const warmer = createCacheWarmer(deps); + await warmer.setEnabled("conv-1", true); warmer.onTurnSettled("conv-1", {}); const firstNextWarmAt = warmer.getState("conv-1").nextWarmAt; @@ -387,6 +492,61 @@ describe("CacheWarmer", () => { expect(after.nextWarmAt).toBe(5000 + 240_000); }); + it("onConversationClosed cancels the schedule, disables warming, persists OFF, and notifies (CR-4c)", async () => { + let changeCount = 0; + const deps = makeDeps({ + onSurfaceChange: () => { + changeCount++; + }, + now: () => 5000, + }); + const warmCalls: string[] = []; + deps.warm = async (convId) => { + warmCalls.push(convId); + return WARM_RESULT; + }; + const warmer = createCacheWarmer(deps); + + await warmer.setEnabled("conv-1", true); + warmer.onTurnSettled("conv-1", {}); + expect(warmer.getState("conv-1").nextWarmAt).not.toBeNull(); + + const countBefore = changeCount; + await warmer.onConversationClosed("conv-1"); + + const state = warmer.getState("conv-1"); + expect(state.enabled).toBe(false); + expect(state.nextWarmAt).toBeNull(); + expect(changeCount).toBe(countBefore + 1); + + // The pending timer is cancelled — flushing fires nothing. + deps.timers.flush(); + await new Promise((r) => setTimeout(r, 10)); + expect(warmCalls).toHaveLength(0); + + // Persisted OFF: a fresh warmer over the same storage stays disabled on enable-read. + const raw = await deps.storage.get("settings:conv-1"); + expect(raw).not.toBeNull(); + expect(JSON.parse(raw as string).enabled).toBe(false); + }); + + it("a turnSettled racing a close does not re-arm (enabled flipped synchronously)", async () => { + const deps = makeDeps({ now: () => 5000 }); + const warmer = createCacheWarmer(deps); + + await warmer.setEnabled("conv-1", true); + warmer.onTurnStarted("conv-1"); + + // Close while "generating" — do NOT await: the sync part must suffice. + const closed = warmer.onConversationClosed("conv-1"); + // The turn settles immediately after the close was issued. + warmer.onTurnSettled("conv-1", {}); + + expect(warmer.getState("conv-1").enabled).toBe(false); + expect(warmer.getState("conv-1").nextWarmAt).toBeNull(); + await closed; + }); + it("the per-conversation spec includes a cache-retention stat", () => { const deps = makeDeps({ now: () => 5000 }); const warmer = createCacheWarmer(deps); diff --git a/packages/cache-warming/src/warmer.ts b/packages/cache-warming/src/warmer.ts index d77bfe0..6ad5c33 100644 --- a/packages/cache-warming/src/warmer.ts +++ b/packages/cache-warming/src/warmer.ts @@ -30,9 +30,17 @@ export interface CacheWarmer { /** Handle a turnSettled event — mark idle, store context, arm timer if enabled. */ readonly onTurnSettled: (conversationId: string, ctx: ConversationContext) => void; - /** Handle a warmCompleted event — process warm result, update surface, re-arm timer. */ + /** Handle a warmCompleted event — process warm result, re-arm timer, update surface. */ readonly onWarmCompleted: (payload: WarmCompletedPayload) => void; + /** + * Handle an explicit "conversation closed" (tab close ≠ disconnect): stop the + * schedule and persist warming OFF for the conversation. The enabled flip is + * applied to in-memory state synchronously (so a turnSettled racing this close + * can never re-arm); only the settings persist is awaited. + */ + readonly onConversationClosed: (conversationId: string) => Promise<void>; + /** Get the current state for a conversation (for surface rendering). */ readonly getState: (conversationId: string) => ConversationState; @@ -63,8 +71,10 @@ export interface CacheWarmerDeps { readonly onSurfaceChange: () => void; } +// Warming is OPT-IN per conversation (CR-4a): default OFF, no warm scheduled +// until the user enables it. const DEFAULT_STATE: ConversationState = { - enabled: true, + enabled: false, intervalMs: DEFAULT_INTERVAL_MS, active: false, lastPct: null, @@ -171,6 +181,9 @@ export function createCacheWarmer(deps: CacheWarmerDeps): CacheWarmer { deps.logger.debug("cache-warming: turn started", { conversationId }); mergeState(conversationId, { active: true }); cancelTimer(conversationId); + // Push the cleared schedule (nextWarmAt: null) so subscribers see + // "no warm scheduled" while the turn is generating. + deps.onSurfaceChange(); }, onTurnSettled(conversationId, ctx) { @@ -182,6 +195,9 @@ export function createCacheWarmer(deps: CacheWarmerDeps): CacheWarmer { if (state.enabled) { armTimer(conversationId); } + // Push the post-seal reschedule so subscribers get the NEW (future) + // nextWarmAt instead of a stale pre-turn one (CR-4b). + deps.onSurfaceChange(); }, onWarmCompleted(payload) { @@ -204,29 +220,51 @@ export function createCacheWarmer(deps: CacheWarmerDeps): CacheWarmer { lastPct: pct, lastExpectedPct: expectedPct, lastWarmAt: nowMs, + // The just-fired schedule is consumed; cleared here so a non-re-armed + // path never reports a stale (past) nextWarmAt. + nextWarmAt: null, }); + + // Re-arm the automatic timer if enabled and not active — BEFORE the + // surface notify, so the pushed update carries the NEW future nextWarmAt + // instead of the fire time of the warm that just completed (CR-4b). + const updated = getState(conversationId); + if (updated.enabled && !updated.active) { + armTimer(conversationId); + } + deps.onSurfaceChange(); deps.logger.debug("cache-warming: warm complete", { conversationId, pct, expectedPct, }); - - // Re-arm the automatic timer if enabled and not active - const updated = getState(conversationId); - if (updated.enabled && !updated.active) { - armTimer(conversationId); - } }, getState, getContext, + async onConversationClosed(conversationId) { + deps.logger.debug("cache-warming: conversation closed", { conversationId }); + // Synchronous part FIRST: stop the schedule + flip enabled in memory so + // any racing turnSettled sees enabled=false and never re-arms. + cancelTimer(conversationId); + const updated = mergeState(conversationId, { enabled: false }); + deps.onSurfaceChange(); + // Persist OFF so a reopened conversation stays opt-in. + await persistSettings(conversationId, { + enabled: false, + intervalMs: updated.intervalMs, + }); + }, + async setEnabled(conversationId, enabled) { const settings = await loadSettings(conversationId); const updated = { ...settings, enabled }; await persistSettings(conversationId, updated); - mergeState(conversationId, { enabled }); + // Merge the FULL settings (not just `enabled`) so re-enabling restores + // the persisted interval into runtime state. + mergeState(conversationId, updated); if (enabled) { const state = getState(conversationId); diff --git a/packages/session-orchestrator/src/extension.ts b/packages/session-orchestrator/src/extension.ts index 4175b0c..781164a 100644 --- a/packages/session-orchestrator/src/extension.ts +++ b/packages/session-orchestrator/src/extension.ts @@ -25,6 +25,7 @@ export const manifest: Manifest = { "session-orchestrator/turn-started", "session-orchestrator/turn-settled", "session-orchestrator/warm-completed", + "session-orchestrator/conversation-closed", ], }, }; diff --git a/packages/session-orchestrator/src/index.ts b/packages/session-orchestrator/src/index.ts index 8bc99d2..b99c15e 100644 --- a/packages/session-orchestrator/src/index.ts +++ b/packages/session-orchestrator/src/index.ts @@ -1,6 +1,8 @@ export { extension, manifest } from "./extension.js"; export { + type ConversationClosedPayload, cacheWarmHandle, + conversationClosed, createSessionOrchestrator, createWarmService, type SessionOrchestrator, diff --git a/packages/session-orchestrator/src/orchestrator.test.ts b/packages/session-orchestrator/src/orchestrator.test.ts index c3fbbc8..799fef5 100644 --- a/packages/session-orchestrator/src/orchestrator.test.ts +++ b/packages/session-orchestrator/src/orchestrator.test.ts @@ -2162,3 +2162,97 @@ describe("user-message event", () => { expect(tm.steps[0]?.usage.outputTokens).toBe(5); }); }); + +describe("closeConversation (CR-4c)", () => { + it("aborts an in-flight turn: done.reason 'aborted', partial messages persisted, turn seals", async () => { + const store = createInMemoryStore(); + let releaseStream: (() => void) | undefined; + const barrier = new Promise<void>((resolve) => { + releaseStream = resolve; + }); + const provider: ProviderContract = { + id: "fake", + stream() { + return (async function* () { + yield { type: "text-delta", delta: "Hello" } as ProviderEvent; + await barrier; + yield { type: "text-delta", delta: " world" } as ProviderEvent; + yield { type: "finish", reason: "stop" } as ProviderEvent; + })(); + }, + }; + + const emittedHooks: string[] = []; + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn, + emit: (hook) => { + emittedHooks.push(hook.id); + }, + }); + + const events: AgentEvent[] = []; + let resolveSealed: (() => void) | undefined; + const sealed = new Promise<void>((resolve) => { + resolveSealed = resolve; + }); + let resolveFirstDelta: (() => void) | undefined; + const firstDelta = new Promise<void>((resolve) => { + resolveFirstDelta = resolve; + }); + orchestrator.subscribe("conv-close", (e) => { + events.push(e); + if (e.type === "text-delta") resolveFirstDelta?.(); + if (e.type === "turn-sealed") resolveSealed?.(); + }); + + orchestrator.startTurn({ conversationId: "conv-close", text: "Hi" }); + await firstDelta; + + const result = orchestrator.closeConversation("conv-close"); + expect(result.abortedTurn).toBe(true); + expect(emittedHooks).toContain("session-orchestrator/conversation-closed"); + + releaseStream?.(); + await sealed; + + const done = events.find((e): e is Extract<AgentEvent, { type: "done" }> => e.type === "done"); + expect(done?.reason).toBe("aborted"); + expect(orchestrator.isActive("conv-close")).toBe(false); + + // Durability: the partial turn persisted normally (user msg + partial reply). + const persisted = store.data.get("conv-close") ?? []; + expect(persisted.length).toBeGreaterThanOrEqual(1); + expect(persisted[0]?.role).toBe("user"); + }); + + it("is idempotent on an idle/unknown conversation: abortedTurn false, hook still emitted", () => { + const store = createInMemoryStore(); + const emitted: Array<{ hook: string; payload: unknown }> = []; + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => createFakeProvider([]), + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn, + emit: (hook, payload) => { + emitted.push({ hook: hook.id, payload }); + }, + }); + + const result = orchestrator.closeConversation("conv-never-seen"); + expect(result.abortedTurn).toBe(false); + expect(emitted).toEqual([ + { + hook: "session-orchestrator/conversation-closed", + payload: { conversationId: "conv-never-seen" }, + }, + ]); + + // Closing again is still safe. + expect(orchestrator.closeConversation("conv-never-seen").abortedTurn).toBe(false); + }); +}); diff --git a/packages/session-orchestrator/src/orchestrator.ts b/packages/session-orchestrator/src/orchestrator.ts index d55114b..b0a1083 100644 --- a/packages/session-orchestrator/src/orchestrator.ts +++ b/packages/session-orchestrator/src/orchestrator.ts @@ -36,6 +36,8 @@ export type TurnEventListener = (event: AgentEvent) => void; interface ActiveTurn { buffer: AgentEvent[]; turnId: string; + /** Aborts this turn's kernel runTurn (closeConversation). */ + controller: AbortController; } // --- Lifecycle event hooks --- @@ -55,6 +57,19 @@ export const turnStarted: EventHookDescriptor<TurnLifecyclePayload> = export const turnSettled: EventHookDescriptor<TurnLifecyclePayload> = defineEventHook<TurnLifecyclePayload>("session-orchestrator/turn-settled"); +/** Payload for the conversationClosed bus event. */ +export interface ConversationClosedPayload { + readonly conversationId: string; +} + +/** + * Fired when a client EXPLICITLY closes a conversation (tab close — NOT a mere + * disconnect). Consumers stop per-conversation background work (e.g. cache-warming + * disables its schedule). Emitted by `SessionOrchestrator.closeConversation`. + */ +export const conversationClosed: EventHookDescriptor<ConversationClosedPayload> = + defineEventHook<ConversationClosedPayload>("session-orchestrator/conversation-closed"); + /** Payload for the warmCompleted bus event. */ export interface WarmCompletedPayload { readonly conversationId: string; @@ -89,6 +104,15 @@ export interface SessionOrchestrator { startTurn(input: StartTurnInput): StartTurnResult; subscribe(conversationId: string, listener: TurnEventListener): () => void; isActive(conversationId: string): boolean; + /** + * Explicitly close a conversation (the user closed its tab — distinct from a + * socket disconnect, which never touches the turn): aborts any in-flight turn + * (the kernel finishes with `finishReason: "aborted"`, partial messages are + * persisted and the turn seals normally) and emits the `conversationClosed` + * hook so per-conversation background work (cache-warming) stops. + * Idempotent — closing an idle/unknown conversation just emits the hook. + */ + closeConversation(conversationId: string): { readonly abortedTurn: boolean }; handleMessage(input: { conversationId: string; text: string; @@ -159,7 +183,8 @@ export function createSessionOrchestrator( cwd: string | undefined, ): void { const turnId = generateTurnId(); - activeTurns.set(conversationId, { buffer: [], turnId }); + const controller = new AbortController(); + activeTurns.set(conversationId, { buffer: [], turnId, controller }); activeConversations.add(conversationId); emitToHub(conversationId, { type: "user-message", conversationId, turnId, text }); @@ -233,6 +258,7 @@ export function createSessionOrchestrator( emit: emitAndAccumulate, conversationId, turnId, + signal: controller.signal, ...(modelOverride !== undefined ? { providerOpts: { model: modelOverride } satisfies ProviderStreamOptions } : {}), @@ -310,6 +336,16 @@ export function createSessionOrchestrator( return activeTurns.has(conversationId); }, + closeConversation(conversationId) { + const turn = activeTurns.get(conversationId); + const abortedTurn = turn !== undefined; + if (turn !== undefined) { + turn.controller.abort(); + } + deps.emit?.(conversationClosed, { conversationId }); + return { abortedTurn }; + }, + async handleMessage({ conversationId, text, onEvent, modelName, cwd }) { const turnInput: StartTurnInput = { conversationId, diff --git a/packages/surface-loaded-extensions/src/extension.ts b/packages/surface-loaded-extensions/src/extension.ts index abef4b6..20abdec 100644 --- a/packages/surface-loaded-extensions/src/extension.ts +++ b/packages/surface-loaded-extensions/src/extension.ts @@ -27,6 +27,7 @@ export function createLoadedExtensionsExtension(): Extension { id: "loaded-extensions", region: "side", title: "Loaded Extensions", + scope: "global", }, getSpec() { return buildLoadedExtensionsSpec(host.getExtensions()); diff --git a/packages/surface-loaded-extensions/src/index.ts b/packages/surface-loaded-extensions/src/index.ts index bc10dc5..ae11e02 100644 --- a/packages/surface-loaded-extensions/src/index.ts +++ b/packages/surface-loaded-extensions/src/index.ts @@ -1,2 +1,2 @@ export { createLoadedExtensionsExtension, manifest } from "./extension.js"; -export { buildLoadedExtensionsSpec } from "./spec.js"; +export { buildLoadedExtensionsSpec, TABLE_RENDERER_ID, type TablePayload } from "./spec.js"; diff --git a/packages/surface-loaded-extensions/src/spec.test.ts b/packages/surface-loaded-extensions/src/spec.test.ts index 9c1aa6a..bc31b9e 100644 --- a/packages/surface-loaded-extensions/src/spec.test.ts +++ b/packages/surface-loaded-extensions/src/spec.test.ts @@ -1,63 +1,78 @@ import type { Manifest } from "@dispatch/kernel"; -import type { StatField } from "@dispatch/ui-contract"; +import type { CustomField, StatField } from "@dispatch/ui-contract"; import { describe, expect, it } from "vitest"; -import { buildLoadedExtensionsSpec } from "./spec.js"; +import { buildLoadedExtensionsSpec, TABLE_RENDERER_ID, type TablePayload } from "./spec.js"; -function fakeManifest(id: string, name: string, version: string): Manifest { +function fakeManifest( + id: string, + name: string, + version: string, + extra: Partial<Manifest> = {}, +): Manifest { return { id, name, version, apiVersion: "^0.1.0", trust: "bundled", + ...extra, }; } +function tablePayload(field: unknown): TablePayload { + const custom = field as CustomField; + expect(custom.kind).toBe("custom"); + expect(custom.rendererId).toBe(TABLE_RENDERER_ID); + return custom.payload as TablePayload; +} + describe("buildLoadedExtensionsSpec", () => { - it("returns a count stat of '0' and no extension stats for empty manifests", () => { + it("returns a count stat of '0' and an empty table for empty manifests", () => { const spec = buildLoadedExtensionsSpec([]); expect(spec.id).toBe("loaded-extensions"); expect(spec.region).toBe("side"); expect(spec.title).toBe("Loaded Extensions"); - expect(spec.fields).toHaveLength(1); + expect(spec.fields).toHaveLength(2); expect(spec.fields[0]).toEqual({ kind: "stat", label: "Loaded", value: "0", }); + expect(tablePayload(spec.fields[1]).rows).toEqual([]); }); - it("returns a count stat plus one stat per manifest in order", () => { + it("returns a count stat plus ONE table field with a row per manifest (CR-1)", () => { const manifests = [ fakeManifest("alpha", "Alpha", "1.0.0"), - fakeManifest("beta", "Beta", "2.3.1"), - fakeManifest("gamma", "Gamma", "0.5.0"), + fakeManifest("beta", "Beta", "2.3.1", { trust: "external", activation: "lazy" }), + fakeManifest("gamma", "Gamma", "0.5.0", { trust: "local" }), ]; const spec = buildLoadedExtensionsSpec(manifests); - expect(spec.fields).toHaveLength(4); + expect(spec.fields).toHaveLength(2); expect(spec.fields[0]).toEqual({ kind: "stat", label: "Loaded", value: "3", }); - expect(spec.fields[1]).toEqual({ - kind: "stat", - label: "Alpha", - value: "1.0.0", - }); - expect(spec.fields[2]).toEqual({ - kind: "stat", - label: "Beta", - value: "2.3.1", - }); - expect(spec.fields[3]).toEqual({ - kind: "stat", - label: "Gamma", - value: "0.5.0", - }); + + const payload = tablePayload(spec.fields[1]); + expect(payload.columns).toEqual(["Name", "Version", "Trust", "Activation"]); + expect(payload.rows).toEqual([ + ["Alpha", "1.0.0", "bundled", "eager"], + ["Beta", "2.3.1", "external", "lazy"], + ["Gamma", "0.5.0", "local", "eager"], + ]); + }); + + it("every row aligns cell-for-cell to the columns", () => { + const spec = buildLoadedExtensionsSpec([fakeManifest("a", "A", "1.0.0")]); + const payload = tablePayload(spec.fields[1]); + for (const row of payload.rows) { + expect(row).toHaveLength(payload.columns.length); + } }); it("preserves input order of manifests", () => { @@ -68,8 +83,8 @@ describe("buildLoadedExtensionsSpec", () => { const spec = buildLoadedExtensionsSpec(manifests); - expect((spec.fields[1] as StatField).label).toBe("Z Last"); - expect((spec.fields[2] as StatField).label).toBe("A First"); + const payload = tablePayload(spec.fields[1]); + expect(payload.rows.map((r) => r[0])).toEqual(["Z Last", "A First"]); }); it("sets the surface id, region, and title correctly", () => { @@ -80,15 +95,14 @@ describe("buildLoadedExtensionsSpec", () => { expect(spec.title).toBe("Loaded Extensions"); }); - it("uses manifest.name as label and manifest.version as value", () => { - const manifests = [fakeManifest("my-ext", "My Extension", "3.2.1")]; - - const spec = buildLoadedExtensionsSpec(manifests); + it("defaults a missing activation to 'eager' (the declared manifest default)", () => { + const spec = buildLoadedExtensionsSpec([fakeManifest("my-ext", "My Extension", "3.2.1")]); + const payload = tablePayload(spec.fields[1]); + expect(payload.rows[0]).toEqual(["My Extension", "3.2.1", "bundled", "eager"]); + }); - expect(spec.fields[1]).toEqual({ - kind: "stat", - label: "My Extension", - value: "3.2.1", - }); + it("the count stat remains a plain stat (graceful-skip clients still see it)", () => { + const spec = buildLoadedExtensionsSpec([fakeManifest("a", "A", "1.0.0")]); + expect((spec.fields[0] as StatField).kind).toBe("stat"); }); }); diff --git a/packages/surface-loaded-extensions/src/spec.ts b/packages/surface-loaded-extensions/src/spec.ts index bd3dd56..72e8d41 100644 --- a/packages/surface-loaded-extensions/src/spec.ts +++ b/packages/surface-loaded-extensions/src/spec.ts @@ -1,25 +1,51 @@ import type { Manifest } from "@dispatch/kernel"; -import type { StatField, SurfaceSpec } from "@dispatch/ui-contract"; +import type { CustomField, StatField, SurfaceSpec } from "@dispatch/ui-contract"; + +/** + * The typed payload of the `rendererId: "table"` custom field (CR-1). Exported + * so a client renderer narrows `CustomField.payload` via this symbol instead of + * a blind `unknown`. Each row aligns cell-for-cell to `columns`. + */ +export interface TablePayload { + readonly columns: readonly string[]; + readonly rows: ReadonlyArray<ReadonlyArray<string | number | boolean>>; +} + +/** The renderer id clients dispatch on for the extensions table. */ +export const TABLE_RENDERER_ID = "table"; /** * Pure core — builds the SurfaceSpec for the loaded-extensions surface. * Zero I/O, zero ambient state. Decision logic only: input → output. + * + * Emits a "Loaded" count stat plus ONE `custom`/"table" field enumerating EVERY + * loaded extension (all trust tiers) as real columns (CR-1). A client without a + * "table" renderer gracefully skips the field and still sees the count. */ export function buildLoadedExtensionsSpec(manifests: readonly Manifest[]): SurfaceSpec { - const fields: StatField[] = [{ kind: "stat", label: "Loaded", value: String(manifests.length) }]; + const count: StatField = { kind: "stat", label: "Loaded", value: String(manifests.length) }; + + const payload: TablePayload = { + columns: ["Name", "Version", "Trust", "Activation"], + rows: manifests.map((manifest) => [ + manifest.name, + manifest.version, + manifest.trust, + // Activation is optional in the manifest; "eager" is the declared default. + manifest.activation ?? "eager", + ]), + }; - for (const manifest of manifests) { - fields.push({ - kind: "stat", - label: manifest.name, - value: manifest.version, - }); - } + const table: CustomField = { + kind: "custom", + rendererId: TABLE_RENDERER_ID, + payload, + }; return { id: "loaded-extensions", region: "side", title: "Loaded Extensions", - fields, + fields: [count, table], }; } diff --git a/packages/transport-contract/package.json b/packages/transport-contract/package.json index 6a7a946..2e9d586 100644 --- a/packages/transport-contract/package.json +++ b/packages/transport-contract/package.json @@ -1,6 +1,6 @@ { "name": "@dispatch/transport-contract", - "version": "0.8.0", + "version": "0.9.0", "type": "module", "private": true, "main": "dist/index.js", diff --git a/packages/transport-contract/src/index.ts b/packages/transport-contract/src/index.ts index 8283cea..b000147 100644 --- a/packages/transport-contract/src/index.ts +++ b/packages/transport-contract/src/index.ts @@ -167,6 +167,28 @@ export interface SetCwdRequest { readonly cwd: string; } +// ─── Conversation close (explicit tab close) ────────────────────────────────── + +/** + * Response of `POST /conversations/:id/close` (no request body). + * + * The EXPLICIT "the user closed this conversation's tab" affordance — distinct + * from a socket disconnect or `chat.unsubscribe`, which deliberately never touch + * the turn or the warming schedule. Closing: + * 1. aborts any in-flight turn (the kernel stops at the next event boundary, + * partial messages are persisted, and the turn SEALS normally with + * `finishReason: "aborted"` — watchers see `done` + `turn-sealed`), and + * 2. stops + disables cache-warming for the conversation (persisted OFF, so a + * reopened conversation stays opt-in). + * Idempotent: closing an idle or unknown conversation succeeds with + * `abortedTurn: false`. + */ +export interface CloseConversationResponse { + readonly conversationId: string; + /** True when an in-flight turn existed and was aborted by this close. */ + readonly abortedTurn: boolean; +} + // ─── Per-conversation LSP status ────────────────────────────────────────────── /** The connection state of a single language server for a workspace. */ diff --git a/packages/transport-http/src/app.test.ts b/packages/transport-http/src/app.test.ts index 32e9689..c26a868 100644 --- a/packages/transport-http/src/app.test.ts +++ b/packages/transport-http/src/app.test.ts @@ -115,6 +115,9 @@ function createFakeOrchestrator(events: AgentEvent[]): SessionOrchestrator { isActive() { return false; }, + closeConversation() { + return { abortedTurn: false }; + }, async handleMessage(input) { for (const event of events) { input.onEvent(event); @@ -142,6 +145,9 @@ function createCapturingOrchestrator(): SessionOrchestrator & { isActive() { return false; }, + closeConversation() { + return { abortedTurn: false }; + }, async handleMessage(input) { state.received = input; }, @@ -159,6 +165,9 @@ function createThrowingOrchestrator(error: Error): SessionOrchestrator { isActive() { return false; }, + closeConversation() { + return { abortedTurn: false }; + }, async handleMessage() { throw error; }, @@ -1088,6 +1097,43 @@ describe("throughput recording + GET /metrics/throughput", () => { }); }); +describe("POST /conversations/:id/close", () => { + it("closes via the orchestrator and returns CloseConversationResponse", async () => { + const closeCalls: string[] = []; + const orchestrator: SessionOrchestrator = { + ...createFakeOrchestrator([]), + closeConversation(conversationId) { + closeCalls.push(conversationId); + return { abortedTurn: true }; + }, + }; + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator, + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv-9/close", { method: "POST" }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ conversationId: "conv-9", abortedTurn: true }); + expect(closeCalls).toEqual(["conv-9"]); + }); + + it("reports abortedTurn false for an idle conversation", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv-idle/close", { method: "POST" }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ conversationId: "conv-idle", abortedTurn: false }); + }); +}); + describe("GET /conversations/:id/cwd", () => { it("returns null when unset", async () => { const app = createApp({ diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts index 7778bad..11d2850 100644 --- a/packages/transport-http/src/app.ts +++ b/packages/transport-http/src/app.ts @@ -1,5 +1,6 @@ import type { AgentEvent, Logger } from "@dispatch/kernel"; import type { + CloseConversationResponse, ConversationHistoryResponse, ConversationMetricsResponse, CwdResponse, @@ -319,6 +320,14 @@ export function createApp(opts: CreateServerOptions): Hono { } }); + app.post("/conversations/:id/close", (c) => { + const conversationId = c.req.param("id"); + const { abortedTurn } = opts.orchestrator.closeConversation(conversationId); + log.info("conversations: closed", { conversationId, abortedTurn }); + const body: CloseConversationResponse = { conversationId, abortedTurn }; + return c.json(body, 200); + }); + app.get("/conversations/:id/cwd", async (c) => { const conversationId = c.req.param("id"); try { diff --git a/packages/transport-http/src/extension.ts b/packages/transport-http/src/extension.ts index 6c988a5..33b9990 100644 --- a/packages/transport-http/src/extension.ts +++ b/packages/transport-http/src/extension.ts @@ -28,6 +28,7 @@ export const manifest: Manifest = { "/chat", "/chat/warm", "/conversations/:id", + "/conversations/:id/close", "/conversations/:id/cwd", "/conversations/:id/lsp", "/health", diff --git a/packages/transport-http/src/server.bun.test.ts b/packages/transport-http/src/server.bun.test.ts index 8a719c0..a465243 100644 --- a/packages/transport-http/src/server.bun.test.ts +++ b/packages/transport-http/src/server.bun.test.ts @@ -64,6 +64,9 @@ function fakeOrchestrator(): SessionOrchestrator { isActive() { return false; }, + closeConversation() { + return { abortedTurn: false }; + }, async handleMessage() {}, }; } diff --git a/packages/transport-ws/src/server.bun.test.ts b/packages/transport-ws/src/server.bun.test.ts index 0f5ce72..43d008a 100644 --- a/packages/transport-ws/src/server.bun.test.ts +++ b/packages/transport-ws/src/server.bun.test.ts @@ -147,6 +147,9 @@ function fakeOrchestrator(opts?: FakeOrchestratorOpts): SessionOrchestrator & { isActive(conversationId) { return listeners.has(conversationId); }, + closeConversation() { + return { abortedTurn: false }; + }, async handleMessage(_input) { // Not used by the new transport-ws, but kept for interface compat. }, @@ -187,6 +190,9 @@ function fakeOrchestratorWithBroadcast(): SessionOrchestrator & { isActive(conversationId) { return listeners.has(conversationId); }, + closeConversation() { + return { abortedTurn: false }; + }, async handleMessage(_input) {}, }; } diff --git a/packages/ui-contract/package.json b/packages/ui-contract/package.json index e7dd93f..3fef8a5 100644 --- a/packages/ui-contract/package.json +++ b/packages/ui-contract/package.json @@ -1,6 +1,6 @@ { "name": "@dispatch/ui-contract", - "version": "0.1.0", + "version": "0.2.0", "type": "module", "private": true, "main": "dist/index.js", diff --git a/packages/ui-contract/src/index.ts b/packages/ui-contract/src/index.ts index a7943aa..16d13c8 100644 --- a/packages/ui-contract/src/index.ts +++ b/packages/ui-contract/src/index.ts @@ -139,6 +139,14 @@ export interface SurfaceCatalogEntry { readonly id: string; readonly region: Region; readonly title: string; + /** + * Whether the surface's spec/values differ per conversation ("conversation") + * or are app-wide ("global"). A client may skip re-subscribing GLOBAL surfaces + * on a conversation switch (they ignore `conversationId`). Optional + additive: + * when absent, a client should assume conversation-scoped (the conservative + * "always send the focused conversationId" policy still works for both). + */ + readonly scope?: "global" | "conversation"; } /** The surface catalog: the list of available surfaces a client can choose to show. */ @@ -297,8 +297,31 @@ outward stream/buffer. FE courier: `frontend-cr3-user-message-handoff.md`. (user-message now precedes turn-start). - **LIVE-VERIFIED vs flash:** a watcher that never sent receives `user-message` (correct text) as its FIRST `chat.delta`, before `turn-sealed`, then the streaming reply. `RESULT: OK`. -- **Process note:** implemented directly by the orchestrator (user directive: "do implementations - yourself going forward") rather than via a summoned owner-agent. +- **Process note:** implemented directly by the orchestrator as a one-off (user-approved at the + time). SUPERSEDED — the user has since confirmed the ORCHESTRATOR.md model governs: the + orchestrator summons owner-agents and does not write feature code itself. + +## Cache warming — FE CR-4 lifecycle + CR-1 extensions table + CR-2 catalog scope (DONE) +FE courier in: `../dispatch-web/backend-handoff-cache-warming.md` (+ CR-1/CR-2 from their living +`backend-handoff.md`). Courier out: `frontend-cache-warming-lifecycle-handoff.md`. Full report: +`reports/cr4-cache-warming-lifecycle.md`. +- **CR-4a:** warming defaults OFF (opt-in per conversation) — `parseSettings` + `DEFAULT_STATE`; + re-enabling now restores the persisted interval. Known gap (pre-existing, fail-safe): no boot + hydration of persisted opt-in across server restarts. +- **CR-4b:** post-warm surface updates now carry the FUTURE `nextWarmAt` (re-arm BEFORE notify); + `turnSettled`/`turnStarted` also push (fresh schedule after seal / `null` while generating). +- **CR-4c:** new `POST /conversations/:id/close` (tab close ≠ disconnect): aborts the in-flight + turn via a per-turn `AbortController` → kernel `runTurn` `signal` (partial persist + normal seal, + `done.reason:"aborted"`), and emits new typed hook `conversationClosed` → cache-warming disables + sync + persists OFF. Disconnect/`chat.unsubscribe` semantics unchanged. +- **CR-4d:** no change — initial `surface` echo already at HEAD (FE probed a stale up2 boot). +- **CR-1:** loaded-extensions emits count stat + ONE `custom`/`rendererId:"table"` field + (`TablePayload` exported); columns Name|Version|Trust|Activation, all trust tiers. +- **CR-2:** `SurfaceCatalogEntry.scope?: "global"|"conversation"` (`ui-contract` `0.1.0→0.2.0`); + set on both surfaces. `transport-contract` `0.8.0→0.9.0` (additive `CloseConversationResponse`). +- 907 tests pass (+13 new); typecheck + biome clean. **LIVE-VERIFIED vs `bin/up`:** default-off, + 2 automatic warms @5s each pushing future `nextWarmAt`, mid-turn close → `abortedTurn:true` + + `done.reason:"aborted"` + warming disabled, catalog scopes + table field present, echo present. ## Open items - **Context window LIMIT (next, sibling of context size):** expose the selected model's max |
