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 /packages/transport-http/src | |
| parent | 9b91d1bca83e7599fb7d7de6038cedf186e61764 (diff) | |
| download | dispatch-c5c34ed70e0f04b7b936fa7a1d88ef807472fb96.tar.gz dispatch-c5c34ed70e0f04b7b936fa7a1d88ef807472fb96.zip | |
feat(heartbeat): workspace heartbeat loop with configurable AI monitoring
Diffstat (limited to 'packages/transport-http/src')
| -rw-r--r-- | packages/transport-http/src/app.ts | 172 | ||||
| -rw-r--r-- | packages/transport-http/src/extension.ts | 9 | ||||
| -rw-r--r-- | packages/transport-http/src/seam.ts | 2 |
3 files changed, 182 insertions, 1 deletions
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"; |
