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.
- package/package.json +4 -4
- package/src/app.ts +11 -1
- package/src/http.ts +39 -0
- package/src/lib/comment-recipients.ts +105 -0
- package/src/lib/hiring-delegate.ts +7 -6
- package/src/lib/instance-paths.ts +11 -0
- package/src/realtime/attention.ts +47 -0
- package/src/realtime/governance.ts +11 -3
- package/src/realtime/heartbeat-runs.ts +33 -11
- package/src/realtime/hub.ts +34 -2
- package/src/realtime/office-space.ts +17 -1
- package/src/routes/agents.ts +81 -12
- package/src/routes/attention.ts +112 -0
- package/src/routes/companies.ts +13 -5
- package/src/routes/goals.ts +10 -2
- package/src/routes/governance.ts +5 -0
- package/src/routes/heartbeats.ts +81 -43
- package/src/routes/issues.ts +293 -62
- package/src/routes/observability.ts +62 -1
- package/src/routes/projects.ts +7 -2
- package/src/scripts/onboard-seed.ts +3 -1
- package/src/server.ts +3 -1
- package/src/services/attention-service.ts +391 -0
- package/src/services/budget-service.ts +99 -2
- package/src/services/comment-recipient-dispatch-service.ts +158 -0
- package/src/services/governance-service.ts +233 -9
- package/src/services/heartbeat-queue-service.ts +318 -0
- package/src/services/heartbeat-service.ts +930 -49
- package/src/services/memory-file-service.ts +513 -35
- package/src/services/plugin-runtime.ts +33 -1
- package/src/services/template-apply-service.ts +37 -2
- package/src/worker/scheduler.ts +46 -8
|
@@ -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
|
+
|