bopodev-db 0.1.28 → 0.1.30
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/index.d.ts +2 -1
- package/dist/ping.d.ts +3 -0
- package/dist/repositories/companies.d.ts +32 -0
- package/dist/repositories/helpers.d.ts +16 -0
- package/dist/repositories/index.d.ts +3 -0
- package/dist/repositories/legacy.d.ts +1420 -0
- package/dist/schema.d.ts +252 -0
- package/package.json +4 -2
- package/src/client.ts +249 -114
- package/src/index.ts +2 -1
- package/src/migrations/0001_issues_external_link.sql +1 -0
- package/src/migrations/0002_issues_goal_goals_owner_agent.sql +2 -0
- package/src/migrations/0003_issue_goals_junction.sql +12 -0
- package/src/migrations/0004_agents_capabilities.sql +1 -0
- package/src/migrations/meta/_journal.json +28 -0
- package/src/ping.ts +7 -0
- package/src/repositories/companies.ts +41 -0
- package/src/repositories/helpers.ts +104 -0
- package/src/repositories/index.ts +3 -0
- package/src/{repositories.ts → repositories/legacy.ts} +148 -115
- package/src/schema.ts +23 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { and, eq } from "drizzle-orm";
|
|
2
|
+
import type { BopoDb } from "../client";
|
|
3
|
+
import { agents, goals, issues, projects, templates } from "../schema";
|
|
4
|
+
|
|
5
|
+
export class RepositoryValidationError extends Error {
|
|
6
|
+
constructor(message: string) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "RepositoryValidationError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function assertProjectBelongsToCompany(db: BopoDb, companyId: string, projectId: string) {
|
|
13
|
+
const [project] = await db
|
|
14
|
+
.select({ id: projects.id })
|
|
15
|
+
.from(projects)
|
|
16
|
+
.where(and(eq(projects.companyId, companyId), eq(projects.id, projectId)))
|
|
17
|
+
.limit(1);
|
|
18
|
+
if (!project) {
|
|
19
|
+
throw new RepositoryValidationError("Project not found for company.");
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function assertIssueBelongsToCompany(db: BopoDb, companyId: string, issueId: string) {
|
|
24
|
+
const [issue] = await db
|
|
25
|
+
.select({ id: issues.id })
|
|
26
|
+
.from(issues)
|
|
27
|
+
.where(and(eq(issues.companyId, companyId), eq(issues.id, issueId)))
|
|
28
|
+
.limit(1);
|
|
29
|
+
if (!issue) {
|
|
30
|
+
throw new RepositoryValidationError("Issue not found for company.");
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function assertGoalBelongsToCompany(db: BopoDb, companyId: string, goalId: string) {
|
|
35
|
+
const [goal] = await db
|
|
36
|
+
.select({ id: goals.id })
|
|
37
|
+
.from(goals)
|
|
38
|
+
.where(and(eq(goals.companyId, companyId), eq(goals.id, goalId)))
|
|
39
|
+
.limit(1);
|
|
40
|
+
if (!goal) {
|
|
41
|
+
throw new RepositoryValidationError("Parent goal not found for company.");
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Ensures a goal can be linked to an issue: same company; project-scoped goals must match the issue's project. */
|
|
46
|
+
export async function assertIssueGoalAssignable(
|
|
47
|
+
db: BopoDb,
|
|
48
|
+
companyId: string,
|
|
49
|
+
issueProjectId: string,
|
|
50
|
+
goalId: string | null | undefined
|
|
51
|
+
) {
|
|
52
|
+
if (!goalId) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const [goal] = await db
|
|
56
|
+
.select({ id: goals.id, projectId: goals.projectId })
|
|
57
|
+
.from(goals)
|
|
58
|
+
.where(and(eq(goals.companyId, companyId), eq(goals.id, goalId)))
|
|
59
|
+
.limit(1);
|
|
60
|
+
if (!goal) {
|
|
61
|
+
throw new RepositoryValidationError("Goal not found for company.");
|
|
62
|
+
}
|
|
63
|
+
if (goal.projectId && goal.projectId !== issueProjectId) {
|
|
64
|
+
throw new RepositoryValidationError("Goal is scoped to a different project than this issue.");
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Validates each goal can be linked to an issue (same company; project goals must match issue project). */
|
|
69
|
+
export async function assertIssueGoalsAssignable(
|
|
70
|
+
db: BopoDb,
|
|
71
|
+
companyId: string,
|
|
72
|
+
issueProjectId: string,
|
|
73
|
+
goalIds: string[]
|
|
74
|
+
) {
|
|
75
|
+
for (const goalId of goalIds) {
|
|
76
|
+
await assertIssueGoalAssignable(db, companyId, issueProjectId, goalId);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function assertAgentBelongsToCompany(db: BopoDb, companyId: string, agentId: string) {
|
|
81
|
+
const [agent] = await db
|
|
82
|
+
.select({ id: agents.id })
|
|
83
|
+
.from(agents)
|
|
84
|
+
.where(and(eq(agents.companyId, companyId), eq(agents.id, agentId)))
|
|
85
|
+
.limit(1);
|
|
86
|
+
if (!agent) {
|
|
87
|
+
throw new RepositoryValidationError("Agent not found for company.");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function assertTemplateBelongsToCompany(db: BopoDb, companyId: string, templateId: string) {
|
|
92
|
+
const [template] = await db
|
|
93
|
+
.select({ id: templates.id })
|
|
94
|
+
.from(templates)
|
|
95
|
+
.where(and(eq(templates.companyId, companyId), eq(templates.id, templateId)))
|
|
96
|
+
.limit(1);
|
|
97
|
+
if (!template) {
|
|
98
|
+
throw new RepositoryValidationError("Template not found for company.");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function compactUpdate<T extends Record<string, unknown>>(input: T) {
|
|
103
|
+
return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
|
|
104
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { and, asc, desc, eq, gt, inArray, notInArray, sql } from "drizzle-orm";
|
|
2
2
|
import { nanoid } from "nanoid";
|
|
3
|
-
import type { BopoDb } from "
|
|
3
|
+
import type { BopoDb } from "../client";
|
|
4
4
|
import {
|
|
5
5
|
activityLogs,
|
|
6
6
|
agents,
|
|
@@ -8,7 +8,6 @@ import {
|
|
|
8
8
|
approvalInboxStates,
|
|
9
9
|
approvalRequests,
|
|
10
10
|
auditEvents,
|
|
11
|
-
companies,
|
|
12
11
|
costLedger,
|
|
13
12
|
goals,
|
|
14
13
|
heartbeatRunQueue,
|
|
@@ -16,6 +15,7 @@ import {
|
|
|
16
15
|
heartbeatRunMessages,
|
|
17
16
|
issueAttachments,
|
|
18
17
|
issueComments,
|
|
18
|
+
issueGoals,
|
|
19
19
|
issues,
|
|
20
20
|
pluginConfigs,
|
|
21
21
|
pluginRuns,
|
|
@@ -26,100 +26,17 @@ import {
|
|
|
26
26
|
templateVersions,
|
|
27
27
|
templates,
|
|
28
28
|
touchUpdatedAtSql
|
|
29
|
-
} from "
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
.select({ id: projects.id })
|
|
41
|
-
.from(projects)
|
|
42
|
-
.where(and(eq(projects.companyId, companyId), eq(projects.id, projectId)))
|
|
43
|
-
.limit(1);
|
|
44
|
-
if (!project) {
|
|
45
|
-
throw new RepositoryValidationError("Project not found for company.");
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
async function assertIssueBelongsToCompany(db: BopoDb, companyId: string, issueId: string) {
|
|
50
|
-
const [issue] = await db
|
|
51
|
-
.select({ id: issues.id })
|
|
52
|
-
.from(issues)
|
|
53
|
-
.where(and(eq(issues.companyId, companyId), eq(issues.id, issueId)))
|
|
54
|
-
.limit(1);
|
|
55
|
-
if (!issue) {
|
|
56
|
-
throw new RepositoryValidationError("Issue not found for company.");
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
async function assertGoalBelongsToCompany(db: BopoDb, companyId: string, goalId: string) {
|
|
61
|
-
const [goal] = await db
|
|
62
|
-
.select({ id: goals.id })
|
|
63
|
-
.from(goals)
|
|
64
|
-
.where(and(eq(goals.companyId, companyId), eq(goals.id, goalId)))
|
|
65
|
-
.limit(1);
|
|
66
|
-
if (!goal) {
|
|
67
|
-
throw new RepositoryValidationError("Parent goal not found for company.");
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
async function assertAgentBelongsToCompany(db: BopoDb, companyId: string, agentId: string) {
|
|
72
|
-
const [agent] = await db
|
|
73
|
-
.select({ id: agents.id })
|
|
74
|
-
.from(agents)
|
|
75
|
-
.where(and(eq(agents.companyId, companyId), eq(agents.id, agentId)))
|
|
76
|
-
.limit(1);
|
|
77
|
-
if (!agent) {
|
|
78
|
-
throw new RepositoryValidationError("Agent not found for company.");
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
async function assertTemplateBelongsToCompany(db: BopoDb, companyId: string, templateId: string) {
|
|
83
|
-
const [template] = await db
|
|
84
|
-
.select({ id: templates.id })
|
|
85
|
-
.from(templates)
|
|
86
|
-
.where(and(eq(templates.companyId, companyId), eq(templates.id, templateId)))
|
|
87
|
-
.limit(1);
|
|
88
|
-
if (!template) {
|
|
89
|
-
throw new RepositoryValidationError("Template not found for company.");
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export async function createCompany(db: BopoDb, input: { name: string; mission?: string | null }) {
|
|
94
|
-
const id = nanoid(12);
|
|
95
|
-
await db.insert(companies).values({
|
|
96
|
-
id,
|
|
97
|
-
name: input.name,
|
|
98
|
-
mission: input.mission ?? null
|
|
99
|
-
});
|
|
100
|
-
return { id, ...input };
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
export async function listCompanies(db: BopoDb) {
|
|
104
|
-
return db.select().from(companies).orderBy(desc(companies.createdAt));
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
export async function updateCompany(
|
|
108
|
-
db: BopoDb,
|
|
109
|
-
input: { id: string; name?: string; mission?: string | null }
|
|
110
|
-
) {
|
|
111
|
-
const [company] = await db
|
|
112
|
-
.update(companies)
|
|
113
|
-
.set(compactUpdate({ name: input.name, mission: input.mission }))
|
|
114
|
-
.where(eq(companies.id, input.id))
|
|
115
|
-
.returning();
|
|
116
|
-
return company ?? null;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
export async function deleteCompany(db: BopoDb, id: string) {
|
|
120
|
-
const [deletedCompany] = await db.delete(companies).where(eq(companies.id, id)).returning({ id: companies.id });
|
|
121
|
-
return Boolean(deletedCompany);
|
|
122
|
-
}
|
|
29
|
+
} from "../schema";
|
|
30
|
+
import {
|
|
31
|
+
assertAgentBelongsToCompany,
|
|
32
|
+
assertGoalBelongsToCompany,
|
|
33
|
+
assertIssueGoalsAssignable,
|
|
34
|
+
assertIssueBelongsToCompany,
|
|
35
|
+
assertProjectBelongsToCompany,
|
|
36
|
+
assertTemplateBelongsToCompany,
|
|
37
|
+
compactUpdate,
|
|
38
|
+
RepositoryValidationError
|
|
39
|
+
} from "./helpers";
|
|
123
40
|
|
|
124
41
|
export async function listProjects(db: BopoDb, companyId: string) {
|
|
125
42
|
const rows = await db.select().from(projects).where(eq(projects.companyId, companyId)).orderBy(desc(projects.createdAt));
|
|
@@ -540,6 +457,46 @@ export async function listIssues(db: BopoDb, companyId: string, projectId?: stri
|
|
|
540
457
|
return db.select().from(issues).where(where).orderBy(desc(issues.updatedAt));
|
|
541
458
|
}
|
|
542
459
|
|
|
460
|
+
export async function listIssueGoalIdsBatch(db: BopoDb, companyId: string, issueIds: string[]) {
|
|
461
|
+
const map = new Map<string, string[]>();
|
|
462
|
+
if (issueIds.length === 0) {
|
|
463
|
+
return map;
|
|
464
|
+
}
|
|
465
|
+
const rows = await db
|
|
466
|
+
.select({ issueId: issueGoals.issueId, goalId: issueGoals.goalId })
|
|
467
|
+
.from(issueGoals)
|
|
468
|
+
.where(and(eq(issueGoals.companyId, companyId), inArray(issueGoals.issueId, issueIds)));
|
|
469
|
+
for (const row of rows) {
|
|
470
|
+
const list = map.get(row.issueId) ?? [];
|
|
471
|
+
list.push(row.goalId);
|
|
472
|
+
map.set(row.issueId, list);
|
|
473
|
+
}
|
|
474
|
+
return map;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export async function syncIssueGoals(
|
|
478
|
+
db: BopoDb,
|
|
479
|
+
input: { companyId: string; issueId: string; projectId: string; goalIds: string[] }
|
|
480
|
+
) {
|
|
481
|
+
const dedupedGoalIds = Array.from(new Set(input.goalIds.map((id) => id.trim()).filter((id) => id.length > 0)));
|
|
482
|
+
await assertIssueBelongsToCompany(db, input.companyId, input.issueId);
|
|
483
|
+
await assertIssueGoalsAssignable(db, input.companyId, input.projectId, dedupedGoalIds);
|
|
484
|
+
|
|
485
|
+
await db
|
|
486
|
+
.delete(issueGoals)
|
|
487
|
+
.where(and(eq(issueGoals.companyId, input.companyId), eq(issueGoals.issueId, input.issueId)));
|
|
488
|
+
|
|
489
|
+
if (dedupedGoalIds.length > 0) {
|
|
490
|
+
await db.insert(issueGoals).values(
|
|
491
|
+
dedupedGoalIds.map((goalId) => ({
|
|
492
|
+
issueId: input.issueId,
|
|
493
|
+
goalId,
|
|
494
|
+
companyId: input.companyId
|
|
495
|
+
}))
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
543
500
|
export async function getIssue(db: BopoDb, companyId: string, issueId: string) {
|
|
544
501
|
const [row] = await db
|
|
545
502
|
.select()
|
|
@@ -555,8 +512,10 @@ export async function createIssue(
|
|
|
555
512
|
companyId: string;
|
|
556
513
|
projectId: string;
|
|
557
514
|
parentIssueId?: string | null;
|
|
515
|
+
goalIds?: string[];
|
|
558
516
|
title: string;
|
|
559
517
|
body?: string;
|
|
518
|
+
externalLink?: string | null;
|
|
560
519
|
status?: string;
|
|
561
520
|
priority?: string;
|
|
562
521
|
assigneeAgentId?: string | null;
|
|
@@ -572,21 +531,35 @@ export async function createIssue(
|
|
|
572
531
|
await assertAgentBelongsToCompany(db, input.companyId, input.assigneeAgentId);
|
|
573
532
|
}
|
|
574
533
|
const id = nanoid(12);
|
|
575
|
-
await db.
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
534
|
+
return await db.transaction(async (tx) => {
|
|
535
|
+
const [row] = await tx
|
|
536
|
+
.insert(issues)
|
|
537
|
+
.values({
|
|
538
|
+
id,
|
|
539
|
+
companyId: input.companyId,
|
|
540
|
+
projectId: input.projectId,
|
|
541
|
+
parentIssueId: input.parentIssueId ?? null,
|
|
542
|
+
title: input.title,
|
|
543
|
+
body: input.body,
|
|
544
|
+
externalLink: input.externalLink?.trim() ? input.externalLink.trim() : null,
|
|
545
|
+
status: input.status ?? "todo",
|
|
546
|
+
priority: input.priority ?? "none",
|
|
547
|
+
assigneeAgentId: input.assigneeAgentId ?? null,
|
|
548
|
+
labelsJson: JSON.stringify(input.labels ?? []),
|
|
549
|
+
tagsJson: JSON.stringify(input.tags ?? [])
|
|
550
|
+
})
|
|
551
|
+
.returning();
|
|
552
|
+
if (!row) {
|
|
553
|
+
throw new RepositoryValidationError("Failed to create issue.");
|
|
554
|
+
}
|
|
555
|
+
await syncIssueGoals(tx as unknown as BopoDb, {
|
|
556
|
+
companyId: input.companyId,
|
|
557
|
+
issueId: row.id,
|
|
558
|
+
projectId: input.projectId,
|
|
559
|
+
goalIds: input.goalIds ?? []
|
|
560
|
+
});
|
|
561
|
+
return row;
|
|
587
562
|
});
|
|
588
|
-
|
|
589
|
-
return { id, ...input };
|
|
590
563
|
}
|
|
591
564
|
|
|
592
565
|
export async function updateIssue(
|
|
@@ -595,8 +568,10 @@ export async function updateIssue(
|
|
|
595
568
|
companyId: string;
|
|
596
569
|
id: string;
|
|
597
570
|
projectId?: string;
|
|
571
|
+
goalIds?: string[];
|
|
598
572
|
title?: string;
|
|
599
573
|
body?: string | null;
|
|
574
|
+
externalLink?: string | null;
|
|
600
575
|
status?: string;
|
|
601
576
|
priority?: string;
|
|
602
577
|
assigneeAgentId?: string | null;
|
|
@@ -604,6 +579,14 @@ export async function updateIssue(
|
|
|
604
579
|
tags?: string[];
|
|
605
580
|
}
|
|
606
581
|
) {
|
|
582
|
+
const [existing] = await db
|
|
583
|
+
.select()
|
|
584
|
+
.from(issues)
|
|
585
|
+
.where(and(eq(issues.companyId, input.companyId), eq(issues.id, input.id)))
|
|
586
|
+
.limit(1);
|
|
587
|
+
if (!existing) {
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
607
590
|
if (input.projectId) {
|
|
608
591
|
await assertProjectBelongsToCompany(db, input.companyId, input.projectId);
|
|
609
592
|
}
|
|
@@ -617,6 +600,12 @@ export async function updateIssue(
|
|
|
617
600
|
projectId: input.projectId,
|
|
618
601
|
title: input.title,
|
|
619
602
|
body: input.body,
|
|
603
|
+
externalLink:
|
|
604
|
+
input.externalLink === undefined
|
|
605
|
+
? undefined
|
|
606
|
+
: input.externalLink?.trim()
|
|
607
|
+
? input.externalLink.trim()
|
|
608
|
+
: null,
|
|
620
609
|
status: input.status,
|
|
621
610
|
priority: input.priority,
|
|
622
611
|
assigneeAgentId: input.assigneeAgentId,
|
|
@@ -627,7 +616,32 @@ export async function updateIssue(
|
|
|
627
616
|
)
|
|
628
617
|
.where(and(eq(issues.companyId, input.companyId), eq(issues.id, input.id)))
|
|
629
618
|
.returning();
|
|
630
|
-
|
|
619
|
+
if (!issue) {
|
|
620
|
+
return null;
|
|
621
|
+
}
|
|
622
|
+
if (input.goalIds !== undefined) {
|
|
623
|
+
await syncIssueGoals(db, {
|
|
624
|
+
companyId: input.companyId,
|
|
625
|
+
issueId: issue.id,
|
|
626
|
+
projectId: issue.projectId,
|
|
627
|
+
goalIds: input.goalIds
|
|
628
|
+
});
|
|
629
|
+
} else if (input.projectId && input.projectId !== existing.projectId) {
|
|
630
|
+
const currentRows = await db
|
|
631
|
+
.select({ goalId: issueGoals.goalId })
|
|
632
|
+
.from(issueGoals)
|
|
633
|
+
.where(and(eq(issueGoals.companyId, input.companyId), eq(issueGoals.issueId, issue.id)));
|
|
634
|
+
const currentGoalIds = currentRows.map((row) => row.goalId);
|
|
635
|
+
if (currentGoalIds.length > 0) {
|
|
636
|
+
await syncIssueGoals(db, {
|
|
637
|
+
companyId: input.companyId,
|
|
638
|
+
issueId: issue.id,
|
|
639
|
+
projectId: issue.projectId,
|
|
640
|
+
goalIds: currentGoalIds
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
return issue;
|
|
631
645
|
}
|
|
632
646
|
|
|
633
647
|
export async function deleteIssue(db: BopoDb, companyId: string, id: string) {
|
|
@@ -922,6 +936,7 @@ export async function createGoal(
|
|
|
922
936
|
companyId: string;
|
|
923
937
|
projectId?: string | null;
|
|
924
938
|
parentGoalId?: string | null;
|
|
939
|
+
ownerAgentId?: string | null;
|
|
925
940
|
level: "company" | "project" | "agent";
|
|
926
941
|
title: string;
|
|
927
942
|
description?: string;
|
|
@@ -933,12 +948,16 @@ export async function createGoal(
|
|
|
933
948
|
if (input.parentGoalId) {
|
|
934
949
|
await assertGoalBelongsToCompany(db, input.companyId, input.parentGoalId);
|
|
935
950
|
}
|
|
951
|
+
if (input.ownerAgentId) {
|
|
952
|
+
await assertAgentBelongsToCompany(db, input.companyId, input.ownerAgentId);
|
|
953
|
+
}
|
|
936
954
|
const id = nanoid(12);
|
|
937
955
|
await db.insert(goals).values({
|
|
938
956
|
id,
|
|
939
957
|
companyId: input.companyId,
|
|
940
958
|
projectId: input.projectId ?? null,
|
|
941
959
|
parentGoalId: input.parentGoalId ?? null,
|
|
960
|
+
ownerAgentId: input.ownerAgentId?.trim() ? input.ownerAgentId.trim() : null,
|
|
942
961
|
level: input.level,
|
|
943
962
|
title: input.title,
|
|
944
963
|
description: input.description ?? null
|
|
@@ -957,6 +976,7 @@ export async function updateGoal(
|
|
|
957
976
|
id: string;
|
|
958
977
|
projectId?: string | null;
|
|
959
978
|
parentGoalId?: string | null;
|
|
979
|
+
ownerAgentId?: string | null;
|
|
960
980
|
level?: "company" | "project" | "agent";
|
|
961
981
|
title?: string;
|
|
962
982
|
description?: string | null;
|
|
@@ -969,12 +989,21 @@ export async function updateGoal(
|
|
|
969
989
|
if (input.parentGoalId) {
|
|
970
990
|
await assertGoalBelongsToCompany(db, input.companyId, input.parentGoalId);
|
|
971
991
|
}
|
|
992
|
+
if (input.ownerAgentId) {
|
|
993
|
+
await assertAgentBelongsToCompany(db, input.companyId, input.ownerAgentId);
|
|
994
|
+
}
|
|
972
995
|
const [goal] = await db
|
|
973
996
|
.update(goals)
|
|
974
997
|
.set(
|
|
975
998
|
compactUpdate({
|
|
976
999
|
projectId: input.projectId,
|
|
977
1000
|
parentGoalId: input.parentGoalId,
|
|
1001
|
+
ownerAgentId:
|
|
1002
|
+
input.ownerAgentId === undefined
|
|
1003
|
+
? undefined
|
|
1004
|
+
: input.ownerAgentId?.trim()
|
|
1005
|
+
? input.ownerAgentId.trim()
|
|
1006
|
+
: null,
|
|
978
1007
|
level: input.level,
|
|
979
1008
|
title: input.title,
|
|
980
1009
|
description: input.description,
|
|
@@ -1003,6 +1032,7 @@ export async function createAgent(
|
|
|
1003
1032
|
role: string;
|
|
1004
1033
|
roleKey?: string | null;
|
|
1005
1034
|
title?: string | null;
|
|
1035
|
+
capabilities?: string | null;
|
|
1006
1036
|
name: string;
|
|
1007
1037
|
providerType:
|
|
1008
1038
|
| "claude_code"
|
|
@@ -1012,6 +1042,7 @@ export async function createAgent(
|
|
|
1012
1042
|
| "gemini_cli"
|
|
1013
1043
|
| "openai_api"
|
|
1014
1044
|
| "anthropic_api"
|
|
1045
|
+
| "openclaw_gateway"
|
|
1015
1046
|
| "http"
|
|
1016
1047
|
| "shell";
|
|
1017
1048
|
heartbeatCron: string;
|
|
@@ -1043,6 +1074,7 @@ export async function createAgent(
|
|
|
1043
1074
|
role: input.role,
|
|
1044
1075
|
roleKey: input.roleKey ?? null,
|
|
1045
1076
|
title: input.title ?? null,
|
|
1077
|
+
capabilities: input.capabilities ?? null,
|
|
1046
1078
|
name: input.name,
|
|
1047
1079
|
providerType: input.providerType,
|
|
1048
1080
|
heartbeatCron: input.heartbeatCron,
|
|
@@ -1078,6 +1110,7 @@ export async function updateAgent(
|
|
|
1078
1110
|
role?: string;
|
|
1079
1111
|
roleKey?: string | null;
|
|
1080
1112
|
title?: string | null;
|
|
1113
|
+
capabilities?: string | null;
|
|
1081
1114
|
name?: string;
|
|
1082
1115
|
providerType?:
|
|
1083
1116
|
| "claude_code"
|
|
@@ -1087,6 +1120,7 @@ export async function updateAgent(
|
|
|
1087
1120
|
| "gemini_cli"
|
|
1088
1121
|
| "openai_api"
|
|
1089
1122
|
| "anthropic_api"
|
|
1123
|
+
| "openclaw_gateway"
|
|
1090
1124
|
| "http"
|
|
1091
1125
|
| "shell";
|
|
1092
1126
|
status?: string;
|
|
@@ -1117,6 +1151,7 @@ export async function updateAgent(
|
|
|
1117
1151
|
role: input.role,
|
|
1118
1152
|
roleKey: input.roleKey,
|
|
1119
1153
|
title: input.title,
|
|
1154
|
+
capabilities: input.capabilities,
|
|
1120
1155
|
name: input.name,
|
|
1121
1156
|
providerType: input.providerType,
|
|
1122
1157
|
status: input.status,
|
|
@@ -1639,6 +1674,7 @@ export async function enqueueHeartbeatJob(
|
|
|
1639
1674
|
}
|
|
1640
1675
|
|
|
1641
1676
|
export async function claimNextHeartbeatJob(db: BopoDb, companyId: string) {
|
|
1677
|
+
// Postgres-specific: CTE + NOT EXISTS + FOR UPDATE SKIP LOCKED + UPDATE FROM — kept as raw SQL for correct job claiming under concurrency.
|
|
1642
1678
|
const result = await db.execute(sql`
|
|
1643
1679
|
WITH candidate AS (
|
|
1644
1680
|
SELECT q.id
|
|
@@ -1893,6 +1929,7 @@ export async function listHeartbeatRunMessagesForRuns(
|
|
|
1893
1929
|
}
|
|
1894
1930
|
const perRunLimit = Math.min(Math.max(input.perRunLimit ?? 60, 1), 500);
|
|
1895
1931
|
const runIdValues = sql.join(runIds.map((runId) => sql`(${runId})`), sql`, `);
|
|
1932
|
+
// Window functions (ROW_NUMBER) and VALUES-driven CTE — impractical to express purely with Drizzle’s query builder.
|
|
1896
1933
|
const rankedRows = await db.execute(sql`
|
|
1897
1934
|
WITH requested(run_id) AS (
|
|
1898
1935
|
VALUES ${runIdValues}
|
|
@@ -2378,7 +2415,3 @@ export async function createTemplateInstall(
|
|
|
2378
2415
|
.returning();
|
|
2379
2416
|
return row ?? null;
|
|
2380
2417
|
}
|
|
2381
|
-
|
|
2382
|
-
function compactUpdate<T extends Record<string, unknown>>(input: T) {
|
|
2383
|
-
return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
|
|
2384
|
-
}
|
package/src/schema.ts
CHANGED
|
@@ -53,6 +53,8 @@ export const goals = pgTable("goals", {
|
|
|
53
53
|
.references(() => companies.id, { onDelete: "cascade" }),
|
|
54
54
|
projectId: text("project_id").references(() => projects.id, { onDelete: "set null" }),
|
|
55
55
|
parentGoalId: text("parent_goal_id"),
|
|
56
|
+
/** When set, this agent-level goal is included only for that agent's heartbeats; null = all agents. */
|
|
57
|
+
ownerAgentId: text("owner_agent_id"),
|
|
56
58
|
level: text("level").notNull(),
|
|
57
59
|
title: text("title").notNull(),
|
|
58
60
|
description: text("description"),
|
|
@@ -70,6 +72,7 @@ export const agents = pgTable("agents", {
|
|
|
70
72
|
role: text("role").notNull(),
|
|
71
73
|
roleKey: text("role_key"),
|
|
72
74
|
title: text("title"),
|
|
75
|
+
capabilities: text("capabilities"),
|
|
73
76
|
name: text("name").notNull(),
|
|
74
77
|
providerType: text("provider_type").notNull(),
|
|
75
78
|
status: text("status").notNull().default("idle"),
|
|
@@ -114,12 +117,31 @@ export const issues = pgTable("issues", {
|
|
|
114
117
|
assigneeAgentId: text("assignee_agent_id"),
|
|
115
118
|
labelsJson: text("labels_json").notNull().default("[]"),
|
|
116
119
|
tagsJson: text("tags_json").notNull().default("[]"),
|
|
120
|
+
/** Optional link to a PR, branch page, or other external tracker (GitHub/GitLab/etc.). */
|
|
121
|
+
externalLink: text("external_link"),
|
|
117
122
|
isClaimed: boolean("is_claimed").notNull().default(false),
|
|
118
123
|
claimedByHeartbeatRunId: text("claimed_by_heartbeat_run_id"),
|
|
119
124
|
createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(),
|
|
120
125
|
updatedAt: timestamp("updated_at", { mode: "date" }).defaultNow().notNull()
|
|
121
126
|
});
|
|
122
127
|
|
|
128
|
+
export const issueGoals = pgTable(
|
|
129
|
+
"issue_goals",
|
|
130
|
+
{
|
|
131
|
+
issueId: text("issue_id")
|
|
132
|
+
.notNull()
|
|
133
|
+
.references(() => issues.id, { onDelete: "cascade" }),
|
|
134
|
+
goalId: text("goal_id")
|
|
135
|
+
.notNull()
|
|
136
|
+
.references(() => goals.id, { onDelete: "cascade" }),
|
|
137
|
+
companyId: text("company_id")
|
|
138
|
+
.notNull()
|
|
139
|
+
.references(() => companies.id, { onDelete: "cascade" }),
|
|
140
|
+
createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull()
|
|
141
|
+
},
|
|
142
|
+
(table) => [primaryKey({ columns: [table.issueId, table.goalId] })]
|
|
143
|
+
);
|
|
144
|
+
|
|
123
145
|
export const issueComments = pgTable("issue_comments", {
|
|
124
146
|
id: text("id").primaryKey(),
|
|
125
147
|
issueId: text("issue_id")
|
|
@@ -423,6 +445,7 @@ export const schema = {
|
|
|
423
445
|
goals,
|
|
424
446
|
agents,
|
|
425
447
|
issues,
|
|
448
|
+
issueGoals,
|
|
426
449
|
issueComments,
|
|
427
450
|
issueAttachments,
|
|
428
451
|
activityLogs,
|