diff options
| author | Adam Malczewski <[email protected]> | 2026-07-01 03:30:42 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-07-01 03:30:42 +0900 |
| commit | 808499ca589724ef2bd681e7ed77177e9126e005 (patch) | |
| tree | 1c0a25a13aae889018b0b036eaec53ffc4973d87 /packages/transport-http | |
| parent | 665672eccff531f7a785295a8f16e57e98106deb (diff) | |
| parent | fdc2d0df8fa5ffca8c1c0957cc8bbbe5f4a5304c (diff) | |
| download | dispatch-808499ca589724ef2bd681e7ed77177e9126e005.tar.gz dispatch-808499ca589724ef2bd681e7ed77177e9126e005.zip | |
Merge branch 'feature/heartbeat-inactive-only' into predev
Diffstat (limited to 'packages/transport-http')
| -rw-r--r-- | packages/transport-http/src/app.test.ts | 90 | ||||
| -rw-r--r-- | packages/transport-http/src/app.ts | 10 |
2 files changed, 100 insertions, 0 deletions
diff --git a/packages/transport-http/src/app.test.ts b/packages/transport-http/src/app.test.ts index 0876cdb..557fb44 100644 --- a/packages/transport-http/src/app.test.ts +++ b/packages/transport-http/src/app.test.ts @@ -549,6 +549,35 @@ function createFakeHeartbeatService(nextRunAt: string | null): HeartbeatService }; } +/** + * A HeartbeatService fake that CAPTURES the updateConfig call (workspaceId + + * partial update) and returns a config echoing the captured update on top of + * the defaults — for asserting the PUT /workspaces/:id/heartbeat route forwards + * validated fields to the service. + */ +function createCapturingHeartbeatService(): HeartbeatService & { + readonly captured: { workspaceId: string; update: Record<string, unknown> }[]; +} { + const captured: { workspaceId: string; update: Record<string, unknown> }[] = []; + const svc: HeartbeatService = { + getConfig: async () => DEFAULT_HEARTBEAT_CONFIG, + async updateConfig(workspaceId, update) { + captured.push({ workspaceId, update: update as Record<string, unknown> }); + return { ...DEFAULT_HEARTBEAT_CONFIG, ...update }; + }, + listRuns: async () => [], + stopRun: async () => ({ ok: true }), + startAll: async () => {}, + stopAll: () => {}, + nextRunAt: async () => null, + }; + return Object.assign(svc, { + get captured() { + return captured; + }, + }); +} + const noopLogger = createFakeLogger(); describe("GET /health", () => { @@ -4805,3 +4834,64 @@ describe("GET /workspaces/:id/heartbeat/next-run", () => { expect(body.nextRunAt).toBeNull(); }); }); + +describe("PUT /workspaces/:id/heartbeat", () => { + it("forwards inactiveOnly to the service and echoes it in the response", async () => { + const hb = createCapturingHeartbeatService(); + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + heartbeatService: hb, + logger: noopLogger, + }); + const res = await app.request("/workspaces/ws-1/heartbeat", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ inactiveOnly: false }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { inactiveOnly: boolean }; + expect(body.inactiveOnly).toBe(false); + expect(hb.captured).toHaveLength(1); + expect(hb.captured[0]?.workspaceId).toBe("ws-1"); + expect(hb.captured[0]?.update.inactiveOnly).toBe(false); + }); + + it("rejects a non-boolean inactiveOnly with 400", async () => { + const hb = createCapturingHeartbeatService(); + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + heartbeatService: hb, + logger: noopLogger, + }); + const res = await app.request("/workspaces/ws-1/heartbeat", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ inactiveOnly: "yes" }), + }); + expect(res.status).toBe(400); + // The service was NOT called (validation happened first). + expect(hb.captured).toHaveLength(0); + }); + + it("omits inactiveOnly from the forwarded update when absent (leaves it unchanged)", async () => { + const hb = createCapturingHeartbeatService(); + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + heartbeatService: hb, + logger: noopLogger, + }); + const res = await app.request("/workspaces/ws-1/heartbeat", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: true }), + }); + expect(res.status).toBe(200); + expect(hb.captured[0]?.update.inactiveOnly).toBeUndefined(); + }); +}); diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts index 6e0748b..32a92f1 100644 --- a/packages/transport-http/src/app.ts +++ b/packages/transport-http/src/app.ts @@ -1698,6 +1698,16 @@ export function createApp(opts: CreateServerOptions): Hono { update.enabled = obj.enabled; } + // inactiveOnly: when true (the default), the heartbeat skips a fire while + // the configured workspace has active agents. A boolean; absent leaves it + // unchanged. + if (obj.inactiveOnly !== undefined) { + if (typeof obj.inactiveOnly !== "boolean") { + return c.json({ error: "Field 'inactiveOnly' must be a boolean" }, 400); + } + update.inactiveOnly = obj.inactiveOnly; + } + if (obj.systemPrompt !== undefined) { if (typeof obj.systemPrompt !== "string") { return c.json({ error: "Field 'systemPrompt' must be a string" }, 400); |
