bopodev-api 0.1.25 → 0.1.27

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.
@@ -26,12 +26,10 @@ import { normalizeRuntimeConfig, runtimeConfigToDb, runtimeConfigToStateBlobPatc
26
26
  import {
27
27
  normalizeAbsolutePath,
28
28
  normalizeCompanyWorkspacePath,
29
- resolveAgentFallbackWorkspacePath,
30
29
  resolveProjectWorkspacePath
31
30
  } from "../lib/instance-paths";
32
31
  import { buildDefaultCeoBootstrapPrompt } from "../lib/ceo-bootstrap-prompt";
33
32
  import { resolveDefaultRuntimeCwdForCompany } from "../lib/workspace-policy";
34
- import { ensureCompanyModelPricingDefaults } from "../services/model-pricing";
35
33
  import { applyTemplateManifest } from "../services/template-apply-service";
36
34
  import { ensureCompanyBuiltinTemplateDefaults } from "../services/template-catalog";
37
35
 
@@ -212,8 +210,6 @@ export async function ensureOnboardingSeed(input: {
212
210
  templateApplied = true;
213
211
  appliedTemplateId = template.id;
214
212
  }
215
- await ensureCompanyModelPricingDefaults(db, companyId);
216
-
217
213
  return {
218
214
  companyId,
219
215
  companyName: resolvedCompanyName,
@@ -305,15 +301,16 @@ async function ensureCeoStartupTask(
305
301
  typeof issue.body === "string" &&
306
302
  issue.body.includes(CEO_STARTUP_TASK_MARKER)
307
303
  );
308
- const ceoWorkspaceRoot = resolveAgentFallbackWorkspacePath(input.companyId, input.ceoId);
309
- const ceoOperatingFolder = `${ceoWorkspaceRoot}/operating`;
310
- const ceoTmpFolder = `${ceoWorkspaceRoot}/tmp`;
304
+ const companyScopedCeoRoot = `workspace/${input.companyId}/agents/${input.ceoId}`;
305
+ const ceoOperatingFolder = `${companyScopedCeoRoot}/operating`;
306
+ const ceoTmpFolder = `${companyScopedCeoRoot}/tmp`;
311
307
  const body = [
312
308
  CEO_STARTUP_TASK_MARKER,
313
309
  "",
314
310
  "Stand up your leadership operating baseline before taking on additional delivery work.",
315
311
  "",
316
- `1. Create your operating folder at \`${ceoOperatingFolder}/\` (system path, outside project workspaces).`,
312
+ `1. Create your operating folder at \`${ceoOperatingFolder}/\`.`,
313
+ " During heartbeats, prefer the absolute path in `$BOPODEV_AGENT_OPERATING_DIR` (set by the runtime) so files land under your agent folder even when the shell cwd is a project workspace.",
317
314
  "2. Author these files with your own voice and responsibilities:",
318
315
  ` - \`${ceoOperatingFolder}/AGENTS.md\``,
319
316
  ` - \`${ceoOperatingFolder}/HEARTBEAT.md\``,
@@ -335,7 +332,7 @@ async function ensureCeoStartupTask(
335
332
  "7. Do not use unsupported hire fields such as `adapterType`, `adapterConfig`, or `reportsTo`.",
336
333
  "",
337
334
  "Safety checks before requesting hire:",
338
- "- Do not write operating/system files under any project workspace folder.",
335
+ `- Keep operating/system files inside \`workspace/${input.companyId}/agents/${input.ceoId}/\` only.`,
339
336
  "- Do not request duplicates if a Founding Engineer already exists.",
340
337
  "- Do not request duplicates if a pending approval for the same role is already open.",
341
338
  "- For control-plane calls, prefer direct header env vars (`BOPODEV_COMPANY_ID`, `BOPODEV_ACTOR_TYPE`, `BOPODEV_ACTOR_ID`, `BOPODEV_ACTOR_COMPANIES`, `BOPODEV_ACTOR_PERMISSIONS`) instead of parsing `BOPODEV_REQUEST_HEADERS_JSON`.",
package/src/server.ts CHANGED
@@ -3,7 +3,7 @@ import { dirname, resolve } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { sql } from "drizzle-orm";
5
5
  import { config as loadDotenv } from "dotenv";
6
- import { bootstrapDatabase, listCompanies } from "bopodev-db";
6
+ import { bootstrapDatabase, listCompanies, resolveDefaultDbPath } from "bopodev-db";
7
7
  import { checkRuntimeCommandHealth } from "bopodev-agent-sdk";
8
8
  import type { RuntimeCommandHealth } from "bopodev-agent-sdk";
9
9
  import { createApp } from "./app";
@@ -33,7 +33,28 @@ async function main() {
33
33
  validateDeploymentConfiguration(deploymentMode, allowedOrigins, allowedHostnames, publicBaseUrl);
34
34
  const dbPath = normalizeOptionalDbPath(process.env.BOPO_DB_PATH);
35
35
  const port = Number(process.env.PORT ?? 4020);
36
- const { db } = await bootstrapDatabase(dbPath);
36
+ const effectiveDbPath = dbPath ?? resolveDefaultDbPath();
37
+ let db: Awaited<ReturnType<typeof bootstrapDatabase>>["db"];
38
+ let dbClient: Awaited<ReturnType<typeof bootstrapDatabase>>["client"];
39
+ try {
40
+ const boot = await bootstrapDatabase(dbPath);
41
+ db = boot.db;
42
+ dbClient = boot.client;
43
+ } catch (error) {
44
+ if (isProbablyPgliteWasmAbort(error)) {
45
+ // eslint-disable-next-line no-console
46
+ console.error(
47
+ "[startup] PGlite (embedded Postgres) failed during database bootstrap. This is unrelated to Codex or heartbeat prompt settings."
48
+ );
49
+ // eslint-disable-next-line no-console
50
+ console.error(`[startup] Data path in use: ${effectiveDbPath}`);
51
+ // eslint-disable-next-line no-console
52
+ console.error(
53
+ "[startup] Recovery: stop all API/node processes using this DB, back up the path above, delete the file/dir, then restart (schema will be recreated). Or set BOPO_DB_PATH to a fresh path. See docs/operations/troubleshooting.md (PGlite)."
54
+ );
55
+ }
56
+ throw error;
57
+ }
37
58
  const existingCompanies = await listCompanies(db);
38
59
  await ensureBuiltinPluginsRegistered(
39
60
  db,
@@ -120,16 +141,69 @@ async function main() {
120
141
 
121
142
  const defaultCompanyId = process.env.BOPO_DEFAULT_COMPANY_ID;
122
143
  const schedulerCompanyId = await resolveSchedulerCompanyId(db, defaultCompanyId ?? null);
144
+ let stopScheduler: (() => void) | undefined;
123
145
  if (schedulerCompanyId && shouldStartScheduler()) {
124
- createHeartbeatScheduler(db, schedulerCompanyId, realtimeHub);
146
+ stopScheduler = createHeartbeatScheduler(db, schedulerCompanyId, realtimeHub);
125
147
  } else if (schedulerCompanyId) {
126
148
  // eslint-disable-next-line no-console
127
149
  console.log("[startup] Scheduler disabled for this instance (BOPO_SCHEDULER_ROLE is follower/off).");
128
150
  }
151
+
152
+ let shutdownInFlight: Promise<void> | null = null;
153
+ function shutdown(signal: string) {
154
+ shutdownInFlight ??= (async () => {
155
+ // eslint-disable-next-line no-console
156
+ console.log(`[shutdown] ${signal} — closing realtime, HTTP server, and embedded DB…`);
157
+ stopScheduler?.();
158
+ try {
159
+ await realtimeHub.close();
160
+ } catch (closeError) {
161
+ // eslint-disable-next-line no-console
162
+ console.error("[shutdown] realtime hub close error", closeError);
163
+ }
164
+ await new Promise<void>((resolve, reject) => {
165
+ server.close((err) => {
166
+ if (err) {
167
+ reject(err);
168
+ return;
169
+ }
170
+ resolve();
171
+ });
172
+ });
173
+ try {
174
+ await closePgliteClient(dbClient);
175
+ } catch (closeDbError) {
176
+ // eslint-disable-next-line no-console
177
+ console.error("[shutdown] PGlite close error", closeDbError);
178
+ }
179
+ // eslint-disable-next-line no-console
180
+ console.log("[shutdown] clean exit");
181
+ process.exit(0);
182
+ })().catch((error) => {
183
+ // eslint-disable-next-line no-console
184
+ console.error("[shutdown] failed", error);
185
+ process.exit(1);
186
+ });
187
+ return shutdownInFlight;
188
+ }
189
+
190
+ process.once("SIGINT", () => void shutdown("SIGINT"));
191
+ process.once("SIGTERM", () => void shutdown("SIGTERM"));
129
192
  }
130
193
 
131
194
  void main();
132
195
 
196
+ async function closePgliteClient(client: unknown) {
197
+ if (!client || typeof client !== "object") {
198
+ return;
199
+ }
200
+ const closeFn = (client as { close?: unknown }).close;
201
+ if (typeof closeFn !== "function") {
202
+ return;
203
+ }
204
+ await (closeFn as () => Promise<void>)();
205
+ }
206
+
133
207
  async function hasCodexAgentsConfigured(db: Awaited<ReturnType<typeof bootstrapDatabase>>["db"]) {
134
208
  const result = await db.execute(sql`
135
209
  SELECT id
@@ -262,3 +336,15 @@ function normalizeOptionalDbPath(value: string | undefined) {
262
336
  const normalized = value?.trim();
263
337
  return normalized && normalized.length > 0 ? normalized : undefined;
264
338
  }
339
+
340
+ function isProbablyPgliteWasmAbort(error: unknown): boolean {
341
+ const message = error instanceof Error ? error.message : String(error);
342
+ const cause = error instanceof Error ? error.cause : undefined;
343
+ const causeMessage = cause instanceof Error ? cause.message : String(cause ?? "");
344
+ return (
345
+ message.includes("Aborted") ||
346
+ causeMessage.includes("Aborted") ||
347
+ message.includes("pglite") ||
348
+ causeMessage.includes("RuntimeError")
349
+ );
350
+ }
@@ -17,6 +17,40 @@ import type { BoardAttentionItem } from "bopodev-contracts";
17
17
 
18
18
  type AttentionStateRow = Awaited<ReturnType<typeof listAttentionInboxStates>>[number];
19
19
 
20
+ /** Keep in sync with `RESOLVED_APPROVAL_INBOX_WINDOW_DAYS` in governance routes (resolved history in Inbox). */
21
+ const RESOLVED_APPROVAL_ATTENTION_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
22
+
23
+ type StoredApprovalRow = Awaited<ReturnType<typeof listApprovalRequests>>[number];
24
+
25
+ function approvalIncludedInAttentionList(approval: Pick<StoredApprovalRow, "status" | "resolvedAt">): boolean {
26
+ if (approval.status === "pending") {
27
+ return true;
28
+ }
29
+ if (!approval.resolvedAt) {
30
+ return false;
31
+ }
32
+ return Date.now() - approval.resolvedAt.getTime() <= RESOLVED_APPROVAL_ATTENTION_WINDOW_MS;
33
+ }
34
+
35
+ function finalizeApprovalAttentionItem(item: BoardAttentionItem, approval: Pick<StoredApprovalRow, "status" | "resolvedAt" | "action">): BoardAttentionItem {
36
+ if (approval.status === "pending") {
37
+ return item;
38
+ }
39
+ const outcome =
40
+ approval.status === "approved" ? "Approved" : approval.status === "rejected" ? "Rejected" : "Overridden";
41
+ const title =
42
+ approval.action === "override_budget" ? `Budget hard-stop · ${outcome}` : `Approval · ${outcome}`;
43
+ const resolvedAtIso = approval.resolvedAt?.toISOString() ?? item.resolvedAt;
44
+ return {
45
+ ...item,
46
+ title,
47
+ state: "resolved",
48
+ resolvedAt: resolvedAtIso,
49
+ severity: "info",
50
+ sourceTimestamp: resolvedAtIso ?? item.sourceTimestamp
51
+ };
52
+ }
53
+
20
54
  export async function listBoardAttentionItems(db: BopoDb, companyId: string, actorId: string): Promise<BoardAttentionItem[]> {
21
55
  const [approvals, blockedIssues, heartbeatRuns, stateRows, boardComments] = await Promise.all([
22
56
  listApprovalRequests(db, companyId),
@@ -42,7 +76,7 @@ export async function listBoardAttentionItems(db: BopoDb, companyId: string, act
42
76
  const items: BoardAttentionItem[] = [];
43
77
 
44
78
  for (const approval of approvals) {
45
- if (approval.status !== "pending") {
79
+ if (!approvalIncludedInAttentionList(approval)) {
46
80
  continue;
47
81
  }
48
82
  const payload = parsePayload(approval.payloadJson);
@@ -55,25 +89,61 @@ export async function listBoardAttentionItems(db: BopoDb, companyId: string, act
55
89
  const usedBudget = asNumber(payload.usedBudgetUsd);
56
90
  const key = `budget:${approval.id}`;
57
91
  items.push(
92
+ finalizeApprovalAttentionItem(
93
+ withState(
94
+ {
95
+ key,
96
+ category: "budget_hard_stop",
97
+ severity: ageHours >= 12 ? "critical" : "warning",
98
+ requiredActor: "board",
99
+ title: "Budget hard-stop requires board decision",
100
+ contextSummary: projectId
101
+ ? `Project ${shortId(projectId)} is blocked by budget hard-stop.`
102
+ : agentId
103
+ ? `Agent ${shortId(agentId)} is blocked by budget hard-stop.`
104
+ : "Agent work is blocked by budget hard-stop.",
105
+ actionLabel: "Review budget override",
106
+ actionHref: "/governance",
107
+ impactSummary: "Heartbeat work stays paused until budget override is approved or rejected.",
108
+ evidence: {
109
+ approvalId: approval.id,
110
+ projectId: projectId ?? undefined,
111
+ agentId: agentId ?? undefined
112
+ },
113
+ sourceTimestamp: approval.createdAt.toISOString(),
114
+ state: "open",
115
+ seenAt: null,
116
+ acknowledgedAt: null,
117
+ dismissedAt: null,
118
+ resolvedAt: null
119
+ },
120
+ stateByKey.get(key),
121
+ `Budget utilization ${formatPercent(utilizationPct)} (${formatUsd(usedBudget)} / ${formatUsd(currentBudget)}).`
122
+ ),
123
+ approval
124
+ )
125
+ );
126
+ continue;
127
+ }
128
+
129
+ const key = `approval:${approval.id}`;
130
+ items.push(
131
+ finalizeApprovalAttentionItem(
58
132
  withState(
59
133
  {
60
134
  key,
61
- category: "budget_hard_stop",
62
- severity: ageHours >= 12 ? "critical" : "warning",
135
+ category: "approval_required",
136
+ severity: ageHours >= 24 ? "critical" : "warning",
63
137
  requiredActor: "board",
64
- title: "Budget hard-stop requires board decision",
65
- contextSummary: projectId
66
- ? `Project ${shortId(projectId)} is blocked by budget hard-stop.`
67
- : agentId
68
- ? `Agent ${shortId(agentId)} is blocked by budget hard-stop.`
69
- : "Agent work is blocked by budget hard-stop.",
70
- actionLabel: "Review budget override",
138
+ title: "Approval required",
139
+ contextSummary: formatApprovalContext(approval.action, payload),
140
+ actionLabel: "Open approvals",
71
141
  actionHref: "/governance",
72
- impactSummary: "Heartbeat work stays paused until budget override is approved or rejected.",
142
+ impactSummary: "Execution remains blocked until this governance decision is resolved.",
73
143
  evidence: {
74
144
  approvalId: approval.id,
75
- projectId: projectId ?? undefined,
76
- agentId: agentId ?? undefined
145
+ projectId: asString(payload.projectId) ?? undefined,
146
+ agentId: asString(payload.agentId) ?? undefined
77
147
  },
78
148
  sourceTimestamp: approval.createdAt.toISOString(),
79
149
  state: "open",
@@ -82,39 +152,9 @@ export async function listBoardAttentionItems(db: BopoDb, companyId: string, act
82
152
  dismissedAt: null,
83
153
  resolvedAt: null
84
154
  },
85
- stateByKey.get(key),
86
- `Budget utilization ${formatPercent(utilizationPct)} (${formatUsd(usedBudget)} / ${formatUsd(currentBudget)}).`
87
- )
88
- );
89
- continue;
90
- }
91
-
92
- const key = `approval:${approval.id}`;
93
- items.push(
94
- withState(
95
- {
96
- key,
97
- category: "approval_required",
98
- severity: ageHours >= 24 ? "critical" : "warning",
99
- requiredActor: "board",
100
- title: "Approval required",
101
- contextSummary: formatApprovalContext(approval.action, payload),
102
- actionLabel: "Open approvals",
103
- actionHref: "/governance",
104
- impactSummary: "Execution remains blocked until this governance decision is resolved.",
105
- evidence: {
106
- approvalId: approval.id,
107
- projectId: asString(payload.projectId) ?? undefined,
108
- agentId: asString(payload.agentId) ?? undefined
109
- },
110
- sourceTimestamp: approval.createdAt.toISOString(),
111
- state: "open",
112
- seenAt: null,
113
- acknowledgedAt: null,
114
- dismissedAt: null,
115
- resolvedAt: null
116
- },
117
- stateByKey.get(key)
155
+ stateByKey.get(key)
156
+ ),
157
+ approval
118
158
  )
119
159
  );
120
160
  }
@@ -219,6 +259,7 @@ export async function listBoardAttentionItems(db: BopoDb, companyId: string, act
219
259
  const key = `comment:${comment.id}`;
220
260
  const body = comment.body.trim().replace(/\s+/g, " ");
221
261
  const summaryBody = body.length > 140 ? `${body.slice(0, 137)}...` : body;
262
+ const conciseUsageLimitSummary = summarizeUsageLimitBoardComment(body);
222
263
  items.push(
223
264
  withState(
224
265
  {
@@ -226,8 +267,8 @@ export async function listBoardAttentionItems(db: BopoDb, companyId: string, act
226
267
  category: "board_mentioned_comment",
227
268
  severity: "warning",
228
269
  requiredActor: "board",
229
- title: "Board input requested on issue comment",
230
- contextSummary: `${comment.issueTitle}: ${summaryBody}`,
270
+ title: conciseUsageLimitSummary ? "Provider usage limit reached" : "Board input requested on issue comment",
271
+ contextSummary: conciseUsageLimitSummary ?? summaryBody,
231
272
  actionLabel: "Open issue thread",
232
273
  actionHref: `/issues/${comment.issueId}`,
233
274
  impactSummary: "The team is waiting for board clarification to continue confidently.",
@@ -250,6 +291,26 @@ export async function listBoardAttentionItems(db: BopoDb, companyId: string, act
250
291
  return dedupeItems(items).sort(compareAttentionItems);
251
292
  }
252
293
 
294
+ function summarizeUsageLimitBoardComment(body: string) {
295
+ const normalized = body.replace(/\s+/g, " ").trim();
296
+ if (!normalized) {
297
+ return null;
298
+ }
299
+ const providerMatch = normalized.match(
300
+ /\b(claude[_\s-]*code|codex|cursor|openai[_\s-]*api|anthropic[_\s-]*api|gemini[_\s-]*cli|opencode)\b(?=.*\busage limit reached\b)/i
301
+ );
302
+ if (!providerMatch) {
303
+ return null;
304
+ }
305
+ const providerToken = providerMatch[1];
306
+ if (!providerToken) {
307
+ return null;
308
+ }
309
+ const rawProvider = providerToken.toLowerCase().replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim();
310
+ const providerLabel = rawProvider.charAt(0).toUpperCase() + rawProvider.slice(1);
311
+ return `${providerLabel} usage limit reached.`;
312
+ }
313
+
253
314
  export async function markBoardAttentionSeen(db: BopoDb, companyId: string, actorId: string, itemKey: string) {
254
315
  await markAttentionInboxSeen(db, { companyId, actorId, itemKey });
255
316
  }
@@ -39,7 +39,6 @@ import {
39
39
  import { resolveOpencodeRuntimeModel } from "../lib/opencode-model";
40
40
  import {
41
41
  normalizeCompanyWorkspacePath,
42
- resolveAgentFallbackWorkspacePath,
43
42
  resolveProjectWorkspacePath
44
43
  } from "../lib/instance-paths";
45
44
  import { assertRuntimeCwdForCompany, hasText, resolveDefaultRuntimeCwdForCompany } from "../lib/workspace-policy";
@@ -770,14 +769,15 @@ function resolveAgentDisplayTitle(title: string | null | undefined, roleKeyInput
770
769
  }
771
770
 
772
771
  function buildAgentStartupTaskBody(companyId: string, agentId: string) {
773
- const agentWorkspaceRoot = resolveAgentFallbackWorkspacePath(companyId, agentId);
774
- const agentOperatingFolder = `${agentWorkspaceRoot}/operating`;
772
+ const companyScopedAgentRoot = `workspace/${companyId}/agents/${agentId}`;
773
+ const agentOperatingFolder = `${companyScopedAgentRoot}/operating`;
775
774
  return [
776
775
  AGENT_STARTUP_TASK_MARKER,
777
776
  "",
778
777
  `Create your operating baseline before starting feature delivery work.`,
779
778
  "",
780
- `1. Create your operating folder at \`${agentOperatingFolder}/\` (system path, outside project workspaces).`,
779
+ `1. Create your operating folder at \`${agentOperatingFolder}/\`.`,
780
+ " During heartbeats, prefer the absolute path in `$BOPODEV_AGENT_OPERATING_DIR` (set by the runtime) so files land under your agent folder even when the shell cwd is a project workspace.",
781
781
  "2. Author these files with your own responsibilities and working style:",
782
782
  ` - \`${agentOperatingFolder}/AGENTS.md\``,
783
783
  ` - \`${agentOperatingFolder}/HEARTBEAT.md\``,
@@ -787,7 +787,7 @@ function buildAgentStartupTaskBody(companyId: string, agentId: string) {
787
787
  "4. Post an issue comment summarizing completed setup artifacts.",
788
788
  "",
789
789
  "Safety checks:",
790
- "- Do not write operating/system files under any project workspace folder.",
790
+ `- Keep operating files inside \`workspace/${companyId}/agents/${agentId}/\` only.`,
791
791
  "- Do not overwrite another agent's operating folder.",
792
792
  "- Keep content original to your role and scope."
793
793
  ].join("\n");