bopodev-api 0.1.25 → 0.1.26

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bopodev-api",
3
- "version": "0.1.25",
3
+ "version": "0.1.26",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "files": [
@@ -17,9 +17,9 @@
17
17
  "nanoid": "^5.1.5",
18
18
  "ws": "^8.19.0",
19
19
  "zod": "^4.1.5",
20
- "bopodev-contracts": "0.1.25",
21
- "bopodev-db": "0.1.25",
22
- "bopodev-agent-sdk": "0.1.25"
20
+ "bopodev-db": "0.1.26",
21
+ "bopodev-agent-sdk": "0.1.26",
22
+ "bopodev-contracts": "0.1.26"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/cors": "^2.8.19",
@@ -1,6 +1,7 @@
1
1
  import { Router } from "express";
2
2
  import { z } from "zod";
3
3
  import {
4
+ addIssueComment,
4
5
  appendAuditEvent,
5
6
  clearApprovalInboxDismissed,
6
7
  countPendingApprovalRequests,
@@ -209,6 +210,36 @@ export function createGovernanceRouter(ctx: AppContext) {
209
210
  if (approval.requestedByAgentId) {
210
211
  await publishOfficeOccupantForAgent(ctx.db, ctx.realtimeHub, req.companyId!, approval.requestedByAgentId);
211
212
  }
213
+ if (parsed.data.status === "approved" && resolution.action === "hire_agent" && resolution.execution.applied) {
214
+ const hireContext = parseHireApprovalCommentContext(approval.payloadJson);
215
+ if (hireContext.issueIds.length > 0) {
216
+ const commentBody = buildHireApprovalIssueComment(hireContext.roleLabel);
217
+ try {
218
+ for (const issueId of hireContext.issueIds) {
219
+ await addIssueComment(ctx.db, {
220
+ companyId: req.companyId!,
221
+ issueId,
222
+ body: commentBody,
223
+ authorType: auditActor.actorType === "agent" ? "agent" : "human",
224
+ authorId: auditActor.actorId
225
+ });
226
+ }
227
+ } catch (error) {
228
+ await appendAuditEvent(ctx.db, {
229
+ companyId: req.companyId!,
230
+ actorType: "system",
231
+ actorId: null,
232
+ eventType: "governance.hire_approval_comment_failed",
233
+ entityType: "approval_request",
234
+ entityId: approval.id,
235
+ payload: {
236
+ error: String(error),
237
+ issueIds: hireContext.issueIds
238
+ }
239
+ });
240
+ }
241
+ }
242
+ }
212
243
  }
213
244
 
214
245
  if (resolution.execution.entityType === "agent" && resolution.execution.entityId) {
@@ -231,11 +262,58 @@ function resolveAuditActor(actor: { type: "board" | "member" | "agent"; id: stri
231
262
  return { actorType: "human" as const, actorId: actor.id };
232
263
  }
233
264
 
234
- function parsePayload(payloadJson: string) {
265
+ function parsePayload(payloadJson: string): Record<string, unknown> {
235
266
  try {
236
267
  const parsed = JSON.parse(payloadJson) as unknown;
237
- return typeof parsed === "object" && parsed !== null ? parsed : {};
268
+ return typeof parsed === "object" && parsed !== null ? (parsed as Record<string, unknown>) : {};
238
269
  } catch {
239
270
  return {};
240
271
  }
241
272
  }
273
+
274
+ function parseHireApprovalCommentContext(payloadJson: string) {
275
+ const payload = parsePayload(payloadJson);
276
+ const issueIds = normalizeSourceIssueIds(
277
+ typeof payload.sourceIssueId === "string" ? payload.sourceIssueId : undefined,
278
+ Array.isArray(payload.sourceIssueIds) ? payload.sourceIssueIds : undefined
279
+ );
280
+ const roleLabel = resolveHireRoleLabel(payload);
281
+ return { issueIds, roleLabel };
282
+ }
283
+
284
+ function normalizeSourceIssueIds(sourceIssueId?: string, sourceIssueIds?: unknown[]) {
285
+ const normalized = new Set<string>();
286
+ for (const entry of [sourceIssueId, ...(sourceIssueIds ?? [])]) {
287
+ if (typeof entry !== "string") {
288
+ continue;
289
+ }
290
+ const trimmed = entry.trim();
291
+ if (trimmed.length > 0) {
292
+ normalized.add(trimmed);
293
+ }
294
+ }
295
+ return Array.from(normalized);
296
+ }
297
+
298
+ function resolveHireRoleLabel(payload: Record<string, unknown>) {
299
+ const title = typeof payload.title === "string" ? payload.title.trim() : "";
300
+ if (title.length > 0) {
301
+ return title;
302
+ }
303
+ const role = typeof payload.role === "string" ? payload.role.trim() : "";
304
+ if (role.length > 0) {
305
+ return role;
306
+ }
307
+ const roleKey = typeof payload.roleKey === "string" ? payload.roleKey.trim() : "";
308
+ if (roleKey.length > 0) {
309
+ return roleKey.replace(/_/g, " ");
310
+ }
311
+ return null;
312
+ }
313
+
314
+ function buildHireApprovalIssueComment(roleLabel: string | null) {
315
+ if (roleLabel) {
316
+ return `Approved hiring of ${roleLabel}.`;
317
+ }
318
+ return "Approved hiring request.";
319
+ }
@@ -1,4 +1,6 @@
1
1
  import { Router } from "express";
2
+ import { readFile, stat } from "node:fs/promises";
3
+ import { basename, isAbsolute, resolve } from "node:path";
2
4
  import { z } from "zod";
3
5
  import {
4
6
  getHeartbeatRun,
@@ -15,6 +17,7 @@ import {
15
17
  } from "bopodev-db";
16
18
  import type { AppContext } from "../context";
17
19
  import { sendError, sendOk } from "../http";
20
+ import { isInsidePath, resolveCompanyWorkspaceRootPath } from "../lib/instance-paths";
18
21
  import { requireCompanyScope } from "../middleware/company-scope";
19
22
  import { requirePermission } from "../middleware/request-actor";
20
23
  import { listAgentMemoryFiles, loadAgentMemoryContext, readAgentMemoryFile } from "../services/memory-file-service";
@@ -113,10 +116,12 @@ export function createObservabilityRouter(ctx: AppContext) {
113
116
  .filter((run) => (agentFilter ? run.agentId === agentFilter : true))
114
117
  .map((run) => {
115
118
  const details = runDetailsByRunId.get(run.id);
116
- const outcome = details?.outcome ?? null;
119
+ const report = toRecord(details?.report);
120
+ const outcome = details?.outcome ?? report?.outcome ?? null;
117
121
  return {
118
- ...serializeRunRow(run, outcome),
119
- outcome
122
+ ...serializeRunRow(run, details),
123
+ outcome,
124
+ report: report ?? null
120
125
  };
121
126
  })
122
127
  );
@@ -138,7 +143,7 @@ export function createObservabilityRouter(ctx: AppContext) {
138
143
  const trace = toRecord(details?.trace);
139
144
  const traceTranscript = Array.isArray(trace?.transcript) ? trace.transcript : [];
140
145
  return sendOk(res, {
141
- run: serializeRunRow(run, details?.outcome ?? null),
146
+ run: serializeRunRow(run, details),
142
147
  details,
143
148
  transcript: {
144
149
  hasPersistedMessages: transcriptResult.items.length > 0,
@@ -148,6 +153,46 @@ export function createObservabilityRouter(ctx: AppContext) {
148
153
  });
149
154
  });
150
155
 
156
+ router.get("/heartbeats/:runId/artifacts/:artifactIndex/download", async (req, res) => {
157
+ const companyId = req.companyId!;
158
+ const runId = req.params.runId;
159
+ const rawArtifactIndex = Number(req.params.artifactIndex);
160
+ const artifactIndex = Number.isFinite(rawArtifactIndex) ? Math.floor(rawArtifactIndex) : NaN;
161
+ if (!Number.isInteger(artifactIndex) || artifactIndex < 0) {
162
+ return sendError(res, "Artifact index must be a non-negative integer.", 422);
163
+ }
164
+ const [run, auditRows] = await Promise.all([getHeartbeatRun(ctx.db, companyId, runId), listAuditEvents(ctx.db, companyId, 500)]);
165
+ if (!run) {
166
+ return sendError(res, "Run not found", 404);
167
+ }
168
+ const details = buildRunDetailsMap(auditRows).get(runId) ?? null;
169
+ const report = toRecord(details?.report);
170
+ const artifacts = Array.isArray(report?.artifacts)
171
+ ? report.artifacts.filter((entry) => typeof entry === "object" && entry !== null)
172
+ : [];
173
+ const artifact = (artifacts[artifactIndex] ?? null) as Record<string, unknown> | null;
174
+ if (!artifact) {
175
+ return sendError(res, "Artifact not found.", 404);
176
+ }
177
+ const resolvedPath = resolveRunArtifactAbsolutePath(companyId, artifact);
178
+ if (!resolvedPath) {
179
+ return sendError(res, "Artifact path is invalid.", 422);
180
+ }
181
+ let stats: Awaited<ReturnType<typeof stat>>;
182
+ try {
183
+ stats = await stat(resolvedPath);
184
+ } catch {
185
+ return sendError(res, "Artifact not found on disk.", 404);
186
+ }
187
+ if (!stats.isFile()) {
188
+ return sendError(res, "Artifact is not a file.", 422);
189
+ }
190
+ const buffer = await readFile(resolvedPath);
191
+ res.setHeader("content-type", "application/octet-stream");
192
+ res.setHeader("content-disposition", `inline; filename="${encodeURIComponent(basename(resolvedPath))}"`);
193
+ return res.send(buffer);
194
+ });
195
+
151
196
  router.get("/heartbeats/:runId/messages", async (req, res) => {
152
197
  const companyId = req.companyId!;
153
198
  const runId = req.params.runId;
@@ -359,6 +404,85 @@ function toRecord(value: unknown) {
359
404
  return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : null;
360
405
  }
361
406
 
407
+ function resolveRunArtifactAbsolutePath(companyId: string, artifact: Record<string, unknown>) {
408
+ const companyWorkspaceRoot = resolveCompanyWorkspaceRootPath(companyId);
409
+ const absolutePathRaw = normalizeAbsoluteArtifactPath(
410
+ typeof artifact.absolutePath === "string" ? artifact.absolutePath.trim() : ""
411
+ );
412
+ const relativePathRaw = normalizeWorkspaceRelativeArtifactPath(
413
+ typeof artifact.relativePath === "string"
414
+ ? artifact.relativePath.trim()
415
+ : typeof artifact.path === "string"
416
+ ? artifact.path.trim()
417
+ : "",
418
+ companyId
419
+ );
420
+ const candidate = relativePathRaw
421
+ ? resolve(companyWorkspaceRoot, relativePathRaw)
422
+ : absolutePathRaw
423
+ ? absolutePathRaw
424
+ : "";
425
+ if (!candidate) {
426
+ return null;
427
+ }
428
+ const resolved = isAbsolute(candidate) ? resolve(candidate) : resolve(companyWorkspaceRoot, candidate);
429
+ if (!isInsidePath(companyWorkspaceRoot, resolved)) {
430
+ return null;
431
+ }
432
+ return resolved;
433
+ }
434
+
435
+ function normalizeAbsoluteArtifactPath(value: string) {
436
+ const trimmed = value.trim();
437
+ if (!trimmed || !isAbsolute(trimmed)) {
438
+ return "";
439
+ }
440
+ return resolve(trimmed);
441
+ }
442
+
443
+ function normalizeWorkspaceRelativeArtifactPath(value: string, companyId: string) {
444
+ const trimmed = value.trim();
445
+ if (!trimmed) {
446
+ return "";
447
+ }
448
+ const unixSeparated = trimmed.replace(/\\/g, "/");
449
+ if (isAbsolute(unixSeparated)) {
450
+ return "";
451
+ }
452
+ const parts: string[] = [];
453
+ for (const part of unixSeparated.split("/")) {
454
+ if (!part || part === ".") {
455
+ continue;
456
+ }
457
+ if (part === "..") {
458
+ if (parts.length > 0 && parts[parts.length - 1] !== "..") {
459
+ parts.pop();
460
+ } else {
461
+ parts.push(part);
462
+ }
463
+ continue;
464
+ }
465
+ parts.push(part);
466
+ }
467
+ const normalized = parts.join("/");
468
+ if (!normalized) {
469
+ return "";
470
+ }
471
+ const workspaceScopedMatch = normalized.match(/(?:^|\/)workspace\/([^/]+)\/(.+)$/);
472
+ if (!workspaceScopedMatch) {
473
+ return normalized;
474
+ }
475
+ const scopedCompanyId = workspaceScopedMatch[1];
476
+ const scopedRelativePath = workspaceScopedMatch[2];
477
+ if (!scopedCompanyId || !scopedRelativePath) {
478
+ return "";
479
+ }
480
+ if (scopedCompanyId !== companyId) {
481
+ return "";
482
+ }
483
+ return scopedRelativePath;
484
+ }
485
+
362
486
  function serializeRunRow(
363
487
  run: {
364
488
  id: string;
@@ -369,14 +493,27 @@ function serializeRunRow(
369
493
  finishedAt: Date | null;
370
494
  message: string | null;
371
495
  },
372
- outcome: unknown
496
+ details: Record<string, unknown> | null | undefined
373
497
  ) {
374
- const runType = resolveRunType(run, outcome);
498
+ const runType = resolveRunType(run, details);
499
+ const report = toRecord(details?.report);
500
+ const publicStatusRaw = typeof report?.finalStatus === "string" ? report.finalStatus : null;
501
+ const publicStatus =
502
+ publicStatusRaw === "completed" || publicStatusRaw === "failed"
503
+ ? publicStatusRaw
504
+ : run.status === "started"
505
+ ? "started"
506
+ : run.status === "failed"
507
+ ? "failed"
508
+ : run.status === "completed"
509
+ ? "completed"
510
+ : "failed";
375
511
  return {
376
512
  id: run.id,
377
513
  companyId: run.companyId,
378
514
  agentId: run.agentId,
379
515
  status: run.status,
516
+ publicStatus,
380
517
  startedAt: run.startedAt.toISOString(),
381
518
  finishedAt: run.finishedAt?.toISOString() ?? null,
382
519
  message: run.message ?? null,
@@ -389,14 +526,25 @@ function resolveRunType(
389
526
  status: string;
390
527
  message: string | null;
391
528
  },
392
- outcome: unknown
529
+ details: Record<string, unknown> | null | undefined
393
530
  ): "work" | "no_assigned_work" | "budget_skip" | "overlap_skip" | "other_skip" | "failed" | "running" {
394
531
  if (run.status === "started") {
395
532
  return "running";
396
533
  }
397
- if (run.status === "failed") {
534
+ const report = toRecord(details?.report);
535
+ const completionReason = typeof report?.completionReason === "string" ? report.completionReason : null;
536
+ if (run.status === "failed" || completionReason === "runtime_error" || completionReason === "provider_unavailable") {
398
537
  return "failed";
399
538
  }
539
+ if (completionReason === "no_assigned_work") {
540
+ return "no_assigned_work";
541
+ }
542
+ if (completionReason === "budget_hard_stop") {
543
+ return "budget_skip";
544
+ }
545
+ if (completionReason === "overlap_in_progress") {
546
+ return "overlap_skip";
547
+ }
400
548
  const normalizedMessage = (run.message ?? "").toLowerCase();
401
549
  if (normalizedMessage.includes("already in progress")) {
402
550
  return "overlap_skip";
@@ -407,7 +555,7 @@ function resolveRunType(
407
555
  if (isNoAssignedWorkMessage(run.message)) {
408
556
  return "no_assigned_work";
409
557
  }
410
- if (isNoAssignedWorkOutcome(outcome)) {
558
+ if (isNoAssignedWorkOutcome(details?.outcome)) {
411
559
  return "no_assigned_work";
412
560
  }
413
561
  if (run.status === "skipped") {
@@ -26,7 +26,6 @@ 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";
@@ -305,15 +304,15 @@ async function ensureCeoStartupTask(
305
304
  typeof issue.body === "string" &&
306
305
  issue.body.includes(CEO_STARTUP_TASK_MARKER)
307
306
  );
308
- const ceoWorkspaceRoot = resolveAgentFallbackWorkspacePath(input.companyId, input.ceoId);
309
- const ceoOperatingFolder = `${ceoWorkspaceRoot}/operating`;
310
- const ceoTmpFolder = `${ceoWorkspaceRoot}/tmp`;
307
+ const companyScopedCeoRoot = `workspace/${input.companyId}/agents/${input.ceoId}`;
308
+ const ceoOperatingFolder = `${companyScopedCeoRoot}/operating`;
309
+ const ceoTmpFolder = `${companyScopedCeoRoot}/tmp`;
311
310
  const body = [
312
311
  CEO_STARTUP_TASK_MARKER,
313
312
  "",
314
313
  "Stand up your leadership operating baseline before taking on additional delivery work.",
315
314
  "",
316
- `1. Create your operating folder at \`${ceoOperatingFolder}/\` (system path, outside project workspaces).`,
315
+ `1. Create your operating folder at \`${ceoOperatingFolder}/\`.`,
317
316
  "2. Author these files with your own voice and responsibilities:",
318
317
  ` - \`${ceoOperatingFolder}/AGENTS.md\``,
319
318
  ` - \`${ceoOperatingFolder}/HEARTBEAT.md\``,
@@ -335,7 +334,7 @@ async function ensureCeoStartupTask(
335
334
  "7. Do not use unsupported hire fields such as `adapterType`, `adapterConfig`, or `reportsTo`.",
336
335
  "",
337
336
  "Safety checks before requesting hire:",
338
- "- Do not write operating/system files under any project workspace folder.",
337
+ `- Keep operating/system files inside \`workspace/${input.companyId}/agents/${input.ceoId}/\` only.`,
339
338
  "- Do not request duplicates if a Founding Engineer already exists.",
340
339
  "- Do not request duplicates if a pending approval for the same role is already open.",
341
340
  "- 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`.",
@@ -219,6 +219,7 @@ export async function listBoardAttentionItems(db: BopoDb, companyId: string, act
219
219
  const key = `comment:${comment.id}`;
220
220
  const body = comment.body.trim().replace(/\s+/g, " ");
221
221
  const summaryBody = body.length > 140 ? `${body.slice(0, 137)}...` : body;
222
+ const conciseUsageLimitSummary = summarizeUsageLimitBoardComment(body);
222
223
  items.push(
223
224
  withState(
224
225
  {
@@ -226,8 +227,8 @@ export async function listBoardAttentionItems(db: BopoDb, companyId: string, act
226
227
  category: "board_mentioned_comment",
227
228
  severity: "warning",
228
229
  requiredActor: "board",
229
- title: "Board input requested on issue comment",
230
- contextSummary: `${comment.issueTitle}: ${summaryBody}`,
230
+ title: conciseUsageLimitSummary ? "Provider usage limit reached" : "Board input requested on issue comment",
231
+ contextSummary: conciseUsageLimitSummary ?? summaryBody,
231
232
  actionLabel: "Open issue thread",
232
233
  actionHref: `/issues/${comment.issueId}`,
233
234
  impactSummary: "The team is waiting for board clarification to continue confidently.",
@@ -250,6 +251,26 @@ export async function listBoardAttentionItems(db: BopoDb, companyId: string, act
250
251
  return dedupeItems(items).sort(compareAttentionItems);
251
252
  }
252
253
 
254
+ function summarizeUsageLimitBoardComment(body: string) {
255
+ const normalized = body.replace(/\s+/g, " ").trim();
256
+ if (!normalized) {
257
+ return null;
258
+ }
259
+ const providerMatch = normalized.match(
260
+ /\b(claude[_\s-]*code|codex|cursor|openai[_\s-]*api|anthropic[_\s-]*api|gemini[_\s-]*cli|opencode)\b(?=.*\busage limit reached\b)/i
261
+ );
262
+ if (!providerMatch) {
263
+ return null;
264
+ }
265
+ const providerToken = providerMatch[1];
266
+ if (!providerToken) {
267
+ return null;
268
+ }
269
+ const rawProvider = providerToken.toLowerCase().replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim();
270
+ const providerLabel = rawProvider.charAt(0).toUpperCase() + rawProvider.slice(1);
271
+ return `${providerLabel} usage limit reached.`;
272
+ }
273
+
253
274
  export async function markBoardAttentionSeen(db: BopoDb, companyId: string, actorId: string, itemKey: string) {
254
275
  await markAttentionInboxSeen(db, { companyId, actorId, itemKey });
255
276
  }
@@ -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,14 @@ 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}/\`.`,
781
780
  "2. Author these files with your own responsibilities and working style:",
782
781
  ` - \`${agentOperatingFolder}/AGENTS.md\``,
783
782
  ` - \`${agentOperatingFolder}/HEARTBEAT.md\``,
@@ -787,7 +786,7 @@ function buildAgentStartupTaskBody(companyId: string, agentId: string) {
787
786
  "4. Post an issue comment summarizing completed setup artifacts.",
788
787
  "",
789
788
  "Safety checks:",
790
- "- Do not write operating/system files under any project workspace folder.",
789
+ `- Keep operating files inside \`workspace/${companyId}/agents/${agentId}/\` only.`,
791
790
  "- Do not overwrite another agent's operating folder.",
792
791
  "- Keep content original to your role and scope."
793
792
  ].join("\n");