summaryrefslogtreecommitdiffhomepage
path: root/packages/transport-http/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-26 00:02:22 +0900
committerAdam Malczewski <[email protected]>2026-06-26 00:02:22 +0900
commitc5c34ed70e0f04b7b936fa7a1d88ef807472fb96 (patch)
tree2eb3d32159c2875cf518fc47b7f48010d506aafe /packages/transport-http/src
parent9b91d1bca83e7599fb7d7de6038cedf186e61764 (diff)
downloaddispatch-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.ts172
-rw-r--r--packages/transport-http/src/extension.ts9
-rw-r--r--packages/transport-http/src/seam.ts2
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";