diff options
Diffstat (limited to 'src/features/cache-warming')
| -rw-r--r-- | src/features/cache-warming/index.ts | 4 | ||||
| -rw-r--r-- | src/features/cache-warming/logic/view-model.test.ts | 406 | ||||
| -rw-r--r-- | src/features/cache-warming/logic/view-model.ts | 244 | ||||
| -rw-r--r-- | src/features/cache-warming/ui/CacheWarmingView.svelte | 468 |
4 files changed, 566 insertions, 556 deletions
diff --git a/src/features/cache-warming/index.ts b/src/features/cache-warming/index.ts index c432de6..976844b 100644 --- a/src/features/cache-warming/index.ts +++ b/src/features/cache-warming/index.ts @@ -3,6 +3,6 @@ export { default as CacheWarmingView } from "./ui/CacheWarmingView.svelte"; /** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ export const manifest = { - name: "cache-warming", - description: "Prompt-cache warming controls, history, and countdown", + name: "cache-warming", + description: "Prompt-cache warming controls, history, and countdown", } as const; diff --git a/src/features/cache-warming/logic/view-model.test.ts b/src/features/cache-warming/logic/view-model.test.ts index d5ea901..39fec80 100644 --- a/src/features/cache-warming/logic/view-model.test.ts +++ b/src/features/cache-warming/logic/view-model.test.ts @@ -1,228 +1,228 @@ import type { SurfaceSpec } from "@dispatch/ui-contract"; import { describe, expect, it } from "vitest"; import { - clampMinutes, - clampSeconds, - colorClass, - formatCountdown, - formatWarmLabel, - fromMinSec, - initialWarmingState, - observeWarm, - parseControls, - parsePct, - secondsUntilNext, - statusForPct, - toMinSec, + clampMinutes, + clampSeconds, + colorClass, + formatCountdown, + formatWarmLabel, + fromMinSec, + initialWarmingState, + observeWarm, + parseControls, + parsePct, + secondsUntilNext, + statusForPct, + toMinSec, } from "./view-model"; const spec = (fields: SurfaceSpec["fields"]): SurfaceSpec => ({ - id: "cache-warming", - region: "side", - title: "Cache Warming", - fields, + id: "cache-warming", + region: "side", + title: "Cache Warming", + fields, }); describe("parsePct", () => { - it("parses a percentage string", () => { - expect(parsePct("100%")).toBe(100); - expect(parsePct("93 %")).toBe(93); - expect(parsePct("0%")).toBe(0); - }); - it("returns null for a dash / non-numeric", () => { - expect(parsePct("—")).toBeNull(); - expect(parsePct("n/a")).toBeNull(); - }); + it("parses a percentage string", () => { + expect(parsePct("100%")).toBe(100); + expect(parsePct("93 %")).toBe(93); + expect(parsePct("0%")).toBe(0); + }); + it("returns null for a dash / non-numeric", () => { + expect(parsePct("—")).toBeNull(); + expect(parsePct("n/a")).toBeNull(); + }); }); describe("parseControls", () => { - it("returns empty defaults for a null spec", () => { - const c = parseControls(null); - expect(c).toEqual({ - enabled: false, - toggleActionId: null, - intervalSeconds: 0, - setIntervalActionId: null, - lastPct: null, - retentionPct: null, - nextWarmAt: null, - lastWarmAt: null, - }); - }); - - it("extracts toggle / number / both stats / timer by kind", () => { - const c = parseControls( - spec([ - { - kind: "toggle", - label: "Enabled", - value: true, - action: { actionId: "cache-warming/toggle" }, - }, - { - kind: "number", - label: "Interval", - value: 240, - unit: "s", - action: { actionId: "cache-warming/set-interval" }, - }, - { kind: "stat", label: "Last cache rate", value: "61%" }, - { kind: "stat", label: "Cache retention", value: "100%" }, - { - kind: "custom", - rendererId: "cache-warming-timer", - payload: { nextWarmAt: 1_700_000_240_000, lastWarmAt: 1_700_000_000_000 }, - }, - ]), - ); - expect(c).toEqual({ - enabled: true, - toggleActionId: "cache-warming/toggle", - intervalSeconds: 240, - setIntervalActionId: "cache-warming/set-interval", - lastPct: 61, - retentionPct: 100, - nextWarmAt: 1_700_000_240_000, - lastWarmAt: 1_700_000_000_000, - }); - }); - - it("tells the retention stat apart from the rate stat by label", () => { - const c = parseControls( - spec([ - { kind: "stat", label: "Cache retention", value: "100%" }, - { kind: "stat", label: "Last cache rate", value: "61%" }, - ]), - ); - expect(c.retentionPct).toBe(100); - expect(c.lastPct).toBe(61); - }); - - it("treats a '—' stat as no pct", () => { - const c = parseControls(spec([{ kind: "stat", label: "Last cache rate", value: "—" }])); - expect(c.lastPct).toBeNull(); - }); - - it("ignores an unknown custom renderer and a malformed timer payload", () => { - const c = parseControls( - spec([ - { kind: "custom", rendererId: "something-else", payload: { nextWarmAt: 5 } }, - { kind: "custom", rendererId: "cache-warming-timer", payload: "nope" }, - ]), - ); - expect(c.nextWarmAt).toBeNull(); - expect(c.lastWarmAt).toBeNull(); - }); + it("returns empty defaults for a null spec", () => { + const c = parseControls(null); + expect(c).toEqual({ + enabled: false, + toggleActionId: null, + intervalSeconds: 0, + setIntervalActionId: null, + lastPct: null, + retentionPct: null, + nextWarmAt: null, + lastWarmAt: null, + }); + }); + + it("extracts toggle / number / both stats / timer by kind", () => { + const c = parseControls( + spec([ + { + kind: "toggle", + label: "Enabled", + value: true, + action: { actionId: "cache-warming/toggle" }, + }, + { + kind: "number", + label: "Interval", + value: 240, + unit: "s", + action: { actionId: "cache-warming/set-interval" }, + }, + { kind: "stat", label: "Last cache rate", value: "61%" }, + { kind: "stat", label: "Cache retention", value: "100%" }, + { + kind: "custom", + rendererId: "cache-warming-timer", + payload: { nextWarmAt: 1_700_000_240_000, lastWarmAt: 1_700_000_000_000 }, + }, + ]), + ); + expect(c).toEqual({ + enabled: true, + toggleActionId: "cache-warming/toggle", + intervalSeconds: 240, + setIntervalActionId: "cache-warming/set-interval", + lastPct: 61, + retentionPct: 100, + nextWarmAt: 1_700_000_240_000, + lastWarmAt: 1_700_000_000_000, + }); + }); + + it("tells the retention stat apart from the rate stat by label", () => { + const c = parseControls( + spec([ + { kind: "stat", label: "Cache retention", value: "100%" }, + { kind: "stat", label: "Last cache rate", value: "61%" }, + ]), + ); + expect(c.retentionPct).toBe(100); + expect(c.lastPct).toBe(61); + }); + + it("treats a '—' stat as no pct", () => { + const c = parseControls(spec([{ kind: "stat", label: "Last cache rate", value: "—" }])); + expect(c.lastPct).toBeNull(); + }); + + it("ignores an unknown custom renderer and a malformed timer payload", () => { + const c = parseControls( + spec([ + { kind: "custom", rendererId: "something-else", payload: { nextWarmAt: 5 } }, + { kind: "custom", rendererId: "cache-warming-timer", payload: "nope" }, + ]), + ); + expect(c.nextWarmAt).toBeNull(); + expect(c.lastWarmAt).toBeNull(); + }); }); describe("interval ↔ min/sec", () => { - it("clampSeconds caps at 0..59", () => { - expect(clampSeconds(75)).toBe(59); - expect(clampSeconds(-3)).toBe(0); - expect(clampSeconds(30)).toBe(30); - expect(clampSeconds(Number.NaN)).toBe(0); - }); - it("clampMinutes floors at 0", () => { - expect(clampMinutes(-1)).toBe(0); - expect(clampMinutes(4)).toBe(4); - }); - it("toMinSec splits total seconds", () => { - expect(toMinSec(240)).toEqual({ minutes: 4, seconds: 0 }); - expect(toMinSec(125)).toEqual({ minutes: 2, seconds: 5 }); - expect(toMinSec(45)).toEqual({ minutes: 0, seconds: 45 }); - }); - it("fromMinSec combines (clamping seconds to 59)", () => { - expect(fromMinSec(4, 0)).toBe(240); - expect(fromMinSec(2, 5)).toBe(125); - expect(fromMinSec(1, 75)).toBe(119); // 75s clamped to 59 - }); + it("clampSeconds caps at 0..59", () => { + expect(clampSeconds(75)).toBe(59); + expect(clampSeconds(-3)).toBe(0); + expect(clampSeconds(30)).toBe(30); + expect(clampSeconds(Number.NaN)).toBe(0); + }); + it("clampMinutes floors at 0", () => { + expect(clampMinutes(-1)).toBe(0); + expect(clampMinutes(4)).toBe(4); + }); + it("toMinSec splits total seconds", () => { + expect(toMinSec(240)).toEqual({ minutes: 4, seconds: 0 }); + expect(toMinSec(125)).toEqual({ minutes: 2, seconds: 5 }); + expect(toMinSec(45)).toEqual({ minutes: 0, seconds: 45 }); + }); + it("fromMinSec combines (clamping seconds to 59)", () => { + expect(fromMinSec(4, 0)).toBe(240); + expect(fromMinSec(2, 5)).toBe(125); + expect(fromMinSec(1, 75)).toBe(119); // 75s clamped to 59 + }); }); describe("status + formatting", () => { - it("statusForPct buckets high/mid/low", () => { - expect(statusForPct(100)).toBe("success"); - expect(statusForPct(80)).toBe("success"); - expect(statusForPct(60)).toBe("warning"); - expect(statusForPct(40)).toBe("warning"); - expect(statusForPct(10)).toBe("error"); - }); - it("colorClass maps to literal DaisyUI classes", () => { - expect(colorClass("success")).toBe("text-success"); - expect(colorClass("warning")).toBe("text-warning"); - expect(colorClass("error")).toBe("text-error"); - }); - it("formatWarmLabel matches the manual-warm phrasing", () => { - expect(formatWarmLabel(100)).toBe("Warmed — 100% cache hit"); - expect(formatWarmLabel(92.6)).toBe("Warmed — 93% cache hit"); - }); - it("formatCountdown renders s and m:ss", () => { - expect(formatCountdown(9)).toBe("9s"); - expect(formatCountdown(59)).toBe("59s"); - expect(formatCountdown(60)).toBe("1:00"); - expect(formatCountdown(185)).toBe("3:05"); - expect(formatCountdown(-5)).toBe("0s"); - }); + it("statusForPct buckets high/mid/low", () => { + expect(statusForPct(100)).toBe("success"); + expect(statusForPct(80)).toBe("success"); + expect(statusForPct(60)).toBe("warning"); + expect(statusForPct(40)).toBe("warning"); + expect(statusForPct(10)).toBe("error"); + }); + it("colorClass maps to literal DaisyUI classes", () => { + expect(colorClass("success")).toBe("text-success"); + expect(colorClass("warning")).toBe("text-warning"); + expect(colorClass("error")).toBe("text-error"); + }); + it("formatWarmLabel matches the manual-warm phrasing", () => { + expect(formatWarmLabel(100)).toBe("Warmed — 100% cache hit"); + expect(formatWarmLabel(92.6)).toBe("Warmed — 93% cache hit"); + }); + it("formatCountdown renders s and m:ss", () => { + expect(formatCountdown(9)).toBe("9s"); + expect(formatCountdown(59)).toBe("59s"); + expect(formatCountdown(60)).toBe("1:00"); + expect(formatCountdown(185)).toBe("3:05"); + expect(formatCountdown(-5)).toBe("0s"); + }); }); describe("warming history reducer (observeWarm)", () => { - it("starts empty", () => { - const s = initialWarmingState(); - expect(s.history).toEqual([]); - expect(s.lastWarmAt).toBeNull(); - }); - - it("records a new entry on each new authoritative lastWarmAt", () => { - let s = initialWarmingState(); - s = observeWarm(s, 1000, 100); - s = observeWarm(s, 2000, 90); - expect(s.history).toEqual([ - { pct: 90, at: 2000 }, - { pct: 100, at: 1000 }, - ]); - expect(s.lastWarmAt).toBe(2000); - }); - - it("de-duplicates on the timestamp, not the pct (a re-pushed surface → no dup)", () => { - let s = initialWarmingState(); - s = observeWarm(s, 1000, 100); // warm - s = observeWarm(s, 1000, 100); // toggle/interval re-push, same lastWarmAt → skip - expect(s.history).toHaveLength(1); - }); - - it("records two warms with the SAME pct (distinct timestamps both count)", () => { - let s = initialWarmingState(); - s = observeWarm(s, 1000, 100); - s = observeWarm(s, 2000, 100); - expect(s.history.map((e) => e.at)).toEqual([2000, 1000]); - }); - - it("ignores a null lastWarmAt; a null pct advances the key without an entry", () => { - let s = initialWarmingState(); - s = observeWarm(s, null, 100); - expect(s.history).toEqual([]); - s = observeWarm(s, 1000, null); - expect(s.history).toEqual([]); - expect(s.lastWarmAt).toBe(1000); - }); + it("starts empty", () => { + const s = initialWarmingState(); + expect(s.history).toEqual([]); + expect(s.lastWarmAt).toBeNull(); + }); + + it("records a new entry on each new authoritative lastWarmAt", () => { + let s = initialWarmingState(); + s = observeWarm(s, 1000, 100); + s = observeWarm(s, 2000, 90); + expect(s.history).toEqual([ + { pct: 90, at: 2000 }, + { pct: 100, at: 1000 }, + ]); + expect(s.lastWarmAt).toBe(2000); + }); + + it("de-duplicates on the timestamp, not the pct (a re-pushed surface → no dup)", () => { + let s = initialWarmingState(); + s = observeWarm(s, 1000, 100); // warm + s = observeWarm(s, 1000, 100); // toggle/interval re-push, same lastWarmAt → skip + expect(s.history).toHaveLength(1); + }); + + it("records two warms with the SAME pct (distinct timestamps both count)", () => { + let s = initialWarmingState(); + s = observeWarm(s, 1000, 100); + s = observeWarm(s, 2000, 100); + expect(s.history.map((e) => e.at)).toEqual([2000, 1000]); + }); + + it("ignores a null lastWarmAt; a null pct advances the key without an entry", () => { + let s = initialWarmingState(); + s = observeWarm(s, null, 100); + expect(s.history).toEqual([]); + s = observeWarm(s, 1000, null); + expect(s.history).toEqual([]); + expect(s.lastWarmAt).toBe(1000); + }); }); describe("secondsUntilNext (authoritative, from nextWarmAt)", () => { - it("is null when nothing is scheduled (nextWarmAt null)", () => { - expect(secondsUntilNext(null, 5000)).toBeNull(); - }); - - it("counts down to nextWarmAt, floored at 0", () => { - expect(secondsUntilNext(10_000, 10_000)).toBe(0); - expect(secondsUntilNext(250_000, 10_000)).toBe(240); - expect(secondsUntilNext(70_000, 10_000)).toBe(60); - }); - - it("treats a nextWarmAt past the stale grace as not scheduled (belt-and-braces)", () => { - // Within the 3s grace an on-time warm may briefly read "0s"… - expect(secondsUntilNext(10_000, 11_000)).toBe(0); - expect(secondsUntilNext(10_000, 13_000)).toBe(0); - // …but beyond it the value is stale → null (the "waiting…" state). - expect(secondsUntilNext(10_000, 13_001)).toBeNull(); - expect(secondsUntilNext(5_000, 999_999)).toBeNull(); - }); + it("is null when nothing is scheduled (nextWarmAt null)", () => { + expect(secondsUntilNext(null, 5000)).toBeNull(); + }); + + it("counts down to nextWarmAt, floored at 0", () => { + expect(secondsUntilNext(10_000, 10_000)).toBe(0); + expect(secondsUntilNext(250_000, 10_000)).toBe(240); + expect(secondsUntilNext(70_000, 10_000)).toBe(60); + }); + + it("treats a nextWarmAt past the stale grace as not scheduled (belt-and-braces)", () => { + // Within the 3s grace an on-time warm may briefly read "0s"… + expect(secondsUntilNext(10_000, 11_000)).toBe(0); + expect(secondsUntilNext(10_000, 13_000)).toBe(0); + // …but beyond it the value is stale → null (the "waiting…" state). + expect(secondsUntilNext(10_000, 13_001)).toBeNull(); + expect(secondsUntilNext(5_000, 999_999)).toBeNull(); + }); }); diff --git a/src/features/cache-warming/logic/view-model.ts b/src/features/cache-warming/logic/view-model.ts index eb105f6..bc8cf2e 100644 --- a/src/features/cache-warming/logic/view-model.ts +++ b/src/features/cache-warming/logic/view-model.ts @@ -15,37 +15,37 @@ import type { SurfaceSpec } from "@dispatch/ui-contract"; // ── Manual-warm port (consumer-defines-port; the composition root adapts the // store's `POST /chat/warm` result to this shape). ────────────────────────── export type WarmFeedback = - | { readonly ok: true; readonly cachePct: number; readonly expectedCacheRate: number } - | { readonly ok: false; readonly error: string }; + | { readonly ok: true; readonly cachePct: number; readonly expectedCacheRate: number } + | { readonly ok: false; readonly error: string }; export type WarmNow = () => Promise<WarmFeedback | null>; // ── Parsed surface controls ─────────────────────────────────────────────────── export interface ParsedControls { - readonly enabled: boolean; - readonly toggleActionId: string | null; - readonly intervalSeconds: number; - readonly setIntervalActionId: string | null; - /** Most recent warm's cache-hit %, from the "last cache rate" stat (`null` when "—"/absent). */ - readonly lastPct: number | null; - /** Cross-turn retention %, from the "cache retention" stat (`null` when "—"/absent). */ - readonly retentionPct: number | null; - /** Authoritative epoch-ms the next AUTOMATIC warm fires, or `null` when not scheduled. */ - readonly nextWarmAt: number | null; - /** Authoritative epoch-ms of the most recent completed warm, or `null` if none. */ - readonly lastWarmAt: number | null; + readonly enabled: boolean; + readonly toggleActionId: string | null; + readonly intervalSeconds: number; + readonly setIntervalActionId: string | null; + /** Most recent warm's cache-hit %, from the "last cache rate" stat (`null` when "—"/absent). */ + readonly lastPct: number | null; + /** Cross-turn retention %, from the "cache retention" stat (`null` when "—"/absent). */ + readonly retentionPct: number | null; + /** Authoritative epoch-ms the next AUTOMATIC warm fires, or `null` when not scheduled. */ + readonly nextWarmAt: number | null; + /** Authoritative epoch-ms of the most recent completed warm, or `null` if none. */ + readonly lastWarmAt: number | null; } const EMPTY_CONTROLS: ParsedControls = { - enabled: false, - toggleActionId: null, - intervalSeconds: 0, - setIntervalActionId: null, - lastPct: null, - retentionPct: null, - nextWarmAt: null, - lastWarmAt: null, + enabled: false, + toggleActionId: null, + intervalSeconds: 0, + setIntervalActionId: null, + lastPct: null, + retentionPct: null, + nextWarmAt: null, + lastWarmAt: null, }; /** The `cache-warming-timer` custom field's renderer id (this feature owns it). */ @@ -53,24 +53,24 @@ const TIMER_RENDERER_ID = "cache-warming-timer"; /** Parse a stat's display string (e.g. "100%", "93 %", "—") into a number or null. */ export function parsePct(value: string): number | null { - const match = value.match(/-?\d+(?:\.\d+)?/); - if (match === null) return null; - const n = Number(match[0]); - return Number.isFinite(n) ? n : null; + const match = value.match(/-?\d+(?:\.\d+)?/); + if (match === null) return null; + const n = Number(match[0]); + return Number.isFinite(n) ? n : null; } /** A finite number, else null. */ function numOrNull(v: unknown): number | null { - return typeof v === "number" && Number.isFinite(v) ? v : null; + return typeof v === "number" && Number.isFinite(v) ? v : null; } /** Pull the authoritative `nextWarmAt`/`lastWarmAt` out of the timer custom payload. */ function parseTimer(payload: unknown): { nextWarmAt: number | null; lastWarmAt: number | null } { - if (typeof payload !== "object" || payload === null) { - return { nextWarmAt: null, lastWarmAt: null }; - } - const p = payload as Record<string, unknown>; - return { nextWarmAt: numOrNull(p.nextWarmAt), lastWarmAt: numOrNull(p.lastWarmAt) }; + if (typeof payload !== "object" || payload === null) { + return { nextWarmAt: null, lastWarmAt: null }; + } + const p = payload as Record<string, unknown>; + return { nextWarmAt: numOrNull(p.nextWarmAt), lastWarmAt: numOrNull(p.lastWarmAt) }; } /** @@ -80,79 +80,79 @@ function parseTimer(payload: unknown): { nextWarmAt: number | null; lastWarmAt: * absent. */ export function parseControls(spec: SurfaceSpec | null): ParsedControls { - if (spec === null) return EMPTY_CONTROLS; - let enabled = false; - let toggleActionId: string | null = null; - let intervalSeconds = 0; - let setIntervalActionId: string | null = null; - let lastPct: number | null = null; - let retentionPct: number | null = null; - let nextWarmAt: number | null = null; - let lastWarmAt: number | null = null; - let seenToggle = false; - let seenNumber = false; - let seenRateStat = false; - for (const field of spec.fields) { - if (field.kind === "toggle" && !seenToggle) { - enabled = field.value; - toggleActionId = field.action.actionId; - seenToggle = true; - } else if (field.kind === "number" && !seenNumber) { - intervalSeconds = field.value; - setIntervalActionId = field.action.actionId; - seenNumber = true; - } else if (field.kind === "stat") { - // Retention is told apart by its label; everything else is the cache rate - // (first one wins, so a stray later stat can't clobber it). - if (/retention/i.test(field.label)) { - retentionPct = parsePct(field.value); - } else if (!seenRateStat) { - lastPct = parsePct(field.value); - seenRateStat = true; - } - } else if (field.kind === "custom" && field.rendererId === TIMER_RENDERER_ID) { - const timer = parseTimer(field.payload); - nextWarmAt = timer.nextWarmAt; - lastWarmAt = timer.lastWarmAt; - } - } - return { - enabled, - toggleActionId, - intervalSeconds, - setIntervalActionId, - lastPct, - retentionPct, - nextWarmAt, - lastWarmAt, - }; + if (spec === null) return EMPTY_CONTROLS; + let enabled = false; + let toggleActionId: string | null = null; + let intervalSeconds = 0; + let setIntervalActionId: string | null = null; + let lastPct: number | null = null; + let retentionPct: number | null = null; + let nextWarmAt: number | null = null; + let lastWarmAt: number | null = null; + let seenToggle = false; + let seenNumber = false; + let seenRateStat = false; + for (const field of spec.fields) { + if (field.kind === "toggle" && !seenToggle) { + enabled = field.value; + toggleActionId = field.action.actionId; + seenToggle = true; + } else if (field.kind === "number" && !seenNumber) { + intervalSeconds = field.value; + setIntervalActionId = field.action.actionId; + seenNumber = true; + } else if (field.kind === "stat") { + // Retention is told apart by its label; everything else is the cache rate + // (first one wins, so a stray later stat can't clobber it). + if (/retention/i.test(field.label)) { + retentionPct = parsePct(field.value); + } else if (!seenRateStat) { + lastPct = parsePct(field.value); + seenRateStat = true; + } + } else if (field.kind === "custom" && field.rendererId === TIMER_RENDERER_ID) { + const timer = parseTimer(field.payload); + nextWarmAt = timer.nextWarmAt; + lastWarmAt = timer.lastWarmAt; + } + } + return { + enabled, + toggleActionId, + intervalSeconds, + setIntervalActionId, + lastPct, + retentionPct, + nextWarmAt, + lastWarmAt, + }; } // ── Interval ↔ minutes/seconds (seconds capped at 59) ───────────────────────── export interface MinSec { - readonly minutes: number; - readonly seconds: number; + readonly minutes: number; + readonly seconds: number; } export function clampSeconds(n: number): number { - if (!Number.isFinite(n)) return 0; - return Math.min(59, Math.max(0, Math.floor(n))); + if (!Number.isFinite(n)) return 0; + return Math.min(59, Math.max(0, Math.floor(n))); } export function clampMinutes(n: number): number { - if (!Number.isFinite(n)) return 0; - return Math.max(0, Math.floor(n)); + if (!Number.isFinite(n)) return 0; + return Math.max(0, Math.floor(n)); } export function toMinSec(totalSeconds: number): MinSec { - const total = Math.max(0, Math.floor(totalSeconds)); - return { minutes: Math.floor(total / 60), seconds: total % 60 }; + const total = Math.max(0, Math.floor(totalSeconds)); + return { minutes: Math.floor(total / 60), seconds: total % 60 }; } /** Combine a minutes + seconds pair (each clamped) into total seconds. */ export function fromMinSec(minutes: number, seconds: number): number { - return clampMinutes(minutes) * 60 + clampSeconds(seconds); + return clampMinutes(minutes) * 60 + clampSeconds(seconds); } // ── Status + formatting ─────────────────────────────────────────────────────── @@ -161,56 +161,56 @@ export type WarmStatus = "success" | "warning" | "error"; /** Cache-hit % → semantic status (green high, yellow mid, red low). */ export function statusForPct(pct: number): WarmStatus { - if (pct >= 80) return "success"; - if (pct >= 40) return "warning"; - return "error"; + if (pct >= 80) return "success"; + if (pct >= 40) return "warning"; + return "error"; } /** A status → its DaisyUI text-colour class (full literal so Tailwind keeps it). */ export function colorClass(status: WarmStatus): string { - switch (status) { - case "success": - return "text-success"; - case "warning": - return "text-warning"; - case "error": - return "text-error"; - } + switch (status) { + case "success": + return "text-success"; + case "warning": + return "text-warning"; + case "error": + return "text-error"; + } } /** The status line for a warm, matching the manual-warm feedback phrasing. */ export function formatWarmLabel(pct: number): string { - return `Warmed — ${Math.round(pct)}% cache hit`; + return `Warmed — ${Math.round(pct)}% cache hit`; } /** Seconds → a short countdown string (e.g. "3:05", "9s"). */ export function formatCountdown(seconds: number): string { - const s = Math.max(0, Math.floor(seconds)); - if (s < 60) return `${s}s`; - const m = Math.floor(s / 60); - const rem = s % 60; - return `${m}:${String(rem).padStart(2, "0")}`; + const s = Math.max(0, Math.floor(seconds)); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + const rem = s % 60; + return `${m}:${String(rem).padStart(2, "0")}`; } // ── Warming history reducer (keyed off the authoritative `lastWarmAt`) ───────── export interface WarmEntry { - readonly pct: number; - /** Authoritative epoch-ms of this warm (the surface's `lastWarmAt`). */ - readonly at: number; + readonly pct: number; + /** Authoritative epoch-ms of this warm (the surface's `lastWarmAt`). */ + readonly at: number; } export interface WarmingViewState { - /** Warmings, MOST RECENT FIRST. */ - readonly history: readonly WarmEntry[]; - /** The last authoritative `lastWarmAt` recorded, for change-detection (de-dup key). */ - readonly lastWarmAt: number | null; + /** Warmings, MOST RECENT FIRST. */ + readonly history: readonly WarmEntry[]; + /** The last authoritative `lastWarmAt` recorded, for change-detection (de-dup key). */ + readonly lastWarmAt: number | null; } const MAX_HISTORY = 50; export function initialWarmingState(): WarmingViewState { - return { history: [], lastWarmAt: null }; + return { history: [], lastWarmAt: null }; } /** @@ -221,14 +221,14 @@ export function initialWarmingState(): WarmingViewState { * ignored; a null pct advances the de-dup key without adding an entry. */ export function observeWarm( - state: WarmingViewState, - lastWarmAt: number | null, - pct: number | null, + state: WarmingViewState, + lastWarmAt: number | null, + pct: number | null, ): WarmingViewState { - if (lastWarmAt === null || lastWarmAt === state.lastWarmAt) return state; - if (pct === null) return { ...state, lastWarmAt }; - const history = [{ pct, at: lastWarmAt }, ...state.history].slice(0, MAX_HISTORY); - return { history, lastWarmAt }; + if (lastWarmAt === null || lastWarmAt === state.lastWarmAt) return state; + if (pct === null) return { ...state, lastWarmAt }; + const history = [{ pct, at: lastWarmAt }, ...state.history].slice(0, MAX_HISTORY); + return { history, lastWarmAt }; } /** @@ -248,7 +248,7 @@ const STALE_NEXT_WARM_MS = 3000; * when `nextWarmAt` is stale (further than the grace into the past). */ export function secondsUntilNext(nextWarmAt: number | null, now: number): number | null { - if (nextWarmAt === null) return null; - if (now - nextWarmAt > STALE_NEXT_WARM_MS) return null; - return Math.max(0, Math.ceil((nextWarmAt - now) / 1000)); + if (nextWarmAt === null) return null; + if (now - nextWarmAt > STALE_NEXT_WARM_MS) return null; + return Math.max(0, Math.ceil((nextWarmAt - now) / 1000)); } diff --git a/src/features/cache-warming/ui/CacheWarmingView.svelte b/src/features/cache-warming/ui/CacheWarmingView.svelte index ced5e99..9b3d694 100644 --- a/src/features/cache-warming/ui/CacheWarmingView.svelte +++ b/src/features/cache-warming/ui/CacheWarmingView.svelte @@ -1,234 +1,244 @@ <script lang="ts"> - import type { InvokeMessage, SurfaceSpec } from "@dispatch/ui-contract"; - import { onMount, untrack } from "svelte"; - import { - clampMinutes, - clampSeconds, - colorClass, - formatCountdown, - formatWarmLabel, - fromMinSec, - initialWarmingState, - observeWarm, - parseControls, - secondsUntilNext, - statusForPct, - toMinSec, - type WarmingViewState, - type WarmNow, - } from "../logic/view-model"; - - let { - spec, - canWarm, - onInvoke, - warmNow, - }: { - /** The cache-warming surface spec for the focused conversation, or null. */ - spec: SurfaceSpec | null; - /** Whether a real conversation is focused (a draft has nothing to warm). */ - canWarm: boolean; - onInvoke: (msg: InvokeMessage) => void; - warmNow: WarmNow; - } = $props(); - - const controls = $derived(parseControls(spec)); - - // View-model state (pure reducer) + the injected clock — owned here, not ambient. - let vm = $state<WarmingViewState>(initialWarmingState()); - let now = $state(Date.now()); - let warming = $state(false); - let errorText = $state<string | null>(null); - // Transient result of the most recent manual warm (immediate feedback; history - // itself is driven authoritatively by the surface's `lastWarmAt`). - let manualResult = $state<{ cachePct: number; expectedCacheRate: number } | null>(null); - - // Local interval inputs, seeded from the surface and re-seeded only when the - // surface's interval differs from what's shown (so a stray update mid-edit - // doesn't clobber typing). - let minutes = $state(0); - let seconds = $state(0); - - onMount(() => { - const id = setInterval(() => { - now = Date.now(); - }, 1000); - return () => clearInterval(id); - }); - - // Fold each authoritative warm (new `lastWarmAt`) into history. - $effect(() => { - const at = controls.lastWarmAt; - const pct = controls.lastPct; - untrack(() => { - vm = observeWarm(vm, at, pct); - }); - }); - - // Keep the min/sec inputs in sync with the surface's interval. - $effect(() => { - const target = controls.intervalSeconds; - untrack(() => { - if (fromMinSec(minutes, seconds) !== target) { - const ms = toMinSec(target); - minutes = ms.minutes; - seconds = ms.seconds; - } - }); - }); - - const remaining = $derived(secondsUntilNext(controls.nextWarmAt, now)); - const history = $derived(vm.history); - const latest = $derived(history[0] ?? null); - const earlier = $derived(history.slice(1)); - - function commitInterval() { - const actionId = controls.setIntervalActionId; - if (actionId === null || spec === null) return; - onInvoke({ type: "invoke", surfaceId: spec.id, actionId, payload: fromMinSec(minutes, seconds) }); - } - - function onMinutes(event: Event) { - const next = (event.target as HTMLInputElement).valueAsNumber; - if (Number.isNaN(next)) return; // empty input — ignore, don't clobber to 0 - minutes = clampMinutes(next); - commitInterval(); - } - - function onSeconds(event: Event) { - const next = (event.target as HTMLInputElement).valueAsNumber; - if (Number.isNaN(next)) return; // empty input — ignore, don't clobber to 0 - seconds = clampSeconds(next); - commitInterval(); - } - - function onToggle() { - const actionId = controls.toggleActionId; - if (actionId === null || spec === null) return; - // The toggle action FLIPS server-side; no payload. - onInvoke({ type: "invoke", surfaceId: spec.id, actionId }); - } - - async function handleWarm() { - if (warming) return; - warming = true; - errorText = null; - const result = await warmNow(); - warming = false; - if (result === null) return; - if (result.ok) { - // Immediate feedback only — the authoritative surface `update` (new - // `lastWarmAt`) drives the history via `observeWarm`. - manualResult = { cachePct: result.cachePct, expectedCacheRate: result.expectedCacheRate }; - } else { - manualResult = null; - errorText = result.error; - } - } + import type { InvokeMessage, SurfaceSpec } from "@dispatch/ui-contract"; + import { onMount, untrack } from "svelte"; + import { + clampMinutes, + clampSeconds, + colorClass, + formatCountdown, + formatWarmLabel, + fromMinSec, + initialWarmingState, + observeWarm, + parseControls, + secondsUntilNext, + statusForPct, + toMinSec, + type WarmingViewState, + type WarmNow, + } from "../logic/view-model"; + + let { + spec, + canWarm, + onInvoke, + warmNow, + }: { + /** The cache-warming surface spec for the focused conversation, or null. */ + spec: SurfaceSpec | null; + /** Whether a real conversation is focused (a draft has nothing to warm). */ + canWarm: boolean; + onInvoke: (msg: InvokeMessage) => void; + warmNow: WarmNow; + } = $props(); + + const controls = $derived(parseControls(spec)); + + // View-model state (pure reducer) + the injected clock — owned here, not ambient. + let vm = $state<WarmingViewState>(initialWarmingState()); + let now = $state(Date.now()); + let warming = $state(false); + let errorText = $state<string | null>(null); + // Transient result of the most recent manual warm (immediate feedback; history + // itself is driven authoritatively by the surface's `lastWarmAt`). + let manualResult = $state<{ cachePct: number; expectedCacheRate: number } | null>(null); + + // Local interval inputs, seeded from the surface and re-seeded only when the + // surface's interval differs from what's shown (so a stray update mid-edit + // doesn't clobber typing). + let minutes = $state(0); + let seconds = $state(0); + + onMount(() => { + const id = setInterval(() => { + now = Date.now(); + }, 1000); + return () => clearInterval(id); + }); + + // Fold each authoritative warm (new `lastWarmAt`) into history. + $effect(() => { + const at = controls.lastWarmAt; + const pct = controls.lastPct; + untrack(() => { + vm = observeWarm(vm, at, pct); + }); + }); + + // Keep the min/sec inputs in sync with the surface's interval. + $effect(() => { + const target = controls.intervalSeconds; + untrack(() => { + if (fromMinSec(minutes, seconds) !== target) { + const ms = toMinSec(target); + minutes = ms.minutes; + seconds = ms.seconds; + } + }); + }); + + const remaining = $derived(secondsUntilNext(controls.nextWarmAt, now)); + const history = $derived(vm.history); + const latest = $derived(history[0] ?? null); + const earlier = $derived(history.slice(1)); + + function commitInterval() { + const actionId = controls.setIntervalActionId; + if (actionId === null || spec === null) return; + onInvoke({ + type: "invoke", + surfaceId: spec.id, + actionId, + payload: fromMinSec(minutes, seconds), + }); + } + + function onMinutes(event: Event) { + const next = (event.target as HTMLInputElement).valueAsNumber; + if (Number.isNaN(next)) return; // empty input — ignore, don't clobber to 0 + minutes = clampMinutes(next); + commitInterval(); + } + + function onSeconds(event: Event) { + const next = (event.target as HTMLInputElement).valueAsNumber; + if (Number.isNaN(next)) return; // empty input — ignore, don't clobber to 0 + seconds = clampSeconds(next); + commitInterval(); + } + + function onToggle() { + const actionId = controls.toggleActionId; + if (actionId === null || spec === null) return; + // The toggle action FLIPS server-side; no payload. + onInvoke({ type: "invoke", surfaceId: spec.id, actionId }); + } + + async function handleWarm() { + if (warming) return; + warming = true; + errorText = null; + const result = await warmNow(); + warming = false; + if (result === null) return; + if (result.ok) { + // Immediate feedback only — the authoritative surface `update` (new + // `lastWarmAt`) drives the history via `observeWarm`. + manualResult = { cachePct: result.cachePct, expectedCacheRate: result.expectedCacheRate }; + } else { + manualResult = null; + errorText = result.error; + } + } </script> <div class="flex flex-col gap-3"> - <!-- Enabled --> - <label class="flex items-center justify-between gap-2 text-sm"> - <span>Enabled</span> - <input - type="checkbox" - class="toggle toggle-sm toggle-success" - checked={controls.enabled} - disabled={spec === null} - onchange={onToggle} - /> - </label> - - <!-- Refresh interval: minutes + seconds (seconds capped at 59) --> - <div class="flex items-center justify-between gap-2 text-sm"> - <span>Refresh interval</span> - <span class="flex items-center gap-1"> - <input - type="number" - class="input input-bordered input-sm w-16" - min="0" - value={minutes} - disabled={spec === null} - onchange={onMinutes} - aria-label="Interval minutes" - /> - <span class="opacity-60">m</span> - <input - type="number" - class="input input-bordered input-sm w-16" - min="0" - max="59" - value={seconds} - disabled={spec === null} - onchange={onSeconds} - aria-label="Interval seconds" - /> - <span class="opacity-60">s</span> - </span> - </div> - - <!-- Countdown to the next automatic warm (authoritative: driven by nextWarmAt) --> - {#if !controls.enabled} - <p class="text-xs opacity-50">Warming paused.</p> - {:else if remaining !== null} - <p class="text-xs opacity-70">Next warm in {formatCountdown(remaining)}</p> - {:else} - <p class="text-xs opacity-50">Next warm: waiting…</p> - {/if} - - <!-- Cross-turn retention (the "is warming working?" health signal) --> - {#if controls.retentionPct !== null} - <p class="text-xs {colorClass(statusForPct(controls.retentionPct))}"> - Cache retention: {controls.retentionPct}% - </p> - {/if} - - <!-- Manual trigger --> - <button - type="button" - class="btn btn-sm btn-outline" - disabled={!canWarm || warming} - onclick={handleWarm} - > - {#if warming} - <span class="loading loading-spinner loading-xs"></span> - Warming… - {:else} - Warm now - {/if} - </button> - - {#if !canWarm} - <p class="text-xs opacity-60">Open or start a conversation to control its cache warming.</p> - {:else if errorText} - <p class="text-xs text-error">{errorText}</p> - {:else if manualResult} - <!-- Headline the retention (cache health) over the raw hit %. --> - <p class="text-xs {colorClass(statusForPct(manualResult.expectedCacheRate))}"> - Warmed — {manualResult.expectedCacheRate}% retained ({manualResult.cachePct}% of prompt cached) - </p> - {/if} - - <!-- Warming history: collapse whose title is the most recent warm, coloured by - hit %, with the earlier warmings inside. --> - {#if latest} - <div class="collapse collapse-arrow bg-base-200"> - <input type="checkbox" aria-label="Toggle warming history" /> - <div class="collapse-title min-h-0 py-2 font-normal text-sm {colorClass(statusForPct(latest.pct))}"> - {formatWarmLabel(latest.pct)} - </div> - <div class="collapse-content flex flex-col gap-1 text-sm"> - {#if earlier.length > 0} - {#each earlier as entry, i (i)} - <p class={colorClass(statusForPct(entry.pct))}>{formatWarmLabel(entry.pct)}</p> - {/each} - {:else} - <p class="text-xs opacity-60">No earlier warmings.</p> - {/if} - </div> - </div> - {:else} - <p class="text-xs opacity-60">No warming yet.</p> - {/if} + <!-- Enabled --> + <label class="flex items-center justify-between gap-2 text-sm"> + <span>Enabled</span> + <input + type="checkbox" + class="toggle toggle-sm toggle-success" + checked={controls.enabled} + disabled={spec === null} + onchange={onToggle} + /> + </label> + + <!-- Refresh interval: minutes + seconds (seconds capped at 59) --> + <div class="flex items-center justify-between gap-2 text-sm"> + <span>Refresh interval</span> + <span class="flex items-center gap-1"> + <input + type="number" + class="input input-bordered input-sm w-16" + min="0" + value={minutes} + disabled={spec === null} + onchange={onMinutes} + aria-label="Interval minutes" + /> + <span class="opacity-60">m</span> + <input + type="number" + class="input input-bordered input-sm w-16" + min="0" + max="59" + value={seconds} + disabled={spec === null} + onchange={onSeconds} + aria-label="Interval seconds" + /> + <span class="opacity-60">s</span> + </span> + </div> + + <!-- Countdown to the next automatic warm (authoritative: driven by nextWarmAt) --> + {#if !controls.enabled} + <p class="text-xs opacity-50">Warming paused.</p> + {:else if remaining !== null} + <p class="text-xs opacity-70">Next warm in {formatCountdown(remaining)}</p> + {:else} + <p class="text-xs opacity-50">Next warm: waiting…</p> + {/if} + + <!-- Cross-turn retention (the "is warming working?" health signal) --> + {#if controls.retentionPct !== null} + <p class="text-xs {colorClass(statusForPct(controls.retentionPct))}"> + Cache retention: {controls.retentionPct}% + </p> + {/if} + + <!-- Manual trigger --> + <button + type="button" + class="btn btn-sm btn-outline" + disabled={!canWarm || warming} + onclick={handleWarm} + > + {#if warming} + <span class="loading loading-spinner loading-xs"></span> + Warming… + {:else} + Warm now + {/if} + </button> + + {#if !canWarm} + <p class="text-xs opacity-60">Open or start a conversation to control its cache warming.</p> + {:else if errorText} + <p class="text-xs text-error">{errorText}</p> + {:else if manualResult} + <!-- Headline the retention (cache health) over the raw hit %. --> + <p class="text-xs {colorClass(statusForPct(manualResult.expectedCacheRate))}"> + Warmed — {manualResult.expectedCacheRate}% retained ({manualResult.cachePct}% of prompt + cached) + </p> + {/if} + + <!-- Warming history: collapse whose title is the most recent warm, coloured by + hit %, with the earlier warmings inside. --> + {#if latest} + <div class="collapse collapse-arrow bg-base-200"> + <input type="checkbox" aria-label="Toggle warming history" /> + <div + class="collapse-title min-h-0 py-2 font-normal text-sm {colorClass( + statusForPct(latest.pct), + )}" + > + {formatWarmLabel(latest.pct)} + </div> + <div class="collapse-content flex flex-col gap-1 text-sm"> + {#if earlier.length > 0} + {#each earlier as entry, i (i)} + <p class={colorClass(statusForPct(entry.pct))}>{formatWarmLabel(entry.pct)}</p> + {/each} + {:else} + <p class="text-xs opacity-60">No earlier warmings.</p> + {/if} + </div> + </div> + {:else} + <p class="text-xs opacity-60">No warming yet.</p> + {/if} </div> |
