bopodev-api 0.1.28 → 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 (42) hide show
  1. package/package.json +4 -4
  2. package/src/app.ts +17 -69
  3. package/src/lib/run-artifact-paths.ts +8 -0
  4. package/src/middleware/cors-config.ts +36 -0
  5. package/src/middleware/request-actor.ts +10 -16
  6. package/src/middleware/request-id.ts +9 -0
  7. package/src/middleware/request-logging.ts +24 -0
  8. package/src/routes/agents.ts +3 -9
  9. package/src/routes/companies.ts +18 -1
  10. package/src/routes/goals.ts +7 -13
  11. package/src/routes/governance.ts +2 -5
  12. package/src/routes/heartbeats.ts +7 -25
  13. package/src/routes/issues.ts +62 -120
  14. package/src/routes/observability.ts +6 -1
  15. package/src/routes/plugins.ts +5 -17
  16. package/src/routes/projects.ts +7 -25
  17. package/src/routes/templates.ts +6 -21
  18. package/src/scripts/onboard-seed.ts +5 -7
  19. package/src/server.ts +33 -292
  20. package/src/services/company-export-service.ts +63 -0
  21. package/src/services/governance-service.ts +4 -1
  22. package/src/services/heartbeat-service/active-runs.ts +15 -0
  23. package/src/services/heartbeat-service/budget-override.ts +46 -0
  24. package/src/services/heartbeat-service/claims.ts +61 -0
  25. package/src/services/heartbeat-service/cron.ts +58 -0
  26. package/src/services/heartbeat-service/heartbeat-realtime.ts +28 -0
  27. package/src/services/heartbeat-service/heartbeat-run-summary-text.ts +53 -0
  28. package/src/services/{heartbeat-service.ts → heartbeat-service/heartbeat-run.ts} +183 -633
  29. package/src/services/heartbeat-service/index.ts +5 -0
  30. package/src/services/heartbeat-service/stop.ts +90 -0
  31. package/src/services/heartbeat-service/sweep.ts +145 -0
  32. package/src/services/heartbeat-service/types.ts +65 -0
  33. package/src/services/memory-file-service.ts +10 -2
  34. package/src/shutdown/graceful-shutdown.ts +77 -0
  35. package/src/startup/database.ts +41 -0
  36. package/src/startup/deployment-validation.ts +37 -0
  37. package/src/startup/env.ts +17 -0
  38. package/src/startup/runtime-health.ts +128 -0
  39. package/src/startup/scheduler-config.ts +39 -0
  40. package/src/types/express.d.ts +13 -0
  41. package/src/types/request-actor.ts +6 -0
  42. package/src/validation/issue-routes.ts +79 -0
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
+ }
@@ -94,6 +94,7 @@ const hireAgentPayloadSchema = AgentCreateRequestSchema.extend({
94
94
  const activateGoalPayloadSchema = z.object({
95
95
  projectId: z.string().optional(),
96
96
  parentGoalId: z.string().optional(),
97
+ ownerAgentId: z.string().optional(),
97
98
  level: z.enum(["company", "project", "agent"]),
98
99
  title: z.string().min(1),
99
100
  description: z.string().optional()
@@ -372,6 +373,7 @@ async function applyApprovalAction(db: BopoDb, companyId: string, action: string
372
373
  companyId,
373
374
  projectId: parsed.data.projectId,
374
375
  parentGoalId: parsed.data.parentGoalId,
376
+ ownerAgentId: parsed.data.ownerAgentId,
375
377
  level: parsed.data.level,
376
378
  title: parsed.data.title,
377
379
  description: parsed.data.description
@@ -784,7 +786,8 @@ function buildAgentStartupTaskBody(companyId: string, agentId: string) {
784
786
  ` - \`${agentOperatingFolder}/HEARTBEAT.md\``,
785
787
  ` - \`${agentOperatingFolder}/SOUL.md\``,
786
788
  ` - \`${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.`,
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.",
788
791
  "4. Post an issue comment summarizing completed setup artifacts.",
789
792
  "",
790
793
  "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
+ }
@@ -0,0 +1,58 @@
1
+ export function isHeartbeatDue(cronExpression: string, lastRunAt: Date | null, now: Date) {
2
+ const normalizedNow = truncateToMinute(now);
3
+ if (!matchesCronExpression(cronExpression, normalizedNow)) {
4
+ return false;
5
+ }
6
+ if (!lastRunAt) {
7
+ return true;
8
+ }
9
+ return truncateToMinute(lastRunAt).getTime() !== normalizedNow.getTime();
10
+ }
11
+
12
+ function truncateToMinute(date: Date) {
13
+ const clone = new Date(date);
14
+ clone.setSeconds(0, 0);
15
+ return clone;
16
+ }
17
+
18
+ function matchesCronExpression(expression: string, date: Date) {
19
+ const parts = expression.trim().split(/\s+/);
20
+ if (parts.length !== 5) {
21
+ return false;
22
+ }
23
+
24
+ const [minute, hour, dayOfMonth, month, dayOfWeek] = parts as [string, string, string, string, string];
25
+ return (
26
+ matchesCronField(minute, date.getMinutes(), 0, 59) &&
27
+ matchesCronField(hour, date.getHours(), 0, 23) &&
28
+ matchesCronField(dayOfMonth, date.getDate(), 1, 31) &&
29
+ matchesCronField(month, date.getMonth() + 1, 1, 12) &&
30
+ matchesCronField(dayOfWeek, date.getDay(), 0, 6)
31
+ );
32
+ }
33
+
34
+ function matchesCronField(field: string, value: number, min: number, max: number) {
35
+ return field.split(",").some((part) => matchesCronPart(part.trim(), value, min, max));
36
+ }
37
+
38
+ function matchesCronPart(part: string, value: number, min: number, max: number): boolean {
39
+ if (part === "*") {
40
+ return true;
41
+ }
42
+
43
+ const stepMatch = part.match(/^\*\/(\d+)$/);
44
+ if (stepMatch) {
45
+ const step = Number(stepMatch[1]);
46
+ return Number.isInteger(step) && step > 0 ? (value - min) % step === 0 : false;
47
+ }
48
+
49
+ const rangeMatch = part.match(/^(\d+)-(\d+)$/);
50
+ if (rangeMatch) {
51
+ const start = Number(rangeMatch[1]);
52
+ const end = Number(rangeMatch[2]);
53
+ return start <= value && value <= end;
54
+ }
55
+
56
+ const exact = Number(part);
57
+ return Number.isInteger(exact) && exact >= min && exact <= max && exact === value;
58
+ }
@@ -0,0 +1,28 @@
1
+ import type { RealtimeHub } from "../../realtime/hub";
2
+ import { createHeartbeatRunsRealtimeEvent } from "../../realtime/heartbeat-runs";
3
+
4
+ export function publishHeartbeatRunStatus(
5
+ realtimeHub: RealtimeHub | undefined,
6
+ input: {
7
+ companyId: string;
8
+ runId: string;
9
+ status: "started" | "completed" | "failed" | "skipped";
10
+ message?: string | null;
11
+ startedAt?: Date;
12
+ finishedAt?: Date;
13
+ }
14
+ ) {
15
+ if (!realtimeHub) {
16
+ return;
17
+ }
18
+ realtimeHub.publish(
19
+ createHeartbeatRunsRealtimeEvent(input.companyId, {
20
+ type: "run.status.updated",
21
+ runId: input.runId,
22
+ status: input.status,
23
+ message: input.message ?? null,
24
+ startedAt: input.startedAt?.toISOString(),
25
+ finishedAt: input.finishedAt?.toISOString() ?? null
26
+ })
27
+ );
28
+ }