bopodev-api 0.1.28 → 0.1.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/package.json +4 -4
  2. package/src/app.ts +17 -69
  3. package/src/lib/ceo-bootstrap-prompt.ts +1 -0
  4. package/src/lib/run-artifact-paths.ts +8 -0
  5. package/src/middleware/cors-config.ts +36 -0
  6. package/src/middleware/request-actor.ts +10 -16
  7. package/src/middleware/request-id.ts +9 -0
  8. package/src/middleware/request-logging.ts +24 -0
  9. package/src/realtime/office-space.ts +1 -0
  10. package/src/routes/agents.ts +90 -46
  11. package/src/routes/companies.ts +20 -1
  12. package/src/routes/goals.ts +7 -13
  13. package/src/routes/governance.ts +2 -5
  14. package/src/routes/heartbeats.ts +7 -25
  15. package/src/routes/issues.ts +65 -120
  16. package/src/routes/observability.ts +6 -1
  17. package/src/routes/plugins.ts +5 -17
  18. package/src/routes/projects.ts +7 -25
  19. package/src/routes/templates.ts +6 -21
  20. package/src/scripts/onboard-seed.ts +18 -8
  21. package/src/server.ts +33 -292
  22. package/src/services/company-export-service.ts +63 -0
  23. package/src/services/governance-service.ts +10 -14
  24. package/src/services/heartbeat-service/active-runs.ts +15 -0
  25. package/src/services/heartbeat-service/budget-override.ts +46 -0
  26. package/src/services/heartbeat-service/claims.ts +61 -0
  27. package/src/services/heartbeat-service/cron.ts +58 -0
  28. package/src/services/heartbeat-service/heartbeat-realtime.ts +28 -0
  29. package/src/services/heartbeat-service/heartbeat-run-summary-text.ts +53 -0
  30. package/src/services/{heartbeat-service.ts → heartbeat-service/heartbeat-run.ts} +201 -634
  31. package/src/services/heartbeat-service/index.ts +5 -0
  32. package/src/services/heartbeat-service/stop.ts +90 -0
  33. package/src/services/heartbeat-service/sweep.ts +145 -0
  34. package/src/services/heartbeat-service/types.ts +66 -0
  35. package/src/services/memory-file-service.ts +10 -2
  36. package/src/services/template-apply-service.ts +6 -0
  37. package/src/services/template-catalog.ts +37 -3
  38. package/src/shutdown/graceful-shutdown.ts +77 -0
  39. package/src/startup/database.ts +41 -0
  40. package/src/startup/deployment-validation.ts +37 -0
  41. package/src/startup/env.ts +17 -0
  42. package/src/startup/runtime-health.ts +128 -0
  43. package/src/startup/scheduler-config.ts +39 -0
  44. package/src/types/express.d.ts +13 -0
  45. package/src/types/request-actor.ts +6 -0
  46. package/src/validation/issue-routes.ts +80 -0
@@ -34,284 +34,49 @@ import {
34
34
  issueComments,
35
35
  issueAttachments,
36
36
  issues,
37
+ listIssueGoalIdsBatch,
37
38
  projects,
38
39
  sql
39
40
  } from "bopodev-db";
