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.
- package/package.json +4 -4
- package/src/app.ts +17 -70
- package/src/lib/drainable-work.ts +36 -0
- package/src/lib/run-artifact-paths.ts +8 -0
- package/src/lib/workspace-policy.ts +1 -2
- package/src/middleware/cors-config.ts +36 -0
- package/src/middleware/request-actor.ts +10 -16
- package/src/middleware/request-id.ts +9 -0
- package/src/middleware/request-logging.ts +24 -0
- package/src/realtime/office-space.ts +3 -1
- package/src/routes/agents.ts +3 -9
- package/src/routes/companies.ts +18 -1
- package/src/routes/goals.ts +7 -13
- package/src/routes/governance.ts +2 -5
- package/src/routes/heartbeats.ts +8 -27
- package/src/routes/issues.ts +66 -121
- package/src/routes/observability.ts +6 -1
- package/src/routes/plugins.ts +5 -17
- package/src/routes/projects.ts +7 -25
- package/src/routes/templates.ts +6 -21
- package/src/scripts/onboard-seed.ts +5 -7
- package/src/server.ts +35 -276
- package/src/services/attention-service.ts +4 -1
- package/src/services/budget-service.ts +1 -2
- package/src/services/comment-recipient-dispatch-service.ts +39 -2
- package/src/services/company-export-service.ts +63 -0
- package/src/services/governance-service.ts +6 -2
- package/src/services/heartbeat-queue-service.ts +34 -3
- package/src/services/heartbeat-service/active-runs.ts +15 -0
- package/src/services/heartbeat-service/budget-override.ts +46 -0
- package/src/services/heartbeat-service/claims.ts +61 -0
- package/src/services/heartbeat-service/cron.ts +58 -0
- package/src/services/heartbeat-service/heartbeat-realtime.ts +28 -0
- package/src/services/heartbeat-service/heartbeat-run-summary-text.ts +53 -0
- package/src/services/{heartbeat-service.ts → heartbeat-service/heartbeat-run.ts} +217 -635
- package/src/services/heartbeat-service/index.ts +5 -0
- package/src/services/heartbeat-service/stop.ts +90 -0
- package/src/services/heartbeat-service/sweep.ts +145 -0
- package/src/services/heartbeat-service/types.ts +65 -0
- package/src/services/memory-file-service.ts +10 -2
- package/src/shutdown/graceful-shutdown.ts +77 -0
- package/src/startup/database.ts +41 -0
- package/src/startup/deployment-validation.ts +37 -0
- package/src/startup/env.ts +17 -0
- package/src/startup/runtime-health.ts +128 -0
- package/src/startup/scheduler-config.ts +39 -0
- package/src/types/express.d.ts +13 -0
- package/src/types/request-actor.ts +6 -0
- package/src/validation/issue-routes.ts +79 -0
- 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(
|
|
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(
|
|
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
|
+
}
|