summaryrefslogtreecommitdiffhomepage
path: root/src/features/heartbeat/logic/view-model.ts
blob: 4a5ba7f985f51d9cd4e584b99a4d3f821ba2991d (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
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;
  inactiveOnly: 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,
    inactiveOnly: config.inactiveOnly !== false,
    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,
    inactiveOnly: true,
    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,
    inactiveOnly: form.inactiveOnly,
    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.inactiveOnly !== config.inactiveOnly ||
    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,
    // Default ON (true): a missing/falsey-but-not-false field (a legacy config
    // persisted before the field shipped) reads back as inactiveOnly: true —
    // the feature is on by default for everyone. Only an explicit `false` opts out.
    inactiveOnly: d.inactiveOnly !== false,
    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";
}