bopodev-api 0.1.26 → 0.1.28
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/README.md +44 -0
- package/package.json +4 -4
- package/src/app.ts +1 -2
- package/src/lib/drainable-work.ts +36 -0
- package/src/lib/run-artifact-paths.ts +80 -0
- package/src/lib/workspace-policy.ts +1 -2
- package/src/pricing/anthropic.ts +23 -0
- package/src/pricing/gemini.ts +11 -0
- package/src/pricing/index.ts +29 -0
- package/src/pricing/openai.ts +47 -0
- package/src/pricing/opencode.ts +5 -0
- package/src/pricing/types.ts +8 -0
- package/src/realtime/office-space.ts +3 -1
- package/src/routes/companies.ts +0 -2
- package/src/routes/heartbeats.ts +1 -2
- package/src/routes/issues.ts +20 -2
- package/src/routes/observability.ts +3 -136
- package/src/scripts/onboard-seed.ts +1 -3
- package/src/server.ts +112 -8
- package/src/services/attention-service.ts +90 -47
- package/src/services/budget-service.ts +1 -2
- package/src/services/comment-recipient-dispatch-service.ts +39 -2
- package/src/services/governance-service.ts +3 -1
- package/src/services/heartbeat-queue-service.ts +34 -3
- package/src/services/heartbeat-service.ts +140 -26
- package/src/services/model-pricing.ts +4 -128
- package/src/worker/scheduler.ts +20 -4
package/src/server.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
2
|
import { dirname, resolve } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
-
import { sql } from "drizzle-orm";
|
|
5
4
|
import { config as loadDotenv } from "dotenv";
|
|
6
|
-
import { bootstrapDatabase, listCompanies } from "bopodev-db";
|
|
5
|
+
import { bootstrapDatabase, listCompanies, resolveDefaultDbPath, sql } from "bopodev-db";
|
|
7
6
|
import { checkRuntimeCommandHealth } from "bopodev-agent-sdk";
|
|
8
7
|
import type { RuntimeCommandHealth } from "bopodev-agent-sdk";
|
|
9
8
|
import { createApp } from "./app";
|
|
@@ -21,6 +20,8 @@ import {
|
|
|
21
20
|
} from "./security/deployment-mode";
|
|
22
21
|
import { ensureBuiltinPluginsRegistered } from "./services/plugin-runtime";
|
|
23
22
|
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";
|
|
24
25
|
import { createHeartbeatScheduler } from "./worker/scheduler";
|
|
25
26
|
|
|
26
27
|
loadApiEnv();
|
|
@@ -32,8 +33,33 @@ async function main() {
|
|
|
32
33
|
const publicBaseUrl = resolvePublicBaseUrl();
|
|
33
34
|
validateDeploymentConfiguration(deploymentMode, allowedOrigins, allowedHostnames, publicBaseUrl);
|
|
34
35
|
const dbPath = normalizeOptionalDbPath(process.env.BOPO_DB_PATH);
|
|
36
|
+
const usingExternalDatabase = Boolean(process.env.DATABASE_URL?.trim());
|
|
35
37
|
const port = Number(process.env.PORT ?? 4020);
|
|
36
|
-
const
|
|
38
|
+
const effectiveDbPath = dbPath ?? resolveDefaultDbPath();
|
|
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
|
+
}
|
|
37
63
|
const existingCompanies = await listCompanies(db);
|
|
38
64
|
await ensureBuiltinPluginsRegistered(
|
|
39
65
|
db,
|
|
@@ -120,16 +146,81 @@ async function main() {
|
|
|
120
146
|
|
|
121
147
|
const defaultCompanyId = process.env.BOPO_DEFAULT_COMPANY_ID;
|
|
122
148
|
const schedulerCompanyId = await resolveSchedulerCompanyId(db, defaultCompanyId ?? null);
|
|
149
|
+
let scheduler: ReturnType<typeof createHeartbeatScheduler> | undefined;
|
|
123
150
|
if (schedulerCompanyId && shouldStartScheduler()) {
|
|
124
|
-
createHeartbeatScheduler(db, schedulerCompanyId, realtimeHub);
|
|
151
|
+
scheduler = createHeartbeatScheduler(db, schedulerCompanyId, realtimeHub);
|
|
125
152
|
} else if (schedulerCompanyId) {
|
|
126
153
|
// eslint-disable-next-line no-console
|
|
127
154
|
console.log("[startup] Scheduler disabled for this instance (BOPO_SCHEDULER_ROLE is follower/off).");
|
|
128
155
|
}
|
|
156
|
+
|
|
157
|
+
let shutdownInFlight: Promise<void> | null = null;
|
|
158
|
+
function shutdown(signal: string) {
|
|
159
|
+
const shutdownTimeoutMs = Number(process.env.BOPO_SHUTDOWN_TIMEOUT_MS ?? 15_000);
|
|
160
|
+
const forcedExit = setTimeout(() => {
|
|
161
|
+
// eslint-disable-next-line no-console
|
|
162
|
+
console.error(`[shutdown] timed out after ${shutdownTimeoutMs}ms; forcing exit.`);
|
|
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"));
|
|
129
209
|
}
|
|
130
210
|
|
|
131
211
|
void main();
|
|
132
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
|
+
|
|
133
224
|
async function hasCodexAgentsConfigured(db: Awaited<ReturnType<typeof bootstrapDatabase>>["db"]) {
|
|
134
225
|
const result = await db.execute(sql`
|
|
135
226
|
SELECT id
|
|
@@ -137,7 +228,7 @@ async function hasCodexAgentsConfigured(db: Awaited<ReturnType<typeof bootstrapD
|
|
|
137
228
|
WHERE provider_type = 'codex'
|
|
138
229
|
LIMIT 1
|
|
139
230
|
`);
|
|
140
|
-
return
|
|
231
|
+
return result.length > 0;
|
|
141
232
|
}
|
|
142
233
|
|
|
143
234
|
async function hasOpenCodeAgentsConfigured(db: Awaited<ReturnType<typeof bootstrapDatabase>>["db"]) {
|
|
@@ -147,7 +238,7 @@ async function hasOpenCodeAgentsConfigured(db: Awaited<ReturnType<typeof bootstr
|
|
|
147
238
|
WHERE provider_type = 'opencode'
|
|
148
239
|
LIMIT 1
|
|
149
240
|
`);
|
|
150
|
-
return
|
|
241
|
+
return result.length > 0;
|
|
151
242
|
}
|
|
152
243
|
|
|
153
244
|
async function resolveSchedulerCompanyId(
|
|
@@ -161,7 +252,7 @@ async function resolveSchedulerCompanyId(
|
|
|
161
252
|
WHERE id = ${configuredCompanyId}
|
|
162
253
|
LIMIT 1
|
|
163
254
|
`);
|
|
164
|
-
if (
|
|
255
|
+
if (configured.length > 0) {
|
|
165
256
|
return configuredCompanyId;
|
|
166
257
|
}
|
|
167
258
|
// eslint-disable-next-line no-console
|
|
@@ -174,7 +265,7 @@ async function resolveSchedulerCompanyId(
|
|
|
174
265
|
ORDER BY created_at ASC
|
|
175
266
|
LIMIT 1
|
|
176
267
|
`);
|
|
177
|
-
const id = fallback
|
|
268
|
+
const id = fallback[0]?.id;
|
|
178
269
|
return typeof id === "string" && id.length > 0 ? id : null;
|
|
179
270
|
}
|
|
180
271
|
|
|
@@ -262,3 +353,16 @@ function normalizeOptionalDbPath(value: string | undefined) {
|
|
|
262
353
|
const normalized = value?.trim();
|
|
263
354
|
return normalized && normalized.length > 0 ? normalized : undefined;
|
|
264
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
|
+
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import { and, desc, eq, like } from "drizzle-orm";
|
|
2
1
|
import {
|
|
2
|
+
and,
|
|
3
3
|
issueComments,
|
|
4
4
|
issues,
|
|
5
|
+
desc,
|
|
6
|
+
eq,
|
|
7
|
+
like,
|
|
5
8
|
listApprovalRequests,
|
|
6
9
|
listAttentionInboxStates,
|
|
7
10
|
listHeartbeatRuns,
|
|
@@ -17,6 +20,40 @@ import type { BoardAttentionItem } from "bopodev-contracts";
|
|
|
17
20
|
|
|
18
21
|
type AttentionStateRow = Awaited<ReturnType<typeof listAttentionInboxStates>>[number];
|
|
19
22
|
|
|
23
|
+
/** Keep in sync with `RESOLVED_APPROVAL_INBOX_WINDOW_DAYS` in governance routes (resolved history in Inbox). */
|
|
24
|
+
const RESOLVED_APPROVAL_ATTENTION_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
|
|
25
|
+
|
|
26
|
+
type StoredApprovalRow = Awaited<ReturnType<typeof listApprovalRequests>>[number];
|
|
27
|
+
|
|
28
|
+
function approvalIncludedInAttentionList(approval: Pick<StoredApprovalRow, "status" | "resolvedAt">): boolean {
|
|
29
|
+
if (approval.status === "pending") {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
if (!approval.resolvedAt) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
return Date.now() - approval.resolvedAt.getTime() <= RESOLVED_APPROVAL_ATTENTION_WINDOW_MS;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function finalizeApprovalAttentionItem(item: BoardAttentionItem, approval: Pick<StoredApprovalRow, "status" | "resolvedAt" | "action">): BoardAttentionItem {
|
|
39
|
+
if (approval.status === "pending") {
|
|
40
|
+
return item;
|
|
41
|
+
}
|
|
42
|
+
const outcome =
|
|
43
|
+
approval.status === "approved" ? "Approved" : approval.status === "rejected" ? "Rejected" : "Overridden";
|
|
44
|
+
const title =
|
|
45
|
+
approval.action === "override_budget" ? `Budget hard-stop · ${outcome}` : `Approval · ${outcome}`;
|
|
46
|
+
const resolvedAtIso = approval.resolvedAt?.toISOString() ?? item.resolvedAt;
|
|
47
|
+
return {
|
|
48
|
+
...item,
|
|
49
|
+
title,
|
|
50
|
+
state: "resolved",
|
|
51
|
+
resolvedAt: resolvedAtIso,
|
|
52
|
+
severity: "info",
|
|
53
|
+
sourceTimestamp: resolvedAtIso ?? item.sourceTimestamp
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
20
57
|
export async function listBoardAttentionItems(db: BopoDb, companyId: string, actorId: string): Promise<BoardAttentionItem[]> {
|
|
21
58
|
const [approvals, blockedIssues, heartbeatRuns, stateRows, boardComments] = await Promise.all([
|
|
22
59
|
listApprovalRequests(db, companyId),
|
|
@@ -42,7 +79,7 @@ export async function listBoardAttentionItems(db: BopoDb, companyId: string, act
|
|
|
42
79
|
const items: BoardAttentionItem[] = [];
|
|
43
80
|
|
|
44
81
|
for (const approval of approvals) {
|
|
45
|
-
if (approval
|
|
82
|
+
if (!approvalIncludedInAttentionList(approval)) {
|
|
46
83
|
continue;
|
|
47
84
|
}
|
|
48
85
|
const payload = parsePayload(approval.payloadJson);
|
|
@@ -55,25 +92,61 @@ export async function listBoardAttentionItems(db: BopoDb, companyId: string, act
|
|
|
55
92
|
const usedBudget = asNumber(payload.usedBudgetUsd);
|
|
56
93
|
const key = `budget:${approval.id}`;
|
|
57
94
|
items.push(
|
|
95
|
+
finalizeApprovalAttentionItem(
|
|
96
|
+
withState(
|
|
97
|
+
{
|
|
98
|
+
key,
|
|
99
|
+
category: "budget_hard_stop",
|
|
100
|
+
severity: ageHours >= 12 ? "critical" : "warning",
|
|
101
|
+
requiredActor: "board",
|
|
102
|
+
title: "Budget hard-stop requires board decision",
|
|
103
|
+
contextSummary: projectId
|
|
104
|
+
? `Project ${shortId(projectId)} is blocked by budget hard-stop.`
|
|
105
|
+
: agentId
|
|
106
|
+
? `Agent ${shortId(agentId)} is blocked by budget hard-stop.`
|
|
107
|
+
: "Agent work is blocked by budget hard-stop.",
|
|
108
|
+
actionLabel: "Review budget override",
|
|
109
|
+
actionHref: "/governance",
|
|
110
|
+
impactSummary: "Heartbeat work stays paused until budget override is approved or rejected.",
|
|
111
|
+
evidence: {
|
|
112
|
+
approvalId: approval.id,
|
|
113
|
+
projectId: projectId ?? undefined,
|
|
114
|
+
agentId: agentId ?? undefined
|
|
115
|
+
},
|
|
116
|
+
sourceTimestamp: approval.createdAt.toISOString(),
|
|
117
|
+
state: "open",
|
|
118
|
+
seenAt: null,
|
|
119
|
+
acknowledgedAt: null,
|
|
120
|
+
dismissedAt: null,
|
|
121
|
+
resolvedAt: null
|
|
122
|
+
},
|
|
123
|
+
stateByKey.get(key),
|
|
124
|
+
`Budget utilization ${formatPercent(utilizationPct)} (${formatUsd(usedBudget)} / ${formatUsd(currentBudget)}).`
|
|
125
|
+
),
|
|
126
|
+
approval
|
|
127
|
+
)
|
|
128
|
+
);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const key = `approval:${approval.id}`;
|
|
133
|
+
items.push(
|
|
134
|
+
finalizeApprovalAttentionItem(
|
|
58
135
|
withState(
|
|
59
136
|
{
|
|
60
137
|
key,
|
|
61
|
-
category: "
|
|
62
|
-
severity: ageHours >=
|
|
138
|
+
category: "approval_required",
|
|
139
|
+
severity: ageHours >= 24 ? "critical" : "warning",
|
|
63
140
|
requiredActor: "board",
|
|
64
|
-
title: "
|
|
65
|
-
contextSummary:
|
|
66
|
-
|
|
67
|
-
: agentId
|
|
68
|
-
? `Agent ${shortId(agentId)} is blocked by budget hard-stop.`
|
|
69
|
-
: "Agent work is blocked by budget hard-stop.",
|
|
70
|
-
actionLabel: "Review budget override",
|
|
141
|
+
title: "Approval required",
|
|
142
|
+
contextSummary: formatApprovalContext(approval.action, payload),
|
|
143
|
+
actionLabel: "Open approvals",
|
|
71
144
|
actionHref: "/governance",
|
|
72
|
-
impactSummary: "
|
|
145
|
+
impactSummary: "Execution remains blocked until this governance decision is resolved.",
|
|
73
146
|
evidence: {
|
|
74
147
|
approvalId: approval.id,
|
|
75
|
-
projectId: projectId ?? undefined,
|
|
76
|
-
agentId: agentId ?? undefined
|
|
148
|
+
projectId: asString(payload.projectId) ?? undefined,
|
|
149
|
+
agentId: asString(payload.agentId) ?? undefined
|
|
77
150
|
},
|
|
78
151
|
sourceTimestamp: approval.createdAt.toISOString(),
|
|
79
152
|
state: "open",
|
|
@@ -82,39 +155,9 @@ export async function listBoardAttentionItems(db: BopoDb, companyId: string, act
|
|
|
82
155
|
dismissedAt: null,
|
|
83
156
|
resolvedAt: null
|
|
84
157
|
},
|
|
85
|
-
stateByKey.get(key)
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
);
|
|
89
|
-
continue;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const key = `approval:${approval.id}`;
|
|
93
|
-
items.push(
|
|
94
|
-
withState(
|
|
95
|
-
{
|
|
96
|
-
key,
|
|
97
|
-
category: "approval_required",
|
|
98
|
-
severity: ageHours >= 24 ? "critical" : "warning",
|
|
99
|
-
requiredActor: "board",
|
|
100
|
-
title: "Approval required",
|
|
101
|
-
contextSummary: formatApprovalContext(approval.action, payload),
|
|
102
|
-
actionLabel: "Open approvals",
|
|
103
|
-
actionHref: "/governance",
|
|
104
|
-
impactSummary: "Execution remains blocked until this governance decision is resolved.",
|
|
105
|
-
evidence: {
|
|
106
|
-
approvalId: approval.id,
|
|
107
|
-
projectId: asString(payload.projectId) ?? undefined,
|
|
108
|
-
agentId: asString(payload.agentId) ?? undefined
|
|
109
|
-
},
|
|
110
|
-
sourceTimestamp: approval.createdAt.toISOString(),
|
|
111
|
-
state: "open",
|
|
112
|
-
seenAt: null,
|
|
113
|
-
acknowledgedAt: null,
|
|
114
|
-
dismissedAt: null,
|
|
115
|
-
resolvedAt: null
|
|
116
|
-
},
|
|
117
|
-
stateByKey.get(key)
|
|
158
|
+
stateByKey.get(key)
|
|
159
|
+
),
|
|
160
|
+
approval
|
|
118
161
|
)
|
|
119
162
|
);
|
|
120
163
|
}
|
|
@@ -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
|
+
|
|
@@ -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";
|
|
@@ -777,6 +778,7 @@ function buildAgentStartupTaskBody(companyId: string, agentId: string) {
|
|
|
777
778
|
`Create your operating baseline before starting feature delivery work.`,
|
|
778
779
|
"",
|
|
779
780
|
`1. Create your operating folder at \`${agentOperatingFolder}/\`.`,
|
|
781
|
+
" During heartbeats, prefer the absolute path in `$BOPODEV_AGENT_OPERATING_DIR` (set by the runtime) so files land under your agent folder even when the shell cwd is a project workspace.",
|
|
780
782
|
"2. Author these files with your own responsibilities and working style:",
|
|
781
783
|
` - \`${agentOperatingFolder}/AGENTS.md\``,
|
|
782
784
|
` - \`${agentOperatingFolder}/HEARTBEAT.md\``,
|
|
@@ -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: {
|