bopodev-api 0.1.26 → 0.1.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/server.ts CHANGED
@@ -1,9 +1,8 @@
1
1
  import { createServer } from "node:http";
2
2
  import { dirname, resolve } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
- import { sql } from "drizzle-orm";
5
4
  import { config as loadDotenv } from "dotenv";
6
- import { bootstrapDatabase, listCompanies } from "bopodev-db";
5
+ import { bootstrapDatabase, listCompanies, resolveDefaultDbPath, sql } from "bopodev-db";
7
6
  import { checkRuntimeCommandHealth } from "bopodev-agent-sdk";
8
7
  import type { RuntimeCommandHealth } from "bopodev-agent-sdk";
9
8
  import { createApp } from "./app";
@@ -21,6 +20,8 @@ import {
21
20
  } from "./security/deployment-mode";
22
21
  import { ensureBuiltinPluginsRegistered } from "./services/plugin-runtime";
23
22
  import { ensureBuiltinTemplatesRegistered } from "./services/template-catalog";
23
+ import { beginIssueCommentDispatchShutdown, waitForIssueCommentDispatchDrain } from "./services/comment-recipient-dispatch-service";
24
+ import { beginHeartbeatQueueShutdown, waitForHeartbeatQueueDrain } from "./services/heartbeat-queue-service";
24
25
  import { createHeartbeatScheduler } from "./worker/scheduler";
25
26
 
26
27
  loadApiEnv();
