bopodev-api 0.1.23 → 0.1.25

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.
@@ -0,0 +1,391 @@
1
+ import { and, desc, eq, like } from "drizzle-orm";
2
+ import {
3
+ issueComments,
4
+ issues,
5
+ listApprovalRequests,
6
+ listAttentionInboxStates,
7
+ listHeartbeatRuns,
8
+ listIssues,
9
+ markAttentionInboxAcknowledged,
10
+ markAttentionInboxDismissed,
11
+ markAttentionInboxResolved,
12
+ markAttentionInboxSeen,
13
+ clearAttentionInboxDismissed,
14
+ type BopoDb
15
+ } from "bopodev-db";
16
+ import type { BoardAttentionItem } from "bopodev-contracts";
17
+
18
+ type AttentionStateRow = Awaited<ReturnType<typeof listAttentionInboxStates>>[number];
19
+
20
+ export async function listBoardAttentionItems(db: BopoDb, companyId: string, actorId: string): Promise<BoardAttentionItem[]> {
21
+ const [approvals, blockedIssues, heartbeatRuns, stateRows, boardComments] = await Promise.all([
22
+ listApprovalRequests(db, companyId),
23
+ listIssues(db, companyId),
24
+ listHeartbeatRuns(db, companyId, 300),
25
+ listAttentionInboxStates(db, companyId, actorId),
26
+ db
27
+ .select({
28
+ id: issueComments.id,
29
+ issueId: issueComments.issueId,
30
+ body: issueComments.body,
31
+ createdAt: issueComments.createdAt,
32
+ issueTitle: issues.title
33
+ })
34
+ .from(issueComments)
35
+ .innerJoin(issues, and(eq(issues.id, issueComments.issueId), eq(issues.companyId, issueComments.companyId)))
36
+ .where(and(eq(issueComments.companyId, companyId), like(issueComments.recipientsJson, '%"recipientType":"board"%')))
37
+ .orderBy(desc(issueComments.createdAt))
38
+ .limit(40)
39
+ ]);
40
+
41
+ const stateByKey = new Map(stateRows.map((row) => [row.itemKey, row]));
42
+ const items: BoardAttentionItem[] = [];
43
+
44
+ for (const approval of approvals) {
45
+ if (approval.status !== "pending") {
46
+ continue;
47
+ }
48
+ const payload = parsePayload(approval.payloadJson);
49
+ const ageHours = ageHoursFromDate(approval.createdAt);
50
+ if (approval.action === "override_budget") {
51
+ const projectId = asString(payload.projectId);
52
+ const agentId = asString(payload.agentId);
53
+ const utilizationPct = asNumber(payload.utilizationPct);
54
+ const currentBudget = asNumber(payload.currentMonthlyBudgetUsd);
55
+ const usedBudget = asNumber(payload.usedBudgetUsd);
56
+ const key = `budget:${approval.id}`;
57
+ items.push(
58
+ withState(
59
+ {
60
+ key,
61
+ category: "budget_hard_stop",
62
+ severity: ageHours >= 12 ? "critical" : "warning",
63
+ requiredActor: "board",
64
+ title: "Budget hard-stop requires board decision",
65
+ contextSummary: projectId
66
+ ? `Project ${shortId(projectId)} is blocked by budget hard-stop.`
67
+ : agentId
68
+ ? `Agent ${shortId(agentId)} is blocked by budget hard-stop.`
69
+ : "Agent work is blocked by budget hard-stop.",
70
+ actionLabel: "Review budget override",
71
+ actionHref: "/governance",
72
+ impactSummary: "Heartbeat work stays paused until budget override is approved or rejected.",
73
+ evidence: {
74
+ approvalId: approval.id,
75
+ projectId: projectId ?? undefined,
76
+ agentId: agentId ?? undefined
77
+ },
78
+ sourceTimestamp: approval.createdAt.toISOString(),
79
+ state: "open",
80
+ seenAt: null,
81
+ acknowledgedAt: null,
82
+ dismissedAt: null,
83
+ resolvedAt: null
84
+ },
85
+ stateByKey.get(key),
86
+ `Budget utilization ${formatPercent(utilizationPct)} (${formatUsd(usedBudget)} / ${formatUsd(currentBudget)}).`
87
+ )
88
+ );
89
+ continue;
90
+ }
91
+
92
+ const key = `approval:${approval.id}`;
93
+ items.push(
94
+ withState(
95
+ {
96
+ key,
97
+ category: "approval_required",
98
+ severity: ageHours >= 24 ? "critical" : "warning",
99
+ requiredActor: "board",
100
+ title: "Approval required",
101
+ contextSummary: formatApprovalContext(approval.action, payload),
102
+ actionLabel: "Open approvals",
103
+ actionHref: "/governance",
104
+ impactSummary: "Execution remains blocked until this governance decision is resolved.",
105
+ evidence: {
106
+ approvalId: approval.id,
107
+ projectId: asString(payload.projectId) ?? undefined,
108
+ agentId: asString(payload.agentId) ?? undefined
109
+ },
110
+ sourceTimestamp: approval.createdAt.toISOString(),
111
+ state: "open",
112
+ seenAt: null,
113
+ acknowledgedAt: null,
114
+ dismissedAt: null,
115
+ resolvedAt: null
116
+ },
117
+ stateByKey.get(key)
118
+ )
119
+ );
120
+ }
121
+
122
+ const openIssues = blockedIssues.filter((issue) => issue.status !== "done" && issue.status !== "canceled");
123
+ const blockedOpenIssues = openIssues.filter((issue) => issue.status === "blocked");
124
+ for (const issue of blockedOpenIssues) {
125
+ const blockedHours = ageHoursFromDate(issue.updatedAt);
126
+ if (blockedHours < 2) {
127
+ continue;
128
+ }
129
+ const key = `issue_blocked:${issue.id}`;
130
+ items.push(
131
+ withState(
132
+ {
133
+ key,
134
+ category: "blocker_escalation",
135
+ severity: blockedHours >= 24 ? "critical" : "warning",
136
+ requiredActor: "board",
137
+ title: "Issue remains blocked",
138
+ contextSummary: `${issue.title} has been blocked for ${formatAgeHours(blockedHours)}.`,
139
+ actionLabel: "Open issue",
140
+ actionHref: `/issues/${issue.id}`,
141
+ impactSummary: "Delivery is delayed until this blocker is addressed or rerouted.",
142
+ evidence: {
143
+ issueId: issue.id,
144
+ projectId: issue.projectId
145
+ },
146
+ sourceTimestamp: issue.updatedAt.toISOString(),
147
+ state: "open",
148
+ seenAt: null,
149
+ acknowledgedAt: null,
150
+ dismissedAt: null,
151
+ resolvedAt: null
152
+ },
153
+ stateByKey.get(key)
154
+ )
155
+ );
156
+ }
157
+
158
+ const staleOpenIssues = openIssues.filter((issue) => ageHoursFromDate(issue.updatedAt) >= 7 * 24);
159
+ if (staleOpenIssues.length > 0) {
160
+ const oldest = staleOpenIssues.reduce((max, issue) => Math.max(max, ageHoursFromDate(issue.updatedAt)), 0);
161
+ const key = "stalled_work:global";
162
+ items.push(
163
+ withState(
164
+ {
165
+ key,
166
+ category: "stalled_work",
167
+ severity: staleOpenIssues.length >= 5 || oldest >= 14 * 24 ? "critical" : "warning",
168
+ requiredActor: "board",
169
+ title: "Stalled work trend detected",
170
+ contextSummary: `${staleOpenIssues.length} open issues have had no updates for over 7 days.`,
171
+ actionLabel: "Review open issues",
172
+ actionHref: "/issues",
173
+ impactSummary: "Backlog throughput is slowing and confidence in delivery decreases over time.",
174
+ evidence: {},
175
+ sourceTimestamp: new Date().toISOString(),
176
+ state: "open",
177
+ seenAt: null,
178
+ acknowledgedAt: null,
179
+ dismissedAt: null,
180
+ resolvedAt: null
181
+ },
182
+ stateByKey.get(key)
183
+ )
184
+ );
185
+ }
186
+
187
+ const now = Date.now();
188
+ const runs24h = heartbeatRuns.filter((run) => now - run.startedAt.getTime() <= 24 * 60 * 60 * 1000);
189
+ const failed24h = runs24h.filter((run) => run.status === "failed").length;
190
+ if (failed24h > 0) {
191
+ const key = "run_failure_spike:24h";
192
+ const severity = failed24h >= 5 || failed24h / Math.max(runs24h.length, 1) >= 0.4 ? "critical" : "warning";
193
+ items.push(
194
+ withState(
195
+ {
196
+ key,
197
+ category: "run_failure_spike",
198
+ severity,
199
+ requiredActor: "board",
200
+ title: "Run failure spike in last 24h",
201
+ contextSummary: `${failed24h} failed runs out of ${runs24h.length} total in the last 24 hours.`,
202
+ actionLabel: "Inspect runs",
203
+ actionHref: "/runs",
204
+ impactSummary: "Repeated runtime failures can halt issue progress across multiple teams.",
205
+ evidence: {},
206
+ sourceTimestamp: new Date().toISOString(),
207
+ state: "open",
208
+ seenAt: null,
209
+ acknowledgedAt: null,
210
+ dismissedAt: null,
211
+ resolvedAt: null
212
+ },
213
+ stateByKey.get(key)
214
+ )
215
+ );
216
+ }
217
+
218
+ for (const comment of boardComments) {
219
+ const key = `comment:${comment.id}`;
220
+ const body = comment.body.trim().replace(/\s+/g, " ");
221
+ const summaryBody = body.length > 140 ? `${body.slice(0, 137)}...` : body;
222
+ items.push(
223
+ withState(
224
+ {
225
+ key,
226
+ category: "board_mentioned_comment",
227
+ severity: "warning",
228
+ requiredActor: "board",
229
+ title: "Board input requested on issue comment",
230
+ contextSummary: `${comment.issueTitle}: ${summaryBody}`,
231
+ actionLabel: "Open issue thread",
232
+ actionHref: `/issues/${comment.issueId}`,
233
+ impactSummary: "The team is waiting for board clarification to continue confidently.",
234
+ evidence: {
235
+ issueId: comment.issueId,
236
+ commentId: comment.id
237
+ },
238
+ sourceTimestamp: comment.createdAt.toISOString(),
239
+ state: "open",
240
+ seenAt: null,
241
+ acknowledgedAt: null,
242
+ dismissedAt: null,
243
+ resolvedAt: null
244
+ },
245
+ stateByKey.get(key)
246
+ )
247
+ );
248
+ }
249
+
250
+ return dedupeItems(items).sort(compareAttentionItems);
251
+ }
252
+
253
+ export async function markBoardAttentionSeen(db: BopoDb, companyId: string, actorId: string, itemKey: string) {
254
+ await markAttentionInboxSeen(db, { companyId, actorId, itemKey });
255
+ }
256
+
257
+ export async function markBoardAttentionAcknowledged(db: BopoDb, companyId: string, actorId: string, itemKey: string) {
258
+ await markAttentionInboxAcknowledged(db, { companyId, actorId, itemKey });
259
+ }
260
+
261
+ export async function markBoardAttentionDismissed(db: BopoDb, companyId: string, actorId: string, itemKey: string) {
262
+ await markAttentionInboxDismissed(db, { companyId, actorId, itemKey });
263
+ }
264
+
265
+ export async function clearBoardAttentionDismissed(db: BopoDb, companyId: string, actorId: string, itemKey: string) {
266
+ await clearAttentionInboxDismissed(db, { companyId, actorId, itemKey });
267
+ }
268
+
269
+ export async function markBoardAttentionResolved(db: BopoDb, companyId: string, actorId: string, itemKey: string) {
270
+ await markAttentionInboxResolved(db, { companyId, actorId, itemKey });
271
+ }
272
+
273
+ function withState(item: BoardAttentionItem, state: AttentionStateRow | undefined, appendContext?: string) {
274
+ const contextSummary = appendContext ? `${item.contextSummary} ${appendContext}`.trim() : item.contextSummary;
275
+ if (!state) {
276
+ return { ...item, contextSummary };
277
+ }
278
+ const resolvedAt = state.resolvedAt?.toISOString() ?? null;
279
+ const dismissedAt = state.dismissedAt?.toISOString() ?? null;
280
+ const acknowledgedAt = state.acknowledgedAt?.toISOString() ?? null;
281
+ const seenAt = state.seenAt?.toISOString() ?? null;
282
+ const computedState =
283
+ resolvedAt ? "resolved" : dismissedAt ? "dismissed" : acknowledgedAt ? "acknowledged" : "open";
284
+ return {
285
+ ...item,
286
+ contextSummary,
287
+ state: computedState,
288
+ resolvedAt,
289
+ dismissedAt,
290
+ acknowledgedAt,
291
+ seenAt
292
+ } satisfies BoardAttentionItem;
293
+ }
294
+
295
+ function compareAttentionItems(a: BoardAttentionItem, b: BoardAttentionItem) {
296
+ const stateRank = new Map<BoardAttentionItem["state"], number>([
297
+ ["open", 0],
298
+ ["acknowledged", 1],
299
+ ["dismissed", 2],
300
+ ["resolved", 3]
301
+ ]);
302
+ const severityRank = new Map<BoardAttentionItem["severity"], number>([
303
+ ["critical", 0],
304
+ ["warning", 1],
305
+ ["info", 2]
306
+ ]);
307
+ const byState = (stateRank.get(a.state) ?? 99) - (stateRank.get(b.state) ?? 99);
308
+ if (byState !== 0) {
309
+ return byState;
310
+ }
311
+ const bySeverity = (severityRank.get(a.severity) ?? 99) - (severityRank.get(b.severity) ?? 99);
312
+ if (bySeverity !== 0) {
313
+ return bySeverity;
314
+ }
315
+ return new Date(b.sourceTimestamp).getTime() - new Date(a.sourceTimestamp).getTime();
316
+ }
317
+
318
+ function dedupeItems(items: BoardAttentionItem[]) {
319
+ const seen = new Set<string>();
320
+ const deduped: BoardAttentionItem[] = [];
321
+ for (const item of items) {
322
+ if (seen.has(item.key)) {
323
+ continue;
324
+ }
325
+ seen.add(item.key);
326
+ deduped.push(item);
327
+ }
328
+ return deduped;
329
+ }
330
+
331
+ function parsePayload(payloadJson: string) {
332
+ try {
333
+ const parsed = JSON.parse(payloadJson) as unknown;
334
+ return typeof parsed === "object" && parsed !== null ? (parsed as Record<string, unknown>) : {};
335
+ } catch {
336
+ return {};
337
+ }
338
+ }
339
+
340
+ function formatApprovalContext(action: string, payload: Record<string, unknown>) {
341
+ const name = asString(payload.name);
342
+ const role = asString(payload.role) ?? asString(payload.title);
343
+ if (name && role) {
344
+ return `${action.replaceAll("_", " ")} for ${name} (${role}).`;
345
+ }
346
+ if (name) {
347
+ return `${action.replaceAll("_", " ")} for ${name}.`;
348
+ }
349
+ const projectId = asString(payload.projectId);
350
+ if (projectId) {
351
+ return `${action.replaceAll("_", " ")} for project ${shortId(projectId)}.`;
352
+ }
353
+ return `${action.replaceAll("_", " ")} pending board decision.`;
354
+ }
355
+
356
+ function shortId(value: string) {
357
+ return value.length > 12 ? `${value.slice(0, 8)}...` : value;
358
+ }
359
+
360
+ function asString(value: unknown) {
361
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
362
+ }
363
+
364
+ function asNumber(value: unknown) {
365
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
366
+ }
367
+
368
+ function formatUsd(value: number | null) {
369
+ if (value === null) {
370
+ return "n/a";
371
+ }
372
+ return `$${value.toFixed(2)}`;
373
+ }
374
+
375
+ function formatPercent(value: number | null) {
376
+ if (value === null) {
377
+ return "n/a";
378
+ }
379
+ return `${value.toFixed(1)}%`;
380
+ }
381
+
382
+ function ageHoursFromDate(date: Date) {
383
+ return Math.max(0, (Date.now() - date.getTime()) / (1000 * 60 * 60));
384
+ }
385
+
386
+ function formatAgeHours(hours: number) {
387
+ if (hours < 24) {
388
+ return `${hours.toFixed(1)}h`;
389
+ }
390
+ return `${(hours / 24).toFixed(1)}d`;
391
+ }
@@ -1,6 +1,6 @@
1
- import { and, eq } from "drizzle-orm";
1
+ import { and, eq, sql } from "drizzle-orm";
2
2
  import type { BopoDb } from "bopodev-db";
