diff options
| author | Adam Malczewski <[email protected]> | 2026-06-26 00:02:22 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-26 00:02:22 +0900 |
| commit | c5c34ed70e0f04b7b936fa7a1d88ef807472fb96 (patch) | |
| tree | 2eb3d32159c2875cf518fc47b7f48010d506aafe | |
| parent | 9b91d1bca83e7599fb7d7de6038cedf186e61764 (diff) | |
| download | dispatch-c5c34ed70e0f04b7b936fa7a1d88ef807472fb96.tar.gz dispatch-c5c34ed70e0f04b7b936fa7a1d88ef807472fb96.zip | |
feat(heartbeat): workspace heartbeat loop with configurable AI monitoring
24 files changed, 1752 insertions, 33 deletions
@@ -58,6 +58,15 @@ "@dispatch/kernel": "workspace:*", }, }, + "packages/heartbeat": { + "name": "@dispatch/heartbeat", + "version": "0.0.0", + "dependencies": { + "@dispatch/kernel": "workspace:*", + "@dispatch/session-orchestrator": "workspace:*", + "@dispatch/transport-contract": "workspace:*", + }, + }, "packages/host-bin": { "name": "@dispatch/host-bin", "version": "0.0.0", @@ -67,6 +76,7 @@ "@dispatch/conversation-store": "workspace:*", "@dispatch/credential-store": "workspace:*", "@dispatch/exec-backend": "workspace:*", + "@dispatch/heartbeat": "workspace:*", "@dispatch/journal-sink": "workspace:*", "@dispatch/kernel": "workspace:*", "@dispatch/lsp": "workspace:*", @@ -310,7 +320,7 @@ }, "packages/transport-contract": { "name": "@dispatch/transport-contract", - "version": "0.22.0", + "version": "0.23.0", "dependencies": { "@dispatch/ui-contract": "workspace:*", "@dispatch/wire": "workspace:*", @@ -322,6 +332,7 @@ "dependencies": { "@dispatch/conversation-store": "workspace:*", "@dispatch/credential-store": "workspace:*", + "@dispatch/heartbeat": "workspace:*", "@dispatch/kernel": "workspace:*", "@dispatch/lsp": "workspace:*", "@dispatch/mcp": "workspace:*", @@ -384,6 +395,8 @@ "@dispatch/exec-backend": ["@dispatch/exec-backend@workspace:packages/exec-backend"], + "@dispatch/heartbeat": ["@dispatch/heartbeat@workspace:packages/heartbeat"], + "@dispatch/host-bin": ["@dispatch/host-bin@workspace:packages/host-bin"], "@dispatch/journal-sink": ["@dispatch/journal-sink@workspace:packages/journal-sink"], diff --git a/packages/heartbeat/package.json b/packages/heartbeat/package.json new file mode 100644 index 0000000..40212b9 --- /dev/null +++ b/packages/heartbeat/package.json @@ -0,0 +1,13 @@ +{ + "name": "@dispatch/heartbeat", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/kernel": "workspace:*", + "@dispatch/session-orchestrator": "workspace:*", + "@dispatch/transport-contract": "workspace:*" + } +} diff --git a/packages/heartbeat/src/config-store.test.ts b/packages/heartbeat/src/config-store.test.ts new file mode 100644 index 0000000..0cb6afa --- /dev/null +++ b/packages/heartbeat/src/config-store.test.ts @@ -0,0 +1,115 @@ +import type { StorageNamespace } from "@dispatch/kernel"; +import { describe, expect, it } from "vitest"; +import { + applyConfigUpdate, + createHeartbeatConfigStore, + DEFAULT_HEARTBEAT_CONFIG, +} from "./config-store.js"; + +function createMemoryStorage(): StorageNamespace { + const data = new Map<string, string>(); + return { + get: async (key) => data.get(key) ?? null, + set: async (key, value) => { + data.set(key, value); + }, + delete: async (key) => { + data.delete(key); + }, + has: async (key) => data.has(key), + keys: async (prefix) => { + const all = [...data.keys()]; + if (prefix === undefined) return all; + return all.filter((k) => k.startsWith(prefix)); + }, + }; +} + +describe("applyConfigUpdate (pure)", () => { + it("leaves omitted fields unchanged from the default", () => { + const next = applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, {}); + expect(next).toEqual(DEFAULT_HEARTBEAT_CONFIG); + }); + + it("applies provided fields", () => { + const next = applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { + enabled: true, + systemPrompt: "you are a monitor", + taskPrompt: "check for stuck chats", + model: "opencode/gpt-4o", + }); + expect(next.enabled).toBe(true); + expect(next.systemPrompt).toBe("you are a monitor"); + expect(next.taskPrompt).toBe("check for stuck chats"); + expect(next.model).toBe("opencode/gpt-4o"); + // Untouched fields keep defaults. + expect(next.intervalMinutes).toBe(30); + expect(next.reasoningEffort).toBeNull(); + }); + + it("clamps intervalMinutes to a minimum of 1", () => { + expect(applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { intervalMinutes: 0 }).intervalMinutes).toBe( + 1, + ); + expect( + applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { intervalMinutes: -5 }).intervalMinutes, + ).toBe(1); + expect(applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { intervalMinutes: 7 }).intervalMinutes).toBe( + 7, + ); + }); + + it("truncates a non-integer interval to an integer", () => { + expect( + applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { intervalMinutes: 12.9 }).intervalMinutes, + ).toBe(12); + }); + + it("clears reasoningEffort when null is passed", () => { + const withEffort = applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { reasoningEffort: "high" }); + expect(withEffort.reasoningEffort).toBe("high"); + const cleared = applyConfigUpdate(withEffort, { reasoningEffort: null }); + expect(cleared.reasoningEffort).toBeNull(); + }); + + it("treats an absent reasoningEffort as unchanged (distinct from null)", () => { + const withEffort = applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { reasoningEffort: "low" }); + const untouched = applyConfigUpdate(withEffort, { enabled: true }); + expect(untouched.reasoningEffort).toBe("low"); + }); +}); + +describe("createHeartbeatConfigStore", () => { + it("returns the default config for an unknown workspace", async () => { + const store = createHeartbeatConfigStore(createMemoryStorage()); + expect(await store.get("ws-1")).toEqual(DEFAULT_HEARTBEAT_CONFIG); + }); + + it("persists and round-trips an update", async () => { + const storage = createMemoryStorage(); + const store = createHeartbeatConfigStore(storage); + const next = await store.update("ws-1", { enabled: true, intervalMinutes: 5 }); + expect(next.enabled).toBe(true); + expect(next.intervalMinutes).toBe(5); + // A fresh store over the same storage reads the persisted value. + const store2 = createHeartbeatConfigStore(storage); + expect(await store2.get("ws-1")).toEqual(next); + }); + + it("applies a partial update on top of existing config", async () => { + const storage = createMemoryStorage(); + const store = createHeartbeatConfigStore(storage); + await store.update("ws-1", { enabled: true, systemPrompt: "a", taskPrompt: "b" }); + const next = await store.update("ws-1", { taskPrompt: "c" }); + expect(next.enabled).toBe(true); + expect(next.systemPrompt).toBe("a"); + expect(next.taskPrompt).toBe("c"); + }); + + it("lists persisted workspace ids", async () => { + const store = createHeartbeatConfigStore(createMemoryStorage()); + await store.update("ws-b", { enabled: true }); + await store.update("ws-a", { enabled: false }); + expect(await store.listWorkspaceIds()).toEqual(["ws-a", "ws-b"]); + }); +}); diff --git a/packages/heartbeat/src/config-store.ts b/packages/heartbeat/src/config-store.ts new file mode 100644 index 0000000..74c01e2 --- /dev/null +++ b/packages/heartbeat/src/config-store.ts @@ -0,0 +1,108 @@ +import type { StorageNamespace } from "@dispatch/kernel"; +import type { HeartbeatConfig, UpdateHeartbeatRequest } from "@dispatch/transport-contract"; + +/** + * The default config returned for a workspace that has never configured a + * heartbeat. A heartbeat is OFF until explicitly enabled. + */ +export const DEFAULT_HEARTBEAT_CONFIG: HeartbeatConfig = { + enabled: false, + systemPrompt: "", + taskPrompt: "", + intervalMinutes: 30, + model: "", + reasoningEffort: null, +}; + +/** Minimum scheduling interval, in minutes (a heartbeat can't fire faster). */ +export const MIN_INTERVAL_MINUTES = 1; + +/** Storage key for a workspace's heartbeat config. */ +function configKey(workspaceId: string): string { + return `config:${workspaceId}`; +} + +/** + * Pure: apply a partial update to a config, returning the new config. Clamps + * `intervalMinutes` to a minimum of 1 (a positive integer). Fields not present + * in `update` are left unchanged. `reasoningEffort: null` clears the override + * (back to inheriting the workspace default); `undefined` (absent) leaves it. + * + * Validation of `reasoningEffort` (rejecting unrecognized strings) is the + * transport layer's job (→ HTTP 400); this function trusts a caller that has + * already validated, but only ever produces a valid `HeartbeatConfig`. + */ +export function applyConfigUpdate( + current: HeartbeatConfig, + update: UpdateHeartbeatRequest, +): HeartbeatConfig { + const next: HeartbeatConfig = { + enabled: update.enabled !== undefined ? update.enabled : current.enabled, + systemPrompt: update.systemPrompt !== undefined ? update.systemPrompt : current.systemPrompt, + taskPrompt: update.taskPrompt !== undefined ? update.taskPrompt : current.taskPrompt, + intervalMinutes: + update.intervalMinutes !== undefined + ? Math.max(MIN_INTERVAL_MINUTES, Math.trunc(update.intervalMinutes)) + : current.intervalMinutes, + model: update.model !== undefined ? update.model : current.model, + reasoningEffort: + update.reasoningEffort !== undefined ? update.reasoningEffort : current.reasoningEffort, + }; + return next; +} + +export interface HeartbeatConfigStore { + /** The config for a workspace (the default when never set). */ + readonly get: (workspaceId: string) => Promise<HeartbeatConfig>; + /** Apply a partial update and persist it; returns the new config. */ + readonly update: ( + workspaceId: string, + update: UpdateHeartbeatRequest, + ) => Promise<HeartbeatConfig>; + /** Every workspace id that has a persisted (non-default) config. */ + readonly listWorkspaceIds: () => Promise<readonly string[]>; +} + +export function createHeartbeatConfigStore( + storage: StorageNamespace, +): HeartbeatConfigStore { + return { + async get(workspaceId: string): Promise<HeartbeatConfig> { + const raw = await storage.get(configKey(workspaceId)); + if (raw === null) return DEFAULT_HEARTBEAT_CONFIG; + try { + const parsed = JSON.parse(raw) as Partial<HeartbeatConfig>; + return { + enabled: typeof parsed.enabled === "boolean" ? parsed.enabled : false, + systemPrompt: typeof parsed.systemPrompt === "string" ? parsed.systemPrompt : "", + taskPrompt: typeof parsed.taskPrompt === "string" ? parsed.taskPrompt : "", + intervalMinutes: + typeof parsed.intervalMinutes === "number" && parsed.intervalMinutes > 0 + ? Math.trunc(parsed.intervalMinutes) + : DEFAULT_HEARTBEAT_CONFIG.intervalMinutes, + model: typeof parsed.model === "string" ? parsed.model : "", + reasoningEffort: parsed.reasoningEffort ?? null, + }; + } catch { + return DEFAULT_HEARTBEAT_CONFIG; + } + }, + + async update( + workspaceId: string, + update: UpdateHeartbeatRequest, + ): Promise<HeartbeatConfig> { + const current = await this.get(workspaceId); + const next = applyConfigUpdate(current, update); + await storage.set(configKey(workspaceId), JSON.stringify(next)); + return next; + }, + + async listWorkspaceIds(): Promise<readonly string[]> { + const keys = await storage.keys("config:"); + const ids = keys.map((k) => k.slice("config:".length)); + // De-duplicate + sort for a stable boot-scan order. + return [...new Set(ids)].sort(); + }, + }; +} diff --git a/packages/heartbeat/src/extension.ts b/packages/heartbeat/src/extension.ts new file mode 100644 index 0000000..66580e6 --- /dev/null +++ b/packages/heartbeat/src/extension.ts @@ -0,0 +1,52 @@ +/** + * Heartbeat extension — manifest + activate(host). + * + * Wires the heartbeat service against the session-orchestrator + a storage + * namespace, registers the typed service handle, and arms every enabled + * workspace's scheduler on boot. + */ + +import type { Extension, HostAPI, Manifest } from "@dispatch/kernel"; +import { + type SessionOrchestrator, + sessionOrchestratorHandle, +} from "@dispatch/session-orchestrator"; +import { createHeartbeatService, heartbeatServiceHandle } from "./heartbeat.js"; + +export const manifest: Manifest = { + id: "heartbeat", + name: "Heartbeat", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + dependsOn: ["session-orchestrator"], + activation: "eager", + contributes: { services: ["heartbeat"] }, +}; + +// Module-scoped store for deactivate (the extension object is created once). +const store: { service: { stopAll: () => void } | null } = { service: null }; + +export const extension: Extension = { + manifest, + async activate(host: HostAPI) { + const orchestrator = host.getService<SessionOrchestrator>(sessionOrchestratorHandle); + const storage = host.storage("heartbeat"); + const logger = host.logger; + + const service = createHeartbeatService({ storage, orchestrator, logger }); + + // Reconcile stale runs + arm enabled workspaces on boot. + await service.startAll(); + store.service = service; + + host.provideService(heartbeatServiceHandle, service); + + host.logger.info("heartbeat extension activated"); + }, + deactivate() { + // Stop schedulers so no new fires happen during shutdown. + store.service?.stopAll(); + store.service = null; + }, +}; diff --git a/packages/heartbeat/src/heartbeat.test.ts b/packages/heartbeat/src/heartbeat.test.ts new file mode 100644 index 0000000..c185c92 --- /dev/null +++ b/packages/heartbeat/src/heartbeat.test.ts @@ -0,0 +1,318 @@ +import type { StorageNamespace } from "@dispatch/kernel"; +import type { + EnqueueInput, + EnqueueResult, + SessionOrchestrator, + StartTurnResult, + TurnEventListener, +} from "@dispatch/session-orchestrator"; +import { describe, expect, it } from "vitest"; +import { createHeartbeatService } from "./heartbeat.js"; + +function createMemoryStorage(): StorageNamespace { + const data = new Map<string, string>(); + return { + get: async (key) => data.get(key) ?? null, + set: async (key, value) => { + data.set(key, value); + }, + delete: async (key) => { + data.delete(key); + }, + has: async (key) => data.has(key), + keys: async (prefix) => { + const all = [...data.keys()]; + if (prefix === undefined) return all; + return all.filter((k) => k.startsWith(prefix)); + }, + }; +} + +/** A controllable fake clock with an `advance` to move virtual time. */ +function createFakeTimers() { + let now = 0; + let nextId = 1; + const timers = new Map<number, { readonly fn: () => void; readonly firesAt: number }>(); + return { + timers: { + now: () => now, + setTimeout: (fn: () => void, ms: number) => { + const id = nextId++; + timers.set(id, { fn, firesAt: now + ms }); + return id as unknown as ReturnType<typeof setTimeout>; + }, + clearTimeout: (handle: ReturnType<typeof setTimeout> | undefined) => { + if (handle !== undefined) timers.delete(handle as unknown as number); + }, + }, + advance(ms: number): void { + now += ms; + const due = [...timers.entries()] + .filter(([, t]) => t.firesAt <= now) + .sort((a, b) => a[0] - b[0]); + for (const [id, t] of due) { + timers.delete(id); + t.fn(); + } + }, + }; +} + +interface PendingTurn { + readonly conversationId: string; + readonly text: string; + readonly systemPrompt?: string; + readonly modelName?: string; + readonly reasoningEffort?: unknown; + readonly workspaceId?: string; + resolve: () => void; +} + +/** + * A fake orchestrator that records handleMessage calls and lets the test + * control when each turn seals. This is the injected edge — the service depends + * on the SessionOrchestrator interface, not its implementation. + */ +function createFakeOrchestrator(): SessionOrchestrator & { + readonly pending: readonly PendingTurn[]; + readonly stopped: readonly string[]; +} { + const pending: PendingTurn[] = []; + const stopped: string[] = []; + const turns = new Map<string, { resolve: () => void; reject: (e: unknown) => void }>(); + + const fake: SessionOrchestrator = { + startTurn(): StartTurnResult { + return { started: false, reason: "already-active" }; + }, + enqueue(_input: EnqueueInput): EnqueueResult { + return { startedTurn: false, queue: [] }; + }, + subscribe(_conversationId: string, _listener: TurnEventListener): () => void { + return () => {}; + }, + isActive(_conversationId: string): boolean { + return false; + }, + closeConversation(_conversationId: string): { abortedTurn: boolean } { + return { abortedTurn: false }; + }, + stopTurn(conversationId: string): { abortedTurn: boolean } { + stopped.push(conversationId); + const t = turns.get(conversationId); + if (t !== undefined) { + turns.delete(conversationId); + t.resolve(); + } + return { abortedTurn: true }; + }, + handleMessage(input): Promise<void> { + return new Promise<void>((resolve, reject) => { + const entry: PendingTurn = { + conversationId: input.conversationId, + text: input.text, + ...(input.systemPrompt !== undefined ? { systemPrompt: input.systemPrompt } : {}), + ...(input.modelName !== undefined ? { modelName: input.modelName } : {}), + ...(input.reasoningEffort !== undefined + ? { reasoningEffort: input.reasoningEffort } + : {}), + ...(input.workspaceId !== undefined ? { workspaceId: input.workspaceId } : {}), + resolve, + }; + pending.push(entry); + turns.set(input.conversationId, { resolve, reject }); + }); + }, + }; + return Object.assign(fake, { + get pending(): readonly PendingTurn[] { + return pending; + }, + get stopped(): readonly string[] { + return stopped; + }, + }); +} + +// Drain microtasks so async .finally handlers (run completion) run. `fire` has +// nested awaits (configStore.get → storage.get, runStore.create → storage.set) +// before it reaches handleMessage, so a single queueMicrotask isn't enough — +// setTimeout(0) schedules a macrotask, letting ALL pending microtasks drain. +const flush = async (): Promise<void> => { + await new Promise((r) => setTimeout(r, 0)); +}; + +function createService(opts: { + readonly orch: ReturnType<typeof createFakeOrchestrator>; + readonly storage?: StorageNamespace; +}) { + const fake = createFakeTimers(); + let id = 0; + const storage = opts.storage ?? createMemoryStorage(); + const svc = createHeartbeatService({ + storage, + orchestrator: opts.orch, + timers: fake.timers, + generateId: () => `id-${++id}`, + }); + return { svc, advance: fake.advance, storage }; +} + +describe("createHeartbeatService", () => { + it("returns the default config for an unknown workspace", async () => { + const { svc } = createService({ orch: createFakeOrchestrator() }); + const cfg = await svc.getConfig("ws-1"); + expect(cfg.enabled).toBe(false); + expect(cfg.intervalMinutes).toBe(30); + }); + + it("arming an enabled config does not fire immediately (waits for the interval)", () => { + const orch = createFakeOrchestrator(); + const { svc, advance } = createService({ orch }); + void svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go" }); + // Just arming → no turn yet. + expect(orch.pending).toHaveLength(0); + // Advancing just shy of the interval still no fire. + advance(59_999); + expect(orch.pending).toHaveLength(0); + }); + + it("fire sends the task prompt with the explicit system prompt + model + effort, and marks completed on seal", async () => { + const orch = createFakeOrchestrator(); + const { svc, advance } = createService({ orch }); + await svc.updateConfig("ws-1", { + enabled: true, + systemPrompt: "you are a monitor", + taskPrompt: "check stuck chats", + model: "opencode/gpt-4o", + reasoningEffort: "high", + intervalMinutes: 1, + }); + + advance(60_000); // 1-minute interval → fire + await flush(); // let the async fire() reach handleMessage + expect(orch.pending).toHaveLength(1); + const turn = orch.pending[0]!; + expect(turn.text).toBe("check stuck chats"); + expect(turn.systemPrompt).toBe("you are a monitor"); + expect(turn.modelName).toBe("opencode/gpt-4o"); + expect(turn.reasoningEffort).toBe("high"); + expect(turn.workspaceId).toBe("ws-1"); + + expect((await svc.listRuns("ws-1"))[0]?.status).toBe("running"); + + turn.resolve(); + await flush(); + expect((await svc.listRuns("ws-1"))[0]?.status).toBe("completed"); + }); + + it("omits modelName/reasoningEffort when empty/null (inherit defaults)", async () => { + const orch = createFakeOrchestrator(); + const { svc, advance } = createService({ orch }); + await svc.updateConfig("ws-1", { + enabled: true, + taskPrompt: "go", + model: "", + reasoningEffort: null, + intervalMinutes: 1, + }); + advance(60_000); + await flush(); + const turn = orch.pending[0]!; + expect(turn.modelName).toBeUndefined(); + expect(turn.reasoningEffort).toBeUndefined(); + turn.resolve(); + await flush(); + }); + + it("stopRun aborts the turn and marks the run stopped (not overwritten on completion)", async () => { + const orch = createFakeOrchestrator(); + const { svc, advance } = createService({ orch }); + await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 }); + advance(60_000); + await flush(); + const conversationId = orch.pending[0]!.conversationId; + const runId = (await svc.listRuns("ws-1"))[0]!.id; + + const res = await svc.stopRun("ws-1", runId); + expect(res).toEqual({ ok: true }); + expect(orch.stopped).toEqual([conversationId]); + + await flush(); // the aborted turn's handleMessage resolves + expect((await svc.listRuns("ws-1"))[0]?.status).toBe("stopped"); + }); + + it("stopRun is idempotent for an already-finished run", async () => { + const orch = createFakeOrchestrator(); + const { svc, advance } = createService({ orch }); + await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 }); + advance(60_000); + await flush(); + orch.pending[0]!.resolve(); + await flush(); + + const runId = (await svc.listRuns("ws-1"))[0]!.id; + const res = await svc.stopRun("ws-1", runId); + expect(res).toEqual({ ok: true }); + expect(orch.stopped).toEqual([]); // no abort on a completed run + }); + + it("stopRun throws for an unknown run id", async () => { + const { svc } = createService({ orch: createFakeOrchestrator() }); + await expect(svc.stopRun("ws-1", "nope")).rejects.toThrow(); + }); + + it("disabling the config stops the schedule (no further fires)", async () => { + const orch = createFakeOrchestrator(); + const { svc, advance } = createService({ orch }); + await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go" }); + await svc.updateConfig("ws-1", { enabled: false }); + advance(60_000); + expect(orch.pending).toHaveLength(0); + }); + + it("startAll sweeps stale running runs to stopped", async () => { + const storage = createMemoryStorage(); + await storage.set( + "run:ws-1:stale", + JSON.stringify({ + id: "stale", + conversationId: "c-stale", + triggeredAt: "2026-01-01T00:00:00.000Z", + status: "running", + }), + ); + await storage.set( + "config:ws-1", + JSON.stringify({ enabled: false, systemPrompt: "", taskPrompt: "", intervalMinutes: 30, model: "", reasoningEffort: null }), + ); + + const { svc } = createService({ orch: createFakeOrchestrator(), storage }); + await svc.startAll(); + expect((await svc.listRuns("ws-1"))[0]?.status).toBe("stopped"); + }); + + it("startAll arms enabled workspaces and skips disabled ones", async () => { + const orch = createFakeOrchestrator(); + const { svc: svc1, storage } = createService({ orch }); + await svc1.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 }); + await svc1.updateConfig("ws-2", { enabled: false, taskPrompt: "no" }); + svc1.stopAll(); + + // Re-create a fresh service over the SAME storage (simulates a reboot), + // with new fake timers we can advance. + const fake = createFakeTimers(); + let id = 1000; + const svc2 = createHeartbeatService({ + storage, + orchestrator: orch, + timers: fake.timers, + generateId: () => `id-${++id}`, + }); + await svc2.startAll(); + fake.advance(60_000); + await flush(); + expect(orch.pending.filter((t) => t.workspaceId === "ws-1")).toHaveLength(1); + expect(orch.pending.filter((t) => t.workspaceId === "ws-2")).toHaveLength(0); + }); +}); diff --git a/packages/heartbeat/src/heartbeat.ts b/packages/heartbeat/src/heartbeat.ts new file mode 100644 index 0000000..d1efe47 --- /dev/null +++ b/packages/heartbeat/src/heartbeat.ts @@ -0,0 +1,205 @@ +import type { Logger, ServiceHandle } from "@dispatch/kernel"; +import { defineService } from "@dispatch/kernel"; +import type { SessionOrchestrator } from "@dispatch/session-orchestrator"; +import type { + HeartbeatConfig, + HeartbeatRun, + StopHeartbeatRunResponse, + UpdateHeartbeatRequest, +} from "@dispatch/transport-contract"; +import { + type HeartbeatConfigStore, + createHeartbeatConfigStore, +} from "./config-store.js"; +import { type HeartbeatRunStore, createHeartbeatRunStore } from "./run-store.js"; +import { HeartbeatScheduler, type Timers, realTimers } from "./scheduler.js"; + +/** + * The heartbeat service surface — what transport-http consumes and what the + * extension wires into the host. + */ +export interface HeartbeatService { + /** The per-workspace heartbeat config (defaults when never set). */ + readonly getConfig: (workspaceId: string) => Promise<HeartbeatConfig>; + /** + * Apply a partial config update. Side effect: arms/disarms the scheduler + * for this workspace (enabled → schedule; disabled → stop). Returns the new + * config. + */ + readonly updateConfig: ( + workspaceId: string, + update: UpdateHeartbeatRequest, + ) => Promise<HeartbeatConfig>; + /** Heartbeat runs for a workspace, most-recent first. */ + readonly listRuns: (workspaceId: string) => Promise<readonly HeartbeatRun[]>; + /** + * Stop an in-flight run (abort its turn). Idempotent for an already-finished + * run. Throws when the run id is unknown (→ HTTP 404). + */ + readonly stopRun: ( + workspaceId: string, + runId: string, + ) => Promise<StopHeartbeatRunResponse>; + /** Boot: sweep stale runs + arm every enabled workspace's scheduler. */ + readonly startAll: () => Promise<void>; + /** Shutdown: stop every scheduler. */ + readonly stopAll: () => void; +} + +/** Typed service handle the heartbeat extension provides and transport consumes. */ +export const heartbeatServiceHandle: ServiceHandle<HeartbeatService> = + defineService<HeartbeatService>("heartbeat"); + +export interface HeartbeatServiceDeps { + /** Namespaced storage (from `host.storage("heartbeat")`). */ + readonly storage: import("@dispatch/kernel").StorageNamespace; + /** The session orchestrator (drives heartbeat turns). */ + readonly orchestrator: SessionOrchestrator; + readonly logger?: Logger; + /** Injectable timers (default: real). */ + readonly timers?: Timers; + /** Injectable id generator (default: crypto.randomUUID). */ + readonly generateId?: () => string; +} + +interface ActiveRun { + readonly conversationId: string; + readonly workspaceId: string; + stopped: boolean; +} + +export function createHeartbeatService(deps: HeartbeatServiceDeps): HeartbeatService { + const logger = deps.logger; + const timers = deps.timers ?? realTimers; + const generateId = deps.generateId ?? (() => crypto.randomUUID()); + const configStore: HeartbeatConfigStore = createHeartbeatConfigStore(deps.storage); + const runStore: HeartbeatRunStore = createHeartbeatRunStore(deps.storage); + const orchestrator = deps.orchestrator; + + // runId → active-run tracking (in-memory; the durable record lives in the + // run store). Used to (a) map a stop request to its conversation, and + // (b) keep a "stopped" flag so the turn's completion doesn't clobber a + // user-initiated stop. + const activeRuns = new Map<string, ActiveRun>(); + + const scheduler = new HeartbeatScheduler({ + timers, + fire: (workspaceId) => fire(workspaceId), + }); + + async function fire(workspaceId: string): Promise<void> { + const config = await configStore.get(workspaceId); + // Race: disabled/disarmed between the timer firing and now. + if (!config.enabled) return; + + const conversationId = generateId(); + const runId = generateId(); + const triggeredAt = new Date(timers.now()).toISOString(); + const run: HeartbeatRun = { + id: runId, + conversationId, + triggeredAt, + status: "running", + }; + await runStore.create(workspaceId, run); + activeRuns.set(runId, { conversationId, workspaceId, stopped: false }); + + logger?.info("heartbeat: run started", { workspaceId, runId, conversationId }); + + try { + await orchestrator.handleMessage({ + conversationId, + text: config.taskPrompt, + // Fire-and-forget: the heartbeat loop does not consume the + // streamed events (it only awaits turn completion to mark the + // run done). A no-op onEvent satisfies the required callback. + onEvent: () => {}, + // Always an explicit override (incl. the empty string = no system + // prompt) — bypasses the templated workspace prompt. + systemPrompt: config.systemPrompt, + ...(config.model !== "" ? { modelName: config.model } : {}), + ...(config.reasoningEffort !== null ? { reasoningEffort: config.reasoningEffort } : {}), + workspaceId, + }); + } finally { + const entry = activeRuns.get(runId); + activeRuns.delete(runId); + // If the user stopped it, stopRun already set "stopped"; don't + // clobber. Otherwise the turn sealed (normally or via abort) → done. + if (entry !== undefined && !entry.stopped) { + await runStore.setStatus(workspaceId, runId, "completed"); + logger?.info("heartbeat: run completed", { workspaceId, runId }); + } + } + } + + return { + async getConfig(workspaceId) { + return configStore.get(workspaceId); + }, + + async updateConfig(workspaceId, update) { + const next = await configStore.update(workspaceId, update); + // Arm/disarm from the new config. An in-progress run is left alone; + // the new interval takes effect on the next re-arm. + scheduler.arm(workspaceId, next); + logger?.info("heartbeat: config updated", { + workspaceId, + enabled: next.enabled, + intervalMinutes: next.intervalMinutes, + }); + return next; + }, + + async listRuns(workspaceId) { + return runStore.list(workspaceId); + }, + + async stopRun(workspaceId, runId) { + const run = await runStore.get(workspaceId, runId); + if (run === null) { + throw new Error("Heartbeat run not found"); + } + // Idempotent: an already-finished run is a no-op. + if (run.status !== "running") { + return { ok: true }; + } + const entry = activeRuns.get(runId); + const conversationId = entry?.conversationId ?? run.conversationId; + if (entry !== undefined) { + entry.stopped = true; + } + await runStore.setStatus(workspaceId, runId, "stopped"); + orchestrator.stopTurn(conversationId); + logger?.info("heartbeat: run stopped", { workspaceId, runId, conversationId }); + return { ok: true }; + }, + + async startAll() { + // Sweep stale "running" runs (orphaned by a prior crash/restart) → + // "stopped". Never leave the system showing an in-flight run that + // can never finish. + const workspaceIds = await configStore.listWorkspaceIds(); + for (const workspaceId of workspaceIds) { + const runs = await runStore.list(workspaceId); + for (const run of runs) { + if (run.status === "running") { + await runStore.setStatus(workspaceId, run.id, "stopped"); + } + } + const config = await configStore.get(workspaceId); + if (config.enabled) { + scheduler.arm(workspaceId, config); + logger?.info("heartbeat: scheduler armed on boot", { + workspaceId, + intervalMinutes: config.intervalMinutes, + }); + } + } + }, + + stopAll() { + scheduler.disarmAll(); + }, + }; +} diff --git a/packages/heartbeat/src/index.ts b/packages/heartbeat/src/index.ts new file mode 100644 index 0000000..3886be5 --- /dev/null +++ b/packages/heartbeat/src/index.ts @@ -0,0 +1,16 @@ +export { extension, manifest } from "./extension.js"; +export { + heartbeatServiceHandle, + createHeartbeatService, + type HeartbeatService, + type HeartbeatServiceDeps, +} from "./heartbeat.js"; +export { + createHeartbeatConfigStore, + applyConfigUpdate, + DEFAULT_HEARTBEAT_CONFIG, + MIN_INTERVAL_MINUTES, + type HeartbeatConfigStore, +} from "./config-store.js"; +export { createHeartbeatRunStore, type HeartbeatRunStore } from "./run-store.js"; +export { HeartbeatScheduler, realTimers, type Timers, type TimerHandle } from "./scheduler.js"; diff --git a/packages/heartbeat/src/run-store.test.ts b/packages/heartbeat/src/run-store.test.ts new file mode 100644 index 0000000..9448838 --- /dev/null +++ b/packages/heartbeat/src/run-store.test.ts @@ -0,0 +1,72 @@ +import type { StorageNamespace } from "@dispatch/kernel"; +import type { HeartbeatRun } from "@dispatch/transport-contract"; +import { describe, expect, it } from "vitest"; +import { createHeartbeatRunStore } from "./run-store.js"; + +function createMemoryStorage(): StorageNamespace { + const data = new Map<string, string>(); + return { + get: async (key) => data.get(key) ?? null, + set: async (key, value) => { + data.set(key, value); + }, + delete: async (key) => { + data.delete(key); + }, + has: async (key) => data.has(key), + keys: async (prefix) => { + const all = [...data.keys()]; + if (prefix === undefined) return all; + return all.filter((k) => k.startsWith(prefix)); + }, + }; +} + +function run(id: string, conversationId: string, triggeredAt: string): HeartbeatRun { + return { id, conversationId, triggeredAt, status: "running" }; +} + +describe("createHeartbeatRunStore", () => { + it("creates and reads back a run", async () => { + const store = createHeartbeatRunStore(createMemoryStorage()); + const created = await store.create("ws-1", run("r1", "c1", "2026-01-01T00:00:00.000Z")); + expect(created.status).toBe("running"); + const read = await store.get("ws-1", "r1"); + expect(read).toEqual(created); + }); + + it("returns null for an unknown run", async () => { + const store = createHeartbeatRunStore(createMemoryStorage()); + expect(await store.get("ws-1", "nope")).toBeNull(); + }); + + it("updates the status of a run", async () => { + const store = createHeartbeatRunStore(createMemoryStorage()); + await store.create("ws-1", run("r1", "c1", "2026-01-01T00:00:00.000Z")); + const updated = await store.setStatus("ws-1", "r1", "completed"); + expect(updated?.status).toBe("completed"); + expect((await store.get("ws-1", "r1"))?.status).toBe("completed"); + }); + + it("setStatus is a no-op (returns null) for an unknown run", async () => { + const store = createHeartbeatRunStore(createMemoryStorage()); + expect(await store.setStatus("ws-1", "ghost", "stopped")).toBeNull(); + }); + + it("lists runs most-recent first by triggeredAt", async () => { + const store = createHeartbeatRunStore(createMemoryStorage()); + await store.create("ws-1", run("r1", "c1", "2026-01-01T00:00:00.000Z")); + await store.create("ws-1", run("r2", "c2", "2026-02-01T00:00:00.000Z")); + await store.create("ws-1", run("r3", "c3", "2026-01-15T00:00:00.000Z")); + const runs = await store.list("ws-1"); + expect(runs.map((r) => r.id)).toEqual(["r2", "r3", "r1"]); + }); + + it("scopes runs per workspace", async () => { + const store = createHeartbeatRunStore(createMemoryStorage()); + await store.create("ws-1", run("r1", "c1", "2026-01-01T00:00:00.000Z")); + await store.create("ws-2", run("r2", "c2", "2026-01-01T00:00:00.000Z")); + expect((await store.list("ws-1")).map((r) => r.id)).toEqual(["r1"]); + expect((await store.list("ws-2")).map((r) => r.id)).toEqual(["r2"]); + }); +}); diff --git a/packages/heartbeat/src/run-store.ts b/packages/heartbeat/src/run-store.ts new file mode 100644 index 0000000..7522aab --- /dev/null +++ b/packages/heartbeat/src/run-store.ts @@ -0,0 +1,103 @@ +import type { StorageNamespace } from "@dispatch/kernel"; +import type { HeartbeatRun, HeartbeatRunStatus } from "@dispatch/transport-contract"; + +/** Storage key for a single heartbeat run record. */ +function runKey(workspaceId: string, runId: string): string { + return `run:${workspaceId}:${runId}`; +} + +/** Prefix matching every run record for a workspace (for enumeration). */ +function runPrefix(workspaceId: string): string { + return `run:${workspaceId}:`; +} + +/** Extract the runId from a full `run:<workspaceId>:<runId>` key. */ +function parseRunId(key: string, workspaceId: string): string { + const prefix = runPrefix(workspaceId); + return key.startsWith(prefix) ? key.slice(prefix.length) : key; +} + +export interface HeartbeatRunStore { + /** Create a new run record (status `"running"`) and persist it. */ + readonly create: ( + workspaceId: string, + run: HeartbeatRun, + ) => Promise<HeartbeatRun>; + /** Update the status of an existing run. No-op if the run is unknown. */ + readonly setStatus: ( + workspaceId: string, + runId: string, + status: HeartbeatRunStatus, + ) => Promise<HeartbeatRun | null>; + /** A single run by id, or `null` when unknown. */ + readonly get: (workspaceId: string, runId: string) => Promise<HeartbeatRun | null>; + /** All runs for a workspace, most-recent first (by `triggeredAt`). */ + readonly list: (workspaceId: string) => Promise<readonly HeartbeatRun[]>; +} + +export function createHeartbeatRunStore( + storage: StorageNamespace, +): HeartbeatRunStore { + async function readRun( + workspaceId: string, + runId: string, + ): Promise<HeartbeatRun | null> { + const raw = await storage.get(runKey(workspaceId, runId)); + if (raw === null) return null; + try { + const parsed = JSON.parse(raw) as Partial<HeartbeatRun>; + if ( + typeof parsed.id !== "string" || + typeof parsed.conversationId !== "string" || + typeof parsed.triggeredAt !== "string" || + typeof parsed.status !== "string" + ) { + return null; + } + return { + id: parsed.id, + conversationId: parsed.conversationId, + triggeredAt: parsed.triggeredAt, + status: parsed.status as HeartbeatRunStatus, + }; + } catch { + return null; + } + } + + return { + async create(workspaceId: string, run: HeartbeatRun): Promise<HeartbeatRun> { + await storage.set(runKey(workspaceId, run.id), JSON.stringify(run)); + return run; + }, + + async setStatus( + workspaceId: string, + runId: string, + status: HeartbeatRunStatus, + ): Promise<HeartbeatRun | null> { + const run = await readRun(workspaceId, runId); + if (run === null) return null; + const updated: HeartbeatRun = { ...run, status }; + await storage.set(runKey(workspaceId, runId), JSON.stringify(updated)); + return updated; + }, + + async get(workspaceId: string, runId: string): Promise<HeartbeatRun | null> { + return readRun(workspaceId, runId); + }, + + async list(workspaceId: string): Promise<readonly HeartbeatRun[]> { + const keys = await storage.keys(runPrefix(workspaceId)); + const runs: HeartbeatRun[] = []; + for (const key of keys) { + const runId = parseRunId(key, workspaceId); + const run = await readRun(workspaceId, runId); + if (run !== null) runs.push(run); + } + // Most-recent first by triggeredAt (ISO-8601 sorts lexicographically). + runs.sort((a, b) => b.triggeredAt.localeCompare(a.triggeredAt)); + return runs; + }, + }; +} diff --git a/packages/heartbeat/src/scheduler.test.ts b/packages/heartbeat/src/scheduler.test.ts new file mode 100644 index 0000000..355410c --- /dev/null +++ b/packages/heartbeat/src/scheduler.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it } from "vitest"; +import { HeartbeatScheduler, type Timers } from "./scheduler.js"; + +interface FakeTimer { + readonly fn: () => void; + readonly firesAt: number; +} + +/** + * A controllable fake clock: `advance(ms)` moves virtual time forward and runs + * any timers that became due. The injected `fire` returns a deferred the test + * resolves manually, so we can assert the "running" state and the re-arm that + * happens only AFTER the run completes. + */ +function createFakeTimers() { + let now = 0; + let nextId = 1; + const timers = new Map<number, FakeTimer>(); + const timersApi: Timers = { + now: () => now, + setTimeout: (fn, ms) => { + const id = nextId++; + timers.set(id, { fn, firesAt: now + ms }); + return id as unknown as ReturnType<typeof setTimeout>; + }, + clearTimeout: (handle) => { + if (handle !== undefined) timers.delete(handle as unknown as number); + }, + }; + return { + timers: timersApi, + advance(ms: number): number { + now += ms; + let fired = 0; + const due = [...timers.entries()] + .filter(([, t]) => t.firesAt <= now) + .sort((a, b) => a[0] - b[0]); + for (const [id, t] of due) { + timers.delete(id); + t.fn(); + fired++; + } + return fired; + }, + pendingCount(): number { + return timers.size; + }, + }; +} + +/** A deferred promise the test resolves to signal run completion. */ +function createDeferred(): { promise: Promise<void>; resolve: () => void } { + let resolve!: () => void; + const promise = new Promise<void>((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +// Flush pending microtasks (the scheduler's .finally re-arm runs as a microtask +// after the fire promise resolves). +const flush = async (): Promise<void> => { + await new Promise((r) => queueMicrotask(r)); +}; + +describe("HeartbeatScheduler", () => { + it("arms a timer that fires after intervalMinutes", () => { + const fake = createFakeTimers(); + const fires: string[] = []; + const deferreds: Array<{ resolve: () => void }> = []; + const scheduler = new HeartbeatScheduler({ + timers: fake.timers, + fire: (ws) => { + fires.push(ws); + const d = createDeferred(); + deferreds.push(d); + return d.promise; + }, + }); + + scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 }); + expect(scheduler.isArmed("ws-1")).toBe(true); + expect(fake.pendingCount()).toBe(1); + + // Just shy of the interval → no fire. + expect(fake.advance(59_999)).toBe(0); + expect(fires).toEqual([]); + + // Exactly the interval (1 minute = 60_000ms) → fires. + expect(fake.advance(1)).toBe(1); + expect(fires).toEqual(["ws-1"]); + // While the run is in progress: running, no pending timer. + expect(scheduler.isRunning("ws-1")).toBe(true); + expect(fake.pendingCount()).toBe(0); + }); + + it("re-arms only after the run completes (reset timer after each run)", async () => { + const fake = createFakeTimers(); + const fires: string[] = []; + const deferreds: Array<{ resolve: () => void }> = []; + const scheduler = new HeartbeatScheduler({ + timers: fake.timers, + fire: (ws) => { + fires.push(ws); + const d = createDeferred(); + deferreds.push(d); + return d.promise; + }, + }); + + scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 }); + fake.advance(60_000); // first fire + expect(fires).toEqual(["ws-1"]); + // Run in progress — no new timer yet. + expect(fake.pendingCount()).toBe(0); + + // Completing the run schedules the next fire. + deferreds[0]?.resolve(); + await flush(); + expect(scheduler.isRunning("ws-1")).toBe(false); + expect(fake.pendingCount()).toBe(1); + + // Next fire after another interval. + fake.advance(60_000); + expect(fires).toEqual(["ws-1", "ws-1"]); + }); + + it("uses a longer interval for a higher intervalMinutes", () => { + const fake = createFakeTimers(); + let fireCount = 0; + const scheduler = new HeartbeatScheduler({ + timers: fake.timers, + fire: () => { + fireCount++; + return createDeferred().promise; + }, + }); + scheduler.arm("ws-1", { enabled: true, intervalMinutes: 30 }); + // 1 minute wouldn't fire a 30-minute schedule. + expect(fake.advance(60_000)).toBe(0); + // 28 more minutes (29 total) still wouldn't fire. + expect(fake.advance(28 * 60_000)).toBe(0); + // The remaining minute completes the 30 minutes → fires. + expect(fake.advance(60_000)).toBe(1); + expect(fireCount).toBe(1); + }); + + it("disarms (clears the pending timer) when disabled", () => { + const fake = createFakeTimers(); + const scheduler = new HeartbeatScheduler({ + timers: fake.timers, + fire: () => createDeferred().promise, + }); + scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 }); + expect(fake.pendingCount()).toBe(1); + scheduler.arm("ws-1", { enabled: false, intervalMinutes: 1 }); + expect(scheduler.isArmed("ws-1")).toBe(false); + expect(fake.pendingCount()).toBe(0); + }); + + it("disarmAll stops every schedule", () => { + const fake = createFakeTimers(); + const scheduler = new HeartbeatScheduler({ + timers: fake.timers, + fire: () => createDeferred().promise, + }); + scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 }); + scheduler.arm("ws-2", { enabled: true, intervalMinutes: 1 }); + expect(fake.pendingCount()).toBe(2); + scheduler.disarmAll(); + expect(fake.pendingCount()).toBe(0); + expect(scheduler.isArmed("ws-1")).toBe(false); + expect(scheduler.isArmed("ws-2")).toBe(false); + }); + + it("does not re-arm after a disarm during a run", 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 + scheduler.disarm("ws-1"); // disarm mid-run + deferreds[0]?.resolve(); + await flush(); + // Disarmed → no re-arm scheduled. + expect(fake.pendingCount()).toBe(0); + expect(scheduler.isArmed("ws-1")).toBe(false); + }); + + it("a config update during a run applies the new interval on the next 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); // first fire (interval=1m) + // Update interval to 5m while the run is in progress. + scheduler.arm("ws-1", { enabled: true, intervalMinutes: 5 }); + expect(scheduler.isRunning("ws-1")).toBe(true); + expect(fake.pendingCount()).toBe(0); // no new timer while running + deferreds[0]?.resolve(); + await flush(); + // Re-armed with the NEW 5-minute interval. + expect(fake.advance(60_000)).toBe(0); // 1 minute isn't enough now + expect(fake.advance(4 * 60_000)).toBe(1); // completes 5 minutes → fires + }); + + it("a thrown fire is swallowed and the loop continues", async () => { + const fake = createFakeTimers(); + let fireCount = 0; + const scheduler = new HeartbeatScheduler({ + timers: fake.timers, + fire: () => { + fireCount++; + return Promise.reject(new Error("boom")); + }, + }); + scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 }); + fake.advance(60_000); + await flush(); + expect(fireCount).toBe(1); + // Loop continues: next fire scheduled. + expect(fake.pendingCount()).toBe(1); + fake.advance(60_000); + expect(fireCount).toBe(2); + }); +}); diff --git a/packages/heartbeat/src/scheduler.ts b/packages/heartbeat/src/scheduler.ts new file mode 100644 index 0000000..53ffb96 --- /dev/null +++ b/packages/heartbeat/src/scheduler.ts @@ -0,0 +1,159 @@ +/** + * Heartbeat scheduler — the imperative timer loop. + * + * The scheduler owns NO knowledge of conversations, prompts, or the + * orchestrator. It only manages per-workspace timers: when armed (a workspace's + * heartbeat is `enabled`), it schedules a fire after `intervalMinutes`; when the + * fire's work completes, it re-arms (resets the timer). This is the "reset timer + * after each run" semantics — the interval is measured from run-completion to + * the next fire, not a fixed wall-clock schedule. + * + * The actual run work (create a conversation, send the task prompt, track the + * run) is the `fire(workspaceId)` callback, injected by the heartbeat service. + * This keeps the scheduler pure-ish (only I/O is the injected timers) and + * testable with fake timers + a fake fire. + */ + +/** A handle returned by `setTimeout`, opaque to the scheduler. */ +export type TimerHandle = ReturnType<typeof setTimeout>; + +/** Injectable timers — the only I/O effect the scheduler touches. */ +export interface Timers { + readonly now: () => number; + readonly setTimeout: (fn: () => void, ms: number) => TimerHandle; + readonly clearTimeout: (handle: TimerHandle | undefined) => void; +} + +/** The real timers (used at runtime; overridable in tests). */ +export const realTimers: Timers = { + now: () => Date.now(), + setTimeout: (fn, ms) => setTimeout(fn, ms), + clearTimeout: (handle) => { + if (handle !== undefined) clearTimeout(handle); + }, +}; + +export interface SchedulerDeps { + readonly timers: Timers; + /** Do the run work for a workspace. Resolves when the turn seals. */ + readonly fire: (workspaceId: string) => Promise<void>; +} + +interface WorkspaceSchedule { + /** Current interval (minutes), updated by `arm` on config changes. */ + intervalMinutes: number; + /** Pending wait timer (null while a fire is in progress). */ + timer: TimerHandle | undefined; + /** A fire is in progress for this workspace. */ + running: boolean; + /** Scheduling is active (the workspace's heartbeat is enabled). */ + armed: boolean; +} + +const MINUTE_MS = 60_000; + +/** + * Manages per-workspace heartbeat timers. A single instance is owned by the + * heartbeat service for its lifetime. + */ +export class HeartbeatScheduler { + private readonly schedules = new Map<string, WorkspaceSchedule>(); + private readonly timers: Timers; + private readonly fire: (workspaceId: string) => Promise<void>; + + constructor(deps: SchedulerDeps) { + this.timers = deps.timers; + this.fire = deps.fire; + } + + /** + * Arm (or re-arm) a workspace's schedule from its config. When `enabled` + * is false the schedule is disarmed. When a config update arrives while a + * run is in progress, the new `intervalMinutes` takes effect on the next + * re-arm (the in-progress run is never cancelled here). + */ + arm(workspaceId: string, config: { + readonly enabled: boolean; + readonly intervalMinutes: number; + }): void { + if (!config.enabled) { + this.disarm(workspaceId); + return; + } + let schedule = this.schedules.get(workspaceId); + if (schedule === undefined) { + schedule = { intervalMinutes: config.intervalMinutes, timer: undefined, running: false, armed: true }; + this.schedules.set(workspaceId, schedule); + } else { + schedule.intervalMinutes = config.intervalMinutes; + schedule.armed = true; + } + // If a fire is in progress, let it finish — it will re-arm with the + // (possibly new) interval. Otherwise schedule the next fire now. + if (!schedule.running) { + this.clearTimer(schedule); + this.scheduleNext(workspaceId, schedule); + } + } + + /** Stop scheduling for a workspace (clears a pending timer; an in-progress run finishes on its own). */ + disarm(workspaceId: string): void { + const schedule = this.schedules.get(workspaceId); + if (schedule === undefined) return; + schedule.armed = false; + this.clearTimer(schedule); + this.schedules.delete(workspaceId); + } + + /** Stop all schedules (deactivate). */ + disarmAll(): void { + for (const workspaceId of [...this.schedules.keys()]) { + this.disarm(workspaceId); + } + } + + /** Whether a schedule is currently armed (enabled) for a workspace. */ + isArmed(workspaceId: string): boolean { + return this.schedules.get(workspaceId)?.armed ?? false; + } + + /** Whether a fire is currently in progress for a workspace. */ + isRunning(workspaceId: string): boolean { + return this.schedules.get(workspaceId)?.running ?? false; + } + + private scheduleNext(workspaceId: string, schedule: WorkspaceSchedule): void { + const ms = Math.max(MINUTE_MS, schedule.intervalMinutes * MINUTE_MS); + schedule.timer = this.timers.setTimeout(() => { + this.onTick(workspaceId); + }, ms); + } + + private onTick(workspaceId: string): void { + const schedule = this.schedules.get(workspaceId); + // Race: the schedule was disarmed after the timer was queued. + if (schedule === undefined || !schedule.armed) return; + schedule.timer = undefined; + schedule.running = true; + this.fire(workspaceId) + .catch(() => { + // The service records the run outcome; a thrown fire is logged + // by the service. Swallow so the loop continues. + }) + .finally(() => { + const current = this.schedules.get(workspaceId); + if (current === undefined) return; + current.running = false; + if (current.armed) { + this.scheduleNext(workspaceId, current); + } + }); + } + + private clearTimer(schedule: WorkspaceSchedule): void { + if (schedule.timer !== undefined) { + this.timers.clearTimeout(schedule.timer); + schedule.timer = undefined; + } + } +} diff --git a/packages/heartbeat/tsconfig.json b/packages/heartbeat/tsconfig.json new file mode 100644 index 0000000..088ee0f --- /dev/null +++ b/packages/heartbeat/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [ + { "path": "../kernel" }, + { "path": "../transport-contract" }, + { "path": "../session-orchestrator" } + ] +} diff --git a/packages/host-bin/package.json b/packages/host-bin/package.json index 64f436e..f27bf06 100644 --- a/packages/host-bin/package.json +++ b/packages/host-bin/package.json @@ -11,6 +11,7 @@ "@dispatch/cache-warming": "workspace:*", "@dispatch/credential-store": "workspace:*", "@dispatch/exec-backend": "workspace:*", + "@dispatch/heartbeat": "workspace:*", "@dispatch/provider-openai-compat": "workspace:*", "@dispatch/provider-umans": "workspace:*", "@dispatch/message-queue": "workspace:*", diff --git a/packages/host-bin/src/main.ts b/packages/host-bin/src/main.ts index 571628f..26015ed 100644 --- a/packages/host-bin/src/main.ts +++ b/packages/host-bin/src/main.ts @@ -2,6 +2,7 @@ import { existsSync, mkdirSync } from "node:fs"; import { dirname } from "node:path"; import { extension as authApikeyExt } from "@dispatch/auth-apikey"; import { extension as cacheWarmingExt } from "@dispatch/cache-warming"; +import { extension as heartbeatExt } from "@dispatch/heartbeat"; import { extension as conversationStoreExt } from "@dispatch/conversation-store"; import { createCredentialStoreExtension } from "@dispatch/credential-store"; import { createExecBackendExtension } from "@dispatch/exec-backend"; @@ -105,6 +106,10 @@ const CORE_EXTENSIONS: readonly Extension[] = [ // tool-serving extensions) to keep the DAG honest — and before // transport-http, whose routes consume the ComputerService it provides. sshExt, + // heartbeat PROVIDES the HeartbeatService (per-workspace AI loop) the + // HTTP routes delegate to. Placed before transport-http (which depends on + // it) — mirrors how ssh precedes transport-http for the same reason. + heartbeatExt, createTransportHttpExtension(), // Surface extensions — dependency order: surface-registry first, then consumers. createSurfaceRegistryExtension(), diff --git a/packages/session-orchestrator/src/orchestrator.ts b/packages/session-orchestrator/src/orchestrator.ts index 4aa77f7..9778d1f 100644 --- a/packages/session-orchestrator/src/orchestrator.ts +++ b/packages/session-orchestrator/src/orchestrator.ts @@ -55,6 +55,16 @@ export interface StartTurnInput { * resolve the effective cwd from the workspace's `defaultCwd`. */ readonly workspaceId?: string; + /** + * An EXPLICIT system-prompt override. When provided (a string, including the + * empty string), it is sent to the provider AS-IS and the system-prompt + * SERVICE is bypassed entirely (no template construction, no + * persist/reuse). This is how a caller that owns its own prompt (e.g. the + * heartbeat extension) drives a turn without the templated workspace prompt. + * Omitted/`undefined` = the existing behavior (construct/reuse via the + * system-prompt service when loaded). + */ + readonly systemPrompt?: string; } export type StartTurnResult = @@ -277,6 +287,8 @@ export interface SessionOrchestrator { computerId?: string; reasoningEffort?: ReasoningEffort; workspaceId?: string; + /** Explicit system-prompt override — see {@link StartTurnInput.systemPrompt}. */ + systemPrompt?: string; }): Promise<void>; } @@ -424,6 +436,7 @@ export function createSessionOrchestrator( computerId: string | undefined, reasoningEffortOverride: ReasoningEffort | undefined, workspaceId: string, + systemPromptOverride: string | undefined, ): void { const turnId = generateTurnId(); const controller = new AbortController(); @@ -609,37 +622,47 @@ export function createSessionOrchestrator( // where a cwd change left the prompt stale. When the system-prompt // service isn't loaded, no system prompt is sent (current behavior // preserved). - const systemPromptService = deps.resolveSystemPrompt?.(); + // + // EXPLICIT OVERRIDE: when `systemPromptOverride` is provided (a + // string, incl. empty), it is sent to the provider AS-IS and the + // system-prompt service is bypassed entirely (no construct/reuse). + // This lets a caller that owns its own prompt (e.g. the heartbeat + // extension) drive a turn without the templated workspace prompt. let systemPrompt: string | undefined; - if (systemPromptService !== undefined) { - if (isNewConversation) { - systemPrompt = await systemPromptService.construct( - conversationId, - effectiveCwd ?? process.cwd(), - { - ...(effectiveModelName !== undefined ? { model: effectiveModelName } : {}), - ...(workspaceId !== undefined ? { workspaceId } : {}), - ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}), - }, - ); - } else { - const meta = await systemPromptService.getWithMeta(conversationId); - const currentCwd = effectiveCwd ?? process.cwd(); - const currentComputerId = effectiveComputerId ?? null; - // Invalidate when cwd OR computerId changed (switching computers - // must rebuild the prompt against the remote OS/hostname). - if ( - meta.prompt !== null && - meta.cwd === currentCwd && - meta.computerId === currentComputerId - ) { - systemPrompt = meta.prompt; + if (systemPromptOverride !== undefined) { + systemPrompt = systemPromptOverride; + } else { + const systemPromptService = deps.resolveSystemPrompt?.(); + if (systemPromptService !== undefined) { + if (isNewConversation) { + systemPrompt = await systemPromptService.construct( + conversationId, + effectiveCwd ?? process.cwd(), + { + ...(effectiveModelName !== undefined ? { model: effectiveModelName } : {}), + ...(workspaceId !== undefined ? { workspaceId } : {}), + ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}), + }, + ); } else { - systemPrompt = await systemPromptService.construct(conversationId, currentCwd, { - ...(effectiveModelName !== undefined ? { model: effectiveModelName } : {}), - ...(workspaceId !== undefined ? { workspaceId } : {}), - ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}), - }); + const meta = await systemPromptService.getWithMeta(conversationId); + const currentCwd = effectiveCwd ?? process.cwd(); + const currentComputerId = effectiveComputerId ?? null; + // Invalidate when cwd OR computerId changed (switching computers + // must rebuild the prompt against the remote OS/hostname). + if ( + meta.prompt !== null && + meta.cwd === currentCwd && + meta.computerId === currentComputerId + ) { + systemPrompt = meta.prompt; + } else { + systemPrompt = await systemPromptService.construct(conversationId, currentCwd, { + ...(effectiveModelName !== undefined ? { model: effectiveModelName } : {}), + ...(workspaceId !== undefined ? { workspaceId } : {}), + ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}), + }); + } } } } @@ -773,7 +796,7 @@ export function createSessionOrchestrator( } const orchestrator: SessionOrchestrator = { - startTurn({ conversationId, text, modelName, cwd, computerId, reasoningEffort, workspaceId }) { + startTurn({ conversationId, text, modelName, cwd, computerId, reasoningEffort, workspaceId, systemPrompt }) { if (activeTurns.has(conversationId)) { return { started: false, reason: "already-active" }; } @@ -785,6 +808,7 @@ export function createSessionOrchestrator( computerId, reasoningEffort, workspaceId ?? "default", + systemPrompt, ); const turn = activeTurns.get(conversationId); const turnId = turn !== undefined ? turn.turnId : ""; @@ -880,6 +904,7 @@ export function createSessionOrchestrator( computerId, reasoningEffort, workspaceId, + systemPrompt, }) { const turnInput: StartTurnInput = { conversationId, @@ -889,6 +914,7 @@ export function createSessionOrchestrator( ...(computerId !== undefined ? { computerId } : {}), ...(reasoningEffort !== undefined ? { reasoningEffort } : {}), ...(workspaceId !== undefined ? { workspaceId } : {}), + ...(systemPrompt !== undefined ? { systemPrompt } : {}), }; const result = orchestrator.startTurn(turnInput); if (!result.started) { diff --git a/packages/transport-contract/package.json b/packages/transport-contract/package.json index 842a28b..7f58c63 100644 --- a/packages/transport-contract/package.json +++ b/packages/transport-contract/package.json @@ -1,6 +1,6 @@ { "name": "@dispatch/transport-contract", - "version": "0.22.0", + "version": "0.23.0", "type": "module", "private": true, "main": "dist/index.js", diff --git a/packages/transport-contract/src/index.ts b/packages/transport-contract/src/index.ts index b32c8a0..2c3ec64 100644 --- a/packages/transport-contract/src/index.ts +++ b/packages/transport-contract/src/index.ts @@ -908,3 +908,80 @@ export interface TestComputerResponse { readonly ok: boolean; readonly error?: string; } + +// ─── Heartbeat ─────────────────────────────────────────────────────────────── + +/** + * The per-workspace Heartbeat config — a single record stored per workspace. + * + * A heartbeat is an AI that runs on a configurable loop per workspace: when + * `enabled`, the backend summons a NEW conversation every `intervalMinutes` + * minutes, gives the heartbeat AI `systemPrompt` + sends `taskPrompt` as the + * opening user message, and inherits the workspace's computer (SSH) + cwd + + * standard tool kit. The main purpose is monitoring (e.g. checking if chats + * are stuck). + * + * `model` is a model name in `<credentialName>/<model>` form (one of the strings + * from `GET /models`), or the empty string to use the server default. + * `reasoningEffort` is `null` to inherit the workspace default, or an explicit + * level. The scheduler resets its timer after each run completes (not a fixed + * wall-clock schedule); on backend restart it resumes scheduling for enabled + * heartbeats. + */ +export interface HeartbeatConfig { + /** Whether the heartbeat loop is active for this workspace. */ + readonly enabled: boolean; + /** Custom system prompt for the heartbeat AI (empty = no system prompt). */ + readonly systemPrompt: string; + /** Task prompt sent as the first user message when the heartbeat fires. */ + readonly taskPrompt: string; + /** How often to fire, in minutes (default 30). */ + readonly intervalMinutes: number; + /** Model name (`<credentialName>/<model>`), or empty string = server default. */ + readonly model: string; + /** Reasoning-effort level, or `null` = inherit the workspace default. */ + readonly reasoningEffort: ReasoningEffort | null; +} + +/** + * Body of `PUT /workspaces/:id/heartbeat` — a partial update. All fields are + * optional; only the provided fields are applied. `reasoningEffort` accepts + * `null` to clear to the workspace default. `intervalMinutes` must be a positive + * integer (clamped server-side to a minimum of 1). An unrecognized + * `reasoningEffort` → HTTP 400. + */ +export interface UpdateHeartbeatRequest { + readonly enabled?: boolean; + readonly systemPrompt?: string; + readonly taskPrompt?: string; + readonly intervalMinutes?: number; + readonly model?: string; + readonly reasoningEffort?: ReasoningEffort | null; +} + +/** The status of a single heartbeat run. */ +export type HeartbeatRunStatus = "running" | "completed" | "stopped"; + +/** + * One heartbeat run — created each time the heartbeat fires. A run tracks the + * conversation it spawned and its lifecycle. `triggeredAt` is an ISO-8601 + * timestamp. `status` is `"running"` while the turn is in flight, + * `"completed"` when the turn sealed normally, or `"stopped"` when the user + * stopped it via the stop endpoint (the turn is aborted and seals `"stopped"`). + */ +export interface HeartbeatRun { + readonly id: string; + readonly conversationId: string; + readonly triggeredAt: string; + readonly status: HeartbeatRunStatus; +} + +/** Response of `GET /workspaces/:id/heartbeat/runs` — runs, most-recent first. */ +export interface HeartbeatRunsResponse { + readonly runs: readonly HeartbeatRun[]; +} + +/** Response of `POST /workspaces/:id/heartbeat/runs/:runId/stop`. */ +export interface StopHeartbeatRunResponse { + readonly ok: true; +} diff --git a/packages/transport-http/package.json b/packages/transport-http/package.json index e7eb85c..7990c2b 100644 --- a/packages/transport-http/package.json +++ b/packages/transport-http/package.json @@ -8,6 +8,7 @@ "dependencies": { "@dispatch/conversation-store": "workspace:*", "@dispatch/credential-store": "workspace:*", + "@dispatch/heartbeat": "workspace:*", "@dispatch/kernel": "workspace:*", "@dispatch/lsp": "workspace:*", "@dispatch/mcp": "workspace:*", diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts index 2e81c46..36b27a4 100644 --- a/packages/transport-http/src/app.ts +++ b/packages/transport-http/src/app.ts @@ -1,4 +1,5 @@ import type { AgentEvent, HostAPI, Logger } from "@dispatch/kernel"; +import { DEFAULT_HEARTBEAT_CONFIG } from "@dispatch/heartbeat"; import { DEFAULT_TEMPLATE, getVariableCatalog } from "@dispatch/system-prompt"; import type { CloseConversationResponse, @@ -14,6 +15,8 @@ import type { ConversationStatusResponse, CwdResponse, DeleteWorkspaceResponse, + HeartbeatConfig, + HeartbeatRunsResponse, LastMessageResponse, LspServerInfo, LspStatusResponse, @@ -28,11 +31,13 @@ import type { SetConversationComputerRequest, SetSystemPromptTemplateRequest, SetWorkspaceDefaultComputerRequest, + StopHeartbeatRunResponse, SystemPromptTemplateResponse, SystemPromptVariablesResponse, TestComputerResponse, ThroughputResponse, TitleResponse, + UpdateHeartbeatRequest, WarmResponse, WorkspaceListResponse, WorkspaceResponse, @@ -47,6 +52,7 @@ import { isParseError, isReasoningEffortParseError, isSinceSeqError, + isValidReasoningEffort, isWindowParamError, parseChatBody, parseModelBody, @@ -64,6 +70,7 @@ import { type ConversationStore, type CredentialStore, conversationOpened, + type HeartbeatService, isValidWorkspaceSlug, type LspServerStatus, type LspService, @@ -87,6 +94,13 @@ export interface CreateServerOptions { /** Optional — system prompt builder service (GET/PUT template). */ readonly systemPromptService?: SystemPromptService; /** + * Optional — per-workspace heartbeat loop service (provided by the + * `heartbeat` extension). When absent (heartbeat not loaded), the + * `/workspaces/:id/heartbeat*` routes degrade: GET returns defaults, + * PUT/POST return 503. + */ + readonly heartbeatService?: HeartbeatService; + /** * Optional — computer discovery + live connection service (provided by the * `ssh` extension). When absent (ssh not loaded), the `/computers*` routes * degrade: list returns `[]`, status returns "disconnected", test returns @@ -1340,6 +1354,164 @@ export function createApp(opts: CreateServerOptions): Hono { } }); + // ─── Heartbeat (per-workspace AI loop) ───────────────────────────────────── + // The config + run history for a workspace's heartbeat loop. Delegated to + // the HeartbeatService (provided by the `heartbeat` extension). When + // heartbeat is NOT loaded the routes degrade: GET config → the defaults, + // GET runs → empty, PUT/POST → 503 (mirrors how /system-prompt returns the + // default template when its service is absent but 503s writes). + + app.get("/workspaces/:id/heartbeat", async (c) => { + const workspaceId = c.req.param("id"); + if (opts.heartbeatService === undefined) { + // Graceful: no heartbeat configured → return the defaults so the FE + // always gets a usable config shape (enabled: false, etc.). + const body: HeartbeatConfig = DEFAULT_HEARTBEAT_CONFIG; + return c.json(body, 200); + } + try { + const config = await opts.heartbeatService.getConfig(workspaceId); + log.info("heartbeat: config read", { workspaceId, enabled: config.enabled }); + const body: HeartbeatConfig = config; + return c.json(body, 200); + } catch (err) { + log.error("heartbeat: config read failure", { err, workspaceId }); + return c.json({ error: "Failed to read heartbeat config" }, 500); + } + }); + + app.put("/workspaces/:id/heartbeat", async (c) => { + const workspaceId = c.req.param("id"); + if (opts.heartbeatService === undefined) { + return c.json({ error: "Heartbeat service not available" }, 503); + } + + let body: unknown; + try { + body = await c.req.json(); + } catch { + log.warn("heartbeat: invalid JSON body", { workspaceId }); + return c.json({ error: "Invalid JSON body" }, 400); + } + + if (body === null || typeof body !== "object") { + return c.json({ error: "Request body must be a JSON object" }, 400); + } + const obj = body as Record<string, unknown>; + + // Build a partial update, validating each present field. All fields are + // optional (a partial update); only provided fields are forwarded. + const update: Record<string, unknown> = {}; + + if (obj.enabled !== undefined) { + if (typeof obj.enabled !== "boolean") { + return c.json({ error: "Field 'enabled' must be a boolean" }, 400); + } + update.enabled = obj.enabled; + } + + if (obj.systemPrompt !== undefined) { + if (typeof obj.systemPrompt !== "string") { + return c.json({ error: "Field 'systemPrompt' must be a string" }, 400); + } + update.systemPrompt = obj.systemPrompt; + } + + if (obj.taskPrompt !== undefined) { + if (typeof obj.taskPrompt !== "string") { + return c.json({ error: "Field 'taskPrompt' must be a string" }, 400); + } + update.taskPrompt = obj.taskPrompt; + } + + if (obj.intervalMinutes !== undefined) { + if (typeof obj.intervalMinutes !== "number" || !Number.isFinite(obj.intervalMinutes)) { + return c.json({ error: "Field 'intervalMinutes' must be a number" }, 400); + } + update.intervalMinutes = obj.intervalMinutes; + } + + if (obj.model !== undefined) { + if (typeof obj.model !== "string") { + return c.json({ error: "Field 'model' must be a string" }, 400); + } + update.model = obj.model; + } + + // `reasoningEffort` accepts a valid level string OR null (clear the + // override → inherit the workspace default). Absent (undefined) leaves + // it unchanged. An unrecognized string → 400. + if (obj.reasoningEffort !== undefined) { + if (obj.reasoningEffort !== null && !isValidReasoningEffort(obj.reasoningEffort)) { + return c.json( + { + error: + "Field 'reasoningEffort' must be one of: low, medium, high, xhigh, max, or null", + }, + 400, + ); + } + update.reasoningEffort = obj.reasoningEffort; + } + + try { + const config = await opts.heartbeatService.updateConfig( + workspaceId, + update as UpdateHeartbeatRequest, + ); + log.info("heartbeat: config updated", { + workspaceId, + enabled: config.enabled, + intervalMinutes: config.intervalMinutes, + }); + const response: HeartbeatConfig = config; + return c.json(response, 200); + } catch (err) { + log.error("heartbeat: config update failure", { err, workspaceId }); + return c.json({ error: "Failed to update heartbeat config" }, 500); + } + }); + + app.get("/workspaces/:id/heartbeat/runs", async (c) => { + const workspaceId = c.req.param("id"); + if (opts.heartbeatService === undefined) { + // Graceful: no heartbeat → no runs. + const body: HeartbeatRunsResponse = { runs: [] }; + return c.json(body, 200); + } + try { + const runs = await opts.heartbeatService.listRuns(workspaceId); + log.info("heartbeat: runs listed", { workspaceId, count: runs.length }); + const body: HeartbeatRunsResponse = { runs }; + return c.json(body, 200); + } catch (err) { + log.error("heartbeat: runs list failure", { err, workspaceId }); + return c.json({ error: "Failed to list heartbeat runs" }, 500); + } + }); + + app.post("/workspaces/:id/heartbeat/runs/:runId/stop", async (c) => { + const workspaceId = c.req.param("id"); + const runId = c.req.param("runId"); + if (opts.heartbeatService === undefined) { + return c.json({ error: "Heartbeat service not available" }, 503); + } + try { + const result = await opts.heartbeatService.stopRun(workspaceId, runId); + log.info("heartbeat: run stopped", { workspaceId, runId }); + const body: StopHeartbeatRunResponse = result; + return c.json(body, 200); + } catch (err) { + // stopRun throws "Heartbeat run not found" for an unknown run id. + const message = err instanceof Error ? err.message : String(err); + if (message.includes("not found")) { + return c.json({ error: "Heartbeat run not found" }, 404); + } + log.error("heartbeat: run stop failure", { err, workspaceId, runId }); + return c.json({ error: "Failed to stop heartbeat run" }, 500); + } + }); + // ─── System prompt template ─────────────────────────────────────────────── app.get("/system-prompt/variables", (c) => { diff --git a/packages/transport-http/src/extension.ts b/packages/transport-http/src/extension.ts index 4ab43ce..e3f9ffc 100644 --- a/packages/transport-http/src/extension.ts +++ b/packages/transport-http/src/extension.ts @@ -7,6 +7,7 @@ import { computerServiceHandle, conversationStoreHandle, credentialStoreHandle, + heartbeatServiceHandle, lspServiceHandle, mcpServiceHandle, sessionOrchestratorHandle, @@ -23,6 +24,7 @@ export const manifest: Manifest = { dependsOn: [ "conversation-store", "credential-store", + "heartbeat", "lsp", "mcp", "session-orchestrator", @@ -60,9 +62,12 @@ export const manifest: Manifest = { "/system-prompt/variables", "/workspaces", "/workspaces/:id", - "/workspaces/:id/title", "/workspaces/:id/default-cwd", "/workspaces/:id/default-computer", + "/workspaces/:id/heartbeat", + "/workspaces/:id/heartbeat/runs", + "/workspaces/:id/heartbeat/runs/:runId/stop", + "/workspaces/:id/title", ], }, activation: "eager", @@ -88,6 +93,7 @@ export function createTransportHttpExtension(): Extension & { const lspService = host.getService(lspServiceHandle); const mcpService = host.getService(mcpServiceHandle); const systemPromptService = host.getService(systemPromptHandle); + const heartbeatService = host.getService(heartbeatServiceHandle); // Optional: the `ssh` extension provides ComputerService. It is NOT in // dependsOn (ssh may be absent), so resolve defensively — when no // provider registered the handle, the computer routes degrade to @@ -111,6 +117,7 @@ export function createTransportHttpExtension(): Extension & { lspService, mcpService, systemPromptService, + heartbeatService, ...(computerService !== undefined ? { computerService } : {}), logger, emit: host.emit.bind(host), diff --git a/packages/transport-http/src/seam.ts b/packages/transport-http/src/seam.ts index ef28a09..d48bd70 100644 --- a/packages/transport-http/src/seam.ts +++ b/packages/transport-http/src/seam.ts @@ -6,6 +6,8 @@ export type { ConversationStore } from "@dispatch/conversation-store"; export { conversationStoreHandle, isValidWorkspaceSlug } from "@dispatch/conversation-store"; export type { CredentialStore } from "@dispatch/credential-store"; export { credentialStoreHandle } from "@dispatch/credential-store"; +export type { HeartbeatService } from "@dispatch/heartbeat"; +export { heartbeatServiceHandle } from "@dispatch/heartbeat"; export type { LspServerStatus, LspService } from "@dispatch/lsp"; export { lspServiceHandle } from "@dispatch/lsp"; export type { McpServerStatus, McpService } from "@dispatch/mcp"; diff --git a/packages/transport-http/tsconfig.json b/packages/transport-http/tsconfig.json index 8dd2439..001a373 100644 --- a/packages/transport-http/tsconfig.json +++ b/packages/transport-http/tsconfig.json @@ -5,6 +5,7 @@ "references": [ { "path": "../conversation-store" }, { "path": "../credential-store" }, + { "path": "../heartbeat" }, { "path": "../kernel" }, { "path": "../lsp" }, { "path": "../session-orchestrator" }, diff --git a/tsconfig.json b/tsconfig.json index aab3ac1..cde3850 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -95,6 +95,9 @@ "path": "./packages/lsp" }, { + "path": "./packages/heartbeat" + }, + { "path": "./packages/system-prompt" }, { |
