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
@@ -0,0 +1,5 @@
1
+ export { claimIssuesForAgent, releaseClaimedIssues } from "./claims";
2
+ export { findPendingProjectBudgetOverrideBlocksForAgent } from "./budget-override";
3
+ export { stopHeartbeatRun } from "./stop";
4
+ export { runHeartbeatSweep } from "./sweep";
5
+ export { runHeartbeatForAgent } from "./heartbeat-run";
@@ -0,0 +1,90 @@
1
+ import { appendAuditEvent } from "bopodev-db";
2
+ import { and, eq, heartbeatRuns } from "bopodev-db";
3
+ import type { BopoDb } from "bopodev-db";
4
+ import type { RealtimeHub } from "../../realtime/hub";
5
+ import { getActiveHeartbeatRun } from "./active-runs";
6
+ import { publishHeartbeatRunStatus } from "./heartbeat-realtime";
7
+ import type { HeartbeatRunTrigger } from "./types";
8
+
9
+ export async function stopHeartbeatRun(
10
+ db: BopoDb,
11
+ companyId: string,
12
+ runId: string,
13
+ options?: { requestId?: string; actorId?: string; trigger?: HeartbeatRunTrigger; realtimeHub?: RealtimeHub }
14
+ ) {
15
+ const runTrigger = options?.trigger ?? "manual";
16
+ const [run] = await db
17
+ .select({
18
+ id: heartbeatRuns.id,
19
+ status: heartbeatRuns.status,
20
+ agentId: heartbeatRuns.agentId
21
+ })
22
+ .from(heartbeatRuns)
23
+ .where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, runId)))
24
+ .limit(1);
25
+ if (!run) {
26
+ return { ok: false as const, reason: "not_found" as const };
27
+ }
28
+ if (run.status !== "started") {
29
+ return { ok: false as const, reason: "invalid_status" as const, status: run.status };
30
+ }
31
+ const active = getActiveHeartbeatRun(runId);
32
+ const cancelReason = "cancelled by stop request";
33
+ const cancelRequestedAt = new Date().toISOString();
34
+ if (active) {
35
+ active.cancelReason = cancelReason;
36
+ active.cancelRequestedAt = cancelRequestedAt;
37
+ active.cancelRequestedBy = options?.actorId ?? null;
38
+ active.abortController.abort(cancelReason);
39
+ } else {
40
+ const finishedAt = new Date();
41
+ await db
42
+ .update(heartbeatRuns)
43
+ .set({
44
+ status: "failed",
45
+ finishedAt,
46
+ message: "Heartbeat cancelled by stop request."
47
+ })
48
+ .where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, runId)));
49
+ publishHeartbeatRunStatus(options?.realtimeHub, {
50
+ companyId,
51
+ runId,
52
+ status: "failed",
53
+ message: "Heartbeat cancelled by stop request.",
54
+ finishedAt
55
+ });
56
+ }
57
+ await appendAuditEvent(db, {
58
+ companyId,
59
+ actorType: "system",
60
+ eventType: "heartbeat.cancel_requested",
61
+ entityType: "heartbeat_run",
62
+ entityId: runId,
63
+ correlationId: options?.requestId ?? runId,
64
+ payload: {
65
+ agentId: run.agentId,
66
+ trigger: runTrigger,
67
+ requestId: options?.requestId ?? null,
68
+ actorId: options?.actorId ?? null,
69
+ inMemoryAbortRegistered: Boolean(active)
70
+ }
71
+ });
72
+ if (!active) {
73
+ await appendAuditEvent(db, {
74
+ companyId,
75
+ actorType: "system",
76
+ eventType: "heartbeat.cancelled",
77
+ entityType: "heartbeat_run",
78
+ entityId: runId,
79
+ correlationId: options?.requestId ?? runId,
80
+ payload: {
81
+ agentId: run.agentId,
82
+ reason: cancelReason,
83
+ trigger: runTrigger,
84
+ requestId: options?.requestId ?? null,
85
+ actorId: options?.actorId ?? null
86
+ }
87
+ });
88
+ }
89
+ return { ok: true as const, runId, agentId: run.agentId, status: run.status };
90
+ }
@@ -0,0 +1,145 @@
1
+ import { appendAuditEvent } from "bopodev-db";
2
+ import { agents, eq, heartbeatRuns, max } from "bopodev-db";
3
+ import type { BopoDb } from "bopodev-db";
4
+ import type { RealtimeHub } from "../../realtime/hub";
5
+ import { findPendingProjectBudgetOverrideBlocksForAgent } from "./budget-override";
6
+ import { isHeartbeatDue } from "./cron";
7
+
8
+ export async function runHeartbeatSweep(
9
+ db: BopoDb,
10
+ companyId: string,
11
+ options?: { requestId?: string; realtimeHub?: RealtimeHub }
12
+ ) {
13
+ const companyAgents = await db.select().from(agents).where(eq(agents.companyId, companyId));
14
+ const latestRunByAgent = await listLatestRunByAgent(db, companyId);
15
+
16
+ const now = new Date();
17
+ const enqueuedJobIds: string[] = [];
18
+ const dueAgents: Array<{ id: string }> = [];
19
+ let skippedNotDue = 0;
20
+ let skippedStatus = 0;
21
+ let skippedBudgetBlocked = 0;
22
+ let failedStarts = 0;
23
+ const sweepStartedAt = Date.now();
24
+ for (const agent of companyAgents) {
25
+ if (agent.status !== "idle" && agent.status !== "running") {
26
+ skippedStatus += 1;
27
+ continue;
28
+ }
29
+ if (!isHeartbeatDue(agent.heartbeatCron, latestRunByAgent.get(agent.id) ?? null, now)) {
30
+ skippedNotDue += 1;
31
+ continue;
32
+ }
33
+ const blockedProjectIds = await findPendingProjectBudgetOverrideBlocksForAgent(db, companyId, agent.id);
34
+ if (blockedProjectIds.length > 0) {
35
+ skippedBudgetBlocked += 1;
36
+ continue;
37
+ }
38
+ dueAgents.push({ id: agent.id });
39
+ }
40
+ const sweepConcurrency = resolveHeartbeatSweepConcurrency(dueAgents.length);
41
+ const queueModule = await import("../heartbeat-queue-service");
42
+ await runWithConcurrency(dueAgents, sweepConcurrency, async (agent) => {
43
+ try {
44
+ const job = await queueModule.enqueueHeartbeatQueueJob(db, {
45
+ companyId,
46
+ agentId: agent.id,
47
+ jobType: "scheduler",
48
+ priority: 80,
49
+ idempotencyKey: options?.requestId ? `scheduler:${agent.id}:${options.requestId}` : null,
50
+ payload: {}
51
+ });
52
+ enqueuedJobIds.push(job.id);
53
+ queueModule.triggerHeartbeatQueueWorker(db, companyId, {
54
+ requestId: options?.requestId,
55
+ realtimeHub: options?.realtimeHub
56
+ });
57
+ } catch {
58
+ failedStarts += 1;
59
+ }
60
+ });
61
+ await appendAuditEvent(db, {
62
+ companyId,
63
+ actorType: "system",
64
+ eventType: "heartbeat.sweep.completed",
65
+ entityType: "company",
66
+ entityId: companyId,
67
+ correlationId: options?.requestId ?? null,
68
+ payload: {
69
+ runIds: enqueuedJobIds,
70
+ startedCount: enqueuedJobIds.length,
71
+ dueCount: dueAgents.length,
72
+ failedStarts,
73
+ skippedStatus,
74
+ skippedNotDue,
75
+ skippedBudgetBlocked,
76
+ concurrency: sweepConcurrency,
77
+ elapsedMs: Date.now() - sweepStartedAt,
78
+ requestId: options?.requestId ?? null
79
+ }
80
+ });
81
+ return enqueuedJobIds;
82
+ }
83
+
84
+ async function listLatestRunByAgent(db: BopoDb, companyId: string) {
85
+ const rows = await db
86
+ .select({
87
+ agentId: heartbeatRuns.agentId,
88
+ latestStartedAt: max(heartbeatRuns.startedAt)
89
+ })
90
+ .from(heartbeatRuns)
91
+ .where(eq(heartbeatRuns.companyId, companyId))
92
+ .groupBy(heartbeatRuns.agentId);
93
+ const latestRunByAgent = new Map<string, Date>();
94
+ for (const row of rows) {
95
+ const startedAt = coerceDate(row.latestStartedAt);
96
+ if (!startedAt) {
97
+ continue;
98
+ }
99
+ latestRunByAgent.set(row.agentId, startedAt);
100
+ }
101
+ return latestRunByAgent;
102
+ }
103
+
104
+ function coerceDate(value: unknown) {
105
+ if (value instanceof Date) {
106
+ return Number.isNaN(value.getTime()) ? null : value;
107
+ }
108
+ if (typeof value === "string" || typeof value === "number") {
109
+ const parsed = new Date(value);
110
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
111
+ }
112
+ return null;
113
+ }
114
+
115
+ function resolveHeartbeatSweepConcurrency(dueAgentsCount: number) {
116
+ const configured = Number(process.env.BOPO_HEARTBEAT_SWEEP_CONCURRENCY ?? "4");
117
+ const fallback = 4;
118
+ const normalized = Number.isFinite(configured) ? Math.floor(configured) : fallback;
119
+ if (normalized < 1) {
120
+ return 1;
121
+ }
122
+ const bounded = Math.min(normalized, 16);
123
+ return Math.min(bounded, Math.max(1, dueAgentsCount));
124
+ }
125
+
126
+ async function runWithConcurrency<T>(
127
+ items: T[],
128
+ concurrency: number,
129
+ worker: (item: T, index: number) => Promise<void>
130
+ ) {
131
+ if (items.length === 0) {
132
+ return;
133
+ }
134
+ const workerCount = Math.max(1, Math.min(Math.floor(concurrency), items.length));
135
+ let cursor = 0;
136
+ await Promise.all(
137
+ Array.from({ length: workerCount }, async () => {
138
+ while (cursor < items.length) {
139
+ const index = cursor;
140
+ cursor += 1;
141
+ await worker(items[index] as T, index);
142
+ }
143
+ })
144
+ );
145
+ }
@@ -0,0 +1,66 @@
1
+ import type { RunCompletionReason } from "bopodev-contracts";
2
+
3
+ export type HeartbeatRunTrigger = "manual" | "scheduler";
4
+ export type HeartbeatRunMode = "default" | "resume" | "redo";
5
+ export type HeartbeatProviderType =
6
+ | "claude_code"
7
+ | "codex"
8
+ | "cursor"
9
+ | "opencode"
10
+ | "gemini_cli"
11
+ | "openai_api"
12
+ | "anthropic_api"
13
+ | "openclaw_gateway"
14
+ | "http"
15
+ | "shell";
16
+
17
+ export type ActiveHeartbeatRun = {
18
+ companyId: string;
19
+ agentId: string;
20
+ abortController: AbortController;
21
+ cancelReason?: string | null;
22
+ cancelRequestedAt?: string | null;
23
+ cancelRequestedBy?: string | null;
24
+ };
25
+
26
+ export type HeartbeatWakeContext = {
27
+ reason?: string | null;
28
+ commentId?: string | null;
29
+ commentBody?: string | null;
30
+ issueIds?: string[];
31
+ };
32
+
33
+ export type RunDigestSignal = {
34
+ sequence: number;
35
+ kind: "system" | "assistant" | "thinking" | "tool_call" | "tool_result" | "result" | "stderr";
36
+ label: string | null;
37
+ text: string | null;
38
+ payload: string | null;
39
+ signalLevel: "high" | "medium" | "low" | "noise";
40
+ groupKey: string | null;
41
+ source: "stdout" | "stderr" | "trace_fallback";
42
+ };
43
+
44
+ export type RunDigest = {
45
+ status: "completed" | "failed" | "skipped";
46
+ headline: string;
47
+ summary: string;
48
+ successes: string[];
49
+ failures: string[];
50
+ blockers: string[];
51
+ nextAction: string;
52
+ evidence: {
53
+ transcriptSignalCount: number;
54
+ outcomeActionCount: number;
55
+ outcomeBlockerCount: number;
56
+ failureType: string | null;
57
+ };
58
+ };
59
+
60
+ export type RunTerminalPresentation = {
61
+ internalStatus: "completed" | "failed" | "skipped";
62
+ publicStatus: "completed" | "failed";
63
+ completionReason: RunCompletionReason;
64
+ };
65
+
66
+ export type HeartbeatIdlePolicy = "full" | "skip_adapter" | "micro_prompt";
@@ -106,6 +106,7 @@ export async function persistHeartbeatMemory(input: {
106
106
  goalContext?: {
107
107
  companyGoals?: string[];
108
108
  projectGoals?: string[];
109
+ agentGoals?: string[];
109
110
  };
110
111
  }): Promise<PersistedHeartbeatMemory> {
111
112
  const memoryRoot = resolveAgentMemoryRootPath(input.companyId, input.agentId);
@@ -237,6 +238,7 @@ function deriveCandidateFacts(
237
238
  goalContext?: {
238
239
  companyGoals?: string[];
239
240
  projectGoals?: string[];
241
+ agentGoals?: string[];
240
242
  };
241
243
  }
242
244
  ): MemoryCandidateFact[] {
@@ -575,6 +577,7 @@ function deriveImpactTags(
575
577
  goalContext?: {
576
578
  companyGoals?: string[];
577
579
  projectGoals?: string[];
580
+ agentGoals?: string[];
578
581
  }
579
582
  ) {
580
583
  const tags = new Set<string>();
@@ -595,7 +598,9 @@ function deriveImpactTags(
595
598
  if (scoreTextMatch(fact, missionTokens) > 0) {
596
599
  tags.add("mission");
597
600
  }
598
- const goalTokens = tokenize([...(goalContext?.companyGoals ?? []), ...(goalContext?.projectGoals ?? [])].join(" "));
601
+ const goalTokens = tokenize(
602
+ [...(goalContext?.companyGoals ?? []), ...(goalContext?.projectGoals ?? []), ...(goalContext?.agentGoals ?? [])].join(" ")
603
+ );
599
604
  if (scoreTextMatch(fact, goalTokens) > 0) {
600
605
  tags.add("goal");
601
606
  }
@@ -608,10 +613,13 @@ function computeMissionAlignmentScore(
608
613
  goalContext?: {
609
614
  companyGoals?: string[];
610
615
  projectGoals?: string[];
616
+ agentGoals?: string[];
611
617
  }
612
618
  ) {
613
619
  const missionTokens = tokenize(mission ?? "");
614
- const goalTokens = tokenize([...(goalContext?.companyGoals ?? []), ...(goalContext?.projectGoals ?? [])].join(" "));
620
+ const goalTokens = tokenize(
621
+ [...(goalContext?.companyGoals ?? []), ...(goalContext?.projectGoals ?? []), ...(goalContext?.agentGoals ?? [])].join(" ")
622
+ );
615
623
  const missionScore = scoreTextMatch(summary, missionTokens);
616
624
  const goalScore = scoreTextMatch(summary, goalTokens);
617
625
  return Math.min(1, missionScore * 0.55 + goalScore * 0.45);
@@ -62,6 +62,7 @@ export async function applyTemplateManifest(
62
62
  role: resolveAgentRoleText(agent.role, agent.roleKey, agent.title),
63
63
  roleKey: normalizeRoleKey(agent.roleKey),
64
64
  title: normalizeTitle(agent.title),
65
+ capabilities: normalizeCapabilities(agent.capabilities),
65
66
  name: agent.name,
66
67
  providerType: agent.providerType,
67
68
  heartbeatCron: agent.heartbeatCron,
@@ -152,6 +153,11 @@ function normalizeTitle(input: string | null | undefined) {
152
153
  return normalized ? normalized : null;
153
154
  }
154
155
 
156
+ function normalizeCapabilities(input: string | null | undefined) {
157
+ const normalized = input?.trim();
158
+ return normalized ? normalized : null;
159
+ }
160
+
155
161
  function resolveAgentRoleText(
156
162
  legacyRole: string | undefined,
157
163
  roleKeyInput: string | undefined,
@@ -9,7 +9,8 @@ import {
9
9
  createTemplate,
10
10
  createTemplateVersion,
11
11
  getTemplateBySlug,
12
- getTemplateVersionByVersion
12
+ getTemplateVersionByVersion,
13
+ updateTemplate
13
14
  } from "bopodev-db";
14
15
 
15
16
  type BuiltinTemplateDefinition = {
@@ -28,7 +29,7 @@ const builtinTemplateDefinitions: BuiltinTemplateDefinition[] = [
28
29
  slug: "founder-startup-basic",
29
30
  name: "Founder Startup Basic",
30
31
  description: "Baseline operating company for solo founders launching and shipping with AI agents.",
31
- version: "1.0.0",
32
+ version: "1.0.1",
32
33
  status: "published",
33
34
  visibility: "company",
34
35
  variables: [
@@ -78,6 +79,10 @@ const builtinTemplateDefinitions: BuiltinTemplateDefinition[] = [
78
79
  {
79
80
  key: "founder-ceo",
80
81
  role: "CEO",
82
+ roleKey: "ceo",
83
+ title: "Founder CEO",
84
+ capabilities:
85
+ "Sets company priorities, runs leadership cadence, hires and coordinates agents toward mission outcomes.",
81
86
  name: "Founder CEO",
82
87
  providerType: "codex",
83
88
  heartbeatCron: "*/15 * * * *",
@@ -119,6 +124,10 @@ const builtinTemplateDefinitions: BuiltinTemplateDefinition[] = [
119
124
  {
120
125
  key: "founding-engineer",
121
126
  role: "Founding Engineer",
127
+ roleKey: "engineer",
128
+ title: "Founding Engineer",
129
+ capabilities:
130
+ "Ships product improvements with small reviewable changes, tests, and clear handoffs to stakeholders.",
122
131
  name: "Founding Engineer",
123
132
  managerAgentKey: "founder-ceo",
124
133
  providerType: "codex",
@@ -152,6 +161,10 @@ const builtinTemplateDefinitions: BuiltinTemplateDefinition[] = [
152
161
  {
153
162
  key: "growth-operator",
154
163
  role: "Growth Operator",
164
+ roleKey: "general",
165
+ title: "Growth Operator",
166
+ capabilities:
167
+ "Runs growth experiments, measures funnel impact, and feeds learnings back to leadership with clear next steps.",
155
168
  name: "Growth Operator",
156
169
  managerAgentKey: "founder-ceo",
157
170
  providerType: "codex",
@@ -225,7 +238,7 @@ const builtinTemplateDefinitions: BuiltinTemplateDefinition[] = [
225
238
  slug: "marketing-content-engine",
226
239
  name: "Marketing Content Engine",
227
240
  description: "Content marketing operating template for publishing, distribution, and analytics loops.",
228
- version: "1.0.0",
241
+ version: "1.0.1",
229
242
  status: "published",
230
243
  visibility: "company",
231
244
  variables: [
@@ -276,6 +289,10 @@ const builtinTemplateDefinitions: BuiltinTemplateDefinition[] = [
276
289
  {
277
290
  key: "head-of-marketing",
278
291
  role: "Head of Marketing",
292
+ roleKey: "cmo",
293
+ title: "Head of Marketing",
294
+ capabilities:
295
+ "Owns marketing narrative, cross-functional alignment, and weekly performance decisions for pipeline growth.",
279
296
  name: "Head of Marketing",
280
297
  providerType: "codex",
281
298
  heartbeatCron: "*/20 * * * *",
@@ -308,6 +325,10 @@ const builtinTemplateDefinitions: BuiltinTemplateDefinition[] = [
308
325
  {
309
326
  key: "content-strategist",
310
327
  role: "Content Strategist",
328
+ roleKey: "general",
329
+ title: "Content Strategist",
330
+ capabilities:
331
+ "Builds editorial calendars, briefs, and topic architecture tied to audience segments and revenue goals.",
311
332
  name: "Content Strategist",
312
333
  managerAgentKey: "head-of-marketing",
313
334
  providerType: "codex",
@@ -337,6 +358,10 @@ const builtinTemplateDefinitions: BuiltinTemplateDefinition[] = [
337
358
  {
338
359
  key: "content-writer",
339
360
  role: "Content Writer",
361
+ roleKey: "general",
362
+ title: "Content Writer",
363
+ capabilities:
364
+ "Produces channel-ready drafts, headline and CTA options, and repurposing notes aligned to campaign intent.",
340
365
  name: "Content Writer",
341
366
  managerAgentKey: "head-of-marketing",
342
367
  providerType: "codex",
@@ -367,6 +392,10 @@ const builtinTemplateDefinitions: BuiltinTemplateDefinition[] = [
367
392
  {
368
393
  key: "distribution-manager",
369
394
  role: "Distribution Manager",
395
+ roleKey: "general",
396
+ title: "Distribution Manager",
397
+ capabilities:
398
+ "Distributes and repurposes assets across channels with tracking discipline and weekly performance reporting.",
370
399
  name: "Distribution Manager",
371
400
  managerAgentKey: "head-of-marketing",
372
401
  providerType: "codex",
@@ -482,6 +511,11 @@ export async function ensureCompanyBuiltinTemplateDefaults(db: BopoDb, companyId
482
511
  version: definition.version,
483
512
  manifestJson: JSON.stringify(manifest)
484
513
  });
514
+ await updateTemplate(db, {
515
+ companyId,
516
+ id: template.id,
517
+ currentVersion: definition.version
518
+ });
485
519
  }
486
520
  }
487
521
  }
@@ -0,0 +1,77 @@
1
+ import type { Server } from "node:http";
2
+ import type { RealtimeHub } from "../realtime/hub";
3
+ import { beginIssueCommentDispatchShutdown, waitForIssueCommentDispatchDrain } from "../services/comment-recipient-dispatch-service";
4
+ import { beginHeartbeatQueueShutdown, waitForHeartbeatQueueDrain } from "../services/heartbeat-queue-service";
5
+
6
+ export async function closeDatabaseClient(client: unknown) {
7
+ if (!client || typeof client !== "object") {
8
+ return;
9
+ }
10
+ const closeFn = (client as { close?: unknown }).close;
11
+ if (typeof closeFn !== "function") {
12
+ return;
13
+ }
14
+ await (closeFn as () => Promise<void>)();
15
+ }
16
+
17
+ export function attachGracefulShutdownHandlers(options: {
18
+ server: Server;
19
+ realtimeHub: RealtimeHub;
20
+ dbClient: unknown;
21
+ scheduler?: { stop: () => Promise<void> };
22
+ }) {
23
+ const { server, realtimeHub, dbClient, scheduler } = options;
24
+ let shutdownInFlight: Promise<void> | null = null;
25
+
26
+ function shutdown(signal: string) {
27
+ const shutdownTimeoutMs = Number(process.env.BOPO_SHUTDOWN_TIMEOUT_MS ?? 15_000);
28
+ const forcedExit = setTimeout(() => {
29
+ // eslint-disable-next-line no-console
30
+ console.error(`[shutdown] timed out after ${shutdownTimeoutMs}ms; forcing exit.`);
31
+ process.exit(process.exitCode ?? 1);
32
+ }, shutdownTimeoutMs);
33
+ forcedExit.unref();
34
+ shutdownInFlight ??= (async () => {
35
+ // eslint-disable-next-line no-console
36
+ console.log(`[shutdown] ${signal} — draining HTTP/background work before closing the embedded database…`);
37
+ beginHeartbeatQueueShutdown();
38
+ beginIssueCommentDispatchShutdown();
39
+ await Promise.allSettled([
40
+ scheduler?.stop() ?? Promise.resolve(),
41
+ new Promise<void>((resolve, reject) => {
42
+ server.close((err) => {
43
+ if (err) {
44
+ reject(err);
45
+ return;
46
+ }
47
+ resolve();
48
+ });
49
+ })
50
+ ]);
51
+ await Promise.allSettled([waitForHeartbeatQueueDrain(), waitForIssueCommentDispatchDrain()]);
52
+ try {
53
+ await realtimeHub.close();
54
+ } catch (closeError) {
55
+ // eslint-disable-next-line no-console
56
+ console.error("[shutdown] realtime hub close error", closeError);
57
+ }
58
+ try {
59
+ await closeDatabaseClient(dbClient);
60
+ } catch (closeDbError) {
61
+ // eslint-disable-next-line no-console
62
+ console.error("[shutdown] database close error", closeDbError);
63
+ }
64
+ // eslint-disable-next-line no-console
65
+ console.log("[shutdown] clean exit");
66
+ process.exitCode = 0;
67
+ })().catch((error) => {
68
+ // eslint-disable-next-line no-console
69
+ console.error("[shutdown] failed", error);
70
+ process.exitCode = 1;
71
+ });
72
+ return shutdownInFlight;
73
+ }
74
+
75
+ process.once("SIGINT", () => void shutdown("SIGINT"));
76
+ process.once("SIGTERM", () => void shutdown("SIGTERM"));
77
+ }
@@ -0,0 +1,41 @@
1
+ import { bootstrapDatabase, resolveDefaultDbPath } from "bopodev-db";
2
+
3
+ export function isProbablyDatabaseStartupError(error: unknown): boolean {
4
+ const message = error instanceof Error ? error.message : String(error);
5
+ const cause = error instanceof Error ? error.cause : undefined;
6
+ const causeMessage = cause instanceof Error ? cause.message : String(cause ?? "");
7
+ return (
8
+ message.includes("database") ||
9
+ message.includes("postgres") ||
10
+ message.includes("migration") ||
11
+ causeMessage.includes("postgres") ||
12
+ causeMessage.includes("connection")
13
+ );
14
+ }
15
+
16
+ export type BootstrappedDb = Awaited<ReturnType<typeof bootstrapDatabase>>;
17
+
18
+ export async function bootstrapDatabaseWithStartupLogging(dbPath: string | undefined): Promise<BootstrappedDb> {
19
+ const usingExternalDatabase = Boolean(process.env.DATABASE_URL?.trim());
20
+ const effectiveDbPath = dbPath ?? resolveDefaultDbPath();
21
+ try {
22
+ return await bootstrapDatabase(dbPath);
23
+ } catch (error) {
24
+ if (isProbablyDatabaseStartupError(error)) {
25
+ // eslint-disable-next-line no-console
26
+ console.error("[startup] Database bootstrap failed before the API could start.");
27
+ if (usingExternalDatabase) {
28
+ // eslint-disable-next-line no-console
29
+ console.error("[startup] Check DATABASE_URL connectivity, permissions, and migration state.");
30
+ } else {
31
+ // eslint-disable-next-line no-console
32
+ console.error(`[startup] Embedded Postgres data path: ${effectiveDbPath}`);
33
+ // eslint-disable-next-line no-console
34
+ console.error(
35
+ "[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."
36
+ );
37
+ }
38
+ }
39
+ throw error;
40
+ }
41
+ }
@@ -0,0 +1,37 @@
1
+ import { isAuthenticatedMode, type DeploymentMode } from "../security/deployment-mode";
2
+
3
+ export function validateDeploymentConfiguration(
4
+ deploymentMode: DeploymentMode,
5
+ allowedOrigins: string[],
6
+ allowedHostnames: string[],
7
+ publicBaseUrl: URL | null
8
+ ) {
9
+ if (deploymentMode === "authenticated_public" && !publicBaseUrl) {
10
+ throw new Error("BOPO_PUBLIC_BASE_URL is required in authenticated_public mode.");
11
+ }
12
+ if (isAuthenticatedMode(deploymentMode) && process.env.BOPO_AUTH_TOKEN_SECRET?.trim() === "") {
13
+ throw new Error("BOPO_AUTH_TOKEN_SECRET must not be empty when set.");
14
+ }
15
+ if (isAuthenticatedMode(deploymentMode) && !process.env.BOPO_AUTH_TOKEN_SECRET?.trim()) {
16
+ // eslint-disable-next-line no-console
17
+ console.warn(
18
+ "[startup] BOPO_AUTH_TOKEN_SECRET is not set. Authenticated modes will require BOPO_TRUST_ACTOR_HEADERS=1 behind a trusted proxy."
19
+ );
20
+ }
21
+ if (
22
+ isAuthenticatedMode(deploymentMode) &&
23
+ process.env.BOPO_TRUST_ACTOR_HEADERS !== "1" &&
24
+ !process.env.BOPO_AUTH_TOKEN_SECRET?.trim()
25
+ ) {
26
+ throw new Error(
27
+ "Authenticated mode requires either BOPO_AUTH_TOKEN_SECRET (token identity) or BOPO_TRUST_ACTOR_HEADERS=1 (trusted proxy headers)."
28
+ );
29
+ }
30
+ if (isAuthenticatedMode(deploymentMode) && process.env.BOPO_ALLOW_LOCAL_BOARD_FALLBACK === "1") {
31
+ throw new Error("BOPO_ALLOW_LOCAL_BOARD_FALLBACK cannot be enabled in authenticated modes.");
32
+ }
33
+ // eslint-disable-next-line no-console
34
+ console.log(
35
+ `[startup] Deployment config: mode=${deploymentMode} origins=${allowedOrigins.join(",")} hosts=${allowedHostnames.join(",")}`
36
+ );
37
+ }
@@ -0,0 +1,17 @@
1
+ import { dirname, resolve } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { config as loadDotenv } from "dotenv";
4
+
5
+ export function loadApiEnv() {
6
+ const sourceDir = dirname(fileURLToPath(import.meta.url));
7
+ const repoRoot = resolve(sourceDir, "../../../../");
8
+ const candidates = [resolve(repoRoot, ".env.local"), resolve(repoRoot, ".env")];
9
+ for (const path of candidates) {
10
+ loadDotenv({ path, override: false, quiet: true });
11
+ }
12
+ }
13
+
14
+ export function normalizeOptionalDbPath(value: string | undefined) {
15
+ const normalized = value?.trim();
16
+ return normalized && normalized.length > 0 ? normalized : undefined;
17
+ }