bopodev-api 0.1.28 → 0.1.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/package.json +4 -4
  2. package/src/app.ts +17 -69
  3. package/src/lib/run-artifact-paths.ts +8 -0
  4. package/src/middleware/cors-config.ts +36 -0
  5. package/src/middleware/request-actor.ts +10 -16
  6. package/src/middleware/request-id.ts +9 -0
  7. package/src/middleware/request-logging.ts +24 -0
  8. package/src/routes/agents.ts +3 -9
  9. package/src/routes/companies.ts +18 -1
  10. package/src/routes/goals.ts +7 -13
  11. package/src/routes/governance.ts +2 -5
  12. package/src/routes/heartbeats.ts +7 -25
  13. package/src/routes/issues.ts +62 -120
  14. package/src/routes/observability.ts +6 -1
  15. package/src/routes/plugins.ts +5 -17
  16. package/src/routes/projects.ts +7 -25
  17. package/src/routes/templates.ts +6 -21
  18. package/src/scripts/onboard-seed.ts +5 -7
  19. package/src/server.ts +33 -292
  20. package/src/services/company-export-service.ts +63 -0
  21. package/src/services/governance-service.ts +4 -1
  22. package/src/services/heartbeat-service/active-runs.ts +15 -0
  23. package/src/services/heartbeat-service/budget-override.ts +46 -0
  24. package/src/services/heartbeat-service/claims.ts +61 -0
  25. package/src/services/heartbeat-service/cron.ts +58 -0
  26. package/src/services/heartbeat-service/heartbeat-realtime.ts +28 -0
  27. package/src/services/heartbeat-service/heartbeat-run-summary-text.ts +53 -0
  28. package/src/services/{heartbeat-service.ts → heartbeat-service/heartbeat-run.ts} +183 -633
  29. package/src/services/heartbeat-service/index.ts +5 -0
  30. package/src/services/heartbeat-service/stop.ts +90 -0
  31. package/src/services/heartbeat-service/sweep.ts +145 -0
  32. package/src/services/heartbeat-service/types.ts +65 -0
  33. package/src/services/memory-file-service.ts +10 -2
  34. package/src/shutdown/graceful-shutdown.ts +77 -0
  35. package/src/startup/database.ts +41 -0
  36. package/src/startup/deployment-validation.ts +37 -0
  37. package/src/startup/env.ts +17 -0
  38. package/src/startup/runtime-health.ts +128 -0
  39. package/src/startup/scheduler-config.ts +39 -0
  40. package/src/types/express.d.ts +13 -0
  41. package/src/types/request-actor.ts +6 -0
  42. package/src/validation/issue-routes.ts +79 -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
41
  import { appendAuditEvent, appendCost } from "bopodev-db";
41
- import { parseRuntimeConfigFromAgentRow } from "../lib/agent-config";
42
- import { bootstrapRepositoryWorkspace, ensureIsolatedGitWorktree, GitRuntimeError } from "../lib/git-runtime";
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,24 +2137,48 @@ 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
 