@@ -32,8 +33,33 @@ async function main() {
32
33
  const publicBaseUrl = resolvePublicBaseUrl();
33
34
  validateDeploymentConfiguration(deploymentMode, allowedOrigins, allowedHostnames, publicBaseUrl);
34
35
  const dbPath = normalizeOptionalDbPath(process.env.BOPO_DB_PATH);
36
+ const usingExternalDatabase = Boolean(process.env.DATABASE_URL?.trim());
35
37
  const port = Number(process.env.PORT ?? 4020);
36
- const { db } = await bootstrapDatabase(dbPath);
38
+ const effectiveDbPath = dbPath ?? resolveDefaultDbPath();
39
+ let db: Awaited<ReturnType<typeof bootstrapDatabase>>["db"];
40
+ let dbClient: Awaited<ReturnType<typeof bootstrapDatabase>>["client"];
41
+ try {
42
+ const boot = await bootstrapDatabase(dbPath);
43
+ db = boot.db;
44
+ dbClient = boot.client;
45
+ } catch (error) {
46
+ if (isProbablyDatabaseStartupError(error)) {
47
+ // eslint-disable-next-line no-console
48
+ console.error("[startup] Database bootstrap failed before the API could start.");
49
+ if (usingExternalDatabase) {
50
+ // eslint-disable-next-line no-console
51
+ console.error("[startup] Check DATABASE_URL connectivity, permissions, and migration state.");
52
+ } else {
53
+ // eslint-disable-next-line no-console
54
+ console.error(`[startup] Embedded Postgres data path: ${effectiveDbPath}`);
55
+ // eslint-disable-next-line no-console
56
+ console.error(
57
+ "[startup] Recovery: stop all API/node processes using this DB, back up the path above, delete the directory if it is corrupted, then restart. Or set BOPO_DB_PATH to a fresh path."
58
+ );
59
+ }
60
+ }
61
+ throw error;
62
+ }
37
63
  const existingCompanies = await listCompanies(db);
38
64
  await ensureBuiltinPluginsRegistered(
39
65
  db,
@@ -120,16 +146,81 @@ async function main() {
120
146
 
121
147
  const defaultCompanyId = process.env.BOPO_DEFAULT_COMPANY_ID;
122
148
  const schedulerCompanyId = await resolveSchedulerCompanyId(db, defaultCompanyId ?? null);
149
+ let scheduler: ReturnType<typeof createHeartbeatScheduler> | undefined;
123
150
  if (schedulerCompanyId && shouldStartScheduler()) {
124
- createHeartbeatScheduler(db, schedulerCompanyId, realtimeHub);
151
+ scheduler = createHeartbeatScheduler(db, schedulerCompanyId, realtimeHub);
125
152
  } else if (schedulerCompanyId) {
126
153
  // eslint-disable-next-line no-console
127
154
  console.log("[startup] Scheduler disabled for this instance (BOPO_SCHEDULER_ROLE is follower/off).");
128
155
  }
156
+
157
+ let shutdownInFlight: Promise<void> | null = null;
158
+ function shutdown(signal: string) {
159
+ const shutdownTimeoutMs = Number(process.env.BOPO_SHUTDOWN_TIMEOUT_MS ?? 15_000);
160
+ const forcedExit = setTimeout(() => {
161
+ // eslint-disable-next-line no-console
162
+ console.error(`[shutdown] timed out after ${shutdownTimeoutMs}ms; forcing exit.`);
163
+ process.exit(process.exitCode ?? 1);
164
+ }, shutdownTimeoutMs);
165
+ forcedExit.unref();
166
+ shutdownInFlight ??= (async () => {
167
+ // eslint-disable-next-line no-console
168
+ console.log(`[shutdown] ${signal} — draining HTTP/background work before closing the embedded database…`);
169
+ beginHeartbeatQueueShutdown();
170
+ beginIssueCommentDispatchShutdown();
171
+ await Promise.allSettled([
172
+ scheduler?.stop() ?? Promise.resolve(),
173
+ new Promise<void>((resolve, reject) => {
174
+ server.close((err) => {
175
+ if (err) {
176
+ reject(err);
177
+ return;
178
+ }
179
+ resolve();
180
+ });
181
+ })
182
+ ]);
183
+ await Promise.allSettled([waitForHeartbeatQueueDrain(), waitForIssueCommentDispatchDrain()]);
184
+ try {
185
+ await realtimeHub.close();
186
+ } catch (closeError) {
187
+ // eslint-disable-next-line no-console
188
+ console.error("[shutdown] realtime hub close error", closeError);
189
+ }
190
+ try {
191
+ await closeDatabaseClient(dbClient);
192
+ } catch (closeDbError) {
193
+ // eslint-disable-next-line no-console
194
+ console.error("[shutdown] database close error", closeDbError);
195
+ }
196
+ // eslint-disable-next-line no-console
197
+ console.log("[shutdown] clean exit");
198
+ process.exitCode = 0;
199
+ })().catch((error) => {
200
+ // eslint-disable-next-line no-console
201
+ console.error("[shutdown] failed", error);
202
+ process.exitCode = 1;
203
+ });
204
+ return shutdownInFlight;
205
+ }
206
+
207
+ process.once("SIGINT", () => void shutdown("SIGINT"));
208
+ process.once("SIGTERM", () => void shutdown("SIGTERM"));
129
209
  }
130
210
 
131
211
  void main();
132
212
 
213
+ async function closeDatabaseClient(client: unknown) {
214
+ if (!client || typeof client !== "object") {
215
+ return;
216
+ }
217
+ const closeFn = (client as { close?: unknown }).close;
218
+ if (typeof closeFn !== "function") {
219
+ return;
220
+ }
221
+ await (closeFn as () => Promise<void>)();
222
+ }
223
+
133
224
  async function hasCodexAgentsConfigured(db: Awaited<ReturnType<typeof bootstrapDatabase>>["db"]) {
134
225
  const result = await db.execute(sql`
135
226
  SELECT id
@@ -137,7 +228,7 @@ async function hasCodexAgentsConfigured(db: Awaited<ReturnType<typeof bootstrapD
137
228
  WHERE provider_type = 'codex'
138
229
  LIMIT 1
139
230
  `);
140
- return (result.rows ?? []).length > 0;
231
+ return result.length > 0;
141
232
  }
142
233
 
143
234
  async function hasOpenCodeAgentsConfigured(db: Awaited<ReturnType<typeof bootstrapDatabase>>["db"]) {
@@ -147,7 +238,7 @@ async function hasOpenCodeAgentsConfigured(db: Awaited<ReturnType<typeof bootstr
147
238
  WHERE provider_type = 'opencode'
148
239
  LIMIT 1
149
240
  `);
150
- return (result.rows ?? []).length > 0;
241
+ return result.length > 0;
151
242
  }
152
243
 
153
244
  async function resolveSchedulerCompanyId(
@@ -161,7 +252,7 @@ async function resolveSchedulerCompanyId(
161
252
  WHERE id = ${configuredCompanyId}
162
253
  LIMIT 1
163
254
  `);
164
- if ((configured.rows ?? []).length > 0) {
255
+ if (configured.length > 0) {
165
256
  return configuredCompanyId;
166
257
  }
167
258
  // eslint-disable-next-line no-console
@@ -174,7 +265,7 @@ async function resolveSchedulerCompanyId(
174
265
  ORDER BY created_at ASC
175
266
  LIMIT 1
176
267
  `);
177
- const id = fallback.rows?.[0]?.id;
268
+ const id = fallback[0]?.id;
178
269
  return typeof id === "string" && id.length > 0 ? id : null;
179
270
  }
180
271
 
@@ -262,3 +353,16 @@ function normalizeOptionalDbPath(value: string | undefined) {
262
353
  const normalized = value?.trim();
263
354
  return normalized && normalized.length > 0 ? normalized : undefined;
264
355
  }
356
+
357
+ function isProbablyDatabaseStartupError(error: unknown): boolean {
358
+ const message = error instanceof Error ? error.message : String(error);
359
+ const cause = error instanceof Error ? error.cause : undefined;
360
+ const causeMessage = cause instanceof Error ? cause.message : String(cause ?? "");
361
+ return (
362
+ message.includes("database") ||
363
+ message.includes("postgres") ||
364
+ message.includes("migration") ||
365
+ causeMessage.includes("postgres") ||
366
+ causeMessage.includes("connection")
367
+ );
368
+ }
@@ -1,7 +1,10 @@
1
- import { and, desc, eq, like } from "drizzle-orm";
2
1
  import {
2
+ and,
3
3
  issueComments,
4
4
  issues,
5
+ desc,
6
+ eq,
7
+ like,
5
8
  listApprovalRequests,
6
9
  listAttentionInboxStates,
7
10
  listHeartbeatRuns,
@@ -17,6 +20,40 @@ import type { BoardAttentionItem } from "bopodev-contracts";
17
20
 
18
21
  type AttentionStateRow = Awaited<ReturnType<typeof listAttentionInboxStates>>[number];
19
22
 
23
+ /** Keep in sync with `RESOLVED_APPROVAL_INBOX_WINDOW_DAYS` in governance routes (resolved history in Inbox). */
24
+ const RESOLVED_APPROVAL_ATTENTION_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
25
+
26
+ type StoredApprovalRow = Awaited<ReturnType<typeof listApprovalRequests>>[number];
27
+
28
+ function approvalIncludedInAttentionList(approval: Pick<StoredApprovalRow, "status" | "resolvedAt">): boolean {
29
+ if (approval.status === "pending") {
30
+ return true;
31
+ }
32
+ if (!approval.resolvedAt) {
33
+ return false;
34
+ }
35
+ return Date.now() - approval.resolvedAt.getTime() <= RESOLVED_APPROVAL_ATTENTION_WINDOW_MS;
36
+ }
37
+
38
+ function finalizeApprovalAttentionItem(item: BoardAttentionItem, approval: Pick<StoredApprovalRow, "status" | "resolvedAt" | "action">): BoardAttentionItem {
39
+ if (approval.status === "pending") {
40
+ return item;
41
+ }
42
+ const outcome =
43
+ approval.status === "approved" ? "Approved" : approval.status === "rejected" ? "Rejected" : "Overridden";
44
+ const title =
45
+ approval.action === "override_budget" ? `Budget hard-stop · ${outcome}` : `Approval · ${outcome}`;
46
+ const resolvedAtIso = approval.resolvedAt?.toISOString() ?? item.resolvedAt;
47
+ return {
48
+ ...item,
49
+ title,
50
+ state: "resolved",
51
+ resolvedAt: resolvedAtIso,
52
+ severity: "info",
53
+ sourceTimestamp: resolvedAtIso ?? item.sourceTimestamp
54
+ };
55
+ }
56
+
20
57
  export async function listBoardAttentionItems(db: BopoDb, companyId: string, actorId: string): Promise<BoardAttentionItem[]> {
21
58
  const [approvals, blockedIssues, heartbeatRuns, stateRows, boardComments] = await Promise.all([
22
59
  listApprovalRequests(db, companyId),
@@ -42,7 +79,7 @@ export async function listBoardAttentionItems(db: BopoDb, companyId: string, act
42
79
  const items: BoardAttentionItem[] = [];
43
80
 
44
81
  for (const approval of approvals) {
45
- if (approval.status !== "pending") {
82
+ if (!approvalIncludedInAttentionList(approval)) {
46
83
  continue;
47
84
  }
48
85
  const payload = parsePayload(approval.payloadJson);
@@ -55,25 +92,61 @@ export async function listBoardAttentionItems(db: BopoDb, companyId: string, act
55
92
  const usedBudget = asNumber(payload.usedBudgetUsd);
56
93
  const key = `budget:${approval.id}`;
57
94
  items.push(
95
+ finalizeApprovalAttentionItem(
96
+ withState(
97
+ {
98
+ key,
99
+ category: "budget_hard_stop",
100
+ severity: ageHours >= 12 ? "critical" : "warning",
101
+ requiredActor: "board",
102
+ title: "Budget hard-stop requires board decision",
103
+ contextSummary: projectId
104
+ ? `Project ${shortId(projectId)} is blocked by budget hard-stop.`
105
+ : agentId
106
+ ? `Agent ${shortId(agentId)} is blocked by budget hard-stop.`
107
+ : "Agent work is blocked by budget hard-stop.",
108
+ actionLabel: "Review budget override",
109
+ actionHref: "/governance",
110
+ impactSummary: "Heartbeat work stays paused until budget override is approved or rejected.",
111
+ evidence: {
112
+ approvalId: approval.id,
113
+ projectId: projectId ?? undefined,
114
+ agentId: agentId ?? undefined
115
+ },
116
+ sourceTimestamp: approval.createdAt.toISOString(),
117
+ state: "open",
118
+ seenAt: null,
119
+ acknowledgedAt: null,
120
+ dismissedAt: null,
121
+ resolvedAt: null
122
+ },
123
+ stateByKey.get(key),
124
+ `Budget utilization ${formatPercent(utilizationPct)} (${formatUsd(usedBudget)} / ${formatUsd(currentBudget)}).`
125
+ ),
126
+ approval
127
+ )
128
+ );
129
+ continue;
130
+ }
131
+
132
+ const key = `approval:${approval.id}`;
133
+ items.push(
134
+ finalizeApprovalAttentionItem(
58
135
  withState(
59
136
  {
60
137
  key,
61
- category: "budget_hard_stop",
62
- severity: ageHours >= 12 ? "critical" : "warning",
138
+ category: "approval_required",
139
+ severity: ageHours >= 24 ? "critical" : "warning",
63
140
  requiredActor: "board",
64
- title: "Budget hard-stop requires board decision",
65
- contextSummary: projectId
66
- ? `Project ${shortId(projectId)} is blocked by budget hard-stop.`
67
- : agentId
68
- ? `Agent ${shortId(agentId)} is blocked by budget hard-stop.`
69
- : "Agent work is blocked by budget hard-stop.",
70
- actionLabel: "Review budget override",
141
+ title: "Approval required",
142
+ contextSummary: formatApprovalContext(approval.action, payload),
143
+ actionLabel: "Open approvals",
71
144
  actionHref: "/governance",
72
- impactSummary: "Heartbeat work stays paused until budget override is approved or rejected.",
145
+ impactSummary: "Execution remains blocked until this governance decision is resolved.",
73
146
  evidence: {
74
147
  approvalId: approval.id,
75
- projectId: projectId ?? undefined,
76
- agentId: agentId ?? undefined
148
+ projectId: asString(payload.projectId) ?? undefined,
149
+ agentId: asString(payload.agentId) ?? undefined
77
150
  },
78
151
  sourceTimestamp: approval.createdAt.toISOString(),
79
152
  state: "open",
@@ -82,39 +155,9 @@ export async function listBoardAttentionItems(db: BopoDb, companyId: string, act
82
155
  dismissedAt: null,
83
156
  resolvedAt: null
84
157
  },
85
- stateByKey.get(key),
86
- `Budget utilization ${formatPercent(utilizationPct)} (${formatUsd(usedBudget)} / ${formatUsd(currentBudget)}).`
87
- )
88
- );
89
- continue;
90
- }
91
-
92
- const key = `approval:${approval.id}`;
93
- items.push(
94
- withState(
95
- {
96
- key,
97
- category: "approval_required",
98
- severity: ageHours >= 24 ? "critical" : "warning",
99
- requiredActor: "board",
100
- title: "Approval required",
101
- contextSummary: formatApprovalContext(approval.action, payload),
102
- actionLabel: "Open approvals",
103
- actionHref: "/governance",
104
- impactSummary: "Execution remains blocked until this governance decision is resolved.",
105
- evidence: {
106
- approvalId: approval.id,
107
- projectId: asString(payload.projectId) ?? undefined,
108
- agentId: asString(payload.agentId) ?? undefined
109
- },
110
- sourceTimestamp: approval.createdAt.toISOString(),
111
- state: "open",
112
- seenAt: null,
113
- acknowledgedAt: null,
114
- dismissedAt: null,
115
- resolvedAt: null
116
- },
117
- stateByKey.get(key)
158
+ stateByKey.get(key)
159
+ ),
160
+ approval
118
161
  )
119
162
  );
120
163
  }
@@ -1,6 +1,5 @@
1
- import { and, eq, sql } from "drizzle-orm";
2
1
  import type { BopoDb } from "bopodev-db";
3
- import { agents, projects } from "bopodev-db";
2
+ import { agents, and, eq, projects, sql } from "bopodev-db";
4
3
 
5
4
  export interface BudgetCheckResult {
6
5
  allowed: boolean;
@@ -1,21 +1,38 @@
1
- import { and, desc, eq, inArray, like } from "drizzle-orm";
2
1
  import {
2
+ and,
3
3
  agents,
4
+ desc,
5
+ eq,
6
+ inArray,
4
7
  issueComments,
8
+ like,
5
9
  updateIssueCommentRecipients,
6
10
  type BopoDb
7
11
  } from "bopodev-db";
12
+ import { createDrainableWorkTracker } from "../lib/drainable-work";
8
13
  import { parseIssueCommentRecipients, type PersistedCommentRecipient } from "../lib/comment-recipients";
9
14
  import type { RealtimeHub } from "../realtime/hub";
10
15
  import { enqueueHeartbeatQueueJob, triggerHeartbeatQueueWorker } from "./heartbeat-queue-service";
11
16
 
12
17
  const COMMENT_DISPATCH_SWEEP_LIMIT = 100;
13
18
  const activeCompanyDispatchRuns = new Set<string>();
19
+ const commentDispatchTracker = createDrainableWorkTracker();
14
20
 
15
21
  export async function runIssueCommentDispatchSweep(
16
22
  db: BopoDb,
17
23
  companyId: string,
18
24
  options?: { requestId?: string; realtimeHub?: RealtimeHub; limit?: number }
25
+ ) {
26
+ if (commentDispatchTracker.isShuttingDown()) {
27
+ return;
28
+ }
29
+ return commentDispatchTracker.track(runIssueCommentDispatchSweepInternal(db, companyId, options));
30
+ }
31
+
32
+ async function runIssueCommentDispatchSweepInternal(
33
+ db: BopoDb,
34
+ companyId: string,
35
+ options?: { requestId?: string; realtimeHub?: RealtimeHub; limit?: number }
19
36
  ) {
20
37
  const rows = await db
21
38
  .select({
@@ -34,6 +51,9 @@ export async function runIssueCommentDispatchSweep(
34
51
  .limit(options?.limit ?? COMMENT_DISPATCH_SWEEP_LIMIT);
35
52
 
36
53
  for (const row of rows) {
54
+ if (commentDispatchTracker.isShuttingDown()) {
55
+ return;
56
+ }
37
57
  const recipients = parseIssueCommentRecipients(row.recipientsJson);
38
58
  if (!recipients.some((recipient) => recipient.deliveryStatus === "pending")) {
39
59
  continue;
@@ -60,11 +80,15 @@ export function triggerIssueCommentDispatchWorker(
60
80
  companyId: string,
61
81
  options?: { requestId?: string; realtimeHub?: RealtimeHub; limit?: number }
62
82
  ) {
63
- if (activeCompanyDispatchRuns.has(companyId)) {
83
+ if (commentDispatchTracker.isShuttingDown() || activeCompanyDispatchRuns.has(companyId)) {
64
84
  return;
65
85
  }
66
86
  activeCompanyDispatchRuns.add(companyId);
67
87
  queueMicrotask(() => {
88
+ if (commentDispatchTracker.isShuttingDown()) {
89
+ activeCompanyDispatchRuns.delete(companyId);
90
+ return;
91
+ }
68
92
  void runIssueCommentDispatchSweep(db, companyId, options)
69
93
  .catch((error) => {
70
94
  // eslint-disable-next-line no-console
@@ -156,3 +180,16 @@ async function dispatchCommentRecipients(
156
180
  return dispatchedRecipients;
157
181
  }
158
182
 
183
+ export function beginIssueCommentDispatchShutdown() {
184
+ commentDispatchTracker.beginShutdown();
185
+ }
186
+
187
+ export async function waitForIssueCommentDispatchDrain() {
188
+ await commentDispatchTracker.drain();
189
+ }
190
+
191
+ export function resetIssueCommentDispatchShutdownForTests() {
192
+ activeCompanyDispatchRuns.clear();
193
+ commentDispatchTracker.resetForTests();
194
+ }
195
+
@@ -1,4 +1,3 @@
1
- import { and, eq } from "drizzle-orm";
2
1
  import { mkdir } from "node:fs/promises";
3
2
  import { z } from "zod";
4
3
  import {
@@ -10,6 +9,7 @@ import {
10
9
  } from "bopodev-contracts";
11
10
  import type { BopoDb } from "bopodev-db";
12
11
  import {
12
+ and,
13
13
  approvalRequests,
14
14
  agents,
15
15
  appendAuditEvent,
@@ -26,6 +26,7 @@ import {
26
26
  listProjectWorkspaces,
27
27
  listProjects,
28
28
  projects,
29
+ eq,
29
30
  updateProjectWorkspace,
30
31
  updatePluginConfig
31
32
  } from "bopodev-db";
@@ -777,6 +778,7 @@ function buildAgentStartupTaskBody(companyId: string, agentId: string) {
777
778
  `Create your operating baseline before starting feature delivery work.`,
778
779
  "",
779
780
  `1. Create your operating folder at \`${agentOperatingFolder}/\`.`,
781
+ " During heartbeats, prefer the absolute path in `$BOPODEV_AGENT_OPERATING_DIR` (set by the runtime) so files land under your agent folder even when the shell cwd is a project workspace.",
780
782
  "2. Author these files with your own responsibilities and working style:",
781
783
  ` - \`${agentOperatingFolder}/AGENTS.md\``,
782
784
  ` - \`${agentOperatingFolder}/HEARTBEAT.md\``,
@@ -1,6 +1,7 @@
1
- import { and, eq } from "drizzle-orm";
2
1
  import {
2
+ and,
3
3
  cancelHeartbeatJob,
4
+ eq,
4
5
  getHeartbeatRun,
5
6
  claimNextHeartbeatJob,
6
7
  enqueueHeartbeatJob,
@@ -11,6 +12,7 @@ import {
11
12
  updateIssueCommentRecipients,
12
13
  type BopoDb
13
14
  } from "bopodev-db";
15
+ import { createDrainableWorkTracker } from "../lib/drainable-work";
14
16
  import { parseIssueCommentRecipients } from "../lib/comment-recipients";
15
17
  import type { RealtimeHub } from "../realtime/hub";
16
18
  import { runHeartbeatForAgent } from "./heartbeat-service";
@@ -30,6 +32,7 @@ type QueueJobPayload = {
30
32
  };
31
33
 
32
34
  const activeCompanyQueueWorkers = new Set<string>();
35
+ const queueWorkTracker = createDrainableWorkTracker();
33
36
 
34
37
  export async function enqueueHeartbeatQueueJob(
35
38
  db: BopoDb,
@@ -61,11 +64,15 @@ export function triggerHeartbeatQueueWorker(
61
64
  companyId: string,
62
65
  options?: { requestId?: string; realtimeHub?: RealtimeHub; maxJobsPerSweep?: number }
63
66
  ) {
64
- if (activeCompanyQueueWorkers.has(companyId)) {
67
+ if (queueWorkTracker.isShuttingDown() || activeCompanyQueueWorkers.has(companyId)) {
65
68
  return;
66
69
  }
67
70
  activeCompanyQueueWorkers.add(companyId);
68
71
  queueMicrotask(() => {
72
+ if (queueWorkTracker.isShuttingDown()) {
73
+ activeCompanyQueueWorkers.delete(companyId);
74
+ return;
75
+ }
69
76
  void runHeartbeatQueueSweep(db, companyId, options)
70
77
  .catch((error) => {
71
78
  // eslint-disable-next-line no-console
@@ -81,10 +88,21 @@ export async function runHeartbeatQueueSweep(
81
88
  db: BopoDb,
82
89
  companyId: string,
83
90
  options?: { requestId?: string; realtimeHub?: RealtimeHub; maxJobsPerSweep?: number }
91
+ ) {
92
+ if (queueWorkTracker.isShuttingDown()) {
93
+ return { processed: 0 };
94
+ }
95
+ return queueWorkTracker.track(runHeartbeatQueueSweepInternal(db, companyId, options));
96
+ }
97
+
98
+ async function runHeartbeatQueueSweepInternal(
99
+ db: BopoDb,
100
+ companyId: string,
101
+ options?: { requestId?: string; realtimeHub?: RealtimeHub; maxJobsPerSweep?: number }
84
102
  ) {
85
103
  const maxJobs = Math.max(1, Math.min(options?.maxJobsPerSweep ?? 50, 500));
86
104
  let processed = 0;
87
- while (processed < maxJobs) {
105
+ while (processed < maxJobs && !queueWorkTracker.isShuttingDown()) {
88
106
  const job = await claimNextHeartbeatJob(db, companyId);
89
107
  if (!job) {
90
108
  break;
@@ -117,6 +135,19 @@ export async function runHeartbeatQueueSweep(
117
135
  return { processed };
118
136
  }
119
137
 
138
+ export function beginHeartbeatQueueShutdown() {
139
+ queueWorkTracker.beginShutdown();
140
+ }
141
+
142
+ export async function waitForHeartbeatQueueDrain() {
143
+ await queueWorkTracker.drain();
144
+ }
145
+
146
+ export function resetHeartbeatQueueShutdownForTests() {
147
+ activeCompanyQueueWorkers.clear();
148
+ queueWorkTracker.resetForTests();
149
+ }
150
+
120
151
  async function processHeartbeatQueueJob(
121
152
  db: BopoDb,
122
153
  input: {