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/src/extension.ts | |
| 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/src/extension.ts')
| -rw-r--r-- | packages/cache-warming/src/extension.ts | 130 |
1 files changed, 130 insertions, 0 deletions
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, +}; |
