bopodev-api 0.1.27 → 0.1.29

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.
Files changed (50) hide show
  1. package/package.json +4 -4
  2. package/src/app.ts +17 -70
  3. package/src/lib/drainable-work.ts +36 -0
  4. package/src/lib/run-artifact-paths.ts +8 -0
  5. package/src/lib/workspace-policy.ts +1 -2
  6. package/src/middleware/cors-config.ts +36 -0
  7. package/src/middleware/request-actor.ts +10 -16
  8. package/src/middleware/request-id.ts +9 -0
  9. package/src/middleware/request-logging.ts +24 -0
  10. package/src/realtime/office-space.ts +3 -1
  11. package/src/routes/agents.ts +3 -9
  12. package/src/routes/companies.ts +18 -1
  13. package/src/routes/goals.ts +7 -13
  14. package/src/routes/governance.ts +2 -5
  15. package/src/routes/heartbeats.ts +8 -27
  16. package/src/routes/issues.ts +66 -121
  17. package/src/routes/observability.ts +6 -1
  18. package/src/routes/plugins.ts +5 -17
  19. package/src/routes/projects.ts +7 -25
  20. package/src/routes/templates.ts +6 -21
  21. package/src/scripts/onboard-seed.ts +5 -7
  22. package/src/server.ts +35 -276
  23. package/src/services/attention-service.ts +4 -1
  24. package/src/services/budget-service.ts +1 -2
  25. package/src/services/comment-recipient-dispatch-service.ts +39 -2
  26. package/src/services/company-export-service.ts +63 -0
  27. package/src/services/governance-service.ts +6 -2
  28. package/src/services/heartbeat-queue-service.ts +34 -3
  29. package/src/services/heartbeat-service/active-runs.ts +15 -0
  30. package/src/services/heartbeat-service/budget-override.ts +46 -0
  31. package/src/services/heartbeat-service/claims.ts +61 -0
  32. package/src/services/heartbeat-service/cron.ts +58 -0
  33. package/src/services/heartbeat-service/heartbeat-realtime.ts +28 -0
  34. package/src/services/heartbeat-service/heartbeat-run-summary-text.ts +53 -0
  35. package/src/services/{heartbeat-service.ts → heartbeat-service/heartbeat-run.ts} +217 -635
  36. package/src/services/heartbeat-service/index.ts +5 -0
  37. package/src/services/heartbeat-service/stop.ts +90 -0
  38. package/src/services/heartbeat-service/sweep.ts +145 -0
  39. package/src/services/heartbeat-service/types.ts +65 -0
  40. package/src/services/memory-file-service.ts +10 -2
  41. package/src/shutdown/graceful-shutdown.ts +77 -0
  42. package/src/startup/database.ts +41 -0
  43. package/src/startup/deployment-validation.ts +37 -0
  44. package/src/startup/env.ts +17 -0
  45. package/src/startup/runtime-health.ts +128 -0
  46. package/src/startup/scheduler-config.ts +39 -0
  47. package/src/types/express.d.ts +13 -0
  48. package/src/types/request-actor.ts +6 -0
  49. package/src/validation/issue-routes.ts +79 -0
  50. package/src/worker/scheduler.ts +20 -4
package/src/server.ts CHANGED
@@ -1,11 +1,5 @@
1
1
  import { createServer } from "node:http";
2
- import { dirname, resolve } from "node:path";
3
- import { fileURLToPath } from "node:url";
4
- import { sql } from "drizzle-orm";
5
- import { config as loadDotenv } from "dotenv";
6
- import { bootstrapDatabase, listCompanies, resolveDefaultDbPath } from "bopodev-db";
7
- import { checkRuntimeCommandHealth } from "bopodev-agent-sdk";
8
- import type { RuntimeCommandHealth } from "bopodev-agent-sdk";
2
+ import { listCompanies } from "bopodev-db";
9
3
  import { createApp } from "./app";
10
4
  import { loadGovernanceRealtimeSnapshot } from "./realtime/governance";
11
5
  import { loadOfficeSpaceRealtimeSnapshot } from "./realtime/office-space";
@@ -13,7 +7,6 @@ import { loadHeartbeatRunsRealtimeSnapshot } from "./realtime/heartbeat-runs";
13
7
  import { loadAttentionRealtimeSnapshot } from "./realtime/attention";
14
8
  import { attachRealtimeHub } from "./realtime/hub";
