bopodev-api 0.1.1
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/LICENSE +21 -0
- package/package.json +36 -0
- package/src/app.ts +76 -0
- package/src/context.ts +8 -0
- package/src/http.ts +9 -0
- package/src/middleware/company-scope.ts +15 -0
- package/src/middleware/request-actor.ts +81 -0
- package/src/realtime/governance.ts +66 -0
- package/src/realtime/hub.ts +142 -0
- package/src/realtime/office-space.ts +448 -0
- package/src/routes/agents.ts +305 -0
- package/src/routes/companies.ts +58 -0
- package/src/routes/goals.ts +134 -0
- package/src/routes/governance.ts +208 -0
- package/src/routes/heartbeats.ts +61 -0
- package/src/routes/issues.ts +319 -0
- package/src/routes/observability.ts +47 -0
- package/src/routes/projects.ts +152 -0
- package/src/scripts/db-init.ts +13 -0
- package/src/server.ts +51 -0
- package/src/services/budget-service.ts +31 -0
- package/src/services/governance-service.ts +229 -0
- package/src/services/heartbeat-service.ts +706 -0
- package/src/worker/scheduler.ts +23 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
|
2
|
+
import { nanoid } from "nanoid";
|
|
3
|
+
import { resolveAdapter } from "bopodev-agent-sdk";
|
|
4
|
+
import type { AgentState, HeartbeatContext } from "bopodev-agent-sdk";
|
|
5
|
+
import type { BopoDb } from "bopodev-db";
|
|
6
|
+
import { agents, appendActivity, companies, goals, heartbeatRuns, issues, projects } from "bopodev-db";
|
|
7
|
+
import { appendAuditEvent, appendCost } from "bopodev-db";
|
|
8
|
+
import type { RealtimeHub } from "../realtime/hub";
|
|
9
|
+
import { publishOfficeOccupantForAgent } from "../realtime/office-space";
|
|
10
|
+
import { checkAgentBudget } from "./budget-service";
|
|
11
|
+
|
|
12
|
+
export async function claimIssuesForAgent(
|
|
13
|
+
db: BopoDb,
|
|
14
|
+
companyId: string,
|
|
15
|
+
agentId: string,
|
|
16
|
+
heartbeatRunId: string,
|
|
17
|
+
maxItems = 5
|
|
18
|
+
) {
|
|
19
|
+
const result = await db.execute(sql`
|
|
20
|
+
WITH candidate AS (
|
|
21
|
+
SELECT id
|
|
22
|
+
FROM issues
|
|
23
|
+
WHERE company_id = ${companyId}
|
|
24
|
+
AND assignee_agent_id = ${agentId}
|
|
25
|
+
AND status IN ('todo', 'in_progress')
|
|
26
|
+
AND is_claimed = false
|
|
27
|
+
ORDER BY 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.title, i.body, i.status, i.priority, i.labels_json, i.tags_json;
|
|
38
|
+
`);
|
|
39
|
+
|
|
40
|
+
return (result.rows ?? []) as Array<{
|
|
41
|
+
id: string;
|
|
42
|
+
project_id: string;
|
|
43
|
+
title: string;
|
|
44
|
+
body: string | null;
|
|
45
|
+
status: string;
|
|
46
|
+
priority: string;
|
|
47
|
+
labels_json: string;
|
|
48
|
+
tags_json: string;
|
|
49
|
+
}>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function releaseClaimedIssues(db: BopoDb, companyId: string, issueIds: string[]) {
|
|
53
|
+
if (issueIds.length === 0) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
await db
|
|
57
|
+
.update(issues)
|
|
58
|
+
.set({ isClaimed: false, claimedByHeartbeatRunId: null, updatedAt: new Date() })
|
|
59
|
+
.where(and(eq(issues.companyId, companyId), inArray(issues.id, issueIds)));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function runHeartbeatForAgent(
|
|
63
|
+
db: BopoDb,
|
|
64
|
+
companyId: string,
|
|
65
|
+
agentId: string,
|
|
66
|
+
options?: { requestId?: string; trigger?: "manual" | "scheduler"; realtimeHub?: RealtimeHub }
|
|
67
|
+
) {
|
|
68
|
+
const [agent] = await db
|
|
69
|
+
.select()
|
|
70
|
+
.from(agents)
|
|
71
|
+
.where(and(eq(agents.companyId, companyId), eq(agents.id, agentId)))
|
|
72
|
+
.limit(1);
|
|
73
|
+
|
|
74
|
+
if (!agent || agent.status === "paused" || agent.status === "terminated") {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const startedRuns = await db
|
|
79
|
+
.select({ id: heartbeatRuns.id, startedAt: heartbeatRuns.startedAt })
|
|
80
|
+
.from(heartbeatRuns)
|
|
81
|
+
.where(
|
|
82
|
+
and(
|
|
83
|
+
eq(heartbeatRuns.companyId, companyId),
|
|
84
|
+
eq(heartbeatRuns.agentId, agentId),
|
|
85
|
+
eq(heartbeatRuns.status, "started")
|
|
86
|
+
)
|
|
87
|
+
);
|
|
88
|
+
const staleRunThresholdMs = resolveStaleRunThresholdMs();
|
|
89
|
+
const nowTs = Date.now();
|
|
90
|
+
const staleRuns = startedRuns.filter((run) => {
|
|
91
|
+
const startedAt = run.startedAt.getTime();
|
|
92
|
+
return nowTs - startedAt >= staleRunThresholdMs;
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
if (staleRuns.length > 0) {
|
|
96
|
+
await recoverStaleHeartbeatRuns(db, companyId, agentId, staleRuns, {
|
|
97
|
+
requestId: options?.requestId,
|
|
98
|
+
trigger: options?.trigger ?? "manual",
|
|
99
|
+
staleRunThresholdMs
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const budgetCheck = await checkAgentBudget(db, companyId, agentId);
|
|
104
|
+
const runId = nanoid(14);
|
|
105
|
+
if (budgetCheck.allowed) {
|
|
106
|
+
const claimed = await insertStartedRunAtomic(db, {
|
|
107
|
+
id: runId,
|
|
108
|
+
companyId,
|
|
109
|
+
agentId,
|
|
110
|
+
message: "Heartbeat started."
|
|
111
|
+
});
|
|
112
|
+
if (!claimed) {
|
|
113
|
+
const skippedRunId = nanoid(14);
|
|
114
|
+
await db.insert(heartbeatRuns).values({
|
|
115
|
+
id: skippedRunId,
|
|
116
|
+
companyId,
|
|
117
|
+
agentId,
|
|
118
|
+
status: "skipped",
|
|
119
|
+
finishedAt: new Date(),
|
|
120
|
+
message: "Heartbeat skipped: another run is already in progress for this agent."
|
|
121
|
+
});
|
|
122
|
+
await appendAuditEvent(db, {
|
|
123
|
+
companyId,
|
|
124
|
+
actorType: "system",
|
|
125
|
+
eventType: "heartbeat.skipped_overlap",
|
|
126
|
+
entityType: "heartbeat_run",
|
|
127
|
+
entityId: skippedRunId,
|
|
128
|
+
correlationId: options?.requestId ?? skippedRunId,
|
|
129
|
+
payload: { agentId, requestId: options?.requestId, trigger: options?.trigger ?? "manual" }
|
|
130
|
+
});
|
|
131
|
+
return skippedRunId;
|
|
132
|
+
}
|
|
133
|
+
} else {
|
|
134
|
+
await db.insert(heartbeatRuns).values({
|
|
135
|
+
id: runId,
|
|
136
|
+
companyId,
|
|
137
|
+
agentId,
|
|
138
|
+
status: "skipped",
|
|
139
|
+
message: "Heartbeat skipped due to budget hard-stop."
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (budgetCheck.allowed) {
|
|
144
|
+
await appendAuditEvent(db, {
|
|
145
|
+
companyId,
|
|
146
|
+
actorType: "system",
|
|
147
|
+
eventType: "heartbeat.started",
|
|
148
|
+
entityType: "heartbeat_run",
|
|
149
|
+
entityId: runId,
|
|
150
|
+
correlationId: options?.requestId ?? runId,
|
|
151
|
+
payload: {
|
|
152
|
+
agentId,
|
|
153
|
+
requestId: options?.requestId ?? null,
|
|
154
|
+
trigger: options?.trigger ?? "manual"
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!budgetCheck.allowed) {
|
|
160
|
+
await appendAuditEvent(db, {
|
|
161
|
+
companyId,
|
|
162
|
+
actorType: "system",
|
|
163
|
+
eventType: "budget.hard_stop",
|
|
164
|
+
entityType: "agent",
|
|
165
|
+
entityId: agentId,
|
|
166
|
+
payload: { utilizationPct: budgetCheck.utilizationPct }
|
|
167
|
+
});
|
|
168
|
+
await publishOfficeOccupantForAgent(db, options?.realtimeHub, companyId, agentId);
|
|
169
|
+
return runId;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (budgetCheck.utilizationPct >= 80) {
|
|
173
|
+
await appendAuditEvent(db, {
|
|
174
|
+
companyId,
|
|
175
|
+
actorType: "system",
|
|
176
|
+
eventType: "budget.soft_warning",
|
|
177
|
+
entityType: "agent",
|
|
178
|
+
entityId: agentId,
|
|
179
|
+
payload: { utilizationPct: budgetCheck.utilizationPct }
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
let issueIds: string[] = [];
|
|
184
|
+
let state: AgentState & {
|
|
185
|
+
runtime?: {
|
|
186
|
+
command?: string;
|
|
187
|
+
args?: string[];
|
|
188
|
+
cwd?: string;
|
|
189
|
+
timeoutMs?: number;
|
|
190
|
+
retryCount?: number;
|
|
191
|
+
retryBackoffMs?: number;
|
|
192
|
+
};
|
|
193
|
+
} = {};
|
|
194
|
+
let executionSummary = "";
|
|
195
|
+
let executionTrace: unknown = null;
|
|
196
|
+
let stateParseError: string | null = null;
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
const workItems = await claimIssuesForAgent(db, companyId, agentId, runId);
|
|
200
|
+
issueIds = workItems.map((item) => item.id);
|
|
201
|
+
await publishOfficeOccupantForAgent(db, options?.realtimeHub, companyId, agentId);
|
|
202
|
+
const adapter = resolveAdapter(agent.providerType as "claude_code" | "codex" | "http" | "shell");
|
|
203
|
+
const parsedState = parseAgentState(agent.stateBlob);
|
|
204
|
+
state = parsedState.state;
|
|
205
|
+
stateParseError = parsedState.parseError;
|
|
206
|
+
|
|
207
|
+
const context = await buildHeartbeatContext(db, companyId, {
|
|
208
|
+
agentId,
|
|
209
|
+
agentName: agent.name,
|
|
210
|
+
agentRole: agent.role,
|
|
211
|
+
managerAgentId: agent.managerAgentId,
|
|
212
|
+
providerType: agent.providerType as "claude_code" | "codex" | "http" | "shell",
|
|
213
|
+
heartbeatRunId: runId,
|
|
214
|
+
state,
|
|
215
|
+
runtime: state.runtime,
|
|
216
|
+
workItems
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const execution = await adapter.execute(context);
|
|
220
|
+
executionSummary = execution.summary;
|
|
221
|
+
executionTrace = execution.trace ?? null;
|
|
222
|
+
|
|
223
|
+
if (execution.tokenInput > 0 || execution.tokenOutput > 0 || execution.usdCost > 0) {
|
|
224
|
+
await appendCost(db, {
|
|
225
|
+
companyId,
|
|
226
|
+
providerType: agent.providerType,
|
|
227
|
+
tokenInput: execution.tokenInput,
|
|
228
|
+
tokenOutput: execution.tokenOutput,
|
|
229
|
+
usdCost: execution.usdCost.toFixed(6),
|
|
230
|
+
issueId: workItems[0]?.id ?? null,
|
|
231
|
+
projectId: workItems[0]?.project_id ?? null,
|
|
232
|
+
agentId
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (
|
|
237
|
+
execution.nextState ||
|
|
238
|
+
execution.usdCost > 0 ||
|
|
239
|
+
execution.tokenInput > 0 ||
|
|
240
|
+
execution.tokenOutput > 0 ||
|
|
241
|
+
execution.status !== "skipped"
|
|
242
|
+
) {
|
|
243
|
+
await db
|
|
244
|
+
.update(agents)
|
|
245
|
+
.set({
|
|
246
|
+
stateBlob: JSON.stringify(execution.nextState ?? state),
|
|
247
|
+
usedBudgetUsd: sql`${agents.usedBudgetUsd} + ${execution.usdCost}`,
|
|
248
|
+
tokenUsage: sql`${agents.tokenUsage} + ${execution.tokenInput + execution.tokenOutput}`,
|
|
249
|
+
updatedAt: new Date()
|
|
250
|
+
})
|
|
251
|
+
.where(and(eq(agents.companyId, companyId), eq(agents.id, agentId)));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (issueIds.length > 0 && execution.status === "ok") {
|
|
255
|
+
await db
|
|
256
|
+
.update(issues)
|
|
257
|
+
.set({ status: "in_review", updatedAt: new Date() })
|
|
258
|
+
.where(and(eq(issues.companyId, companyId), inArray(issues.id, issueIds)));
|
|
259
|
+
|
|
260
|
+
for (const issueId of issueIds) {
|
|
261
|
+
await appendActivity(db, {
|
|
262
|
+
companyId,
|
|
263
|
+
issueId,
|
|
264
|
+
actorType: "system",
|
|
265
|
+
eventType: "issue.sent_to_review",
|
|
266
|
+
payload: { heartbeatRunId: runId, agentId }
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
await db
|
|
272
|
+
.update(heartbeatRuns)
|
|
273
|
+
.set({
|
|
274
|
+
status: execution.status === "failed" ? "failed" : "completed",
|
|
275
|
+
finishedAt: new Date(),
|
|
276
|
+
message: execution.summary
|
|
277
|
+
})
|
|
278
|
+
.where(eq(heartbeatRuns.id, runId));
|
|
279
|
+
|
|
280
|
+
await appendAuditEvent(db, {
|
|
281
|
+
companyId,
|
|
282
|
+
actorType: "system",
|
|
283
|
+
eventType: "heartbeat.completed",
|
|
284
|
+
entityType: "heartbeat_run",
|
|
285
|
+
entityId: runId,
|
|
286
|
+
correlationId: options?.requestId ?? runId,
|
|
287
|
+
payload: {
|
|
288
|
+
agentId,
|
|
289
|
+
result: execution.summary,
|
|
290
|
+
issueIds,
|
|
291
|
+
usage: {
|
|
292
|
+
tokenInput: execution.tokenInput,
|
|
293
|
+
tokenOutput: execution.tokenOutput,
|
|
294
|
+
usdCost: execution.usdCost
|
|
295
|
+
},
|
|
296
|
+
trace: execution.trace ?? null,
|
|
297
|
+
diagnostics: {
|
|
298
|
+
stateParseError,
|
|
299
|
+
requestId: options?.requestId,
|
|
300
|
+
trigger: options?.trigger ?? "manual"
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
} catch (error) {
|
|
305
|
+
const classified = classifyHeartbeatError(error);
|
|
306
|
+
executionSummary = `Heartbeat failed (${classified.type}): ${classified.message}`;
|
|
307
|
+
await db
|
|
308
|
+
.update(heartbeatRuns)
|
|
309
|
+
.set({
|
|
310
|
+
status: "failed",
|
|
311
|
+
finishedAt: new Date(),
|
|
312
|
+
message: executionSummary
|
|
313
|
+
})
|
|
314
|
+
.where(eq(heartbeatRuns.id, runId));
|
|
315
|
+
await appendAuditEvent(db, {
|
|
316
|
+
companyId,
|
|
317
|
+
actorType: "system",
|
|
318
|
+
eventType: "heartbeat.failed",
|
|
319
|
+
entityType: "heartbeat_run",
|
|
320
|
+
entityId: runId,
|
|
321
|
+
correlationId: options?.requestId ?? runId,
|
|
322
|
+
payload: {
|
|
323
|
+
agentId,
|
|
324
|
+
issueIds,
|
|
325
|
+
errorType: classified.type,
|
|
326
|
+
errorMessage: classified.message,
|
|
327
|
+
trace: executionTrace,
|
|
328
|
+
diagnostics: {
|
|
329
|
+
stateParseError,
|
|
330
|
+
requestId: options?.requestId,
|
|
331
|
+
trigger: options?.trigger ?? "manual"
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
} finally {
|
|
336
|
+
try {
|
|
337
|
+
await releaseClaimedIssues(db, companyId, issueIds);
|
|
338
|
+
} catch (releaseError) {
|
|
339
|
+
await appendAuditEvent(db, {
|
|
340
|
+
companyId,
|
|
341
|
+
actorType: "system",
|
|
342
|
+
eventType: "heartbeat.release_failed",
|
|
343
|
+
entityType: "heartbeat_run",
|
|
344
|
+
entityId: runId,
|
|
345
|
+
correlationId: options?.requestId ?? runId,
|
|
346
|
+
payload: {
|
|
347
|
+
agentId,
|
|
348
|
+
issueIds,
|
|
349
|
+
error: String(releaseError)
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
await publishOfficeOccupantForAgent(db, options?.realtimeHub, companyId, agentId);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return runId;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function insertStartedRunAtomic(
|
|
360
|
+
db: BopoDb,
|
|
361
|
+
input: { id: string; companyId: string; agentId: string; message: string }
|
|
362
|
+
) {
|
|
363
|
+
const result = await db.execute(sql`
|
|
364
|
+
INSERT INTO heartbeat_runs (id, company_id, agent_id, status, message)
|
|
365
|
+
VALUES (${input.id}, ${input.companyId}, ${input.agentId}, 'started', ${input.message})
|
|
366
|
+
ON CONFLICT DO NOTHING
|
|
367
|
+
RETURNING id
|
|
368
|
+
`);
|
|
369
|
+
return (result.rows ?? []).length > 0;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function recoverStaleHeartbeatRuns(
|
|
373
|
+
db: BopoDb,
|
|
374
|
+
companyId: string,
|
|
375
|
+
agentId: string,
|
|
376
|
+
staleRuns: Array<{ id: string; startedAt: Date }>,
|
|
377
|
+
input: { requestId?: string; trigger: "manual" | "scheduler"; staleRunThresholdMs: number }
|
|
378
|
+
) {
|
|
379
|
+
const staleRunIds = staleRuns.map((run) => run.id);
|
|
380
|
+
if (staleRunIds.length === 0) {
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
await db
|
|
385
|
+
.update(heartbeatRuns)
|
|
386
|
+
.set({
|
|
387
|
+
status: "failed",
|
|
388
|
+
finishedAt: new Date(),
|
|
389
|
+
message: "Heartbeat auto-failed after stale in-progress timeout."
|
|
390
|
+
})
|
|
391
|
+
.where(
|
|
392
|
+
and(
|
|
393
|
+
eq(heartbeatRuns.companyId, companyId),
|
|
394
|
+
eq(heartbeatRuns.agentId, agentId),
|
|
395
|
+
inArray(heartbeatRuns.id, staleRunIds),
|
|
396
|
+
eq(heartbeatRuns.status, "started")
|
|
397
|
+
)
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
const claimedIssueRows = await db
|
|
401
|
+
.select({ id: issues.id })
|
|
402
|
+
.from(issues)
|
|
403
|
+
.where(and(eq(issues.companyId, companyId), inArray(issues.claimedByHeartbeatRunId, staleRunIds), eq(issues.isClaimed, true)));
|
|
404
|
+
await releaseClaimedIssues(
|
|
405
|
+
db,
|
|
406
|
+
companyId,
|
|
407
|
+
claimedIssueRows.map((row) => row.id)
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
for (const staleRun of staleRuns) {
|
|
411
|
+
await appendAuditEvent(db, {
|
|
412
|
+
companyId,
|
|
413
|
+
actorType: "system",
|
|
414
|
+
eventType: "heartbeat.stale_recovered",
|
|
415
|
+
entityType: "heartbeat_run",
|
|
416
|
+
entityId: staleRun.id,
|
|
417
|
+
correlationId: input.requestId ?? staleRun.id,
|
|
418
|
+
payload: {
|
|
419
|
+
agentId,
|
|
420
|
+
trigger: input.trigger,
|
|
421
|
+
requestId: input.requestId ?? null,
|
|
422
|
+
staleRunThresholdMs: input.staleRunThresholdMs,
|
|
423
|
+
staleForMs: Date.now() - staleRun.startedAt.getTime()
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export async function runHeartbeatSweep(
|
|
430
|
+
db: BopoDb,
|
|
431
|
+
companyId: string,
|
|
432
|
+
options?: { requestId?: string; realtimeHub?: RealtimeHub }
|
|
433
|
+
) {
|
|
434
|
+
const companyAgents = await db.select().from(agents).where(eq(agents.companyId, companyId));
|
|
435
|
+
const recentRuns = await db
|
|
436
|
+
.select({ agentId: heartbeatRuns.agentId, startedAt: heartbeatRuns.startedAt })
|
|
437
|
+
.from(heartbeatRuns)
|
|
438
|
+
.where(eq(heartbeatRuns.companyId, companyId))
|
|
439
|
+
.orderBy(desc(heartbeatRuns.startedAt));
|
|
440
|
+
const latestRunByAgent = new Map<string, Date>();
|
|
441
|
+
for (const run of recentRuns) {
|
|
442
|
+
if (!latestRunByAgent.has(run.agentId)) {
|
|
443
|
+
latestRunByAgent.set(run.agentId, run.startedAt);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const now = new Date();
|
|
448
|
+
const runs: string[] = [];
|
|
449
|
+
let skippedNotDue = 0;
|
|
450
|
+
let skippedStatus = 0;
|
|
451
|
+
let failedStarts = 0;
|
|
452
|
+
const sweepStartedAt = Date.now();
|
|
453
|
+
for (const agent of companyAgents) {
|
|
454
|
+
if (agent.status !== "idle" && agent.status !== "running") {
|
|
455
|
+
skippedStatus += 1;
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
if (!isHeartbeatDue(agent.heartbeatCron, latestRunByAgent.get(agent.id) ?? null, now)) {
|
|
459
|
+
skippedNotDue += 1;
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
try {
|
|
463
|
+
const runId = await runHeartbeatForAgent(db, companyId, agent.id, {
|
|
464
|
+
trigger: "scheduler",
|
|
465
|
+
requestId: options?.requestId,
|
|
466
|
+
realtimeHub: options?.realtimeHub
|
|
467
|
+
});
|
|
468
|
+
if (runId) {
|
|
469
|
+
runs.push(runId);
|
|
470
|
+
}
|
|
471
|
+
} catch {
|
|
472
|
+
failedStarts += 1;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
await appendAuditEvent(db, {
|
|
476
|
+
companyId,
|
|
477
|
+
actorType: "system",
|
|
478
|
+
eventType: "heartbeat.sweep.completed",
|
|
479
|
+
entityType: "company",
|
|
480
|
+
entityId: companyId,
|
|
481
|
+
correlationId: options?.requestId ?? null,
|
|
482
|
+
payload: {
|
|
483
|
+
runIds: runs,
|
|
484
|
+
startedCount: runs.length,
|
|
485
|
+
failedStarts,
|
|
486
|
+
skippedStatus,
|
|
487
|
+
skippedNotDue,
|
|
488
|
+
elapsedMs: Date.now() - sweepStartedAt,
|
|
489
|
+
requestId: options?.requestId ?? null
|
|
490
|
+
}
|
|
491
|
+
});
|
|
492
|
+
return runs;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async function buildHeartbeatContext(
|
|
496
|
+
db: BopoDb,
|
|
497
|
+
companyId: string,
|
|
498
|
+
input: {
|
|
499
|
+
agentId: string;
|
|
500
|
+
agentName: string;
|
|
501
|
+
agentRole: string;
|
|
502
|
+
managerAgentId: string | null;
|
|
503
|
+
providerType: "claude_code" | "codex" | "http" | "shell";
|
|
504
|
+
heartbeatRunId: string;
|
|
505
|
+
state: AgentState;
|
|
506
|
+
runtime?: { command?: string; args?: string[]; cwd?: string; timeoutMs?: number };
|
|
507
|
+
workItems: Array<{
|
|
508
|
+
id: string;
|
|
509
|
+
project_id: string;
|
|
510
|
+
title: string;
|
|
511
|
+
body: string | null;
|
|
512
|
+
status: string;
|
|
513
|
+
priority: string;
|
|
514
|
+
labels_json: string;
|
|
515
|
+
tags_json: string;
|
|
516
|
+
}>;
|
|
517
|
+
}
|
|
518
|
+
): Promise<HeartbeatContext> {
|
|
519
|
+
const [company] = await db
|
|
520
|
+
.select({ name: companies.name, mission: companies.mission })
|
|
521
|
+
.from(companies)
|
|
522
|
+
.where(eq(companies.id, companyId))
|
|
523
|
+
.limit(1);
|
|
524
|
+
const projectIds = Array.from(new Set(input.workItems.map((item) => item.project_id)));
|
|
525
|
+
const projectRows =
|
|
526
|
+
projectIds.length > 0
|
|
527
|
+
? await db
|
|
528
|
+
.select({ id: projects.id, name: projects.name })
|
|
529
|
+
.from(projects)
|
|
530
|
+
.where(and(eq(projects.companyId, companyId), inArray(projects.id, projectIds)))
|
|
531
|
+
: [];
|
|
532
|
+
const projectNameById = new Map(projectRows.map((row) => [row.id, row.name]));
|
|
533
|
+
const goalRows = await db
|
|
534
|
+
.select({
|
|
535
|
+
id: goals.id,
|
|
536
|
+
level: goals.level,
|
|
537
|
+
title: goals.title,
|
|
538
|
+
status: goals.status,
|
|
539
|
+
projectId: goals.projectId
|
|
540
|
+
})
|
|
541
|
+
.from(goals)
|
|
542
|
+
.where(eq(goals.companyId, companyId));
|
|
543
|
+
|
|
544
|
+
const activeCompanyGoals = goalRows
|
|
545
|
+
.filter((goal) => goal.status === "active" && goal.level === "company")
|
|
546
|
+
.map((goal) => goal.title);
|
|
547
|
+
const activeProjectGoals = goalRows
|
|
548
|
+
.filter(
|
|
549
|
+
(goal) =>
|
|
550
|
+
goal.status === "active" && goal.level === "project" && goal.projectId && projectIds.includes(goal.projectId)
|
|
551
|
+
)
|
|
552
|
+
.map((goal) => goal.title);
|
|
553
|
+
const activeAgentGoals = goalRows
|
|
554
|
+
.filter((goal) => goal.status === "active" && goal.level === "agent")
|
|
555
|
+
.map((goal) => goal.title);
|
|
556
|
+
|
|
557
|
+
return {
|
|
558
|
+
companyId,
|
|
559
|
+
agentId: input.agentId,
|
|
560
|
+
providerType: input.providerType,
|
|
561
|
+
heartbeatRunId: input.heartbeatRunId,
|
|
562
|
+
company: {
|
|
563
|
+
name: company?.name ?? "Unknown company",
|
|
564
|
+
mission: company?.mission ?? null
|
|
565
|
+
},
|
|
566
|
+
agent: {
|
|
567
|
+
name: input.agentName,
|
|
568
|
+
role: input.agentRole,
|
|
569
|
+
managerAgentId: input.managerAgentId
|
|
570
|
+
},
|
|
571
|
+
state: input.state,
|
|
572
|
+
runtime: input.runtime,
|
|
573
|
+
goalContext: {
|
|
574
|
+
companyGoals: activeCompanyGoals,
|
|
575
|
+
projectGoals: activeProjectGoals,
|
|
576
|
+
agentGoals: activeAgentGoals
|
|
577
|
+
},
|
|
578
|
+
workItems: input.workItems.map((item) => ({
|
|
579
|
+
issueId: item.id,
|
|
580
|
+
projectId: item.project_id,
|
|
581
|
+
projectName: projectNameById.get(item.project_id) ?? null,
|
|
582
|
+
title: item.title,
|
|
583
|
+
body: item.body,
|
|
584
|
+
status: item.status,
|
|
585
|
+
priority: item.priority,
|
|
586
|
+
labels: parseStringArray(item.labels_json),
|
|
587
|
+
tags: parseStringArray(item.tags_json)
|
|
588
|
+
}))
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function parseStringArray(value: string | null) {
|
|
593
|
+
if (!value) {
|
|
594
|
+
return [];
|
|
595
|
+
}
|
|
596
|
+
try {
|
|
597
|
+
const parsed = JSON.parse(value) as unknown;
|
|
598
|
+
return Array.isArray(parsed) ? parsed.map((entry) => String(entry)) : [];
|
|
599
|
+
} catch {
|
|
600
|
+
return [];
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function parseAgentState(stateBlob: string | null) {
|
|
605
|
+
if (!stateBlob) {
|
|
606
|
+
return { state: {} as AgentState, parseError: null };
|
|
607
|
+
}
|
|
608
|
+
try {
|
|
609
|
+
return {
|
|
610
|
+
state: JSON.parse(stateBlob) as AgentState & {
|
|
611
|
+
runtime?: {
|
|
612
|
+
command?: string;
|
|
613
|
+
args?: string[];
|
|
614
|
+
cwd?: string;
|
|
615
|
+
timeoutMs?: number;
|
|
616
|
+
retryCount?: number;
|
|
617
|
+
retryBackoffMs?: number;
|
|
618
|
+
};
|
|
619
|
+
},
|
|
620
|
+
parseError: null
|
|
621
|
+
};
|
|
622
|
+
} catch (error) {
|
|
623
|
+
return {
|
|
624
|
+
state: {} as AgentState,
|
|
625
|
+
parseError: String(error)
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function classifyHeartbeatError(error: unknown) {
|
|
631
|
+
const message = String(error);
|
|
632
|
+
if (message.includes("ENOENT")) {
|
|
633
|
+
return { type: "runtime_missing", message };
|
|
634
|
+
}
|
|
635
|
+
if (message.includes("timeout")) {
|
|
636
|
+
return { type: "timeout", message };
|
|
637
|
+
}
|
|
638
|
+
return { type: "unknown", message };
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function resolveStaleRunThresholdMs() {
|
|
642
|
+
const parsed = Number(process.env.BOPO_HEARTBEAT_STALE_RUN_MS ?? 10 * 60 * 1000);
|
|
643
|
+
if (!Number.isFinite(parsed) || parsed < 1_000) {
|
|
644
|
+
return 10 * 60 * 1000;
|
|
645
|
+
}
|
|
646
|
+
return parsed;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function isHeartbeatDue(cronExpression: string, lastRunAt: Date | null, now: Date) {
|
|
650
|
+
const normalizedNow = truncateToMinute(now);
|
|
651
|
+
if (!matchesCronExpression(cronExpression, normalizedNow)) {
|
|
652
|
+
return false;
|
|
653
|
+
}
|
|
654
|
+
if (!lastRunAt) {
|
|
655
|
+
return true;
|
|
656
|
+
}
|
|
657
|
+
return truncateToMinute(lastRunAt).getTime() !== normalizedNow.getTime();
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function truncateToMinute(date: Date) {
|
|
661
|
+
const clone = new Date(date);
|
|
662
|
+
clone.setSeconds(0, 0);
|
|
663
|
+
return clone;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function matchesCronExpression(expression: string, date: Date) {
|
|
667
|
+
const parts = expression.trim().split(/\s+/);
|
|
668
|
+
if (parts.length !== 5) {
|
|
669
|
+
return false;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts as [string, string, string, string, string];
|
|
673
|
+
return (
|
|
674
|
+
matchesCronField(minute, date.getMinutes(), 0, 59) &&
|
|
675
|
+
matchesCronField(hour, date.getHours(), 0, 23) &&
|
|
676
|
+
matchesCronField(dayOfMonth, date.getDate(), 1, 31) &&
|
|
677
|
+
matchesCronField(month, date.getMonth() + 1, 1, 12) &&
|
|
678
|
+
matchesCronField(dayOfWeek, date.getDay(), 0, 6)
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function matchesCronField(field: string, value: number, min: number, max: number) {
|
|
683
|
+
return field.split(",").some((part) => matchesCronPart(part.trim(), value, min, max));
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function matchesCronPart(part: string, value: number, min: number, max: number): boolean {
|
|
687
|
+
if (part === "*") {
|
|
688
|
+
return true;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const stepMatch = part.match(/^\*\/(\d+)$/);
|
|
692
|
+
if (stepMatch) {
|
|
693
|
+
const step = Number(stepMatch[1]);
|
|
694
|
+
return Number.isInteger(step) && step > 0 ? (value - min) % step === 0 : false;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const rangeMatch = part.match(/^(\d+)-(\d+)$/);
|
|
698
|
+
if (rangeMatch) {
|
|
699
|
+
const start = Number(rangeMatch[1]);
|
|
700
|
+
const end = Number(rangeMatch[2]);
|
|
701
|
+
return start <= value && value <= end;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const exact = Number(part);
|
|
705
|
+
return Number.isInteger(exact) && exact >= min && exact <= max && exact === value;
|
|
706
|
+
}
|