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
package/src/server.ts
CHANGED
|
@@ -1,11 +1,5 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
|
-
import {
|
|
3
|
-
import { fileURLToPath } from "node:url";
|
|
4
|
-
import { sql } from "drizzle-orm";
|
|
5
|
-
import { config as loadDotenv } from "dotenv";
|
|
6
|
-
import { bootstrapDatabase, listCompanies, resolveDefaultDbPath } from "bopodev-db";
|
|
7
|
-
import { checkRuntimeCommandHealth } from "bopodev-agent-sdk";
|
|
8
|
-
import type { RuntimeCommandHealth } from "bopodev-agent-sdk";
|
|
2
|
+
import { listCompanies } from "bopodev-db";
|
|
9
3
|
import { createApp } from "./app";
|
|
10
4
|
import { loadGovernanceRealtimeSnapshot } from "./realtime/governance";
|
|
11
5
|
import { loadOfficeSpaceRealtimeSnapshot } from "./realtime/office-space";
|
|
@@ -13,7 +7,6 @@ import { loadHeartbeatRunsRealtimeSnapshot } from "./realtime/heartbeat-runs";
|
|
|
13
7
|
import { loadAttentionRealtimeSnapshot } from "./realtime/attention";
|
|
14
8
|
import { attachRealtimeHub } from "./realtime/hub";
|
|
15
9
|
import {
|
|
16
|
-
isAuthenticatedMode,
|
|
17
10
|
resolveAllowedHostnames,
|
|
18
11
|
resolveAllowedOrigins,
|
|
19
12
|
resolveDeploymentMode,
|
|
@@ -22,6 +15,17 @@ import {
|
|
|
22
15
|
import { ensureBuiltinPluginsRegistered } from "./services/plugin-runtime";
|
|
23
16
|
import { ensureBuiltinTemplatesRegistered } from "./services/template-catalog";
|
|
24
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";
|
|
25
29
|
|
|
26
30
|
loadApiEnv();
|
|
27
31
|
|
|
@@ -33,28 +37,7 @@ async function main() {
|
|
|
33
37
|
validateDeploymentConfiguration(deploymentMode, allowedOrigins, allowedHostnames, publicBaseUrl);
|
|
34
38
|
const dbPath = normalizeOptionalDbPath(process.env.BOPO_DB_PATH);
|
|
35
39
|
const port = Number(process.env.PORT ?? 4020);
|
|
36
|
-
const
|
|
37
|
-
let db: Awaited<ReturnType<typeof bootstrapDatabase>>["db"];
|
|
38
|
-
let dbClient: Awaited<ReturnType<typeof bootstrapDatabase>>["client"];
|
|
39
|
-
try {
|
|
40
|
-
const boot = await bootstrapDatabase(dbPath);
|
|
41
|
-
db = boot.db;
|
|
42
|
-
dbClient = boot.client;
|
|
43
|
-
} catch (error) {
|
|
44
|
-
if (isProbablyPgliteWasmAbort(error)) {
|
|
45
|
-
// eslint-disable-next-line no-console
|
|
46
|
-
console.error(
|
|
47
|
-
"[startup] PGlite (embedded Postgres) failed during database bootstrap. This is unrelated to Codex or heartbeat prompt settings."
|
|
48
|
-
);
|
|
49
|
-
// eslint-disable-next-line no-console
|
|
50
|
-
console.error(`[startup] Data path in use: ${effectiveDbPath}`);
|
|
51
|
-
// eslint-disable-next-line no-console
|
|
52
|
-
console.error(
|
|
53
|
-
"[startup] Recovery: stop all API/node processes using this DB, back up the path above, delete the file/dir, then restart (schema will be recreated). Or set BOPO_DB_PATH to a fresh path. See docs/operations/troubleshooting.md (PGlite)."
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
|
-
throw error;
|
|
57
|
-
}
|
|
40
|
+
const { db, client: dbClient } = await bootstrapDatabaseWithStartupLogging(dbPath);
|
|
58
41
|
const existingCompanies = await listCompanies(db);
|
|
59
42
|
await ensureBuiltinPluginsRegistered(
|
|
60
43
|
db,
|
|
@@ -74,54 +57,20 @@ async function main() {
|
|
|
74
57
|
const openCodeHealthRequired =
|
|
75
58
|
!skipOpenCodePreflight &&
|
|
76
59
|
(process.env.BOPO_REQUIRE_OPENCODE_HEALTH === "1" || (await hasOpenCodeAgentsConfigured(db)));
|
|
77
|
-
const getRuntimeHealth =
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const opencode = openCodeHealthRequired
|
|
92
|
-
? await checkRuntimeCommandHealth(openCodeCommand, {
|
|
93
|
-
timeoutMs: 5_000
|
|
94
|
-
})
|
|
95
|
-
: {
|
|
96
|
-
command: openCodeCommand,
|
|
97
|
-
available: skipOpenCodePreflight ? false : true,
|
|
98
|
-
exitCode: null,
|
|
99
|
-
elapsedMs: 0,
|
|
100
|
-
error: skipOpenCodePreflight
|
|
101
|
-
? "Skipped by configuration: BOPO_SKIP_OPENCODE_PREFLIGHT=1."
|
|
102
|
-
: "Skipped: no OpenCode agents configured."
|
|
103
|
-
};
|
|
104
|
-
return {
|
|
105
|
-
codex,
|
|
106
|
-
opencode
|
|
107
|
-
};
|
|
108
|
-
};
|
|
109
|
-
if (codexHealthRequired) {
|
|
110
|
-
const startupCodexHealth = await checkRuntimeCommandHealth(codexCommand, {
|
|
111
|
-
timeoutMs: 5_000
|
|
112
|
-
});
|
|
113
|
-
if (!startupCodexHealth.available) {
|
|
114
|
-
emitCodexPreflightWarning(startupCodexHealth);
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
if (openCodeHealthRequired) {
|
|
118
|
-
const startupOpenCodeHealth = await checkRuntimeCommandHealth(openCodeCommand, {
|
|
119
|
-
timeoutMs: 5_000
|
|
120
|
-
});
|
|
121
|
-
if (!startupOpenCodeHealth.available) {
|
|
122
|
-
emitOpenCodePreflightWarning(startupOpenCodeHealth);
|
|
123
|
-
}
|
|
124
|
-
}
|
|
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
|
+
});
|
|
125
74
|
|
|
126
75
|
const server = createServer();
|
|
127
76
|
const realtimeHub = attachRealtimeHub(server, {
|
|
@@ -141,210 +90,20 @@ async function main() {
|
|
|
141
90
|
|
|
142
91
|
const defaultCompanyId = process.env.BOPO_DEFAULT_COMPANY_ID;
|
|
143
92
|
const schedulerCompanyId = await resolveSchedulerCompanyId(db, defaultCompanyId ?? null);
|
|
144
|
-
let
|
|
93
|
+
let scheduler: ReturnType<typeof createHeartbeatScheduler> | undefined;
|
|
145
94
|
if (schedulerCompanyId && shouldStartScheduler()) {
|
|
146
|
-
|
|
95
|
+
scheduler = createHeartbeatScheduler(db, schedulerCompanyId, realtimeHub);
|
|
147
96
|
} else if (schedulerCompanyId) {
|
|
148
97
|
// eslint-disable-next-line no-console
|
|
149
98
|
console.log("[startup] Scheduler disabled for this instance (BOPO_SCHEDULER_ROLE is follower/off).");
|
|
150
99
|
}
|
|
151
100
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
try {
|
|
159
|
-
await realtimeHub.close();
|
|
160
|
-
} catch (closeError) {
|
|
161
|
-
// eslint-disable-next-line no-console
|
|
162
|
-
console.error("[shutdown] realtime hub close error", closeError);
|
|
163
|
-
}
|
|
164
|
-
await new Promise<void>((resolve, reject) => {
|
|
165
|
-
server.close((err) => {
|
|
166
|
-
if (err) {
|
|
167
|
-
reject(err);
|
|
168
|
-
return;
|
|
169
|
-
}
|
|
170
|
-
resolve();
|
|
171
|
-
});
|
|
172
|
-
});
|
|
173
|
-
try {
|
|
174
|
-
await closePgliteClient(dbClient);
|
|
175
|
-
} catch (closeDbError) {
|
|
176
|
-
// eslint-disable-next-line no-console
|
|
177
|
-
console.error("[shutdown] PGlite close error", closeDbError);
|
|
178
|
-
}
|
|
179
|
-
// eslint-disable-next-line no-console
|
|
180
|
-
console.log("[shutdown] clean exit");
|
|
181
|
-
process.exit(0);
|
|
182
|
-
})().catch((error) => {
|
|
183
|
-
// eslint-disable-next-line no-console
|
|
184
|
-
console.error("[shutdown] failed", error);
|
|
185
|
-
process.exit(1);
|
|
186
|
-
});
|
|
187
|
-
return shutdownInFlight;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
process.once("SIGINT", () => void shutdown("SIGINT"));
|
|
191
|
-
process.once("SIGTERM", () => void shutdown("SIGTERM"));
|
|
101
|
+
attachGracefulShutdownHandlers({
|
|
102
|
+
server,
|
|
103
|
+
realtimeHub,
|
|
104
|
+
dbClient,
|
|
105
|
+
scheduler
|
|
106
|
+
});
|
|
192
107
|
}
|
|
193
108
|
|
|
194
109
|
void main();
|
|
195
|
-
|
|
196
|
-
async function closePgliteClient(client: unknown) {
|
|
197
|
-
if (!client || typeof client !== "object") {
|
|
198
|
-
return;
|
|
199
|
-
}
|
|
200
|
-
const closeFn = (client as { close?: unknown }).close;
|
|
201
|
-
if (typeof closeFn !== "function") {
|
|
202
|
-
return;
|
|
203
|
-
}
|
|
204
|
-
await (closeFn as () => Promise<void>)();
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
async function hasCodexAgentsConfigured(db: Awaited<ReturnType<typeof bootstrapDatabase>>["db"]) {
|
|
208
|
-
const result = await db.execute(sql`
|
|
209
|
-
SELECT id
|
|
210
|
-
FROM agents
|
|
211
|
-
WHERE provider_type = 'codex'
|
|
212
|
-
LIMIT 1
|
|
213
|
-
`);
|
|
214
|
-
return (result.rows ?? []).length > 0;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
async function hasOpenCodeAgentsConfigured(db: Awaited<ReturnType<typeof bootstrapDatabase>>["db"]) {
|
|
218
|
-
const result = await db.execute(sql`
|
|
219
|
-
SELECT id
|
|
220
|
-
FROM agents
|
|
221
|
-
WHERE provider_type = 'opencode'
|
|
222
|
-
LIMIT 1
|
|
223
|
-
`);
|
|
224
|
-
return (result.rows ?? []).length > 0;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
async function resolveSchedulerCompanyId(
|
|
228
|
-
db: Awaited<ReturnType<typeof bootstrapDatabase>>["db"],
|
|
229
|
-
configuredCompanyId: string | null
|
|
230
|
-
) {
|
|
231
|
-
if (configuredCompanyId) {
|
|
232
|
-
const configured = await db.execute(sql`
|
|
233
|
-
SELECT id
|
|
234
|
-
FROM companies
|
|
235
|
-
WHERE id = ${configuredCompanyId}
|
|
236
|
-
LIMIT 1
|
|
237
|
-
`);
|
|
238
|
-
if ((configured.rows ?? []).length > 0) {
|
|
239
|
-
return configuredCompanyId;
|
|
240
|
-
}
|
|
241
|
-
// eslint-disable-next-line no-console
|
|
242
|
-
console.warn(`[startup] BOPO_DEFAULT_COMPANY_ID='${configuredCompanyId}' was not found; using first available company.`);
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
const fallback = await db.execute(sql`
|
|
246
|
-
SELECT id
|
|
247
|
-
FROM companies
|
|
248
|
-
ORDER BY created_at ASC
|
|
249
|
-
LIMIT 1
|
|
250
|
-
`);
|
|
251
|
-
const id = fallback.rows?.[0]?.id;
|
|
252
|
-
return typeof id === "string" && id.length > 0 ? id : null;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
function emitCodexPreflightWarning(health: RuntimeCommandHealth) {
|
|
256
|
-
const red = process.stderr.isTTY ? "\x1b[31m" : "";
|
|
257
|
-
const yellow = process.stderr.isTTY ? "\x1b[33m" : "";
|
|
258
|
-
const reset = process.stderr.isTTY ? "\x1b[0m" : "";
|
|
259
|
-
const symbol = `${red}✖${reset}`;
|
|
260
|
-
process.stderr.write(
|
|
261
|
-
`${symbol} ${yellow}Codex preflight failed${reset}: command '${health.command}' is unavailable.\n`
|
|
262
|
-
);
|
|
263
|
-
process.stderr.write(` Install Codex CLI or set BOPO_SKIP_CODEX_PREFLIGHT=1 for local dev.\n`);
|
|
264
|
-
if (process.env.BOPO_VERBOSE_STARTUP_WARNINGS === "1") {
|
|
265
|
-
process.stderr.write(` Details: ${JSON.stringify(health)}\n`);
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
function emitOpenCodePreflightWarning(health: RuntimeCommandHealth) {
|
|
270
|
-
const red = process.stderr.isTTY ? "\x1b[31m" : "";
|
|
271
|
-
const yellow = process.stderr.isTTY ? "\x1b[33m" : "";
|
|
272
|
-
const reset = process.stderr.isTTY ? "\x1b[0m" : "";
|
|
273
|
-
const symbol = `${red}✖${reset}`;
|
|
274
|
-
process.stderr.write(
|
|
275
|
-
`${symbol} ${yellow}OpenCode preflight failed${reset}: command '${health.command}' is unavailable.\n`
|
|
276
|
-
);
|
|
277
|
-
process.stderr.write(` Install OpenCode CLI or set BOPO_SKIP_OPENCODE_PREFLIGHT=1 for local dev.\n`);
|
|
278
|
-
if (process.env.BOPO_VERBOSE_STARTUP_WARNINGS === "1") {
|
|
279
|
-
process.stderr.write(` Details: ${JSON.stringify(health)}\n`);
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
function validateDeploymentConfiguration(
|
|
284
|
-
deploymentMode: ReturnType<typeof resolveDeploymentMode>,
|
|
285
|
-
allowedOrigins: string[],
|
|
286
|
-
allowedHostnames: string[],
|
|
287
|
-
publicBaseUrl: URL | null
|
|
288
|
-
) {
|
|
289
|
-
if (deploymentMode === "authenticated_public" && !publicBaseUrl) {
|
|
290
|
-
throw new Error("BOPO_PUBLIC_BASE_URL is required in authenticated_public mode.");
|
|
291
|
-
}
|
|
292
|
-
if (isAuthenticatedMode(deploymentMode) && process.env.BOPO_AUTH_TOKEN_SECRET?.trim() === "") {
|
|
293
|
-
throw new Error("BOPO_AUTH_TOKEN_SECRET must not be empty when set.");
|
|
294
|
-
}
|
|
295
|
-
if (isAuthenticatedMode(deploymentMode) && !process.env.BOPO_AUTH_TOKEN_SECRET?.trim()) {
|
|
296
|
-
// eslint-disable-next-line no-console
|
|
297
|
-
console.warn(
|
|
298
|
-
"[startup] BOPO_AUTH_TOKEN_SECRET is not set. Authenticated modes will require BOPO_TRUST_ACTOR_HEADERS=1 behind a trusted proxy."
|
|
299
|
-
);
|
|
300
|
-
}
|
|
301
|
-
if (isAuthenticatedMode(deploymentMode) && process.env.BOPO_TRUST_ACTOR_HEADERS !== "1" && !process.env.BOPO_AUTH_TOKEN_SECRET?.trim()) {
|
|
302
|
-
throw new Error(
|
|
303
|
-
"Authenticated mode requires either BOPO_AUTH_TOKEN_SECRET (token identity) or BOPO_TRUST_ACTOR_HEADERS=1 (trusted proxy headers)."
|
|
304
|
-
);
|
|
305
|
-
}
|
|
306
|
-
if (isAuthenticatedMode(deploymentMode) && process.env.BOPO_ALLOW_LOCAL_BOARD_FALLBACK === "1") {
|
|
307
|
-
throw new Error("BOPO_ALLOW_LOCAL_BOARD_FALLBACK cannot be enabled in authenticated modes.");
|
|
308
|
-
}
|
|
309
|
-
// eslint-disable-next-line no-console
|
|
310
|
-
console.log(
|
|
311
|
-
`[startup] Deployment config: mode=${deploymentMode} origins=${allowedOrigins.join(",")} hosts=${allowedHostnames.join(",")}`
|
|
312
|
-
);
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
function shouldStartScheduler() {
|
|
316
|
-
const rawRole = (process.env.BOPO_SCHEDULER_ROLE ?? "auto").trim().toLowerCase();
|
|
317
|
-
if (rawRole === "off" || rawRole === "follower") {
|
|
318
|
-
return false;
|
|
319
|
-
}
|
|
320
|
-
if (rawRole === "leader" || rawRole === "auto") {
|
|
321
|
-
return true;
|
|
322
|
-
}
|
|
323
|
-
throw new Error(`Invalid BOPO_SCHEDULER_ROLE '${rawRole}'. Expected one of: auto, leader, follower, off.`);
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
function loadApiEnv() {
|
|
327
|
-
const sourceDir = dirname(fileURLToPath(import.meta.url));
|
|
328
|
-
const repoRoot = resolve(sourceDir, "../../../");
|
|
329
|
-
const candidates = [resolve(repoRoot, ".env.local"), resolve(repoRoot, ".env")];
|
|
330
|
-
for (const path of candidates) {
|
|
331
|
-
loadDotenv({ path, override: false, quiet: true });
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
function normalizeOptionalDbPath(value: string | undefined) {
|
|
336
|
-
const normalized = value?.trim();
|
|
337
|
-
return normalized && normalized.length > 0 ? normalized : undefined;
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
function isProbablyPgliteWasmAbort(error: unknown): boolean {
|
|
341
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
342
|
-
const cause = error instanceof Error ? error.cause : undefined;
|
|
343
|
-
const causeMessage = cause instanceof Error ? cause.message : String(cause ?? "");
|
|
344
|
-
return (
|
|
345
|
-
message.includes("Aborted") ||
|
|
346
|
-
causeMessage.includes("Aborted") ||
|
|
347
|
-
message.includes("pglite") ||
|
|
348
|
-
causeMessage.includes("RuntimeError")
|
|
349
|
-
);
|
|
350
|
-
}
|
|
@@ -1,21 +1,38 @@
|
|
|
1
|
-
import { and, desc, eq, inArray, like } from "drizzle-orm";
|
|
2
1
|
import {
|
|
2
|
+
and,
|
|
3
3
|
agents,
|
|
4
|
+
desc,
|
|
5
|
+
eq,
|
|
6
|
+
inArray,
|
|
4
7
|
issueComments,
|
|
8
|
+
like,
|
|
5
9
|
updateIssueCommentRecipients,
|
|
6
10
|
type BopoDb
|
|
7
11
|
} from "bopodev-db";
|
|
12
|
+
import { createDrainableWorkTracker } from "../lib/drainable-work";
|
|
8
13
|
import { parseIssueCommentRecipients, type PersistedCommentRecipient } from "../lib/comment-recipients";
|
|
9
14
|
import type { RealtimeHub } from "../realtime/hub";
|
|
10
15
|
import { enqueueHeartbeatQueueJob, triggerHeartbeatQueueWorker } from "./heartbeat-queue-service";
|
|
11
16
|
|
|
12
17
|
const COMMENT_DISPATCH_SWEEP_LIMIT = 100;
|
|
13
18
|
const activeCompanyDispatchRuns = new Set<string>();
|
|
19
|
+
const commentDispatchTracker = createDrainableWorkTracker();
|
|
14
20
|
|
|
15
21
|
export async function runIssueCommentDispatchSweep(
|
|
16
22
|
db: BopoDb,
|
|
17
23
|
companyId: string,
|
|
18
24
|
options?: { requestId?: string; realtimeHub?: RealtimeHub; limit?: number }
|
|
25
|
+
) {
|
|
26
|
+
if (commentDispatchTracker.isShuttingDown()) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
return commentDispatchTracker.track(runIssueCommentDispatchSweepInternal(db, companyId, options));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function runIssueCommentDispatchSweepInternal(
|
|
33
|
+
db: BopoDb,
|
|
34
|
+
companyId: string,
|
|
35
|
+
options?: { requestId?: string; realtimeHub?: RealtimeHub; limit?: number }
|
|
19
36
|
) {
|
|
20
37
|
const rows = await db
|
|
21
38
|
.select({
|
|
@@ -34,6 +51,9 @@ export async function runIssueCommentDispatchSweep(
|
|
|
34
51
|
.limit(options?.limit ?? COMMENT_DISPATCH_SWEEP_LIMIT);
|
|
35
52
|
|
|
36
53
|
for (const row of rows) {
|
|
54
|
+
if (commentDispatchTracker.isShuttingDown()) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
37
57
|
const recipients = parseIssueCommentRecipients(row.recipientsJson);
|
|
38
58
|
if (!recipients.some((recipient) => recipient.deliveryStatus === "pending")) {
|
|
39
59
|
continue;
|
|
@@ -60,11 +80,15 @@ export function triggerIssueCommentDispatchWorker(
|
|
|
60
80
|
companyId: string,
|
|
61
81
|
options?: { requestId?: string; realtimeHub?: RealtimeHub; limit?: number }
|
|
62
82
|
) {
|
|
63
|
-
if (activeCompanyDispatchRuns.has(companyId)) {
|
|
83
|
+
if (commentDispatchTracker.isShuttingDown() || activeCompanyDispatchRuns.has(companyId)) {
|
|
64
84
|
return;
|
|
65
85
|
}
|
|
66
86
|
activeCompanyDispatchRuns.add(companyId);
|
|
67
87
|
queueMicrotask(() => {
|
|
88
|
+
if (commentDispatchTracker.isShuttingDown()) {
|
|
89
|
+
activeCompanyDispatchRuns.delete(companyId);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
68
92
|
void runIssueCommentDispatchSweep(db, companyId, options)
|
|
69
93
|
.catch((error) => {
|
|
70
94
|
// eslint-disable-next-line no-console
|
|
@@ -156,3 +180,16 @@ async function dispatchCommentRecipients(
|
|
|
156
180
|
return dispatchedRecipients;
|
|
157
181
|
}
|
|
158
182
|
|
|
183
|
+
export function beginIssueCommentDispatchShutdown() {
|
|
184
|
+
commentDispatchTracker.beginShutdown();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function waitForIssueCommentDispatchDrain() {
|
|
188
|
+
await commentDispatchTracker.drain();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function resetIssueCommentDispatchShutdownForTests() {
|
|
192
|
+
activeCompanyDispatchRuns.clear();
|
|
193
|
+
commentDispatchTracker.resetForTests();
|
|
194
|
+
}
|
|
195
|
+
|
|
@@ -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
|
+
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { and, eq } from "drizzle-orm";
|
|
2
1
|
import { mkdir } from "node:fs/promises";
|
|
3
2
|
import { z } from "zod";
|
|
4
3
|
import {
|
|
@@ -10,6 +9,7 @@ import {
|
|
|
10
9
|
} from "bopodev-contracts";
|
|
11
10
|
import type { BopoDb } from "bopodev-db";
|
|
12
11
|
import {
|
|
12
|
+
and,
|
|
13
13
|
approvalRequests,
|
|
14
14
|
agents,
|
|
15
15
|
appendAuditEvent,
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
listProjectWorkspaces,
|
|
27
27
|
listProjects,
|
|
28
28
|
projects,
|
|
29
|
+
eq,
|
|
29
30
|
updateProjectWorkspace,
|
|
30
31
|
updatePluginConfig
|
|
31
32
|
} from "bopodev-db";
|
|
@@ -93,6 +94,7 @@ const hireAgentPayloadSchema = AgentCreateRequestSchema.extend({
|
|
|
93
94
|
const activateGoalPayloadSchema = z.object({
|
|
94
95
|
projectId: z.string().optional(),
|
|
95
96
|
parentGoalId: z.string().optional(),
|
|
97
|
+
ownerAgentId: z.string().optional(),
|
|
96
98
|
level: z.enum(["company", "project", "agent"]),
|
|
97
99
|
title: z.string().min(1),
|
|
98
100
|
description: z.string().optional()
|
|
@@ -371,6 +373,7 @@ async function applyApprovalAction(db: BopoDb, companyId: string, action: string
|
|
|
371
373
|
companyId,
|
|
372
374
|
projectId: parsed.data.projectId,
|
|
373
375
|
parentGoalId: parsed.data.parentGoalId,
|
|
376
|
+
ownerAgentId: parsed.data.ownerAgentId,
|
|
374
377
|
level: parsed.data.level,
|
|
375
378
|
title: parsed.data.title,
|
|
376
379
|
description: parsed.data.description
|
|
@@ -783,7 +786,8 @@ function buildAgentStartupTaskBody(companyId: string, agentId: string) {
|
|
|
783
786
|
` - \`${agentOperatingFolder}/HEARTBEAT.md\``,
|
|
784
787
|
` - \`${agentOperatingFolder}/SOUL.md\``,
|
|
785
788
|
` - \`${agentOperatingFolder}/TOOLS.md\``,
|
|
786
|
-
|
|
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.",
|
|
787
791
|
"4. Post an issue comment summarizing completed setup artifacts.",
|
|
788
792
|
"",
|
|
789
793
|
"Safety checks:",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { and, eq } from "drizzle-orm";
|
|
2
1
|
import {
|
|
2
|
+
and,
|
|
3
3
|
cancelHeartbeatJob,
|
|
4
|
+
eq,
|
|
4
5
|
getHeartbeatRun,
|
|
5
6
|
claimNextHeartbeatJob,
|
|
6
7
|
enqueueHeartbeatJob,
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
updateIssueCommentRecipients,
|
|
12
13
|
type BopoDb
|
|
13
14
|
} from "bopodev-db";
|
|
15
|
+
import { createDrainableWorkTracker } from "../lib/drainable-work";
|
|
14
16
|
import { parseIssueCommentRecipients } from "../lib/comment-recipients";
|
|
15
17
|
import type { RealtimeHub } from "../realtime/hub";
|
|
16
18
|
import { runHeartbeatForAgent } from "./heartbeat-service";
|
|
@@ -30,6 +32,7 @@ type QueueJobPayload = {
|
|
|
30
32
|
};
|
|
31
33
|
|
|
32
34
|
const activeCompanyQueueWorkers = new Set<string>();
|
|
35
|
+
const queueWorkTracker = createDrainableWorkTracker();
|
|
33
36
|
|
|
34
37
|
export async function enqueueHeartbeatQueueJob(
|
|
35
38
|
db: BopoDb,
|
|
@@ -61,11 +64,15 @@ export function triggerHeartbeatQueueWorker(
|
|
|
61
64
|
companyId: string,
|
|
62
65
|
options?: { requestId?: string; realtimeHub?: RealtimeHub; maxJobsPerSweep?: number }
|
|
63
66
|
) {
|
|
64
|
-
if (activeCompanyQueueWorkers.has(companyId)) {
|
|
67
|
+
if (queueWorkTracker.isShuttingDown() || activeCompanyQueueWorkers.has(companyId)) {
|
|
65
68
|
return;
|
|
66
69
|
}
|
|
67
70
|
activeCompanyQueueWorkers.add(companyId);
|
|
68
71
|
queueMicrotask(() => {
|
|
72
|
+
if (queueWorkTracker.isShuttingDown()) {
|
|
73
|
+
activeCompanyQueueWorkers.delete(companyId);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
69
76
|
void runHeartbeatQueueSweep(db, companyId, options)
|
|
70
77
|
.catch((error) => {
|
|
71
78
|
// eslint-disable-next-line no-console
|
|
@@ -81,10 +88,21 @@ export async function runHeartbeatQueueSweep(
|
|
|
81
88
|
db: BopoDb,
|
|
82
89
|
companyId: string,
|
|
83
90
|
options?: { requestId?: string; realtimeHub?: RealtimeHub; maxJobsPerSweep?: number }
|
|
91
|
+
) {
|
|
92
|
+
if (queueWorkTracker.isShuttingDown()) {
|
|
93
|
+
return { processed: 0 };
|
|
94
|
+
}
|
|
95
|
+
return queueWorkTracker.track(runHeartbeatQueueSweepInternal(db, companyId, options));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function runHeartbeatQueueSweepInternal(
|
|
99
|
+
db: BopoDb,
|
|
100
|
+
companyId: string,
|
|
101
|
+
options?: { requestId?: string; realtimeHub?: RealtimeHub; maxJobsPerSweep?: number }
|
|
84
102
|
) {
|
|
85
103
|
const maxJobs = Math.max(1, Math.min(options?.maxJobsPerSweep ?? 50, 500));
|
|
86
104
|
let processed = 0;
|
|
87
|
-
while (processed < maxJobs) {
|
|
105
|
+
while (processed < maxJobs && !queueWorkTracker.isShuttingDown()) {
|
|
88
106
|
const job = await claimNextHeartbeatJob(db, companyId);
|
|
89
107
|
if (!job) {
|
|
90
108
|
break;
|
|
@@ -117,6 +135,19 @@ export async function runHeartbeatQueueSweep(
|
|
|
117
135
|
return { processed };
|
|
118
136
|
}
|
|
119
137
|
|
|
138
|
+
export function beginHeartbeatQueueShutdown() {
|
|
139
|
+
queueWorkTracker.beginShutdown();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function waitForHeartbeatQueueDrain() {
|
|
143
|
+
await queueWorkTracker.drain();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function resetHeartbeatQueueShutdownForTests() {
|
|
147
|
+
activeCompanyQueueWorkers.clear();
|
|
148
|
+
queueWorkTracker.resetForTests();
|
|
149
|
+
}
|
|
150
|
+
|
|
120
151
|
async function processHeartbeatQueueJob(
|
|
121
152
|
db: BopoDb,
|
|
122
153
|
input: {
|
|
@@ -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
|
+
}
|