bopodev-api 0.1.28 → 0.1.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/app.ts +17 -69
- 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/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 +7 -25
- package/src/routes/issues.ts +62 -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 +5 -7
- package/src/server.ts +33 -292
- package/src/services/company-export-service.ts +63 -0
- package/src/services/governance-service.ts +4 -1
- 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} +183 -633
- 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/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
|
+
}
|
|
@@ -94,6 +94,7 @@ const hireAgentPayloadSchema = AgentCreateRequestSchema.extend({
|
|
|
94
94
|
const activateGoalPayloadSchema = z.object({
|
|
95
95
|
projectId: z.string().optional(),
|
|
96
96
|
parentGoalId: z.string().optional(),
|
|
97
|
+
ownerAgentId: z.string().optional(),
|
|
97
98
|
level: z.enum(["company", "project", "agent"]),
|
|
98
99
|
title: z.string().min(1),
|
|
99
100
|
description: z.string().optional()
|
|
@@ -372,6 +373,7 @@ async function applyApprovalAction(db: BopoDb, companyId: string, action: string
|
|
|
372
373
|
companyId,
|
|
373
374
|
projectId: parsed.data.projectId,
|
|
374
375
|
parentGoalId: parsed.data.parentGoalId,
|
|
376
|
+
ownerAgentId: parsed.data.ownerAgentId,
|
|
375
377
|
level: parsed.data.level,
|
|
376
378
|
title: parsed.data.title,
|
|
377
379
|
description: parsed.data.description
|
|
@@ -784,7 +786,8 @@ function buildAgentStartupTaskBody(companyId: string, agentId: string) {
|
|
|
784
786
|
` - \`${agentOperatingFolder}/HEARTBEAT.md\``,
|
|
785
787
|
` - \`${agentOperatingFolder}/SOUL.md\``,
|
|
786
788
|
` - \`${agentOperatingFolder}/TOOLS.md\``,
|
|
787
|
-
|
|
789
|
+
"3. Each heartbeat already includes your operating directory via `$BOPODEV_AGENT_OPERATING_DIR` and directs you to AGENTS.md and related files there when they exist.",
|
|
790
|
+
" You do not need to save file paths into `bootstrapPrompt` for operating docs—use `PUT /agents/:agentId` `bootstrapPrompt` only for optional extra standing instructions.",
|
|
788
791
|
"4. Post an issue comment summarizing completed setup artifacts.",
|
|
789
792
|
"",
|
|
790
793
|
"Safety checks:",
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ActiveHeartbeatRun } from "./types";
|
|
2
|
+
|
|
3
|
+
const activeHeartbeatRuns = new Map<string, ActiveHeartbeatRun>();
|
|
4
|
+
|
|
5
|
+
export function registerActiveHeartbeatRun(runId: string, run: ActiveHeartbeatRun) {
|
|
6
|
+
activeHeartbeatRuns.set(runId, run);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function unregisterActiveHeartbeatRun(runId: string) {
|
|
10
|
+
activeHeartbeatRuns.delete(runId);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getActiveHeartbeatRun(runId: string) {
|
|
14
|
+
return activeHeartbeatRuns.get(runId);
|
|
15
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { and, eq, inArray, approvalRequests, issues } from "bopodev-db";
|
|
2
|
+
import type { BopoDb } from "bopodev-db";
|
|
3
|
+
|
|
4
|
+
export async function findPendingProjectBudgetOverrideBlocksForAgent(
|
|
5
|
+
db: BopoDb,
|
|
6
|
+
companyId: string,
|
|
7
|
+
agentId: string
|
|
8
|
+
) {
|
|
9
|
+
const assignedRows = await db
|
|
10
|
+
.select({ projectId: issues.projectId })
|
|
11
|
+
.from(issues)
|
|
12
|
+
.where(
|
|
13
|
+
and(
|
|
14
|
+
eq(issues.companyId, companyId),
|
|
15
|
+
eq(issues.assigneeAgentId, agentId),
|
|
16
|
+
inArray(issues.status, ["todo", "in_progress"])
|
|
17
|
+
)
|
|
18
|
+
);
|
|
19
|
+
const assignedProjectIds = new Set(assignedRows.map((row) => row.projectId));
|
|
20
|
+
if (assignedProjectIds.size === 0) {
|
|
21
|
+
return [] as string[];
|
|
22
|
+
}
|
|
23
|
+
const pendingOverrides = await db
|
|
24
|
+
.select({ payloadJson: approvalRequests.payloadJson })
|
|
25
|
+
.from(approvalRequests)
|
|
26
|
+
.where(
|
|
27
|
+
and(
|
|
28
|
+
eq(approvalRequests.companyId, companyId),
|
|
29
|
+
eq(approvalRequests.action, "override_budget"),
|
|
30
|
+
eq(approvalRequests.status, "pending")
|
|
31
|
+
)
|
|
32
|
+
);
|
|
33
|
+
const blockedProjectIds = new Set<string>();
|
|
34
|
+
for (const approval of pendingOverrides) {
|
|
35
|
+
try {
|
|
36
|
+
const payload = JSON.parse(approval.payloadJson) as Record<string, unknown>;
|
|
37
|
+
const projectId = typeof payload.projectId === "string" ? payload.projectId.trim() : "";
|
|
38
|
+
if (projectId && assignedProjectIds.has(projectId)) {
|
|
39
|
+
blockedProjectIds.add(projectId);
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
// Ignore malformed payloads to keep enforcement resilient.
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return Array.from(blockedProjectIds);
|
|
46
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { and, eq, inArray, issues, sql } from "bopodev-db";
|
|
2
|
+
import type { BopoDb } from "bopodev-db";
|
|
3
|
+
|
|
4
|
+
export async function claimIssuesForAgent(
|
|
5
|
+
db: BopoDb,
|
|
6
|
+
companyId: string,
|
|
7
|
+
agentId: string,
|
|
8
|
+
heartbeatRunId: string,
|
|
9
|
+
maxItems = 5
|
|
10
|
+
) {
|
|
11
|
+
const result = await db.execute(sql`
|
|
12
|
+
WITH candidate AS (
|
|
13
|
+
SELECT id
|
|
14
|
+
FROM issues
|
|
15
|
+
WHERE company_id = ${companyId}
|
|
16
|
+
AND assignee_agent_id = ${agentId}
|
|
17
|
+
AND status IN ('todo', 'in_progress')
|
|
18
|
+
AND is_claimed = false
|
|
19
|
+
ORDER BY
|
|
20
|
+
CASE priority
|
|
21
|
+
WHEN 'urgent' THEN 0
|
|
22
|
+
WHEN 'high' THEN 1
|
|
23
|
+
WHEN 'medium' THEN 2
|
|
24
|
+
WHEN 'low' THEN 3
|
|
25
|
+
ELSE 4
|
|
26
|
+
END ASC,
|
|
27
|
+
updated_at ASC
|
|
28
|
+
LIMIT ${maxItems}
|
|
29
|
+
FOR UPDATE SKIP LOCKED
|
|
30
|
+
)
|
|
31
|
+
UPDATE issues i
|
|
32
|
+
SET is_claimed = true,
|
|
33
|
+
claimed_by_heartbeat_run_id = ${heartbeatRunId},
|
|
34
|
+
updated_at = CURRENT_TIMESTAMP
|
|
35
|
+
FROM candidate c
|
|
36
|
+
WHERE i.id = c.id
|
|
37
|
+
RETURNING i.id, i.project_id, i.parent_issue_id, i.title, i.body, i.status, i.priority, i.labels_json, i.tags_json;
|
|
38
|
+
`);
|
|
39
|
+
|
|
40
|
+
return result as unknown as Array<{
|
|
41
|
+
id: string;
|
|
42
|
+
project_id: string;
|
|
43
|
+
parent_issue_id: string | null;
|
|
44
|
+
title: string;
|
|
45
|
+
body: string | null;
|
|
46
|
+
status: string;
|
|
47
|
+
priority: string;
|
|
48
|
+
labels_json: string;
|
|
49
|
+
tags_json: string;
|
|
50
|
+
}>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function releaseClaimedIssues(db: BopoDb, companyId: string, issueIds: string[]) {
|
|
54
|
+
if (issueIds.length === 0) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
await db
|
|
58
|
+
.update(issues)
|
|
59
|
+
.set({ isClaimed: false, claimedByHeartbeatRunId: null, updatedAt: new Date() })
|
|
60
|
+
.where(and(eq(issues.companyId, companyId), inArray(issues.id, issueIds)));
|
|
61
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export function isHeartbeatDue(cronExpression: string, lastRunAt: Date | null, now: Date) {
|
|
2
|
+
const normalizedNow = truncateToMinute(now);
|
|
3
|
+
if (!matchesCronExpression(cronExpression, normalizedNow)) {
|
|
4
|
+
return false;
|
|
5
|
+
}
|
|
6
|
+
if (!lastRunAt) {
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
return truncateToMinute(lastRunAt).getTime() !== normalizedNow.getTime();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function truncateToMinute(date: Date) {
|
|
13
|
+
const clone = new Date(date);
|
|
14
|
+
clone.setSeconds(0, 0);
|
|
15
|
+
return clone;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function matchesCronExpression(expression: string, date: Date) {
|
|
19
|
+
const parts = expression.trim().split(/\s+/);
|
|
20
|
+
if (parts.length !== 5) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts as [string, string, string, string, string];
|
|
25
|
+
return (
|
|
26
|
+
matchesCronField(minute, date.getMinutes(), 0, 59) &&
|
|
27
|
+
matchesCronField(hour, date.getHours(), 0, 23) &&
|
|
28
|
+
matchesCronField(dayOfMonth, date.getDate(), 1, 31) &&
|
|
29
|
+
matchesCronField(month, date.getMonth() + 1, 1, 12) &&
|
|
30
|
+
matchesCronField(dayOfWeek, date.getDay(), 0, 6)
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function matchesCronField(field: string, value: number, min: number, max: number) {
|
|
35
|
+
return field.split(",").some((part) => matchesCronPart(part.trim(), value, min, max));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function matchesCronPart(part: string, value: number, min: number, max: number): boolean {
|
|
39
|
+
if (part === "*") {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const stepMatch = part.match(/^\*\/(\d+)$/);
|
|
44
|
+
if (stepMatch) {
|
|
45
|
+
const step = Number(stepMatch[1]);
|
|
46
|
+
return Number.isInteger(step) && step > 0 ? (value - min) % step === 0 : false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const rangeMatch = part.match(/^(\d+)-(\d+)$/);
|
|
50
|
+
if (rangeMatch) {
|
|
51
|
+
const start = Number(rangeMatch[1]);
|
|
52
|
+
const end = Number(rangeMatch[2]);
|
|
53
|
+
return start <= value && value <= end;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const exact = Number(part);
|
|
57
|
+
return Number.isInteger(exact) && exact >= min && exact <= max && exact === value;
|
|
58
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { RealtimeHub } from "../../realtime/hub";
|
|
2
|
+
import { createHeartbeatRunsRealtimeEvent } from "../../realtime/heartbeat-runs";
|
|
3
|
+
|
|
4
|
+
export function publishHeartbeatRunStatus(
|
|
5
|
+
realtimeHub: RealtimeHub | undefined,
|
|
6
|
+
input: {
|
|
7
|
+
companyId: string;
|
|
8
|
+
runId: string;
|
|
9
|
+
status: "started" | "completed" | "failed" | "skipped";
|
|
10
|
+
message?: string | null;
|
|
11
|
+
startedAt?: Date;
|
|
12
|
+
finishedAt?: Date;
|
|
13
|
+
}
|
|
14
|
+
) {
|
|
15
|
+
if (!realtimeHub) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
realtimeHub.publish(
|
|
19
|
+
createHeartbeatRunsRealtimeEvent(input.companyId, {
|
|
20
|
+
type: "run.status.updated",
|
|
21
|
+
runId: input.runId,
|
|
22
|
+
status: input.status,
|
|
23
|
+
message: input.message ?? null,
|
|
24
|
+
startedAt: input.startedAt?.toISOString(),
|
|
25
|
+
finishedAt: input.finishedAt?.toISOString() ?? null
|
|
26
|
+
})
|
|
27
|
+
);
|
|
28
|
+
}
|