bopodev-api 0.1.8 → 0.1.9
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 +7 -4
- package/src/lib/agent-config.ts +255 -0
- package/src/lib/instance-paths.ts +88 -0
- package/src/lib/workspace-policy.ts +75 -0
- package/src/middleware/request-actor.ts +26 -5
- package/src/realtime/office-space.ts +7 -0
- package/src/routes/agents.ts +335 -66
- package/src/routes/heartbeats.ts +21 -2
- package/src/routes/issues.ts +122 -4
- package/src/routes/projects.ts +60 -3
- package/src/scripts/backfill-project-workspaces.ts +118 -0
- package/src/scripts/onboard-seed.ts +314 -0
- package/src/server.ts +43 -13
- package/src/services/governance-service.ts +144 -18
- package/src/services/heartbeat-service.ts +616 -3
- package/src/worker/scheduler.ts +6 -63
package/src/routes/issues.ts
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import { Router } from "express";
|
|
2
|
+
import { and, eq } from "drizzle-orm";
|
|
2
3
|
import { z } from "zod";
|
|
3
4
|
import {
|
|
4
5
|
addIssueComment,
|
|
6
|
+
agents,
|
|
5
7
|
appendActivity,
|
|
6
8
|
appendAuditEvent,
|
|
7
9
|
createIssue,
|
|
8
10
|
deleteIssueComment,
|
|
9
11
|
deleteIssue,
|
|
12
|
+
issues,
|
|
13
|
+
listIssueActivity,
|
|
10
14
|
listIssueComments,
|
|
11
15
|
listIssues,
|
|
16
|
+
projects,
|
|
12
17
|
updateIssueComment,
|
|
13
18
|
updateIssue
|
|
14
19
|
} from "bopodev-db";
|
|
@@ -31,14 +36,14 @@ const createIssueSchema = z.object({
|
|
|
31
36
|
|
|
32
37
|
const createIssueCommentSchema = z.object({
|
|
33
38
|
body: z.string().min(1),
|
|
34
|
-
authorType: z.enum(["human", "agent", "system"]).
|
|
39
|
+
authorType: z.enum(["human", "agent", "system"]).optional(),
|
|
35
40
|
authorId: z.string().optional()
|
|
36
41
|
});
|
|
37
42
|
|
|
38
43
|
const createIssueCommentLegacySchema = z.object({
|
|
39
44
|
issueId: z.string().min(1),
|
|
40
45
|
body: z.string().min(1),
|
|
41
|
-
authorType: z.enum(["human", "agent", "system"]).
|
|
46
|
+
authorType: z.enum(["human", "agent", "system"]).optional(),
|
|
42
47
|
authorId: z.string().optional()
|
|
43
48
|
});
|
|
44
49
|
|
|
@@ -107,6 +112,17 @@ export function createIssuesRouter(ctx: AppContext) {
|
|
|
107
112
|
if (!parsed.success) {
|
|
108
113
|
return sendError(res, parsed.error.message, 422);
|
|
109
114
|
}
|
|
115
|
+
if (parsed.data.assigneeAgentId) {
|
|
116
|
+
const assignmentValidation = await validateIssueAssignmentScope(
|
|
117
|
+
ctx,
|
|
118
|
+
req.companyId!,
|
|
119
|
+
parsed.data.projectId,
|
|
120
|
+
parsed.data.assigneeAgentId
|
|
121
|
+
);
|
|
122
|
+
if (assignmentValidation) {
|
|
123
|
+
return sendError(res, assignmentValidation, 422);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
110
126
|
const issue = await createIssue(ctx.db, { companyId: req.companyId!, ...parsed.data });
|
|
111
127
|
await appendActivity(ctx.db, {
|
|
112
128
|
companyId: req.companyId!,
|
|
@@ -131,6 +147,17 @@ export function createIssuesRouter(ctx: AppContext) {
|
|
|
131
147
|
return sendOk(res, comments);
|
|
132
148
|
});
|
|
133
149
|
|
|
150
|
+
router.get("/:issueId/activity", async (req, res) => {
|
|
151
|
+
const activity = await listIssueActivity(ctx.db, req.companyId!, req.params.issueId);
|
|
152
|
+
return sendOk(
|
|
153
|
+
res,
|
|
154
|
+
activity.map((row) => ({
|
|
155
|
+
...row,
|
|
156
|
+
payload: parsePayload(row.payloadJson)
|
|
157
|
+
}))
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
134
161
|
router.post("/:issueId/comments", async (req, res) => {
|
|
135
162
|
requirePermission("issues:write")(req, res, () => {});
|
|
136
163
|
if (res.headersSent) {
|
|
@@ -140,10 +167,13 @@ export function createIssuesRouter(ctx: AppContext) {
|
|
|
140
167
|
if (!parsed.success) {
|
|
141
168
|
return sendError(res, parsed.error.message, 422);
|
|
142
169
|
}
|
|
170
|
+
const author = resolveIssueCommentAuthor(req.actor?.type, req.actor?.id, parsed.data);
|
|
143
171
|
const comment = await addIssueComment(ctx.db, {
|
|
144
172
|
companyId: req.companyId!,
|
|
145
173
|
issueId: req.params.issueId,
|
|
146
|
-
|
|
174
|
+
body: parsed.data.body,
|
|
175
|
+
authorType: author.authorType,
|
|
176
|
+
authorId: author.authorId
|
|
147
177
|
});
|
|
148
178
|
await appendActivity(ctx.db, {
|
|
149
179
|
companyId: req.companyId!,
|
|
@@ -175,7 +205,14 @@ export function createIssuesRouter(ctx: AppContext) {
|
|
|
175
205
|
if (!parsed.success) {
|
|
176
206
|
return sendError(res, parsed.error.message, 422);
|
|
177
207
|
}
|
|
178
|
-
const
|
|
208
|
+
const author = resolveIssueCommentAuthor(req.actor?.type, req.actor?.id, parsed.data);
|
|
209
|
+
const comment = await addIssueComment(ctx.db, {
|
|
210
|
+
companyId: req.companyId!,
|
|
211
|
+
issueId: parsed.data.issueId,
|
|
212
|
+
body: parsed.data.body,
|
|
213
|
+
authorType: author.authorType,
|
|
214
|
+
authorId: author.authorId
|
|
215
|
+
});
|
|
179
216
|
await appendActivity(ctx.db, {
|
|
180
217
|
companyId: req.companyId!,
|
|
181
218
|
issueId: comment.issueId,
|
|
@@ -271,6 +308,34 @@ export function createIssuesRouter(ctx: AppContext) {
|
|
|
271
308
|
if (!parsed.success) {
|
|
272
309
|
return sendError(res, parsed.error.message, 422);
|
|
273
310
|
}
|
|
311
|
+
if (parsed.data.assigneeAgentId !== undefined || parsed.data.projectId !== undefined) {
|
|
312
|
+
const [existingIssue] = await ctx.db
|
|
313
|
+
.select({
|
|
314
|
+
id: issues.id,
|
|
315
|
+
projectId: issues.projectId,
|
|
316
|
+
assigneeAgentId: issues.assigneeAgentId
|
|
317
|
+
})
|
|
318
|
+
.from(issues)
|
|
319
|
+
.where(and(eq(issues.companyId, req.companyId!), eq(issues.id, req.params.issueId)))
|
|
320
|
+
.limit(1);
|
|
321
|
+
if (!existingIssue) {
|
|
322
|
+
return sendError(res, "Issue not found.", 404);
|
|
323
|
+
}
|
|
324
|
+
const effectiveProjectId = parsed.data.projectId ?? existingIssue.projectId;
|
|
325
|
+
const effectiveAssigneeAgentId =
|
|
326
|
+
parsed.data.assigneeAgentId === undefined ? existingIssue.assigneeAgentId : parsed.data.assigneeAgentId;
|
|
327
|
+
if (effectiveAssigneeAgentId) {
|
|
328
|
+
const assignmentValidation = await validateIssueAssignmentScope(
|
|
329
|
+
ctx,
|
|
330
|
+
req.companyId!,
|
|
331
|
+
effectiveProjectId,
|
|
332
|
+
effectiveAssigneeAgentId
|
|
333
|
+
);
|
|
334
|
+
if (assignmentValidation) {
|
|
335
|
+
return sendError(res, assignmentValidation, 422);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
274
339
|
|
|
275
340
|
const issue = await updateIssue(ctx.db, { companyId: req.companyId!, id: req.params.issueId, ...parsed.data });
|
|
276
341
|
if (!issue) {
|
|
@@ -317,3 +382,56 @@ export function createIssuesRouter(ctx: AppContext) {
|
|
|
317
382
|
|
|
318
383
|
return router;
|
|
319
384
|
}
|
|
385
|
+
|
|
386
|
+
function parsePayload(payloadJson: string) {
|
|
387
|
+
try {
|
|
388
|
+
const parsed = JSON.parse(payloadJson) as unknown;
|
|
389
|
+
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
|
|
390
|
+
} catch {
|
|
391
|
+
return {};
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function resolveIssueCommentAuthor(
|
|
396
|
+
actorType: "board" | "member" | "agent" | undefined,
|
|
397
|
+
actorId: string | undefined,
|
|
398
|
+
input: { authorType?: "human" | "agent" | "system"; authorId?: string }
|
|
399
|
+
) {
|
|
400
|
+
const inferredAuthorType = actorType === "agent" ? "agent" : "human";
|
|
401
|
+
const authorType = input.authorType ?? inferredAuthorType;
|
|
402
|
+
if (input.authorId) {
|
|
403
|
+
return { authorType, authorId: input.authorId };
|
|
404
|
+
}
|
|
405
|
+
if (authorType === "agent" && actorId) {
|
|
406
|
+
return { authorType, authorId: actorId };
|
|
407
|
+
}
|
|
408
|
+
return { authorType, authorId: undefined };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function validateIssueAssignmentScope(
|
|
412
|
+
ctx: AppContext,
|
|
413
|
+
companyId: string,
|
|
414
|
+
projectId: string,
|
|
415
|
+
assigneeAgentId: string
|
|
416
|
+
) {
|
|
417
|
+
const [project] = await ctx.db
|
|
418
|
+
.select({ id: projects.id })
|
|
419
|
+
.from(projects)
|
|
420
|
+
.where(and(eq(projects.companyId, companyId), eq(projects.id, projectId)))
|
|
421
|
+
.limit(1);
|
|
422
|
+
if (!project) {
|
|
423
|
+
return "Project not found.";
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const [agent] = await ctx.db
|
|
427
|
+
.select({ id: agents.id })
|
|
428
|
+
.from(agents)
|
|
429
|
+
.where(and(eq(agents.companyId, companyId), eq(agents.id, assigneeAgentId)))
|
|
430
|
+
.limit(1);
|
|
431
|
+
if (!agent) {
|
|
432
|
+
return "Assigned agent not found.";
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
|
package/src/routes/projects.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { Router } from "express";
|
|
2
|
+
import { mkdir } from "node:fs/promises";
|
|
2
3
|
import { z } from "zod";
|
|
3
4
|
import { appendAuditEvent, createProject, deleteProject, listProjects, syncProjectGoals, updateProject } from "bopodev-db";
|
|
4
5
|
import type { AppContext } from "../context";
|
|
5
6
|
import { sendError, sendOk } from "../http";
|
|
7
|
+
import { normalizeAbsolutePath, resolveProjectWorkspacePath } from "../lib/instance-paths";
|
|
6
8
|
import { requireCompanyScope } from "../middleware/company-scope";
|
|
7
9
|
import { requirePermission } from "../middleware/request-actor";
|
|
8
10
|
|
|
@@ -59,15 +61,23 @@ export function createProjectsRouter(ctx: AppContext) {
|
|
|
59
61
|
if (!parsed.success) {
|
|
60
62
|
return sendError(res, parsed.error.message, 422);
|
|
61
63
|
}
|
|
62
|
-
const
|
|
64
|
+
const explicitWorkspaceLocalPath = normalizeOptionalPath(parsed.data.workspaceLocalPath);
|
|
65
|
+
const created = await createProject(ctx.db, {
|
|
63
66
|
companyId: req.companyId!,
|
|
64
67
|
name: parsed.data.name,
|
|
65
68
|
description: parsed.data.description,
|
|
66
69
|
status: parsed.data.status,
|
|
67
70
|
plannedStartAt: parsePlannedStartAt(parsed.data.plannedStartAt),
|
|
68
|
-
workspaceLocalPath:
|
|
71
|
+
workspaceLocalPath: explicitWorkspaceLocalPath ?? undefined,
|
|
69
72
|
workspaceGithubRepo: parsed.data.workspaceGithubRepo
|
|
70
73
|
});
|
|
74
|
+
const project =
|
|
75
|
+
explicitWorkspaceLocalPath
|
|
76
|
+
? created
|
|
77
|
+
: await ensureAutoWorkspaceLocalPath(ctx, req.companyId!, created.id);
|
|
78
|
+
if (explicitWorkspaceLocalPath) {
|
|
79
|
+
await mkdir(explicitWorkspaceLocalPath, { recursive: true });
|
|
80
|
+
}
|
|
71
81
|
await syncProjectGoals(ctx.db, {
|
|
72
82
|
companyId: req.companyId!,
|
|
73
83
|
projectId: project.id,
|
|
@@ -102,12 +112,38 @@ export function createProjectsRouter(ctx: AppContext) {
|
|
|
102
112
|
status: parsed.data.status,
|
|
103
113
|
plannedStartAt:
|
|
104
114
|
parsed.data.plannedStartAt === undefined ? undefined : parsePlannedStartAt(parsed.data.plannedStartAt),
|
|
105
|
-
workspaceLocalPath:
|
|
115
|
+
workspaceLocalPath:
|
|
116
|
+
parsed.data.workspaceLocalPath === undefined
|
|
117
|
+
? undefined
|
|
118
|
+
: parsed.data.workspaceLocalPath === null
|
|
119
|
+
? null
|
|
120
|
+
: normalizeAbsolutePath(parsed.data.workspaceLocalPath),
|
|
106
121
|
workspaceGithubRepo: parsed.data.workspaceGithubRepo
|
|
107
122
|
});
|
|
123
|
+
if (
|
|
124
|
+
parsed.data.workspaceLocalPath !== undefined &&
|
|
125
|
+
parsed.data.workspaceLocalPath !== null &&
|
|
126
|
+
parsed.data.workspaceLocalPath.trim().length > 0
|
|
127
|
+
) {
|
|
128
|
+
await mkdir(normalizeAbsolutePath(parsed.data.workspaceLocalPath), { recursive: true });
|
|
129
|
+
}
|
|
108
130
|
if (!project) {
|
|
109
131
|
return sendError(res, "Project not found.", 404);
|
|
110
132
|
}
|
|
133
|
+
if (parsed.data.workspaceLocalPath === null) {
|
|
134
|
+
const autoWorkspacePath = resolveProjectWorkspacePath(req.companyId!, project.id);
|
|
135
|
+
await mkdir(autoWorkspacePath, { recursive: true });
|
|
136
|
+
const updated = await updateProject(ctx.db, {
|
|
137
|
+
companyId: req.companyId!,
|
|
138
|
+
id: req.params.projectId,
|
|
139
|
+
workspaceLocalPath: autoWorkspacePath
|
|
140
|
+
});
|
|
141
|
+
if (!updated) {
|
|
142
|
+
return sendError(res, "Project not found.", 404);
|
|
143
|
+
}
|
|
144
|
+
Object.assign(project, updated);
|
|
145
|
+
}
|
|
146
|
+
|
|
111
147
|
if (parsed.data.goalIds) {
|
|
112
148
|
await syncProjectGoals(ctx.db, {
|
|
113
149
|
companyId: req.companyId!,
|
|
@@ -150,3 +186,24 @@ export function createProjectsRouter(ctx: AppContext) {
|
|
|
150
186
|
|
|
151
187
|
return router;
|
|
152
188
|
}
|
|
189
|
+
|
|
190
|
+
async function ensureAutoWorkspaceLocalPath(ctx: AppContext, companyId: string, projectId: string) {
|
|
191
|
+
const workspaceLocalPath = resolveProjectWorkspacePath(companyId, projectId);
|
|
192
|
+
await mkdir(workspaceLocalPath, { recursive: true });
|
|
193
|
+
const updated = await updateProject(ctx.db, {
|
|
194
|
+
companyId,
|
|
195
|
+
id: projectId,
|
|
196
|
+
workspaceLocalPath
|
|
197
|
+
});
|
|
198
|
+
if (!updated) {
|
|
199
|
+
throw new Error("Project not found after creation.");
|
|
200
|
+
}
|
|
201
|
+
return updated;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function normalizeOptionalPath(value: string | undefined) {
|
|
205
|
+
if (!value || value.trim().length === 0) {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
return normalizeAbsolutePath(value);
|
|
209
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { access, constants, mkdir } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { bootstrapDatabase, listCompanies, listProjects, updateProject } from "bopodev-db";
|
|
5
|
+
import { normalizeAbsolutePath, resolveBopoInstanceRoot, resolveProjectWorkspacePath, resolveStorageRoot } from "../lib/instance-paths";
|
|
6
|
+
|
|
7
|
+
export interface ProjectWorkspaceBackfillSummary {
|
|
8
|
+
scannedProjects: number;
|
|
9
|
+
missingWorkspaceLocalPath: number;
|
|
10
|
+
relativeWorkspaceLocalPath: number;
|
|
11
|
+
updatedProjects: number;
|
|
12
|
+
createdDirectories: number;
|
|
13
|
+
writableInstanceRoot: boolean;
|
|
14
|
+
writableStorageRoot: boolean;
|
|
15
|
+
dryRun: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function backfillProjectWorkspaces(input: { dbPath?: string; dryRun: boolean }) {
|
|
19
|
+
const { db, client } = await bootstrapDatabase(input.dbPath);
|
|
20
|
+
const instanceRoot = resolveBopoInstanceRoot();
|
|
21
|
+
const storageRoot = resolveStorageRoot();
|
|
22
|
+
let scannedProjects = 0;
|
|
23
|
+
let missingWorkspaceLocalPath = 0;
|
|
24
|
+
let relativeWorkspaceLocalPath = 0;
|
|
25
|
+
let updatedProjects = 0;
|
|
26
|
+
let createdDirectories = 0;
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const companies = await listCompanies(db);
|
|
30
|
+
for (const company of companies) {
|
|
31
|
+
const projects = await listProjects(db, company.id);
|
|
32
|
+
for (const project of projects) {
|
|
33
|
+
scannedProjects += 1;
|
|
34
|
+
const workspaceLocalPath = project.workspaceLocalPath?.trim() ?? "";
|
|
35
|
+
if (!workspaceLocalPath) {
|
|
36
|
+
missingWorkspaceLocalPath += 1;
|
|
37
|
+
const nextPath = resolveProjectWorkspacePath(company.id, project.id);
|
|
38
|
+
if (!input.dryRun) {
|
|
39
|
+
await mkdir(nextPath, { recursive: true });
|
|
40
|
+
createdDirectories += 1;
|
|
41
|
+
const updated = await updateProject(db, {
|
|
42
|
+
companyId: company.id,
|
|
43
|
+
id: project.id,
|
|
44
|
+
workspaceLocalPath: nextPath
|
|
45
|
+
});
|
|
46
|
+
if (updated) {
|
|
47
|
+
updatedProjects += 1;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!isAbsolute(workspaceLocalPath)) {
|
|
54
|
+
relativeWorkspaceLocalPath += 1;
|
|
55
|
+
const nextPath = normalizeAbsolutePath(workspaceLocalPath);
|
|
56
|
+
if (!input.dryRun) {
|
|
57
|
+
await mkdir(nextPath, { recursive: true });
|
|
58
|
+
createdDirectories += 1;
|
|
59
|
+
const updated = await updateProject(db, {
|
|
60
|
+
companyId: company.id,
|
|
61
|
+
id: project.id,
|
|
62
|
+
workspaceLocalPath: nextPath
|
|
63
|
+
});
|
|
64
|
+
if (updated) {
|
|
65
|
+
updatedProjects += 1;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!input.dryRun) {
|
|
73
|
+
await mkdir(instanceRoot, { recursive: true });
|
|
74
|
+
await mkdir(storageRoot, { recursive: true });
|
|
75
|
+
}
|
|
76
|
+
const writableInstanceRoot = await isDirectoryWritable(instanceRoot);
|
|
77
|
+
const writableStorageRoot = await isDirectoryWritable(storageRoot);
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
scannedProjects,
|
|
81
|
+
missingWorkspaceLocalPath,
|
|
82
|
+
relativeWorkspaceLocalPath,
|
|
83
|
+
updatedProjects,
|
|
84
|
+
createdDirectories,
|
|
85
|
+
writableInstanceRoot,
|
|
86
|
+
writableStorageRoot,
|
|
87
|
+
dryRun: input.dryRun
|
|
88
|
+
} satisfies ProjectWorkspaceBackfillSummary;
|
|
89
|
+
} finally {
|
|
90
|
+
const maybeClose = (client as { close?: () => Promise<void> }).close;
|
|
91
|
+
if (maybeClose) {
|
|
92
|
+
await maybeClose.call(client);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function isDirectoryWritable(path: string) {
|
|
98
|
+
try {
|
|
99
|
+
await mkdir(path, { recursive: true });
|
|
100
|
+
await access(path, constants.W_OK);
|
|
101
|
+
return true;
|
|
102
|
+
} catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function main() {
|
|
108
|
+
const summary = await backfillProjectWorkspaces({
|
|
109
|
+
dbPath: process.env.BOPO_DB_PATH,
|
|
110
|
+
dryRun: process.env.BOPO_BACKFILL_DRY_RUN !== "0"
|
|
111
|
+
});
|
|
112
|
+
// eslint-disable-next-line no-console
|
|
113
|
+
console.log(JSON.stringify(summary));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
117
|
+
void main();
|
|
118
|
+
}
|