3
- import { agents } from "bopodev-db";
3
+ import { agents, projects } from "bopodev-db";
4
4
 
5
5
  export interface BudgetCheckResult {
6
6
  allowed: boolean;
@@ -8,7 +8,15 @@ export interface BudgetCheckResult {
8
8
  utilizationPct: number;
9
9
  }
10
10
 
11
+ export interface ProjectBudgetCheckResult extends BudgetCheckResult {
12
+ projectId: string;
13
+ monthlyBudgetUsd: number;
14
+ usedBudgetUsd: number;
15
+ budgetWindowStartAt: Date | null;
16
+ }
17
+
11
18
  export async function checkAgentBudget(db: BopoDb, companyId: string, agentId: string): Promise<BudgetCheckResult> {
19
+ // Budget enforcement is currently agent-scoped. Project/issue budgets are intentionally out of scope.
12
20
  const [agent] = await db
13
21
  .select()
14
22
  .from(agents)
@@ -29,3 +37,92 @@ export async function checkAgentBudget(db: BopoDb, companyId: string, agentId: s
29
37
  utilizationPct
30
38
  };
31
39
  }
40
+
41
+ export async function checkProjectBudget(
42
+ db: BopoDb,
43
+ companyId: string,
44
+ projectId: string,
45
+ now = new Date()
46
+ ): Promise<ProjectBudgetCheckResult> {
47
+ const [project] = await db
48
+ .select({
49
+ id: projects.id,
50
+ monthlyBudgetUsd: projects.monthlyBudgetUsd,
51
+ usedBudgetUsd: projects.usedBudgetUsd,
52
+ budgetWindowStartAt: projects.budgetWindowStartAt
53
+ })
54
+ .from(projects)
55
+ .where(and(eq(projects.companyId, companyId), eq(projects.id, projectId)))
56
+ .limit(1);
57
+
58
+ if (!project) {
59
+ return {
60
+ projectId,
61
+ allowed: false,
62
+ hardStopped: true,
63
+ utilizationPct: 100,
64
+ monthlyBudgetUsd: 0,
65
+ usedBudgetUsd: 0,
66
+ budgetWindowStartAt: null
67
+ };
68
+ }
69
+
70
+ const expectedWindowStart = startOfCurrentMonthUtc(now);
71
+ const persistedWindowStart = project.budgetWindowStartAt ? new Date(project.budgetWindowStartAt) : null;
72
+ const staleWindow = !persistedWindowStart || !isSameUtcMonth(persistedWindowStart, expectedWindowStart);
73
+ if (staleWindow) {
74
+ await db
75
+ .update(projects)
76
+ .set({
77
+ usedBudgetUsd: "0.0000",
78
+ budgetWindowStartAt: expectedWindowStart,
79
+ updatedAt: new Date()
80
+ })
81
+ .where(and(eq(projects.companyId, companyId), eq(projects.id, projectId)));
82
+ }
83
+
84
+ const monthlyBudgetUsd = Number(project.monthlyBudgetUsd);
85
+ const usedBudgetUsd = staleWindow ? 0 : Number(project.usedBudgetUsd);
86
+ const utilizationPct = monthlyBudgetUsd <= 0 ? 100 : (usedBudgetUsd / monthlyBudgetUsd) * 100;
87
+ const allowed = monthlyBudgetUsd > 0 && utilizationPct < 100;
88
+
89
+ return {
90
+ projectId: project.id,
91
+ allowed,
92
+ hardStopped: !allowed,
93
+ utilizationPct,
94
+ monthlyBudgetUsd,
95
+ usedBudgetUsd,
96
+ budgetWindowStartAt: staleWindow ? expectedWindowStart : persistedWindowStart
97
+ };
98
+ }
99
+
100
+ export async function appendProjectBudgetUsage(
101
+ db: BopoDb,
102
+ input: {
103
+ companyId: string;
104
+ projectCostsUsd: Array<{ projectId: string; usdCost: number }>;
105
+ }
106
+ ) {
107
+ for (const entry of input.projectCostsUsd) {
108
+ const cost = Math.max(0, entry.usdCost);
109
+ if (cost <= 0) {
110
+ continue;
111
+ }
112
+ await db
113
+ .update(projects)
114
+ .set({
115
+ usedBudgetUsd: sql`${projects.usedBudgetUsd} + ${cost}`,
116
+ updatedAt: new Date()
117
+ })
118
+ .where(and(eq(projects.companyId, input.companyId), eq(projects.id, entry.projectId)));
119
+ }
120
+ }
121
+
122
+ function startOfCurrentMonthUtc(now: Date) {
123
+ return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1, 0, 0, 0, 0));
124
+ }
125
+
126
+ function isSameUtcMonth(left: Date, right: Date) {
127
+ return left.getUTCFullYear() === right.getUTCFullYear() && left.getUTCMonth() === right.getUTCMonth();
128
+ }
@@ -0,0 +1,158 @@
1
+ import { and, desc, eq, inArray, like } from "drizzle-orm";
2
+ import {
3
+ agents,
4
+ issueComments,
5
+ updateIssueCommentRecipients,
6
+ type BopoDb
7
+ } from "bopodev-db";
8
+ import { parseIssueCommentRecipients, type PersistedCommentRecipient } from "../lib/comment-recipients";
9
+ import type { RealtimeHub } from "../realtime/hub";
10
+ import { enqueueHeartbeatQueueJob, triggerHeartbeatQueueWorker } from "./heartbeat-queue-service";
11
+
12
+ const COMMENT_DISPATCH_SWEEP_LIMIT = 100;
13
+ const activeCompanyDispatchRuns = new Set<string>();
14
+
15
+ export async function runIssueCommentDispatchSweep(
16
+ db: BopoDb,
17
+ companyId: string,
18
+ options?: { requestId?: string; realtimeHub?: RealtimeHub; limit?: number }
19
+ ) {
20
+ const rows = await db
21
+ .select({
22
+ id: issueComments.id,
23
+ issueId: issueComments.issueId,
24
+ recipientsJson: issueComments.recipientsJson
25
+ })
26
+ .from(issueComments)
27
+ .where(
28
+ and(
29
+ eq(issueComments.companyId, companyId),
30
+ like(issueComments.recipientsJson, '%"deliveryStatus":"pending"%')
31
+ )
32
+ )
33
+ .orderBy(desc(issueComments.createdAt))
34
+ .limit(options?.limit ?? COMMENT_DISPATCH_SWEEP_LIMIT);
35
+
36
+ for (const row of rows) {
37
+ const recipients = parseIssueCommentRecipients(row.recipientsJson);
38
+ if (!recipients.some((recipient) => recipient.deliveryStatus === "pending")) {
39
+ continue;
40
+ }
41
+ const updatedRecipients = await dispatchCommentRecipients(db, {
42
+ companyId,
43
+ issueId: row.issueId,
44
+ commentId: row.id,
45
+ recipients,
46
+ requestId: options?.requestId,
47
+ realtimeHub: options?.realtimeHub
48
+ });
49
+ await updateIssueCommentRecipients(db, {
50
+ companyId,
51
+ issueId: row.issueId,
52
+ id: row.id,
53
+ recipients: updatedRecipients
54
+ });
55
+ }
56
+ }
57
+
58
+ export function triggerIssueCommentDispatchWorker(
59
+ db: BopoDb,
60
+ companyId: string,
61
+ options?: { requestId?: string; realtimeHub?: RealtimeHub; limit?: number }
62
+ ) {
63
+ if (activeCompanyDispatchRuns.has(companyId)) {
64
+ return;
65
+ }
66
+ activeCompanyDispatchRuns.add(companyId);
67
+ queueMicrotask(() => {
68
+ void runIssueCommentDispatchSweep(db, companyId, options)
69
+ .catch((error) => {
70
+ // eslint-disable-next-line no-console
71
+ console.error("[comment-dispatch] immediate worker run failed", error);
72
+ })
73
+ .finally(() => {
74
+ activeCompanyDispatchRuns.delete(companyId);
75
+ });
76
+ });
77
+ }
78
+
79
+ async function dispatchCommentRecipients(
80
+ db: BopoDb,
81
+ input: {
82
+ companyId: string;
83
+ issueId: string;
84
+ commentId: string;
85
+ recipients: PersistedCommentRecipient[];
86
+ requestId?: string;
87
+ realtimeHub?: RealtimeHub;
88
+ }
89
+ ) {
90
+ if (input.recipients.length === 0) {
91
+ return [];
92
+ }
93
+ const agentRecipientIds = input.recipients
94
+ .filter((recipient) => recipient.recipientType === "agent" && recipient.recipientId)
95
+ .map((recipient) => recipient.recipientId as string);
96
+ const availableAgents = agentRecipientIds.length
97
+ ? await db
98
+ .select({ id: agents.id, status: agents.status })
99
+ .from(agents)
100
+ .where(and(eq(agents.companyId, input.companyId), inArray(agents.id, agentRecipientIds)))
101
+ : [];
102
+ const agentStatusById = new Map(availableAgents.map((agent) => [agent.id, agent.status]));
103
+ const dispatchedRecipients: PersistedCommentRecipient[] = [];
104
+ for (const recipient of input.recipients) {
105
+ if (recipient.deliveryStatus !== "pending") {
106
+ dispatchedRecipients.push(recipient);
107
+ continue;
108
+ }
109
+ if (recipient.recipientType !== "agent" || !recipient.recipientId) {
110
+ dispatchedRecipients.push(recipient);
111
+ continue;
112
+ }
113
+ const status = agentStatusById.get(recipient.recipientId);
114
+ if (!status || status === "paused" || status === "terminated") {
115
+ dispatchedRecipients.push({
116
+ ...recipient,
117
+ deliveryStatus: "failed"
118
+ });
119
+ continue;
120
+ }
121
+ try {
122
+ await enqueueHeartbeatQueueJob(db, {
123
+ companyId: input.companyId,
124
+ agentId: recipient.recipientId,
125
+ jobType: "comment_dispatch",
126
+ priority: 20,
127
+ maxAttempts: 12,
128
+ idempotencyKey: `comment_dispatch:${input.commentId}:${recipient.recipientId}`,
129
+ payload: {
130
+ wakeContext: {
131
+ reason: "issue_comment_recipient",
132
+ commentId: input.commentId,
133
+ issueIds: [input.issueId]
134
+ },
135
+ commentDispatch: {
136
+ commentId: input.commentId,
137
+ issueId: input.issueId,
138
+ recipientId: recipient.recipientId
139
+ }
140
+ }
141
+ });
142
+ triggerHeartbeatQueueWorker(db, input.companyId, {
143
+ requestId: input.requestId,
144
+ realtimeHub: input.realtimeHub
145
+ });
146
+ dispatchedRecipients.push(recipient);
147
+ continue;
148
+ } catch {
149
+ dispatchedRecipients.push({
150
+ ...recipient,
151
+ deliveryStatus: "failed"
152
+ });
153
+ continue;
154
+ }
155
+ }
156
+ return dispatchedRecipients;
157
+ }
158
+