/** * Pure core for cache-warming — zero I/O, zero ambient state. * Every function is input → output; testable without mocks. */ import type { 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; } /** 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; } /** 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); } /** * Compute expected cache retention rate from token counts. * Of the cacheable prefix the warm touched, how much was still warm (read back) * vs. had to be (re)written. * Returns an integer in [0, 100]. cacheRead + cacheWrite ≤ 0 → 0. */ export function computeExpectedCacheRate( cacheReadTokens: number, cacheWriteTokens: number, ): number { const total = cacheReadTokens + cacheWriteTokens; if (total <= 0) return 0; return Math.round((cacheReadTokens / total) * 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. * * Warming defaults to OFF (CR-4a): a new conversation never schedules warms * 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; 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); } /** The storage key for a conversation's settings. */ 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, 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], }; } /** * 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).value === "number" ) { const v = (payload as Record).value as number; if (Number.isFinite(v) && v > 0) return v; } return null; }