diff options
| author | Adam Malczewski <[email protected]> | 2026-06-27 00:46:15 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-27 00:46:15 +0900 |
| commit | d92a4af6191d7d20acf861adf605ad0227b6b287 (patch) | |
| tree | 281cbf31faf648e96c0ddc89ef822254de52b8cd | |
| parent | 038e0d72e0fb66b293cb9dbb93c25c4818cf07c3 (diff) | |
| download | dispatch-d92a4af6191d7d20acf861adf605ad0227b6b287.tar.gz dispatch-d92a4af6191d7d20acf861adf605ad0227b6b287.zip | |
feat(heartbeat): GET /workspaces/:id/heartbeat/next-run endpoint (CR-HB-3)
| -rw-r--r-- | packages/heartbeat/src/heartbeat.test.ts | 50 | ||||
| -rw-r--r-- | packages/heartbeat/src/heartbeat.ts | 15 | ||||
| -rw-r--r-- | packages/heartbeat/src/scheduler.test.ts | 103 | ||||
| -rw-r--r-- | packages/heartbeat/src/scheduler.ts | 26 | ||||
| -rw-r--r-- | packages/transport-http/src/app.test.ts | 62 | ||||
| -rw-r--r-- | packages/transport-http/src/app.ts | 21 |
6 files changed, 277 insertions, 0 deletions
diff --git a/packages/heartbeat/src/heartbeat.test.ts b/packages/heartbeat/src/heartbeat.test.ts index b2a0644..7e82611 100644 --- a/packages/heartbeat/src/heartbeat.test.ts +++ b/packages/heartbeat/src/heartbeat.test.ts @@ -601,4 +601,54 @@ describe("createHeartbeatService", () => { expect(orch.pending).toHaveLength(1); expect(orch.pending[0]?.workspaceId).toBe("heartbeat"); }); + + // ─── nextRunAt (CR-HB-3: server-authoritative next-run time) ──────────────── + + it("nextRunAt returns null when the heartbeat is disabled", async () => { + const { svc } = createService({ orch: createFakeOrchestrator() }); + // Default config → disabled → no schedule armed. + expect(await svc.nextRunAt("ws-1")).toBeNull(); + }); + + it("nextRunAt returns the ISO timestamp of the next fire when enabled", async () => { + const { svc } = createService({ orch: createFakeOrchestrator() }); + await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 }); + // now=0, interval=1m → next fire at epoch-ms 60_000 → its ISO form. + expect(await svc.nextRunAt("ws-1")).toBe(new Date(60_000).toISOString()); + }); + + it("nextRunAt returns null while a run is in progress, then the next fire after it completes", async () => { + const orch = createFakeOrchestrator(); + const { svc, advance } = createService({ orch }); + await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 }); + expect(await svc.nextRunAt("ws-1")).toBe(new Date(60_000).toISOString()); + + advance(60_000); // fire → run in progress + await flush(); + expect(orch.pending).toHaveLength(1); + // In flight → no next run queued yet. + expect(await svc.nextRunAt("ws-1")).toBeNull(); + + orch.pending[0]?.resolve(); + await flush(); + // Re-armed at completion-time (60_000) + interval (60_000) = 120_000. + expect(await svc.nextRunAt("ws-1")).toBe(new Date(120_000).toISOString()); + }); + + it("nextRunAt returns null after disabling the heartbeat", async () => { + const { svc } = createService({ orch: createFakeOrchestrator() }); + await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 }); + expect(await svc.nextRunAt("ws-1")).not.toBeNull(); + await svc.updateConfig("ws-1", { enabled: false }); + expect(await svc.nextRunAt("ws-1")).toBeNull(); + }); + + it("nextRunAt reflects a changed interval on the next re-arm", async () => { + const { svc } = createService({ orch: createFakeOrchestrator() }); + await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 }); + expect(await svc.nextRunAt("ws-1")).toBe(new Date(60_000).toISOString()); + // Re-arm with a 5-minute interval (not running) → recomputed fire time. + await svc.updateConfig("ws-1", { intervalMinutes: 5 }); + expect(await svc.nextRunAt("ws-1")).toBe(new Date(5 * 60_000).toISOString()); + }); }); diff --git a/packages/heartbeat/src/heartbeat.ts b/packages/heartbeat/src/heartbeat.ts index cb5afb4..8623907 100644 --- a/packages/heartbeat/src/heartbeat.ts +++ b/packages/heartbeat/src/heartbeat.ts @@ -40,6 +40,16 @@ export interface HeartbeatService { /** Heartbeat runs for a workspace, most-recent first. */ readonly listRuns: (workspaceId: string) => Promise<readonly HeartbeatRun[]>; /** + * The server-authoritative next-fire time for a workspace's heartbeat, as + * an ISO 8601 string — the moment the scheduler will fire the next run + * (the last run's completion + `intervalMinutes`, or the moment `enabled` + * was toggled on + `intervalMinutes` for the first run). `null` when the + * heartbeat is disabled/disarmed, or when a run is in flight and the next + * hasn't been queued yet (no countdown to show). A cheap read of the + * scheduler's pending fire time. + */ + readonly nextRunAt: (workspaceId: string) => Promise<string | null>; + /** * Stop an in-flight run (abort its turn). Idempotent for an already-finished * run. Throws when the run id is unknown (→ HTTP 404). */ @@ -260,6 +270,11 @@ export function createHeartbeatService(deps: HeartbeatServiceDeps): HeartbeatSer return runStore.list(workspaceId); }, + async nextRunAt(workspaceId) { + const ms = scheduler.nextFireAt(workspaceId); + return ms === null ? null : new Date(ms).toISOString(); + }, + async stopRun(workspaceId, runId) { const run = await runStore.get(workspaceId, runId); if (run === null) { diff --git a/packages/heartbeat/src/scheduler.test.ts b/packages/heartbeat/src/scheduler.test.ts index 355410c..826322c 100644 --- a/packages/heartbeat/src/scheduler.test.ts +++ b/packages/heartbeat/src/scheduler.test.ts @@ -237,4 +237,107 @@ describe("HeartbeatScheduler", () => { fake.advance(60_000); expect(fireCount).toBe(2); }); + + // ─── nextFireAt (CR-HB-3: server-authoritative next-run time) ────────────── + + it("nextFireAt returns null for a workspace with no schedule", () => { + const fake = createFakeTimers(); + const scheduler = new HeartbeatScheduler({ + timers: fake.timers, + fire: () => createDeferred().promise, + }); + expect(scheduler.nextFireAt("unknown")).toBeNull(); + }); + + it("nextFireAt returns the absolute fire time when armed (now + intervalMs)", () => { + const fake = createFakeTimers(); + const scheduler = new HeartbeatScheduler({ + timers: fake.timers, + fire: () => createDeferred().promise, + }); + // now=0, interval=1m → next fire at epoch-ms 60_000. + scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 }); + expect(scheduler.nextFireAt("ws-1")).toBe(60_000); + }); + + it("nextFireAt reflects a longer interval", () => { + const fake = createFakeTimers(); + const scheduler = new HeartbeatScheduler({ + timers: fake.timers, + fire: () => createDeferred().promise, + }); + scheduler.arm("ws-1", { enabled: true, intervalMinutes: 30 }); + expect(scheduler.nextFireAt("ws-1")).toBe(30 * 60_000); + }); + + it("nextFireAt reflects a new interval on re-arm (not running)", () => { + const fake = createFakeTimers(); + const scheduler = new HeartbeatScheduler({ + timers: fake.timers, + fire: () => createDeferred().promise, + }); + scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 }); + expect(scheduler.nextFireAt("ws-1")).toBe(60_000); + // Re-arm with a 5-minute interval (armed, not running) → recomputed. + scheduler.arm("ws-1", { enabled: true, intervalMinutes: 5 }); + expect(scheduler.nextFireAt("ws-1")).toBe(5 * 60_000); + }); + + it("nextFireAt returns null when disarmed", () => { + const fake = createFakeTimers(); + const scheduler = new HeartbeatScheduler({ + timers: fake.timers, + fire: () => createDeferred().promise, + }); + scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 }); + expect(scheduler.nextFireAt("ws-1")).toBe(60_000); + scheduler.disarm("ws-1"); + expect(scheduler.nextFireAt("ws-1")).toBeNull(); + }); + + it("nextFireAt returns null while a run is in progress, then the next fire after it completes", async () => { + const fake = createFakeTimers(); + const deferreds: Array<{ resolve: () => void }> = []; + const scheduler = new HeartbeatScheduler({ + timers: fake.timers, + fire: () => { + const d = createDeferred(); + deferreds.push(d); + return d.promise; + }, + }); + scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 }); + expect(scheduler.nextFireAt("ws-1")).toBe(60_000); + + fake.advance(60_000); // fire → run in progress + expect(scheduler.isRunning("ws-1")).toBe(true); + // In flight → no next run queued yet. + expect(scheduler.nextFireAt("ws-1")).toBeNull(); + + deferreds[0]?.resolve(); + await flush(); + // Re-armed at completion-time (60_000) + interval (60_000) = 120_000. + expect(scheduler.nextFireAt("ws-1")).toBe(120_000); + }); + + it("nextFireAt returns null after disarming mid-run (no re-arm)", async () => { + const fake = createFakeTimers(); + const deferreds: Array<{ resolve: () => void }> = []; + const scheduler = new HeartbeatScheduler({ + timers: fake.timers, + fire: () => { + const d = createDeferred(); + deferreds.push(d); + return d.promise; + }, + }); + scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 }); + fake.advance(60_000); // fire in progress + expect(scheduler.nextFireAt("ws-1")).toBeNull(); // running + scheduler.disarm("ws-1"); // disarm mid-run + deferreds[0]?.resolve(); + await flush(); + // Disarmed → no re-arm, no fire time. + expect(scheduler.nextFireAt("ws-1")).toBeNull(); + }); }); diff --git a/packages/heartbeat/src/scheduler.ts b/packages/heartbeat/src/scheduler.ts index e366bbc..c495b1e 100644 --- a/packages/heartbeat/src/scheduler.ts +++ b/packages/heartbeat/src/scheduler.ts @@ -48,6 +48,14 @@ interface WorkspaceSchedule { running: boolean; /** Scheduling is active (the workspace's heartbeat is enabled). */ armed: boolean; + /** + * Absolute epoch-ms timestamp of the next scheduled fire, or null when no + * fire is pending — the schedule is disarmed, or a run is in progress + * (the next fire is scheduled only after the run completes). This is the + * server-authoritative next-run time the `/heartbeat/next-run` endpoint + * derives its response from. + */ + nextFireMs: number | null; } const MINUTE_MS = 60_000; @@ -90,6 +98,7 @@ export class HeartbeatScheduler { timer: undefined, running: false, armed: true, + nextFireMs: null, }; this.schedules.set(workspaceId, schedule); } else { @@ -130,8 +139,21 @@ export class HeartbeatScheduler { return this.schedules.get(workspaceId)?.running ?? false; } + /** + * The absolute epoch-ms timestamp of the next scheduled fire for a workspace, + * or null when no fire is pending — the heartbeat is disabled/disarmed, or a + * run is in progress (the next fire is scheduled only after the run + * completes). This is the server-authoritative next-run time. + */ + nextFireAt(workspaceId: string): number | null { + return this.schedules.get(workspaceId)?.nextFireMs ?? null; + } + private scheduleNext(workspaceId: string, schedule: WorkspaceSchedule): void { const ms = Math.max(MINUTE_MS, schedule.intervalMinutes * MINUTE_MS); + // Record the absolute fire time (now + delay) BEFORE arming the timer + // so `nextFireAt` reports it while the timer is pending. + schedule.nextFireMs = this.timers.now() + ms; schedule.timer = this.timers.setTimeout(() => { this.onTick(workspaceId); }, ms); @@ -142,6 +164,9 @@ export class HeartbeatScheduler { // Race: the schedule was disarmed after the timer was queued. if (schedule === undefined || !schedule.armed) return; schedule.timer = undefined; + // A fire is now in progress — no next run is queued yet (it's scheduled + // only after the run completes), so report no pending fire time. + schedule.nextFireMs = null; schedule.running = true; this.fire(workspaceId) .catch(() => { @@ -163,5 +188,6 @@ export class HeartbeatScheduler { this.timers.clearTimeout(schedule.timer); schedule.timer = undefined; } + schedule.nextFireMs = null; } } diff --git a/packages/transport-http/src/app.test.ts b/packages/transport-http/src/app.test.ts index 4f64ece..316ab67 100644 --- a/packages/transport-http/src/app.test.ts +++ b/packages/transport-http/src/app.test.ts @@ -1,3 +1,4 @@ +import { DEFAULT_HEARTBEAT_CONFIG } from "@dispatch/heartbeat"; import type { AgentEvent, ChatMessage, @@ -29,6 +30,7 @@ import type { ComputerService, ConversationStore, CredentialStore, + HeartbeatService, LspService, McpService, SessionOrchestrator, @@ -516,6 +518,23 @@ function createFakeComputerService(computers: readonly ComputerEntry[] = []): Co }; } +/** + * A minimal HeartbeatService fake for the next-run route: only `nextRunAt` is + * exercised by the route; the rest return inert defaults so the object + * satisfies the interface without dragging in real stores/scheduler. + */ +function createFakeHeartbeatService(nextRunAt: string | null): HeartbeatService { + return { + getConfig: async () => DEFAULT_HEARTBEAT_CONFIG, + updateConfig: async () => DEFAULT_HEARTBEAT_CONFIG, + listRuns: async () => [], + stopRun: async () => ({ ok: true }), + startAll: async () => {}, + stopAll: () => {}, + nextRunAt: async () => nextRunAt, + }; +} + const noopLogger = createFakeLogger(); describe("GET /health", () => { @@ -4330,3 +4349,46 @@ describe("POST /chat threads computerId", () => { expect(cap.received?.computerId).toBeUndefined(); }); }); + +describe("GET /workspaces/:id/heartbeat/next-run", () => { + it("returns { nextRunAt: null } when no HeartbeatService is wired (graceful degrade)", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/workspaces/ws-1/heartbeat/next-run"); + expect(res.status).toBe(200); + const body = (await res.json()) as { nextRunAt: string | null }; + expect(body.nextRunAt).toBeNull(); + }); + + it("delegates to the HeartbeatService and returns the next-run ISO timestamp", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + heartbeatService: createFakeHeartbeatService("2026-06-25T14:05:00Z"), + logger: noopLogger, + }); + const res = await app.request("/workspaces/ws-1/heartbeat/next-run"); + expect(res.status).toBe(200); + const body = (await res.json()) as { nextRunAt: string | null }; + expect(body.nextRunAt).toBe("2026-06-25T14:05:00Z"); + }); + + it("returns { nextRunAt: null } when the service reports no scheduled run", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + heartbeatService: createFakeHeartbeatService(null), + logger: noopLogger, + }); + const res = await app.request("/workspaces/ws-1/heartbeat/next-run"); + expect(res.status).toBe(200); + const body = (await res.json()) as { nextRunAt: string | null }; + expect(body.nextRunAt).toBeNull(); + }); +}); diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts index b9327b3..27ae2bb 100644 --- a/packages/transport-http/src/app.ts +++ b/packages/transport-http/src/app.ts @@ -1489,6 +1489,27 @@ export function createApp(opts: CreateServerOptions): Hono { } }); + // The server-authoritative next-fire time for a workspace's heartbeat. A + // lightweight read of the scheduler's pending fire time (polled by the FE + // alongside the runs list). `nextRunAt` is null when the heartbeat is + // disabled/disarmed, or when a run is in flight and the next hasn't been + // queued yet — the FE then shows no countdown, not a fabricated one. + app.get("/workspaces/:id/heartbeat/next-run", async (c) => { + const workspaceId = c.req.param("id"); + if (opts.heartbeatService === undefined) { + // Graceful: no heartbeat configured → no next run scheduled. + return c.json({ nextRunAt: null }, 200); + } + try { + const nextRunAt = await opts.heartbeatService.nextRunAt(workspaceId); + log.info("heartbeat: next-run read", { workspaceId, nextRunAt }); + return c.json({ nextRunAt }, 200); + } catch (err) { + log.error("heartbeat: next-run read failure", { err, workspaceId }); + return c.json({ error: "Failed to read heartbeat next-run" }, 500); + } + }); + app.post("/workspaces/:id/heartbeat/runs/:runId/stop", async (c) => { const workspaceId = c.req.param("id"); const runId = c.req.param("runId"); |
