summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-27 00:46:15 +0900
committerAdam Malczewski <[email protected]>2026-06-27 00:46:15 +0900
commitd92a4af6191d7d20acf861adf605ad0227b6b287 (patch)
tree281cbf31faf648e96c0ddc89ef822254de52b8cd
parent038e0d72e0fb66b293cb9dbb93c25c4818cf07c3 (diff)
downloaddispatch-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.ts50
-rw-r--r--packages/heartbeat/src/heartbeat.ts15
-rw-r--r--packages/heartbeat/src/scheduler.test.ts103
-rw-r--r--packages/heartbeat/src/scheduler.ts26
-rw-r--r--packages/transport-http/src/app.test.ts62
-rw-r--r--packages/transport-http/src/app.ts21
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");