summaryrefslogtreecommitdiffhomepage
path: root/src/features/cache-warming
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-26 22:21:55 +0900
committerAdam Malczewski <[email protected]>2026-06-26 22:23:39 +0900
commitc333fcec32b1f90bf0da6bb14d2609c20e38a74f (patch)
tree0db3ec77a6838c4f800c362df0de3c6cd8544431 /src/features/cache-warming
parent1285564f12238b22f6b39b9f3fbcecaca8456911 (diff)
downloaddispatch-web-c333fcec32b1f90bf0da6bb14d2609c20e38a74f.tar.gz
dispatch-web-c333fcec32b1f90bf0da6bb14d2609c20e38a74f.zip
style: switch from tabs to 2-space indentation (incl. svelte)
Diffstat (limited to 'src/features/cache-warming')
-rw-r--r--src/features/cache-warming/index.ts4
-rw-r--r--src/features/cache-warming/logic/view-model.test.ts406
-rw-r--r--src/features/cache-warming/logic/view-model.ts244
-rw-r--r--src/features/cache-warming/ui/CacheWarmingView.svelte468
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>