40
- import { appendAuditEvent, appendCost } from "bopodev-db";
41
- import { parseRuntimeConfigFromAgentRow } from "../lib/agent-config";
42
- import { bootstrapRepositoryWorkspace, ensureIsolatedGitWorktree, GitRuntimeError } from "../lib/git-runtime";
41
+ import { appendAuditEvent, appendCost, listAgents } from "bopodev-db";
42
+ import { parseRuntimeConfigFromAgentRow } from "../../lib/agent-config";
43
+ import { bootstrapRepositoryWorkspace, ensureIsolatedGitWorktree, GitRuntimeError } from "../../lib/git-runtime";
43
44
  import {
44
45
  isInsidePath,
45
46
  normalizeCompanyWorkspacePath,
46
47
  resolveCompanyWorkspaceRootPath,
47
48
  resolveProjectWorkspacePath
48
- } from "../lib/instance-paths";
49
- import { resolveRunArtifactAbsolutePath } from "../lib/run-artifact-paths";
49
+ } from "../../lib/instance-paths";
50
+ import { resolveRunArtifactAbsolutePath } from "../../lib/run-artifact-paths";
50
51
  import {
51
52
  assertRuntimeCwdForCompany,
52
53
  getProjectWorkspaceContextMap,
53
54
  hasText,
54
55
  resolveAgentFallbackWorkspace
55
- } from "../lib/workspace-policy";
56
- import type { RealtimeHub } from "../realtime/hub";
57
- import { createHeartbeatRunsRealtimeEvent, loadHeartbeatRunsRealtimeSnapshot } from "../realtime/heartbeat-runs";
58
- import { publishAttentionSnapshot } from "../realtime/attention";
59
- import { publishOfficeOccupantForAgent } from "../realtime/office-space";
60
- import { appendProjectBudgetUsage, checkAgentBudget, checkProjectBudget } from "./budget-service";
61
- import { appendDurableFact, loadAgentMemoryContext, persistHeartbeatMemory } from "./memory-file-service";
62
- import { calculateModelPricedUsdCost } from "./model-pricing";
63
- import { runPluginHook } from "./plugin-runtime";
64
-
65
- type HeartbeatRunTrigger = "manual" | "scheduler";
66
- type HeartbeatRunMode = "default" | "resume" | "redo";
67
- type HeartbeatProviderType =
68
- | "claude_code"
69
- | "codex"
70
- | "cursor"
71
- | "opencode"
72
- | "gemini_cli"
73
- | "openai_api"
74
- | "anthropic_api"
75
- | "http"
76
- | "shell";
77
-
78
- type ActiveHeartbeatRun = {
79
- companyId: string;
80
- agentId: string;
81
- abortController: AbortController;
82
- cancelReason?: string | null;
83
- cancelRequestedAt?: string | null;
84
- cancelRequestedBy?: string | null;
85
- };
86
-
87
- const activeHeartbeatRuns = new Map<string, ActiveHeartbeatRun>();
88
- type HeartbeatWakeContext = {
89
- reason?: string | null;
90
- commentId?: string | null;
91
- commentBody?: string | null;
92
- issueIds?: string[];
93
- };
94
-
95
- const AGENT_COMMENT_EMOJI_REGEX = /[\p{Extended_Pictographic}\uFE0F\u200D]/gu;
96
-
97
- type RunDigestSignal = {
98
- sequence: number;
99
- kind: "system" | "assistant" | "thinking" | "tool_call" | "tool_result" | "result" | "stderr";
100
- label: string | null;
101
- text: string | null;
102
- payload: string | null;
103
- signalLevel: "high" | "medium" | "low" | "noise";
104
- groupKey: string | null;
105
- source: "stdout" | "stderr" | "trace_fallback";
106
- };
107
-
108
- type RunDigest = {
109
- status: "completed" | "failed" | "skipped";
110
- headline: string;
111
- summary: string;
112
- successes: string[];
113
- failures: string[];
114
- blockers: string[];
115
- nextAction: string;
116
- evidence: {
117
- transcriptSignalCount: number;
118
- outcomeActionCount: number;
119
- outcomeBlockerCount: number;
120
- failureType: string | null;
121
- };
122
- };
123
-
124
- type RunTerminalPresentation = {
125
- internalStatus: "completed" | "failed" | "skipped";
126
- publicStatus: "completed" | "failed";
127
- completionReason: RunCompletionReason;
128
- };
129
-
130
- export async function claimIssuesForAgent(
131
- db: BopoDb,
132
- companyId: string,
133
- agentId: string,
134
- heartbeatRunId: string,
135
- maxItems = 5
136
- ) {
137
- const result = await db.execute(sql`
138
- WITH candidate AS (
139
- SELECT id
140
- FROM issues
141
- WHERE company_id = ${companyId}
142
- AND assignee_agent_id = ${agentId}
143
- AND status IN ('todo', 'in_progress')
144
- AND is_claimed = false
145
- ORDER BY
146
- CASE priority
147
- WHEN 'urgent' THEN 0
148
- WHEN 'high' THEN 1
149
- WHEN 'medium' THEN 2
150
- WHEN 'low' THEN 3
151
- ELSE 4
152
- END ASC,
153
- updated_at ASC
154
- LIMIT ${maxItems}
155
- FOR UPDATE SKIP LOCKED
156
- )
157
- UPDATE issues i
158
- SET is_claimed = true,
159
- claimed_by_heartbeat_run_id = ${heartbeatRunId},
160
- updated_at = CURRENT_TIMESTAMP
161
- FROM candidate c
162
- WHERE i.id = c.id
163
- RETURNING i.id, i.project_id, i.parent_issue_id, i.title, i.body, i.status, i.priority, i.labels_json, i.tags_json;
164
- `);
165
-
166
- return result as unknown as Array<{
167
- id: string;
168
- project_id: string;
169
- parent_issue_id: string | null;
170
- title: string;
171
- body: string | null;
172
- status: string;
173
- priority: string;
174
- labels_json: string;
175
- tags_json: string;
176
- }>;
177
- }
178
-
179
- export async function releaseClaimedIssues(db: BopoDb, companyId: string, issueIds: string[]) {
180
- if (issueIds.length === 0) {
181
- return;
182
- }
183
- await db
184
- .update(issues)
185
- .set({ isClaimed: false, claimedByHeartbeatRunId: null, updatedAt: new Date() })
186
- .where(and(eq(issues.companyId, companyId), inArray(issues.id, issueIds)));
187
- }
188
-
189
- export async function stopHeartbeatRun(
190
- db: BopoDb,
191
- companyId: string,
192
- runId: string,
193
- options?: { requestId?: string; actorId?: string; trigger?: HeartbeatRunTrigger; realtimeHub?: RealtimeHub }
194
- ) {
195
- const runTrigger = options?.trigger ?? "manual";
196
- const [run] = await db
197
- .select({
198
- id: heartbeatRuns.id,
199
- status: heartbeatRuns.status,
200
- agentId: heartbeatRuns.agentId
201
- })
202
- .from(heartbeatRuns)
203
- .where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, runId)))
204
- .limit(1);
205
- if (!run) {
206
- return { ok: false as const, reason: "not_found" as const };
207
- }
208
- if (run.status !== "started") {
209
- return { ok: false as const, reason: "invalid_status" as const, status: run.status };
210
- }
211
- const active = activeHeartbeatRuns.get(runId);
212
- const cancelReason = "cancelled by stop request";
213
- const cancelRequestedAt = new Date().toISOString();
214
- if (active) {
215
- active.cancelReason = cancelReason;
216
- active.cancelRequestedAt = cancelRequestedAt;
217
- active.cancelRequestedBy = options?.actorId ?? null;
218
- active.abortController.abort(cancelReason);
219
- } else {
220
- const finishedAt = new Date();
221
- await db
222
- .update(heartbeatRuns)
223
- .set({
224
- status: "failed",
225
- finishedAt,
226
- message: "Heartbeat cancelled by stop request."
227
- })
228
- .where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, runId)));
229
- publishHeartbeatRunStatus(options?.realtimeHub, {
230
- companyId,
231
- runId,
232
- status: "failed",
233
- message: "Heartbeat cancelled by stop request.",
234
- finishedAt
235
- });
236
- }
237
- await appendAuditEvent(db, {
238
- companyId,
239
- actorType: "system",
240
- eventType: "heartbeat.cancel_requested",
241
- entityType: "heartbeat_run",
242
- entityId: runId,
243
- correlationId: options?.requestId ?? runId,
244
- payload: {
245
- agentId: run.agentId,
246
- trigger: runTrigger,
247
- requestId: options?.requestId ?? null,
248
- actorId: options?.actorId ?? null,
249
- inMemoryAbortRegistered: Boolean(active)
250
- }
251
- });
252
- if (!active) {
253
- await appendAuditEvent(db, {
254
- companyId,
255
- actorType: "system",
256
- eventType: "heartbeat.cancelled",
257
- entityType: "heartbeat_run",
258
- entityId: runId,
259
- correlationId: options?.requestId ?? runId,
260
- payload: {
261
- agentId: run.agentId,
262
- reason: cancelReason,
263
- trigger: runTrigger,
264
- requestId: options?.requestId ?? null,
265
- actorId: options?.actorId ?? null
266
- }
267
- });
268
- }
269
- return { ok: true as const, runId, agentId: run.agentId, status: run.status };
270
- }
271
-
272
- export async function findPendingProjectBudgetOverrideBlocksForAgent(
273
- db: BopoDb,
274
- companyId: string,
275
- agentId: string
276
- ) {
277
- const assignedRows = await db
278
- .select({ projectId: issues.projectId })
279
- .from(issues)
280
- .where(
281
- and(
282
- eq(issues.companyId, companyId),
283
- eq(issues.assigneeAgentId, agentId),
284
- inArray(issues.status, ["todo", "in_progress"])
285
- )
286
- );
287
- const assignedProjectIds = new Set(assignedRows.map((row) => row.projectId));
288
- if (assignedProjectIds.size === 0) {
289
- return [] as string[];
290
- }
291
- const pendingOverrides = await db
292
- .select({ payloadJson: approvalRequests.payloadJson })
293
- .from(approvalRequests)
294
- .where(
295
- and(
296
- eq(approvalRequests.companyId, companyId),
297
- eq(approvalRequests.action, "override_budget"),
298
- eq(approvalRequests.status, "pending")
299
- )
300
- );
301
- const blockedProjectIds = new Set<string>();
302
- for (const approval of pendingOverrides) {
303
- try {
304
- const payload = JSON.parse(approval.payloadJson) as Record<string, unknown>;
305
- const projectId = typeof payload.projectId === "string" ? payload.projectId.trim() : "";
306
- if (projectId && assignedProjectIds.has(projectId)) {
307
- blockedProjectIds.add(projectId);
308
- }
309
- } catch {
310
- // Ignore malformed payloads to keep enforcement resilient.
311
- }
312
- }
313
- return Array.from(blockedProjectIds);
314
- }
56
+ } from "../../lib/workspace-policy";
57
+ import type { RealtimeHub } from "../../realtime/hub";
58
+ import { createHeartbeatRunsRealtimeEvent, loadHeartbeatRunsRealtimeSnapshot } from "../../realtime/heartbeat-runs";
59
+ import { publishAttentionSnapshot } from "../../realtime/attention";
60
+ import { publishOfficeOccupantForAgent } from "../../realtime/office-space";
61
+ import { appendProjectBudgetUsage, checkAgentBudget, checkProjectBudget } from "../budget-service";
62
+ import { appendDurableFact, loadAgentMemoryContext, persistHeartbeatMemory } from "../memory-file-service";
63
+ import { calculateModelPricedUsdCost } from "../model-pricing";
64
+ import { runPluginHook } from "../plugin-runtime";
65
+ import { registerActiveHeartbeatRun, unregisterActiveHeartbeatRun } from "./active-runs";
66
+ import { claimIssuesForAgent, releaseClaimedIssues } from "./claims";
67
+ import { publishHeartbeatRunStatus } from "./heartbeat-realtime";
68
+ import { extractNaturalRunUpdate, sanitizeAgentSummaryCommentBody } from "./heartbeat-run-summary-text";
69
+ import type {
70
+ ActiveHeartbeatRun,
71
+ HeartbeatIdlePolicy,
72
+ HeartbeatProviderType,
73
+ HeartbeatRunMode,
74
+ HeartbeatRunTrigger,
75
+ HeartbeatWakeContext,
76
+ RunDigest,
77
+ RunDigestSignal,
78
+ RunTerminalPresentation
79
+ } from "./types";
315
80
 
