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.
- package/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-lint.log +4 -0
- package/.turbo/turbo-typecheck.log +1 -1
- package/dist/repositories.d.ts +393 -7
- package/dist/schema.d.ts +1357 -207
- package/package.json +1 -1
- package/src/bootstrap.ts +99 -0
- package/src/repositories.ts +605 -13
- package/src/schema.ts +55 -0
package/src/repositories.ts
CHANGED
|
@@ -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
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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()
|