15
9
  import {
16
- isAuthenticatedMode,
17
10
  resolveAllowedHostnames,
18
11
  resolveAllowedOrigins,
19
12
  resolveDeploymentMode,
@@ -22,6 +15,17 @@ import {
22
15
  import { ensureBuiltinPluginsRegistered } from "./services/plugin-runtime";
23
16
  import { ensureBuiltinTemplatesRegistered } from "./services/template-catalog";
24
17
  import { createHeartbeatScheduler } from "./worker/scheduler";
18
+ import { bootstrapDatabaseWithStartupLogging } from "./startup/database";
19
+ import { validateDeploymentConfiguration } from "./startup/deployment-validation";
20
+ import { loadApiEnv, normalizeOptionalDbPath } from "./startup/env";
21
+ import {
22
+ buildGetRuntimeHealth,
23
+ hasCodexAgentsConfigured,
24
+ hasOpenCodeAgentsConfigured,
25
+ runStartupRuntimePreflights
26
+ } from "./startup/runtime-health";
27
+ import { resolveSchedulerCompanyId, shouldStartScheduler } from "./startup/scheduler-config";
28
+ import { attachGracefulShutdownHandlers } from "./shutdown/graceful-shutdown";
25
29
 
26
30
  loadApiEnv();
27
31
 
@@ -33,28 +37,7 @@ async function main() {
33
37
  validateDeploymentConfiguration(deploymentMode, allowedOrigins, allowedHostnames, publicBaseUrl);
34
38
  const dbPath = normalizeOptionalDbPath(process.env.BOPO_DB_PATH);
35
39
  const port = Number(process.env.PORT ?? 4020);
36
- const effectiveDbPath = dbPath ?? resolveDefaultDbPath();
37
- let db: Awaited<ReturnType<typeof bootstrapDatabase>>["db"];
38
- let dbClient: Awaited<ReturnType<typeof bootstrapDatabase>>["client"];
39
- try {
40
- const boot = await bootstrapDatabase(dbPath);
41
- db = boot.db;
42
- dbClient = boot.client;
43
- } catch (error) {
44
- if (isProbablyPgliteWasmAbort(error)) {
45
- // eslint-disable-next-line no-console
46
- console.error(
47
- "[startup] PGlite (embedded Postgres) failed during database bootstrap. This is unrelated to Codex or heartbeat prompt settings."
48
- );
49
- // eslint-disable-next-line no-console
50
- console.error(`[startup] Data path in use: ${effectiveDbPath}`);
51
- // eslint-disable-next-line no-console
52
- console.error(
53
- "[startup] Recovery: stop all API/node processes using this DB, back up the path above, delete the file/dir, then restart (schema will be recreated). Or set BOPO_DB_PATH to a fresh path. See docs/operations/troubleshooting.md (PGlite)."
54
- );
55
- }
56
- throw error;
57
- }
40
+ const { db, client: dbClient } = await bootstrapDatabaseWithStartupLogging(dbPath);
58
41
  const existingCompanies = await listCompanies(db);
59
42
  await ensureBuiltinPluginsRegistered(
60
43
  db,
@@ -74,54 +57,20 @@ async function main() {
74
57
  const openCodeHealthRequired =
75
58
  !skipOpenCodePreflight &&
76
59
  (process.env.BOPO_REQUIRE_OPENCODE_HEALTH === "1" || (await hasOpenCodeAgentsConfigured(db)));
77
- const getRuntimeHealth = async () => {
78
- const codex = codexHealthRequired
79
- ? await checkRuntimeCommandHealth(codexCommand, {
80
- timeoutMs: 5_000
81
- })
82
- : {
83
- command: codexCommand,
84
- available: skipCodexPreflight ? false : true,
85
- exitCode: null,
86
- elapsedMs: 0,
87
- error: skipCodexPreflight
88
- ? "Skipped by configuration: BOPO_SKIP_CODEX_PREFLIGHT=1."
89
- : "Skipped: no Codex agents configured."
90
- };
91
- const opencode = openCodeHealthRequired
92
- ? await checkRuntimeCommandHealth(openCodeCommand, {
93
- timeoutMs: 5_000
94
- })
95
- : {
96
- command: openCodeCommand,
97
- available: skipOpenCodePreflight ? false : true,
98
- exitCode: null,
99
- elapsedMs: 0,
100
- error: skipOpenCodePreflight
101
- ? "Skipped by configuration: BOPO_SKIP_OPENCODE_PREFLIGHT=1."
102
- : "Skipped: no OpenCode agents configured."
103
- };
104
- return {
105
- codex,
106
- opencode
107
- };
108
- };
109
- if (codexHealthRequired) {
110
- const startupCodexHealth = await checkRuntimeCommandHealth(codexCommand, {
111
- timeoutMs: 5_000
112
- });
113
- if (!startupCodexHealth.available) {
114
- emitCodexPreflightWarning(startupCodexHealth);
115
- }
116
- }
117
- if (openCodeHealthRequired) {
118
- const startupOpenCodeHealth = await checkRuntimeCommandHealth(openCodeCommand, {
119
- timeoutMs: 5_000
120
- });
121
- if (!startupOpenCodeHealth.available) {
122
- emitOpenCodePreflightWarning(startupOpenCodeHealth);
123
- }
124
- }
60
+ const getRuntimeHealth = buildGetRuntimeHealth({
61
+ codexCommand,
62
+ openCodeCommand,
63
+ skipCodexPreflight,
64
+ skipOpenCodePreflight,
65
+ codexHealthRequired,
66
+ openCodeHealthRequired
67
+ });
68
+ await runStartupRuntimePreflights({
69
+ codexHealthRequired,
70
+ openCodeHealthRequired,
71
+ codexCommand,
72
+ openCodeCommand
73
+ });
125
74
 
126
75
  const server = createServer();
127
76
  const realtimeHub = attachRealtimeHub(server, {
@@ -141,210 +90,20 @@ async function main() {
141
90
 
142
91
  const defaultCompanyId = process.env.BOPO_DEFAULT_COMPANY_ID;
143
92
  const schedulerCompanyId = await resolveSchedulerCompanyId(db, defaultCompanyId ?? null);
144
- let stopScheduler: (() => void) | undefined;
93
+ let scheduler: ReturnType<typeof createHeartbeatScheduler> | undefined;
145
94
  if (schedulerCompanyId && shouldStartScheduler()) {
146
- stopScheduler = createHeartbeatScheduler(db, schedulerCompanyId, realtimeHub);
95
+ scheduler = createHeartbeatScheduler(db, schedulerCompanyId, realtimeHub);
147
96
  } else if (schedulerCompanyId) {
148
97
  // eslint-disable-next-line no-console
149
98
  console.log("[startup] Scheduler disabled for this instance (BOPO_SCHEDULER_ROLE is follower/off).");
150
99
  }
151
100
 
152
- let shutdownInFlight: Promise<void> | null = null;
153
- function shutdown(signal: string) {
154
- shutdownInFlight ??= (async () => {
155
- // eslint-disable-next-line no-console
156
- console.log(`[shutdown] ${signal} — closing realtime, HTTP server, and embedded DB…`);
157
- stopScheduler?.();
158
- try {
159
- await realtimeHub.close();
160
- } catch (closeError) {
161
- // eslint-disable-next-line no-console
162
- console.error("[shutdown] realtime hub close error", closeError);
163
- }
164
- await new Promise<void>((resolve, reject) => {
165
- server.close((err) => {
166
- if (err) {
167
- reject(err);
168
- return;
169
- }
170
- resolve();
171
- });
172
- });
173
- try {
174
- await closePgliteClient(dbClient);
175
- } catch (closeDbError) {
176
- // eslint-disable-next-line no-console
177
- console.error("[shutdown] PGlite close error", closeDbError);
178
- }
179
- // eslint-disable-next-line no-console
180
- console.log("[shutdown] clean exit");
181
- process.exit(0);
182
- })().catch((error) => {
183
- // eslint-disable-next-line no-console
184
- console.error("[shutdown] failed", error);
185
- process.exit(1);
186
- });
187
- return shutdownInFlight;
188
- }
189
-
190
- process.once("SIGINT", () => void shutdown("SIGINT"));
191
- process.once("SIGTERM", () => void shutdown("SIGTERM"));
101
+ attachGracefulShutdownHandlers({
102
+ server,
103
+ realtimeHub,
104
+ dbClient,
105
+ scheduler
106
+ });
192
107
  }
193
108
 
194
109
  void main();
195
-
196
- async function closePgliteClient(client: unknown) {
197
- if (!client || typeof client !== "object") {
198
- return;
199
- }
200
- const closeFn = (client as { close?: unknown }).close;
201
- if (typeof closeFn !== "function") {
202
- return;
203
- }
204
- await (closeFn as () => Promise<void>)();
205
- }
206
-
207
- async function hasCodexAgentsConfigured(db: Awaited<ReturnType<typeof bootstrapDatabase>>["db"]) {
208
- const result = await db.execute(sql`
209
- SELECT id
210
- FROM agents
211
- WHERE provider_type = 'codex'
212
- LIMIT 1
213
- `);
214
- return (result.rows ?? []).length > 0;
215
- }
216
-
217
- async function hasOpenCodeAgentsConfigured(db: Awaited<ReturnType<typeof bootstrapDatabase>>["db"]) {
218
- const result = await db.execute(sql`
219
- SELECT id
220
- FROM agents
221
- WHERE provider_type = 'opencode'
222
- LIMIT 1
223
- `);
224
- return (result.rows ?? []).length > 0;
225
- }
226
-
227
- async function resolveSchedulerCompanyId(
228
- db: Awaited<ReturnType<typeof bootstrapDatabase>>["db"],
229
- configuredCompanyId: string | null
230
- ) {
231
- if (configuredCompanyId) {
232
- const configured = await db.execute(sql`
233
- SELECT id
234
- FROM companies
235
- WHERE id = ${configuredCompanyId}
236
- LIMIT 1
237
- `);
238
- if ((configured.rows ?? []).length > 0) {
239
- return configuredCompanyId;
240
- }
241
- // eslint-disable-next-line no-console
242
- console.warn(`[startup] BOPO_DEFAULT_COMPANY_ID='${configuredCompanyId}' was not found; using first available company.`);
243
- }
244
-
245
- const fallback = await db.execute(sql`
246
- SELECT id
247
- FROM companies
248
- ORDER BY created_at ASC
249
- LIMIT 1
250
- `);
251
- const id = fallback.rows?.[0]?.id;
252
- return typeof id === "string" && id.length > 0 ? id : null;
253
- }
254
-
255
- function emitCodexPreflightWarning(health: RuntimeCommandHealth) {
256
- const red = process.stderr.isTTY ? "\x1b[31m" : "";
257
- const yellow = process.stderr.isTTY ? "\x1b[33m" : "";
258
- const reset = process.stderr.isTTY ? "\x1b[0m" : "";
259
- const symbol = `${red}✖${reset}`;
260
- process.stderr.write(
261
- `${symbol} ${yellow}Codex preflight failed${reset}: command '${health.command}' is unavailable.\n`
262
- );
263
- process.stderr.write(` Install Codex CLI or set BOPO_SKIP_CODEX_PREFLIGHT=1 for local dev.\n`);
264
- if (process.env.BOPO_VERBOSE_STARTUP_WARNINGS === "1") {
265
- process.stderr.write(` Details: ${JSON.stringify(health)}\n`);
266
- }
267
- }
268
-
269
- function emitOpenCodePreflightWarning(health: RuntimeCommandHealth) {
270
- const red = process.stderr.isTTY ? "\x1b[31m" : "";
271
- const yellow = process.stderr.isTTY ? "\x1b[33m" : "";
272
- const reset = process.stderr.isTTY ? "\x1b[0m" : "";
273
- const symbol = `${red}✖${reset}`;
274
- process.stderr.write(
275
- `${symbol} ${yellow}OpenCode preflight failed${reset}: command '${health.command}' is unavailable.\n`
276
- );
277
- process.stderr.write(` Install OpenCode CLI or set BOPO_SKIP_OPENCODE_PREFLIGHT=1 for local dev.\n`);
278
- if (process.env.BOPO_VERBOSE_STARTUP_WARNINGS === "1") {
279
- process.stderr.write(` Details: ${JSON.stringify(health)}\n`);
280
- }
281
- }
282
-
283
- function validateDeploymentConfiguration(
284
- deploymentMode: ReturnType<typeof resolveDeploymentMode>,
285
- allowedOrigins: string[],
286
- allowedHostnames: string[],
287
- publicBaseUrl: URL | null
288
- ) {
289
- if (deploymentMode === "authenticated_public" && !publicBaseUrl) {
290
- throw new Error("BOPO_PUBLIC_BASE_URL is required in authenticated_public mode.");
291
- }
292
- if (isAuthenticatedMode(deploymentMode) && process.env.BOPO_AUTH_TOKEN_SECRET?.trim() === "") {
293
- throw new Error("BOPO_AUTH_TOKEN_SECRET must not be empty when set.");
294
- }
295
- if (isAuthenticatedMode(deploymentMode) && !process.env.BOPO_AUTH_TOKEN_SECRET?.trim()) {
296
- // eslint-disable-next-line no-console
297
- console.warn(
298
- "[startup] BOPO_AUTH_TOKEN_SECRET is not set. Authenticated modes will require BOPO_TRUST_ACTOR_HEADERS=1 behind a trusted proxy."
299
- );
300
- }
301
- if (isAuthenticatedMode(deploymentMode) && process.env.BOPO_TRUST_ACTOR_HEADERS !== "1" && !process.env.BOPO_AUTH_TOKEN_SECRET?.trim()) {
302
- throw new Error(
303
- "Authenticated mode requires either BOPO_AUTH_TOKEN_SECRET (token identity) or BOPO_TRUST_ACTOR_HEADERS=1 (trusted proxy headers)."
304
- );
305
- }
306
- if (isAuthenticatedMode(deploymentMode) && process.env.BOPO_ALLOW_LOCAL_BOARD_FALLBACK === "1") {
307
- throw new Error("BOPO_ALLOW_LOCAL_BOARD_FALLBACK cannot be enabled in authenticated modes.");
308
- }
309
- // eslint-disable-next-line no-console
310
- console.log(
311
- `[startup] Deployment config: mode=${deploymentMode} origins=${allowedOrigins.join(",")} hosts=${allowedHostnames.join(",")}`
312
- );
313
- }
314
-
315
- function shouldStartScheduler() {
316
- const rawRole = (process.env.BOPO_SCHEDULER_ROLE ?? "auto").trim().toLowerCase();
317
- if (rawRole === "off" || rawRole === "follower") {
318
- return false;
319
- }
320
- if (rawRole === "leader" || rawRole === "auto") {
321
- return true;
322
- }
323
- throw new Error(`Invalid BOPO_SCHEDULER_ROLE '${rawRole}'. Expected one of: auto, leader, follower, off.`);
324
- }
325
-
326
- function loadApiEnv() {
327
- const sourceDir = dirname(fileURLToPath(import.meta.url));
328
- const repoRoot = resolve(sourceDir, "../../../");
329
- const candidates = [resolve(repoRoot, ".env.local"), resolve(repoRoot, ".env")];
330
- for (const path of candidates) {
331
- loadDotenv({ path, override: false, quiet: true });
332
- }
333
- }
334
-
335
- function normalizeOptionalDbPath(value: string | undefined) {
336
- const normalized = value?.trim();
337
- return normalized && normalized.length > 0 ? normalized : undefined;
338
- }
339
-
340
- function isProbablyPgliteWasmAbort(error: unknown): boolean {
341
- const message = error instanceof Error ? error.message : String(error);
342
- const cause = error instanceof Error ? error.cause : undefined;
343
- const causeMessage = cause instanceof Error ? cause.message : String(cause ?? "");
344
- return (
345
- message.includes("Aborted") ||
346
- causeMessage.includes("Aborted") ||
347
- message.includes("pglite") ||
348
- causeMessage.includes("RuntimeError")
349
- );
350
- }
@@ -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,
@@ -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
+
@@ -0,0 +1,63 @@
1
+ import type { BopoDb } from "bopodev-db";
2
+ import { getCompany, listAgents, listGoals, listIssues, listProjects } from "bopodev-db";
3
+
4
+ function serializeValue(value: unknown): unknown {
5
+ if (value instanceof Date) {
6
+ return value.toISOString();
7
+ }
8
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
9
+ return serializeRow(value as Record<string, unknown>);
10
+ }
11
+ if (Array.isArray(value)) {
12
+ return value.map((entry) => serializeValue(entry));
13
+ }
14
+ return value;
15
+ }
16
+
17
+ function serializeRow<T extends Record<string, unknown>>(row: T): Record<string, unknown> {
18
+ const out: Record<string, unknown> = {};
19
+ for (const [key, val] of Object.entries(row)) {
20
+ out[key] = serializeValue(val);
21
+ }
22
+ return out;
23
+ }
24
+
25
+ /** Strip agent fields that commonly hold secrets or session state for shareable exports. */
26
+ function scrubAgentRow(agent: Record<string, unknown>) {
27
+ const base = serializeRow(agent);
28
+ return {
29
+ ...base,
30
+ runtimeEnvJson: "{}",
31
+ bootstrapPrompt: null,
32
+ stateBlob: "{}",
33
+ runtimeCommand: null,
34
+ runtimeArgsJson: "[]"
35
+ };
36
+ }
37
+
38
+ /**
39
+ * Portable company snapshot for backup, templates, or handoff (secrets and agent session state redacted).
40
+ */
41
+ export async function buildCompanyPortabilityExport(db: BopoDb, companyId: string) {
42
+ const company = await getCompany(db, companyId);
43
+ if (!company) {
44
+ return null;
45
+ }
46
+
47
+ const [projects, goals, issues, agents] = await Promise.all([
48
+ listProjects(db, companyId),
49
+ listGoals(db, companyId),
50
+ listIssues(db, companyId),
51
+ listAgents(db, companyId)
52
+ ]);
53
+
54
+ return {
55
+ exportedAt: new Date().toISOString(),
56
+ formatVersion: 1,
57
+ company: serializeRow(company as unknown as Record<string, unknown>),
58
+ projects: projects.map((p) => serializeRow(p as unknown as Record<string, unknown>)),
59
+ goals: goals.map((g) => serializeRow(g as unknown as Record<string, unknown>)),
60
+ issues: issues.map((issue) => serializeRow(issue as unknown as Record<string, unknown>)),
61
+ agents: agents.map((agent) => scrubAgentRow(agent as unknown as Record<string, unknown>))
62
+ };
63
+ }
@@ -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";
@@ -93,6 +94,7 @@ const hireAgentPayloadSchema = AgentCreateRequestSchema.extend({
93
94
  const activateGoalPayloadSchema = z.object({
94
95
  projectId: z.string().optional(),
95
96
  parentGoalId: z.string().optional(),
97
+ ownerAgentId: z.string().optional(),
96
98
  level: z.enum(["company", "project", "agent"]),
97
99
  title: z.string().min(1),
98
100
  description: z.string().optional()
@@ -371,6 +373,7 @@ async function applyApprovalAction(db: BopoDb, companyId: string, action: string
371
373
  companyId,
372
374
  projectId: parsed.data.projectId,
373
375
  parentGoalId: parsed.data.parentGoalId,
376
+ ownerAgentId: parsed.data.ownerAgentId,
374
377
  level: parsed.data.level,
375
378
  title: parsed.data.title,
376
379
  description: parsed.data.description
@@ -783,7 +786,8 @@ function buildAgentStartupTaskBody(companyId: string, agentId: string) {
783
786
  ` - \`${agentOperatingFolder}/HEARTBEAT.md\``,
784
787
  ` - \`${agentOperatingFolder}/SOUL.md\``,
785
788
  ` - \`${agentOperatingFolder}/TOOLS.md\``,
786
- `3. Update your own agent runtime config via \`PUT /agents/:agentId\` and set \`runtimeConfig.bootstrapPrompt\` to reference \`${agentOperatingFolder}/AGENTS.md\` as your primary guide.`,
789
+ "3. Each heartbeat already includes your operating directory via `$BOPODEV_AGENT_OPERATING_DIR` and directs you to AGENTS.md and related files there when they exist.",
790
+ " You do not need to save file paths into `bootstrapPrompt` for operating docs—use `PUT /agents/:agentId` `bootstrapPrompt` only for optional extra standing instructions.",
787
791
  "4. Post an issue comment summarizing completed setup artifacts.",
788
792
  "",
789
793
  "Safety checks:",
@@ -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: {
@@ -0,0 +1,15 @@
1
+ import type { ActiveHeartbeatRun } from "./types";
2
+
3
+ const activeHeartbeatRuns = new Map<string, ActiveHeartbeatRun>();
4
+
5
+ export function registerActiveHeartbeatRun(runId: string, run: ActiveHeartbeatRun) {
6
+ activeHeartbeatRuns.set(runId, run);
7
+ }
8
+
9
+ export function unregisterActiveHeartbeatRun(runId: string) {
10
+ activeHeartbeatRuns.delete(runId);
11
+ }
12
+
13
+ export function getActiveHeartbeatRun(runId: string) {
14
+ return activeHeartbeatRuns.get(runId);
15
+ }