@@ -2500,6 +2217,11 @@ async function buildHeartbeatContext(
2500
2217
  issueId: item.id,
2501
2218
  projectId: item.project_id,
2502
2219
  parentIssueId: item.parent_issue_id,
2220
+ goalIds: item.goal_ids,
2221
+ goalAncestryChains: item.goal_ids.map((gid) => {
2222
+ const chain = buildGoalAncestryLines(gid, goalById, GOAL_ANCESTRY_NODE_DESC_MAX_CHARS);
2223
+ return chain.length > 0 ? chain : [gid];
2224
+ }),
2503
2225
  childIssueIds: childIssueIdsByParent.get(item.id) ?? [],
2504
2226
  projectName: projectNameById.get(item.project_id) ?? null,
2505
2227
  title: item.title,
@@ -2531,10 +2253,13 @@ function computeMissionAlignmentSignal(input: {
2531
2253
  mission: string | null;
2532
2254
  companyGoals: string[];
2533
2255
  projectGoals: string[];
2256
+ agentGoals: string[];
2534
2257
  }) {
2535
2258
  const summaryTokens = new Set(tokenizeAlignmentText(input.summary));
2536
2259
  const missionTokens = tokenizeAlignmentText(input.mission ?? "");
2537
- const goalTokens = tokenizeAlignmentText([...input.companyGoals, ...input.projectGoals].join(" "));
2260
+ const goalTokens = tokenizeAlignmentText(
2261
+ [...input.companyGoals, ...input.projectGoals, ...input.agentGoals].join(" ")
2262
+ );
2538
2263
  const matchedMissionTerms = missionTokens.filter((token) => summaryTokens.has(token));
2539
2264
  const matchedGoalTerms = goalTokens.filter((token) => summaryTokens.has(token));
2540
2265
  const missionScore = missionTokens.length > 0 ? matchedMissionTerms.length / missionTokens.length : 0;
@@ -2765,34 +2490,6 @@ async function ensureProjectBudgetOverrideApprovalRequest(
2765
2490
  return approvalId;
2766
2491
  }
2767
2492
 
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
2493
  function buildRunDigest(input: {
2797
2494
  status: "completed" | "failed" | "skipped";
2798
2495
  executionSummary: string;
@@ -3188,6 +2885,11 @@ function normalizeAgentOperatingArtifactRelativePath(pathValue: string | null, c
3188
2885
  const [, agentId, suffix = ""] = directMatch;
3189
2886
  return `workspace/${companyId}/agents/${agentId}/operating${suffix}`;
3190
2887
  }
2888
+ const projectAgentsMatch = normalized.match(/^projects\/agents\/([^/]+)\/operating(\/.*)?$/);
2889
+ if (projectAgentsMatch) {
2890
+ const [, agentId, suffix = ""] = projectAgentsMatch;
2891
+ return `workspace/${companyId}/agents/${agentId}/operating${suffix}`;
2892
+ }
3191
2893
  const issueScopedMatch = normalized.match(
3192
2894
  /^(?:[^/]+\/)?projects\/[^/]+\/issues\/[^/]+\/agents\/([^/]+)\/operating(\/.*)?$/
3193
2895
  );
@@ -3484,30 +3186,6 @@ function isMachineNoiseLine(text: string) {
3484
3186
  return patterns.some((pattern) => pattern.test(normalized));
3485
3187
  }
3486
3188
 
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
3189
  async function appendRunSummaryComments(
3512
3190
  db: BopoDb,
3513
3191
  input: {
@@ -3881,32 +3559,6 @@ function isUsefulTranscriptSignal(level: "high" | "medium" | "low" | "noise") {
3881
3559
  return level === "high" || level === "medium";
3882
3560
  }
3883
3561
 
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
3562
  async function resolveRuntimeWorkspaceForWorkItems(
3911
3563
  db: BopoDb,
3912
3564
  companyId: string,
@@ -4071,39 +3723,6 @@ function resolveStaleRunThresholdMs() {
4071
3723
  return parsed;
4072
3724
  }
4073
3725
 
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
3726
  function resolveEffectiveStaleRunThresholdMs(input: {
4108
3727
  baseThresholdMs: number;
4109
3728
  runtimeTimeoutSec: number;
@@ -4323,14 +3942,6 @@ function mergeRuntimeForExecution(
4323
3942
  };
4324
3943
  }
4325
3944
 
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
3945
  function clearResumeState(
4335
3946
  state: AgentState & {
4336
3947
  runtime?: {
@@ -4382,8 +3993,6 @@ function resolveHeartbeatPromptMode(): "full" | "compact" {
4382
3993
  return raw === "compact" ? "compact" : "full";
4383
3994
  }
4384
3995
 
4385
- type HeartbeatIdlePolicy = "full" | "skip_adapter" | "micro_prompt";
4386
-
4387
3996
  function resolveHeartbeatIdlePolicy(): HeartbeatIdlePolicy {
4388
3997
  const raw = process.env.BOPO_HEARTBEAT_IDLE_POLICY?.trim().toLowerCase();
4389
3998
  if (raw === "skip_adapter") {
@@ -4770,62 +4379,3 @@ async function appendFinishedRunCostEntry(input: {
4770
4379
  usdCostStatus
4771
4380
  };
4772
4381
  }
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
- }