diff options
Diffstat (limited to 'packages/cache-warming/src')
| -rw-r--r-- | packages/cache-warming/src/extension.ts | 272 | ||||
| -rw-r--r-- | packages/cache-warming/src/index.ts | 40 | ||||
| -rw-r--r-- | packages/cache-warming/src/pure.test.ts | 486 | ||||
| -rw-r--r-- | packages/cache-warming/src/pure.ts | 236 | ||||
| -rw-r--r-- | packages/cache-warming/src/warmer.test.ts | 1072 | ||||
| -rw-r--r-- | packages/cache-warming/src/warmer.ts | 550 |
6 files changed, 1328 insertions, 1328 deletions
diff --git a/packages/cache-warming/src/extension.ts b/packages/cache-warming/src/extension.ts index 920177f..99b9e1e 100644 --- a/packages/cache-warming/src/extension.ts +++ b/packages/cache-warming/src/extension.ts @@ -1,154 +1,154 @@ import type { Extension, HostAPI, Manifest } from "@dispatch/kernel"; import { - cacheWarmHandle, - conversationClosed, - turnSettled, - turnStarted, - warmCompleted, + cacheWarmHandle, + conversationClosed, + turnSettled, + turnStarted, + warmCompleted, } from "@dispatch/session-orchestrator"; import type { SurfaceContext, SurfaceProvider } from "@dispatch/surface-registry"; import { surfaceRegistryHandle } from "@dispatch/surface-registry"; import type { SurfaceSpec } from "@dispatch/ui-contract"; import { - buildConversationSpec, - buildDefaultSpec, - parseIntervalPayload, - secondsToMs, + buildConversationSpec, + buildDefaultSpec, + parseIntervalPayload, + secondsToMs, } from "./pure.js"; import { createCacheWarmer } from "./warmer.js"; export const manifest: Manifest = { - id: "cache-warming", - name: "Cache Warming", - version: "0.0.0", - apiVersion: "^0.1.0", - trust: "bundled", - activation: "eager", - dependsOn: ["session-orchestrator", "surface-registry"], - capabilities: {}, - contributes: { - services: ["cache-warming/surface"], - }, + id: "cache-warming", + name: "Cache Warming", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + activation: "eager", + dependsOn: ["session-orchestrator", "surface-registry"], + capabilities: {}, + contributes: { + services: ["cache-warming/surface"], + }, }; export function activate(host: HostAPI): void { - const warmService = host.getService(cacheWarmHandle); - const registry = host.getService(surfaceRegistryHandle); - const storage = host.storage("cache-warming"); - - const subscribers = new Set<() => void>(); - - const timeoutMap = new Map<number, ReturnType<typeof setTimeout>>(); - let nextTimerId = 1; - - const warmer = createCacheWarmer({ - warm: warmService.warm, - storage, - logger: host.logger, - timers: { - setTimer(fn, ms) { - const id = nextTimerId++; - timeoutMap.set(id, setTimeout(fn, ms)); - return id; - }, - clearTimer(id) { - const timeout = timeoutMap.get(id); - if (timeout !== undefined) { - clearTimeout(timeout); - timeoutMap.delete(id); - } - }, - }, - now: () => Date.now(), - onSurfaceChange: () => { - for (const notify of subscribers) { - notify(); - } - }, - }); - - host.on(turnStarted, (payload) => { - warmer.onTurnStarted(payload.conversationId); - }); - - host.on(turnSettled, (payload) => { - warmer.onTurnSettled(payload.conversationId, { - ...(payload.cwd !== undefined ? { cwd: payload.cwd } : {}), - ...(payload.modelName !== undefined ? { modelName: payload.modelName } : {}), - }); - }); - - host.on(warmCompleted, (payload) => { - 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) { - return buildDefaultSpec(); - } - const state = warmer.getState(convId); - return buildConversationSpec( - state.enabled, - state.intervalMs, - state.lastPct, - state.lastExpectedPct, - state.nextWarmAt, - state.lastWarmAt, - ); - } - - async function invoke( - actionId: string, - payload?: unknown, - context?: SurfaceContext, - ): Promise<void> { - const convId = context?.conversationId; - if (convId === undefined) return; - - if (actionId === "cache-warming/toggle") { - const current = warmer.getState(convId); - await warmer.setEnabled(convId, !current.enabled); - } - - if (actionId === "cache-warming/set-interval") { - const seconds = parseIntervalPayload(payload); - if (seconds === null) return; - const ms = secondsToMs(seconds); - if (ms === null) return; - await warmer.setIntervalMs(convId, ms); - } - } - - const provider: SurfaceProvider = { - catalogEntry: { - id: "cache-warming", - region: "side", - title: "Cache Warming", - scope: "conversation", - }, - getSpec, - invoke, - subscribe(onChange) { - subscribers.add(onChange); - return () => { - subscribers.delete(onChange); - }; - }, - }; - - registry.register(provider); - - host.logger.info("cache-warming: registered"); + const warmService = host.getService(cacheWarmHandle); + const registry = host.getService(surfaceRegistryHandle); + const storage = host.storage("cache-warming"); + + const subscribers = new Set<() => void>(); + + const timeoutMap = new Map<number, ReturnType<typeof setTimeout>>(); + let nextTimerId = 1; + + const warmer = createCacheWarmer({ + warm: warmService.warm, + storage, + logger: host.logger, + timers: { + setTimer(fn, ms) { + const id = nextTimerId++; + timeoutMap.set(id, setTimeout(fn, ms)); + return id; + }, + clearTimer(id) { + const timeout = timeoutMap.get(id); + if (timeout !== undefined) { + clearTimeout(timeout); + timeoutMap.delete(id); + } + }, + }, + now: () => Date.now(), + onSurfaceChange: () => { + for (const notify of subscribers) { + notify(); + } + }, + }); + + host.on(turnStarted, (payload) => { + warmer.onTurnStarted(payload.conversationId); + }); + + host.on(turnSettled, (payload) => { + warmer.onTurnSettled(payload.conversationId, { + ...(payload.cwd !== undefined ? { cwd: payload.cwd } : {}), + ...(payload.modelName !== undefined ? { modelName: payload.modelName } : {}), + }); + }); + + host.on(warmCompleted, (payload) => { + 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) { + return buildDefaultSpec(); + } + const state = warmer.getState(convId); + return buildConversationSpec( + state.enabled, + state.intervalMs, + state.lastPct, + state.lastExpectedPct, + state.nextWarmAt, + state.lastWarmAt, + ); + } + + async function invoke( + actionId: string, + payload?: unknown, + context?: SurfaceContext, + ): Promise<void> { + const convId = context?.conversationId; + if (convId === undefined) return; + + if (actionId === "cache-warming/toggle") { + const current = warmer.getState(convId); + await warmer.setEnabled(convId, !current.enabled); + } + + if (actionId === "cache-warming/set-interval") { + const seconds = parseIntervalPayload(payload); + if (seconds === null) return; + const ms = secondsToMs(seconds); + if (ms === null) return; + await warmer.setIntervalMs(convId, ms); + } + } + + const provider: SurfaceProvider = { + catalogEntry: { + id: "cache-warming", + region: "side", + title: "Cache Warming", + scope: "conversation", + }, + getSpec, + invoke, + subscribe(onChange) { + subscribers.add(onChange); + return () => { + subscribers.delete(onChange); + }; + }, + }; + + registry.register(provider); + + host.logger.info("cache-warming: registered"); } export const extension: Extension = { - manifest, - activate, + manifest, + activate, }; diff --git a/packages/cache-warming/src/index.ts b/packages/cache-warming/src/index.ts index 88cab3b..a6c3e99 100644 --- a/packages/cache-warming/src/index.ts +++ b/packages/cache-warming/src/index.ts @@ -1,25 +1,25 @@ export { extension, manifest } from "./extension.js"; export { - buildConversationSpec, - buildDefaultSpec, - type ConversationSettings, - type ConversationState, - computeCachePct, - computeExpectedCacheRate, - DEFAULT_INTERVAL_MS, - isTokenCurrent, - MIN_INTERVAL_MS, - msToSeconds, - parseIntervalPayload, - parseSettings, - secondsToMs, - serializeSettings, - settingsKey, - shouldWarm, + buildConversationSpec, + buildDefaultSpec, + type ConversationSettings, + type ConversationState, + computeCachePct, + computeExpectedCacheRate, + DEFAULT_INTERVAL_MS, + isTokenCurrent, + MIN_INTERVAL_MS, + msToSeconds, + parseIntervalPayload, + parseSettings, + secondsToMs, + serializeSettings, + settingsKey, + shouldWarm, } from "./pure.js"; export { - type CacheWarmer, - type CacheWarmerDeps, - createCacheWarmer, - type TimerDeps, + type CacheWarmer, + type CacheWarmerDeps, + createCacheWarmer, + type TimerDeps, } from "./warmer.js"; diff --git a/packages/cache-warming/src/pure.test.ts b/packages/cache-warming/src/pure.test.ts index 357fcb9..02adaf7 100644 --- a/packages/cache-warming/src/pure.test.ts +++ b/packages/cache-warming/src/pure.test.ts @@ -1,297 +1,297 @@ import { describe, expect, it } from "vitest"; import type { ConversationState } from "./pure.js"; import { - buildConversationSpec, - buildDefaultSpec, - computeCachePct, - computeExpectedCacheRate, - isTokenCurrent, - MIN_INTERVAL_MS, - msToSeconds, - parseIntervalPayload, - parseSettings, - secondsToMs, - serializeSettings, - shouldWarm, + buildConversationSpec, + buildDefaultSpec, + computeCachePct, + computeExpectedCacheRate, + isTokenCurrent, + MIN_INTERVAL_MS, + msToSeconds, + parseIntervalPayload, + parseSettings, + secondsToMs, + serializeSettings, + shouldWarm, } from "./pure.js"; describe("computeCachePct", () => { - it("cacheRead/input rounded and clamped to 0..100", () => { - expect(computeCachePct(1000, 800)).toBe(80); - expect(computeCachePct(1000, 1200)).toBe(100); - expect(computeCachePct(1000, -100)).toBe(0); - expect(computeCachePct(1000, 0)).toBe(0); - expect(computeCachePct(1000, 333)).toBe(33); - }); + it("cacheRead/input rounded and clamped to 0..100", () => { + expect(computeCachePct(1000, 800)).toBe(80); + expect(computeCachePct(1000, 1200)).toBe(100); + expect(computeCachePct(1000, -100)).toBe(0); + expect(computeCachePct(1000, 0)).toBe(0); + expect(computeCachePct(1000, 333)).toBe(33); + }); - it("zero input tokens → 0", () => { - expect(computeCachePct(0, 500)).toBe(0); - expect(computeCachePct(-1, 500)).toBe(0); - }); + it("zero input tokens → 0", () => { + expect(computeCachePct(0, 500)).toBe(0); + expect(computeCachePct(-1, 500)).toBe(0); + }); }); describe("computeExpectedCacheRate", () => { - it("cacheRead/(cacheRead+cacheWrite) rounded", () => { - expect(computeExpectedCacheRate(800, 200)).toBe(80); - expect(computeExpectedCacheRate(500, 500)).toBe(50); - expect(computeExpectedCacheRate(1000, 0)).toBe(100); - expect(computeExpectedCacheRate(0, 1000)).toBe(0); - expect(computeExpectedCacheRate(333, 667)).toBe(33); - }); + it("cacheRead/(cacheRead+cacheWrite) rounded", () => { + expect(computeExpectedCacheRate(800, 200)).toBe(80); + expect(computeExpectedCacheRate(500, 500)).toBe(50); + expect(computeExpectedCacheRate(1000, 0)).toBe(100); + expect(computeExpectedCacheRate(0, 1000)).toBe(0); + expect(computeExpectedCacheRate(333, 667)).toBe(33); + }); - it("0 when cacheRead+cacheWrite is 0", () => { - expect(computeExpectedCacheRate(0, 0)).toBe(0); - }); + it("0 when cacheRead+cacheWrite is 0", () => { + expect(computeExpectedCacheRate(0, 0)).toBe(0); + }); }); describe("shouldWarm", () => { - it("returns true when enabled, idle, and token matches", () => { - const state: ConversationState = { - enabled: true, - intervalMs: 240_000, - active: false, - lastPct: null, - lastExpectedPct: null, - lastWarmAt: null, - nextWarmAt: null, - token: 5, - }; - expect(shouldWarm(state, 5)).toBe(true); - }); + it("returns true when enabled, idle, and token matches", () => { + const state: ConversationState = { + enabled: true, + intervalMs: 240_000, + active: false, + lastPct: null, + lastExpectedPct: null, + lastWarmAt: null, + nextWarmAt: null, + token: 5, + }; + expect(shouldWarm(state, 5)).toBe(true); + }); - it("returns false when disabled", () => { - const state: ConversationState = { - enabled: false, - intervalMs: 240_000, - active: false, - lastPct: null, - lastExpectedPct: null, - lastWarmAt: null, - nextWarmAt: null, - token: 5, - }; - expect(shouldWarm(state, 5)).toBe(false); - }); + it("returns false when disabled", () => { + const state: ConversationState = { + enabled: false, + intervalMs: 240_000, + active: false, + lastPct: null, + lastExpectedPct: null, + lastWarmAt: null, + nextWarmAt: null, + token: 5, + }; + expect(shouldWarm(state, 5)).toBe(false); + }); - it("returns false when active", () => { - const state: ConversationState = { - enabled: true, - intervalMs: 240_000, - active: true, - lastPct: null, - lastExpectedPct: null, - lastWarmAt: null, - nextWarmAt: null, - token: 5, - }; - expect(shouldWarm(state, 5)).toBe(false); - }); + it("returns false when active", () => { + const state: ConversationState = { + enabled: true, + intervalMs: 240_000, + active: true, + lastPct: null, + lastExpectedPct: null, + lastWarmAt: null, + nextWarmAt: null, + token: 5, + }; + expect(shouldWarm(state, 5)).toBe(false); + }); - it("returns false when token is superseded", () => { - const state: ConversationState = { - enabled: true, - intervalMs: 240_000, - active: false, - lastPct: null, - lastExpectedPct: null, - lastWarmAt: null, - nextWarmAt: null, - token: 5, - }; - expect(shouldWarm(state, 6)).toBe(false); - }); + it("returns false when token is superseded", () => { + const state: ConversationState = { + enabled: true, + intervalMs: 240_000, + active: false, + lastPct: null, + lastExpectedPct: null, + lastWarmAt: null, + nextWarmAt: null, + token: 5, + }; + expect(shouldWarm(state, 6)).toBe(false); + }); }); describe("isTokenCurrent", () => { - it("returns true when tokens match", () => { - expect(isTokenCurrent(5, 5)).toBe(true); - }); + it("returns true when tokens match", () => { + expect(isTokenCurrent(5, 5)).toBe(true); + }); - it("returns false when tokens differ", () => { - expect(isTokenCurrent(5, 6)).toBe(false); - }); + it("returns false when tokens differ", () => { + expect(isTokenCurrent(5, 6)).toBe(false); + }); }); describe("parseSettings/serializeSettings round-trip", () => { - it("round-trips enabled + intervalMs", () => { - const original = { enabled: false, intervalMs: 120_000 }; - const serialized = serializeSettings(original); - const parsed = parseSettings(serialized); - expect(parsed).toEqual(original); - }); + it("round-trips enabled + intervalMs", () => { + const original = { enabled: false, intervalMs: 120_000 }; + const serialized = serializeSettings(original); + const parsed = parseSettings(serialized); + expect(parsed).toEqual(original); + }); - it("returns defaults for null input (warming OFF by default — CR-4a)", () => { - const parsed = parseSettings(null); - expect(parsed).toEqual({ enabled: false, intervalMs: 240_000 }); - }); + it("returns defaults for null input (warming OFF by default — CR-4a)", () => { + const parsed = parseSettings(null); + expect(parsed).toEqual({ enabled: false, intervalMs: 240_000 }); + }); - it("returns defaults for malformed JSON (warming OFF by default — CR-4a)", () => { - const parsed = parseSettings("not-json{{{"); - expect(parsed).toEqual({ enabled: false, intervalMs: 240_000 }); - }); + it("returns defaults for malformed JSON (warming OFF by default — CR-4a)", () => { + const parsed = parseSettings("not-json{{{"); + expect(parsed).toEqual({ enabled: false, intervalMs: 240_000 }); + }); - it("clamps non-positive interval to MIN_INTERVAL_MS", () => { - const parsed = parseSettings(JSON.stringify({ enabled: true, intervalMs: -500 })); - expect(parsed.intervalMs).toBe(1000); - }); + it("clamps non-positive interval to MIN_INTERVAL_MS", () => { + const parsed = parseSettings(JSON.stringify({ enabled: true, intervalMs: -500 })); + expect(parsed.intervalMs).toBe(1000); + }); - it("uses default for NaN interval", () => { - const parsed = parseSettings(JSON.stringify({ enabled: true, intervalMs: Number.NaN })); - expect(parsed.intervalMs).toBe(240_000); - }); + it("uses default for NaN interval", () => { + const parsed = parseSettings(JSON.stringify({ enabled: true, intervalMs: Number.NaN })); + expect(parsed.intervalMs).toBe(240_000); + }); }); describe("msToSeconds", () => { - it("converts ms to seconds, rounded", () => { - expect(msToSeconds(240_000)).toBe(240); - expect(msToSeconds(1500)).toBe(2); - expect(msToSeconds(1000)).toBe(1); - expect(msToSeconds(0)).toBe(0); - }); + it("converts ms to seconds, rounded", () => { + expect(msToSeconds(240_000)).toBe(240); + expect(msToSeconds(1500)).toBe(2); + expect(msToSeconds(1000)).toBe(1); + expect(msToSeconds(0)).toBe(0); + }); }); describe("secondsToMs", () => { - it("converts seconds to ms, floors at MIN_INTERVAL_MS", () => { - expect(secondsToMs(240)).toBe(240_000); - expect(secondsToMs(1)).toBe(1000); - expect(secondsToMs(0.5)).toBe(MIN_INTERVAL_MS); - }); + it("converts seconds to ms, floors at MIN_INTERVAL_MS", () => { + expect(secondsToMs(240)).toBe(240_000); + expect(secondsToMs(1)).toBe(1000); + expect(secondsToMs(0.5)).toBe(MIN_INTERVAL_MS); + }); - it("returns null for NaN / non-positive", () => { - expect(secondsToMs(Number.NaN)).toBeNull(); - expect(secondsToMs(0)).toBeNull(); - expect(secondsToMs(-5)).toBeNull(); - expect(secondsToMs(Number.POSITIVE_INFINITY)).toBeNull(); - }); + it("returns null for NaN / non-positive", () => { + expect(secondsToMs(Number.NaN)).toBeNull(); + expect(secondsToMs(0)).toBeNull(); + expect(secondsToMs(-5)).toBeNull(); + expect(secondsToMs(Number.POSITIVE_INFINITY)).toBeNull(); + }); }); describe("parseIntervalPayload", () => { - it("accepts a bare positive number", () => { - expect(parseIntervalPayload(30)).toBe(30); - expect(parseIntervalPayload(1)).toBe(1); - }); + it("accepts a bare positive number", () => { + expect(parseIntervalPayload(30)).toBe(30); + expect(parseIntervalPayload(1)).toBe(1); + }); - it("accepts { value: number }", () => { - expect(parseIntervalPayload({ value: 30 })).toBe(30); - expect(parseIntervalPayload({ value: 1 })).toBe(1); - }); + it("accepts { value: number }", () => { + expect(parseIntervalPayload({ value: 30 })).toBe(30); + expect(parseIntervalPayload({ value: 1 })).toBe(1); + }); - it("returns null for NaN / non-positive / wrong shape", () => { - expect(parseIntervalPayload(Number.NaN)).toBeNull(); - expect(parseIntervalPayload(0)).toBeNull(); - expect(parseIntervalPayload(-5)).toBeNull(); - expect(parseIntervalPayload("30")).toBeNull(); - expect(parseIntervalPayload({ value: "30" })).toBeNull(); - expect(parseIntervalPayload({})).toBeNull(); - expect(parseIntervalPayload(null)).toBeNull(); - expect(parseIntervalPayload(undefined)).toBeNull(); - }); + it("returns null for NaN / non-positive / wrong shape", () => { + expect(parseIntervalPayload(Number.NaN)).toBeNull(); + expect(parseIntervalPayload(0)).toBeNull(); + expect(parseIntervalPayload(-5)).toBeNull(); + expect(parseIntervalPayload("30")).toBeNull(); + expect(parseIntervalPayload({ value: "30" })).toBeNull(); + expect(parseIntervalPayload({})).toBeNull(); + expect(parseIntervalPayload(null)).toBeNull(); + expect(parseIntervalPayload(undefined)).toBeNull(); + }); }); describe("buildConversationSpec", () => { - it("builds a per-conversation spec with toggle + number(interval) + last-% + retention + timer fields", () => { - const spec = buildConversationSpec(true, 240_000, 80, 95, 1000, 500); - expect(spec.id).toBe("cache-warming"); - expect(spec.region).toBe("side"); - expect(spec.title).toBe("Cache Warming"); - expect(spec.fields).toHaveLength(5); + it("builds a per-conversation spec with toggle + number(interval) + last-% + retention + timer fields", () => { + const spec = buildConversationSpec(true, 240_000, 80, 95, 1000, 500); + expect(spec.id).toBe("cache-warming"); + expect(spec.region).toBe("side"); + expect(spec.title).toBe("Cache Warming"); + expect(spec.fields).toHaveLength(5); - const toggle = spec.fields[0]; - expect(toggle).toEqual({ - kind: "toggle", - label: "Enabled", - value: true, - action: { actionId: "cache-warming/toggle" }, - }); + const toggle = spec.fields[0]; + expect(toggle).toEqual({ + kind: "toggle", + label: "Enabled", + value: true, + action: { actionId: "cache-warming/toggle" }, + }); - const number = spec.fields[1]; - expect(number).toEqual({ - kind: "number", - label: "Refresh Interval", - value: 240, - min: 1, - step: 1, - unit: "s", - action: { actionId: "cache-warming/set-interval" }, - }); + const number = spec.fields[1]; + expect(number).toEqual({ + kind: "number", + label: "Refresh Interval", + value: 240, + min: 1, + step: 1, + unit: "s", + action: { actionId: "cache-warming/set-interval" }, + }); - const stat = spec.fields[2]; - expect(stat).toEqual({ - kind: "stat", - label: "Last Cache %", - value: "80%", - }); + const stat = spec.fields[2]; + expect(stat).toEqual({ + kind: "stat", + label: "Last Cache %", + value: "80%", + }); - const retention = spec.fields[3]; - expect(retention).toEqual({ - kind: "stat", - label: "Cache retention", - value: "95%", - }); + const retention = spec.fields[3]; + expect(retention).toEqual({ + kind: "stat", + label: "Cache retention", + value: "95%", + }); - const timer = spec.fields[4]; - expect(timer).toEqual({ - kind: "custom", - rendererId: "cache-warming-timer", - payload: { nextWarmAt: 1000, lastWarmAt: 500 }, - }); - }); + const timer = spec.fields[4]; + expect(timer).toEqual({ + kind: "custom", + rendererId: "cache-warming-timer", + payload: { nextWarmAt: 1000, lastWarmAt: 500 }, + }); + }); - it("shows — when lastPct and lastExpectedPct are null", () => { - const spec = buildConversationSpec(true, 240_000, null, null, null, null); - const stat = spec.fields[2]; - expect(stat).toEqual({ - kind: "stat", - label: "Last Cache %", - value: "—", - }); - const retention = spec.fields[3]; - expect(retention).toEqual({ - kind: "stat", - label: "Cache retention", - value: "—", - }); - const timer = spec.fields[4]; - expect(timer).toEqual({ - kind: "custom", - rendererId: "cache-warming-timer", - payload: { nextWarmAt: null, lastWarmAt: null }, - }); - }); + it("shows — when lastPct and lastExpectedPct are null", () => { + const spec = buildConversationSpec(true, 240_000, null, null, null, null); + const stat = spec.fields[2]; + expect(stat).toEqual({ + kind: "stat", + label: "Last Cache %", + value: "—", + }); + const retention = spec.fields[3]; + expect(retention).toEqual({ + kind: "stat", + label: "Cache retention", + value: "—", + }); + const timer = spec.fields[4]; + expect(timer).toEqual({ + kind: "custom", + rendererId: "cache-warming-timer", + payload: { nextWarmAt: null, lastWarmAt: null }, + }); + }); - it("reflects disabled state", () => { - const spec = buildConversationSpec(false, 120_000, 50, 75, null, null); - const toggle = spec.fields[0]; - expect(toggle).toEqual({ - kind: "toggle", - label: "Enabled", - value: false, - action: { actionId: "cache-warming/toggle" }, - }); - const number = spec.fields[1]; - expect(number).toEqual({ - kind: "number", - label: "Refresh Interval", - value: 120, - min: 1, - step: 1, - unit: "s", - action: { actionId: "cache-warming/set-interval" }, - }); - }); + it("reflects disabled state", () => { + const spec = buildConversationSpec(false, 120_000, 50, 75, null, null); + const toggle = spec.fields[0]; + expect(toggle).toEqual({ + kind: "toggle", + label: "Enabled", + value: false, + action: { actionId: "cache-warming/toggle" }, + }); + const number = spec.fields[1]; + expect(number).toEqual({ + kind: "number", + label: "Refresh Interval", + value: 120, + min: 1, + step: 1, + unit: "s", + action: { actionId: "cache-warming/set-interval" }, + }); + }); }); describe("buildDefaultSpec", () => { - it("returns a default spec with no conversationId", () => { - const spec = buildDefaultSpec(); - expect(spec.id).toBe("cache-warming"); - expect(spec.region).toBe("side"); - expect(spec.title).toBe("Cache Warming"); - expect(spec.fields).toHaveLength(1); - expect(spec.fields[0]).toEqual({ - kind: "stat", - label: "Status", - value: "No conversation focused", - }); - }); + it("returns a default spec with no conversationId", () => { + const spec = buildDefaultSpec(); + expect(spec.id).toBe("cache-warming"); + expect(spec.region).toBe("side"); + expect(spec.title).toBe("Cache Warming"); + expect(spec.fields).toHaveLength(1); + expect(spec.fields[0]).toEqual({ + kind: "stat", + label: "Status", + value: "No conversation focused", + }); + }); }); diff --git a/packages/cache-warming/src/pure.ts b/packages/cache-warming/src/pure.ts index 2a2fd1b..16b3cb1 100644 --- a/packages/cache-warming/src/pure.ts +++ b/packages/cache-warming/src/pure.ts @@ -4,35 +4,35 @@ */ import type { - CustomField, - NumberField, - StatField, - SurfaceSpec, - ToggleField, + CustomField, + NumberField, + StatField, + SurfaceSpec, + ToggleField, } from "@dispatch/ui-contract"; // --- Types --- /** Persisted per-conversation settings (storage-facing). */ export interface ConversationSettings { - readonly enabled: boolean; - readonly intervalMs: number; + readonly enabled: boolean; + readonly intervalMs: number; } /** Full per-conversation runtime state (in-memory, not persisted). */ export interface ConversationState extends ConversationSettings { - readonly active: boolean; - readonly lastPct: number | null; - readonly lastExpectedPct: number | null; - readonly lastWarmAt: number | null; - readonly nextWarmAt: number | null; - readonly token: number; + readonly active: boolean; + readonly lastPct: number | null; + readonly lastExpectedPct: number | null; + readonly lastWarmAt: number | null; + readonly nextWarmAt: number | null; + readonly token: number; } /** Context stored per-conversation from the latest lifecycle event. */ export interface ConversationContext { - readonly cwd?: string; - readonly modelName?: string; + readonly cwd?: string; + readonly modelName?: string; } export const DEFAULT_INTERVAL_MS = 240_000; @@ -45,10 +45,10 @@ export const MIN_INTERVAL_MS = 1000; * Returns an integer in [0, 100]. inputTokens ≤ 0 → 0. */ export function computeCachePct(inputTokens: number, cacheReadTokens: number): number { - if (inputTokens <= 0) return 0; - const ratio = cacheReadTokens / inputTokens; - const clamped = Math.max(0, Math.min(1, ratio)); - return Math.round(clamped * 100); + if (inputTokens <= 0) return 0; + const ratio = cacheReadTokens / inputTokens; + const clamped = Math.max(0, Math.min(1, ratio)); + return Math.round(clamped * 100); } /** @@ -58,12 +58,12 @@ export function computeCachePct(inputTokens: number, cacheReadTokens: number): n * Returns an integer in [0, 100]. cacheRead + cacheWrite ≤ 0 → 0. */ export function computeExpectedCacheRate( - cacheReadTokens: number, - cacheWriteTokens: number, + cacheReadTokens: number, + cacheWriteTokens: number, ): number { - const total = cacheReadTokens + cacheWriteTokens; - if (total <= 0) return 0; - return Math.round((cacheReadTokens / total) * 100); + const total = cacheReadTokens + cacheWriteTokens; + if (total <= 0) return 0; + return Math.round((cacheReadTokens / total) * 100); } /** @@ -71,14 +71,14 @@ export function computeExpectedCacheRate( * Requires: enabled, idle (not active), and the token is current (not superseded). */ export function shouldWarm(state: ConversationState, currentToken: number): boolean { - return state.enabled && !state.active && state.token === currentToken; + return state.enabled && !state.active && state.token === currentToken; } /** * Check whether a token is still current (not superseded by a newer cancel/fire). */ export function isTokenCurrent(current: number, expected: number): boolean { - return current === expected; + return current === expected; } const SETTINGS_KEY = "settings"; @@ -91,43 +91,43 @@ const SETTINGS_KEY = "settings"; * until the user explicitly opts in via the toggle. */ export function parseSettings(raw: string | null): ConversationSettings { - 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: false, intervalMs: DEFAULT_INTERVAL_MS }; - } - const obj = parsed as Record<string, unknown>; - 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)) { - intervalMs = - rawInterval <= 0 ? MIN_INTERVAL_MS : Math.max(MIN_INTERVAL_MS, Math.round(rawInterval)); - } - return { enabled, intervalMs }; - } catch { - return { enabled: false, 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: false, intervalMs: DEFAULT_INTERVAL_MS }; + } + const obj = parsed as Record<string, unknown>; + 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)) { + intervalMs = + rawInterval <= 0 ? MIN_INTERVAL_MS : Math.max(MIN_INTERVAL_MS, Math.round(rawInterval)); + } + return { enabled, intervalMs }; + } catch { + return { enabled: false, intervalMs: DEFAULT_INTERVAL_MS }; + } } /** * Serialize settings for storage. */ export function serializeSettings(settings: ConversationSettings): string { - return JSON.stringify(settings); + return JSON.stringify(settings); } /** The storage key for a conversation's settings. */ export function settingsKey(conversationId: string): string { - return `${SETTINGS_KEY}:${conversationId}`; + return `${SETTINGS_KEY}:${conversationId}`; } // --- Surface spec builders (pure) --- /** Convert intervalMs to display seconds (rounded). */ export function msToSeconds(intervalMs: number): number { - return Math.round(intervalMs / 1000); + return Math.round(intervalMs / 1000); } /** @@ -135,8 +135,8 @@ export function msToSeconds(intervalMs: number): number { * Returns null for NaN / non-positive (caller should ignore). */ export function secondsToMs(seconds: number): number | null { - if (!Number.isFinite(seconds) || seconds <= 0) return null; - return Math.max(MIN_INTERVAL_MS, Math.round(seconds * 1000)); + if (!Number.isFinite(seconds) || seconds <= 0) return null; + return Math.max(MIN_INTERVAL_MS, Math.round(seconds * 1000)); } /** @@ -144,51 +144,51 @@ export function secondsToMs(seconds: number): number | null { * Pure — no I/O. */ export function buildConversationSpec( - enabled: boolean, - intervalMs: number, - lastPct: number | null, - lastExpectedPct: number | null, - nextWarmAt: number | null, - lastWarmAt: number | null, + enabled: boolean, + intervalMs: number, + lastPct: number | null, + lastExpectedPct: number | null, + nextWarmAt: number | null, + lastWarmAt: number | null, ): SurfaceSpec { - const pctDisplay = lastPct === null ? "—" : `${lastPct}%`; - const retentionDisplay = lastExpectedPct === null ? "—" : `${lastExpectedPct}%`; - const toggle: ToggleField = { - kind: "toggle", - label: "Enabled", - value: enabled, - action: { actionId: "cache-warming/toggle" }, - }; - const interval: NumberField = { - kind: "number", - label: "Refresh Interval", - value: msToSeconds(intervalMs), - min: 1, - step: 1, - unit: "s", - action: { actionId: "cache-warming/set-interval" }, - }; - const stat: StatField = { - kind: "stat", - label: "Last Cache %", - value: pctDisplay, - }; - const retentionStat: StatField = { - kind: "stat", - label: "Cache retention", - value: retentionDisplay, - }; - const timer: CustomField = { - kind: "custom", - rendererId: "cache-warming-timer", - payload: { nextWarmAt, lastWarmAt }, - }; - return { - id: "cache-warming", - region: "side", - title: "Cache Warming", - fields: [toggle, interval, stat, retentionStat, timer], - }; + const pctDisplay = lastPct === null ? "—" : `${lastPct}%`; + const retentionDisplay = lastExpectedPct === null ? "—" : `${lastExpectedPct}%`; + const toggle: ToggleField = { + kind: "toggle", + label: "Enabled", + value: enabled, + action: { actionId: "cache-warming/toggle" }, + }; + const interval: NumberField = { + kind: "number", + label: "Refresh Interval", + value: msToSeconds(intervalMs), + min: 1, + step: 1, + unit: "s", + action: { actionId: "cache-warming/set-interval" }, + }; + const stat: StatField = { + kind: "stat", + label: "Last Cache %", + value: pctDisplay, + }; + const retentionStat: StatField = { + kind: "stat", + label: "Cache retention", + value: retentionDisplay, + }; + const timer: CustomField = { + kind: "custom", + rendererId: "cache-warming-timer", + payload: { nextWarmAt, lastWarmAt }, + }; + return { + id: "cache-warming", + region: "side", + title: "Cache Warming", + fields: [toggle, interval, stat, retentionStat, timer], + }; } /** @@ -196,18 +196,18 @@ export function buildConversationSpec( * Pure — no I/O. */ export function buildDefaultSpec(): SurfaceSpec { - return { - id: "cache-warming", - region: "side", - title: "Cache Warming", - fields: [ - { - kind: "stat", - label: "Status", - value: "No conversation focused", - }, - ], - }; + return { + id: "cache-warming", + region: "side", + title: "Cache Warming", + fields: [ + { + kind: "stat", + label: "Status", + value: "No conversation focused", + }, + ], + }; } /** @@ -216,17 +216,17 @@ export function buildDefaultSpec(): SurfaceSpec { * null if the payload is invalid (NaN / non-positive / wrong shape). */ export function parseIntervalPayload(payload: unknown): number | null { - if (typeof payload === "number" && Number.isFinite(payload) && payload > 0) { - return payload; - } - if ( - typeof payload === "object" && - payload !== null && - "value" in payload && - typeof (payload as Record<string, unknown>).value === "number" - ) { - const v = (payload as Record<string, unknown>).value as number; - if (Number.isFinite(v) && v > 0) return v; - } - return null; + if (typeof payload === "number" && Number.isFinite(payload) && payload > 0) { + return payload; + } + if ( + typeof payload === "object" && + payload !== null && + "value" in payload && + typeof (payload as Record<string, unknown>).value === "number" + ) { + const v = (payload as Record<string, unknown>).value as number; + if (Number.isFinite(v) && v > 0) return v; + } + return null; } diff --git a/packages/cache-warming/src/warmer.test.ts b/packages/cache-warming/src/warmer.test.ts index 98fa634..26445a2 100644 --- a/packages/cache-warming/src/warmer.test.ts +++ b/packages/cache-warming/src/warmer.test.ts @@ -5,559 +5,559 @@ import { MIN_INTERVAL_MS } from "./pure.js"; import { createCacheWarmer, type TimerDeps } from "./warmer.js"; function memStorage(): StorageNamespace { - const map = new Map<string, string>(); - return { - get: async (k) => map.get(k) ?? null, - set: async (k, v) => { - map.set(k, v); - }, - delete: async (k) => { - map.delete(k); - }, - has: async (k) => map.has(k), - keys: async (prefix) => - [...map.keys()].filter((k) => (prefix === undefined ? true : k.startsWith(prefix))), - }; + const map = new Map<string, string>(); + return { + get: async (k) => map.get(k) ?? null, + set: async (k, v) => { + map.set(k, v); + }, + delete: async (k) => { + map.delete(k); + }, + has: async (k) => map.has(k), + keys: async (prefix) => + [...map.keys()].filter((k) => (prefix === undefined ? true : k.startsWith(prefix))), + }; } function makeSpan(): Span { - const span: Span = { - id: "span", - log: makeLogger(), - setAttributes: () => {}, - addLink: () => {}, - child: () => makeSpan(), - end: () => {}, - }; - return span; + const span: Span = { + id: "span", + log: makeLogger(), + setAttributes: () => {}, + addLink: () => {}, + child: () => makeSpan(), + end: () => {}, + }; + return span; } function makeLogger(): Logger { - return { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - child: () => makeLogger(), - span: () => makeSpan(), - }; + return { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + child: () => makeLogger(), + span: () => makeSpan(), + }; } function fakeTimers(): TimerDeps & { flush: () => void } { - let nextId = 1; - const pending = new Map<number, () => void>(); - return { - setTimer(fn, _ms) { - const id = nextId++; - pending.set(id, fn); - return id; - }, - clearTimer(id) { - pending.delete(id); - }, - flush() { - const fns = [...pending.values()]; - pending.clear(); - for (const fn of fns) fn(); - }, - }; + let nextId = 1; + const pending = new Map<number, () => void>(); + return { + setTimer(fn, _ms) { + const id = nextId++; + pending.set(id, fn); + return id; + }, + clearTimer(id) { + pending.delete(id); + }, + flush() { + const fns = [...pending.values()]; + pending.clear(); + for (const fn of fns) fn(); + }, + }; } const WARM_RESULT: WarmResult = { - inputTokens: 1000, - outputTokens: 10, - cacheReadTokens: 800, - cacheWriteTokens: 0, + inputTokens: 1000, + outputTokens: 10, + cacheReadTokens: 800, + cacheWriteTokens: 0, }; function makeDeps( - overrides: Partial<{ - warm: (conversationId: string) => Promise<WarmResult>; - onSurfaceChange: () => void; - now: () => number; - }> = {}, + overrides: Partial<{ + warm: (conversationId: string) => Promise<WarmResult>; + onSurfaceChange: () => void; + now: () => number; + }> = {}, ) { - const timers = fakeTimers(); - let nowMs = 1000; - return { - timers, - warm: overrides.warm ?? (async () => WARM_RESULT), - storage: memStorage(), - logger: makeLogger(), - now: overrides.now ?? (() => nowMs), - onSurfaceChange: overrides.onSurfaceChange ?? (() => {}), - setNow(ms: number) { - nowMs = ms; - }, - }; + const timers = fakeTimers(); + let nowMs = 1000; + return { + timers, + warm: overrides.warm ?? (async () => WARM_RESULT), + storage: memStorage(), + logger: makeLogger(), + now: overrides.now ?? (() => nowMs), + onSurfaceChange: overrides.onSurfaceChange ?? (() => {}), + setNow(ms: number) { + nowMs = ms; + }, + }; } describe("CacheWarmer", () => { - it("arms a timer on turnSettled and warms when it fires (enabled)", async () => { - const deps = makeDeps(); - 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", {}); - deps.timers.flush(); - - await new Promise((r) => setTimeout(r, 10)); - 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[] = []; - deps.warm = async (convId) => { - warmCalls.push(convId); - return WARM_RESULT; - }; - const warmer = createCacheWarmer(deps); - - warmer.onTurnSettled("conv-1", {}); - warmer.onTurnStarted("conv-1"); - deps.timers.flush(); - - expect(warmCalls).toHaveLength(0); - }); - - it("disabled conversation does not warm", async () => { - const deps = makeDeps(); - const warmCalls: string[] = []; - deps.warm = async (convId) => { - warmCalls.push(convId); - return WARM_RESULT; - }; - const warmer = createCacheWarmer(deps); - - await warmer.setEnabled("conv-1", false); - warmer.onTurnSettled("conv-1", {}); - deps.timers.flush(); - - await new Promise((r) => setTimeout(r, 10)); - expect(warmCalls).toHaveLength(0); - }); - - it("setIntervalMs converts seconds→ms, floors at MIN_INTERVAL_MS, and re-arms", async () => { - const deps = makeDeps(); - const warmCalls: string[] = []; - deps.warm = async (convId) => { - warmCalls.push(convId); - return WARM_RESULT; - }; - 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) - const settings = await warmer.setIntervalMs("conv-1", 30_000); - expect(settings.intervalMs).toBe(30_000); - - const state = warmer.getState("conv-1"); - expect(state.intervalMs).toBe(30_000); - - // Timer should still be armed — flush fires it - deps.timers.flush(); - await new Promise((r) => setTimeout(r, 10)); - expect(warmCalls).toContain("conv-1"); - }); - - it("setIntervalMs clamps values below MIN_INTERVAL_MS", async () => { - const deps = makeDeps(); - const warmer = createCacheWarmer(deps); - - warmer.onTurnSettled("conv-1", {}); - - // Set interval to 500ms — should clamp to MIN_INTERVAL_MS (1000) - const settings = await warmer.setIntervalMs("conv-1", 500); - expect(settings.intervalMs).toBe(1000); - }); - - it("setIntervalMs ignores NaN / non-positive (clamps to MIN_INTERVAL_MS)", async () => { - const deps = makeDeps(); - const warmer = createCacheWarmer(deps); - - warmer.onTurnSettled("conv-1", {}); - - const settings1 = await warmer.setIntervalMs("conv-1", Number.NaN); - expect(settings1.intervalMs).toBe(MIN_INTERVAL_MS); - - const settings2 = await warmer.setIntervalMs("conv-1", -5000); - expect(settings2.intervalMs).toBe(MIN_INTERVAL_MS); - - const settings3 = await warmer.setIntervalMs("conv-1", 0); - expect(settings3.intervalMs).toBe(MIN_INTERVAL_MS); - }); - - it("setEnabled flips enabled for a conversation", async () => { - const deps = makeDeps(); - const warmer = createCacheWarmer(deps); - - // 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); - }); - - 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 () => { - let changeCount = 0; - const deps = makeDeps({ - onSurfaceChange: () => { - changeCount++; - }, - }); - const warmer = createCacheWarmer(deps); - - await warmer.setEnabled("conv-1", false); - expect(changeCount).toBe(1); - - await warmer.setIntervalMs("conv-1", 30_000); - expect(changeCount).toBe(2); - }); - - it("warmCompleted updates lastPct/lastExpectedPct/lastWarmAt and re-arms (nextWarmAt set), pushes onSurfaceChange", async () => { - let changeCount = 0; - let nowMs = 5000; - const deps = makeDeps({ - onSurfaceChange: () => { - changeCount++; - }, - now: () => nowMs, - }); - 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", - usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 700, cacheWriteTokens: 300 }, - }); - - const state = warmer.getState("conv-1"); - expect(state.lastPct).toBe(70); - expect(state.lastExpectedPct).toBe(70); - expect(state.lastWarmAt).toBe(6000); - expect(state.nextWarmAt).not.toBeNull(); - 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("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: () => { - changeCount++; - }, - now: () => 5000, - }); - 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 }, - }); - - const state = warmer.getState("conv-1"); - expect(state.lastPct).toBeNull(); - expect(state.lastWarmAt).toBeNull(); - expect(state.nextWarmAt).toBeNull(); - expect(changeCount).toBe(countBefore); - }); - - it("nextWarmAt is set when armed and null when disabled or active", async () => { - let nowMs = 1000; - const deps = makeDeps({ now: () => nowMs }); - const warmer = createCacheWarmer(deps); - - // Before any event — not armed - expect(warmer.getState("conv-1").nextWarmAt).toBeNull(); - - // After enable + turnSettled — armed with nextWarmAt - await warmer.setEnabled("conv-1", true); - nowMs = 2000; - warmer.onTurnSettled("conv-1", {}); - const stateArmed = warmer.getState("conv-1"); - expect(stateArmed.nextWarmAt).toBe(2000 + 240_000); - - // After turnStarted — cancelled (null) - warmer.onTurnStarted("conv-1"); - 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", async () => { - let changeCount = 0; - let nowMs = 5000; - const deps = makeDeps({ - onSurfaceChange: () => { - changeCount++; - }, - now: () => nowMs, - }); - const warmer = createCacheWarmer(deps); - - // 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", - usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 900, cacheWriteTokens: 100 }, - }); - - const after = warmer.getState("conv-1"); - expect(after.lastPct).toBe(90); - expect(after.lastExpectedPct).toBe(90); - expect(after.lastWarmAt).toBe(8000); - // Timer should be re-armed with new nextWarmAt - expect(after.nextWarmAt).toBe(8000 + 240_000); - expect(changeCount).toBe(countBefore + 1); - }); - - it("stores lastPct from the warmCompleted event", () => { - const deps = makeDeps({ now: () => 5000 }); - const warmer = createCacheWarmer(deps); - - warmer.onTurnSettled("conv-1", {}); - warmer.onWarmCompleted({ - conversationId: "conv-1", - usage: WARM_RESULT, - }); - - const state = warmer.getState("conv-1"); - expect(state.lastPct).toBe(80); - }); - - it("a completed warm stores both lastPct (rate) and lastExpectedPct (retention)", () => { - const deps = makeDeps({ now: () => 5000 }); - const warmer = createCacheWarmer(deps); - - warmer.onTurnSettled("conv-1", {}); - warmer.onWarmCompleted({ - conversationId: "conv-1", - usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 700, cacheWriteTokens: 300 }, - }); - - const state = warmer.getState("conv-1"); - expect(state.lastPct).toBe(70); - expect(state.lastExpectedPct).toBe(70); - }); - - 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; - - nowMs = 5000; - warmer.onWarmCompleted({ - conversationId: "conv-1", - usage: WARM_RESULT, - }); - - const after = warmer.getState("conv-1"); - expect(after.nextWarmAt).not.toBeNull(); - expect(after.nextWarmAt).not.toBe(firstNextWarmAt); - 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); - - warmer.onTurnSettled("conv-1", {}); - warmer.onWarmCompleted({ - conversationId: "conv-1", - usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 900, cacheWriteTokens: 100 }, - }); - - const state = warmer.getState("conv-1"); - expect(state.lastExpectedPct).toBe(90); - }); + it("arms a timer on turnSettled and warms when it fires (enabled)", async () => { + const deps = makeDeps(); + 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", {}); + deps.timers.flush(); + + await new Promise((r) => setTimeout(r, 10)); + 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[] = []; + deps.warm = async (convId) => { + warmCalls.push(convId); + return WARM_RESULT; + }; + const warmer = createCacheWarmer(deps); + + warmer.onTurnSettled("conv-1", {}); + warmer.onTurnStarted("conv-1"); + deps.timers.flush(); + + expect(warmCalls).toHaveLength(0); + }); + + it("disabled conversation does not warm", async () => { + const deps = makeDeps(); + const warmCalls: string[] = []; + deps.warm = async (convId) => { + warmCalls.push(convId); + return WARM_RESULT; + }; + const warmer = createCacheWarmer(deps); + + await warmer.setEnabled("conv-1", false); + warmer.onTurnSettled("conv-1", {}); + deps.timers.flush(); + + await new Promise((r) => setTimeout(r, 10)); + expect(warmCalls).toHaveLength(0); + }); + + it("setIntervalMs converts seconds→ms, floors at MIN_INTERVAL_MS, and re-arms", async () => { + const deps = makeDeps(); + const warmCalls: string[] = []; + deps.warm = async (convId) => { + warmCalls.push(convId); + return WARM_RESULT; + }; + 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) + const settings = await warmer.setIntervalMs("conv-1", 30_000); + expect(settings.intervalMs).toBe(30_000); + + const state = warmer.getState("conv-1"); + expect(state.intervalMs).toBe(30_000); + + // Timer should still be armed — flush fires it + deps.timers.flush(); + await new Promise((r) => setTimeout(r, 10)); + expect(warmCalls).toContain("conv-1"); + }); + + it("setIntervalMs clamps values below MIN_INTERVAL_MS", async () => { + const deps = makeDeps(); + const warmer = createCacheWarmer(deps); + + warmer.onTurnSettled("conv-1", {}); + + // Set interval to 500ms — should clamp to MIN_INTERVAL_MS (1000) + const settings = await warmer.setIntervalMs("conv-1", 500); + expect(settings.intervalMs).toBe(1000); + }); + + it("setIntervalMs ignores NaN / non-positive (clamps to MIN_INTERVAL_MS)", async () => { + const deps = makeDeps(); + const warmer = createCacheWarmer(deps); + + warmer.onTurnSettled("conv-1", {}); + + const settings1 = await warmer.setIntervalMs("conv-1", Number.NaN); + expect(settings1.intervalMs).toBe(MIN_INTERVAL_MS); + + const settings2 = await warmer.setIntervalMs("conv-1", -5000); + expect(settings2.intervalMs).toBe(MIN_INTERVAL_MS); + + const settings3 = await warmer.setIntervalMs("conv-1", 0); + expect(settings3.intervalMs).toBe(MIN_INTERVAL_MS); + }); + + it("setEnabled flips enabled for a conversation", async () => { + const deps = makeDeps(); + const warmer = createCacheWarmer(deps); + + // 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); + }); + + 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 () => { + let changeCount = 0; + const deps = makeDeps({ + onSurfaceChange: () => { + changeCount++; + }, + }); + const warmer = createCacheWarmer(deps); + + await warmer.setEnabled("conv-1", false); + expect(changeCount).toBe(1); + + await warmer.setIntervalMs("conv-1", 30_000); + expect(changeCount).toBe(2); + }); + + it("warmCompleted updates lastPct/lastExpectedPct/lastWarmAt and re-arms (nextWarmAt set), pushes onSurfaceChange", async () => { + let changeCount = 0; + let nowMs = 5000; + const deps = makeDeps({ + onSurfaceChange: () => { + changeCount++; + }, + now: () => nowMs, + }); + 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", + usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 700, cacheWriteTokens: 300 }, + }); + + const state = warmer.getState("conv-1"); + expect(state.lastPct).toBe(70); + expect(state.lastExpectedPct).toBe(70); + expect(state.lastWarmAt).toBe(6000); + expect(state.nextWarmAt).not.toBeNull(); + 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("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: () => { + changeCount++; + }, + now: () => 5000, + }); + 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 }, + }); + + const state = warmer.getState("conv-1"); + expect(state.lastPct).toBeNull(); + expect(state.lastWarmAt).toBeNull(); + expect(state.nextWarmAt).toBeNull(); + expect(changeCount).toBe(countBefore); + }); + + it("nextWarmAt is set when armed and null when disabled or active", async () => { + let nowMs = 1000; + const deps = makeDeps({ now: () => nowMs }); + const warmer = createCacheWarmer(deps); + + // Before any event — not armed + expect(warmer.getState("conv-1").nextWarmAt).toBeNull(); + + // After enable + turnSettled — armed with nextWarmAt + await warmer.setEnabled("conv-1", true); + nowMs = 2000; + warmer.onTurnSettled("conv-1", {}); + const stateArmed = warmer.getState("conv-1"); + expect(stateArmed.nextWarmAt).toBe(2000 + 240_000); + + // After turnStarted — cancelled (null) + warmer.onTurnStarted("conv-1"); + 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", async () => { + let changeCount = 0; + let nowMs = 5000; + const deps = makeDeps({ + onSurfaceChange: () => { + changeCount++; + }, + now: () => nowMs, + }); + const warmer = createCacheWarmer(deps); + + // 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", + usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 900, cacheWriteTokens: 100 }, + }); + + const after = warmer.getState("conv-1"); + expect(after.lastPct).toBe(90); + expect(after.lastExpectedPct).toBe(90); + expect(after.lastWarmAt).toBe(8000); + // Timer should be re-armed with new nextWarmAt + expect(after.nextWarmAt).toBe(8000 + 240_000); + expect(changeCount).toBe(countBefore + 1); + }); + + it("stores lastPct from the warmCompleted event", () => { + const deps = makeDeps({ now: () => 5000 }); + const warmer = createCacheWarmer(deps); + + warmer.onTurnSettled("conv-1", {}); + warmer.onWarmCompleted({ + conversationId: "conv-1", + usage: WARM_RESULT, + }); + + const state = warmer.getState("conv-1"); + expect(state.lastPct).toBe(80); + }); + + it("a completed warm stores both lastPct (rate) and lastExpectedPct (retention)", () => { + const deps = makeDeps({ now: () => 5000 }); + const warmer = createCacheWarmer(deps); + + warmer.onTurnSettled("conv-1", {}); + warmer.onWarmCompleted({ + conversationId: "conv-1", + usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 700, cacheWriteTokens: 300 }, + }); + + const state = warmer.getState("conv-1"); + expect(state.lastPct).toBe(70); + expect(state.lastExpectedPct).toBe(70); + }); + + 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; + + nowMs = 5000; + warmer.onWarmCompleted({ + conversationId: "conv-1", + usage: WARM_RESULT, + }); + + const after = warmer.getState("conv-1"); + expect(after.nextWarmAt).not.toBeNull(); + expect(after.nextWarmAt).not.toBe(firstNextWarmAt); + 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); + + warmer.onTurnSettled("conv-1", {}); + warmer.onWarmCompleted({ + conversationId: "conv-1", + usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 900, cacheWriteTokens: 100 }, + }); + + const state = warmer.getState("conv-1"); + expect(state.lastExpectedPct).toBe(90); + }); }); diff --git a/packages/cache-warming/src/warmer.ts b/packages/cache-warming/src/warmer.ts index 6ad5c33..1d504e9 100644 --- a/packages/cache-warming/src/warmer.ts +++ b/packages/cache-warming/src/warmer.ts @@ -1,308 +1,308 @@ import type { Logger, StorageNamespace } from "@dispatch/kernel"; import type { WarmCompletedPayload, WarmService } from "@dispatch/session-orchestrator"; import { - type ConversationContext, - type ConversationSettings, - type ConversationState, - computeCachePct, - computeExpectedCacheRate, - DEFAULT_INTERVAL_MS, - MIN_INTERVAL_MS, - parseSettings, - serializeSettings, - settingsKey, - shouldWarm, + type ConversationContext, + type ConversationSettings, + type ConversationState, + computeCachePct, + computeExpectedCacheRate, + DEFAULT_INTERVAL_MS, + MIN_INTERVAL_MS, + parseSettings, + serializeSettings, + settingsKey, + shouldWarm, } from "./pure.js"; // --- Timer abstraction (injectable for tests) --- export interface TimerDeps { - readonly setTimer: (fn: () => void, ms: number) => number; - readonly clearTimer: (id: number) => void; + readonly setTimer: (fn: () => void, ms: number) => number; + readonly clearTimer: (id: number) => void; } // --- Warmer interface --- export interface CacheWarmer { - /** Handle a turnStarted event — mark conversation active, cancel pending warm. */ - readonly onTurnStarted: (conversationId: string) => void; + /** Handle a turnStarted event — mark conversation active, cancel pending warm. */ + readonly onTurnStarted: (conversationId: string) => void; - /** Handle a turnSettled event — mark idle, store context, arm timer if enabled. */ - readonly onTurnSettled: (conversationId: string, ctx: ConversationContext) => void; + /** 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, re-arm timer, update surface. */ - readonly onWarmCompleted: (payload: WarmCompletedPayload) => void; + /** 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>; + /** + * 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; + /** Get the current state for a conversation (for surface rendering). */ + readonly getState: (conversationId: string) => ConversationState; - /** Get the stored context for a conversation. */ - readonly getContext: (conversationId: string) => ConversationContext; + /** Get the stored context for a conversation. */ + readonly getContext: (conversationId: string) => ConversationContext; - /** Toggle enabled for a conversation. Returns updated settings. */ - readonly setEnabled: (conversationId: string, enabled: boolean) => Promise<ConversationSettings>; + /** Toggle enabled for a conversation. Returns updated settings. */ + readonly setEnabled: (conversationId: string, enabled: boolean) => Promise<ConversationSettings>; - /** Set the refresh interval for a conversation. Returns updated settings. */ - readonly setIntervalMs: ( - conversationId: string, - intervalMs: number, - ) => Promise<ConversationSettings>; + /** Set the refresh interval for a conversation. Returns updated settings. */ + readonly setIntervalMs: ( + conversationId: string, + intervalMs: number, + ) => Promise<ConversationSettings>; - /** Dispose all timers (for cleanup). */ - readonly dispose: () => void; + /** Dispose all timers (for cleanup). */ + readonly dispose: () => void; } export interface CacheWarmerDeps { - readonly warm: WarmService["warm"]; - readonly storage: StorageNamespace; - readonly logger: Logger; - readonly timers: TimerDeps; - /** Injected clock — returns epoch-ms. */ - readonly now: () => number; - /** Called when surface subscribers should re-fetch the spec. */ - readonly onSurfaceChange: () => void; + readonly warm: WarmService["warm"]; + readonly storage: StorageNamespace; + readonly logger: Logger; + readonly timers: TimerDeps; + /** Injected clock — returns epoch-ms. */ + readonly now: () => number; + /** Called when surface subscribers should re-fetch the spec. */ + 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: false, - intervalMs: DEFAULT_INTERVAL_MS, - active: false, - lastPct: null, - lastExpectedPct: null, - lastWarmAt: null, - nextWarmAt: null, - token: 0, + enabled: false, + intervalMs: DEFAULT_INTERVAL_MS, + active: false, + lastPct: null, + lastExpectedPct: null, + lastWarmAt: null, + nextWarmAt: null, + token: 0, }; export function createCacheWarmer(deps: CacheWarmerDeps): CacheWarmer { - // Per-conversation runtime state (not persisted — reconstructed from storage + events) - const states = new Map<string, ConversationState>(); - // Per-conversation context from latest lifecycle event - const contexts = new Map<string, ConversationContext>(); - // Per-conversation pending timer id - const timers = new Map<string, number>(); - // Monotonic token per conversation for in-flight invalidation - let nextToken = 1; - - function getState(conversationId: string): ConversationState { - return states.get(conversationId) ?? DEFAULT_STATE; - } - - function getContext(conversationId: string): ConversationContext { - return contexts.get(conversationId) ?? {}; - } - - function setState(conversationId: string, state: ConversationState): void { - states.set(conversationId, state); - } - - function cancelTimer(conversationId: string): void { - const existing = timers.get(conversationId); - if (existing !== undefined) { - deps.timers.clearTimer(existing); - timers.delete(conversationId); - } - mergeState(conversationId, { nextWarmAt: null }); - } - - function armTimer(conversationId: string): void { - cancelTimer(conversationId); - const state = getState(conversationId); - if (!state.enabled || state.active) return; - - const token = nextToken++; - const nowMs = deps.now(); - const nextWarmAt = nowMs + state.intervalMs; - setState(conversationId, { ...state, token, nextWarmAt }); - - const timerId = deps.timers.setTimer(() => { - timers.delete(conversationId); - void fireWarm(conversationId, token); - }, state.intervalMs); - - timers.set(conversationId, timerId); - } - - async function fireWarm(conversationId: string, token: number): Promise<void> { - const state = getState(conversationId); - if (!shouldWarm(state, token)) { - deps.logger.debug("cache-warming: skip warm (superseded or disabled)", { - conversationId, - }); - return; - } - - const ctx = getContext(conversationId); - deps.logger.debug("cache-warming: firing warm", { conversationId }); - - await deps.warm(conversationId, { - ...(ctx.cwd !== undefined ? { cwd: ctx.cwd } : {}), - ...(ctx.modelName !== undefined ? { modelName: ctx.modelName } : {}), - }); - - // Result processing is handled by the warmCompleted event handler. - // Timer re-arm is also handled there on success. - } - - async function loadSettings(conversationId: string): Promise<ConversationSettings> { - const raw = await deps.storage.get(settingsKey(conversationId)); - return parseSettings(raw); - } - - async function persistSettings( - conversationId: string, - settings: ConversationSettings, - ): Promise<void> { - await deps.storage.set(settingsKey(conversationId), serializeSettings(settings)); - } - - function mergeState( - conversationId: string, - partial: Partial<ConversationState>, - ): ConversationState { - const current = getState(conversationId); - const updated = { ...current, ...partial }; - setState(conversationId, updated); - return updated; - } - - return { - onTurnStarted(conversationId) { - 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) { - deps.logger.debug("cache-warming: turn settled", { conversationId }); - contexts.set(conversationId, ctx); - mergeState(conversationId, { active: false }); - - const state = getState(conversationId); - 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) { - const { conversationId, usage } = payload; - const state = getState(conversationId); - - // Drop if the conversation became active while the warm was in flight - if (state.active) { - deps.logger.debug("cache-warming: dropping warm result (conversation active)", { - conversationId, - }); - return; - } - - const pct = computeCachePct(usage.inputTokens, usage.cacheReadTokens); - const expectedPct = computeExpectedCacheRate(usage.cacheReadTokens, usage.cacheWriteTokens); - const nowMs = deps.now(); - setState(conversationId, { - ...state, - 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, - }); - }, - - 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); - // 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); - if (!state.active) { - armTimer(conversationId); - } - } else { - cancelTimer(conversationId); - } - - deps.onSurfaceChange(); - return updated; - }, - - async setIntervalMs(conversationId, intervalMs) { - const clamped = - !Number.isFinite(intervalMs) || intervalMs <= 0 - ? MIN_INTERVAL_MS - : Math.max(MIN_INTERVAL_MS, Math.round(intervalMs)); - const settings = await loadSettings(conversationId); - const updated = { ...settings, intervalMs: clamped }; - await persistSettings(conversationId, updated); - mergeState(conversationId, { intervalMs: clamped }); - - // Re-arm with new interval if currently armed - const state = getState(conversationId); - if (state.enabled && !state.active && timers.has(conversationId)) { - armTimer(conversationId); - } - - deps.onSurfaceChange(); - return updated; - }, - - dispose() { - for (const [conversationId] of timers) { - cancelTimer(conversationId); - } - }, - }; + // Per-conversation runtime state (not persisted — reconstructed from storage + events) + const states = new Map<string, ConversationState>(); + // Per-conversation context from latest lifecycle event + const contexts = new Map<string, ConversationContext>(); + // Per-conversation pending timer id + const timers = new Map<string, number>(); + // Monotonic token per conversation for in-flight invalidation + let nextToken = 1; + + function getState(conversationId: string): ConversationState { + return states.get(conversationId) ?? DEFAULT_STATE; + } + + function getContext(conversationId: string): ConversationContext { + return contexts.get(conversationId) ?? {}; + } + + function setState(conversationId: string, state: ConversationState): void { + states.set(conversationId, state); + } + + function cancelTimer(conversationId: string): void { + const existing = timers.get(conversationId); + if (existing !== undefined) { + deps.timers.clearTimer(existing); + timers.delete(conversationId); + } + mergeState(conversationId, { nextWarmAt: null }); + } + + function armTimer(conversationId: string): void { + cancelTimer(conversationId); + const state = getState(conversationId); + if (!state.enabled || state.active) return; + + const token = nextToken++; + const nowMs = deps.now(); + const nextWarmAt = nowMs + state.intervalMs; + setState(conversationId, { ...state, token, nextWarmAt }); + + const timerId = deps.timers.setTimer(() => { + timers.delete(conversationId); + void fireWarm(conversationId, token); + }, state.intervalMs); + + timers.set(conversationId, timerId); + } + + async function fireWarm(conversationId: string, token: number): Promise<void> { + const state = getState(conversationId); + if (!shouldWarm(state, token)) { + deps.logger.debug("cache-warming: skip warm (superseded or disabled)", { + conversationId, + }); + return; + } + + const ctx = getContext(conversationId); + deps.logger.debug("cache-warming: firing warm", { conversationId }); + + await deps.warm(conversationId, { + ...(ctx.cwd !== undefined ? { cwd: ctx.cwd } : {}), + ...(ctx.modelName !== undefined ? { modelName: ctx.modelName } : {}), + }); + + // Result processing is handled by the warmCompleted event handler. + // Timer re-arm is also handled there on success. + } + + async function loadSettings(conversationId: string): Promise<ConversationSettings> { + const raw = await deps.storage.get(settingsKey(conversationId)); + return parseSettings(raw); + } + + async function persistSettings( + conversationId: string, + settings: ConversationSettings, + ): Promise<void> { + await deps.storage.set(settingsKey(conversationId), serializeSettings(settings)); + } + + function mergeState( + conversationId: string, + partial: Partial<ConversationState>, + ): ConversationState { + const current = getState(conversationId); + const updated = { ...current, ...partial }; + setState(conversationId, updated); + return updated; + } + + return { + onTurnStarted(conversationId) { + 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) { + deps.logger.debug("cache-warming: turn settled", { conversationId }); + contexts.set(conversationId, ctx); + mergeState(conversationId, { active: false }); + + const state = getState(conversationId); + 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) { + const { conversationId, usage } = payload; + const state = getState(conversationId); + + // Drop if the conversation became active while the warm was in flight + if (state.active) { + deps.logger.debug("cache-warming: dropping warm result (conversation active)", { + conversationId, + }); + return; + } + + const pct = computeCachePct(usage.inputTokens, usage.cacheReadTokens); + const expectedPct = computeExpectedCacheRate(usage.cacheReadTokens, usage.cacheWriteTokens); + const nowMs = deps.now(); + setState(conversationId, { + ...state, + 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, + }); + }, + + 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); + // 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); + if (!state.active) { + armTimer(conversationId); + } + } else { + cancelTimer(conversationId); + } + + deps.onSurfaceChange(); + return updated; + }, + + async setIntervalMs(conversationId, intervalMs) { + const clamped = + !Number.isFinite(intervalMs) || intervalMs <= 0 + ? MIN_INTERVAL_MS + : Math.max(MIN_INTERVAL_MS, Math.round(intervalMs)); + const settings = await loadSettings(conversationId); + const updated = { ...settings, intervalMs: clamped }; + await persistSettings(conversationId, updated); + mergeState(conversationId, { intervalMs: clamped }); + + // Re-arm with new interval if currently armed + const state = getState(conversationId); + if (state.enabled && !state.active && timers.has(conversationId)) { + armTimer(conversationId); + } + + deps.onSurfaceChange(); + return updated; + }, + + dispose() { + for (const [conversationId] of timers) { + cancelTimer(conversationId); + } + }, + }; } |
