bopodev-api 0.1.28 → 0.1.30

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 (46) hide show
  1. package/package.json +4 -4
  2. package/src/app.ts +17 -69
  3. package/src/lib/ceo-bootstrap-prompt.ts +1 -0
  4. package/src/lib/run-artifact-paths.ts +8 -0
  5. package/src/middleware/cors-config.ts +36 -0
  6. package/src/middleware/request-actor.ts +10 -16
  7. package/src/middleware/request-id.ts +9 -0
  8. package/src/middleware/request-logging.ts +24 -0
  9. package/src/realtime/office-space.ts +1 -0
  10. package/src/routes/agents.ts +90 -46
  11. package/src/routes/companies.ts +20 -1
  12. package/src/routes/goals.ts +7 -13
  13. package/src/routes/governance.ts +2 -5
  14. package/src/routes/heartbeats.ts +7 -25
  15. package/src/routes/issues.ts +65 -120
  16. package/src/routes/observability.ts +6 -1
  17. package/src/routes/plugins.ts +5 -17
  18. package/src/routes/projects.ts +7 -25
  19. package/src/routes/templates.ts +6 -21
  20. package/src/scripts/onboard-seed.ts +18 -8
  21. package/src/server.ts +33 -292
  22. package/src/services/company-export-service.ts +63 -0
  23. package/src/services/governance-service.ts +10 -14
  24. package/src/services/heartbeat-service/active-runs.ts +15 -0
  25. package/src/services/heartbeat-service/budget-override.ts +46 -0
  26. package/src/services/heartbeat-service/claims.ts +61 -0
  27. package/src/services/heartbeat-service/cron.ts +58 -0
  28. package/src/services/heartbeat-service/heartbeat-realtime.ts +28 -0
  29. package/src/services/heartbeat-service/heartbeat-run-summary-text.ts +53 -0
  30. package/src/services/{heartbeat-service.ts → heartbeat-service/heartbeat-run.ts} +201 -634
  31. package/src/services/heartbeat-service/index.ts +5 -0
  32. package/src/services/heartbeat-service/stop.ts +90 -0
  33. package/src/services/heartbeat-service/sweep.ts +145 -0
  34. package/src/services/heartbeat-service/types.ts +66 -0
  35. package/src/services/memory-file-service.ts +10 -2
  36. package/src/services/template-apply-service.ts +6 -0
  37. package/src/services/template-catalog.ts +37 -3
  38. package/src/shutdown/graceful-shutdown.ts +77 -0
  39. package/src/startup/database.ts +41 -0
  40. package/src/startup/deployment-validation.ts +37 -0
  41. package/src/startup/env.ts +17 -0
  42. package/src/startup/runtime-health.ts +128 -0
  43. package/src/startup/scheduler-config.ts +39 -0
  44. package/src/types/express.d.ts +13 -0
  45. package/src/types/request-actor.ts +6 -0
  46. package/src/validation/issue-routes.ts +80 -0
@@ -50,7 +50,16 @@ const DEFAULT_COMPANY_ID_ENV = "BOPO_DEFAULT_COMPANY_ID";
50
50
  const DEFAULT_AGENT_PROVIDER_ENV = "BOPO_DEFAULT_AGENT_PROVIDER";
51
51
  const DEFAULT_AGENT_MODEL_ENV = "BOPO_DEFAULT_AGENT_MODEL";
52
52
  const DEFAULT_TEMPLATE_ENV = "BOPO_DEFAULT_TEMPLATE_ID";
53
- type AgentProvider = "codex" | "claude_code" | "cursor" | "gemini_cli" | "opencode" | "openai_api" | "anthropic_api" | "shell";
53
+ type AgentProvider =
54
+ | "codex"
55
+ | "claude_code"
56
+ | "cursor"
57
+ | "gemini_cli"
58
+ | "opencode"
59
+ | "openai_api"
60
+ | "anthropic_api"
61
+ | "openclaw_gateway"
62
+ | "shell";
54
63
  const CEO_BOOTSTRAP_SUMMARY = "ceo bootstrap heartbeat";
55
64
  const STARTUP_PROJECT_NAME = "Leadership Setup";
