bopodev-api 0.1.27 → 0.1.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/app.ts +17 -70
- package/src/lib/drainable-work.ts +36 -0
- package/src/lib/run-artifact-paths.ts +8 -0
- package/src/lib/workspace-policy.ts +1 -2
- package/src/middleware/cors-config.ts +36 -0
- package/src/middleware/request-actor.ts +10 -16
- package/src/middleware/request-id.ts +9 -0
- package/src/middleware/request-logging.ts +24 -0
- package/src/realtime/office-space.ts +3 -1
- package/src/routes/agents.ts +3 -9
- package/src/routes/companies.ts +18 -1
- package/src/routes/goals.ts +7 -13
- package/src/routes/governance.ts +2 -5
- package/src/routes/heartbeats.ts +8 -27
- package/src/routes/issues.ts +66 -121
- package/src/routes/observability.ts +6 -1
- package/src/routes/plugins.ts +5 -17
- package/src/routes/projects.ts +7 -25
- package/src/routes/templates.ts +6 -21
- package/src/scripts/onboard-seed.ts +5 -7
- package/src/server.ts +35 -276
- package/src/services/attention-service.ts +4 -1
- package/src/services/budget-service.ts +1 -2
- package/src/services/comment-recipient-dispatch-service.ts +39 -2
- package/src/services/company-export-service.ts +63 -0
- package/src/services/governance-service.ts +6 -2
- package/src/services/heartbeat-queue-service.ts +34 -3
- package/src/services/heartbeat-service/active-runs.ts +15 -0
- package/src/services/heartbeat-service/budget-override.ts +46 -0
- package/src/services/heartbeat-service/claims.ts +61 -0
- package/src/services/heartbeat-service/cron.ts +58 -0
- package/src/services/heartbeat-service/heartbeat-realtime.ts +28 -0
- package/src/services/heartbeat-service/heartbeat-run-summary-text.ts +53 -0
- package/src/services/{heartbeat-service.ts → heartbeat-service/heartbeat-run.ts} +217 -635
- package/src/services/heartbeat-service/index.ts +5 -0
- package/src/services/heartbeat-service/stop.ts +90 -0
- package/src/services/heartbeat-service/sweep.ts +145 -0
- package/src/services/heartbeat-service/types.ts +65 -0
- package/src/services/memory-file-service.ts +10 -2
- package/src/shutdown/graceful-shutdown.ts +77 -0
- package/src/startup/database.ts +41 -0
- package/src/startup/deployment-validation.ts +37 -0
- package/src/startup/env.ts +17 -0
- package/src/startup/runtime-health.ts +128 -0
- package/src/startup/scheduler-config.ts +39 -0
- package/src/types/express.d.ts +13 -0
- package/src/types/request-actor.ts +6 -0
- package/src/validation/issue-routes.ts +79 -0
- package/src/worker/scheduler.ts +20 -4
package/src/routes/issues.ts
CHANGED
|
@@ -1,27 +1,30 @@
|
|
|
1
1
|
import { Router } from "express";
|
|
2
2
|
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
3
3
|
import { basename, extname, join, resolve } from "node:path";
|
|
4
|
-
import { and, desc, eq, inArray } from "drizzle-orm";
|
|
5
4
|
import multer from "multer";
|
|
6
|
-
import { z } from "zod";
|
|
7
5
|
import { IssueDetailSchema, IssueSchema } from "bopodev-contracts";
|
|
8
6
|
import {
|
|
9
7
|
addIssueAttachment,
|
|
10
8
|
addIssueComment,
|
|
11
9
|
agents,
|
|
10
|
+
and,
|
|
12
11
|
appendActivity,
|
|
13
12
|
appendAuditEvent,
|
|
14
13
|
createIssue,
|
|
15
14
|
deleteIssueAttachment,
|
|
16
15
|
deleteIssueComment,
|
|
17
16
|
deleteIssue,
|
|
17
|
+
desc,
|
|
18
|
+
eq,
|
|
18
19
|
getIssue,
|
|
19
20
|
heartbeatRuns,
|
|
20
21
|
getIssueAttachment,
|
|
22
|
+
inArray,
|
|
21
23
|
issues,
|
|
22
24
|
listIssueAttachments,
|
|
23
25
|
listIssueActivity,
|
|
24
26
|
listIssueComments,
|
|
27
|
+
listIssueGoalIdsBatch,
|
|
25
28
|
listIssues,
|
|
26
29
|
projects,
|
|
27
30
|
projectWorkspaces,
|
|
@@ -39,70 +42,16 @@ import {
|
|
|
39
42
|
} from "../lib/comment-recipients";
|
|
40
43
|
import { isInsidePath, normalizeCompanyWorkspacePath, resolveProjectWorkspacePath } from "../lib/instance-paths";
|
|
41
44
|
import { requireCompanyScope } from "../middleware/company-scope";
|
|
42
|
-
import {
|
|
45
|
+
import { enforcePermission } from "../middleware/request-actor";
|
|
43
46
|
import { triggerIssueCommentDispatchWorker } from "../services/comment-recipient-dispatch-service";
|
|
44
47
|
import { publishAttentionSnapshot } from "../realtime/attention";
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
.object({
|
|
53
|
-
delegatedHiringIntent: z
|
|
54
|
-
.object({
|
|
55
|
-
intentType: z.literal("agent_hiring_request"),
|
|
56
|
-
requestedRole: z.string().nullable().optional(),
|
|
57
|
-
requestedRoleKey: z.string().nullable().optional(),
|
|
58
|
-
requestedTitle: z.string().nullable().optional(),
|
|
59
|
-
requestedName: z.string().nullable().optional(),
|
|
60
|
-
requestedManagerAgentId: z.string().nullable().optional(),
|
|
61
|
-
requestedProviderType: z.string().nullable().optional(),
|
|
62
|
-
requestedRuntimeModel: z.string().nullable().optional()
|
|
63
|
-
})
|
|
64
|
-
.optional()
|
|
65
|
-
})
|
|
66
|
-
.optional(),
|
|
67
|
-
status: z.enum(["todo", "in_progress", "blocked", "in_review", "done", "canceled"]).default("todo"),
|
|
68
|
-
priority: z.enum(["none", "low", "medium", "high", "urgent"]).default("none"),
|
|
69
|
-
assigneeAgentId: z.string().nullable().optional(),
|
|
70
|
-
labels: z.array(z.string()).default([]),
|
|
71
|
-
tags: z.array(z.string()).default([])
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
const createIssueCommentSchema = z.object({
|
|
75
|
-
body: z.string().min(1),
|
|
76
|
-
recipients: z
|
|
77
|
-
.array(
|
|
78
|
-
z.object({
|
|
79
|
-
recipientType: z.enum(["agent", "board", "member"]),
|
|
80
|
-
recipientId: z.string().nullable().optional()
|
|
81
|
-
})
|
|
82
|
-
)
|
|
83
|
-
.default([]),
|
|
84
|
-
authorType: z.enum(["human", "agent", "system"]).optional(),
|
|
85
|
-
authorId: z.string().optional()
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
const createIssueCommentLegacySchema = z.object({
|
|
89
|
-
issueId: z.string().min(1),
|
|
90
|
-
body: z.string().min(1),
|
|
91
|
-
recipients: z
|
|
92
|
-
.array(
|
|
93
|
-
z.object({
|
|
94
|
-
recipientType: z.enum(["agent", "board", "member"]),
|
|
95
|
-
recipientId: z.string().nullable().optional()
|
|
96
|
-
})
|
|
97
|
-
)
|
|
98
|
-
.default([]),
|
|
99
|
-
authorType: z.enum(["human", "agent", "system"]).optional(),
|
|
100
|
-
authorId: z.string().optional()
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
const updateIssueCommentSchema = z.object({
|
|
104
|
-
body: z.string().min(1)
|
|
105
|
-
});
|
|
48
|
+
import {
|
|
49
|
+
createIssueCommentLegacySchema,
|
|
50
|
+
createIssueCommentSchema,
|
|
51
|
+
createIssueSchema,
|
|
52
|
+
updateIssueCommentSchema,
|
|
53
|
+
updateIssueSchema
|
|
54
|
+
} from "../validation/issue-routes";
|
|
106
55
|
|
|
107
56
|
const MAX_ATTACHMENTS_PER_REQUEST = parsePositiveIntEnv("BOPO_ISSUE_ATTACHMENTS_MAX_FILES", 10);
|
|
108
57
|
const MAX_ATTACHMENT_SIZE_BYTES = parsePositiveIntEnv("BOPO_ISSUE_ATTACHMENTS_MAX_BYTES", 20 * 1024 * 1024);
|
|
@@ -145,30 +94,32 @@ function parseStringArray(value: unknown) {
|
|
|
145
94
|
}
|
|
146
95
|
}
|
|
147
96
|
|
|
148
|
-
function
|
|
97
|
+
function normalizeOptionalExternalLink(value: string | null | undefined) {
|
|
98
|
+
if (value === undefined) {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
const trimmed = value?.trim() ?? "";
|
|
102
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function toIssueResponse(issue: Record<string, unknown>, goalIds: string[] = []) {
|
|
149
106
|
const labels = parseStringArray(issue.labelsJson);
|
|
150
107
|
const tags = parseStringArray(issue.tagsJson);
|
|
151
|
-
const { labelsJson: _labelsJson, tagsJson: _tagsJson, ...rest } = issue
|
|
108
|
+
const { labelsJson: _labelsJson, tagsJson: _tagsJson, goalId: _legacyGoalId, ...rest } = issue as Record<string, unknown> & {
|
|
109
|
+
goalId?: unknown;
|
|
110
|
+
};
|
|
111
|
+
const externalRaw = rest.externalLink;
|
|
112
|
+
const externalLink =
|
|
113
|
+
typeof externalRaw === "string" && externalRaw.trim().length > 0 ? externalRaw.trim() : null;
|
|
152
114
|
return {
|
|
153
115
|
...rest,
|
|
116
|
+
externalLink,
|
|
154
117
|
labels,
|
|
155
|
-
tags
|
|
118
|
+
tags,
|
|
119
|
+
goalIds
|
|
156
120
|
};
|
|
157
121
|
}
|
|
158
122
|
|
|
159
|
-
const updateIssueSchema = z
|
|
160
|
-
.object({
|
|
161
|
-
projectId: z.string().min(1).optional(),
|
|
162
|
-
title: z.string().min(1).optional(),
|
|
163
|
-
body: z.string().nullable().optional(),
|
|
164
|
-
status: z.enum(["todo", "in_progress", "blocked", "in_review", "done", "canceled"]).optional(),
|
|
165
|
-
priority: z.enum(["none", "low", "medium", "high", "urgent"]).optional(),
|
|
166
|
-
assigneeAgentId: z.string().nullable().optional(),
|
|
167
|
-
labels: z.array(z.string()).optional(),
|
|
168
|
-
tags: z.array(z.string()).optional()
|
|
169
|
-
})
|
|
170
|
-
.refine((payload) => Object.keys(payload).length > 0, "At least one field must be provided.");
|
|
171
|
-
|
|
172
123
|
export function createIssuesRouter(ctx: AppContext) {
|
|
173
124
|
const router = Router();
|
|
174
125
|
const upload = multer({
|
|
@@ -183,10 +134,17 @@ export function createIssuesRouter(ctx: AppContext) {
|
|
|
183
134
|
router.get("/", async (req, res) => {
|
|
184
135
|
const projectId = req.query.projectId?.toString();
|
|
185
136
|
const rows = await listIssues(ctx.db, req.companyId!, projectId);
|
|
137
|
+
const goalMap = await listIssueGoalIdsBatch(
|
|
138
|
+
ctx.db,
|
|
139
|
+
req.companyId!,
|
|
140
|
+
rows.map((row) => row.id)
|
|
141
|
+
);
|
|
186
142
|
return sendOkValidated(
|
|
187
143
|
res,
|
|
188
144
|
IssueSchema.array(),
|
|
189
|
-
rows.map((row) =>
|
|
145
|
+
rows.map((row) =>
|
|
146
|
+
toIssueResponse(row as unknown as Record<string, unknown>, goalMap.get(row.id) ?? [])
|
|
147
|
+
),
|
|
190
148
|
"issues.list"
|
|
191
149
|
);
|
|
192
150
|
});
|
|
@@ -197,7 +155,8 @@ export function createIssuesRouter(ctx: AppContext) {
|
|
|
197
155
|
if (!issueRow) {
|
|
198
156
|
return sendError(res, "Issue not found.", 404);
|
|
199
157
|
}
|
|
200
|
-
const
|
|
158
|
+
const goalMap = await listIssueGoalIdsBatch(ctx.db, req.companyId!, [issueId]);
|
|
159
|
+
const base = toIssueResponse(issueRow as unknown as Record<string, unknown>, goalMap.get(issueId) ?? []);
|
|
201
160
|
const attachmentRows = await listIssueAttachments(ctx.db, req.companyId!, issueId);
|
|
202
161
|
const attachments = attachmentRows.map((row) =>
|
|
203
162
|
toIssueAttachmentResponse(row as unknown as Record<string, unknown>, issueId)
|
|
@@ -206,10 +165,7 @@ export function createIssuesRouter(ctx: AppContext) {
|
|
|
206
165
|
});
|
|
207
166
|
|
|
208
167
|
router.post("/", async (req, res) => {
|
|
209
|
-
|
|
210
|
-
if (res.headersSent) {
|
|
211
|
-
return;
|
|
212
|
-
}
|
|
168
|
+
if (!enforcePermission(req, res, "issues:write")) return;
|
|
213
169
|
const parsed = createIssueSchema.safeParse(req.body);
|
|
214
170
|
if (!parsed.success) {
|
|
215
171
|
return sendError(res, parsed.error.message, 422);
|
|
@@ -229,8 +185,10 @@ export function createIssuesRouter(ctx: AppContext) {
|
|
|
229
185
|
companyId: req.companyId!,
|
|
230
186
|
projectId: parsed.data.projectId,
|
|
231
187
|
parentIssueId: parsed.data.parentIssueId,
|
|
188
|
+
goalIds: parsed.data.goalIds,
|
|
232
189
|
title: parsed.data.title,
|
|
233
190
|
body: applyIssueMetadataToBody(parsed.data.body, parsed.data.metadata),
|
|
191
|
+
externalLink: normalizeOptionalExternalLink(parsed.data.externalLink ?? null),
|
|
234
192
|
status: parsed.data.status,
|
|
235
193
|
priority: parsed.data.priority,
|
|
236
194
|
assigneeAgentId: parsed.data.assigneeAgentId,
|
|
@@ -252,14 +210,14 @@ export function createIssuesRouter(ctx: AppContext) {
|
|
|
252
210
|
entityId: issue.id,
|
|
253
211
|
payload: issue
|
|
254
212
|
});
|
|
255
|
-
return sendOk(
|
|
213
|
+
return sendOk(
|
|
214
|
+
res,
|
|
215
|
+
toIssueResponse(issue as unknown as Record<string, unknown>, parsed.data.goalIds)
|
|
216
|
+
);
|
|
256
217
|
});
|
|
257
218
|
|
|
258
219
|
router.post("/:issueId/attachments", async (req, res) => {
|
|
259
|
-
|
|
260
|
-
if (res.headersSent) {
|
|
261
|
-
return;
|
|
262
|
-
}
|
|
220
|
+
if (!enforcePermission(req, res, "issues:write")) return;
|
|
263
221
|
|
|
264
222
|
upload.array("files", MAX_ATTACHMENTS_PER_REQUEST)(req, res, async (uploadError) => {
|
|
265
223
|
if (uploadError) {
|
|
@@ -406,10 +364,7 @@ export function createIssuesRouter(ctx: AppContext) {
|
|
|
406
364
|
});
|
|
407
365
|
|
|
408
366
|
router.delete("/:issueId/attachments/:attachmentId", async (req, res) => {
|
|
409
|
-
|
|
410
|
-
if (res.headersSent) {
|
|
411
|
-
return;
|
|
412
|
-
}
|
|
367
|
+
if (!enforcePermission(req, res, "issues:write")) return;
|
|
413
368
|
const issueContext = await getIssueContextForAttachment(ctx, req.companyId!, req.params.issueId);
|
|
414
369
|
if (!issueContext) {
|
|
415
370
|
return sendError(res, "Issue not found.", 404);
|
|
@@ -465,10 +420,7 @@ export function createIssuesRouter(ctx: AppContext) {
|
|
|
465
420
|
});
|
|
466
421
|
|
|
467
422
|
router.post("/:issueId/comments", async (req, res) => {
|
|
468
|
-
|
|
469
|
-
if (res.headersSent) {
|
|
470
|
-
return;
|
|
471
|
-
}
|
|
423
|
+
if (!enforcePermission(req, res, "issues:write")) return;
|
|
472
424
|
const parsed = createIssueCommentSchema.safeParse(req.body);
|
|
473
425
|
if (!parsed.success) {
|
|
474
426
|
return sendError(res, parsed.error.message, 422);
|
|
@@ -484,10 +436,7 @@ export function createIssuesRouter(ctx: AppContext) {
|
|
|
484
436
|
|
|
485
437
|
// Backward-compatible endpoint used by older clients.
|
|
486
438
|
router.post("/comment", async (req, res) => {
|
|
487
|
-
|
|
488
|
-
if (res.headersSent) {
|
|
489
|
-
return;
|
|
490
|
-
}
|
|
439
|
+
if (!enforcePermission(req, res, "issues:write")) return;
|
|
491
440
|
const parsed = createIssueCommentLegacySchema.safeParse(req.body);
|
|
492
441
|
if (!parsed.success) {
|
|
493
442
|
return sendError(res, parsed.error.message, 422);
|
|
@@ -502,10 +451,7 @@ export function createIssuesRouter(ctx: AppContext) {
|
|
|
502
451
|
});
|
|
503
452
|
|
|
504
453
|
router.put("/:issueId/comments/:commentId", async (req, res) => {
|
|
505
|
-
|
|
506
|
-
if (res.headersSent) {
|
|
507
|
-
return;
|
|
508
|
-
}
|
|
454
|
+
if (!enforcePermission(req, res, "issues:write")) return;
|
|
509
455
|
const parsed = updateIssueCommentSchema.safeParse(req.body);
|
|
510
456
|
if (!parsed.success) {
|
|
511
457
|
return sendError(res, parsed.error.message, 422);
|
|
@@ -547,10 +493,7 @@ export function createIssuesRouter(ctx: AppContext) {
|
|
|
547
493
|
});
|
|
548
494
|
|
|
549
495
|
router.delete("/:issueId/comments/:commentId", async (req, res) => {
|
|
550
|
-
|
|
551
|
-
if (res.headersSent) {
|
|
552
|
-
return;
|
|
553
|
-
}
|
|
496
|
+
if (!enforcePermission(req, res, "issues:write")) return;
|
|
554
497
|
const deleted = await deleteIssueComment(ctx.db, req.companyId!, req.params.issueId, req.params.commentId);
|
|
555
498
|
if (!deleted) {
|
|
556
499
|
return sendError(res, "Comment not found.", 404);
|
|
@@ -577,10 +520,7 @@ export function createIssuesRouter(ctx: AppContext) {
|
|
|
577
520
|
});
|
|
578
521
|
|
|
579
522
|
router.put("/:issueId", async (req, res) => {
|
|
580
|
-
|
|
581
|
-
if (res.headersSent) {
|
|
582
|
-
return;
|
|
583
|
-
}
|
|
523
|
+
if (!enforcePermission(req, res, "issues:write")) return;
|
|
584
524
|
const parsed = updateIssueSchema.safeParse(req.body);
|
|
585
525
|
if (!parsed.success) {
|
|
586
526
|
return sendError(res, parsed.error.message, 422);
|
|
@@ -614,7 +554,11 @@ export function createIssuesRouter(ctx: AppContext) {
|
|
|
614
554
|
}
|
|
615
555
|
}
|
|
616
556
|
|
|
617
|
-
const
|
|
557
|
+
const updateBody = { ...parsed.data };
|
|
558
|
+
if (updateBody.externalLink !== undefined) {
|
|
559
|
+
updateBody.externalLink = normalizeOptionalExternalLink(updateBody.externalLink) ?? null;
|
|
560
|
+
}
|
|
561
|
+
const issue = await updateIssue(ctx.db, { companyId: req.companyId!, id: req.params.issueId, ...updateBody });
|
|
618
562
|
if (!issue) {
|
|
619
563
|
return sendError(res, "Issue not found.", 404);
|
|
620
564
|
}
|
|
@@ -634,14 +578,15 @@ export function createIssuesRouter(ctx: AppContext) {
|
|
|
634
578
|
entityId: issue.id,
|
|
635
579
|
payload: issue
|
|
636
580
|
});
|
|
637
|
-
|
|
581
|
+
const goalMap = await listIssueGoalIdsBatch(ctx.db, req.companyId!, [issue.id]);
|
|
582
|
+
return sendOk(
|
|
583
|
+
res,
|
|
584
|
+
toIssueResponse(issue as unknown as Record<string, unknown>, goalMap.get(issue.id) ?? [])
|
|
585
|
+
);
|
|
638
586
|
});
|
|
639
587
|
|
|
640
588
|
router.delete("/:issueId", async (req, res) => {
|
|
641
|
-
|
|
642
|
-
if (res.headersSent) {
|
|
643
|
-
return;
|
|
644
|
-
}
|
|
589
|
+
if (!enforcePermission(req, res, "issues:write")) return;
|
|
645
590
|
const deleted = await deleteIssue(ctx.db, req.companyId!, req.params.issueId);
|
|
646
591
|
if (!deleted) {
|
|
647
592
|
return sendError(res, "Issue not found.", 404);
|
|
@@ -292,7 +292,12 @@ export function createObservabilityRouter(ctx: AppContext) {
|
|
|
292
292
|
.filter((goal) => goal.status === "active" && goal.level === "project" && goal.projectId && projectIds.includes(goal.projectId))
|
|
293
293
|
.map((goal) => goal.title);
|
|
294
294
|
const activeAgentGoals = goals
|
|
295
|
-
.filter(
|
|
295
|
+
.filter(
|
|
296
|
+
(goal) =>
|
|
297
|
+
goal.status === "active" &&
|
|
298
|
+
goal.level === "agent" &&
|
|
299
|
+
(!goal.ownerAgentId || goal.ownerAgentId === agentId)
|
|
300
|
+
)
|
|
296
301
|
.map((goal) => goal.title);
|
|
297
302
|
const compiledPreview = [
|
|
298
303
|
`Agent: ${agent.name} (${agent.role})`,
|
package/src/routes/plugins.ts
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
import type { AppContext } from "../context";
|
|
15
15
|
import { sendError, sendOk } from "../http";
|
|
16
16
|
import { requireCompanyScope } from "../middleware/company-scope";
|
|
17
|
-
import {
|
|
17
|
+
import { enforcePermission, requireBoardRole } from "../middleware/request-actor";
|
|
18
18
|
import { deletePluginManifestFromFilesystem, writePluginManifestToFilesystem } from "../services/plugin-manifest-loader";
|
|
19
19
|
import { registerPluginManifest } from "../services/plugin-runtime";
|
|
20
20
|
|
|
@@ -74,10 +74,7 @@ export function createPluginsRouter(ctx: AppContext) {
|
|
|
74
74
|
});
|
|
75
75
|
|
|
76
76
|
router.put("/:pluginId", async (req, res) => {
|
|
77
|
-
|
|
78
|
-
if (res.headersSent) {
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
77
|
+
if (!enforcePermission(req, res, "plugins:write")) return;
|
|
81
78
|
const parsed = pluginConfigSchema.safeParse(req.body);
|
|
82
79
|
if (!parsed.success) {
|
|
83
80
|
return sendError(res, parsed.error.message, 422);
|
|
@@ -123,10 +120,7 @@ export function createPluginsRouter(ctx: AppContext) {
|
|
|
123
120
|
});
|
|
124
121
|
|
|
125
122
|
router.post("/install-from-json", async (req, res) => {
|
|
126
|
-
|
|
127
|
-
if (res.headersSent) {
|
|
128
|
-
return;
|
|
129
|
-
}
|
|
123
|
+
if (!enforcePermission(req, res, "plugins:write")) return;
|
|
130
124
|
const parsed = pluginManifestCreateSchema.safeParse(req.body);
|
|
131
125
|
if (!parsed.success) {
|
|
132
126
|
return sendError(res, parsed.error.message, 422);
|
|
@@ -164,10 +158,7 @@ export function createPluginsRouter(ctx: AppContext) {
|
|
|
164
158
|
});
|
|
165
159
|
|
|
166
160
|
router.post("/:pluginId/install", async (req, res) => {
|
|
167
|
-
|
|
168
|
-
if (res.headersSent) {
|
|
169
|
-
return;
|
|
170
|
-
}
|
|
161
|
+
if (!enforcePermission(req, res, "plugins:write")) return;
|
|
171
162
|
const pluginId = readPluginIdParam(req.params.pluginId);
|
|
172
163
|
if (!pluginId) {
|
|
173
164
|
return sendError(res, "Missing plugin id.", 422);
|
|
@@ -193,10 +184,7 @@ export function createPluginsRouter(ctx: AppContext) {
|
|
|
193
184
|
});
|
|
194
185
|
|
|
195
186
|
router.delete("/:pluginId/install", async (req, res) => {
|
|
196
|
-
|
|
197
|
-
if (res.headersSent) {
|
|
198
|
-
return;
|
|
199
|
-
}
|
|
187
|
+
if (!enforcePermission(req, res, "plugins:write")) return;
|
|
200
188
|
const pluginId = readPluginIdParam(req.params.pluginId);
|
|
201
189
|
if (!pluginId) {
|
|
202
190
|
return sendError(res, "Missing plugin id.", 422);
|
package/src/routes/projects.ts
CHANGED
|
@@ -19,7 +19,7 @@ import type { AppContext } from "../context";
|
|
|
19
19
|
import { sendError, sendOk, sendOkValidated } from "../http";
|
|
20
20
|
import { normalizeCompanyWorkspacePath, resolveProjectWorkspacePath } from "../lib/instance-paths";
|
|
21
21
|
import { requireCompanyScope } from "../middleware/company-scope";
|
|
22
|
-
import {
|
|
22
|
+
import { enforcePermission } from "../middleware/request-actor";
|
|
23
23
|
|
|
24
24
|
const projectStatusSchema = z.enum(["planned", "active", "paused", "blocked", "completed", "archived"]);
|
|
25
25
|
const executionWorkspacePolicySchema = z
|
|
@@ -129,10 +129,7 @@ export function createProjectsRouter(ctx: AppContext) {
|
|
|
129
129
|
});
|
|
130
130
|
|
|
131
131
|
router.post("/", async (req, res) => {
|
|
132
|
-
|
|
133
|
-
if (res.headersSent) {
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
132
|
+
if (!enforcePermission(req, res, "projects:write")) return;
|
|
136
133
|
const parsed = createProjectSchema.safeParse(req.body);
|
|
137
134
|
if (!parsed.success) {
|
|
138
135
|
return sendError(res, parsed.error.message, 422);
|
|
@@ -203,10 +200,7 @@ export function createProjectsRouter(ctx: AppContext) {
|
|
|
203
200
|
});
|
|
204
201
|
|
|
205
202
|
router.put("/:projectId", async (req, res) => {
|
|
206
|
-
|
|
207
|
-
if (res.headersSent) {
|
|
208
|
-
return;
|
|
209
|
-
}
|
|
203
|
+
if (!enforcePermission(req, res, "projects:write")) return;
|
|
210
204
|
const parsed = updateProjectSchema.safeParse(req.body);
|
|
211
205
|
if (!parsed.success) {
|
|
212
206
|
return sendError(res, parsed.error.message, 422);
|
|
@@ -257,10 +251,7 @@ export function createProjectsRouter(ctx: AppContext) {
|
|
|
257
251
|
});
|
|
258
252
|
|
|
259
253
|
router.post("/:projectId/workspaces", async (req, res) => {
|
|
260
|
-
|
|
261
|
-
if (res.headersSent) {
|
|
262
|
-
return;
|
|
263
|
-
}
|
|
254
|
+
if (!enforcePermission(req, res, "projects:write")) return;
|
|
264
255
|
const parsed = createProjectWorkspaceSchema.safeParse(req.body);
|
|
265
256
|
if (!parsed.success) {
|
|
266
257
|
return sendError(res, parsed.error.message, 422);
|
|
@@ -306,10 +297,7 @@ export function createProjectsRouter(ctx: AppContext) {
|
|
|
306
297
|
});
|
|
307
298
|
|
|
308
299
|
router.put("/:projectId/workspaces/:workspaceId", async (req, res) => {
|
|
309
|
-
|
|
310
|
-
if (res.headersSent) {
|
|
311
|
-
return;
|
|
312
|
-
}
|
|
300
|
+
if (!enforcePermission(req, res, "projects:write")) return;
|
|
313
301
|
const parsed = updateProjectWorkspaceSchema.safeParse(req.body);
|
|
314
302
|
if (!parsed.success) {
|
|
315
303
|
return sendError(res, parsed.error.message, 422);
|
|
@@ -356,10 +344,7 @@ export function createProjectsRouter(ctx: AppContext) {
|
|
|
356
344
|
});
|
|
357
345
|
|
|
358
346
|
router.delete("/:projectId/workspaces/:workspaceId", async (req, res) => {
|
|
359
|
-
|
|
360
|
-
if (res.headersSent) {
|
|
361
|
-
return;
|
|
362
|
-
}
|
|
347
|
+
if (!enforcePermission(req, res, "projects:write")) return;
|
|
363
348
|
const deleted = await deleteProjectWorkspace(ctx.db, {
|
|
364
349
|
companyId: req.companyId!,
|
|
365
350
|
projectId: req.params.projectId,
|
|
@@ -380,10 +365,7 @@ export function createProjectsRouter(ctx: AppContext) {
|
|
|
380
365
|
});
|
|
381
366
|
|
|
382
367
|
router.delete("/:projectId", async (req, res) => {
|
|
383
|
-
|
|
384
|
-
if (res.headersSent) {
|
|
385
|
-
return;
|
|
386
|
-
}
|
|
368
|
+
if (!enforcePermission(req, res, "projects:write")) return;
|
|
387
369
|
const deleted = await deleteProject(ctx.db, req.companyId!, req.params.projectId);
|
|
388
370
|
if (!deleted) {
|
|
389
371
|
return sendError(res, "Project not found.", 404);
|
package/src/routes/templates.ts
CHANGED
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
import type { AppContext } from "../context";
|
|
25
25
|
import { sendError, sendOk } from "../http";
|
|
26
26
|
import { requireCompanyScope } from "../middleware/company-scope";
|
|
27
|
-
import {
|
|
27
|
+
import { enforcePermission } from "../middleware/request-actor";
|
|
28
28
|
import { applyTemplateManifest } from "../services/template-apply-service";
|
|
29
29
|
import { buildTemplatePreview } from "../services/template-preview-service";
|
|
30
30
|
|
|
@@ -39,10 +39,7 @@ export function createTemplatesRouter(ctx: AppContext) {
|
|
|
39
39
|
});
|
|
40
40
|
|
|
41
41
|
router.post("/", async (req, res) => {
|
|
42
|
-
|
|
43
|
-
if (res.headersSent) {
|
|
44
|
-
return;
|
|
45
|
-
}
|
|
42
|
+
if (!enforcePermission(req, res, "templates:write")) return;
|
|
46
43
|
const parsed = TemplateCreateRequestSchema.safeParse(req.body);
|
|
47
44
|
if (!parsed.success) {
|
|
48
45
|
return sendError(res, parsed.error.message, 422);
|
|
@@ -83,10 +80,7 @@ export function createTemplatesRouter(ctx: AppContext) {
|
|
|
83
80
|
});
|
|
84
81
|
|
|
85
82
|
router.put("/:templateId", async (req, res) => {
|
|
86
|
-
|
|
87
|
-
if (res.headersSent) {
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
83
|
+
if (!enforcePermission(req, res, "templates:write")) return;
|
|
90
84
|
const parsed = TemplateUpdateRequestSchema.safeParse(req.body);
|
|
91
85
|
if (!parsed.success) {
|
|
92
86
|
return sendError(res, parsed.error.message, 422);
|
|
@@ -138,10 +132,7 @@ export function createTemplatesRouter(ctx: AppContext) {
|
|
|
138
132
|
});
|
|
139
133
|
|
|
140
134
|
router.delete("/:templateId", async (req, res) => {
|
|
141
|
-
|
|
142
|
-
if (res.headersSent) {
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
135
|
+
if (!enforcePermission(req, res, "templates:write")) return;
|
|
145
136
|
const deleted = await deleteTemplate(ctx.db, req.companyId!, req.params.templateId);
|
|
146
137
|
if (!deleted) {
|
|
147
138
|
return sendError(res, "Template not found.", 404);
|
|
@@ -188,10 +179,7 @@ export function createTemplatesRouter(ctx: AppContext) {
|
|
|
188
179
|
});
|
|
189
180
|
|
|
190
181
|
router.post("/:templateId/apply", async (req, res) => {
|
|
191
|
-
|
|
192
|
-
if (res.headersSent) {
|
|
193
|
-
return;
|
|
194
|
-
}
|
|
182
|
+
if (!enforcePermission(req, res, "templates:write")) return;
|
|
195
183
|
const parsed = TemplateApplyRequestSchema.safeParse(req.body);
|
|
196
184
|
if (!parsed.success) {
|
|
197
185
|
return sendError(res, parsed.error.message, 422);
|
|
@@ -261,10 +249,7 @@ export function createTemplatesRouter(ctx: AppContext) {
|
|
|
261
249
|
});
|
|
262
250
|
|
|
263
251
|
router.post("/import", async (req, res) => {
|
|
264
|
-
|
|
265
|
-
if (res.headersSent) {
|
|
266
|
-
return;
|
|
267
|
-
}
|
|
252
|
+
if (!enforcePermission(req, res, "templates:write")) return;
|
|
268
253
|
const parsed = TemplateImportRequestSchema.safeParse(req.body);
|
|
269
254
|
if (!parsed.success) {
|
|
270
255
|
return sendError(res, parsed.error.message, 422);
|
|
@@ -316,18 +316,16 @@ async function ensureCeoStartupTask(
|
|
|
316
316
|
` - \`${ceoOperatingFolder}/HEARTBEAT.md\``,
|
|
317
317
|
` - \`${ceoOperatingFolder}/SOUL.md\``,
|
|
318
318
|
` - \`${ceoOperatingFolder}/TOOLS.md\``,
|
|
319
|
-
"3.
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
" - Prefer heredoc/stdin payloads (for example `curl --data-binary @- <<'JSON' ... JSON`) to avoid temp-file cleanup failures under runtime policy.",
|
|
323
|
-
` - If you must use payload files, store them in \`${ceoTmpFolder}/\` (or OS temp via \`mktemp\`) and avoid chaining cleanup commands into critical task flow.`,
|
|
319
|
+
"3. Each heartbeat already includes your operating directory via `$BOPODEV_AGENT_OPERATING_DIR` and directs you to AGENTS.md and related files there when they exist.",
|
|
320
|
+
" You do not need to save file paths into `bootstrapPrompt` for operating docs—reserve `bootstrapPrompt` only for optional extra standing instructions.",
|
|
321
|
+
` - Prefer heredoc/stdin payloads (for example \`curl --data-binary @- <<'JSON' ... JSON\`) when calling APIs; if you must use payload files, store them in \`${ceoTmpFolder}/\` (or OS temp via \`mktemp\`).`,
|
|
324
322
|
"4. To inspect your own agent record, use `GET /agents` and filter by your agent id. Do not call `GET /agents/:agentId`.",
|
|
325
323
|
" - `GET /agents` uses envelope shape `{ \"ok\": true, \"data\": [...] }`; treat any other shape as failure.",
|
|
326
|
-
" - Deterministic filter: `jq -er --arg id \"$BOPODEV_AGENT_ID\" '.data | if type==\"array\" then . else error(\"invalid_agents_payload\") end | map(select((.id? // \"\") == $id))[0] | {id,name,role
|
|
324
|
+
" - Deterministic filter: `jq -er --arg id \"$BOPODEV_AGENT_ID\" '.data | if type==\"array\" then . else error(\"invalid_agents_payload\") end | map(select((.id? // \"\") == $id))[0] | {id,name,role}'`",
|
|
327
325
|
"5. Heartbeat-assigned issues are already claimed for the current run. Do not call a checkout endpoint; update status with `PUT /issues/:issueId` only.",
|
|
328
326
|
"6. After your operating files are active, submit a hire request for a Founding Engineer via `POST /agents` using supported fields:",
|
|
329
327
|
" - `name`, `role`, `providerType`, `heartbeatCron`, `monthlyBudgetUsd`",
|
|
330
|
-
" - optional `managerAgentId`, `bootstrapPrompt
|
|
328
|
+
" - optional `managerAgentId`, `bootstrapPrompt` (extra standing instructions only), `runtimeConfig`, `canHireAgents`",
|
|
331
329
|
" - `requestApproval: true` and `sourceIssueId`",
|
|
332
330
|
"7. Do not use unsupported hire fields such as `adapterType`, `adapterConfig`, or `reportsTo`.",
|
|
333
331
|
"",
|