summaryrefslogtreecommitdiffhomepage
path: root/src/features/heartbeat/logic
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/heartbeat/logic')
-rw-r--r--src/features/heartbeat/logic/types.ts103
-rw-r--r--src/features/heartbeat/logic/view-model.test.ts470
-rw-r--r--src/features/heartbeat/logic/view-model.ts410
3 files changed, 983 insertions, 0 deletions
diff --git a/src/features/heartbeat/logic/types.ts b/src/features/heartbeat/logic/types.ts
new file mode 100644
index 0000000..3d3d525
--- /dev/null
+++ b/src/features/heartbeat/logic/types.ts
@@ -0,0 +1,103 @@
+import type { ReasoningEffort } from "@dispatch/transport-contract";
+
+/**
+ * Pure core types for the heartbeat feature — zero DOM, zero effects, zero Svelte.
+ *
+ * Heartbeat is a workspace-scoped autonomous agent loop: the backend periodically
+ * runs a turn in a dedicated conversation using a configured system prompt, task
+ * prompt, model, reasoning effort, and interval. The FE exposes the config
+ * (`GET`/`PUT /workspaces/:id/heartbeat`), the run history
+ * (`GET /workspaces/:id/heartbeat/runs`), and a per-run stop
+ * (`POST /workspaces/:id/heartbeat/runs/:runId/stop`).
+ *
+ * The backend's heartbeat API is a plain REST surface — it is NOT part of the
+ * shared `@dispatch/transport-contract` / `@dispatch/wire` packages (verified:
+ * no `heartbeat` symbol in either `dist/`). So, following the consumer-defines-
+ * port pattern (mirrors `features/mcp` / `features/computer` result types), the
+ * FE owns these shapes here and adapts the untyped JSON at the network seam in
+ * the composition root. If the backend later promotes these to a shared contract
+ * package, swap the local types for the imports (see `backend-handoff.md`).
+ */
+
+/** The canonical run lifecycle status (backend-owned enum, verbatim). */
+export type HeartbeatRunStatus = "running" | "completed" | "stopped";
+
+/** The workspace's heartbeat configuration (`GET /workspaces/:id/heartbeat`). */
+export interface HeartbeatConfig {
+ /** Whether the autonomous loop is enabled (running on the interval). */
+ readonly enabled: boolean;
+ readonly systemPrompt: string;
+ readonly taskPrompt: string;
+ /** Minutes between runs. */
+ readonly intervalMinutes: number;
+ /** The model name (`<credential>/<model>`) the heartbeat runs with. */
+ readonly model: string;
+ /**
+ * The heartbeat's reasoning effort, or null when never set (the server
+ * default `"high"` then applies) — mirrors the per-conversation knob's
+ * resolution chain.
+ */
+ readonly reasoningEffort: ReasoningEffort | null;
+}
+
+/**
+ * A partial config patch for `PUT /workspaces/:id/heartbeat`. Every field is
+ * optional — the backend merges the patch onto the stored config.
+ */
+export interface HeartbeatConfigPatch {
+ readonly enabled?: boolean;
+ readonly systemPrompt?: string;
+ readonly taskPrompt?: string;
+ readonly intervalMinutes?: number;
+ readonly model?: string;
+ readonly reasoningEffort?: ReasoningEffort | null;
+}
+
+/** One heartbeat run (`GET /workspaces/:id/heartbeat/runs`). */
+export interface HeartbeatRun {
+ readonly id: string;
+ /** The conversation this run wrote to (watch it live for the chat). */
+ readonly conversationId: string;
+ /** ISO timestamp of when the run was triggered. */
+ readonly triggeredAt: string;
+ readonly status: HeartbeatRunStatus;
+}
+
+// ── Injected ports (consumer-defines-port; the composition root adapts the
+// store's HTTP calls to these shapes). ──────────────────────────────────────
+
+/** Outcome of `GET /workspaces/:id/heartbeat` (or the PUT response). */
+export type HeartbeatConfigResult =
+ | { readonly ok: true; readonly config: HeartbeatConfig }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of `GET /workspaces/:id/heartbeat/runs`. */
+export type HeartbeatRunsResult =
+ | { readonly ok: true; readonly runs: readonly HeartbeatRun[] }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of `POST /workspaces/:id/heartbeat/runs/:runId/stop`. */
+export type HeartbeatStopResult =
+ | { readonly ok: true }
+ | { readonly ok: false; readonly error: string };
+
+export type LoadHeartbeatConfig = () => Promise<HeartbeatConfigResult | null>;
+export type SaveHeartbeatConfig = (
+ patch: HeartbeatConfigPatch,
+) => Promise<HeartbeatConfigResult | null>;
+export type LoadHeartbeatRuns = () => Promise<HeartbeatRunsResult | null>;
+export type StopHeartbeatRun = (runId: string) => Promise<HeartbeatStopResult | null>;
+
+/**
+ * Outcome of `GET /workspaces/:id/heartbeat/next-run` — the server-authoritative
+ * timestamp of the next scheduled heartbeat run (ISO 8601 string), or `null`
+ * when the heartbeat is disabled or no run is scheduled. The FE computes a live
+ * countdown from this + a 1s clock (see `formatCountdown`). When the endpoint is
+ * unavailable (404 — backend hasn't shipped it yet), the FE falls back to an
+ * approximation from the runs + config (see `approximateNextRunEpoch`).
+ */
+export type HeartbeatNextRunResult =
+ | { readonly ok: true; readonly nextRunAt: string | null }
+ | { readonly ok: false; readonly error: string };
+
+export type LoadHeartbeatNextRun = () => Promise<HeartbeatNextRunResult | null>;
diff --git a/src/features/heartbeat/logic/view-model.test.ts b/src/features/heartbeat/logic/view-model.test.ts
new file mode 100644
index 0000000..aca0aa6
--- /dev/null
+++ b/src/features/heartbeat/logic/view-model.test.ts
@@ -0,0 +1,470 @@
+import type { ReasoningEffort } from "@dispatch/transport-contract";
+import { describe, expect, it } from "vitest";
+import type { HeartbeatConfig, HeartbeatRun } from "./types";
+import {
+ approximateNextRunEpoch,
+ badgeForStatus,
+ DEFAULT_INTERVAL_MINUTES,
+ effectiveSystemPrompt,
+ effortOptions,
+ emptyForm,
+ formatCountdown,
+ formatRunTime,
+ formDiffers,
+ formFromConfig,
+ isInheritingSystemPrompt,
+ joinInterval,
+ nextRunEpoch,
+ normalizeHeartbeatConfig,
+ normalizeHeartbeatRuns,
+ normalizeInterval,
+ patchFromForm,
+ persistedSystemPrompt,
+ relativeLabel,
+ splitInterval,
+ statusLabelFor,
+ viewRun,
+ viewRuns,
+} from "./view-model";
+
+const NOW = Date.UTC(2026, 5, 25, 14, 30, 5); // 2026-06-25T14:30:05Z
+const ISO_AT = "2026-06-25T14:30:05Z"; // exactly NOW
+const run = (over: Partial<HeartbeatRun> = {}): HeartbeatRun => ({
+ id: "run-1",
+ conversationId: "conv-1",
+ triggeredAt: ISO_AT,
+ status: "completed",
+ ...over,
+});
+
+const config = (over: Partial<HeartbeatConfig> = {}): HeartbeatConfig => ({
+ enabled: false,
+ systemPrompt: "be helpful",
+ taskPrompt: "check status",
+ intervalMinutes: 15,
+ model: "openai/gpt-4o",
+ reasoningEffort: null,
+ ...over,
+});
+
+describe("badgeForStatus", () => {
+ it("running → warning + busy (spinner)", () => {
+ expect(badgeForStatus("running")).toEqual({ badge: "warning", busy: true });
+ });
+ it("completed → success, not busy", () => {
+ expect(badgeForStatus("completed")).toEqual({ badge: "success", busy: false });
+ });
+ it("stopped → neutral, not busy", () => {
+ expect(badgeForStatus("stopped")).toEqual({ badge: "neutral", busy: false });
+ });
+});
+
+describe("statusLabelFor", () => {
+ it("maps each status to a display label", () => {
+ expect(statusLabelFor("running")).toBe("Running");
+ expect(statusLabelFor("completed")).toBe("Completed");
+ expect(statusLabelFor("stopped")).toBe("Stopped");
+ });
+});
+
+describe("formatRunTime", () => {
+ it("formats an ISO timestamp as HH:MM:SS (UTC components)", () => {
+ // Uses local getHours/Minutes/Seconds; under UTC env (TZ=UTC) reads 14:30:05.
+ // We assert the SHAPE (3 colon-separated 2-digit groups) so it's TZ-stable.
+ expect(formatRunTime(ISO_AT)).toMatch(/^\d{2}:\d{2}:\d{2}$/);
+ expect(formatRunTime(ISO_AT).split(":")).toHaveLength(3);
+ });
+ it("returns — for an unparseable timestamp", () => {
+ expect(formatRunTime("not-a-date")).toBe("—");
+ expect(formatRunTime("")).toBe("—");
+ });
+});
+
+describe("relativeLabel", () => {
+ it("just now when within a minute", () => {
+ expect(relativeLabel(ISO_AT, NOW)).toBe("just now");
+ expect(relativeLabel(ISO_AT, NOW + 30_000)).toBe("just now");
+ });
+ it("Nm ago under an hour", () => {
+ expect(relativeLabel(ISO_AT, NOW + 5 * 60_000)).toBe("5m ago");
+ expect(relativeLabel(ISO_AT, NOW + 59 * 60_000)).toBe("59m ago");
+ });
+ it("Nh ago under a day", () => {
+ expect(relativeLabel(ISO_AT, NOW + 2 * 3_600_000)).toBe("2h ago");
+ });
+ it("absolute date+time past a day", () => {
+ const label = relativeLabel(ISO_AT, NOW + 26 * 3_600_000);
+ expect(label).toMatch(/^[A-Z][a-z]{2} \d+, \d{2}:\d{2}$/);
+ });
+ it("future timestamp → just now (clock skew tolerance)", () => {
+ expect(relativeLabel(ISO_AT, NOW - 10_000)).toBe("just now");
+ });
+ it("returns — for an unparseable timestamp", () => {
+ expect(relativeLabel("nope", NOW)).toBe("—");
+ });
+});
+
+describe("viewRun / viewRuns", () => {
+ it("running run: warning badge + busy + labels", () => {
+ const v = viewRun(run({ status: "running" }), NOW);
+ expect(v.badge).toBe("warning");
+ expect(v.busy).toBe(true);
+ expect(v.statusLabel).toBe("Running");
+ expect(v.id).toBe("run-1");
+ expect(v.conversationId).toBe("conv-1");
+ expect(v.timeLabel).toMatch(/^\d{2}:\d{2}:\d{2}$/);
+ expect(v.relativeLabel).toBe("just now");
+ });
+ it("completed run: success badge, not busy", () => {
+ expect(viewRun(run({ status: "completed" }), NOW).badge).toBe("success");
+ });
+ it("stopped run: neutral badge, not busy", () => {
+ expect(viewRun(run({ status: "stopped" }), NOW).badge).toBe("neutral");
+ });
+ it("viewRuns preserves order", () => {
+ const views = viewRuns([run({ id: "a" }), run({ id: "b" })], NOW);
+ expect(views.map((v) => v.id)).toEqual(["a", "b"]);
+ });
+});
+
+describe("config form", () => {
+ it("emptyForm has defaults (disabled, default interval split, default effort)", () => {
+ const f = emptyForm();
+ expect(f.enabled).toBe(false);
+ // 30 min → 0h 30m
+ expect(f.intervalHours).toBe(0);
+ expect(f.intervalMinutes).toBe(30);
+ expect(f.reasoningEffort).toBe("high"); // DEFAULT_REASONING_EFFORT
+ expect(f.systemPrompt).toBe("");
+ expect(f.model).toBe("");
+ });
+
+ it("formFromConfig resolves null reasoningEffort to the default", () => {
+ const f = formFromConfig(config({ reasoningEffort: null }));
+ expect(f.reasoningEffort).toBe("high");
+ });
+
+ it("formFromConfig passes through a set reasoningEffort", () => {
+ const f = formFromConfig(config({ reasoningEffort: "max" }));
+ expect(f.reasoningEffort).toBe("max");
+ });
+
+ it("formFromConfig splits intervalMinutes into hours + minutes (0–59)", () => {
+ expect(formFromConfig(config({ intervalMinutes: 90 }))).toMatchObject({
+ intervalHours: 1,
+ intervalMinutes: 30,
+ });
+ expect(formFromConfig(config({ intervalMinutes: 60 }))).toMatchObject({
+ intervalHours: 1,
+ intervalMinutes: 0,
+ });
+ expect(formFromConfig(config({ intervalMinutes: 59 }))).toMatchObject({
+ intervalHours: 0,
+ intervalMinutes: 59,
+ });
+ expect(formFromConfig(config({ intervalMinutes: 1440 }))).toMatchObject({
+ intervalHours: 24,
+ intervalMinutes: 0,
+ });
+ });
+
+ it("formFromConfig coerces malformed fields safely", () => {
+ const f = formFromConfig(
+ config({
+ enabled: "yes" as unknown as boolean,
+ intervalMinutes: -5,
+ model: 42 as unknown as string,
+ systemPrompt: undefined as unknown as string,
+ }),
+ );
+ expect(f.enabled).toBe(false); // non-true → false
+ // -5 clamps to 1 → 0h 1m
+ expect(f.intervalHours).toBe(0);
+ expect(f.intervalMinutes).toBe(1);
+ expect(f.model).toBe(""); // non-string → ""
+ expect(f.systemPrompt).toBe(""); // undefined → ""
+ });
+
+ it("normalizeInterval clamps to 1–1440 and rounds", () => {
+ expect(normalizeInterval(0)).toBe(1);
+ expect(normalizeInterval(-10)).toBe(1);
+ expect(normalizeInterval(1.4)).toBe(1);
+ expect(normalizeInterval(15.6)).toBe(16);
+ expect(normalizeInterval(2000)).toBe(1440);
+ expect(normalizeInterval("30" as unknown as number)).toBe(30); // default on non-number
+ expect(normalizeInterval(undefined)).toBe(DEFAULT_INTERVAL_MINUTES);
+ });
+
+ it("splitInterval / joinInterval round-trip (and clamp)", () => {
+ expect(splitInterval(90)).toEqual({ hours: 1, minutes: 30 });
+ expect(splitInterval(0)).toEqual({ hours: 0, minutes: 1 }); // 0 → clamps to 1
+ expect(splitInterval(1440)).toEqual({ hours: 24, minutes: 0 });
+ expect(splitInterval(2000)).toEqual({ hours: 24, minutes: 0 }); // clamped
+ // join recomputes + clamps
+ expect(joinInterval(1, 30)).toBe(90);
+ expect(joinInterval(0, 0)).toBe(1); // 0 → clamps to 1
+ expect(joinInterval(25, 0)).toBe(1440); // 1500 → clamps to 1440
+ expect(joinInterval(-1, 30)).toBe(30); // negatives floored to 0
+ expect(joinInterval("x" as unknown as number, 15)).toBe(15); // non-finite → 0h
+ });
+
+ it("patchFromForm recombines hours+minutes into intervalMinutes + carries every field", () => {
+ const f = formFromConfig(config({ intervalMinutes: 2000 }));
+ // 2000 clamps to 1440 → 24h 0m in the form
+ expect(f.intervalHours).toBe(24);
+ expect(f.intervalMinutes).toBe(0);
+ const patch = patchFromForm(f);
+ expect(patch.intervalMinutes).toBe(1440);
+ expect(patch.enabled).toBe(false);
+ expect(patch.model).toBe("openai/gpt-4o");
+ expect(patch.reasoningEffort).toBe("high");
+ expect(patch.systemPrompt).toBe("be helpful");
+ expect(patch.taskPrompt).toBe("check status");
+ });
+
+ it("patchFromForm recombines an arbitrary hours/minutes edit", () => {
+ const f = formFromConfig(config({ intervalMinutes: 15 }));
+ f.intervalHours = 2;
+ f.intervalMinutes = 45;
+ expect(patchFromForm(f).intervalMinutes).toBe(165);
+ });
+
+ it("formDiffers is false for a form seeded from the config (no edits)", () => {
+ const c = config({ reasoningEffort: "medium" });
+ const f = formFromConfig(c);
+ expect(formDiffers(f, c)).toBe(false);
+ });
+
+ it("formDiffers is true after an edit", () => {
+ const c = config();
+ const f = formFromConfig(c);
+ f.systemPrompt = "changed";
+ expect(formDiffers(f, c)).toBe(true);
+ });
+
+ it("formDiffers is true after an interval edit (hours or minutes)", () => {
+ const c = config({ intervalMinutes: 90 });
+ const f = formFromConfig(c);
+ f.intervalMinutes = 45; // 1h45m vs 1h30m
+ expect(formDiffers(f, c)).toBe(true);
+ });
+
+ it("formDiffers treats null config effort as the default (matches the resolved form)", () => {
+ const c = config({ reasoningEffort: null });
+ const f = formFromConfig(c);
+ expect(formDiffers(f, c)).toBe(false); // null resolves to "high" == form
+ });
+});
+
+describe("system-prompt inheritance (override ⇄ global default)", () => {
+ const DEFAULT = "You are a helpful assistant.";
+
+ it("effectiveSystemPrompt: override wins when non-empty, else the default", () => {
+ expect(effectiveSystemPrompt("custom", DEFAULT)).toBe("custom");
+ expect(effectiveSystemPrompt("", DEFAULT)).toBe(DEFAULT);
+ });
+
+ it("isInheritingSystemPrompt: true iff the override is empty", () => {
+ expect(isInheritingSystemPrompt("")).toBe(true);
+ expect(isInheritingSystemPrompt("custom")).toBe(false);
+ });
+
+ it('persistedSystemPrompt: empty or matching-the-default → inherit ("")', () => {
+ // matching the default → inherit (never duplicate the default into the config)
+ expect(persistedSystemPrompt(DEFAULT, DEFAULT)).toBe("");
+ // empty edit → inherit
+ expect(persistedSystemPrompt("", DEFAULT)).toBe("");
+ });
+
+ it("persistedSystemPrompt: a distinct edit → the override verbatim", () => {
+ expect(persistedSystemPrompt("custom", DEFAULT)).toBe("custom");
+ expect(persistedSystemPrompt(`${DEFAULT}\nmore`, DEFAULT)).toBe(`${DEFAULT}\nmore`);
+ });
+
+ it("round-trip: inherit → display default → reset (no edit) → persist inherit", () => {
+ // A heartbeat inheriting (override "") displays the default; with no edit,
+ // persisting yields inherit ("") — so the global default stays the source.
+ const override = "";
+ const displayed = effectiveSystemPrompt(override, DEFAULT);
+ expect(displayed).toBe(DEFAULT);
+ expect(persistedSystemPrompt(displayed, DEFAULT)).toBe("");
+ });
+
+ it("round-trip: override → reset to default → persist inherit (clears override)", () => {
+ // User had an override, clicks Reset (textarea ← default): persisting clears
+ // the override ("" → inherit) because the text now matches the default.
+ const afterReset = DEFAULT;
+ expect(persistedSystemPrompt(afterReset, DEFAULT)).toBe("");
+ });
+});
+
+describe("effortOptions re-export", () => {
+ it("exposes the canonical ladder with the default marked", () => {
+ const opts = effortOptions();
+ const values = opts.map((o) => o.value) as readonly string[];
+ expect(values).toEqual(["low", "medium", "high", "xhigh", "max"]);
+ const def = opts.find((o) => o.value === "high");
+ expect(def?.label).toBe("high (default)");
+ });
+});
+
+describe("reasoningEffort type narrowing (sanity)", () => {
+ // Ensures the imported ladder stays the wire's canonical set — if the wire
+ // ladder changes, this test flags the drift alongside the chat feature.
+ it("the five canonical levels", () => {
+ const levels: readonly ReasoningEffort[] = ["low", "medium", "high", "xhigh", "max"];
+ expect(levels).toHaveLength(5);
+ });
+});
+
+describe("normalizeHeartbeatConfig", () => {
+ it("passes through a well-formed config", () => {
+ const c = normalizeHeartbeatConfig({
+ enabled: true,
+ systemPrompt: "sys",
+ taskPrompt: "task",
+ intervalMinutes: 20,
+ model: "openai/gpt-4o",
+ reasoningEffort: "max",
+ });
+ expect(c).toEqual({
+ enabled: true,
+ systemPrompt: "sys",
+ taskPrompt: "task",
+ intervalMinutes: 20,
+ model: "openai/gpt-4o",
+ reasoningEffort: "max",
+ });
+ });
+ it("coerces a malformed body safely (never throws, never undefined)", () => {
+ const c = normalizeHeartbeatConfig({
+ enabled: "yes",
+ intervalMinutes: -3,
+ reasoningEffort: "bogus",
+ });
+ expect(c.enabled).toBe(false);
+ expect(c.intervalMinutes).toBe(1);
+ expect(c.reasoningEffort).toBeNull();
+ expect(c.systemPrompt).toBe("");
+ expect(c.taskPrompt).toBe("");
+ expect(c.model).toBe("");
+ });
+ it("accepts a null reasoningEffort", () => {
+ expect(normalizeHeartbeatConfig({ reasoningEffort: null }).reasoningEffort).toBeNull();
+ });
+ it("handles null / non-object input", () => {
+ const c = normalizeHeartbeatConfig(null);
+ expect(c.enabled).toBe(false);
+ expect(c.intervalMinutes).toBe(DEFAULT_INTERVAL_MINUTES);
+ expect(c.model).toBe("");
+ });
+ it("clamps a huge interval", () => {
+ expect(normalizeHeartbeatConfig({ intervalMinutes: 99999 }).intervalMinutes).toBe(1440);
+ });
+});
+
+describe("normalizeHeartbeatRuns", () => {
+ it("maps a well-formed runs list", () => {
+ const runs = normalizeHeartbeatRuns({
+ runs: [
+ { id: "r1", conversationId: "c1", triggeredAt: "2026-06-25T10:00:00Z", status: "running" },
+ {
+ id: "r2",
+ conversationId: "c2",
+ triggeredAt: "2026-06-25T09:00:00Z",
+ status: "completed",
+ },
+ ],
+ });
+ expect(runs).toHaveLength(2);
+ expect(runs[0]).toMatchObject({ id: "r1", status: "running" });
+ expect(runs[1]).toMatchObject({ id: "r2", status: "completed" });
+ });
+ it("returns [] for malformed body", () => {
+ expect(normalizeHeartbeatRuns(null)).toEqual([]);
+ expect(normalizeHeartbeatRuns({})).toEqual([]);
+ expect(normalizeHeartbeatRuns({ runs: "nope" })).toEqual([]);
+ });
+ it("drops runs missing id/conversationId and defaults unknown status", () => {
+ const runs = normalizeHeartbeatRuns({
+ runs: [
+ { id: "r1", conversationId: "c1", triggeredAt: "x", status: "garbage" },
+ { id: "", conversationId: "c2", triggeredAt: "x", status: "completed" },
+ { id: "r3", conversationId: "", triggeredAt: "x", status: "running" },
+ { id: "r4", conversationId: "c4", triggeredAt: "x", status: "stopped" },
+ ],
+ });
+ expect(runs).toHaveLength(2);
+ expect(runs[0]?.status).toBe("completed"); // "garbage" → default
+ expect(runs[0]?.id).toBe("r1");
+ expect(runs[1]?.id).toBe("r4");
+ });
+});
+
+describe("next-run countdown", () => {
+ const ISO_AT = "2026-06-25T14:05:00Z"; // 5 min past the hour
+
+ describe("nextRunEpoch", () => {
+ it("parses an ISO timestamp to epoch-ms", () => {
+ expect(nextRunEpoch(ISO_AT)).toBe(Date.parse(ISO_AT));
+ });
+ it("returns null for unparseable / empty / non-string", () => {
+ expect(nextRunEpoch("not-a-date")).toBeNull();
+ expect(nextRunEpoch("")).toBeNull();
+ expect(nextRunEpoch(null)).toBeNull();
+ expect(nextRunEpoch(undefined)).toBeNull();
+ });
+ });
+
+ describe("formatCountdown", () => {
+ it("null → —", () => {
+ expect(formatCountdown(null)).toBe("—");
+ });
+ it("≤ 0 → due", () => {
+ expect(formatCountdown(0)).toBe("due");
+ expect(formatCountdown(-5000)).toBe("due");
+ });
+ it("seconds only (< 1m)", () => {
+ expect(formatCountdown(32_000)).toBe("32s");
+ expect(formatCountdown(1_000)).toBe("1s");
+ });
+ it("minutes + seconds (1m–1h)", () => {
+ expect(formatCountdown(4 * 60_000 + 32_000)).toBe("4m 32s");
+ expect(formatCountdown(59 * 60_000 + 5_000)).toBe("59m 05s");
+ });
+ it("hours + minutes (≥ 1h)", () => {
+ expect(formatCountdown(3_600_000 + 5 * 60_000)).toBe("1h 05m");
+ expect(formatCountdown(2 * 3_600_000 + 30 * 60_000)).toBe("2h 30m");
+ });
+ });
+
+ describe("approximateNextRunEpoch", () => {
+ const runs = (times: string[]): HeartbeatRun[] =>
+ times.map((t, i) => ({
+ id: `r${i}`,
+ conversationId: "c",
+ triggeredAt: t,
+ status: "completed",
+ }));
+
+ it("disabled → null", () => {
+ expect(approximateNextRunEpoch(runs([ISO_AT]), 15, false)).toBeNull();
+ });
+ it("no runs → null (no fabricated countdown)", () => {
+ expect(approximateNextRunEpoch([], 15, true)).toBeNull();
+ });
+ it("latest run + interval (minutes)", () => {
+ // latest is the max triggeredAt (runs need not be ordered)
+ const unordered = runs(["2026-06-25T13:00:00Z", "2026-06-25T13:50:00Z"]);
+ // 13:50 + 15 min = 14:05
+ expect(approximateNextRunEpoch(unordered, 15, true)).toBe(Date.parse("2026-06-25T14:05:00Z"));
+ });
+ it("ignores unparseable triggeredAt values", () => {
+ const mixed = runs(["not-a-date", "2026-06-25T13:50:00Z"]);
+ expect(approximateNextRunEpoch(mixed, 15, true)).toBe(Date.parse("2026-06-25T14:05:00Z"));
+ });
+ it("all-unparseable → null", () => {
+ expect(approximateNextRunEpoch(runs(["nope", "also-nope"]), 15, true)).toBeNull();
+ });
+ });
+});
diff --git a/src/features/heartbeat/logic/view-model.ts b/src/features/heartbeat/logic/view-model.ts
new file mode 100644
index 0000000..e91febd
--- /dev/null
+++ b/src/features/heartbeat/logic/view-model.ts
@@ -0,0 +1,410 @@
+import type { ReasoningEffort } from "@dispatch/transport-contract";
+import {
+ DEFAULT_REASONING_EFFORT,
+ effectiveEffort,
+ effortOptions,
+} from "../../chat/reasoning-effort";
+import type {
+ HeartbeatConfig,
+ HeartbeatConfigPatch,
+ HeartbeatRun,
+ HeartbeatRunStatus,
+} from "./types";
+
+/**
+ * Pure view-models for the heartbeat feature — zero DOM, zero effects, zero
+ * Svelte. Maps backend `HeartbeatConfig`/`HeartbeatRun` to display shapes
+ * (badges, labels, formatted times) and holds the config-form helpers.
+ *
+ * The reasoning-effort ladder + resolution are SERVER-owned and shared with the
+ * per-conversation knob, so they are REUSED from `features/chat/reasoning-effort`
+ * (a sanctioned cross-feature import through its public exports) rather than
+ * redefined — no drift.
+ */
+
+export type Badge = "success" | "warning" | "error" | "neutral";
+
+/** A run shaped for display in the scrolling runs list. */
+export interface HeartbeatRunView {
+ readonly id: string;
+ readonly conversationId: string;
+ readonly status: HeartbeatRunStatus;
+ readonly statusLabel: string;
+ readonly badge: Badge;
+ /** True while the run is in flight (show a spinner). */
+ readonly busy: boolean;
+ /** A short absolute clock label, e.g. "14:30:05". */
+ readonly timeLabel: string;
+ /** A relative label, e.g. "5m ago" / "just now". */
+ readonly relativeLabel: string;
+}
+
+const RUNNING_LABEL = "Running";
+const COMPLETED_LABEL = "Completed";
+const STOPPED_LABEL = "Stopped";
+
+/**
+ * Map a run's status to a display badge + busy flag. `running` → warning +
+ * spinner, `completed` → success, `stopped` → neutral. Mirrors the LSP/MCP
+ * status visual treatment.
+ */
+export function badgeForStatus(status: HeartbeatRunStatus): { badge: Badge; busy: boolean } {
+ switch (status) {
+ case "running":
+ return { badge: "warning", busy: true };
+ case "completed":
+ return { badge: "success", busy: false };
+ case "stopped":
+ return { badge: "neutral", busy: false };
+ }
+}
+
+export function statusLabelFor(status: HeartbeatRunStatus): string {
+ switch (status) {
+ case "running":
+ return RUNNING_LABEL;
+ case "completed":
+ return COMPLETED_LABEL;
+ case "stopped":
+ return STOPPED_LABEL;
+ }
+}
+
+/**
+ * Format an ISO timestamp as a short absolute clock label (HH:MM:SS) in the
+ * viewer's locale. Returns "—" for an unparseable timestamp so the UI never
+ * crashes on a malformed backend value. Pure (no `now` needed — an absolute
+ * clock label doesn't depend on the current time).
+ */
+export function formatRunTime(triggeredAt: string): string {
+ const t = parseTime(triggeredAt);
+ if (t === null) return "—";
+ return clockLabel(t);
+}
+
+/**
+ * A coarse relative label — "just now" (<1m), "Nm ago", "Nh ago", else the
+ * absolute date+time (so an old run reads "Jun 24, 14:30"). Pure via `now`.
+ */
+export function relativeLabel(triggeredAt: string, now: number = Date.now()): string {
+ const t = parseTime(triggeredAt);
+ if (t === null) return "—";
+ const deltaMs = now - t;
+ if (deltaMs < 0) return "just now";
+ const mins = Math.floor(deltaMs / 60000);
+ if (mins < 1) return "just now";
+ if (mins < 60) return `${mins}m ago`;
+ const hours = Math.floor(mins / 60);
+ if (hours < 24) return `${hours}h ago`;
+ return dateLabel(t);
+}
+
+/**
+ * Build a display view for a run. `now` is injectable for tests (defaults to
+ * `Date.now()`); the composition-root component passes nothing in production.
+ */
+export function viewRun(run: HeartbeatRun, now: number = Date.now()): HeartbeatRunView {
+ const { badge, busy } = badgeForStatus(run.status);
+ return {
+ id: run.id,
+ conversationId: run.conversationId,
+ status: run.status,
+ statusLabel: statusLabelFor(run.status),
+ badge,
+ busy,
+ timeLabel: formatRunTime(run.triggeredAt),
+ relativeLabel: relativeLabel(run.triggeredAt, now),
+ };
+}
+
+export function viewRuns(
+ runs: readonly HeartbeatRun[],
+ now: number = Date.now(),
+): readonly HeartbeatRunView[] {
+ return runs.map((r) => viewRun(r, now));
+}
+
+// ── Time formatting (pure: no `Date` mutation; injectable `now` for tests) ─────
+
+/** Parse an ISO timestamp to epoch ms, or null if unparseable. */
+function parseTime(iso: string): number | null {
+ if (typeof iso !== "string" || iso.length === 0) return null;
+ const t = Date.parse(iso);
+ return Number.isNaN(t) ? null : t;
+}
+
+/** `HH:MM:SS` in the viewer's locale (24h where the locale uses it). */
+function clockLabel(epochMs: number): string {
+ const d = new Date(epochMs);
+ const hh = String(d.getHours()).padStart(2, "0");
+ const mm = String(d.getMinutes()).padStart(2, "0");
+ const ss = String(d.getSeconds()).padStart(2, "0");
+ return `${hh}:${mm}:${ss}`;
+}
+
+/** A short absolute date+time label for an old run, e.g. "Jun 24, 14:30". */
+function dateLabel(epochMs: number): string {
+ const d = new Date(epochMs);
+ const month = d.toLocaleString(undefined, { month: "short" });
+ const day = d.getDate();
+ const hh = String(d.getHours()).padStart(2, "0");
+ const mm = String(d.getMinutes()).padStart(2, "0");
+ return `${month} ${day}, ${hh}:${mm}`;
+}
+
+// ── Next-run countdown (timer of when the next heartbeat fires) ───────────────
+//
+// The authoritative next-run time comes from the backend
+// (`GET /workspaces/:id/heartbeat/next-run` → `nextRunAt` ISO string); the FE
+// computes a live countdown from it + a 1s clock. When that endpoint is absent,
+// the FE falls back to an approximation (`approximateNextRunEpoch`) from the
+// latest run + the configured interval.
+
+/** Parse an ISO timestamp to epoch-ms, or null if unparseable. */
+export function nextRunEpoch(iso: string | null | undefined): number | null {
+ if (typeof iso !== "string" || iso.length === 0) return null;
+ const t = Date.parse(iso);
+ return Number.isNaN(t) ? null : t;
+}
+
+/**
+ * Format a remaining-ms delta as a short countdown: "4m 32s", "32s", "1h 05m",
+ * "due" (≤ 0), or "—" (unknown/null). Pure via the injected `remainingMs`.
+ */
+export function formatCountdown(remainingMs: number | null): string {
+ if (remainingMs === null) return "—";
+ if (remainingMs <= 0) return "due";
+ const totalSec = Math.floor(remainingMs / 1000);
+ const hours = Math.floor(totalSec / 3600);
+ const mins = Math.floor((totalSec % 3600) / 60);
+ const secs = totalSec % 60;
+ if (hours > 0) return `${hours}h ${String(mins).padStart(2, "0")}m`;
+ if (mins > 0) return `${mins}m ${String(secs).padStart(2, "0")}s`;
+ return `${secs}s`;
+}
+
+/**
+ * Approximate the next-run epoch-ms when the backend's `next-run` endpoint is
+ * unavailable: the LATEST run's `triggeredAt` + `intervalMinutes` (only when the
+ * heartbeat is enabled AND at least one run exists). Returns null otherwise (the
+ * FE then shows no countdown — never a fabricated one). The latest run is the
+ * max `triggeredAt` (runs need not be ordered). Pure (no `now` needed — the next
+ * run is latest + interval, independent of the current time).
+ */
+export function approximateNextRunEpoch(
+ runs: readonly HeartbeatRun[],
+ intervalMinutes: number,
+ enabled: boolean,
+): number | null {
+ if (!enabled) return null;
+ let latest: number | null = null;
+ for (const r of runs) {
+ const t = Date.parse(r.triggeredAt);
+ if (!Number.isNaN(t) && (latest === null || t > latest)) latest = t;
+ }
+ if (latest === null) return null;
+ return latest + intervalMinutes * 60_000;
+}
+
+// ── Config form ───────────────────────────────────────────────────────────────
+
+/**
+ * The editable form state for the config panel — a mutable mirror of a loaded
+ * `HeartbeatConfig` that the inputs bind to. `reasoningEffort` is resolved to
+ * an effective level for the `<select>` (null ⇒ default `high`), exactly like
+ * the per-conversation selector.
+ *
+ * The interval is split into `intervalHours` + `intervalMinutes` (0–59) for the
+ * UI (two inputs), and recombined to a total-minutes value at the patch seam
+ * (`patchFromForm`); the backend stores a single `intervalMinutes`.
+ */
+export interface HeartbeatFormState {
+ enabled: boolean;
+ systemPrompt: string;
+ taskPrompt: string;
+ intervalHours: number;
+ intervalMinutes: number;
+ model: string;
+ reasoningEffort: ReasoningEffort;
+}
+
+/** The default interval (minutes) shown for an empty/unset config. */
+export const DEFAULT_INTERVAL_MINUTES = 30;
+
+/** Split a total-minutes value into { hours, minutes (0–59) }. Pure. */
+export function splitInterval(totalMinutes: number): { hours: number; minutes: number } {
+ const total = normalizeInterval(totalMinutes);
+ const hours = Math.floor(total / 60);
+ const minutes = total - hours * 60;
+ return { hours, minutes };
+}
+
+/** Recombine hours + minutes into a clamped total-minutes value. Pure. */
+export function joinInterval(hours: number, minutes: number): number {
+ const h = Number.isFinite(hours) ? Math.max(0, Math.floor(hours)) : 0;
+ const m = Number.isFinite(minutes) ? Math.max(0, Math.floor(minutes)) : 0;
+ return normalizeInterval(h * 60 + m);
+}
+
+/**
+ * Seed the editable form state from a loaded config, applying safe defaults for
+ * any malformed/absent backend field so the inputs are never `undefined`.
+ */
+export function formFromConfig(config: HeartbeatConfig): HeartbeatFormState {
+ const { hours, minutes } = splitInterval(config.intervalMinutes);
+ return {
+ enabled: config.enabled === true,
+ systemPrompt: config.systemPrompt ?? "",
+ taskPrompt: config.taskPrompt ?? "",
+ intervalHours: hours,
+ intervalMinutes: minutes,
+ model: typeof config.model === "string" ? config.model : "",
+ reasoningEffort: effectiveEffort(config.reasoningEffort ?? null),
+ };
+}
+
+/** An empty form (before the config loads). */
+export function emptyForm(): HeartbeatFormState {
+ const { hours, minutes } = splitInterval(DEFAULT_INTERVAL_MINUTES);
+ return {
+ enabled: false,
+ systemPrompt: "",
+ taskPrompt: "",
+ intervalHours: hours,
+ intervalMinutes: minutes,
+ model: "",
+ reasoningEffort: DEFAULT_REASONING_EFFORT,
+ };
+}
+
+/** Clamp a raw interval to a sane positive-minute range (1–1440 = 1 min–24 h). */
+export function normalizeInterval(value: unknown): number {
+ const n = typeof value === "number" && Number.isFinite(value) ? value : DEFAULT_INTERVAL_MINUTES;
+ const int = Math.round(n);
+ if (int < 1) return 1;
+ if (int > 1440) return 1440;
+ return int;
+}
+
+/**
+ * The patch to PUT when persisting the form. The split hours+minutes are
+ * recombined into a single `intervalMinutes` (clamped); text fields are sent
+ * verbatim. `reasoningEffort` is always present (a resolved level) since the
+ * heartbeat has no per-run override — it persists the level.
+ */
+export function patchFromForm(form: HeartbeatFormState): HeartbeatConfigPatch {
+ return {
+ enabled: form.enabled,
+ systemPrompt: form.systemPrompt,
+ taskPrompt: form.taskPrompt,
+ intervalMinutes: joinInterval(form.intervalHours, form.intervalMinutes),
+ model: form.model,
+ reasoningEffort: form.reasoningEffort,
+ };
+}
+
+/** Whether the form differs from the loaded config (drives the Save button). */
+export function formDiffers(form: HeartbeatFormState, config: HeartbeatConfig): boolean {
+ const { hours, minutes } = splitInterval(config.intervalMinutes);
+ return (
+ form.enabled !== config.enabled ||
+ form.systemPrompt !== (config.systemPrompt ?? "") ||
+ form.taskPrompt !== (config.taskPrompt ?? "") ||
+ form.intervalHours !== hours ||
+ form.intervalMinutes !== minutes ||
+ form.model !== (typeof config.model === "string" ? config.model : "") ||
+ form.reasoningEffort !== effectiveEffort(config.reasoningEffort ?? null)
+ );
+}
+
+// ── System-prompt inheritance (heartbeat override ⇄ global default) ────────────
+//
+// The heartbeat's `systemPrompt` is an OVERRIDE of the global system prompt (the
+// one every workspace conversation uses — there is no per-workspace system
+// prompt; `GET /system-prompt` is global). An EMPTY override means "inherit the
+// global default" (server-owned resolution: the backend resolves empty → global
+// at run time; see CR-HB-2). These pure helpers keep the override/inherit
+// semantics in ONE place so the editor + form agree.
+
+/**
+ * The prompt to DISPLAY: the heartbeat's override if it set one, else the global
+ * default. The editor pre-fills the textarea with this so the user can see (and
+ * tweak) what will run — but a pre-filled default is NOT an explicit edit.
+ */
+export function effectiveSystemPrompt(override: string, defaultPrompt: string): string {
+ return override !== "" ? override : defaultPrompt;
+}
+
+/** Whether the heartbeat is inheriting the global default (empty override). */
+export function isInheritingSystemPrompt(override: string): boolean {
+ return override === "";
+}
+
+/**
+ * The `systemPrompt` value to PERSIST for the given editable text: if the user's
+ * text matches the global default (or is empty), persist `""` to INHERIT (so a
+ * later change to the global default still flows through); otherwise persist the
+ * text verbatim as an override. This keeps "matching the default = inheriting it"
+ * — never duplicating the default into the heartbeat config.
+ */
+export function persistedSystemPrompt(editable: string, defaultPrompt: string): string {
+ if (editable === "" || editable === defaultPrompt) return "";
+ return editable;
+}
+
+// The reasoning-effort `<option>`s are reused verbatim from the per-conversation
+// selector (re-exported so the config panel imports a single source).
+export { effortOptions };
+
+// ── Network-seam normalization (pure; called by the composition root) ────────
+//
+// The heartbeat API is untyped JSON (not a transport-contract type), so the
+// store coerces each response defensively HERE (pure + tested) — a malformed/
+// partial backend value can never crash the renderer. Mirrors the inline
+// `Array.isArray(data.servers) ? … : []` guard the store does for LSP/MCP.
+
+/** Narrow an untrusted string to the run-status enum, defaulting to "completed". */
+function asRunStatus(value: unknown): HeartbeatRunStatus {
+ if (value === "running" || value === "completed" || value === "stopped") return value;
+ return "completed";
+}
+
+/** Coerce an untrusted `GET .../heartbeat/runs` body into a typed run list. */
+export function normalizeHeartbeatRuns(data: unknown): readonly HeartbeatRun[] {
+ if (!isRecord(data) || !Array.isArray(data.runs)) return [];
+ const runs = data.runs as readonly unknown[];
+ return runs
+ .filter((r): r is Record<string, unknown> => r !== null && typeof r === "object")
+ .map((r) => ({
+ id: typeof r.id === "string" ? r.id : "",
+ conversationId: typeof r.conversationId === "string" ? r.conversationId : "",
+ triggeredAt: typeof r.triggeredAt === "string" ? r.triggeredAt : "",
+ status: asRunStatus(r.status),
+ }))
+ .filter((r) => r.id !== "" && r.conversationId !== "");
+}
+
+/** Coerce an untrusted `GET`/`PUT .../heartbeat` body into a typed config. */
+export function normalizeHeartbeatConfig(data: unknown): HeartbeatConfig {
+ const d = isRecord(data) ? data : {};
+ const effort = d.reasoningEffort;
+ return {
+ enabled: d.enabled === true,
+ systemPrompt: typeof d.systemPrompt === "string" ? d.systemPrompt : "",
+ taskPrompt: typeof d.taskPrompt === "string" ? d.taskPrompt : "",
+ intervalMinutes: normalizeInterval(d.intervalMinutes),
+ model: typeof d.model === "string" ? d.model : "",
+ reasoningEffort:
+ effort === "low" ||
+ effort === "medium" ||
+ effort === "high" ||
+ effort === "xhigh" ||
+ effort === "max"
+ ? effort
+ : null,
+ };
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+ return value !== null && typeof value === "object";
+}