56
65
  const CEO_STARTUP_TASK_TITLE = "Set up CEO operating files and hire founding engineer";
@@ -130,6 +139,8 @@ export async function ensureOnboardingSeed(input: {
130
139
  role: "CEO",
131
140
  roleKey: "ceo",
132
141
  title: "CEO",
142
+ capabilities:
143
+ "Company leadership: priorities, hiring, governance, and aligning agents to mission and budget.",
133
144
  name: "CEO",
134
145
  providerType: agentProvider,
135
146
  heartbeatCron: "*/5 * * * *",
@@ -316,18 +327,16 @@ async function ensureCeoStartupTask(
316
327
  ` - \`${ceoOperatingFolder}/HEARTBEAT.md\``,
317
328
  ` - \`${ceoOperatingFolder}/SOUL.md\``,
318
329
  ` - \`${ceoOperatingFolder}/TOOLS.md\``,
319
- "3. Save your operating-file reference on your own agent record via `PUT /agents/:agentId`.",
320
- ` - Supported simple body: \`{ "bootstrapPrompt": "Primary operating reference: ${ceoOperatingFolder}/AGENTS.md ..." }\``,
321
- " - If using `runtimeConfig`, only `runtimeConfig.bootstrapPrompt` is supported there.",
322
- " - Prefer heredoc/stdin payloads (for example `curl --data-binary @- <<'JSON' ... JSON`) to avoid temp-file cleanup failures under runtime policy.",
323
- ` - If you must use payload files, store them in \`${ceoTmpFolder}/\` (or OS temp via \`mktemp\`) and avoid chaining cleanup commands into critical task flow.`,
330
+ "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.",
331
+ " You do not need to save file paths into `bootstrapPrompt` for operating docs—reserve `bootstrapPrompt` only for optional extra standing instructions.",
332
+ ` - Prefer heredoc/stdin payloads (for example \`curl --data-binary @- <<'JSON' ... JSON\`) when calling APIs; if you must use payload files, store them in \`${ceoTmpFolder}/\` (or OS temp via \`mktemp\`).`,
324
333
  "4. To inspect your own agent record, use `GET /agents` and filter by your agent id. Do not call `GET /agents/:agentId`.",
325
334
  " - `GET /agents` uses envelope shape `{ \"ok\": true, \"data\": [...] }`; treat any other shape as failure.",
326
- " - Deterministic filter: `jq -er --arg id \"$BOPODEV_AGENT_ID\" '.data | if type==\"array\" then . else error(\"invalid_agents_payload\") end | map(select((.id? // \"\") == $id))[0] | {id,name,role,bootstrapPrompt}'`",
335
+ " - Deterministic filter: `jq -er --arg id \"$BOPODEV_AGENT_ID\" '.data | if type==\"array\" then . else error(\"invalid_agents_payload\") end | map(select((.id? // \"\") == $id))[0] | {id,name,role}'`",
327
336
  "5. Heartbeat-assigned issues are already claimed for the current run. Do not call a checkout endpoint; update status with `PUT /issues/:issueId` only.",
328
337
  "6. After your operating files are active, submit a hire request for a Founding Engineer via `POST /agents` using supported fields:",
329
338
  " - `name`, `role`, `providerType`, `heartbeatCron`, `monthlyBudgetUsd`",
330
- " - optional `managerAgentId`, `bootstrapPrompt`, `runtimeConfig`, `canHireAgents`",
339
+ " - optional `managerAgentId`, `bootstrapPrompt` (extra standing instructions only), `runtimeConfig`, `canHireAgents`",
331
340
  " - `requestApproval: true` and `sourceIssueId`",
332
341
  "7. Do not use unsupported hire fields such as `adapterType`, `adapterConfig`, or `reportsTo`.",
333
342
  "",
@@ -401,6 +410,7 @@ function parseAgentProvider(value: unknown): AgentProvider | null {
401
410
  value === "opencode" ||
402
411
  value === "openai_api" ||
403
412
  value === "anthropic_api" ||
413
+ value === "openclaw_gateway" ||
404
414
  value === "shell"
405
415
  ) {
406
416
  return value;
package/src/server.ts CHANGED
@@ -1,10 +1,5 @@
1
1
  import { createServer } from "node:http";
2
- import { dirname, resolve } from "node:path";
3
- import { fileURLToPath } from "node:url";
4
- import { config as loadDotenv } from "dotenv";
5
- import { bootstrapDatabase, listCompanies, resolveDefaultDbPath, sql } from "bopodev-db";
6
- import { checkRuntimeCommandHealth } from "bopodev-agent-sdk";
7
- import type { RuntimeCommandHealth } from "bopodev-agent-sdk";
2
+ import { listCompanies } from "bopodev-db";
8
3
  import { createApp } from "./app";
9
4
  import { loadGovernanceRealtimeSnapshot } from "./realtime/governance";
10
5
  import { loadOfficeSpaceRealtimeSnapshot } from "./realtime/office-space";
@@ -12,7 +7,6 @@ import { loadHeartbeatRunsRealtimeSnapshot } from "./realtime/heartbeat-runs";
12
7
  import { loadAttentionRealtimeSnapshot } from "./realtime/attention";
13
8
  import { attachRealtimeHub } from "./realtime/hub";
14
9
  import {
15
- isAuthenticatedMode,
16
10
  resolveAllowedHostnames,
17
11
  resolveAllowedOrigins,
18
12
  resolveDeploymentMode,
@@ -20,9 +14,18 @@ import {
20
14
  } from "./security/deployment-mode";
21
15
  import { ensureBuiltinPluginsRegistered } from "./services/plugin-runtime";
22
16
  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";
25
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";
26
29
 
27
30
  loadApiEnv();
28
31
 
@@ -33,33 +36,8 @@ async function main() {
33
36
  const publicBaseUrl = resolvePublicBaseUrl();
34
37
  validateDeploymentConfiguration(deploymentMode, allowedOrigins, allowedHostnames, publicBaseUrl);
35
38
  const dbPath = normalizeOptionalDbPath(process.env.BOPO_DB_PATH);
36
- const usingExternalDatabase = Boolean(process.env.DATABASE_URL?.trim());
37
39
  const port = Number(process.env.PORT ?? 4020);
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
- }
40
+ const { db, client: dbClient } = await bootstrapDatabaseWithStartupLogging(dbPath);
63
41
  const existingCompanies = await listCompanies(db);
64
42
  await ensureBuiltinPluginsRegistered(
65
43
  db,
@@ -79,54 +57,20 @@ async function main() {
79
57
  const openCodeHealthRequired =
80
58
  !skipOpenCodePreflight &&
81
59
  (process.env.BOPO_REQUIRE_OPENCODE_HEALTH === "1" || (await hasOpenCodeAgentsConfigured(db)));
82
- const getRuntimeHealth = async () => {
83
- const codex = codexHealthRequired
84
- ? await checkRuntimeCommandHealth(codexCommand, {
85
- timeoutMs: 5_000
86
- })
87
- : {
88
- command: codexCommand,
89
- available: skipCodexPreflight ? false : true,
90
- exitCode: null,
91
- elapsedMs: 0,
92
- error: skipCodexPreflight
93
- ? "Skipped by configuration: BOPO_SKIP_CODEX_PREFLIGHT=1."
94
- : "Skipped: no Codex agents configured."
95
- };
96
- const opencode = openCodeHealthRequired
97
- ? await checkRuntimeCommandHealth(openCodeCommand, {
98
- timeoutMs: 5_000
99
- })
100
- : {
101
- command: openCodeCommand,
102
- available: skipOpenCodePreflight ? false : true,
103
- exitCode: null,
104
- elapsedMs: 0,
105
- error: skipOpenCodePreflight
106
- ? "Skipped by configuration: BOPO_SKIP_OPENCODE_PREFLIGHT=1."
107
- : "Skipped: no OpenCode agents configured."
108
- };
109
- return {
110
- codex,
111
- opencode
112
- };
113
- };
114
- if (codexHealthRequired) {
115
- const startupCodexHealth = await checkRuntimeCommandHealth(codexCommand, {
116
- timeoutMs: 5_000
117
- });
118
- if (!startupCodexHealth.available) {
119
- emitCodexPreflightWarning(startupCodexHealth);
120
- }
121
- }
122
- if (openCodeHealthRequired) {
123
- const startupOpenCodeHealth = await checkRuntimeCommandHealth(openCodeCommand, {
124
- timeoutMs: 5_000
125
- });
126
- if (!startupOpenCodeHealth.available) {
127
- emitOpenCodePreflightWarning(startupOpenCodeHealth);
128
- }
129
- }
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
+ });
130
74
 
131
75
  const server = createServer();
132
76
  const realtimeHub = attachRealtimeHub(server, {
@@ -154,215 +98,12 @@ async function main() {
154
98
  console.log("[startup] Scheduler disabled for this instance (BOPO_SCHEDULER_ROLE is follower/off).");
155
99
  }
156
100
 
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"));
101
+ attachGracefulShutdownHandlers({
102
+ server,
103
+ realtimeHub,
104
+ dbClient,
105
+ scheduler
106
+ });
209
107
  }
210
108
 
211
109
  void main();
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
-
224
- async function hasCodexAgentsConfigured(db: Awaited<ReturnType<typeof bootstrapDatabase>>["db"]) {
225
- const result = await db.execute(sql`
226
- SELECT id
227
- FROM agents
228
- WHERE provider_type = 'codex'
229
- LIMIT 1
230
- `);
231
- return result.length > 0;
232
- }
233
-
234
- async function hasOpenCodeAgentsConfigured(db: Awaited<ReturnType<typeof bootstrapDatabase>>["db"]) {
235
- const result = await db.execute(sql`
236
- SELECT id
237
- FROM agents
238
- WHERE provider_type = 'opencode'
239
- LIMIT 1
240
- `);
241
- return result.length > 0;
242
- }
243
-
244
- async function resolveSchedulerCompanyId(
245
- db: Awaited<ReturnType<typeof bootstrapDatabase>>["db"],
246
- configuredCompanyId: string | null
247
- ) {
248
- if (configuredCompanyId) {
249
- const configured = await db.execute(sql`
250
- SELECT id
251
- FROM companies
252
- WHERE id = ${configuredCompanyId}
253
- LIMIT 1
254
- `);
255
- if (configured.length > 0) {
256
- return configuredCompanyId;
257
- }
258
- // eslint-disable-next-line no-console
259
- console.warn(`[startup] BOPO_DEFAULT_COMPANY_ID='${configuredCompanyId}' was not found; using first available company.`);
260
- }
261
-
262
- const fallback = await db.execute(sql`
263
- SELECT id
264
- FROM companies
265
- ORDER BY created_at ASC
266
- LIMIT 1
267
- `);
268
- const id = fallback[0]?.id;
269
- return typeof id === "string" && id.length > 0 ? id : null;
270
- }
271
-
272
- function emitCodexPreflightWarning(health: RuntimeCommandHealth) {
273
- const red = process.stderr.isTTY ? "\x1b[31m" : "";
274
- const yellow = process.stderr.isTTY ? "\x1b[33m" : "";
275
- const reset = process.stderr.isTTY ? "\x1b[0m" : "";
276
- const symbol = `${red}✖${reset}`;
277
- process.stderr.write(
278
- `${symbol} ${yellow}Codex preflight failed${reset}: command '${health.command}' is unavailable.\n`
279
- );
280
- process.stderr.write(` Install Codex CLI or set BOPO_SKIP_CODEX_PREFLIGHT=1 for local dev.\n`);
281
- if (process.env.BOPO_VERBOSE_STARTUP_WARNINGS === "1") {
282
- process.stderr.write(` Details: ${JSON.stringify(health)}\n`);
283
- }
284
- }
285
-
286
- function emitOpenCodePreflightWarning(health: RuntimeCommandHealth) {
287
- const red = process.stderr.isTTY ? "\x1b[31m" : "";
288
- const yellow = process.stderr.isTTY ? "\x1b[33m" : "";
289
- const reset = process.stderr.isTTY ? "\x1b[0m" : "";
290
- const symbol = `${red}✖${reset}`;
291
- process.stderr.write(
292
- `${symbol} ${yellow}OpenCode preflight failed${reset}: command '${health.command}' is unavailable.\n`
293
- );
294
- process.stderr.write(` Install OpenCode CLI or set BOPO_SKIP_OPENCODE_PREFLIGHT=1 for local dev.\n`);
295
- if (process.env.BOPO_VERBOSE_STARTUP_WARNINGS === "1") {
296
- process.stderr.write(` Details: ${JSON.stringify(health)}\n`);
297
- }
298
- }
299
-
300
- function validateDeploymentConfiguration(
301
- deploymentMode: ReturnType<typeof resolveDeploymentMode>,
302
- allowedOrigins: string[],
303
- allowedHostnames: string[],
304
- publicBaseUrl: URL | null
305
- ) {
306
- if (deploymentMode === "authenticated_public" && !publicBaseUrl) {
307
- throw new Error("BOPO_PUBLIC_BASE_URL is required in authenticated_public mode.");
308
- }
309
- if (isAuthenticatedMode(deploymentMode) && process.env.BOPO_AUTH_TOKEN_SECRET?.trim() === "") {
310
- throw new Error("BOPO_AUTH_TOKEN_SECRET must not be empty when set.");
311
- }
312
- if (isAuthenticatedMode(deploymentMode) && !process.env.BOPO_AUTH_TOKEN_SECRET?.trim()) {
313
- // eslint-disable-next-line no-console
314
- console.warn(
315
- "[startup] BOPO_AUTH_TOKEN_SECRET is not set. Authenticated modes will require BOPO_TRUST_ACTOR_HEADERS=1 behind a trusted proxy."
316
- );
317
- }
318
- if (isAuthenticatedMode(deploymentMode) && process.env.BOPO_TRUST_ACTOR_HEADERS !== "1" && !process.env.BOPO_AUTH_TOKEN_SECRET?.trim()) {
319
- throw new Error(
320
- "Authenticated mode requires either BOPO_AUTH_TOKEN_SECRET (token identity) or BOPO_TRUST_ACTOR_HEADERS=1 (trusted proxy headers)."
321
- );
322
- }
323
- if (isAuthenticatedMode(deploymentMode) && process.env.BOPO_ALLOW_LOCAL_BOARD_FALLBACK === "1") {
324
- throw new Error("BOPO_ALLOW_LOCAL_BOARD_FALLBACK cannot be enabled in authenticated modes.");
325
- }
326
- // eslint-disable-next-line no-console
327
- console.log(
328
- `[startup] Deployment config: mode=${deploymentMode} origins=${allowedOrigins.join(",")} hosts=${allowedHostnames.join(",")}`
329
- );
330
- }
331
-
332
- function shouldStartScheduler() {
333
- const rawRole = (process.env.BOPO_SCHEDULER_ROLE ?? "auto").trim().toLowerCase();
334
- if (rawRole === "off" || rawRole === "follower") {
335
- return false;
336
- }
337
- if (rawRole === "leader" || rawRole === "auto") {
338
- return true;
339
- }
340
- throw new Error(`Invalid BOPO_SCHEDULER_ROLE '${rawRole}'. Expected one of: auto, leader, follower, off.`);
341
- }
342
-
343
- function loadApiEnv() {
344
- const sourceDir = dirname(fileURLToPath(import.meta.url));
345
- const repoRoot = resolve(sourceDir, "../../../");
346
- const candidates = [resolve(repoRoot, ".env.local"), resolve(repoRoot, ".env")];
347
- for (const path of candidates) {
348
- loadDotenv({ path, override: false, quiet: true });
349
- }
350
- }
351
-
352
- function normalizeOptionalDbPath(value: string | undefined) {
353
- const normalized = value?.trim();
354
- return normalized && normalized.length > 0 ? normalized : undefined;
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
- }
@@ -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
+ }
@@ -60,19 +60,6 @@ const approvalGatedActions = new Set([
60
60
  const hireAgentPayloadSchema = AgentCreateRequestSchema.extend({
61
61
  sourceIssueId: z.string().min(1).optional(),
62
62
  sourceIssueIds: z.array(z.string().min(1)).default([]),
63
- delegationIntent: z
64
- .object({
65
- intentType: z.literal("agent_hiring_request"),
66
- requestedRole: z.string().nullable().optional(),
67
- requestedName: z.string().nullable().optional(),
68
- requestedManagerAgentId: z.string().nullable().optional(),
69
- requestedProviderType: z
70
- .enum(["claude_code", "codex", "cursor", "opencode", "gemini_cli", "openai_api", "anthropic_api", "http", "shell"])
71
- .nullable()
72
- .optional(),
73
- requestedRuntimeModel: z.string().nullable().optional()
74
- })
75
- .optional(),
76
63
  runtimeCommand: z.string().optional(),
77
64
  runtimeArgs: z.array(z.string()).optional(),
78
65
  runtimeCwd: z.string().optional(),
@@ -94,6 +81,7 @@ const hireAgentPayloadSchema = AgentCreateRequestSchema.extend({
94
81
  const activateGoalPayloadSchema = z.object({
95
82
  projectId: z.string().optional(),
96
83
  parentGoalId: z.string().optional(),
84
+ ownerAgentId: z.string().optional(),
97
85
  level: z.enum(["company", "project", "agent"]),
98
86
  title: z.string().min(1),
99
87
  description: z.string().optional()
@@ -309,6 +297,7 @@ async function applyApprovalAction(db: BopoDb, companyId: string, action: string
309
297
  role: resolveAgentRoleText(parsed.data.role, parsed.data.roleKey, parsed.data.title),
310
298
  roleKey: normalizeRoleKey(parsed.data.roleKey),
311
299
  title: normalizeTitle(parsed.data.title),
300
+ capabilities: normalizeCapabilities(parsed.data.capabilities),
312
301
  name: parsed.data.name,
313
302
  providerType: parsed.data.providerType,
314
303
  heartbeatCron: parsed.data.heartbeatCron,
@@ -372,6 +361,7 @@ async function applyApprovalAction(db: BopoDb, companyId: string, action: string
372
361
  companyId,
373
362
  projectId: parsed.data.projectId,
374
363
  parentGoalId: parsed.data.parentGoalId,
364
+ ownerAgentId: parsed.data.ownerAgentId,
375
365
  level: parsed.data.level,
376
366
  title: parsed.data.title,
377
367
  description: parsed.data.description
@@ -737,6 +727,11 @@ function normalizeTitle(input: string | null | undefined) {
737
727
  return normalized ? normalized : null;
738
728
  }
739
729
 
730
+ function normalizeCapabilities(input: string | null | undefined) {
731
+ const normalized = input?.trim();
732
+ return normalized ? normalized : null;
733
+ }
734
+
740
735
  function resolveAgentRoleText(
741
736
  legacyRole: string | undefined,
742
737
  roleKeyInput: string | undefined,
@@ -784,7 +779,8 @@ function buildAgentStartupTaskBody(companyId: string, agentId: string) {
784
779
  ` - \`${agentOperatingFolder}/HEARTBEAT.md\``,
785
780
  ` - \`${agentOperatingFolder}/SOUL.md\``,
786
781
  ` - \`${agentOperatingFolder}/TOOLS.md\``,
787
- `3. Update your own agent runtime config via \`PUT /agents/:agentId\` and set \`runtimeConfig.bootstrapPrompt\` to reference \`${agentOperatingFolder}/AGENTS.md\` as your primary guide.`,
782
+ "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.",
783
+ " You do not need to save file paths into `bootstrapPrompt` for operating docs—use `PUT /agents/:agentId` `bootstrapPrompt` only for optional extra standing instructions.",
788
784
  "4. Post an issue comment summarizing completed setup artifacts.",
789
785
  "",
790
786
  "Safety checks:",
@@ -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
+ }
@@ -0,0 +1,46 @@
1
+ import { and, eq, inArray, approvalRequests, issues } from "bopodev-db";
2
+ import type { BopoDb } from "bopodev-db";
3
+
4
+ export async function findPendingProjectBudgetOverrideBlocksForAgent(
5
+ db: BopoDb,
6
+ companyId: string,
7
+ agentId: string
8
+ ) {
9
+ const assignedRows = await db
10
+ .select({ projectId: issues.projectId })
11
+ .from(issues)
12
+ .where(
13
+ and(
14
+ eq(issues.companyId, companyId),
15
+ eq(issues.assigneeAgentId, agentId),
16
+ inArray(issues.status, ["todo", "in_progress"])
17
+ )
18
+ );
19
+ const assignedProjectIds = new Set(assignedRows.map((row) => row.projectId));
20
+ if (assignedProjectIds.size === 0) {
21
+ return [] as string[];
22
+ }
23
+ const pendingOverrides = await db
24
+ .select({ payloadJson: approvalRequests.payloadJson })
25
+ .from(approvalRequests)
26
+ .where(
27
+ and(
28
+ eq(approvalRequests.companyId, companyId),
29
+ eq(approvalRequests.action, "override_budget"),
30
+ eq(approvalRequests.status, "pending")
31
+ )
32
+ );
33
+ const blockedProjectIds = new Set<string>();
34
+ for (const approval of pendingOverrides) {
35
+ try {
36
+ const payload = JSON.parse(approval.payloadJson) as Record<string, unknown>;
37
+ const projectId = typeof payload.projectId === "string" ? payload.projectId.trim() : "";
38
+ if (projectId && assignedProjectIds.has(projectId)) {
39
+ blockedProjectIds.add(projectId);
40
+ }
41
+ } catch {
42
+ // Ignore malformed payloads to keep enforcement resilient.
43
+ }
44
+ }
45
+ return Array.from(blockedProjectIds);
46
+ }
@@ -0,0 +1,61 @@
1
+ import { and, eq, inArray, issues, sql } from "bopodev-db";
2
+ import type { BopoDb } from "bopodev-db";
3
+
4
+ export async function claimIssuesForAgent(
5
+ db: BopoDb,
6
+ companyId: string,
7
+ agentId: string,
8
+ heartbeatRunId: string,
9
+ maxItems = 5
10
+ ) {
11
+ const result = await db.execute(sql`
12
+ WITH candidate AS (
13
+ SELECT id
14
+ FROM issues
15
+ WHERE company_id = ${companyId}
16
+ AND assignee_agent_id = ${agentId}
17
+ AND status IN ('todo', 'in_progress')
18
+ AND is_claimed = false
19
+ ORDER BY
20
+ CASE priority
21
+ WHEN 'urgent' THEN 0
22
+ WHEN 'high' THEN 1
23
+ WHEN 'medium' THEN 2
24
+ WHEN 'low' THEN 3
25
+ ELSE 4
26
+ END ASC,
27
+ updated_at ASC
28
+ LIMIT ${maxItems}
29
+ FOR UPDATE SKIP LOCKED
30
+ )
31
+ UPDATE issues i
32
+ SET is_claimed = true,
33
+ claimed_by_heartbeat_run_id = ${heartbeatRunId},
34
+ updated_at = CURRENT_TIMESTAMP
35
+ FROM candidate c
36
+ WHERE i.id = c.id
37
+ RETURNING i.id, i.project_id, i.parent_issue_id, i.title, i.body, i.status, i.priority, i.labels_json, i.tags_json;
38
+ `);
39
+
40
+ return result as unknown as Array<{
41
+ id: string;
42
+ project_id: string;
43
+ parent_issue_id: string | null;
44
+ title: string;
45
+ body: string | null;
46
+ status: string;
47
+ priority: string;
48
+ labels_json: string;
49
+ tags_json: string;
50
+ }>;
51
+ }
52
+
53
+ export async function releaseClaimedIssues(db: BopoDb, companyId: string, issueIds: string[]) {
54
+ if (issueIds.length === 0) {
55
+ return;
56
+ }
57
+ await db
58
+ .update(issues)
59
+ .set({ isClaimed: false, claimedByHeartbeatRunId: null, updatedAt: new Date() })
60
+ .where(and(eq(issues.companyId, companyId), inArray(issues.id, issueIds)));
61
+ }