diff options
| author | Adam Malczewski <[email protected]> | 2026-06-11 12:23:06 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-11 12:23:06 +0900 |
| commit | c2b4c05d91fa88b8d02c055a0e15c22abd8e21f3 (patch) | |
| tree | 3f7c2feddbe697a79abd952bb80ed0e01dac0a7a /packages/cache-warming | |
| parent | f6b45507210e04e9884256b0132900640de4334b (diff) | |
| download | dispatch-c2b4c05d91fa88b8d02c055a0e15c22abd8e21f3.tar.gz dispatch-c2b4c05d91fa88b8d02c055a0e15c22abd8e21f3.zip | |
feat(cache-warming): per-conversation prompt-cache warming + warm() service
Backend-driven warming targeting whatever provider a conversation uses (incl. the
external Claude provider-anthropic). Core engine + on/off + last-cache-% done;
interval-as-view-control pending a ui-contract NumberField (surface-system gap).
Mechanism:
- kernel: expose HostAPI.emit (typed bus event emit; counterpart of on)
- session-orchestrator: turnStarted/turnSettled event hooks (conversationId/cwd/model);
warm() service (cacheWarmHandle) reusing the real-turn assembly (byte-identical prefix,
provider-agnostic), refuses mid-turn, never persists/emits, returns Usage
- cache-warming (new ext): per-conversation timers (arm on settle, cancel on start,
in-flight invalidation), calls warm(), pct=round(clamp(cacheRead/input,0,1)*100),
persists {enabled,intervalMs} (default on/240s), registers a controls surface
- host-bin: register cache-warming; transport-http: HostAPI stub +emit (fan-out)
Honors old-code invariants. 760 vitest + 109 bun = 869 tests; tsc -b EXIT 0; biome clean.
Diffstat (limited to 'packages/cache-warming')
| -rw-r--r-- | packages/cache-warming/package.json | 14 | ||||
| -rw-r--r-- | packages/cache-warming/src/extension.ts | 130 | ||||
| -rw-r--r-- | packages/cache-warming/src/index.ts | 19 | ||||
| -rw-r--r-- | packages/cache-warming/src/pure.test.ts | 109 | ||||
| -rw-r--r-- | packages/cache-warming/src/pure.ts | 95 | ||||
| -rw-r--r-- | packages/cache-warming/src/warmer.test.ts | 207 | ||||
| -rw-r--r-- | packages/cache-warming/src/warmer.ts | 248 | ||||
| -rw-r--r-- | packages/cache-warming/tsconfig.json | 11 |
8 files changed, 833 insertions, 0 deletions
diff --git a/packages/cache-warming/package.json b/packages/cache-warming/package.json new file mode 100644 index 0000000..eaf3fda --- /dev/null +++ b/packages/cache-warming/package.json @@ -0,0 +1,14 @@ +{ + "name": "@dispatch/cache-warming", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/kernel": "workspace:*", + "@dispatch/session-orchestrator": "workspace:*", + "@dispatch/surface-registry": "workspace:*", + "@dispatch/ui-contract": "workspace:*" + } +} diff --git a/packages/cache-warming/src/extension.ts b/packages/cache-warming/src/extension.ts new file mode 100644 index 0000000..16515a8 --- /dev/null +++ b/packages/cache-warming/src/extension.ts @@ -0,0 +1,130 @@ +import type { Extension, HostAPI, Manifest } from "@dispatch/kernel"; +import { cacheWarmHandle, turnSettled, turnStarted } from "@dispatch/session-orchestrator"; +import type { SurfaceProvider } from "@dispatch/surface-registry"; +import { surfaceRegistryHandle } from "@dispatch/surface-registry"; +import type { SurfaceSpec } from "@dispatch/ui-contract"; +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"], + }, +}; + +function buildSurfaceSpec( + _conversationId: string | undefined, + enabled: boolean, + lastPct: number | null, +): SurfaceSpec { + const pctDisplay = lastPct === null ? "—" : `${lastPct}%`; + return { + id: "cache-warming", + region: "side", + title: "Cache Warming", + fields: [ + { + kind: "toggle", + label: "Enabled", + value: enabled, + action: { actionId: "cache-warming/toggle" }, + }, + { + kind: "stat", + label: "Last Cache %", + value: pctDisplay, + }, + ], + }; +} + +export function activate(host: HostAPI): void { + const warmService = host.getService(cacheWarmHandle); + const registry = host.getService(surfaceRegistryHandle); + const storage = host.storage("cache-warming"); + + let currentConversationId: string | undefined; + + // Timer wrapper: setTimeout/clearTimeout return Timeout in Node types, + // but our TimerDeps uses number ids. Map between them. + 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); + } + }, + }, + onSurfaceChange: () => { + // Surface subscribers will re-fetch on next getSpec() + }, + }); + + host.on(turnStarted, (payload) => { + currentConversationId = payload.conversationId; + warmer.onTurnStarted(payload.conversationId); + }); + + host.on(turnSettled, (payload) => { + currentConversationId = payload.conversationId; + warmer.onTurnSettled(payload.conversationId, { + ...(payload.cwd !== undefined ? { cwd: payload.cwd } : {}), + ...(payload.modelName !== undefined ? { modelName: payload.modelName } : {}), + }); + }); + + const provider: SurfaceProvider = { + catalogEntry: { + id: "cache-warming", + region: "side", + title: "Cache Warming", + }, + getSpec() { + const convId = currentConversationId; + const state = + convId !== undefined ? warmer.getState(convId) : { enabled: true, lastPct: null }; + return buildSurfaceSpec(convId, state.enabled, state.lastPct); + }, + async invoke(actionId, payload) { + const pl = payload as Record<string, unknown> | undefined; + const convId = + (typeof pl?.conversationId === "string" ? pl.conversationId : undefined) ?? + currentConversationId; + if (convId === undefined) return; + + if (actionId === "cache-warming/toggle") { + const current = warmer.getState(convId); + await warmer.setEnabled(convId, !current.enabled); + } + }, + }; + + registry.register(provider); + + host.logger.info("cache-warming: registered"); +} + +export const extension: Extension = { + manifest, + activate, +}; diff --git a/packages/cache-warming/src/index.ts b/packages/cache-warming/src/index.ts new file mode 100644 index 0000000..8670dc5 --- /dev/null +++ b/packages/cache-warming/src/index.ts @@ -0,0 +1,19 @@ +export { extension, manifest } from "./extension.js"; +export { + type ConversationSettings, + type ConversationState, + computeCachePct, + DEFAULT_INTERVAL_MS, + isTokenCurrent, + MIN_INTERVAL_MS, + parseSettings, + serializeSettings, + settingsKey, + shouldWarm, +} from "./pure.js"; +export { + 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 new file mode 100644 index 0000000..820260b --- /dev/null +++ b/packages/cache-warming/src/pure.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import type { ConversationState } from "./pure.js"; +import { + computeCachePct, + isTokenCurrent, + parseSettings, + 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("zero input tokens → 0", () => { + expect(computeCachePct(0, 500)).toBe(0); + expect(computeCachePct(-1, 500)).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, + 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, + 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, + 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, + token: 5, + }; + expect(shouldWarm(state, 6)).toBe(false); + }); +}); + +describe("isTokenCurrent", () => { + it("returns true when tokens match", () => { + expect(isTokenCurrent(5, 5)).toBe(true); + }); + + 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("returns defaults for null input", () => { + const parsed = parseSettings(null); + expect(parsed).toEqual({ enabled: true, intervalMs: 240_000 }); + }); + + it("returns defaults for malformed JSON", () => { + const parsed = parseSettings("not-json{{{"); + expect(parsed).toEqual({ enabled: true, 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("uses default for NaN interval", () => { + const parsed = parseSettings(JSON.stringify({ enabled: true, intervalMs: Number.NaN })); + expect(parsed.intervalMs).toBe(240_000); + }); +}); diff --git a/packages/cache-warming/src/pure.ts b/packages/cache-warming/src/pure.ts new file mode 100644 index 0000000..2b00dab --- /dev/null +++ b/packages/cache-warming/src/pure.ts @@ -0,0 +1,95 @@ +/** + * Pure core for cache-warming — zero I/O, zero ambient state. + * Every function is input → output; testable without mocks. + */ + +// --- Types --- + +/** Persisted per-conversation settings (storage-facing). */ +export interface ConversationSettings { + 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 token: number; +} + +/** Context stored per-conversation from the latest lifecycle event. */ +export interface ConversationContext { + readonly cwd?: string; + readonly modelName?: string; +} + +export const DEFAULT_INTERVAL_MS = 240_000; +export const MIN_INTERVAL_MS = 1000; + +// --- Pure functions --- + +/** + * Compute cache-hit percentage from token counts. + * 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); +} + +/** + * Decide whether a conversation should be warmed right now. + * 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; +} + +/** + * 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; +} + +const SETTINGS_KEY = "settings"; + +/** + * Parse settings from a raw storage string. + * Returns defaults if null or malformed. + */ +export function parseSettings(raw: string | null): ConversationSettings { + if (raw === null) return { enabled: true, intervalMs: DEFAULT_INTERVAL_MS }; + try { + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null) { + return { enabled: true, intervalMs: DEFAULT_INTERVAL_MS }; + } + const obj = parsed as Record<string, unknown>; + const enabled = typeof obj.enabled === "boolean" ? obj.enabled : true; + 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: true, intervalMs: DEFAULT_INTERVAL_MS }; + } +} + +/** + * Serialize settings for storage. + */ +export function serializeSettings(settings: ConversationSettings): string { + return JSON.stringify(settings); +} + +/** The storage key for a conversation's settings. */ +export function settingsKey(conversationId: string): string { + return `${SETTINGS_KEY}:${conversationId}`; +} diff --git a/packages/cache-warming/src/warmer.test.ts b/packages/cache-warming/src/warmer.test.ts new file mode 100644 index 0000000..9b9ba93 --- /dev/null +++ b/packages/cache-warming/src/warmer.test.ts @@ -0,0 +1,207 @@ +import type { Logger, Span } from "@dispatch/kernel"; +import type { WarmResult } from "@dispatch/session-orchestrator"; +import { describe, expect, it } from "vitest"; +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))), + }; +} + +function makeSpan(): 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(), + }; +} + +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(); + }, + }; +} + +const WARM_RESULT: WarmResult = { + inputTokens: 1000, + outputTokens: 10, + cacheReadTokens: 800, + cacheWriteTokens: 0, +}; + +import type { StorageNamespace } from "@dispatch/kernel"; + +describe("CacheWarmer", () => { + it("arms a timer on turnSettled and warms when it fires (enabled)", async () => { + const timers = fakeTimers(); + const warmCalls: string[] = []; + const warmer = createCacheWarmer({ + warm: async (convId) => { + warmCalls.push(convId); + return WARM_RESULT; + }, + storage: memStorage(), + logger: makeLogger(), + timers, + onSurfaceChange: () => {}, + }); + + warmer.onTurnSettled("conv-1", {}); + timers.flush(); + + await new Promise((r) => setTimeout(r, 10)); + expect(warmCalls).toContain("conv-1"); + }); + + it("cancels the timer on turnStarted (no warm while generating)", () => { + const timers = fakeTimers(); + const warmCalls: string[] = []; + const warmer = createCacheWarmer({ + warm: async (convId) => { + warmCalls.push(convId); + return WARM_RESULT; + }, + storage: memStorage(), + logger: makeLogger(), + timers, + onSurfaceChange: () => {}, + }); + + warmer.onTurnSettled("conv-1", {}); + warmer.onTurnStarted("conv-1"); + timers.flush(); + + expect(warmCalls).toHaveLength(0); + }); + + it("in-flight warm result is dropped when superseded (token mismatch)", async () => { + const timers = fakeTimers(); + let resolveWarm: (v: WarmResult) => void = () => {}; + const warmPromise = new Promise<WarmResult>((r) => { + resolveWarm = r; + }); + const warmer = createCacheWarmer({ + warm: () => warmPromise, + storage: memStorage(), + logger: makeLogger(), + timers, + onSurfaceChange: () => {}, + }); + + warmer.onTurnSettled("conv-1", {}); + timers.flush(); + + warmer.onTurnStarted("conv-1"); + warmer.onTurnSettled("conv-1", {}); + + resolveWarm?.(WARM_RESULT); + await new Promise((r) => setTimeout(r, 10)); + + const state = warmer.getState("conv-1"); + expect(state.lastPct).toBeNull(); + }); + + it("disabled conversation does not warm", async () => { + const timers = fakeTimers(); + const warmCalls: string[] = []; + const warmer = createCacheWarmer({ + warm: async (convId) => { + warmCalls.push(convId); + return WARM_RESULT; + }, + storage: memStorage(), + logger: makeLogger(), + timers, + onSurfaceChange: () => {}, + }); + + await warmer.setEnabled("conv-1", false); + warmer.onTurnSettled("conv-1", {}); + timers.flush(); + + await new Promise((r) => setTimeout(r, 10)); + expect(warmCalls).toHaveLength(0); + }); + + it("stores lastPct from the warm result", async () => { + const timers = fakeTimers(); + const warmer = createCacheWarmer({ + warm: async () => WARM_RESULT, + storage: memStorage(), + logger: makeLogger(), + timers, + onSurfaceChange: () => {}, + }); + + warmer.onTurnSettled("conv-1", {}); + timers.flush(); + + await new Promise((r) => setTimeout(r, 10)); + const state = warmer.getState("conv-1"); + expect(state.lastPct).toBe(80); + }); + + it("re-arms timer after warm completes", async () => { + const timers = fakeTimers(); + let warmCount = 0; + const warmer = createCacheWarmer({ + warm: async () => { + warmCount++; + return WARM_RESULT; + }, + storage: memStorage(), + logger: makeLogger(), + timers, + onSurfaceChange: () => {}, + }); + + warmer.onTurnSettled("conv-1", {}); + timers.flush(); + await new Promise((r) => setTimeout(r, 10)); + + timers.flush(); + await new Promise((r) => setTimeout(r, 10)); + + expect(warmCount).toBe(2); + }); +}); diff --git a/packages/cache-warming/src/warmer.ts b/packages/cache-warming/src/warmer.ts new file mode 100644 index 0000000..31dd41e --- /dev/null +++ b/packages/cache-warming/src/warmer.ts @@ -0,0 +1,248 @@ +import type { Logger, StorageNamespace } from "@dispatch/kernel"; +import type { WarmService } from "@dispatch/session-orchestrator"; +import { + type ConversationContext, + type ConversationSettings, + type ConversationState, + computeCachePct, + DEFAULT_INTERVAL_MS, + isTokenCurrent, + 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; +} + +// --- Warmer interface --- + +export interface CacheWarmer { + /** 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; + + /** 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; + + /** 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>; + + /** Dispose all timers (for cleanup). */ + readonly dispose: () => void; +} + +export interface CacheWarmerDeps { + readonly warm: WarmService["warm"]; + readonly storage: StorageNamespace; + readonly logger: Logger; + readonly timers: TimerDeps; + /** Called when surface subscribers should re-fetch the spec. */ + readonly onSurfaceChange: () => void; +} + +const DEFAULT_STATE: ConversationState = { + enabled: true, + intervalMs: DEFAULT_INTERVAL_MS, + active: false, + lastPct: 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); + } + } + + function armTimer(conversationId: string): void { + cancelTimer(conversationId); + const state = getState(conversationId); + if (!state.enabled || state.active) return; + + const token = nextToken++; + setState(conversationId, { ...state, token }); + + 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 }); + + const result = await deps.warm(conversationId, { + ...(ctx.cwd !== undefined ? { cwd: ctx.cwd } : {}), + ...(ctx.modelName !== undefined ? { modelName: ctx.modelName } : {}), + }); + + // Re-check token after async warm — result may be stale + const currentState = getState(conversationId); + if (!isTokenCurrent(currentState.token, token)) { + deps.logger.debug("cache-warming: discarding stale warm result", { + conversationId, + }); + return; + } + + if ("error" in result) { + deps.logger.debug("cache-warming: warm returned error (normal)", { + conversationId, + error: result.error, + }); + } else { + const pct = computeCachePct(result.inputTokens, result.cacheReadTokens); + setState(conversationId, { ...currentState, lastPct: pct }); + deps.onSurfaceChange(); + deps.logger.debug("cache-warming: warm complete", { + conversationId, + pct, + }); + } + + // Re-arm for next cycle + armTimer(conversationId); + } + + 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); + }, + + 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); + } + }, + + getState, + getContext, + + async setEnabled(conversationId, enabled) { + const settings = await loadSettings(conversationId); + const updated = { ...settings, enabled }; + await persistSettings(conversationId, updated); + mergeState(conversationId, { enabled }); + + 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); + } + }, + }; +} diff --git a/packages/cache-warming/tsconfig.json b/packages/cache-warming/tsconfig.json new file mode 100644 index 0000000..6557731 --- /dev/null +++ b/packages/cache-warming/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [ + { "path": "../kernel" }, + { "path": "../session-orchestrator" }, + { "path": "../surface-registry" }, + { "path": "../ui-contract" } + ] +} |
