summaryrefslogtreecommitdiffhomepage
path: root/packages/cache-warming/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-11 13:08:38 +0900
committerAdam Malczewski <[email protected]>2026-06-11 13:08:38 +0900
commitffbbcf692a97ec8648af39353b49f32896367207 (patch)
tree2e2ddfd03d4a868f4a4ba12e20586cc03c37f90a /packages/cache-warming/src
parent27fd0be36b2f6395249de5aacc86e41fe4e0207f (diff)
downloaddispatch-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.ts99
-rw-r--r--packages/cache-warming/src/index.ts5
-rw-r--r--packages/cache-warming/src/pure.test.ts135
-rw-r--r--packages/cache-warming/src/pure.ts96
-rw-r--r--packages/cache-warming/src/warmer.test.ts112
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);
+ });
});