summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--notes/heartbeat-setting-handoff.md130
-rw-r--r--packages/heartbeat/src/config-store.test.ts37
-rw-r--r--packages/heartbeat/src/config-store.ts3
-rw-r--r--packages/heartbeat/src/extension.ts13
-rw-r--r--packages/heartbeat/src/heartbeat.test.ts127
-rw-r--r--packages/heartbeat/src/heartbeat.ts29
-rw-r--r--packages/transport-contract/src/index.ts17
-rw-r--r--packages/transport-http/src/app.test.ts90
-rw-r--r--packages/transport-http/src/app.ts10
9 files changed, 456 insertions, 0 deletions
diff --git a/notes/heartbeat-setting-handoff.md b/notes/heartbeat-setting-handoff.md
new file mode 100644
index 0000000..53382aa
--- /dev/null
+++ b/notes/heartbeat-setting-handoff.md
@@ -0,0 +1,130 @@
+# FE handoff — heartbeat `inactiveOnly` (skip while workspace is busy)
+
+Courier this to `../frontend` (cross-repo contract change; `lsp references` does not span
+repos — ORCHESTRATOR §7). All changes are ADDITIVE — nothing existing breaks. The new
+field has a default, so a frontend that never sends it gets the new behavior automatically.
+
+## What shipped (backend)
+
+A new per-workspace heartbeat setting, **`inactiveOnly`**: when on (the default), the
+heartbeat SKIPS a fire whenever the configured workspace has any active agents — i.e. a
+conversation whose persisted status is `"active"` (driving a turn) or `"queued"` (waiting on
+the message queue). The heartbeat stays quiet while the user is actively working and only
+fires when the workspace is idle.
+
+The spawned heartbeat conversation lives in the DEDICATED heartbeat workspace (not the
+configured one), so a heartbeat run never counts as an "active agent" of the configured
+workspace — there is no self-block.
+
+## The setting
+
+```ts
+// @dispatch/transport-contract (HeartbeatConfig)
+interface HeartbeatConfig {
+ enabled: boolean;
+ inactiveOnly: boolean; // ← NEW. default: true
+ systemPrompt: string;
+ taskPrompt: string;
+ intervalMinutes: number;
+ model: string;
+ reasoningEffort: ReasoningEffort | null;
+}
+```
+
+- **Name:** `inactiveOnly`
+- **Type:** `boolean`
+- **Default:** `true` (the heartbeat is quiet-by-default while the workspace is busy)
+- **Semantics:**
+ - `true` → before each scheduled fire, the backend checks the configured workspace for
+ active agents. If any exist, the fire is **silently skipped** (no run is recorded, no
+ `HeartbeatRun` created). The scheduler re-arms and tries again at the next
+ `intervalMinutes` interval.
+ - `false` → the heartbeat fires unconditionally on every interval, regardless of
+ workspace activity (the pre-existing behavior).
+- **What counts as an "active agent":** any conversation in the configured workspace whose
+ persisted `ConversationStatus` is `"active"` or `"queued"`. The orchestrator sets
+ `"active"` when a turn starts and `"idle"` when it settles, so this is the live
+ busy/idle signal.
+- **Self-block safety:** heartbeat-spawned conversations are filed in the `heartbeat`
+ workspace, so an in-flight heartbeat run does NOT keep the configured workspace "busy".
+
+## API endpoints
+
+The setting rides on the existing heartbeat config endpoints — no new routes.
+
+### Read
+
+`GET /workspaces/:id/heartbeat` → `HeartbeatConfig` (200)
+
+The response now includes `inactiveOnly`. For a workspace that never configured a
+heartbeat, the full default config is returned (`enabled: false`, `inactiveOnly: true`,
+`intervalMinutes: 30`, …).
+
+### Update
+
+`PUT /workspaces/:id/heartbeat` with a partial `UpdateHeartbeatRequest` body → `HeartbeatConfig` (200)
+
+```ts
+// @dispatch/transport-contract (UpdateHeartbeatRequest)
+interface UpdateHeartbeatRequest {
+ enabled?: boolean;
+ inactiveOnly?: boolean; // ← NEW. optional; absent = unchanged
+ systemPrompt?: string;
+ taskPrompt?: string;
+ intervalMinutes?: number;
+ model?: string;
+ reasoningEffort?: ReasoningEffort | null;
+}
+```
+
+All fields optional (a partial update); only provided fields are applied. Absent
+`inactiveOnly` leaves it unchanged (distinct from sending `false`).
+
+**Validation:** `inactiveOnly` must be a JSON `boolean` when present. A non-boolean
+(e.g. `"yes"`, `1`) → HTTP 400 `{ error: "Field 'inactiveOnly' must be a boolean" }`.
+The service is NOT called on validation failure.
+
+**Example:**
+
+```http
+PUT /workspaces/proj/heartbeat
+Content-Type: application/json
+
+{ "inactiveOnly": false }
+```
+
+```json
+200 OK
+{
+ "enabled": false,
+ "inactiveOnly": false,
+ "systemPrompt": "",
+ "taskPrompt": "",
+ "intervalMinutes": 30,
+ "model": "",
+ "reasoningEffort": null
+}
+```
+
+## Frontend implementation notes
+
+- Render a **checkbox** (default checked) in the workspace heartbeat settings UI, labelled
+ something like "Only run when the workspace is idle" / "Pause while agents are active".
+- The checkbox reflects and edits `inactiveOnly` on the heartbeat config.
+- On toggle, send `PUT /workspaces/:id/heartbeat` with `{ inactiveOnly: <bool> }` (a partial
+ update — no need to send the rest of the config).
+- A skipped fire produces **no `HeartbeatRun`**, so the runs list (`GET
+ /workspaces/:id/heartbeat/runs`) and the next-run countdown (`GET
+ /workspaces/:id/heartbeat/next-run`) behave exactly as on any other idle interval: the
+ next-run timestamp advances to `now + intervalMinutes`. No new event type is emitted on a
+ skip (it is silent); the backend logs it at `info` level only.
+- Legacy workspaces: a config persisted by an older backend (no `inactiveOnly` field) is
+ read back as `inactiveOnly: true` (the default) — the feature is ON by default for
+ everyone, including pre-existing workspaces.
+
+## Versions / deps
+
+The new field is added to `HeartbeatConfig` / `UpdateHeartbeatRequest` in
+`@dispatch/transport-contract`. It is a purely additive field with a default, so no
+version bump of the pinned `file:` dependency is strictly required to keep working — but
+bump it when convenient to pick up the typed field.
diff --git a/packages/heartbeat/src/config-store.test.ts b/packages/heartbeat/src/config-store.test.ts
index e77d10a..9a6772d 100644
--- a/packages/heartbeat/src/config-store.test.ts
+++ b/packages/heartbeat/src/config-store.test.ts
@@ -77,6 +77,21 @@ describe("applyConfigUpdate (pure)", () => {
const untouched = applyConfigUpdate(withEffort, { enabled: true });
expect(untouched.reasoningEffort).toBe("low");
});
+
+ it("defaults inactiveOnly to true (the heartbeat is quiet by default while the workspace is busy)", () => {
+ expect(DEFAULT_HEARTBEAT_CONFIG.inactiveOnly).toBe(true);
+ });
+
+ it("applies an inactiveOnly update", () => {
+ const next = applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { inactiveOnly: false });
+ expect(next.inactiveOnly).toBe(false);
+ });
+
+ it("leaves inactiveOnly unchanged when absent from the update", () => {
+ const off = applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { inactiveOnly: false });
+ const untouched = applyConfigUpdate(off, { enabled: true });
+ expect(untouched.inactiveOnly).toBe(false);
+ });
});
describe("createHeartbeatConfigStore", () => {
@@ -106,6 +121,28 @@ describe("createHeartbeatConfigStore", () => {
expect(next.taskPrompt).toBe("c");
});
+ it("round-trips inactiveOnly through persistence", async () => {
+ const storage = createMemoryStorage();
+ const store = createHeartbeatConfigStore(storage);
+ const next = await store.update("ws-1", { inactiveOnly: false });
+ expect(next.inactiveOnly).toBe(false);
+ const store2 = createHeartbeatConfigStore(storage);
+ expect((await store2.get("ws-1")).inactiveOnly).toBe(false);
+ });
+
+ it("defaults inactiveOnly to true for legacy configs persisted before the field existed", async () => {
+ const storage = createMemoryStorage();
+ // Simulate a config written by an older build that had no inactiveOnly
+ // field (a pre-inactive-only heartbeat config).
+ await storage.set("config:ws-1", JSON.stringify({ enabled: true, intervalMinutes: 5 }));
+ const store = createHeartbeatConfigStore(storage);
+ const config = await store.get("ws-1");
+ expect(config.enabled).toBe(true);
+ expect(config.intervalMinutes).toBe(5);
+ // The missing field defaults ON (feature on by default) — never `undefined`.
+ expect(config.inactiveOnly).toBe(true);
+ });
+
it("lists persisted workspace ids", async () => {
const store = createHeartbeatConfigStore(createMemoryStorage());
await store.update("ws-b", { enabled: true });
diff --git a/packages/heartbeat/src/config-store.ts b/packages/heartbeat/src/config-store.ts
index f06c9e8..52aca3e 100644
--- a/packages/heartbeat/src/config-store.ts
+++ b/packages/heartbeat/src/config-store.ts
@@ -7,6 +7,7 @@ import type { HeartbeatConfig, UpdateHeartbeatRequest } from "@dispatch/transpor
*/
export const DEFAULT_HEARTBEAT_CONFIG: HeartbeatConfig = {
enabled: false,
+ inactiveOnly: true,
systemPrompt: "",
taskPrompt: "",
intervalMinutes: 30,
@@ -38,6 +39,7 @@ export function applyConfigUpdate(
): HeartbeatConfig {
const next: HeartbeatConfig = {
enabled: update.enabled !== undefined ? update.enabled : current.enabled,
+ inactiveOnly: update.inactiveOnly !== undefined ? update.inactiveOnly : current.inactiveOnly,
systemPrompt: update.systemPrompt !== undefined ? update.systemPrompt : current.systemPrompt,
taskPrompt: update.taskPrompt !== undefined ? update.taskPrompt : current.taskPrompt,
intervalMinutes:
@@ -72,6 +74,7 @@ export function createHeartbeatConfigStore(storage: StorageNamespace): Heartbeat
const parsed = JSON.parse(raw) as Partial<HeartbeatConfig>;
return {
enabled: typeof parsed.enabled === "boolean" ? parsed.enabled : false,
+ inactiveOnly: typeof parsed.inactiveOnly === "boolean" ? parsed.inactiveOnly : true,
systemPrompt: typeof parsed.systemPrompt === "string" ? parsed.systemPrompt : "",
taskPrompt: typeof parsed.taskPrompt === "string" ? parsed.taskPrompt : "",
intervalMinutes:
diff --git a/packages/heartbeat/src/extension.ts b/packages/heartbeat/src/extension.ts
index f245b7e..b044619 100644
--- a/packages/heartbeat/src/extension.ts
+++ b/packages/heartbeat/src/extension.ts
@@ -90,6 +90,19 @@ export const extension: Extension = {
// Lazy (resolved at fire time, mirroring resolvePrompt).
getWorkspaceCwd: async (wsId) =>
(await conversationStore.getWorkspace(wsId))?.defaultCwd ?? null,
+ // inactiveOnly: the configured workspace is "busy" while any of its
+ // conversations are driving or queued for a turn. The orchestrator
+ // sets persisted status "active" on turn start and "idle" on settle,
+ // so a single store read (filtered to the configured workspaceId) is
+ // the live active-agent check. The heartbeat's own spawned conversation
+ // lives in the DEDICATED heartbeat workspace, so it never self-blocks.
+ hasActiveAgents: async (wsId) => {
+ const active = await conversationStore.listConversations({
+ workspaceId: wsId,
+ status: ["active", "queued"],
+ });
+ return active.length > 0;
+ },
});
// Reconcile stale runs + arm enabled workspaces on boot.
diff --git a/packages/heartbeat/src/heartbeat.test.ts b/packages/heartbeat/src/heartbeat.test.ts
index ff1ec10..a6f7ebb 100644
--- a/packages/heartbeat/src/heartbeat.test.ts
+++ b/packages/heartbeat/src/heartbeat.test.ts
@@ -157,6 +157,7 @@ function createService(opts: {
) => Promise<string>;
readonly getGlobalSystemPrompt?: () => Promise<string>;
readonly getWorkspaceCwd?: (workspaceId: string) => Promise<string | null>;
+ readonly hasActiveAgents?: (workspaceId: string) => Promise<boolean>;
}) {
const fake = createFakeTimers();
let id = 0;
@@ -171,6 +172,7 @@ function createService(opts: {
? { getGlobalSystemPrompt: opts.getGlobalSystemPrompt }
: {}),
...(opts.getWorkspaceCwd !== undefined ? { getWorkspaceCwd: opts.getWorkspaceCwd } : {}),
+ ...(opts.hasActiveAgents !== undefined ? { hasActiveAgents: opts.hasActiveAgents } : {}),
});
return { svc, advance: fake.advance, storage };
}
@@ -181,6 +183,131 @@ describe("createHeartbeatService", () => {
const cfg = await svc.getConfig("ws-1");
expect(cfg.enabled).toBe(false);
expect(cfg.intervalMinutes).toBe(30);
+ // inactiveOnly defaults ON (the heartbeat is quiet by default while the
+ // workspace is busy).
+ expect(cfg.inactiveOnly).toBe(true);
+ });
+
+ describe("inactiveOnly (skip fire while the workspace has active agents)", () => {
+ it("skips the fire (records no run) when inactiveOnly is true and the workspace has active agents", async () => {
+ const orch = createFakeOrchestrator();
+ const { svc, advance } = createService({
+ orch,
+ hasActiveAgents: () => Promise.resolve(true),
+ });
+ await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
+
+ advance(60_000); // interval elapses → fire
+ await flush(); // let the async fire() reach the active-agent check + return
+
+ expect(orch.pending).toHaveLength(0); // no turn started
+ expect(await svc.listRuns("ws-1")).toHaveLength(0); // no run recorded
+ });
+
+ it("still fires when inactiveOnly is true but the workspace has NO active agents", async () => {
+ const orch = createFakeOrchestrator();
+ const { svc, advance } = createService({
+ orch,
+ hasActiveAgents: () => Promise.resolve(false),
+ });
+ await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
+
+ advance(60_000);
+ await flush();
+ expect(orch.pending).toHaveLength(1);
+ expect((await svc.listRuns("ws-1"))[0]?.status).toBe("running");
+ orch.pending[0]!.resolve();
+ await flush();
+ });
+
+ it("fires unconditionally when inactiveOnly is false, even with active agents", async () => {
+ const orch = createFakeOrchestrator();
+ const { svc, advance } = createService({
+ orch,
+ // Active agents reported, but the setting is OFF → must not block.
+ hasActiveAgents: () => Promise.resolve(true),
+ });
+ await svc.updateConfig("ws-1", {
+ enabled: true,
+ inactiveOnly: false,
+ taskPrompt: "go",
+ intervalMinutes: 1,
+ });
+
+ advance(60_000);
+ await flush();
+ expect(orch.pending).toHaveLength(1);
+ expect((await svc.listRuns("ws-1"))[0]?.status).toBe("running");
+ orch.pending[0]!.resolve();
+ await flush();
+ });
+
+ it("re-arms and fires on the next interval after a skipped fire (the scheduler keeps ticking)", async () => {
+ const orch = createFakeOrchestrator();
+ let busy = true; // workspace busy on the first fire, free on the next
+ const { svc, advance } = createService({
+ orch,
+ hasActiveAgents: () => Promise.resolve(busy),
+ });
+ await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
+
+ advance(60_000); // first fire — skipped (busy)
+ await flush();
+ expect(orch.pending).toHaveLength(0);
+
+ busy = false; // workspace goes idle
+ advance(60_000); // next interval → fire again
+ await flush();
+ expect(orch.pending).toHaveLength(1); // now it fires
+ orch.pending[0]!.resolve();
+ await flush();
+ });
+
+ it("does not consult hasActiveAgents when inactiveOnly is false (degrades off cleanly)", async () => {
+ const orch = createFakeOrchestrator();
+ let consulted = false;
+ const { svc, advance } = createService({
+ orch,
+ hasActiveAgents: () => {
+ consulted = true;
+ return Promise.resolve(true);
+ },
+ });
+ await svc.updateConfig("ws-1", {
+ enabled: true,
+ inactiveOnly: false,
+ taskPrompt: "go",
+ intervalMinutes: 1,
+ });
+
+ advance(60_000);
+ await flush();
+ expect(consulted).toBe(false); // never asked — setting is off
+ expect(orch.pending).toHaveLength(1);
+ orch.pending[0]!.resolve();
+ await flush();
+ });
+
+ it("checks active agents per configured workspace (only the configured workspace is consulted)", async () => {
+ const orch = createFakeOrchestrator();
+ const busyWorkspaces = new Set<string>(["ws-busy"]);
+ const { svc, advance } = createService({
+ orch,
+ hasActiveAgents: (wsId) => Promise.resolve(busyWorkspaces.has(wsId)),
+ });
+ // ws-free is idle, ws-busy has active agents.
+ await svc.updateConfig("ws-free", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
+ await svc.updateConfig("ws-busy", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
+
+ advance(60_000); // both fire
+ await flush();
+ // ws-free fired (idle), ws-busy skipped (busy).
+ expect(orch.pending).toHaveLength(1);
+ expect((await svc.listRuns("ws-free"))[0]?.status).toBe("running");
+ expect(await svc.listRuns("ws-busy")).toHaveLength(0);
+ orch.pending[0]!.resolve();
+ await flush();
+ });
});
it("arming an enabled config does not fire immediately (waits for the interval)", () => {
diff --git a/packages/heartbeat/src/heartbeat.ts b/packages/heartbeat/src/heartbeat.ts
index 12e70e3..e04c538 100644
--- a/packages/heartbeat/src/heartbeat.ts
+++ b/packages/heartbeat/src/heartbeat.ts
@@ -116,6 +116,19 @@ export interface HeartbeatServiceDeps {
* (against `conversationStore.getWorkspace`); tests inject a fake.
*/
readonly getWorkspaceCwd?: (workspaceId: string) => Promise<string | null>;
+ /**
+ * Whether the configured workspace currently has any ACTIVE agents —
+ * conversations driving (or queued for) a turn. When `config.inactiveOnly`
+ * is `true`, the heartbeat SKIPS a fire while this returns `true` (the
+ * workspace is busy). The extension wires the real check against
+ * `conversationStore.listConversations({ workspaceId, status: ["active",
+ * "queued"] })` (the configured workspace's persisted statuses — the
+ * orchestrator sets `"active"` on turn start, `"idle"` on settle); tests
+ * inject a fake. When omitted, `false` (no active agents → never skip) so
+ * the inactive-only feature degrades off cleanly — the heartbeat fires
+ * unconditionally, matching the pre-inactive-only behavior.
+ */
+ readonly hasActiveAgents?: (workspaceId: string) => Promise<boolean>;
}
interface ActiveRun {
@@ -143,6 +156,10 @@ export function createHeartbeatService(deps: HeartbeatServiceDeps): HeartbeatSer
// conversation's workspace). The extension wires the real getter so the
// turn pins to the CONFIGURED workspace's defaultCwd; tests inject a fake.
const getWorkspaceCwd = deps.getWorkspaceCwd ?? (() => Promise.resolve(null));
+ // Default: no active agents (never skip) — the inactive-only feature degrades
+ // off cleanly. The extension wires the real check (against the conversation
+ // store's persisted statuses); tests inject a fake.
+ const hasActiveAgents = deps.hasActiveAgents ?? (() => Promise.resolve(false));
// 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
@@ -160,6 +177,18 @@ export function createHeartbeatService(deps: HeartbeatServiceDeps): HeartbeatSer
// Race: disabled/disarmed between the timer firing and now.
if (!config.enabled) return;
+ // inactiveOnly: skip this fire when the configured workspace has active
+ // agents (a conversation driving or queued for a turn). The fire is
+ // silently skipped — no run is recorded — and the scheduler re-arms to
+ // try again at the next interval. The spawned heartbeat conversation lives
+ // in the DEDICATED heartbeat workspace, so it never self-blocks (a prior
+ // in-flight heartbeat run is NOT an active agent of the configured
+ // workspace). Disabled (inactiveOnly === false) fires unconditionally.
+ if (config.inactiveOnly && (await hasActiveAgents(workspaceId))) {
+ logger?.info("heartbeat: fire skipped — workspace has active agents", { workspaceId });
+ return;
+ }
+
const conversationId = generateId();
const runId = generateId();
const triggeredAt = new Date(timers.now()).toISOString();
diff --git a/packages/transport-contract/src/index.ts b/packages/transport-contract/src/index.ts
index f583954..32b03d3 100644
--- a/packages/transport-contract/src/index.ts
+++ b/packages/transport-contract/src/index.ts
@@ -1025,10 +1025,26 @@ export interface TestComputerResponse {
* 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.
+ *
+ * `inactiveOnly` (default `true`) gates each fire on the configured workspace
+ * having NO active agents (no conversation with status `"active"` or `"queued"`):
+ * the heartbeat stays quiet while the user is actively working, and only fires
+ * when the workspace is idle. Set `false` to fire unconditionally.
*/
export interface HeartbeatConfig {
/** Whether the heartbeat loop is active for this workspace. */
readonly enabled: boolean;
+ /**
+ * When `true` (the default), the heartbeat SKIPS a fire when the configured
+ * workspace has any active agents — conversations whose persisted status is
+ * `"active"` (driving a turn) or `"queued"` (waiting on the message queue).
+ * The fire is silently skipped (no run is recorded); the scheduler re-arms
+ * and tries again at the next interval. When `false`, the heartbeat fires
+ * unconditionally regardless of workspace activity. The spawned heartbeat
+ * conversation lives in the DEDICATED heartbeat workspace, so it never
+ * counts as an "active agent" of the configured workspace (no self-block).
+ */
+ readonly inactiveOnly: 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. */
@@ -1050,6 +1066,7 @@ export interface HeartbeatConfig {
*/
export interface UpdateHeartbeatRequest {
readonly enabled?: boolean;
+ readonly inactiveOnly?: boolean;
readonly systemPrompt?: string;
readonly taskPrompt?: string;
readonly intervalMinutes?: number;
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);