bopodev-api 0.1.28 → 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.
Files changed (42) hide show
  1. package/package.json +4 -4
  2. package/src/app.ts +17 -69
  3. package/src/lib/run-artifact-paths.ts +8 -0
  4. package/src/middleware/cors-config.ts +36 -0
  5. package/src/middleware/request-actor.ts +10 -16
  6. package/src/middleware/request-id.ts +9 -0
  7. package/src/middleware/request-logging.ts +24 -0
  8. package/src/routes/agents.ts +3 -9
  9. package/src/routes/companies.ts +18 -1
  10. package/src/routes/goals.ts +7 -13
  11. package/src/routes/governance.ts +2 -5
  12. package/src/routes/heartbeats.ts +7 -25
  13. package/src/routes/issues.ts +62 -120
  14. package/src/routes/observability.ts +6 -1
  15. package/src/routes/plugins.ts +5 -17
  16. package/src/routes/projects.ts +7 -25
  17. package/src/routes/templates.ts +6 -21
  18. package/src/scripts/onboard-seed.ts +5 -7
  19. package/src/server.ts +33 -292
  20. package/src/services/company-export-service.ts +63 -0
  21. package/src/services/governance-service.ts +4 -1
  22. package/src/services/heartbeat-service/active-runs.ts +15 -0
  23. package/src/services/heartbeat-service/budget-override.ts +46 -0
  24. package/src/services/heartbeat-service/claims.ts +61 -0
  25. package/src/services/heartbeat-service/cron.ts +58 -0
  26. package/src/services/heartbeat-service/heartbeat-realtime.ts +28 -0
  27. package/src/services/heartbeat-service/heartbeat-run-summary-text.ts +53 -0
  28. package/src/services/{heartbeat-service.ts → heartbeat-service/heartbeat-run.ts} +183 -633
  29. package/src/services/heartbeat-service/index.ts +5 -0
  30. package/src/services/heartbeat-service/stop.ts +90 -0
  31. package/src/services/heartbeat-service/sweep.ts +145 -0
  32. package/src/services/heartbeat-service/types.ts +65 -0
  33. package/src/services/memory-file-service.ts +10 -2
  34. package/src/shutdown/graceful-shutdown.ts +77 -0
  35. package/src/startup/database.ts +41 -0
  36. package/src/startup/deployment-validation.ts +37 -0
  37. package/src/startup/env.ts +17 -0
  38. package/src/startup/runtime-health.ts +128 -0
  39. package/src/startup/scheduler-config.ts +39 -0
  40. package/src/types/express.d.ts +13 -0
  41. package/src/types/request-actor.ts +6 -0
  42. package/src/validation/issue-routes.ts +79 -0
@@ -2,7 +2,6 @@ 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
4
  import multer from "multer";
5
- import { z } from "zod";
6
5
  import { IssueDetailSchema, IssueSchema } from "bopodev-contracts";
