bopodev-db 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.
@@ -4,12 +4,14 @@ import type { BopoDb } from "./client";
4
4
  import {
5
5
  activityLogs,
6
6
  agents,
7
+ attentionInboxStates,
7
8
  approvalInboxStates,
8
9
  approvalRequests,
9
10
  auditEvents,
10
11
  companies,
11
12
  costLedger,
12
13
  goals,
14
+ heartbeatRunQueue,
13
15
  heartbeatRuns,
14
16
  heartbeatRunMessages,
15
17
  issueAttachments,
@@ -134,6 +136,9 @@ export async function createProject(
134
136
  description?: string | null;
135
137
  status?: "planned" | "active" | "paused" | "blocked" | "completed" | "archived";
136
138
  plannedStartAt?: Date | null;
139
+ monthlyBudgetUsd?: string;
140
+ usedBudgetUsd?: string;
141
+ budgetWindowStartAt?: Date | null;
137
142
  executionWorkspacePolicy?: Record<string, unknown> | null;
138
143
  workspaceLocalPath?: string | null;
139
144
  workspaceGithubRepo?: string | null;
@@ -147,6 +152,9 @@ export async function createProject(
147
152
  description: input.description ?? null,
148
153
  status: input.status ?? "planned",
149
154
  plannedStartAt: input.plannedStartAt ?? null,
155
+ monthlyBudgetUsd: input.monthlyBudgetUsd ?? "100.0000",
156
+ usedBudgetUsd: input.usedBudgetUsd ?? "0.0000",
157
+ budgetWindowStartAt: input.budgetWindowStartAt ?? new Date(),
150
158
  executionWorkspacePolicy: input.executionWorkspacePolicy ? JSON.stringify(input.executionWorkspacePolicy) : null
151
159
  });
152
160
  const legacyWorkspaceLocalPath = input.workspaceLocalPath?.trim();
@@ -173,6 +181,9 @@ export async function updateProject(
173
181
  description?: string | null;
174
182
  status?: "planned" | "active" | "paused" | "blocked" | "completed" | "archived";
175
183
  plannedStartAt?: Date | null;
184
+ monthlyBudgetUsd?: string;
185
+ usedBudgetUsd?: string;
186
+ budgetWindowStartAt?: Date | null;
176
187
  executionWorkspacePolicy?: Record<string, unknown> | null;
177
188
  workspaceLocalPath?: string | null;
178
189
  workspaceGithubRepo?: string | null;
@@ -186,6 +197,9 @@ export async function updateProject(
186
197
  description: input.description,
187
198
  status: input.status,
188
199
  plannedStartAt: input.plannedStartAt,
200
+ monthlyBudgetUsd: input.monthlyBudgetUsd,
201
+ usedBudgetUsd: input.usedBudgetUsd,
202
+ budgetWindowStartAt: input.budgetWindowStartAt,
189
203
  executionWorkspacePolicy:
190
204
  input.executionWorkspacePolicy === undefined
191
205
  ? undefined
@@ -472,6 +486,9 @@ async function hydrateProjectsWithWorkspaces(
472
486
  return [] as Array<
473
487
  typeof projects.$inferSelect & {
474
488
  executionWorkspacePolicy: Record<string, unknown> | null;
489
+ monthlyBudgetUsd: number;
490
+ usedBudgetUsd: number;
491
+ budgetWindowStartAt: string | null;
475
492
  workspaces: Array<typeof projectWorkspaces.$inferSelect>;
476
493
  primaryWorkspace: typeof projectWorkspaces.$inferSelect | null;
477
494
  }
@@ -504,6 +521,9 @@ async function hydrateProjectsWithWorkspaces(
504
521
  }
505
522
  return {
506
523
  ...project,
524
+ monthlyBudgetUsd: Number(project.monthlyBudgetUsd),
525
+ usedBudgetUsd: Number(project.usedBudgetUsd),
526
+ budgetWindowStartAt: project.budgetWindowStartAt ? project.budgetWindowStartAt.toISOString() : null,
507
527
  workspaceLocalPath: primaryWorkspace?.cwd ?? null,
508
528
  workspaceGithubRepo: primaryWorkspace?.repoUrl ?? null,
509
529
  executionWorkspacePolicy,
@@ -687,29 +707,55 @@ export async function addIssueComment(
687
707
  issueId: string;
688
708
  authorType: "human" | "agent" | "system";
689
709
  authorId?: string | null;
710
+ runId?: string | null;
711
+ recipients?: Array<{
712
+ recipientType: "agent" | "board" | "member";
713
+ recipientId?: string | null;
714
+ deliveryStatus?: "pending" | "dispatched" | "failed" | "skipped";
715
+ dispatchedRunId?: string | null;
716
+ dispatchedAt?: string | null;
717
+ acknowledgedAt?: string | null;
718
+ }>;
690
719
  body: string;
691
720
  }
692
721
  ) {
693
722
  await assertIssueBelongsToCompany(db, input.companyId, input.issueId);
694
723
  const id = nanoid(12);
695
- await db.insert(issueComments).values({
696
- id,
697
- companyId: input.companyId,
698
- issueId: input.issueId,
699
- authorType: input.authorType,
700
- authorId: input.authorId ?? null,
701
- body: input.body
702
- });
703
-
704
- return { id, ...input };
724
+ const [comment] = await db
725
+ .insert(issueComments)
726
+ .values({
727
+ id,
728
+ companyId: input.companyId,
729
+ issueId: input.issueId,
730
+ authorType: input.authorType,
731
+ authorId: input.authorId ?? null,
732
+ recipientsJson: JSON.stringify(input.recipients ?? []),
733
+ runId: input.runId ?? null,
734
+ body: input.body
735
+ })
736
+ .returning();
737
+ if (!comment) {
738
+ return {
739
+ id,
740
+ companyId: input.companyId,
741
+ issueId: input.issueId,
742
+ authorType: input.authorType,
743
+ authorId: input.authorId ?? null,
744
+ body: input.body,
745
+ runId: input.runId ?? null,
746
+ recipients: input.recipients ?? []
747
+ };
748
+ }
749
+ return normalizeIssueComment(comment);
705
750
  }
706
751
 
707
752
  export async function listIssueComments(db: BopoDb, companyId: string, issueId: string) {
708
- return db
753
+ const comments = await db
709
754
  .select()
710
755
  .from(issueComments)
711
756
  .where(and(eq(issueComments.companyId, companyId), eq(issueComments.issueId, issueId)))
712
- .orderBy(desc(issueComments.createdAt));
757
+ .orderBy(asc(issueComments.createdAt));
758
+ return comments.map((comment) => normalizeIssueComment(comment));
713
759
  }
714
760
 
715
761
  export async function listIssueActivity(db: BopoDb, companyId: string, issueId: string, limit = 100) {
@@ -741,7 +787,37 @@ export async function updateIssueComment(
741
787
  )
742
788
  )
743
789
  .returning();
744
- return comment ?? null;
790
+ return comment ? normalizeIssueComment(comment) : null;
791
+ }
792
+
793
+ export async function updateIssueCommentRecipients(
794
+ db: BopoDb,
795
+ input: {
796
+ companyId: string;
797
+ issueId: string;
798
+ id: string;
799
+ recipients: Array<{
800
+ recipientType: "agent" | "board" | "member";
801
+ recipientId?: string | null;
802
+ deliveryStatus?: "pending" | "dispatched" | "failed" | "skipped";
803
+ dispatchedRunId?: string | null;
804
+ dispatchedAt?: string | null;
805
+ acknowledgedAt?: string | null;
806
+ }>;
807
+ }
808
+ ) {
809
+ const [comment] = await db
810
+ .update(issueComments)
811
+ .set({ recipientsJson: JSON.stringify(input.recipients ?? []) })
812
+ .where(
813
+ and(
814
+ eq(issueComments.companyId, input.companyId),
815
+ eq(issueComments.issueId, input.issueId),
816
+ eq(issueComments.id, input.id)
817
+ )
818
+ )
819
+ .returning();
820
+ return comment ? normalizeIssueComment(comment) : null;
745
821
  }
746
822
 
747
823
  export async function deleteIssueComment(db: BopoDb, companyId: string, issueId: string, id: string) {
@@ -752,6 +828,86 @@ export async function deleteIssueComment(db: BopoDb, companyId: string, issueId:
752
828
  return Boolean(deletedComment);
753
829
  }
754
830
 
831
+ function normalizeIssueComment(comment: typeof issueComments.$inferSelect) {
832
+ const { recipientsJson, ...rest } = comment;
833
+ return {
834
+ ...rest,
835
+ recipients: parseIssueCommentRecipients(recipientsJson)
836
+ };
837
+ }
838
+
839
+ function parseIssueCommentRecipients(raw: string | null) {
840
+ if (!raw) {
841
+ return [] as Array<{
842
+ recipientType: "agent" | "board" | "member";
843
+ recipientId: string | null;
844
+ deliveryStatus: "pending" | "dispatched" | "failed" | "skipped";
845
+ dispatchedRunId: string | null;
846
+ dispatchedAt: string | null;
847
+ acknowledgedAt: string | null;
848
+ }>;
849
+ }
850
+ try {
851
+ const parsed = JSON.parse(raw) as unknown;
852
+ if (!Array.isArray(parsed)) {
853
+ return [];
854
+ }
855
+ return parsed
856
+ .map((entry) => {
857
+ if (!entry || typeof entry !== "object") {
858
+ return null;
859
+ }
860
+ const candidate = entry as Record<string, unknown>;
861
+ const recipientTypeRaw = String(candidate.recipientType ?? "").trim();
862
+ if (recipientTypeRaw !== "agent" && recipientTypeRaw !== "board" && recipientTypeRaw !== "member") {
863
+ return null;
864
+ }
865
+ const recipientType = recipientTypeRaw as "agent" | "board" | "member";
866
+ const deliveryStatusRaw = String(candidate.deliveryStatus ?? "").trim();
867
+ const deliveryStatus =
868
+ deliveryStatusRaw === "pending" ||
869
+ deliveryStatusRaw === "dispatched" ||
870
+ deliveryStatusRaw === "failed" ||
871
+ deliveryStatusRaw === "skipped"
872
+ ? deliveryStatusRaw
873
+ : "pending";
874
+ const recipientId = typeof candidate.recipientId === "string" && candidate.recipientId.trim().length > 0
875
+ ? candidate.recipientId.trim()
876
+ : null;
877
+ const dispatchedRunId =
878
+ typeof candidate.dispatchedRunId === "string" && candidate.dispatchedRunId.trim().length > 0
879
+ ? candidate.dispatchedRunId.trim()
880
+ : null;
881
+ const dispatchedAt =
882
+ typeof candidate.dispatchedAt === "string" && candidate.dispatchedAt.trim().length > 0
883
+ ? candidate.dispatchedAt.trim()
884
+ : null;
885
+ const acknowledgedAt =
886
+ typeof candidate.acknowledgedAt === "string" && candidate.acknowledgedAt.trim().length > 0
887
+ ? candidate.acknowledgedAt.trim()
888
+ : null;
889
+ return {
890
+ recipientType,
891
+ recipientId,
892
+ deliveryStatus,
893
+ dispatchedRunId,
894
+ dispatchedAt,
895
+ acknowledgedAt
896
+ };
897
+ })
898
+ .filter(Boolean) as Array<{
899
+ recipientType: "agent" | "board" | "member";
900
+ recipientId: string | null;
901
+ deliveryStatus: "pending" | "dispatched" | "failed" | "skipped";
902
+ dispatchedRunId: string | null;
903
+ dispatchedAt: string | null;
904
+ acknowledgedAt: string | null;
905
+ }>;
906
+ } catch {
907
+ return [];
908
+ }
909
+ }
910
+
755
911
  export async function createGoal(
756
912
  db: BopoDb,
757
913
  input: {
@@ -837,6 +993,8 @@ export async function createAgent(
837
993
  companyId: string;
838
994
  managerAgentId?: string | null;
839
995
  role: string;
996
+ roleKey?: string | null;
997
+ title?: string | null;
840
998
  name: string;
841
999
  providerType:
842
1000
  | "claude_code"
@@ -875,6 +1033,8 @@ export async function createAgent(
875
1033
  companyId: input.companyId,
876
1034
  managerAgentId: input.managerAgentId ?? null,
877
1035
  role: input.role,
1036
+ roleKey: input.roleKey ?? null,
1037
+ title: input.title ?? null,
878
1038
  name: input.name,
879
1039
  providerType: input.providerType,
880
1040
  heartbeatCron: input.heartbeatCron,
@@ -908,6 +1068,8 @@ export async function updateAgent(
908
1068
  id: string;
909
1069
  managerAgentId?: string | null;
910
1070
  role?: string;
1071
+ roleKey?: string | null;
1072
+ title?: string | null;
911
1073
  name?: string;
912
1074
  providerType?:
913
1075
  | "claude_code"
@@ -945,6 +1107,8 @@ export async function updateAgent(
945
1107
  compactUpdate({
946
1108
  managerAgentId: input.managerAgentId,
947
1109
  role: input.role,
1110
+ roleKey: input.roleKey,
1111
+ title: input.title,
948
1112
  name: input.name,
949
1113
  providerType: input.providerType,
950
1114
  status: input.status,
@@ -1149,6 +1313,147 @@ export async function clearApprovalInboxDismissed(
1149
1313
  });
1150
1314
  }
1151
1315
 
1316
+ export async function listAttentionInboxStates(db: BopoDb, companyId: string, actorId: string) {
1317
+ return db
1318
+ .select()
1319
+ .from(attentionInboxStates)
1320
+ .where(and(eq(attentionInboxStates.companyId, companyId), eq(attentionInboxStates.actorId, actorId)))
1321
+ .orderBy(desc(attentionInboxStates.updatedAt));
1322
+ }
1323
+
1324
+ export async function markAttentionInboxSeen(
1325
+ db: BopoDb,
1326
+ input: {
1327
+ companyId: string;
1328
+ actorId: string;
1329
+ itemKey: string;
1330
+ seenAt?: Date;
1331
+ }
1332
+ ) {
1333
+ const seenAt = input.seenAt ?? new Date();
1334
+ await db
1335
+ .insert(attentionInboxStates)
1336
+ .values({
1337
+ companyId: input.companyId,
1338
+ actorId: input.actorId,
1339
+ itemKey: input.itemKey,
1340
+ seenAt
1341
+ })
1342
+ .onConflictDoUpdate({
1343
+ target: [attentionInboxStates.companyId, attentionInboxStates.actorId, attentionInboxStates.itemKey],
1344
+ set: {
1345
+ seenAt,
1346
+ updatedAt: sql`CURRENT_TIMESTAMP`
1347
+ }
1348
+ });
1349
+ }
1350
+
1351
+ export async function markAttentionInboxAcknowledged(
1352
+ db: BopoDb,
1353
+ input: {
1354
+ companyId: string;
1355
+ actorId: string;
1356
+ itemKey: string;
1357
+ acknowledgedAt?: Date;
1358
+ }
1359
+ ) {
1360
+ const acknowledgedAt = input.acknowledgedAt ?? new Date();
1361
+ await db
1362
+ .insert(attentionInboxStates)
1363
+ .values({
1364
+ companyId: input.companyId,
1365
+ actorId: input.actorId,
1366
+ itemKey: input.itemKey,
1367
+ acknowledgedAt
1368
+ })
1369
+ .onConflictDoUpdate({
1370
+ target: [attentionInboxStates.companyId, attentionInboxStates.actorId, attentionInboxStates.itemKey],
1371
+ set: {
1372
+ acknowledgedAt,
1373
+ updatedAt: sql`CURRENT_TIMESTAMP`
1374
+ }
1375
+ });
1376
+ }
1377
+
1378
+ export async function markAttentionInboxDismissed(
1379
+ db: BopoDb,
1380
+ input: {
1381
+ companyId: string;
1382
+ actorId: string;
1383
+ itemKey: string;
1384
+ dismissedAt?: Date;
1385
+ }
1386
+ ) {
1387
+ const dismissedAt = input.dismissedAt ?? new Date();
1388
+ await db
1389
+ .insert(attentionInboxStates)
1390
+ .values({
1391
+ companyId: input.companyId,
1392
+ actorId: input.actorId,
1393
+ itemKey: input.itemKey,
1394
+ dismissedAt
1395
+ })
1396
+ .onConflictDoUpdate({
1397
+ target: [attentionInboxStates.companyId, attentionInboxStates.actorId, attentionInboxStates.itemKey],
1398
+ set: {
1399
+ dismissedAt,
1400
+ updatedAt: sql`CURRENT_TIMESTAMP`
1401
+ }
1402
+ });
1403
+ }
1404
+
1405
+ export async function clearAttentionInboxDismissed(
1406
+ db: BopoDb,
1407
+ input: {
1408
+ companyId: string;
1409
+ actorId: string;
1410
+ itemKey: string;
1411
+ }
1412
+ ) {
1413
+ await db
1414
+ .insert(attentionInboxStates)
1415
+ .values({
1416
+ companyId: input.companyId,
1417
+ actorId: input.actorId,
1418
+ itemKey: input.itemKey,
1419
+ dismissedAt: null
1420
+ })
1421
+ .onConflictDoUpdate({
1422
+ target: [attentionInboxStates.companyId, attentionInboxStates.actorId, attentionInboxStates.itemKey],
1423
+ set: {
1424
+ dismissedAt: null,
1425
+ updatedAt: sql`CURRENT_TIMESTAMP`
1426
+ }
1427
+ });
1428
+ }
1429
+
1430
+ export async function markAttentionInboxResolved(
1431
+ db: BopoDb,
1432
+ input: {
1433
+ companyId: string;
1434
+ actorId: string;
1435
+ itemKey: string;
1436
+ resolvedAt?: Date;
1437
+ }
1438
+ ) {
1439
+ const resolvedAt = input.resolvedAt ?? new Date();
1440
+ await db
1441
+ .insert(attentionInboxStates)
1442
+ .values({
1443
+ companyId: input.companyId,
1444
+ actorId: input.actorId,
1445
+ itemKey: input.itemKey,
1446
+ resolvedAt
1447
+ })
1448
+ .onConflictDoUpdate({
1449
+ target: [attentionInboxStates.companyId, attentionInboxStates.actorId, attentionInboxStates.itemKey],
1450
+ set: {
1451
+ resolvedAt,
1452
+ updatedAt: sql`CURRENT_TIMESTAMP`
1453
+ }
1454
+ });
1455
+ }
1456
+
1152
1457
  export async function appendCost(
1153
1458
  db: BopoDb,
1154
1459
  input: {
@@ -1203,6 +1508,293 @@ export async function listHeartbeatRuns(db: BopoDb, companyId: string, limit = 1
1203
1508
  .limit(limit);
1204
1509
  }
1205
1510
 
1511
+ export type HeartbeatQueueJobType = "manual" | "scheduler" | "resume" | "redo" | "comment_dispatch";
1512
+ export type HeartbeatQueueJobStatus = "pending" | "running" | "completed" | "failed" | "dead_letter" | "canceled";
1513
+
1514
+ type HeartbeatQueueJobRow = typeof heartbeatRunQueue.$inferSelect;
1515
+
1516
+ function normalizeHeartbeatQueueJob(rawRow: HeartbeatQueueJobRow | Record<string, unknown>) {
1517
+ const row = {
1518
+ id: String((rawRow as Record<string, unknown>).id ?? ""),
1519
+ companyId: String((rawRow as Record<string, unknown>).companyId ?? (rawRow as Record<string, unknown>).company_id ?? ""),
1520
+ agentId: String((rawRow as Record<string, unknown>).agentId ?? (rawRow as Record<string, unknown>).agent_id ?? ""),
1521
+ jobType: String((rawRow as Record<string, unknown>).jobType ?? (rawRow as Record<string, unknown>).job_type ?? ""),
1522
+ payloadJson: String((rawRow as Record<string, unknown>).payloadJson ?? (rawRow as Record<string, unknown>).payload_json ?? "{}"),
1523
+ status: String((rawRow as Record<string, unknown>).status ?? "pending"),
1524
+ priority: Number((rawRow as Record<string, unknown>).priority ?? 100),
1525
+ idempotencyKey:
1526
+ typeof (rawRow as Record<string, unknown>).idempotencyKey === "string"
1527
+ ? ((rawRow as Record<string, unknown>).idempotencyKey as string)
1528
+ : typeof (rawRow as Record<string, unknown>).idempotency_key === "string"
1529
+ ? ((rawRow as Record<string, unknown>).idempotency_key as string)
1530
+ : null,
1531
+ availableAt: coerceDate((rawRow as Record<string, unknown>).availableAt ?? (rawRow as Record<string, unknown>).available_at) ?? new Date(),
1532
+ attemptCount: Number((rawRow as Record<string, unknown>).attemptCount ?? (rawRow as Record<string, unknown>).attempt_count ?? 0),
1533
+ maxAttempts: Number((rawRow as Record<string, unknown>).maxAttempts ?? (rawRow as Record<string, unknown>).max_attempts ?? 10),
1534
+ lastError:
1535
+ typeof (rawRow as Record<string, unknown>).lastError === "string"
1536
+ ? ((rawRow as Record<string, unknown>).lastError as string)
1537
+ : typeof (rawRow as Record<string, unknown>).last_error === "string"
1538
+ ? ((rawRow as Record<string, unknown>).last_error as string)
1539
+ : null,
1540
+ startedAt: coerceDate((rawRow as Record<string, unknown>).startedAt ?? (rawRow as Record<string, unknown>).started_at),
1541
+ finishedAt: coerceDate((rawRow as Record<string, unknown>).finishedAt ?? (rawRow as Record<string, unknown>).finished_at),
1542
+ heartbeatRunId:
1543
+ typeof (rawRow as Record<string, unknown>).heartbeatRunId === "string"
1544
+ ? ((rawRow as Record<string, unknown>).heartbeatRunId as string)
1545
+ : typeof (rawRow as Record<string, unknown>).heartbeat_run_id === "string"
1546
+ ? ((rawRow as Record<string, unknown>).heartbeat_run_id as string)
1547
+ : null,
1548
+ createdAt: coerceDate((rawRow as Record<string, unknown>).createdAt ?? (rawRow as Record<string, unknown>).created_at) ?? new Date(),
1549
+ updatedAt: coerceDate((rawRow as Record<string, unknown>).updatedAt ?? (rawRow as Record<string, unknown>).updated_at) ?? new Date()
1550
+ } satisfies HeartbeatQueueJobRow;
1551
+ let payload: Record<string, unknown> = {};
1552
+ try {
1553
+ payload = JSON.parse(row.payloadJson ?? "{}") as Record<string, unknown>;
1554
+ } catch {
1555
+ payload = {};
1556
+ }
1557
+ return {
1558
+ ...row,
1559
+ payload
1560
+ };
1561
+ }
1562
+
1563
+ function coerceDate(value: unknown) {
1564
+ if (value instanceof Date) {
1565
+ return Number.isNaN(value.getTime()) ? null : value;
1566
+ }
1567
+ if (typeof value === "string" && value.trim().length > 0) {
1568
+ const parsed = new Date(value);
1569
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
1570
+ }
1571
+ return null;
1572
+ }
1573
+
1574
+ export async function enqueueHeartbeatJob(
1575
+ db: BopoDb,
1576
+ input: {
1577
+ companyId: string;
1578
+ agentId: string;
1579
+ jobType: HeartbeatQueueJobType;
1580
+ payload?: Record<string, unknown>;
1581
+ priority?: number;
1582
+ availableAt?: Date;
1583
+ maxAttempts?: number;
1584
+ idempotencyKey?: string | null;
1585
+ }
1586
+ ) {
1587
+ await assertAgentBelongsToCompany(db, input.companyId, input.agentId);
1588
+ const normalizedIdempotencyKey = input.idempotencyKey?.trim() || null;
1589
+ if (normalizedIdempotencyKey) {
1590
+ const [existing] = await db
1591
+ .select()
1592
+ .from(heartbeatRunQueue)
1593
+ .where(
1594
+ and(
1595
+ eq(heartbeatRunQueue.companyId, input.companyId),
1596
+ eq(heartbeatRunQueue.agentId, input.agentId),
1597
+ eq(heartbeatRunQueue.idempotencyKey, normalizedIdempotencyKey),
1598
+ notInArray(heartbeatRunQueue.status, ["failed", "dead_letter", "canceled"])
1599
+ )
1600
+ )
1601
+ .orderBy(desc(heartbeatRunQueue.createdAt))
1602
+ .limit(1);
1603
+ if (existing) {
1604
+ return normalizeHeartbeatQueueJob(existing);
1605
+ }
1606
+ }
1607
+ const id = nanoid(14);
1608
+ const [job] = await db
1609
+ .insert(heartbeatRunQueue)
1610
+ .values({
1611
+ id,
1612
+ companyId: input.companyId,
1613
+ agentId: input.agentId,
1614
+ jobType: input.jobType,
1615
+ payloadJson: JSON.stringify(input.payload ?? {}),
1616
+ status: "pending",
1617
+ priority: Number.isFinite(input.priority) ? Math.max(0, Math.floor(input.priority!)) : 100,
1618
+ idempotencyKey: normalizedIdempotencyKey,
1619
+ availableAt: input.availableAt ?? new Date(),
1620
+ maxAttempts: Number.isFinite(input.maxAttempts) ? Math.max(1, Math.floor(input.maxAttempts!)) : 10
1621
+ })
1622
+ .returning();
1623
+ if (!job) {
1624
+ throw new RepositoryValidationError("Failed to enqueue heartbeat job.");
1625
+ }
1626
+ return normalizeHeartbeatQueueJob(job);
1627
+ }
1628
+
1629
+ export async function claimNextHeartbeatJob(db: BopoDb, companyId: string) {
1630
+ const result = await db.execute(sql`
1631
+ WITH candidate AS (
1632
+ SELECT q.id
1633
+ FROM heartbeat_run_queue q
1634
+ WHERE q.company_id = ${companyId}
1635
+ AND q.status = 'pending'
1636
+ AND q.available_at <= CURRENT_TIMESTAMP
1637
+ AND NOT EXISTS (
1638
+ SELECT 1
1639
+ FROM heartbeat_run_queue active_q
1640
+ WHERE active_q.company_id = q.company_id
1641
+ AND active_q.agent_id = q.agent_id
1642
+ AND active_q.status = 'running'
1643
+ )
1644
+ AND NOT EXISTS (
1645
+ SELECT 1
1646
+ FROM heartbeat_runs r
1647
+ WHERE r.company_id = q.company_id
1648
+ AND r.agent_id = q.agent_id
1649
+ AND r.status = 'started'
1650
+ )
1651
+ ORDER BY q.priority ASC, q.created_at ASC
1652
+ LIMIT 1
1653
+ FOR UPDATE SKIP LOCKED
1654
+ )
1655
+ UPDATE heartbeat_run_queue q
1656
+ SET
1657
+ status = 'running',
1658
+ started_at = CURRENT_TIMESTAMP,
1659
+ updated_at = CURRENT_TIMESTAMP,
1660
+ attempt_count = q.attempt_count + 1
1661
+ FROM candidate c
1662
+ WHERE q.id = c.id
1663
+ RETURNING q.*;
1664
+ `);
1665
+ const row = (result.rows ?? [])[0] as Record<string, unknown> | undefined;
1666
+ return row ? normalizeHeartbeatQueueJob(row) : null;
1667
+ }
1668
+
1669
+ export async function getHeartbeatQueueJob(db: BopoDb, companyId: string, id: string) {
1670
+ const [job] = await db
1671
+ .select()
1672
+ .from(heartbeatRunQueue)
1673
+ .where(and(eq(heartbeatRunQueue.companyId, companyId), eq(heartbeatRunQueue.id, id)))
1674
+ .limit(1);
1675
+ return job ? normalizeHeartbeatQueueJob(job) : null;
1676
+ }
1677
+
1678
+ export async function markHeartbeatJobCompleted(
1679
+ db: BopoDb,
1680
+ input: { companyId: string; id: string; heartbeatRunId?: string | null }
1681
+ ) {
1682
+ const [job] = await db
1683
+ .update(heartbeatRunQueue)
1684
+ .set({
1685
+ status: "completed",
1686
+ heartbeatRunId: input.heartbeatRunId ?? null,
1687
+ finishedAt: new Date(),
1688
+ updatedAt: touchUpdatedAtSql
1689
+ })
1690
+ .where(and(eq(heartbeatRunQueue.companyId, input.companyId), eq(heartbeatRunQueue.id, input.id)))
1691
+ .returning();
1692
+ return job ? normalizeHeartbeatQueueJob(job) : null;
1693
+ }
1694
+
1695
+ export async function markHeartbeatJobRetry(
1696
+ db: BopoDb,
1697
+ input: { companyId: string; id: string; retryAt: Date; error?: string | null; heartbeatRunId?: string | null }
1698
+ ) {
1699
+ const [job] = await db
1700
+ .update(heartbeatRunQueue)
1701
+ .set({
1702
+ status: "pending",
1703
+ availableAt: input.retryAt,
1704
+ lastError: input.error ?? null,
1705
+ heartbeatRunId: input.heartbeatRunId ?? null,
1706
+ updatedAt: touchUpdatedAtSql
1707
+ })
1708
+ .where(and(eq(heartbeatRunQueue.companyId, input.companyId), eq(heartbeatRunQueue.id, input.id)))
1709
+ .returning();
1710
+ return job ? normalizeHeartbeatQueueJob(job) : null;
1711
+ }
1712
+
1713
+ export async function markHeartbeatJobFailed(
1714
+ db: BopoDb,
1715
+ input: { companyId: string; id: string; error?: string | null; heartbeatRunId?: string | null }
1716
+ ) {
1717
+ const [job] = await db
1718
+ .update(heartbeatRunQueue)
1719
+ .set({
1720
+ status: "failed",
1721
+ lastError: input.error ?? null,
1722
+ heartbeatRunId: input.heartbeatRunId ?? null,
1723
+ finishedAt: new Date(),
1724
+ updatedAt: touchUpdatedAtSql
1725
+ })
1726
+ .where(and(eq(heartbeatRunQueue.companyId, input.companyId), eq(heartbeatRunQueue.id, input.id)))
1727
+ .returning();
1728
+ return job ? normalizeHeartbeatQueueJob(job) : null;
1729
+ }
1730
+
1731
+ export async function markHeartbeatJobDeadLetter(
1732
+ db: BopoDb,
1733
+ input: { companyId: string; id: string; error?: string | null; heartbeatRunId?: string | null }
1734
+ ) {
1735
+ const [job] = await db
1736
+ .update(heartbeatRunQueue)
1737
+ .set({
1738
+ status: "dead_letter",
1739
+ lastError: input.error ?? null,
1740
+ heartbeatRunId: input.heartbeatRunId ?? null,
1741
+ finishedAt: new Date(),
1742
+ updatedAt: touchUpdatedAtSql
1743
+ })
1744
+ .where(and(eq(heartbeatRunQueue.companyId, input.companyId), eq(heartbeatRunQueue.id, input.id)))
1745
+ .returning();
1746
+ return job ? normalizeHeartbeatQueueJob(job) : null;
1747
+ }
1748
+
1749
+ export async function cancelHeartbeatJob(db: BopoDb, input: { companyId: string; id: string }) {
1750
+ const [job] = await db
1751
+ .update(heartbeatRunQueue)
1752
+ .set({
1753
+ status: "canceled",
1754
+ finishedAt: new Date(),
1755
+ updatedAt: touchUpdatedAtSql
1756
+ })
1757
+ .where(
1758
+ and(
1759
+ eq(heartbeatRunQueue.companyId, input.companyId),
1760
+ eq(heartbeatRunQueue.id, input.id),
1761
+ notInArray(heartbeatRunQueue.status, ["completed", "failed", "dead_letter", "canceled"])
1762
+ )
1763
+ )
1764
+ .returning();
1765
+ return job ? normalizeHeartbeatQueueJob(job) : null;
1766
+ }
1767
+
1768
+ export async function listHeartbeatQueueJobs(
1769
+ db: BopoDb,
1770
+ input: {
1771
+ companyId: string;
1772
+ status?: HeartbeatQueueJobStatus;
1773
+ agentId?: string;
1774
+ jobType?: HeartbeatQueueJobType;
1775
+ limit?: number;
1776
+ }
1777
+ ) {
1778
+ const conditions = [eq(heartbeatRunQueue.companyId, input.companyId)];
1779
+ if (input.status) {
1780
+ conditions.push(eq(heartbeatRunQueue.status, input.status));
1781
+ }
1782
+ if (input.agentId) {
1783
+ conditions.push(eq(heartbeatRunQueue.agentId, input.agentId));
1784
+ }
1785
+ if (input.jobType) {
1786
+ conditions.push(eq(heartbeatRunQueue.jobType, input.jobType));
1787
+ }
1788
+ const limit = Math.min(Math.max(input.limit ?? 200, 1), 1000);
1789
+ const rows = await db
1790
+ .select()
1791
+ .from(heartbeatRunQueue)
1792
+ .where(and(...conditions))
1793
+ .orderBy(asc(heartbeatRunQueue.priority), asc(heartbeatRunQueue.availableAt), asc(heartbeatRunQueue.createdAt))
1794
+ .limit(limit);
1795
+ return rows.map((row) => normalizeHeartbeatQueueJob(row));
1796
+ }
1797
+
1206
1798
  export async function getHeartbeatRun(db: BopoDb, companyId: string, runId: string) {
1207
1799
  const [run] = await db
1208
1800
  .select()