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
@@ -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,65 @@
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
+ | "http"
14
+ | "shell";
15
+
16
+ export type ActiveHeartbeatRun = {
17
+ companyId: string;
18
+ agentId: string;
19
+ abortController: AbortController;
20
+ cancelReason?: string | null;
21
+ cancelRequestedAt?: string | null;
22
+ cancelRequestedBy?: string | null;
23
+ };
24
+
25
+ export type HeartbeatWakeContext = {
26
+ reason?: string | null;
27
+ commentId?: string | null;
28
+ commentBody?: string | null;
29
+ issueIds?: string[];
30
+ };
31
+
32
+ export type RunDigestSignal = {
33
+ sequence: number;
34
+ kind: "system" | "assistant" | "thinking" | "tool_call" | "tool_result" | "result" | "stderr";
35
+ label: string | null;
36
+ text: string | null;
37
+ payload: string | null;
38
+ signalLevel: "high" | "medium" | "low" | "noise";
39
+ groupKey: string | null;
40
+ source: "stdout" | "stderr" | "trace_fallback";
41
+ };
42
+
43
+ export type RunDigest = {
44
+ status: "completed" | "failed" | "skipped";
45
+ headline: string;
46
+ summary: string;
47
+ successes: string[];
48
+ failures: string[];
49
+ blockers: string[];
50
+ nextAction: string;
51
+ evidence: {
52
+ transcriptSignalCount: number;
53
+ outcomeActionCount: number;
54
+ outcomeBlockerCount: number;
55
+ failureType: string | null;
56
+ };
57
+ };
58
+
59
+ export type RunTerminalPresentation = {
60
+ internalStatus: "completed" | "failed" | "skipped";
61
+ publicStatus: "completed" | "failed";
62
+ completionReason: RunCompletionReason;
63
+ };
64
+
65
+ 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);
@@ -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
+ }
@@ -0,0 +1,128 @@
1
+ import { agents, eq } from "bopodev-db";
2
+ import { checkRuntimeCommandHealth } from "bopodev-agent-sdk";
3
+ import type { RuntimeCommandHealth } from "bopodev-agent-sdk";
4
+ import type { BootstrappedDb } from "./database";
5
+
6
+ type BopoDb = BootstrappedDb["db"];
7
+
8
+ export async function hasCodexAgentsConfigured(db: BopoDb) {
9
+ const result = await db
10
+ .select({ id: agents.id })
11
+ .from(agents)
12
+ .where(eq(agents.providerType, "codex"))
13
+ .limit(1);
14
+ return result.length > 0;
15
+ }
16
+
17
+ export async function hasOpenCodeAgentsConfigured(db: BopoDb) {
18
+ const result = await db
19
+ .select({ id: agents.id })
20
+ .from(agents)
21
+ .where(eq(agents.providerType, "opencode"))
22
+ .limit(1);
23
+ return result.length > 0;
24
+ }
25
+
26
+ export function emitCodexPreflightWarning(health: RuntimeCommandHealth) {
27
+ const red = process.stderr.isTTY ? "\x1b[31m" : "";
28
+ const yellow = process.stderr.isTTY ? "\x1b[33m" : "";
29
+ const reset = process.stderr.isTTY ? "\x1b[0m" : "";
30
+ const symbol = `${red}✖${reset}`;
31
+ process.stderr.write(
32
+ `${symbol} ${yellow}Codex preflight failed${reset}: command '${health.command}' is unavailable.\n`
33
+ );
34
+ process.stderr.write(` Install Codex CLI or set BOPO_SKIP_CODEX_PREFLIGHT=1 for local dev.\n`);
35
+ if (process.env.BOPO_VERBOSE_STARTUP_WARNINGS === "1") {
36
+ process.stderr.write(` Details: ${JSON.stringify(health)}\n`);
37
+ }
38
+ }
39
+
40
+ export function emitOpenCodePreflightWarning(health: RuntimeCommandHealth) {
41
+ const red = process.stderr.isTTY ? "\x1b[31m" : "";
42
+ const yellow = process.stderr.isTTY ? "\x1b[33m" : "";
43
+ const reset = process.stderr.isTTY ? "\x1b[0m" : "";
44
+ const symbol = `${red}✖${reset}`;
45
+ process.stderr.write(
46
+ `${symbol} ${yellow}OpenCode preflight failed${reset}: command '${health.command}' is unavailable.\n`
47
+ );
48
+ process.stderr.write(` Install OpenCode CLI or set BOPO_SKIP_OPENCODE_PREFLIGHT=1 for local dev.\n`);
49
+ if (process.env.BOPO_VERBOSE_STARTUP_WARNINGS === "1") {
50
+ process.stderr.write(` Details: ${JSON.stringify(health)}\n`);
51
+ }
52
+ }
53
+
54
+ export async function runStartupRuntimePreflights(options: {
55
+ codexHealthRequired: boolean;
56
+ openCodeHealthRequired: boolean;
57
+ codexCommand: string;
58
+ openCodeCommand: string;
59
+ }) {
60
+ const { codexHealthRequired, openCodeHealthRequired, codexCommand, openCodeCommand } = options;
61
+ if (codexHealthRequired) {
62
+ const startupCodexHealth = await checkRuntimeCommandHealth(codexCommand, {
63
+ timeoutMs: 5_000
64
+ });
65
+ if (!startupCodexHealth.available) {
66
+ emitCodexPreflightWarning(startupCodexHealth);
67
+ }
68
+ }
69
+ if (openCodeHealthRequired) {
70
+ const startupOpenCodeHealth = await checkRuntimeCommandHealth(openCodeCommand, {
71
+ timeoutMs: 5_000
72
+ });
73
+ if (!startupOpenCodeHealth.available) {
74
+ emitOpenCodePreflightWarning(startupOpenCodeHealth);
75
+ }
76
+ }
77
+ }
78
+
79
+ export function buildGetRuntimeHealth(options: {
80
+ codexCommand: string;
81
+ openCodeCommand: string;
82
+ skipCodexPreflight: boolean;
83
+ skipOpenCodePreflight: boolean;
84
+ codexHealthRequired: boolean;
85
+ openCodeHealthRequired: boolean;
86
+ }) {
87
+ const {
88
+ codexCommand,
89
+ openCodeCommand,
90
+ skipCodexPreflight,
91
+ skipOpenCodePreflight,
92
+ codexHealthRequired,
93
+ openCodeHealthRequired
94
+ } = options;
95
+
96
+ return async () => {
97
+ const codex = codexHealthRequired
98
+ ? await checkRuntimeCommandHealth(codexCommand, {
99
+ timeoutMs: 5_000
100
+ })
101
+ : {
102
+ command: codexCommand,
103
+ available: skipCodexPreflight ? false : true,
104
+ exitCode: null,
105
+ elapsedMs: 0,
106
+ error: skipCodexPreflight
107
+ ? "Skipped by configuration: BOPO_SKIP_CODEX_PREFLIGHT=1."
108
+ : "Skipped: no Codex agents configured."
109
+ };
110
+ const opencode = openCodeHealthRequired
111
+ ? await checkRuntimeCommandHealth(openCodeCommand, {
112
+ timeoutMs: 5_000
113
+ })
114
+ : {
115
+ command: openCodeCommand,
116
+ available: skipOpenCodePreflight ? false : true,
117
+ exitCode: null,
118
+ elapsedMs: 0,
119
+ error: skipOpenCodePreflight
120
+ ? "Skipped by configuration: BOPO_SKIP_OPENCODE_PREFLIGHT=1."
121
+ : "Skipped: no OpenCode agents configured."
122
+ };
123
+ return {
124
+ codex,
125
+ opencode
126
+ };
127
+ };
128
+ }