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.
- package/package.json +4 -4
- package/src/app.ts +17 -69
- package/src/lib/ceo-bootstrap-prompt.ts +1 -0
- package/src/lib/run-artifact-paths.ts +8 -0
- 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 +1 -0
- package/src/routes/agents.ts +90 -46
- package/src/routes/companies.ts +20 -1
- package/src/routes/goals.ts +7 -13
- package/src/routes/governance.ts +2 -5
- package/src/routes/heartbeats.ts +7 -25
- package/src/routes/issues.ts +65 -120
- 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 +18 -8
- package/src/server.ts +33 -292
- package/src/services/company-export-service.ts +63 -0
- package/src/services/governance-service.ts +10 -14
- 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} +201 -634
- 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 +66 -0
- package/src/services/memory-file-service.ts +10 -2
- package/src/services/template-apply-service.ts +6 -0
- package/src/services/template-catalog.ts +37 -3
- 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 +80 -0
|
@@ -50,7 +50,16 @@ const DEFAULT_COMPANY_ID_ENV = "BOPO_DEFAULT_COMPANY_ID";
|
|
|
50
50
|
const DEFAULT_AGENT_PROVIDER_ENV = "BOPO_DEFAULT_AGENT_PROVIDER";
|
|
51
51
|
const DEFAULT_AGENT_MODEL_ENV = "BOPO_DEFAULT_AGENT_MODEL";
|
|
52
52
|
const DEFAULT_TEMPLATE_ENV = "BOPO_DEFAULT_TEMPLATE_ID";
|
|
53
|
-
type AgentProvider =
|
|
53
|
+
type AgentProvider =
|
|
54
|
+
| "codex"
|
|
55
|
+
| "claude_code"
|
|
56
|
+
| "cursor"
|
|
57
|
+
| "gemini_cli"
|
|
58
|
+
| "opencode"
|
|
59
|
+
| "openai_api"
|
|
60
|
+
| "anthropic_api"
|
|
61
|
+
| "openclaw_gateway"
|
|
62
|
+
| "shell";
|
|
54
63
|
const CEO_BOOTSTRAP_SUMMARY = "ceo bootstrap heartbeat";
|
|
55
64
|
const STARTUP_PROJECT_NAME = "Leadership Setup";
|
|
56
65
|
const CEO_STARTUP_TASK_TITLE = "Set up CEO operating files and hire founding engineer";
|
|
@@ -130,6 +139,8 @@ export async function ensureOnboardingSeed(input: {
|
|
|
130
139
|
role: "CEO",
|
|
131
140
|
roleKey: "ceo",
|
|
132
141
|
title: "CEO",
|
|
142
|
+
capabilities:
|
|
143
|
+
"Company leadership: priorities, hiring, governance, and aligning agents to mission and budget.",
|
|
133
144
|
name: "CEO",
|
|
134
145
|
providerType: agentProvider,
|
|
135
146
|
heartbeatCron: "*/5 * * * *",
|
|
@@ -316,18 +327,16 @@ async function ensureCeoStartupTask(
|
|
|
316
327
|
` - \`${ceoOperatingFolder}/HEARTBEAT.md\``,
|
|
317
328
|
` - \`${ceoOperatingFolder}/SOUL.md\``,
|
|
318
329
|
` - \`${ceoOperatingFolder}/TOOLS.md\``,
|
|
319
|
-
"3.
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
" - Prefer heredoc/stdin payloads (for example `curl --data-binary @- <<'JSON' ... JSON`) to avoid temp-file cleanup failures under runtime policy.",
|
|
323
|
-
` - If you must use payload files, store them in \`${ceoTmpFolder}/\` (or OS temp via \`mktemp\`) and avoid chaining cleanup commands into critical task flow.`,
|
|
330
|
+
"3. Each heartbeat already includes your operating directory via `$BOPODEV_AGENT_OPERATING_DIR` and directs you to AGENTS.md and related files there when they exist.",
|
|
331
|
+
" You do not need to save file paths into `bootstrapPrompt` for operating docs—reserve `bootstrapPrompt` only for optional extra standing instructions.",
|
|
332
|
+
` - Prefer heredoc/stdin payloads (for example \`curl --data-binary @- <<'JSON' ... JSON\`) when calling APIs; if you must use payload files, store them in \`${ceoTmpFolder}/\` (or OS temp via \`mktemp\`).`,
|
|
324
333
|
"4. To inspect your own agent record, use `GET /agents` and filter by your agent id. Do not call `GET /agents/:agentId`.",
|
|
325
334
|
" - `GET /agents` uses envelope shape `{ \"ok\": true, \"data\": [...] }`; treat any other shape as failure.",
|
|
326
|
-
" - Deterministic filter: `jq -er --arg id \"$BOPODEV_AGENT_ID\" '.data | if type==\"array\" then . else error(\"invalid_agents_payload\") end | map(select((.id? // \"\") == $id))[0] | {id,name,role
|
|
335
|
+
" - Deterministic filter: `jq -er --arg id \"$BOPODEV_AGENT_ID\" '.data | if type==\"array\" then . else error(\"invalid_agents_payload\") end | map(select((.id? // \"\") == $id))[0] | {id,name,role}'`",
|
|
327
336
|
"5. Heartbeat-assigned issues are already claimed for the current run. Do not call a checkout endpoint; update status with `PUT /issues/:issueId` only.",
|
|
328
337
|
"6. After your operating files are active, submit a hire request for a Founding Engineer via `POST /agents` using supported fields:",
|
|
329
338
|
" - `name`, `role`, `providerType`, `heartbeatCron`, `monthlyBudgetUsd`",
|
|
330
|
-
" - optional `managerAgentId`, `bootstrapPrompt
|
|
339
|
+
" - optional `managerAgentId`, `bootstrapPrompt` (extra standing instructions only), `runtimeConfig`, `canHireAgents`",
|
|
331
340
|
" - `requestApproval: true` and `sourceIssueId`",
|
|
332
341
|
"7. Do not use unsupported hire fields such as `adapterType`, `adapterConfig`, or `reportsTo`.",
|
|
333
342
|
"",
|
|
@@ -401,6 +410,7 @@ function parseAgentProvider(value: unknown): AgentProvider | null {
|
|
|
401
410
|
value === "opencode" ||
|
|
402
411
|
value === "openai_api" ||
|
|
403
412
|
value === "anthropic_api" ||
|
|
413
|
+
value === "openclaw_gateway" ||
|
|
404
414
|
value === "shell"
|
|
405
415
|
) {
|
|
406
416
|
return value;
|
package/src/server.ts
CHANGED
|
@@ -1,10 +1,5 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
|
-
import {
|
|
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
|
|
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 =
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
process.exit(process.exitCode ?? 1);
|
|
164
|
-
}, shutdownTimeoutMs);
|
|
165
|
-
forcedExit.unref();
|
|
166
|
-
shutdownInFlight ??= (async () => {
|
|
167
|
-
// eslint-disable-next-line no-console
|
|
168
|
-
console.log(`[shutdown] ${signal} — draining HTTP/background work before closing the embedded database…`);
|
|
169
|
-
beginHeartbeatQueueShutdown();
|
|
170
|
-
beginIssueCommentDispatchShutdown();
|
|
171
|
-
await Promise.allSettled([
|
|
172
|
-
scheduler?.stop() ?? Promise.resolve(),
|
|
173
|
-
new Promise<void>((resolve, reject) => {
|
|
174
|
-
server.close((err) => {
|
|
175
|
-
if (err) {
|
|
176
|
-
reject(err);
|
|
177
|
-
return;
|
|
178
|
-
}
|
|
179
|
-
resolve();
|
|
180
|
-
});
|
|
181
|
-
})
|
|
182
|
-
]);
|
|
183
|
-
await Promise.allSettled([waitForHeartbeatQueueDrain(), waitForIssueCommentDispatchDrain()]);
|
|
184
|
-
try {
|
|
185
|
-
await realtimeHub.close();
|
|
186
|
-
} catch (closeError) {
|
|
187
|
-
// eslint-disable-next-line no-console
|
|
188
|
-
console.error("[shutdown] realtime hub close error", closeError);
|
|
189
|
-
}
|
|
190
|
-
try {
|
|
191
|
-
await closeDatabaseClient(dbClient);
|
|
192
|
-
} catch (closeDbError) {
|
|
193
|
-
// eslint-disable-next-line no-console
|
|
194
|
-
console.error("[shutdown] database close error", closeDbError);
|
|
195
|
-
}
|
|
196
|
-
// eslint-disable-next-line no-console
|
|
197
|
-
console.log("[shutdown] clean exit");
|
|
198
|
-
process.exitCode = 0;
|
|
199
|
-
})().catch((error) => {
|
|
200
|
-
// eslint-disable-next-line no-console
|
|
201
|
-
console.error("[shutdown] failed", error);
|
|
202
|
-
process.exitCode = 1;
|
|
203
|
-
});
|
|
204
|
-
return shutdownInFlight;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
process.once("SIGINT", () => void shutdown("SIGINT"));
|
|
208
|
-
process.once("SIGTERM", () => void shutdown("SIGTERM"));
|
|
101
|
+
attachGracefulShutdownHandlers({
|
|
102
|
+
server,
|
|
103
|
+
realtimeHub,
|
|
104
|
+
dbClient,
|
|
105
|
+
scheduler
|
|
106
|
+
});
|
|
209
107
|
}
|
|
210
108
|
|
|
211
109
|
void main();
|
|
212
|
-
|
|
213
|
-
async function closeDatabaseClient(client: unknown) {
|
|
214
|
-
if (!client || typeof client !== "object") {
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
217
|
-
const closeFn = (client as { close?: unknown }).close;
|
|
218
|
-
if (typeof closeFn !== "function") {
|
|
219
|
-
return;
|
|
220
|
-
}
|
|
221
|
-
await (closeFn as () => Promise<void>)();
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
async function hasCodexAgentsConfigured(db: Awaited<ReturnType<typeof bootstrapDatabase>>["db"]) {
|
|
225
|
-
const result = await db.execute(sql`
|
|
226
|
-
SELECT id
|
|
227
|
-
FROM agents
|
|
228
|
-
WHERE provider_type = 'codex'
|
|
229
|
-
LIMIT 1
|
|
230
|
-
`);
|
|
231
|
-
return result.length > 0;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
async function hasOpenCodeAgentsConfigured(db: Awaited<ReturnType<typeof bootstrapDatabase>>["db"]) {
|
|
235
|
-
const result = await db.execute(sql`
|
|
236
|
-
SELECT id
|
|
237
|
-
FROM agents
|
|
238
|
-
WHERE provider_type = 'opencode'
|
|
239
|
-
LIMIT 1
|
|
240
|
-
`);
|
|
241
|
-
return result.length > 0;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
async function resolveSchedulerCompanyId(
|
|
245
|
-
db: Awaited<ReturnType<typeof bootstrapDatabase>>["db"],
|
|
246
|
-
configuredCompanyId: string | null
|
|
247
|
-
) {
|
|
248
|
-
if (configuredCompanyId) {
|
|
249
|
-
const configured = await db.execute(sql`
|
|
250
|
-
SELECT id
|
|
251
|
-
FROM companies
|
|
252
|
-
WHERE id = ${configuredCompanyId}
|
|
253
|
-
LIMIT 1
|
|
254
|
-
`);
|
|
255
|
-
if (configured.length > 0) {
|
|
256
|
-
return configuredCompanyId;
|
|
257
|
-
}
|
|
258
|
-
// eslint-disable-next-line no-console
|
|
259
|
-
console.warn(`[startup] BOPO_DEFAULT_COMPANY_ID='${configuredCompanyId}' was not found; using first available company.`);
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
const fallback = await db.execute(sql`
|
|
263
|
-
SELECT id
|
|
264
|
-
FROM companies
|
|
265
|
-
ORDER BY created_at ASC
|
|
266
|
-
LIMIT 1
|
|
267
|
-
`);
|
|
268
|
-
const id = fallback[0]?.id;
|
|
269
|
-
return typeof id === "string" && id.length > 0 ? id : null;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
function emitCodexPreflightWarning(health: RuntimeCommandHealth) {
|
|
273
|
-
const red = process.stderr.isTTY ? "\x1b[31m" : "";
|
|
274
|
-
const yellow = process.stderr.isTTY ? "\x1b[33m" : "";
|
|
275
|
-
const reset = process.stderr.isTTY ? "\x1b[0m" : "";
|
|
276
|
-
const symbol = `${red}✖${reset}`;
|
|
277
|
-
process.stderr.write(
|
|
278
|
-
`${symbol} ${yellow}Codex preflight failed${reset}: command '${health.command}' is unavailable.\n`
|
|
279
|
-
);
|
|
280
|
-
process.stderr.write(` Install Codex CLI or set BOPO_SKIP_CODEX_PREFLIGHT=1 for local dev.\n`);
|
|
281
|
-
if (process.env.BOPO_VERBOSE_STARTUP_WARNINGS === "1") {
|
|
282
|
-
process.stderr.write(` Details: ${JSON.stringify(health)}\n`);
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
function emitOpenCodePreflightWarning(health: RuntimeCommandHealth) {
|
|
287
|
-
const red = process.stderr.isTTY ? "\x1b[31m" : "";
|
|
288
|
-
const yellow = process.stderr.isTTY ? "\x1b[33m" : "";
|
|
289
|
-
const reset = process.stderr.isTTY ? "\x1b[0m" : "";
|
|
290
|
-
const symbol = `${red}✖${reset}`;
|
|
291
|
-
process.stderr.write(
|
|
292
|
-
`${symbol} ${yellow}OpenCode preflight failed${reset}: command '${health.command}' is unavailable.\n`
|
|
293
|
-
);
|
|
294
|
-
process.stderr.write(` Install OpenCode CLI or set BOPO_SKIP_OPENCODE_PREFLIGHT=1 for local dev.\n`);
|
|
295
|
-
if (process.env.BOPO_VERBOSE_STARTUP_WARNINGS === "1") {
|
|
296
|
-
process.stderr.write(` Details: ${JSON.stringify(health)}\n`);
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
function validateDeploymentConfiguration(
|
|
301
|
-
deploymentMode: ReturnType<typeof resolveDeploymentMode>,
|
|
302
|
-
allowedOrigins: string[],
|
|
303
|
-
allowedHostnames: string[],
|
|
304
|
-
publicBaseUrl: URL | null
|
|
305
|
-
) {
|
|
306
|
-
if (deploymentMode === "authenticated_public" && !publicBaseUrl) {
|
|
307
|
-
throw new Error("BOPO_PUBLIC_BASE_URL is required in authenticated_public mode.");
|
|
308
|
-
}
|
|
309
|
-
if (isAuthenticatedMode(deploymentMode) && process.env.BOPO_AUTH_TOKEN_SECRET?.trim() === "") {
|
|
310
|
-
throw new Error("BOPO_AUTH_TOKEN_SECRET must not be empty when set.");
|
|
311
|
-
}
|
|
312
|
-
if (isAuthenticatedMode(deploymentMode) && !process.env.BOPO_AUTH_TOKEN_SECRET?.trim()) {
|
|
313
|
-
// eslint-disable-next-line no-console
|
|
314
|
-
console.warn(
|
|
315
|
-
"[startup] BOPO_AUTH_TOKEN_SECRET is not set. Authenticated modes will require BOPO_TRUST_ACTOR_HEADERS=1 behind a trusted proxy."
|
|
316
|
-
);
|
|
317
|
-
}
|
|
318
|
-
if (isAuthenticatedMode(deploymentMode) && process.env.BOPO_TRUST_ACTOR_HEADERS !== "1" && !process.env.BOPO_AUTH_TOKEN_SECRET?.trim()) {
|
|
319
|
-
throw new Error(
|
|
320
|
-
"Authenticated mode requires either BOPO_AUTH_TOKEN_SECRET (token identity) or BOPO_TRUST_ACTOR_HEADERS=1 (trusted proxy headers)."
|
|
321
|
-
);
|
|
322
|
-
}
|
|
323
|
-
if (isAuthenticatedMode(deploymentMode) && process.env.BOPO_ALLOW_LOCAL_BOARD_FALLBACK === "1") {
|
|
324
|
-
throw new Error("BOPO_ALLOW_LOCAL_BOARD_FALLBACK cannot be enabled in authenticated modes.");
|
|
325
|
-
}
|
|
326
|
-
// eslint-disable-next-line no-console
|
|
327
|
-
console.log(
|
|
328
|
-
`[startup] Deployment config: mode=${deploymentMode} origins=${allowedOrigins.join(",")} hosts=${allowedHostnames.join(",")}`
|
|
329
|
-
);
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
function shouldStartScheduler() {
|
|
333
|
-
const rawRole = (process.env.BOPO_SCHEDULER_ROLE ?? "auto").trim().toLowerCase();
|
|
334
|
-
if (rawRole === "off" || rawRole === "follower") {
|
|
335
|
-
return false;
|
|
336
|
-
}
|
|
337
|
-
if (rawRole === "leader" || rawRole === "auto") {
|
|
338
|
-
return true;
|
|
339
|
-
}
|
|
340
|
-
throw new Error(`Invalid BOPO_SCHEDULER_ROLE '${rawRole}'. Expected one of: auto, leader, follower, off.`);
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
function loadApiEnv() {
|
|
344
|
-
const sourceDir = dirname(fileURLToPath(import.meta.url));
|
|
345
|
-
const repoRoot = resolve(sourceDir, "../../../");
|
|
346
|
-
const candidates = [resolve(repoRoot, ".env.local"), resolve(repoRoot, ".env")];
|
|
347
|
-
for (const path of candidates) {
|
|
348
|
-
loadDotenv({ path, override: false, quiet: true });
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
function normalizeOptionalDbPath(value: string | undefined) {
|
|
353
|
-
const normalized = value?.trim();
|
|
354
|
-
return normalized && normalized.length > 0 ? normalized : undefined;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
function isProbablyDatabaseStartupError(error: unknown): boolean {
|
|
358
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
359
|
-
const cause = error instanceof Error ? error.cause : undefined;
|
|
360
|
-
const causeMessage = cause instanceof Error ? cause.message : String(cause ?? "");
|
|
361
|
-
return (
|
|
362
|
-
message.includes("database") ||
|
|
363
|
-
message.includes("postgres") ||
|
|
364
|
-
message.includes("migration") ||
|
|
365
|
-
causeMessage.includes("postgres") ||
|
|
366
|
-
causeMessage.includes("connection")
|
|
367
|
-
);
|
|
368
|
-
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { BopoDb } from "bopodev-db";
|
|
2
|
+
import { getCompany, listAgents, listGoals, listIssues, listProjects } from "bopodev-db";
|
|
3
|
+
|
|
4
|
+
function serializeValue(value: unknown): unknown {
|
|
5
|
+
if (value instanceof Date) {
|
|
6
|
+
return value.toISOString();
|
|
7
|
+
}
|
|
8
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
9
|
+
return serializeRow(value as Record<string, unknown>);
|
|
10
|
+
}
|
|
11
|
+
if (Array.isArray(value)) {
|
|
12
|
+
return value.map((entry) => serializeValue(entry));
|
|
13
|
+
}
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function serializeRow<T extends Record<string, unknown>>(row: T): Record<string, unknown> {
|
|
18
|
+
const out: Record<string, unknown> = {};
|
|
19
|
+
for (const [key, val] of Object.entries(row)) {
|
|
20
|
+
out[key] = serializeValue(val);
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Strip agent fields that commonly hold secrets or session state for shareable exports. */
|
|
26
|
+
function scrubAgentRow(agent: Record<string, unknown>) {
|
|
27
|
+
const base = serializeRow(agent);
|
|
28
|
+
return {
|
|
29
|
+
...base,
|
|
30
|
+
runtimeEnvJson: "{}",
|
|
31
|
+
bootstrapPrompt: null,
|
|
32
|
+
stateBlob: "{}",
|
|
33
|
+
runtimeCommand: null,
|
|
34
|
+
runtimeArgsJson: "[]"
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Portable company snapshot for backup, templates, or handoff (secrets and agent session state redacted).
|
|
40
|
+
*/
|
|
41
|
+
export async function buildCompanyPortabilityExport(db: BopoDb, companyId: string) {
|
|
42
|
+
const company = await getCompany(db, companyId);
|
|
43
|
+
if (!company) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const [projects, goals, issues, agents] = await Promise.all([
|
|
48
|
+
listProjects(db, companyId),
|
|
49
|
+
listGoals(db, companyId),
|
|
50
|
+
listIssues(db, companyId),
|
|
51
|
+
listAgents(db, companyId)
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
exportedAt: new Date().toISOString(),
|
|
56
|
+
formatVersion: 1,
|
|
57
|
+
company: serializeRow(company as unknown as Record<string, unknown>),
|
|
58
|
+
projects: projects.map((p) => serializeRow(p as unknown as Record<string, unknown>)),
|
|
59
|
+
goals: goals.map((g) => serializeRow(g as unknown as Record<string, unknown>)),
|
|
60
|
+
issues: issues.map((issue) => serializeRow(issue as unknown as Record<string, unknown>)),
|
|
61
|
+
agents: agents.map((agent) => scrubAgentRow(agent as unknown as Record<string, unknown>))
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -60,19 +60,6 @@ const approvalGatedActions = new Set([
|
|
|
60
60
|
const hireAgentPayloadSchema = AgentCreateRequestSchema.extend({
|
|
61
61
|
sourceIssueId: z.string().min(1).optional(),
|
|
62
62
|
sourceIssueIds: z.array(z.string().min(1)).default([]),
|
|
63
|
-
delegationIntent: z
|
|
64
|
-
.object({
|
|
65
|
-
intentType: z.literal("agent_hiring_request"),
|
|
66
|
-
requestedRole: z.string().nullable().optional(),
|
|
67
|
-
requestedName: z.string().nullable().optional(),
|
|
68
|
-
requestedManagerAgentId: z.string().nullable().optional(),
|
|
69
|
-
requestedProviderType: z
|
|
70
|
-
.enum(["claude_code", "codex", "cursor", "opencode", "gemini_cli", "openai_api", "anthropic_api", "http", "shell"])
|
|
71
|
-
.nullable()
|
|
72
|
-
.optional(),
|
|
73
|
-
requestedRuntimeModel: z.string().nullable().optional()
|
|
74
|
-
})
|
|
75
|
-
.optional(),
|
|
76
63
|
runtimeCommand: z.string().optional(),
|
|
77
64
|
runtimeArgs: z.array(z.string()).optional(),
|
|
78
65
|
runtimeCwd: z.string().optional(),
|
|
@@ -94,6 +81,7 @@ const hireAgentPayloadSchema = AgentCreateRequestSchema.extend({
|
|
|
94
81
|
const activateGoalPayloadSchema = z.object({
|
|
95
82
|
projectId: z.string().optional(),
|
|
96
83
|
parentGoalId: z.string().optional(),
|
|
84
|
+
ownerAgentId: z.string().optional(),
|
|
97
85
|
level: z.enum(["company", "project", "agent"]),
|
|
98
86
|
title: z.string().min(1),
|
|
99
87
|
description: z.string().optional()
|
|
@@ -309,6 +297,7 @@ async function applyApprovalAction(db: BopoDb, companyId: string, action: string
|
|
|
309
297
|
role: resolveAgentRoleText(parsed.data.role, parsed.data.roleKey, parsed.data.title),
|
|
310
298
|
roleKey: normalizeRoleKey(parsed.data.roleKey),
|
|
311
299
|
title: normalizeTitle(parsed.data.title),
|
|
300
|
+
capabilities: normalizeCapabilities(parsed.data.capabilities),
|
|
312
301
|
name: parsed.data.name,
|
|
313
302
|
providerType: parsed.data.providerType,
|
|
314
303
|
heartbeatCron: parsed.data.heartbeatCron,
|
|
@@ -372,6 +361,7 @@ async function applyApprovalAction(db: BopoDb, companyId: string, action: string
|
|
|
372
361
|
companyId,
|
|
373
362
|
projectId: parsed.data.projectId,
|
|
374
363
|
parentGoalId: parsed.data.parentGoalId,
|
|
364
|
+
ownerAgentId: parsed.data.ownerAgentId,
|
|
375
365
|
level: parsed.data.level,
|
|
376
366
|
title: parsed.data.title,
|
|
377
367
|
description: parsed.data.description
|
|
@@ -737,6 +727,11 @@ function normalizeTitle(input: string | null | undefined) {
|
|
|
737
727
|
return normalized ? normalized : null;
|
|
738
728
|
}
|
|
739
729
|
|
|
730
|
+
function normalizeCapabilities(input: string | null | undefined) {
|
|
731
|
+
const normalized = input?.trim();
|
|
732
|
+
return normalized ? normalized : null;
|
|
733
|
+
}
|
|
734
|
+
|
|
740
735
|
function resolveAgentRoleText(
|
|
741
736
|
legacyRole: string | undefined,
|
|
742
737
|
roleKeyInput: string | undefined,
|
|
@@ -784,7 +779,8 @@ function buildAgentStartupTaskBody(companyId: string, agentId: string) {
|
|
|
784
779
|
` - \`${agentOperatingFolder}/HEARTBEAT.md\``,
|
|
785
780
|
` - \`${agentOperatingFolder}/SOUL.md\``,
|
|
786
781
|
` - \`${agentOperatingFolder}/TOOLS.md\``,
|
|
787
|
-
|
|
782
|
+
"3. Each heartbeat already includes your operating directory via `$BOPODEV_AGENT_OPERATING_DIR` and directs you to AGENTS.md and related files there when they exist.",
|
|
783
|
+
" You do not need to save file paths into `bootstrapPrompt` for operating docs—use `PUT /agents/:agentId` `bootstrapPrompt` only for optional extra standing instructions.",
|
|
788
784
|
"4. Post an issue comment summarizing completed setup artifacts.",
|
|
789
785
|
"",
|
|
790
786
|
"Safety checks:",
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ActiveHeartbeatRun } from "./types";
|
|
2
|
+
|
|
3
|
+
const activeHeartbeatRuns = new Map<string, ActiveHeartbeatRun>();
|
|
4
|
+
|
|
5
|
+
export function registerActiveHeartbeatRun(runId: string, run: ActiveHeartbeatRun) {
|
|
6
|
+
activeHeartbeatRuns.set(runId, run);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function unregisterActiveHeartbeatRun(runId: string) {
|
|
10
|
+
activeHeartbeatRuns.delete(runId);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getActiveHeartbeatRun(runId: string) {
|
|
14
|
+
return activeHeartbeatRuns.get(runId);
|
|
15
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { and, eq, inArray, approvalRequests, issues } from "bopodev-db";
|
|
2
|
+
import type { BopoDb } from "bopodev-db";
|
|
3
|
+
|
|
4
|
+
export async function findPendingProjectBudgetOverrideBlocksForAgent(
|
|
5
|
+
db: BopoDb,
|
|
6
|
+
companyId: string,
|
|
7
|
+
agentId: string
|
|
8
|
+
) {
|
|
9
|
+
const assignedRows = await db
|
|
10
|
+
.select({ projectId: issues.projectId })
|
|
11
|
+
.from(issues)
|
|
12
|
+
.where(
|
|
13
|
+
and(
|
|
14
|
+
eq(issues.companyId, companyId),
|
|
15
|
+
eq(issues.assigneeAgentId, agentId),
|
|
16
|
+
inArray(issues.status, ["todo", "in_progress"])
|
|
17
|
+
)
|
|
18
|
+
);
|
|
19
|
+
const assignedProjectIds = new Set(assignedRows.map((row) => row.projectId));
|
|
20
|
+
if (assignedProjectIds.size === 0) {
|
|
21
|
+
return [] as string[];
|
|
22
|
+
}
|
|
23
|
+
const pendingOverrides = await db
|
|
24
|
+
.select({ payloadJson: approvalRequests.payloadJson })
|
|
25
|
+
.from(approvalRequests)
|
|
26
|
+
.where(
|
|
27
|
+
and(
|
|
28
|
+
eq(approvalRequests.companyId, companyId),
|
|
29
|
+
eq(approvalRequests.action, "override_budget"),
|
|
30
|
+
eq(approvalRequests.status, "pending")
|
|
31
|
+
)
|
|
32
|
+
);
|
|
33
|
+
const blockedProjectIds = new Set<string>();
|
|
34
|
+
for (const approval of pendingOverrides) {
|
|
35
|
+
try {
|
|
36
|
+
const payload = JSON.parse(approval.payloadJson) as Record<string, unknown>;
|
|
37
|
+
const projectId = typeof payload.projectId === "string" ? payload.projectId.trim() : "";
|
|
38
|
+
if (projectId && assignedProjectIds.has(projectId)) {
|
|
39
|
+
blockedProjectIds.add(projectId);
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
// Ignore malformed payloads to keep enforcement resilient.
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return Array.from(blockedProjectIds);
|
|
46
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { and, eq, inArray, issues, sql } from "bopodev-db";
|
|
2
|
+
import type { BopoDb } from "bopodev-db";
|
|
3
|
+
|
|
4
|
+
export async function claimIssuesForAgent(
|
|
5
|
+
db: BopoDb,
|
|
6
|
+
companyId: string,
|
|
7
|
+
agentId: string,
|
|
8
|
+
heartbeatRunId: string,
|
|
9
|
+
maxItems = 5
|
|
10
|
+
) {
|
|
11
|
+
const result = await db.execute(sql`
|
|
12
|
+
WITH candidate AS (
|
|
13
|
+
SELECT id
|
|
14
|
+
FROM issues
|
|
15
|
+
WHERE company_id = ${companyId}
|
|
16
|
+
AND assignee_agent_id = ${agentId}
|
|
17
|
+
AND status IN ('todo', 'in_progress')
|
|
18
|
+
AND is_claimed = false
|
|
19
|
+
ORDER BY
|
|
20
|
+
CASE priority
|
|
21
|
+
WHEN 'urgent' THEN 0
|
|
22
|
+
WHEN 'high' THEN 1
|
|
23
|
+
WHEN 'medium' THEN 2
|
|
24
|
+
WHEN 'low' THEN 3
|
|
25
|
+
ELSE 4
|
|
26
|
+
END ASC,
|
|
27
|
+
updated_at ASC
|
|
28
|
+
LIMIT ${maxItems}
|
|
29
|
+
FOR UPDATE SKIP LOCKED
|
|
30
|
+
)
|
|
31
|
+
UPDATE issues i
|
|
32
|
+
SET is_claimed = true,
|
|
33
|
+
claimed_by_heartbeat_run_id = ${heartbeatRunId},
|
|
34
|
+
updated_at = CURRENT_TIMESTAMP
|
|
35
|
+
FROM candidate c
|
|
36
|
+
WHERE i.id = c.id
|
|
37
|
+
RETURNING i.id, i.project_id, i.parent_issue_id, i.title, i.body, i.status, i.priority, i.labels_json, i.tags_json;
|
|
38
|
+
`);
|
|
39
|
+
|
|
40
|
+
return result as unknown as Array<{
|
|
41
|
+
id: string;
|
|
42
|
+
project_id: string;
|
|
43
|
+
parent_issue_id: string | null;
|
|
44
|
+
title: string;
|
|
45
|
+
body: string | null;
|
|
46
|
+
status: string;
|
|
47
|
+
priority: string;
|
|
48
|
+
labels_json: string;
|
|
49
|
+
tags_json: string;
|
|
50
|
+
}>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function releaseClaimedIssues(db: BopoDb, companyId: string, issueIds: string[]) {
|
|
54
|
+
if (issueIds.length === 0) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
await db
|
|
58
|
+
.update(issues)
|
|
59
|
+
.set({ isClaimed: false, claimedByHeartbeatRunId: null, updatedAt: new Date() })
|
|
60
|
+
.where(and(eq(issues.companyId, companyId), inArray(issues.id, issueIds)));
|
|
61
|
+
}
|