diff options
| author | Adam Malczewski <[email protected]> | 2026-06-11 13:08:38 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-11 13:08:38 +0900 |
| commit | ffbbcf692a97ec8648af39353b49f32896367207 (patch) | |
| tree | 2e2ddfd03d4a868f4a4ba12e20586cc03c37f90a /packages/cache-warming/src | |
| parent | 27fd0be36b2f6395249de5aacc86e41fe4e0207f (diff) | |
| download | dispatch-ffbbcf692a97ec8648af39353b49f32896367207.tar.gz dispatch-ffbbcf692a97ec8648af39353b49f32896367207.zip | |
feat(surfaces): NumberField + per-conversation surface scoping; cache-warming controls
Extend the surface framework so cache-warming exposes per-conversation controls:
- ui-contract: add NumberField (settable free-value numeric) to SurfaceField;
add optional conversationId to subscribe/unsubscribe/invoke + surface/update
- surface-registry: SurfaceContext { conversationId? } on getSpec/invoke (backward-compatible)
- transport-ws: thread conversationId; key subscriptions by (surfaceId, conversationId);
tag surface/update replies with conversationId
- cache-warming: per-conversation surface — Toggle(enabled) + Number(interval seconds,
cache-warming/set-interval) + Stat(last cache %); drop the currentConversationId closure
Global surfaces (surface-loaded-extensions) unchanged. 784 vitest + 109 bun = 893 tests;
tsc -b EXIT 0; biome clean.
Diffstat (limited to 'packages/cache-warming/src')
| -rw-r--r-- | packages/cache-warming/src/extension.ts | 99 | ||||
| -rw-r--r-- | packages/cache-warming/src/index.ts | 5 | ||||
| -rw-r--r-- | packages/cache-warming/src/pure.test.ts | 135 | ||||
| -rw-r--r-- | packages/cache-warming/src/pure.ts | 96 | ||||
| -rw-r--r-- | packages/cache-warming/src/warmer.test.ts | 112 |
5 files changed, 397 insertions, 50 deletions
diff --git a/packages/cache-warming/src/extension.ts b/packages/cache-warming/src/extension.ts index 16515a8..26d429b 100644 --- a/packages/cache-warming/src/extension.ts +++ b/packages/cache-warming/src/extension.ts @@ -1,8 +1,14 @@ import type { Extension, HostAPI, Manifest } from "@dispatch/kernel"; import { cacheWarmHandle, turnSettled, turnStarted } from "@dispatch/session-orchestrator"; -import type { SurfaceProvider } from "@dispatch/surface-registry"; +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, +} from "./pure.js"; import { createCacheWarmer } from "./warmer.js"; export const manifest: Manifest = { @@ -19,41 +25,13 @@ export const manifest: Manifest = { }, }; -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; + const subscribers = new Set<() => void>(); - // 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; @@ -76,46 +54,67 @@ export function activate(host: HostAPI): void { }, }, onSurfaceChange: () => { - // Surface subscribers will re-fetch on next getSpec() + for (const notify of subscribers) { + notify(); + } }, }); 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 } : {}), }); }); + 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); + } + + 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", }, - 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); - } + getSpec, + invoke, + subscribe(onChange) { + subscribers.add(onChange); + return () => { + subscribers.delete(onChange); + }; }, }; diff --git a/packages/cache-warming/src/index.ts b/packages/cache-warming/src/index.ts index 8670dc5..d77f4ec 100644 --- a/packages/cache-warming/src/index.ts +++ b/packages/cache-warming/src/index.ts @@ -1,12 +1,17 @@ export { extension, manifest } from "./extension.js"; export { + buildConversationSpec, + buildDefaultSpec, type ConversationSettings, type ConversationState, computeCachePct, DEFAULT_INTERVAL_MS, isTokenCurrent, MIN_INTERVAL_MS, + msToSeconds, + parseIntervalPayload, parseSettings, + secondsToMs, serializeSettings, settingsKey, shouldWarm, diff --git a/packages/cache-warming/src/pure.test.ts b/packages/cache-warming/src/pure.test.ts index 820260b..1c912f2 100644 --- a/packages/cache-warming/src/pure.test.ts +++ b/packages/cache-warming/src/pure.test.ts @@ -1,9 +1,15 @@ import { describe, expect, it } from "vitest"; import type { ConversationState } from "./pure.js"; import { + buildConversationSpec, + buildDefaultSpec, computeCachePct, isTokenCurrent, + MIN_INTERVAL_MS, + msToSeconds, + parseIntervalPayload, parseSettings, + secondsToMs, serializeSettings, shouldWarm, } from "./pure.js"; @@ -107,3 +113,132 @@ describe("parseSettings/serializeSettings round-trip", () => { 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); + }); +}); + +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("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 { 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(); + }); +}); + +describe("buildConversationSpec", () => { + it("builds a per-conversation spec with toggle + number(interval) + last-% fields", () => { + const spec = buildConversationSpec(true, 240_000, 80); + expect(spec.id).toBe("cache-warming"); + expect(spec.region).toBe("side"); + expect(spec.title).toBe("Cache Warming"); + expect(spec.fields).toHaveLength(3); + + 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 stat = spec.fields[2]; + expect(stat).toEqual({ + kind: "stat", + label: "Last Cache %", + value: "80%", + }); + }); + + it("shows — when lastPct is null", () => { + const spec = buildConversationSpec(true, 240_000, null); + const stat = spec.fields[2]; + expect(stat).toEqual({ + kind: "stat", + label: "Last Cache %", + value: "—", + }); + }); + + it("reflects disabled state", () => { + const spec = buildConversationSpec(false, 120_000, 50); + 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", + }); + }); +}); diff --git a/packages/cache-warming/src/pure.ts b/packages/cache-warming/src/pure.ts index 2b00dab..7b91b11 100644 --- a/packages/cache-warming/src/pure.ts +++ b/packages/cache-warming/src/pure.ts @@ -3,6 +3,8 @@ * Every function is input → output; testable without mocks. */ +import type { NumberField, StatField, SurfaceSpec, ToggleField } from "@dispatch/ui-contract"; + // --- Types --- /** Persisted per-conversation settings (storage-facing). */ @@ -93,3 +95,97 @@ export function serializeSettings(settings: ConversationSettings): string { export function settingsKey(conversationId: string): string { 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); +} + +/** + * Convert seconds (from the UI) to intervalMs, flooring at MIN_INTERVAL_MS. + * 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)); +} + +/** + * Build a per-conversation surface spec with toggle + number(interval) + stat fields. + * Pure — no I/O. + */ +export function buildConversationSpec( + enabled: boolean, + intervalMs: number, + lastPct: number | null, +): SurfaceSpec { + const pctDisplay = lastPct === null ? "—" : `${lastPct}%`; + 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, + }; + return { + id: "cache-warming", + region: "side", + title: "Cache Warming", + fields: [toggle, interval, stat], + }; +} + +/** + * Build a default surface spec when no conversation is in focus. + * 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", + }, + ], + }; +} + +/** + * Parse the payload for a set-interval action. + * Accepts a bare number OR { value: number }. Returns the seconds value, or + * 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; +} diff --git a/packages/cache-warming/src/warmer.test.ts b/packages/cache-warming/src/warmer.test.ts index 9b9ba93..9865877 100644 --- a/packages/cache-warming/src/warmer.test.ts +++ b/packages/cache-warming/src/warmer.test.ts @@ -1,6 +1,7 @@ import type { Logger, Span } from "@dispatch/kernel"; import type { WarmResult } from "@dispatch/session-orchestrator"; import { describe, expect, it } from "vitest"; +import { MIN_INTERVAL_MS } from "./pure.js"; import { createCacheWarmer, type TimerDeps } from "./warmer.js"; function memStorage(): StorageNamespace { @@ -204,4 +205,115 @@ describe("CacheWarmer", () => { expect(warmCount).toBe(2); }); + + it("setIntervalMs converts seconds→ms, floors at MIN_INTERVAL_MS, and re-arms", 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: () => {}, + }); + + // Enable and settle to arm the timer + 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 + timers.flush(); + await new Promise((r) => setTimeout(r, 10)); + expect(warmCalls).toContain("conv-1"); + }); + + it("setIntervalMs clamps values below MIN_INTERVAL_MS", async () => { + const timers = fakeTimers(); + const warmer = createCacheWarmer({ + warm: async () => WARM_RESULT, + storage: memStorage(), + logger: makeLogger(), + timers, + onSurfaceChange: () => {}, + }); + + 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 timers = fakeTimers(); + const warmer = createCacheWarmer({ + warm: async () => WARM_RESULT, + storage: memStorage(), + logger: makeLogger(), + timers, + onSurfaceChange: () => {}, + }); + + 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 timers = fakeTimers(); + const warmer = createCacheWarmer({ + warm: async () => WARM_RESULT, + storage: memStorage(), + logger: makeLogger(), + timers, + onSurfaceChange: () => {}, + }); + + // Default is enabled + expect(warmer.getState("conv-1").enabled).toBe(true); + + // Toggle off + await warmer.setEnabled("conv-1", false); + expect(warmer.getState("conv-1").enabled).toBe(false); + + // Toggle on + await warmer.setEnabled("conv-1", true); + expect(warmer.getState("conv-1").enabled).toBe(true); + }); + + it("onSurfaceChange is called when settings change", async () => { + const timers = fakeTimers(); + let changeCount = 0; + const warmer = createCacheWarmer({ + warm: async () => WARM_RESULT, + storage: memStorage(), + logger: makeLogger(), + timers, + onSurfaceChange: () => { + changeCount++; + }, + }); + + await warmer.setEnabled("conv-1", false); + expect(changeCount).toBe(1); + + await warmer.setIntervalMs("conv-1", 30_000); + expect(changeCount).toBe(2); + }); }); |
