diff options
| author | Adam Malczewski <[email protected]> | 2026-06-27 00:03:21 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-27 00:03:21 +0900 |
| commit | 80f99665034a0e510300793205c162fc7a46769f (patch) | |
| tree | fcff9e54903d107449b29b77a0ac6f70094e100b /src | |
| parent | 121fd7280bc3f77c61da7025cca06480798038b7 (diff) | |
| download | dispatch-web-80f99665034a0e510300793205c162fc7a46769f.tar.gz dispatch-web-80f99665034a0e510300793205c162fc7a46769f.zip | |
feat(heartbeat): show next heartbeat timer + backend handoff for timestamp endpoint
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/App.svelte | 6 | ||||
| -rw-r--r-- | src/app/store.svelte.ts | 39 | ||||
| -rw-r--r-- | src/features/heartbeat/index.ts | 5 | ||||
| -rw-r--r-- | src/features/heartbeat/logic/types.ts | 14 | ||||
| -rw-r--r-- | src/features/heartbeat/logic/view-model.test.ts | 71 | ||||
| -rw-r--r-- | src/features/heartbeat/logic/view-model.ts | 54 | ||||
| -rw-r--r-- | src/features/heartbeat/ui/HeartbeatView.svelte | 134 |
7 files changed, 292 insertions, 31 deletions
diff --git a/src/app/App.svelte b/src/app/App.svelte index 7ad8733..09be947 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -64,6 +64,7 @@ manifest as heartbeatManifest, RunModal, type HeartbeatConfigResult, + type HeartbeatNextRunResult, type HeartbeatRunView, type HeartbeatRunsResult, type HeartbeatStopResult, @@ -399,6 +400,10 @@ return store.stopHeartbeatRun(runId); } + async function loadHeartbeatNextRun(): Promise<HeartbeatNextRunResult> { + return store.heartbeatNextRun(); + } + // Run-chat modal: open a live watch on the run's conversation (the store owns // the ChatStore + the `chat.subscribe` stream), and tear it down on close. function openRunChat(conversationId: string): ChatStore { @@ -682,6 +687,7 @@ stopRun={stopHeartbeatRun} loadVariables={loadSystemPromptVariablesPrompt} loadDefaultPrompt={loadSystemPromptPrompt} + loadNextRun={loadHeartbeatNextRun} onOpenRun={(run) => (heartbeatRun = run)} /> {/if} diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts index 1ad0c08..0e2d112 100644 --- a/src/app/store.svelte.ts +++ b/src/app/store.svelte.ts @@ -59,6 +59,7 @@ import type { HeartbeatConfig, HeartbeatConfigPatch, HeartbeatConfigResult, + HeartbeatNextRunResult, HeartbeatRun, HeartbeatRunsResult, HeartbeatStopResult, @@ -341,6 +342,15 @@ export interface AppStore { */ stopHeartbeatRun(runId: string): Promise<HeartbeatStopResult>; /** + * Fetch the server-authoritative next-run timestamp + * (`GET /workspaces/:id/heartbeat/next-run`) — when the next heartbeat run + * will fire (ISO 8601), or null when disabled / no run scheduled. The FE shows + * a live countdown from this. When the endpoint is absent (404 — backend + * hasn't shipped CR-HB-3 yet) it returns `ok: false` so the FE falls back to + * an approximation from the runs + config. + */ + heartbeatNextRun(): Promise<HeartbeatNextRunResult>; + /** * Open a "watch" on a conversation for a modal viewer (the heartbeat run-chat * modal): ensures a live {@link ChatStore} for the conversation, subscribing * to its turn stream (`chat.subscribe`) + loading history. Reuses the open @@ -1649,6 +1659,35 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { } }, + async heartbeatNextRun(): Promise<HeartbeatNextRunResult> { + const wsId = untrack(() => activeWorkspaceId); + try { + const res = await fetchImpl( + `${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat/next-run`, + ); + if (!res.ok) { + // 404 = the backend hasn't shipped CR-HB-3 yet → the FE falls back to + // an approximation. Surface as ok:false (non-fatal). + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Heartbeat next-run failed (HTTP ${res.status})`, + }; + } + const data = (await res.json().catch(() => null)) as { nextRunAt?: string | null } | null; + // `null` (disabled / no run scheduled) passes through; anything non-string + // also becomes null so a malformed body can't crash the countdown. + const raw = data?.nextRunAt; + const nextRunAt = typeof raw === "string" ? raw : null; + return { ok: true, nextRunAt }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Heartbeat next-run request failed", + }; + } + }, + watchConversation(conversationId: string): ChatStore { return watchConversation(conversationId); }, diff --git a/src/features/heartbeat/index.ts b/src/features/heartbeat/index.ts index 0789ea6..fd1cf8d 100644 --- a/src/features/heartbeat/index.ts +++ b/src/features/heartbeat/index.ts @@ -2,27 +2,32 @@ export type { HeartbeatConfig, HeartbeatConfigPatch, HeartbeatConfigResult, + HeartbeatNextRunResult, HeartbeatRun, HeartbeatRunStatus, HeartbeatRunsResult, HeartbeatStopResult, LoadHeartbeatConfig, + LoadHeartbeatNextRun, LoadHeartbeatRuns, SaveHeartbeatConfig, StopHeartbeatRun, } from "./logic/types"; export type { Badge, HeartbeatFormState, HeartbeatRunView } from "./logic/view-model"; export { + approximateNextRunEpoch, badgeForStatus, DEFAULT_INTERVAL_MINUTES, effectiveSystemPrompt, effortOptions, emptyForm, + formatCountdown, formatRunTime, formDiffers, formFromConfig, isInheritingSystemPrompt, joinInterval, + nextRunEpoch, normalizeHeartbeatConfig, normalizeHeartbeatRuns, normalizeInterval, diff --git a/src/features/heartbeat/logic/types.ts b/src/features/heartbeat/logic/types.ts index b3871c9..ede22a6 100644 --- a/src/features/heartbeat/logic/types.ts +++ b/src/features/heartbeat/logic/types.ts @@ -87,3 +87,17 @@ export type SaveHeartbeatConfig = ( ) => 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 index 63bbf16..d237817 100644 --- a/src/features/heartbeat/logic/view-model.test.ts +++ b/src/features/heartbeat/logic/view-model.test.ts @@ -2,16 +2,19 @@ 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, @@ -397,3 +400,71 @@ describe("normalizeHeartbeatRuns", () => { 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 index 9f25eb9..93c0e55 100644 --- a/src/features/heartbeat/logic/view-model.ts +++ b/src/features/heartbeat/logic/view-model.ts @@ -152,6 +152,60 @@ function dateLabel(epochMs: number): string { 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 ─────────────────────────────────────────────────────────────── /** diff --git a/src/features/heartbeat/ui/HeartbeatView.svelte b/src/features/heartbeat/ui/HeartbeatView.svelte index d2a7e96..5f262f8 100644 --- a/src/features/heartbeat/ui/HeartbeatView.svelte +++ b/src/features/heartbeat/ui/HeartbeatView.svelte @@ -3,19 +3,25 @@ import type { ReasoningEffort } from "@dispatch/transport-contract"; import { isReasoningEffort } from "../../chat/reasoning-effort"; import { + approximateNextRunEpoch, badgeForStatus, type Badge, emptyForm, effortOptions, + formatCountdown, formDiffers, formFromConfig, + joinInterval, + nextRunEpoch, patchFromForm, viewRuns, type HeartbeatFormState, type HeartbeatRunView, } from "../logic/view-model"; import type { + HeartbeatRun, LoadHeartbeatConfig, + LoadHeartbeatNextRun, LoadHeartbeatRuns, SaveHeartbeatConfig, StopHeartbeatRun, @@ -34,6 +40,7 @@ stopRun, loadVariables, loadDefaultPrompt, + loadNextRun, onOpenRun, }: { /** The available model names (for the config's model dropdown). */ @@ -47,6 +54,8 @@ /** Load the global system prompt — the default the heartbeat inherits when * its `systemPrompt` is empty (the workspace's regular prompt). */ loadDefaultPrompt: LoadSystemPrompt; + /** Load the server-authoritative next-run timestamp (the countdown source). */ + loadNextRun: LoadHeartbeatNextRun; /** Open a run's chat in the fullscreen modal (composition-root wires the live watch). */ onOpenRun: (run: HeartbeatRunView) => void; } = $props(); @@ -133,6 +142,9 @@ // ── Runs list (polls while mounted) ─────────────────────────────────────── let runs = $state<readonly HeartbeatRunView[]>([]); + /** The raw backend runs (carry `triggeredAt`), kept for the next-run + * approximation fallback (the view drops `triggeredAt` for display labels). */ + let rawRuns = $state<readonly HeartbeatRun[]>([]); /** True after the first successful load (gates the "No runs yet" empty state * WITHOUT flashing it before the initial fetch resolves). The per-poll * loading is intentionally INVISIBLE — it's near-instant and a visible @@ -145,6 +157,40 @@ /** Re-entrancy guard for background polling (no UI — prevents overlapping fetches). */ let refreshInFlight = false; + // ── Next-run countdown ─────────────────────────────────────────────────── + /** Epoch-ms of the next scheduled run, or null (no countdown shown). Sourced + * from the backend's `next-run` endpoint; falls back to an approximation + * (latest run + interval) when the endpoint is unavailable (404 — pre-CR-HB-3). */ + let nextRunAt = $state<number | null>(null); + /** Once the next-run endpoint fails (404), stop polling it (avoid 404 spam) and + * rely on the approximation. Reset only on remount. */ + let nextRunEndpointFailed = $state(false); + + async function refreshNextRun(): Promise<void> { + if (nextRunEndpointFailed) return; + const result = await loadNextRun(); + if (result === null) return; + if (result.ok) { + nextRunAt = nextRunEpoch(result.nextRunAt); + } else { + // Endpoint absent / errored → stop polling it + use the approximation. + nextRunEndpointFailed = true; + } + } + + /** The fallback countdown source: latest run + interval (only when enabled + + * ≥1 run). Recomputed reactively from the loaded config + raw runs. */ + const approxNextRun = $derived( + approximateNextRunEpoch( + rawRuns, + joinInterval(loadedConfig.intervalHours, loadedConfig.intervalMinutes), + loadedConfig.enabled, + ), + ); + /** The effective next-run epoch: the server value if available, else the + * approximation. Drives the countdown. */ + const effectiveNextRun = $derived(nextRunEndpointFailed ? approxNextRun : nextRunAt); + const RUN_POLL_MS = 4000; async function refreshRuns(): Promise<void> { @@ -154,6 +200,7 @@ refreshInFlight = false; if (result === null) return; if (result.ok) { + rawRuns = result.runs; runs = viewRuns(result.runs); // Clear the error only on success so it stays visible (stable, no // flicker) during an in-flight retry rather than vanishing mid-poll. @@ -178,15 +225,18 @@ } } - // Load config + runs on mount, and poll runs while the view is alive so a - // running run's completion/stopped transition shows without a manual refresh. + // Load config + runs + next-run on mount, and poll them while the view is + // alive so a running run's completion/stopped transition + the next-run timer + // stay fresh without a manual refresh. $effect(() => { untrack(() => { void refreshConfig(); void refreshRuns(); + void refreshNextRun(); }); pollHandle = setInterval(() => { void refreshRuns(); + void refreshNextRun(); }, RUN_POLL_MS); return () => { if (pollHandle !== null) clearInterval(pollHandle); @@ -207,45 +257,67 @@ void tick; // depend on the ticker return runs; }); + + // The countdown clock: ticks every second so the "next run in Xm Ys" stays + // live. Pure countdown math is in `formatCountdown` (view-model); this only + // advances `now`. + let now = $state(Date.now()); + $effect(() => { + const h = setInterval(() => { + now = Date.now(); + }, 1000); + return () => clearInterval(h); + }); + const countdownMs = $derived( + effectiveNextRun !== null ? effectiveNextRun - now : null, + ); + const countdownLabel = $derived(formatCountdown(countdownMs)); </script> <div class="flex flex-col gap-3"> <!-- Enable / status header --> - <section class="flex items-center justify-between gap-2"> - <div class="flex items-center gap-2"> + <section class="flex flex-col gap-1"> + <div class="flex items-center justify-between gap-2"> + <div class="flex items-center gap-2"> + <button + type="button" + role="switch" + aria-checked={form.enabled} + aria-label="Toggle heartbeat" + class="toggle toggle-sm" + class:toggle-primary={form.enabled} + disabled={saving || configLoading} + onclick={handleToggleEnabled} + ></button> + <span class="text-xs font-semibold uppercase opacity-60"> + {#if configLoading} + Loading… + {:else if form.enabled} + Enabled + {:else} + Disabled + {/if} + </span> + </div> <button type="button" - role="switch" - aria-checked={form.enabled} - aria-label="Toggle heartbeat" - class="toggle toggle-sm" - class:toggle-primary={form.enabled} - disabled={saving || configLoading} - onclick={handleToggleEnabled} - ></button> - <span class="text-xs font-semibold uppercase opacity-60"> + class="btn btn-ghost btn-xs" + disabled={configLoading} + onclick={() => refreshConfig()} + aria-label="Refresh heartbeat config" + > {#if configLoading} - Loading… - {:else if form.enabled} - Enabled + <span class="loading loading-spinner loading-xs"></span> {:else} - Disabled + Refresh {/if} - </span> + </button> </div> - <button - type="button" - class="btn btn-ghost btn-xs" - disabled={configLoading} - onclick={() => refreshConfig()} - aria-label="Refresh heartbeat config" - > - {#if configLoading} - <span class="loading loading-spinner loading-xs"></span> - {:else} - Refresh - {/if} - </button> + {#if form.enabled && effectiveNextRun !== null} + <p class="text-xs opacity-60" title="When the next heartbeat run fires"> + Next run in {countdownLabel} + </p> + {/if} </section> {#if configError} |