316
81
  export async function runHeartbeatForAgent(
317
82
  db: BopoDb,
@@ -908,7 +673,11 @@ export async function runHeartbeatForAgent(
908
673
  const heartbeatIdlePolicy = resolveHeartbeatIdlePolicy();
909
674
  const workItems = isCommentOrderWake ? [] : await claimIssuesForAgent(db, companyId, agentId, runId);
910
675
  const wakeWorkItems = await loadWakeContextWorkItems(db, companyId, options?.wakeContext?.issueIds);
911
- const contextWorkItems = resolveExecutionWorkItems(workItems, wakeWorkItems, options?.wakeContext);
676
+ const contextWorkItems = await hydrateIssueWorkItemsWithGoalIds(
677
+ db,
678
+ companyId,
679
+ resolveExecutionWorkItems(workItems, wakeWorkItems, options?.wakeContext)
680
+ );
912
681
  executionWorkItemsForBudget = contextWorkItems.map((item) => ({ issueId: item.id, projectId: item.project_id }));
913
682
  claimedIssueIds = workItems.map((item) => item.id);
914
683
  issueIds = contextWorkItems.map((item) => item.id);
@@ -1300,7 +1069,8 @@ export async function runHeartbeatForAgent(
1300
1069
  mission: context.company.mission ?? null,
1301
1070
  goalContext: {
1302
1071
  companyGoals: context.goalContext?.companyGoals ?? [],
1303
- projectGoals: context.goalContext?.projectGoals ?? []
1072
+ projectGoals: context.goalContext?.projectGoals ?? [],
1073
+ agentGoals: context.goalContext?.agentGoals ?? []
1304
1074
  }
1305
1075
  });
1306
1076
  await appendAuditEvent(db, {
@@ -1344,7 +1114,8 @@ export async function runHeartbeatForAgent(
1344
1114
  summary: executionSummary,
1345
1115
  mission: context.company.mission ?? null,
1346
1116
  companyGoals: context.goalContext?.companyGoals ?? [],
1347
- projectGoals: context.goalContext?.projectGoals ?? []
1117
+ projectGoals: context.goalContext?.projectGoals ?? [],
1118
+ agentGoals: context.goalContext?.agentGoals ?? []
1348
1119
  });
1349
1120
  await appendAuditEvent(db, {
1350
1121
  companyId,
@@ -1994,7 +1765,7 @@ export async function runHeartbeatForAgent(
1994
1765
  }
1995
1766
  await publishOfficeOccupantForAgent(db, options?.realtimeHub, companyId, agentId);
1996
1767
  try {
1997
- const queueModule = await import("./heartbeat-queue-service");
1768
+ const queueModule = await import("../heartbeat-queue-service");
1998
1769
  queueModule.triggerHeartbeatQueueWorker(db, companyId, {
1999
1770
  requestId: options?.requestId,
2000
1771
  realtimeHub: options?.realtimeHub
@@ -2020,13 +1791,18 @@ async function insertStartedRunAtomic(
2020
1791
  db: BopoDb,
2021
1792
  input: { id: string; companyId: string; agentId: string; message: string }
2022
1793
  ) {
2023
- const result = await db.execute(sql`
2024
- INSERT INTO heartbeat_runs (id, company_id, agent_id, status, message)
2025
- VALUES (${input.id}, ${input.companyId}, ${input.agentId}, 'started', ${input.message})
2026
- ON CONFLICT DO NOTHING
2027
- RETURNING id
2028
- `);
2029
- return result.length > 0;
1794
+ const inserted = await db
1795
+ .insert(heartbeatRuns)
1796
+ .values({
1797
+ id: input.id,
1798
+ companyId: input.companyId,
1799
+ agentId: input.agentId,
1800
+ status: "started",
1801
+ message: input.message
1802
+ })
1803
+ .onConflictDoNothing()
1804
+ .returning({ id: heartbeatRuns.id });
1805
+ return inserted.length > 0;
2030
1806
  }
2031
1807
 
2032
1808
  async function recoverStaleHeartbeatRuns(
@@ -2086,129 +1862,43 @@ async function recoverStaleHeartbeatRuns(
2086
1862
  }
2087
1863
  }
2088
1864
 
2089
- export async function runHeartbeatSweep(
1865
+ type IssueWorkItemRow = {
1866
+ id: string;
1867
+ project_id: string;
1868
+ parent_issue_id: string | null;
1869
+ title: string;
1870
+ body: string | null;
1871
+ status: string;
1872
+ priority: string;
1873
+ labels_json: string;
1874
+ tags_json: string;
1875
+ };
1876
+
1877
+ type IssueWorkItemRowWithGoals = IssueWorkItemRow & { goal_ids: string[] };
1878
+
1879
+ async function hydrateIssueWorkItemsWithGoalIds(
2090
1880
  db: BopoDb,
2091
1881
  companyId: string,
2092
- options?: { requestId?: string; realtimeHub?: RealtimeHub }
2093
- ) {
2094
- const companyAgents = await db.select().from(agents).where(eq(agents.companyId, companyId));
2095
- const latestRunByAgent = await listLatestRunByAgent(db, companyId);
2096
-
2097
- const now = new Date();
2098
- const enqueuedJobIds: string[] = [];
2099
- const dueAgents: Array<{ id: string }> = [];
2100
- let skippedNotDue = 0;
2101
- let skippedStatus = 0;
2102
- let skippedBudgetBlocked = 0;
2103
- let failedStarts = 0;
2104
- const sweepStartedAt = Date.now();
2105
- for (const agent of companyAgents) {
2106
- if (agent.status !== "idle" && agent.status !== "running") {
2107
- skippedStatus += 1;
2108
- continue;
2109
- }
2110
- if (!isHeartbeatDue(agent.heartbeatCron, latestRunByAgent.get(agent.id) ?? null, now)) {
2111
- skippedNotDue += 1;
2112
- continue;
2113
- }
2114
- const blockedProjectIds = await findPendingProjectBudgetOverrideBlocksForAgent(db, companyId, agent.id);
2115
- if (blockedProjectIds.length > 0) {
2116
- skippedBudgetBlocked += 1;
2117
- continue;
2118
- }
2119
- dueAgents.push({ id: agent.id });
1882
+ items: IssueWorkItemRow[]
1883
+ ): Promise<IssueWorkItemRowWithGoals[]> {
1884
+ if (items.length === 0) {
1885
+ return [];
2120
1886
  }
2121
- const sweepConcurrency = resolveHeartbeatSweepConcurrency(dueAgents.length);
2122
- const queueModule = await import("./heartbeat-queue-service");
2123
- await runWithConcurrency(dueAgents, sweepConcurrency, async (agent) => {
2124
- try {
2125
- const job = await queueModule.enqueueHeartbeatQueueJob(db, {
2126
- companyId,
2127
- agentId: agent.id,
2128
- jobType: "scheduler",
2129
- priority: 80,
2130
- idempotencyKey: options?.requestId ? `scheduler:${agent.id}:${options.requestId}` : null,
2131
- payload: {}
2132
- });
2133
- enqueuedJobIds.push(job.id);
2134
- queueModule.triggerHeartbeatQueueWorker(db, companyId, {
2135
- requestId: options?.requestId,
2136
- realtimeHub: options?.realtimeHub
2137
- });
2138
- } catch {
2139
- failedStarts += 1;
2140
- }
2141
- });
2142
- await appendAuditEvent(db, {
1887
+ const map = await listIssueGoalIdsBatch(
1888
+ db,
2143
1889
  companyId,
2144
- actorType: "system",
2145
- eventType: "heartbeat.sweep.completed",
2146
- entityType: "company",
2147
- entityId: companyId,
2148
- correlationId: options?.requestId ?? null,
2149
- payload: {
2150
- runIds: enqueuedJobIds,
2151
- startedCount: enqueuedJobIds.length,
2152
- dueCount: dueAgents.length,
2153
- failedStarts,
2154
- skippedStatus,
2155
- skippedNotDue,
2156
- skippedBudgetBlocked,
2157
- concurrency: sweepConcurrency,
2158
- elapsedMs: Date.now() - sweepStartedAt,
2159
- requestId: options?.requestId ?? null
2160
- }
2161
- });
2162
- return enqueuedJobIds;
2163
- }
2164
-
2165
- async function listLatestRunByAgent(db: BopoDb, companyId: string) {
2166
- const result = await db.execute(sql`
2167
- SELECT agent_id, MAX(started_at) AS latest_started_at
2168
- FROM heartbeat_runs
2169
- WHERE company_id = ${companyId}
2170
- GROUP BY agent_id
2171
- `);
2172
- const latestRunByAgent = new Map<string, Date>();
2173
- for (const row of result as Array<Record<string, unknown>>) {
2174
- const agentId = typeof row.agent_id === "string" ? row.agent_id : null;
2175
- if (!agentId) {
2176
- continue;
2177
- }
2178
- const startedAt = coerceDate(row.latest_started_at);
2179
- if (!startedAt) {
2180
- continue;
2181
- }
2182
- latestRunByAgent.set(agentId, startedAt);
2183
- }
2184
- return latestRunByAgent;
2185
- }
2186
-
2187
- function coerceDate(value: unknown) {
2188
- if (value instanceof Date) {
2189
- return Number.isNaN(value.getTime()) ? null : value;
2190
- }
2191
- if (typeof value === "string" || typeof value === "number") {
2192
- const parsed = new Date(value);
2193
- return Number.isNaN(parsed.getTime()) ? null : parsed;
2194
- }
2195
- return null;
1890
+ items.map((item) => item.id)
1891
+ );
1892
+ return items.map((item) => ({
1893
+ ...item,
1894
+ goal_ids: map.get(item.id) ?? []
1895
+ }));
2196
1896
  }
2197
1897
 
2198
1898
  async function loadWakeContextWorkItems(db: BopoDb, companyId: string, wakeIssueIds?: string[]) {
2199
1899
  const normalizedIds = Array.from(new Set((wakeIssueIds ?? []).filter((id) => id.trim().length > 0)));
2200
1900
  if (normalizedIds.length === 0) {
2201
- return [] as Array<{
2202
- id: string;
2203
- project_id: string;
2204
- parent_issue_id: string | null;
2205
- title: string;
2206
- body: string | null;
2207
- status: string;
2208
- priority: string;
2209
- labels_json: string;
2210
- tags_json: string;
2211
- }>;
1901
+ return [] as IssueWorkItemRow[];
2212
1902
  }
2213
1903
  const rows = await db
2214
1904
  .select({
@@ -2228,32 +1918,9 @@ async function loadWakeContextWorkItems(db: BopoDb, companyId: string, wakeIssue
2228
1918
  return rows.sort((a, b) => (sortOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (sortOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER));
2229
1919
  }
2230
1920
 
2231
- function mergeContextWorkItems(
2232
- assigned: Array<{
2233
- id: string;
2234
- project_id: string;
2235
- parent_issue_id: string | null;
2236
- title: string;
2237
- body: string | null;
2238
- status: string;
2239
- priority: string;
2240
- labels_json: string;
2241
- tags_json: string;
2242
- }>,
2243
- wakeContext: Array<{
2244
- id: string;
2245
- project_id: string;
2246
- parent_issue_id: string | null;
2247
- title: string;
2248
- body: string | null;
2249
- status: string;
2250
- priority: string;
2251
- labels_json: string;
2252
- tags_json: string;
2253
- }>
2254
- ) {
1921
+ function mergeContextWorkItems(assigned: IssueWorkItemRow[], wakeContext: IssueWorkItemRow[]) {
2255
1922
  const seen = new Set<string>();
2256
- const merged: typeof assigned = [];
1923
+ const merged: IssueWorkItemRow[] = [];
2257
1924
  for (const item of assigned) {
2258
1925
  if (!seen.has(item.id)) {
2259
1926
  seen.add(item.id);
@@ -2270,28 +1937,8 @@ function mergeContextWorkItems(
2270
1937
  }
2271
1938
 
2272
1939
  function resolveExecutionWorkItems(
2273
- assigned: Array<{
2274
- id: string;
2275
- project_id: string;
2276
- parent_issue_id: string | null;
2277
- title: string;
2278
- body: string | null;
2279
- status: string;
2280
- priority: string;
2281
- labels_json: string;
2282
- tags_json: string;
2283
- }>,
2284
- wakeContextItems: Array<{
2285
- id: string;
2286
- project_id: string;
2287
- parent_issue_id: string | null;
2288
- title: string;
2289
- body: string | null;
2290
- status: string;
2291
- priority: string;
2292
- labels_json: string;
2293
- tags_json: string;
2294
- }>,
1940
+ assigned: IssueWorkItemRow[],
1941
+ wakeContextItems: IssueWorkItemRow[],
2295
1942
  wakeContext?: HeartbeatWakeContext
2296
1943
  ) {
2297
1944
  if (wakeContext?.reason === "issue_comment_recipient" && wakeContextItems.length > 0) {
@@ -2329,6 +1976,62 @@ async function loadWakeContextCommentBody(db: BopoDb, companyId: string, comment
2329
1976
  return body && body.length > 0 ? body : null;
2330
1977
  }
2331
1978
 
1979
+ const GOAL_CONTEXT_DESC_MAX_CHARS = 280;
1980
+ const GOAL_ANCESTRY_NODE_DESC_MAX_CHARS = 120;
1981
+ const GOAL_ANCESTRY_MAX_DEPTH = 16;
1982
+
1983
+ type GoalRowForHeartbeat = {
1984
+ id: string;
1985
+ title: string;
1986
+ description: string | null;
1987
+ parentGoalId: string | null;
1988
+ level: string;
1989
+ projectId: string | null;
1990
+ status: string;
1991
+ ownerAgentId: string | null;
1992
+ };
1993
+
1994
+ function clipGoalDescription(text: string, max: number) {
1995
+ const t = text.trim();
1996
+ if (t.length <= max) {
1997
+ return t;
1998
+ }
1999
+ return `${t.slice(0, Math.max(0, max - 1))}…`;
2000
+ }
2001
+
2002
+ function formatGoalContextLine(goal: Pick<GoalRowForHeartbeat, "title" | "description">, descMax: number) {
2003
+ const title = goal.title.trim();
2004
+ const desc = goal.description?.trim();
2005
+ if (!desc) {
2006
+ return title;
2007
+ }
2008
+ return `${title} — ${clipGoalDescription(desc, descMax)}`;
2009
+ }
2010
+
2011
+ function buildGoalAncestryLines(
2012
+ goalId: string | null | undefined,
2013
+ byId: Map<string, GoalRowForHeartbeat>,
2014
+ nodeDescMax: number
2015
+ ) {
2016
+ if (!goalId) {
2017
+ return [] as string[];
2018
+ }
2019
+ const chain: string[] = [];
2020
+ let current: GoalRowForHeartbeat | undefined = byId.get(goalId);
2021
+ const visited = new Set<string>();
2022
+ let depth = 0;
2023
+ while (current && depth < GOAL_ANCESTRY_MAX_DEPTH) {
2024
+ if (visited.has(current.id)) {
2025
+ break;
2026
+ }
2027
+ visited.add(current.id);
2028
+ chain.unshift(formatGoalContextLine(current, nodeDescMax));
2029
+ current = current.parentGoalId ? byId.get(current.parentGoalId) : undefined;
2030
+ depth += 1;
2031
+ }
2032
+ return chain;
2033
+ }
2034
+
2332
2035
  async function buildHeartbeatContext(
2333
2036
  db: BopoDb,
2334
2037
  companyId: string,
@@ -2343,17 +2046,7 @@ async function buildHeartbeatContext(
2343
2046
  memoryContext?: HeartbeatContext["memoryContext"];
2344
2047
  runtime?: { command?: string; args?: string[]; cwd?: string; timeoutMs?: number };
2345
2048
  wakeContext?: HeartbeatWakeContext;
2346
- workItems: Array<{
2347
- id: string;
2348
- project_id: string;
2349
- parent_issue_id: string | null;
2350
- title: string;
2351
- body: string | null;
2352
- status: string;
2353
- priority: string;
2354
- labels_json: string;
2355
- tags_json: string;
2356
- }>;
2049
+ workItems: IssueWorkItemRowWithGoals[];
2357
2050
  }
2358
2051
  ): Promise<HeartbeatContext> {
2359
2052
  const [company] = await db
@@ -2444,27 +2137,67 @@ async function buildHeartbeatContext(
2444
2137
  id: goals.id,
2445
2138
  level: goals.level,
2446
2139
  title: goals.title,
2140
+ description: goals.description,
2447
2141
  status: goals.status,
2448
- projectId: goals.projectId
2142
+ projectId: goals.projectId,
2143
+ parentGoalId: goals.parentGoalId,
2144
+ ownerAgentId: goals.ownerAgentId
2449
2145
  })
2450
2146
  .from(goals)
2451
2147
  .where(eq(goals.companyId, companyId));
2452
2148
 
2149
+ const goalById = new Map<string, GoalRowForHeartbeat>(
2150
+ goalRows.map((row) => [
2151
+ row.id,
2152
+ {
2153
+ id: row.id,
2154
+ title: row.title,
2155
+ description: row.description,
2156
+ parentGoalId: row.parentGoalId,
2157
+ level: row.level,
2158
+ projectId: row.projectId,
2159
+ status: row.status,
2160
+ ownerAgentId: row.ownerAgentId
2161
+ }
2162
+ ])
2163
+ );
2164
+
2453
2165
  const activeCompanyGoals = goalRows
2454
2166
  .filter((goal) => goal.status === "active" && goal.level === "company")
2455
- .map((goal) => goal.title);
2167
+ .map((goal) => formatGoalContextLine(goal, GOAL_CONTEXT_DESC_MAX_CHARS));
2456
2168
  const activeProjectGoals = goalRows
2457
2169
  .filter(
2458
2170
  (goal) =>
2459
2171
  goal.status === "active" && goal.level === "project" && goal.projectId && projectIds.includes(goal.projectId)
2460
2172
  )
2461
- .map((goal) => goal.title);
2173
+ .map((goal) => formatGoalContextLine(goal, GOAL_CONTEXT_DESC_MAX_CHARS));
2462
2174
  const activeAgentGoals = goalRows
2463
- .filter((goal) => goal.status === "active" && goal.level === "agent")
2464
- .map((goal) => goal.title);
2175
+ .filter(
2176
+ (goal) =>
2177
+ goal.status === "active" &&
2178
+ goal.level === "agent" &&
2179
+ (!goal.ownerAgentId || goal.ownerAgentId === input.agentId)
2180
+ )
2181
+ .map((goal) => formatGoalContextLine(goal, GOAL_CONTEXT_DESC_MAX_CHARS));
2465
2182
  const isCommentOrderWake = input.wakeContext?.reason === "issue_comment_recipient";
2466
2183
  const promptMode = resolveHeartbeatPromptMode();
2467
2184
 
2185
+ const companyAgentRows = await listAgents(db, companyId);
2186
+ const teamRoster = companyAgentRows
2187
+ .filter((row) => row.status !== "terminated")
2188
+ .sort((a, b) => {
2189
+ const byName = a.name.localeCompare(b.name);
2190
+ return byName !== 0 ? byName : a.id.localeCompare(b.id);
2191
+ })
2192
+ .map((row) => ({
2193
+ id: row.id,
2194
+ name: row.name,
2195
+ role: row.role,
2196
+ title: row.title ?? null,
2197
+ capabilities: row.capabilities ?? null,
2198
+ status: row.status
2199
+ }));
2200
+
2468
2201
  return {
2469
2202
  companyId,
2470
2203
  agentId: input.agentId,
@@ -2480,6 +2213,7 @@ async function buildHeartbeatContext(
2480
2213
  role: input.agentRole,
2481
2214
  managerAgentId: input.managerAgentId
2482
2215
  },
2216
+ teamRoster,
2483
2217
  state: input.state,
2484
2218
  memoryContext: input.memoryContext,
2485
2219
  runtime: input.runtime,
@@ -2500,6 +2234,11 @@ async function buildHeartbeatContext(
2500
2234
  issueId: item.id,
2501
2235
  projectId: item.project_id,
2502
2236
  parentIssueId: item.parent_issue_id,
2237
+ goalIds: item.goal_ids,
2238
+ goalAncestryChains: item.goal_ids.map((gid) => {
2239
+ const chain = buildGoalAncestryLines(gid, goalById, GOAL_ANCESTRY_NODE_DESC_MAX_CHARS);
2240
+ return chain.length > 0 ? chain : [gid];
2241
+ }),
2503
2242
  childIssueIds: childIssueIdsByParent.get(item.id) ?? [],
2504
2243
  projectName: projectNameById.get(item.project_id) ?? null,
2505
2244
  title: item.title,
@@ -2531,10 +2270,13 @@ function computeMissionAlignmentSignal(input: {
2531
2270
  mission: string | null;
2532
2271
  companyGoals: string[];
2533
2272
  projectGoals: string[];
2273
+ agentGoals: string[];
2534
2274
  }) {
2535
2275
  const summaryTokens = new Set(tokenizeAlignmentText(input.summary));
2536
2276
  const missionTokens = tokenizeAlignmentText(input.mission ?? "");
2537
- const goalTokens = tokenizeAlignmentText([...input.companyGoals, ...input.projectGoals].join(" "));
2277
+ const goalTokens = tokenizeAlignmentText(
2278
+ [...input.companyGoals, ...input.projectGoals, ...input.agentGoals].join(" ")
2279
+ );
2538
2280
  const matchedMissionTerms = missionTokens.filter((token) => summaryTokens.has(token));
2539
2281
  const matchedGoalTerms = goalTokens.filter((token) => summaryTokens.has(token));
2540
2282
  const missionScore = missionTokens.length > 0 ? matchedMissionTerms.length / missionTokens.length : 0;
@@ -2765,34 +2507,6 @@ async function ensureProjectBudgetOverrideApprovalRequest(
2765
2507
  return approvalId;
2766
2508
  }
2767
2509
 
2768
- function sanitizeAgentSummaryCommentBody(body: string) {
2769
- const sanitized = body.replace(AGENT_COMMENT_EMOJI_REGEX, "").trim();
2770
- return sanitized.length > 0 ? sanitized : "Run update.";
2771
- }
2772
-
2773
- function extractNaturalRunUpdate(executionSummary: string) {
2774
- const normalized = executionSummary.trim();
2775
- const jsonSummary = extractSummaryFromJsonLikeText(normalized);
2776
- const source = jsonSummary ?? normalized;
2777
- const lines = source
2778
- .split("\n")
2779
- .map((line) => line.trim())
2780
- .filter((line) => line.length > 0)
2781
- .filter((line) => !line.startsWith("{") && !line.startsWith("}"));
2782
- const compact = (lines.length > 0 ? lines.slice(0, 2).join(" ") : source)
2783
- .replace(/^run (failure )?summary\s*:\s*/i, "")
2784
- .replace(/^completed all assigned issue steps\s*:\s*/i, "")
2785
- .replace(/^issue status\s*:\s*/i, "")
2786
- .replace(/`+/g, "")
2787
- .replace(/\s+/g, " ")
2788
- .trim();
2789
- const bounded = compact.length > 260 ? `${compact.slice(0, 257).trimEnd()}...` : compact;
2790
- if (!bounded) {
2791
- return "Run update.";
2792
- }
2793
- return /[.!?]$/.test(bounded) ? bounded : `${bounded}.`;
2794
- }
2795
-
2796
2510
  function buildRunDigest(input: {
2797
2511
  status: "completed" | "failed" | "skipped";
2798
2512
  executionSummary: string;
@@ -3188,6 +2902,11 @@ function normalizeAgentOperatingArtifactRelativePath(pathValue: string | null, c
3188
2902
  const [, agentId, suffix = ""] = directMatch;
3189
2903
  return `workspace/${companyId}/agents/${agentId}/operating${suffix}`;
3190
2904
  }
2905
+ const projectAgentsMatch = normalized.match(/^projects\/agents\/([^/]+)\/operating(\/.*)?$/);
2906
+ if (projectAgentsMatch) {
2907
+ const [, agentId, suffix = ""] = projectAgentsMatch;
2908
+ return `workspace/${companyId}/agents/${agentId}/operating${suffix}`;
2909
+ }
3191
2910
  const issueScopedMatch = normalized.match(
3192
2911
  /^(?:[^/]+\/)?projects\/[^/]+\/issues\/[^/]+\/agents\/([^/]+)\/operating(\/.*)?$/
3193
2912
  );
@@ -3484,30 +3203,6 @@ function isMachineNoiseLine(text: string) {
3484
3203
  return patterns.some((pattern) => pattern.test(normalized));
3485
3204
  }
3486
3205
 
3487
- function extractSummaryFromJsonLikeText(input: string) {
3488
- const fencedMatch = input.match(/```(?:json)?\s*([\s\S]*?)```/i);
3489
- const candidate = fencedMatch?.[1]?.trim() ?? input.match(/\{[\s\S]*\}\s*$/)?.[0]?.trim();
3490
- if (!candidate) {
3491
- return null;
3492
- }
3493
- try {
3494
- const parsed = JSON.parse(candidate) as Record<string, unknown>;
3495
- const summary = parsed.summary;
3496
- if (typeof summary === "string" && summary.trim().length > 0) {
3497
- return summary.trim();
3498
- }
3499
- } catch {
3500
- // Fall through to regex extraction for loosely-formatted JSON.
3501
- }
3502
- const summaryMatch = candidate.match(/"summary"\s*:\s*"([\s\S]*?)"/);
3503
- const summary = summaryMatch?.[1]
3504
- ?.replace(/\\"/g, "\"")
3505
- .replace(/\\n/g, " ")
3506
- .replace(/\s+/g, " ")
3507
- .trim();
3508
- return summary && summary.length > 0 ? summary : null;
3509
- }
3510
-
3511
3206
  async function appendRunSummaryComments(
3512
3207
  db: BopoDb,
3513
3208
  input: {
@@ -3881,32 +3576,6 @@ function isUsefulTranscriptSignal(level: "high" | "medium" | "low" | "noise") {
3881
3576
  return level === "high" || level === "medium";
3882
3577
  }
3883
3578
 
3884
- function publishHeartbeatRunStatus(
3885
- realtimeHub: RealtimeHub | undefined,
3886
- input: {
3887
- companyId: string;
3888
- runId: string;
3889
- status: "started" | "completed" | "failed" | "skipped";
3890
- message?: string | null;
3891
- startedAt?: Date;
3892
- finishedAt?: Date;
3893
- }
3894
- ) {
3895
- if (!realtimeHub) {
3896
- return;
3897
- }
3898
- realtimeHub.publish(
3899
- createHeartbeatRunsRealtimeEvent(input.companyId, {
3900
- type: "run.status.updated",
3901
- runId: input.runId,
3902
- status: input.status,
3903
- message: input.message ?? null,
3904
- startedAt: input.startedAt?.toISOString(),
3905
- finishedAt: input.finishedAt?.toISOString() ?? null
3906
- })
3907
- );
3908
- }
3909
-
3910
3579
  async function resolveRuntimeWorkspaceForWorkItems(
3911
3580
  db: BopoDb,
3912
3581
  companyId: string,
@@ -4071,39 +3740,6 @@ function resolveStaleRunThresholdMs() {
4071
3740
  return parsed;
4072
3741
  }
4073
3742
 
4074
- function resolveHeartbeatSweepConcurrency(dueAgentsCount: number) {
4075
- const configured = Number(process.env.BOPO_HEARTBEAT_SWEEP_CONCURRENCY ?? "4");
4076
- const fallback = 4;
4077
- const normalized = Number.isFinite(configured) ? Math.floor(configured) : fallback;
4078
- if (normalized < 1) {
4079
- return 1;
4080
- }
4081
- // Prevent scheduler bursts from starving the API event loop.
4082
- const bounded = Math.min(normalized, 16);
4083
- return Math.min(bounded, Math.max(1, dueAgentsCount));
4084
- }
4085
-
4086
- async function runWithConcurrency<T>(
4087
- items: T[],
4088
- concurrency: number,
4089
- worker: (item: T, index: number) => Promise<void>
4090
- ) {
4091
- if (items.length === 0) {
4092
- return;
4093
- }
4094
- const workerCount = Math.max(1, Math.min(Math.floor(concurrency), items.length));
4095
- let cursor = 0;
4096
- await Promise.all(
4097
- Array.from({ length: workerCount }, async () => {
4098
- while (cursor < items.length) {
4099
- const index = cursor;
4100
- cursor += 1;
4101
- await worker(items[index] as T, index);
4102
- }
4103
- })
4104
- );
4105
- }
4106
-
4107
3743
  function resolveEffectiveStaleRunThresholdMs(input: {
4108
3744
  baseThresholdMs: number;
4109
3745
  runtimeTimeoutSec: number;
@@ -4323,14 +3959,6 @@ function mergeRuntimeForExecution(
4323
3959
  };
4324
3960
  }
4325
3961
 
4326
- function registerActiveHeartbeatRun(runId: string, run: ActiveHeartbeatRun) {
4327
- activeHeartbeatRuns.set(runId, run);
4328
- }
4329
-
4330
- function unregisterActiveHeartbeatRun(runId: string) {
4331
- activeHeartbeatRuns.delete(runId);
4332
- }
4333
-
4334
3962
  function clearResumeState(
4335
3963
  state: AgentState & {
4336
3964
  runtime?: {
@@ -4382,8 +4010,6 @@ function resolveHeartbeatPromptMode(): "full" | "compact" {
4382
4010
  return raw === "compact" ? "compact" : "full";
4383
4011
  }
4384
4012
 
4385
- type HeartbeatIdlePolicy = "full" | "skip_adapter" | "micro_prompt";
4386
-
4387
4013
  function resolveHeartbeatIdlePolicy(): HeartbeatIdlePolicy {
4388
4014
  const raw = process.env.BOPO_HEARTBEAT_IDLE_POLICY?.trim().toLowerCase();
4389
4015
  if (raw === "skip_adapter") {
@@ -4770,62 +4396,3 @@ async function appendFinishedRunCostEntry(input: {
4770
4396
  usdCostStatus
4771
4397
  };
4772
4398
  }
4773
-
4774
- function isHeartbeatDue(cronExpression: string, lastRunAt: Date | null, now: Date) {
4775
- const normalizedNow = truncateToMinute(now);
4776
- if (!matchesCronExpression(cronExpression, normalizedNow)) {
4777
- return false;
4778
- }
4779
- if (!lastRunAt) {
4780
- return true;
4781
- }
4782
- return truncateToMinute(lastRunAt).getTime() !== normalizedNow.getTime();
4783
- }
4784
-
4785
- function truncateToMinute(date: Date) {
4786
- const clone = new Date(date);
4787
- clone.setSeconds(0, 0);
4788
- return clone;
4789
- }
4790
-
4791
- function matchesCronExpression(expression: string, date: Date) {
4792
- const parts = expression.trim().split(/\s+/);
4793
- if (parts.length !== 5) {
4794
- return false;
4795
- }
4796
-
4797
- const [minute, hour, dayOfMonth, month, dayOfWeek] = parts as [string, string, string, string, string];
4798
- return (
4799
- matchesCronField(minute, date.getMinutes(), 0, 59) &&
4800
- matchesCronField(hour, date.getHours(), 0, 23) &&
4801
- matchesCronField(dayOfMonth, date.getDate(), 1, 31) &&
4802
- matchesCronField(month, date.getMonth() + 1, 1, 12) &&
4803
- matchesCronField(dayOfWeek, date.getDay(), 0, 6)
4804
- );
4805
- }
4806
-
4807
- function matchesCronField(field: string, value: number, min: number, max: number) {
4808
- return field.split(",").some((part) => matchesCronPart(part.trim(), value, min, max));
4809
- }
4810
-
4811
- function matchesCronPart(part: string, value: number, min: number, max: number): boolean {
4812
- if (part === "*") {
4813
- return true;
4814
- }
4815
-
4816
- const stepMatch = part.match(/^\*\/(\d+)$/);
4817
- if (stepMatch) {
4818
- const step = Number(stepMatch[1]);
4819
- return Number.isInteger(step) && step > 0 ? (value - min) % step === 0 : false;
4820
- }
4821
-
4822
- const rangeMatch = part.match(/^(\d+)-(\d+)$/);
4823
- if (rangeMatch) {
4824
- const start = Number(rangeMatch[1]);
4825
- const end = Number(rangeMatch[2]);
4826
- return start <= value && value <= end;
4827
- }
4828
-
4829
- const exact = Number(part);
4830
- return Number.isInteger(exact) && exact >= min && exact <= max && exact === value;
4831
- }