7
6
  import {
8
7
  addIssueAttachment,
@@ -25,6 +24,7 @@ import {
25
24
  listIssueAttachments,
26
25
  listIssueActivity,
27
26
  listIssueComments,
27
+ listIssueGoalIdsBatch,
28
28
  listIssues,
29
29
  projects,
30
30
  projectWorkspaces,
@@ -42,70 +42,16 @@ import {
42
42
  } from "../lib/comment-recipients";
43
43
  import { isInsidePath, normalizeCompanyWorkspacePath, resolveProjectWorkspacePath } from "../lib/instance-paths";
44
44
  import { requireCompanyScope } from "../middleware/company-scope";
45
- import { requirePermission } from "../middleware/request-actor";
45
+ import { enforcePermission } from "../middleware/request-actor";
46
46
  import { triggerIssueCommentDispatchWorker } from "../services/comment-recipient-dispatch-service";
47
47
  import { publishAttentionSnapshot } from "../realtime/attention";
48
-
49
- const createIssueSchema = z.object({
50
- projectId: z.string().min(1),
51
- parentIssueId: z.string().optional(),
52
- title: z.string().min(1),
53
- body: z.string().optional(),
54
- metadata: z
55
- .object({
56
- delegatedHiringIntent: z
57
- .object({
58
- intentType: z.literal("agent_hiring_request"),
59
- requestedRole: z.string().nullable().optional(),
60
- requestedRoleKey: z.string().nullable().optional(),
61
- requestedTitle: z.string().nullable().optional(),
62
- requestedName: z.string().nullable().optional(),
63
- requestedManagerAgentId: z.string().nullable().optional(),
64
- requestedProviderType: z.string().nullable().optional(),
65
- requestedRuntimeModel: z.string().nullable().optional()
66
- })
67
- .optional()
68
- })
69
- .optional(),
70
- status: z.enum(["todo", "in_progress", "blocked", "in_review", "done", "canceled"]).default("todo"),
71
- priority: z.enum(["none", "low", "medium", "high", "urgent"]).default("none"),
72
- assigneeAgentId: z.string().nullable().optional(),
73
- labels: z.array(z.string()).default([]),
74
- tags: z.array(z.string()).default([])
75
- });
76
-
77
- const createIssueCommentSchema = z.object({
78
- body: z.string().min(1),
79
- recipients: z
80
- .array(
81
- z.object({
82
- recipientType: z.enum(["agent", "board", "member"]),
83
- recipientId: z.string().nullable().optional()
84
- })
85
- )
86
- .default([]),
87
- authorType: z.enum(["human", "agent", "system"]).optional(),
88
- authorId: z.string().optional()
89
- });
90
-
91
- const createIssueCommentLegacySchema = z.object({
92
- issueId: z.string().min(1),
93
- body: z.string().min(1),
94
- recipients: z
95
- .array(
96
- z.object({
97
- recipientType: z.enum(["agent", "board", "member"]),
98
- recipientId: z.string().nullable().optional()
99
- })
100
- )
101
- .default([]),
102
- authorType: z.enum(["human", "agent", "system"]).optional(),
103
- authorId: z.string().optional()
104
- });
105
-
106
- const updateIssueCommentSchema = z.object({
107
- body: z.string().min(1)
108
- });
48
+ import {
49
+ createIssueCommentLegacySchema,
50
+ createIssueCommentSchema,
51
+ createIssueSchema,
52
+ updateIssueCommentSchema,
53
+ updateIssueSchema
54
+ } from "../validation/issue-routes";
109
55
 
110
56
  const MAX_ATTACHMENTS_PER_REQUEST = parsePositiveIntEnv("BOPO_ISSUE_ATTACHMENTS_MAX_FILES", 10);
111
57
  const MAX_ATTACHMENT_SIZE_BYTES = parsePositiveIntEnv("BOPO_ISSUE_ATTACHMENTS_MAX_BYTES", 20 * 1024 * 1024);
@@ -148,30 +94,32 @@ function parseStringArray(value: unknown) {
148
94
  }
149
95
  }
150
96
 
151
- function toIssueResponse(issue: Record<string, unknown>) {
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[] = []) {
152
106
  const labels = parseStringArray(issue.labelsJson);
153
107
  const tags = parseStringArray(issue.tagsJson);
154
- 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;
155
114
  return {
156
115
  ...rest,
116
+ externalLink,
157
117
  labels,
158
- tags
118
+ tags,
119
+ goalIds
159
120
  };
160
121
  }
161
122
 
162
- const updateIssueSchema = z
163
- .object({
164
- projectId: z.string().min(1).optional(),
165
- title: z.string().min(1).optional(),
166
- body: z.string().nullable().optional(),
167
- status: z.enum(["todo", "in_progress", "blocked", "in_review", "done", "canceled"]).optional(),
168
- priority: z.enum(["none", "low", "medium", "high", "urgent"]).optional(),
169
- assigneeAgentId: z.string().nullable().optional(),
170
- labels: z.array(z.string()).optional(),
171
- tags: z.array(z.string()).optional()
172
- })
173
- .refine((payload) => Object.keys(payload).length > 0, "At least one field must be provided.");
174
-
175
123
  export function createIssuesRouter(ctx: AppContext) {
176
124
  const router = Router();
177
125
  const upload = multer({
@@ -186,10 +134,17 @@ export function createIssuesRouter(ctx: AppContext) {
186
134
  router.get("/", async (req, res) => {
187
135
  const projectId = req.query.projectId?.toString();
188
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
+ );
189
142
  return sendOkValidated(
190
143
  res,
191
144
  IssueSchema.array(),
192
- rows.map((row) => toIssueResponse(row as unknown as Record<string, unknown>)),
145
+ rows.map((row) =>
146
+ toIssueResponse(row as unknown as Record<string, unknown>, goalMap.get(row.id) ?? [])
147
+ ),
193
148
  "issues.list"
194
149
  );
195
150
  });
@@ -200,7 +155,8 @@ export function createIssuesRouter(ctx: AppContext) {
200
155
  if (!issueRow) {
201
156
  return sendError(res, "Issue not found.", 404);
202
157
  }
203
- const base = toIssueResponse(issueRow as unknown as Record<string, unknown>);
158
+ const goalMap = await listIssueGoalIdsBatch(ctx.db, req.companyId!, [issueId]);
159
+ const base = toIssueResponse(issueRow as unknown as Record<string, unknown>, goalMap.get(issueId) ?? []);
204
160
  const attachmentRows = await listIssueAttachments(ctx.db, req.companyId!, issueId);
205
161
  const attachments = attachmentRows.map((row) =>
206
162
  toIssueAttachmentResponse(row as unknown as Record<string, unknown>, issueId)
@@ -209,10 +165,7 @@ export function createIssuesRouter(ctx: AppContext) {
209
165
  });
210
166
 
211
167
  router.post("/", async (req, res) => {
212
- requirePermission("issues:write")(req, res, () => {});
213
- if (res.headersSent) {
214
- return;
215
- }
168
+ if (!enforcePermission(req, res, "issues:write")) return;
216
169
  const parsed = createIssueSchema.safeParse(req.body);
217
170
  if (!parsed.success) {
218
171
  return sendError(res, parsed.error.message, 422);
@@ -232,8 +185,10 @@ export function createIssuesRouter(ctx: AppContext) {
232
185
  companyId: req.companyId!,
233
186
  projectId: parsed.data.projectId,
234
187
  parentIssueId: parsed.data.parentIssueId,
188
+ goalIds: parsed.data.goalIds,
235
189
  title: parsed.data.title,
236
190
  body: applyIssueMetadataToBody(parsed.data.body, parsed.data.metadata),
191
+ externalLink: normalizeOptionalExternalLink(parsed.data.externalLink ?? null),
237
192
  status: parsed.data.status,
238
193
  priority: parsed.data.priority,
239
194
  assigneeAgentId: parsed.data.assigneeAgentId,
@@ -255,14 +210,14 @@ export function createIssuesRouter(ctx: AppContext) {
255
210
  entityId: issue.id,
256
211
  payload: issue
257
212
  });
258
- return sendOk(res, toIssueResponse(issue as unknown as Record<string, unknown>));
213
+ return sendOk(
214
+ res,
215
+ toIssueResponse(issue as unknown as Record<string, unknown>, parsed.data.goalIds)
216
+ );
259
217
  });
260
218
 
261
219
  router.post("/:issueId/attachments", async (req, res) => {
262
- requirePermission("issues:write")(req, res, () => {});
263
- if (res.headersSent) {
264
- return;
265
- }
220
+ if (!enforcePermission(req, res, "issues:write")) return;
266
221
 
267
222
  upload.array("files", MAX_ATTACHMENTS_PER_REQUEST)(req, res, async (uploadError) => {
268
223
  if (uploadError) {
@@ -409,10 +364,7 @@ export function createIssuesRouter(ctx: AppContext) {
409
364
  });
410
365
 
411
366
  router.delete("/:issueId/attachments/:attachmentId", async (req, res) => {
412
- requirePermission("issues:write")(req, res, () => {});
413
- if (res.headersSent) {
414
- return;
415
- }
367
+ if (!enforcePermission(req, res, "issues:write")) return;
416
368
  const issueContext = await getIssueContextForAttachment(ctx, req.companyId!, req.params.issueId);
417
369
  if (!issueContext) {
418
370
  return sendError(res, "Issue not found.", 404);
@@ -468,10 +420,7 @@ export function createIssuesRouter(ctx: AppContext) {
468
420
  });
469
421
 
470
422
  router.post("/:issueId/comments", async (req, res) => {
471
- requirePermission("issues:write")(req, res, () => {});
472
- if (res.headersSent) {
473
- return;
474
- }
423
+ if (!enforcePermission(req, res, "issues:write")) return;
475
424
  const parsed = createIssueCommentSchema.safeParse(req.body);
476
425
  if (!parsed.success) {
477
426
  return sendError(res, parsed.error.message, 422);
@@ -487,10 +436,7 @@ export function createIssuesRouter(ctx: AppContext) {
487
436
 
488
437
  // Backward-compatible endpoint used by older clients.
489
438
  router.post("/comment", async (req, res) => {
490
- requirePermission("issues:write")(req, res, () => {});
491
- if (res.headersSent) {
492
- return;
493
- }
439
+ if (!enforcePermission(req, res, "issues:write")) return;
494
440
  const parsed = createIssueCommentLegacySchema.safeParse(req.body);
495
441
  if (!parsed.success) {
496
442
  return sendError(res, parsed.error.message, 422);
@@ -505,10 +451,7 @@ export function createIssuesRouter(ctx: AppContext) {
505
451
  });
506
452
 
507
453
  router.put("/:issueId/comments/:commentId", async (req, res) => {
508
- requirePermission("issues:write")(req, res, () => {});
509
- if (res.headersSent) {
510
- return;
511
- }
454
+ if (!enforcePermission(req, res, "issues:write")) return;
512
455
  const parsed = updateIssueCommentSchema.safeParse(req.body);
513
456
  if (!parsed.success) {
514
457
  return sendError(res, parsed.error.message, 422);
@@ -550,10 +493,7 @@ export function createIssuesRouter(ctx: AppContext) {
550
493
  });
551
494
 
552
495
  router.delete("/:issueId/comments/:commentId", async (req, res) => {
553
- requirePermission("issues:write")(req, res, () => {});
554
- if (res.headersSent) {
555
- return;
556
- }
496
+ if (!enforcePermission(req, res, "issues:write")) return;
557
497
  const deleted = await deleteIssueComment(ctx.db, req.companyId!, req.params.issueId, req.params.commentId);
558
498
  if (!deleted) {
559
499
  return sendError(res, "Comment not found.", 404);
@@ -580,10 +520,7 @@ export function createIssuesRouter(ctx: AppContext) {
580
520
  });
581
521
 
582
522
  router.put("/:issueId", async (req, res) => {
583
- requirePermission("issues:write")(req, res, () => {});
584
- if (res.headersSent) {
585
- return;
586
- }
523
+ if (!enforcePermission(req, res, "issues:write")) return;
587
524
  const parsed = updateIssueSchema.safeParse(req.body);
588
525
  if (!parsed.success) {
589
526
  return sendError(res, parsed.error.message, 422);
@@ -617,7 +554,11 @@ export function createIssuesRouter(ctx: AppContext) {
617
554
  }
618
555
  }
619
556
 
620
- const issue = await updateIssue(ctx.db, { companyId: req.companyId!, id: req.params.issueId, ...parsed.data });
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 });
621
562
  if (!issue) {
622
563
  return sendError(res, "Issue not found.", 404);
623
564
  }
@@ -637,14 +578,15 @@ export function createIssuesRouter(ctx: AppContext) {
637
578
  entityId: issue.id,
638
579
  payload: issue
639
580
  });
640
- return sendOk(res, toIssueResponse(issue as unknown as Record<string, unknown>));
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
+ );
641
586
  });
642
587
 
643
588
  router.delete("/:issueId", async (req, res) => {
644
- requirePermission("issues:write")(req, res, () => {});
645
- if (res.headersSent) {
646
- return;
647
- }
589
+ if (!enforcePermission(req, res, "issues:write")) return;
648
590
  const deleted = await deleteIssue(ctx.db, req.companyId!, req.params.issueId);
649
591
  if (!deleted) {
650
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((goal) => goal.status === "active" && goal.level === "agent")
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})`,
@@ -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 { requireBoardRole, requirePermission } from "../middleware/request-actor";
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
- requirePermission("plugins:write")(req, res, () => {});
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
- requirePermission("plugins:write")(req, res, () => {});
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
- requirePermission("plugins:write")(req, res, () => {});
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
- requirePermission("plugins:write")(req, res, () => {});
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);
@@ -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 { requirePermission } from "../middleware/request-actor";
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
- requirePermission("projects:write")(req, res, () => {});
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
- requirePermission("projects:write")(req, res, () => {});
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
- requirePermission("projects:write")(req, res, () => {});
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
- requirePermission("projects:write")(req, res, () => {});
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
- requirePermission("projects:write")(req, res, () => {});
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
- requirePermission("projects:write")(req, res, () => {});
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);
@@ -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 { requirePermission } from "../middleware/request-actor";
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
- requirePermission("templates:write")(req, res, () => {});
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
- requirePermission("templates:write")(req, res, () => {});
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
- requirePermission("templates:write")(req, res, () => {});
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
- requirePermission("templates:write")(req, res, () => {});
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
- requirePermission("templates:write")(req, res, () => {});
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. Save your operating-file reference on your own agent record via `PUT /agents/:agentId`.",
320
- ` - Supported simple body: \`{ "bootstrapPrompt": "Primary operating reference: ${ceoOperatingFolder}/AGENTS.md ..." }\``,
321
- " - If using `runtimeConfig`, only `runtimeConfig.bootstrapPrompt` is supported there.",
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,bootstrapPrompt}'`",
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`, `runtimeConfig`, `canHireAgents`",
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
  "",