bopodev-api 0.1.24 → 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.
@@ -1,10 +1,14 @@
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,
7
+ listCompanies,
5
8
  listAgents,
6
9
  listAuditEvents,
7
10
  listCostEntries,
11
+ listGoals,
8
12
  listHeartbeatRunMessages,
9
13
  listHeartbeatRuns,
10
14
  listModelPricing,
@@ -13,9 +17,10 @@ import {
13
17
  } from "bopodev-db";
14
18
  import type { AppContext } from "../context";
15
19
  import { sendError, sendOk } from "../http";
20
+ import { isInsidePath, resolveCompanyWorkspaceRootPath } from "../lib/instance-paths";
16
21
  import { requireCompanyScope } from "../middleware/company-scope";
17
22
  import { requirePermission } from "../middleware/request-actor";
18
- import { listAgentMemoryFiles, readAgentMemoryFile } from "../services/memory-file-service";
23
+ import { listAgentMemoryFiles, loadAgentMemoryContext, readAgentMemoryFile } from "../services/memory-file-service";
19
24
 
20
25
  export function createObservabilityRouter(ctx: AppContext) {
21
26
  const router = Router();
@@ -111,10 +116,12 @@ export function createObservabilityRouter(ctx: AppContext) {
111
116
  .filter((run) => (agentFilter ? run.agentId === agentFilter : true))
112
117
  .map((run) => {
113
118
  const details = runDetailsByRunId.get(run.id);
114
- const outcome = details?.outcome ?? null;
119
+ const report = toRecord(details?.report);
120
+ const outcome = details?.outcome ?? report?.outcome ?? null;
115
121
  return {
116
- ...serializeRunRow(run, outcome),
117
- outcome
122
+ ...serializeRunRow(run, details),
123
+ outcome,
124
+ report: report ?? null
118
125
  };
119
126
  })
120
127
  );
@@ -136,7 +143,7 @@ export function createObservabilityRouter(ctx: AppContext) {
136
143
  const trace = toRecord(details?.trace);
137
144
  const traceTranscript = Array.isArray(trace?.transcript) ? trace.transcript : [];
138
145
  return sendOk(res, {
139
- run: serializeRunRow(run, details?.outcome ?? null),
146
+ run: serializeRunRow(run, details),
140
147
  details,
141
148
  transcript: {
142
149
  hasPersistedMessages: transcriptResult.items.length > 0,
@@ -146,6 +153,46 @@ export function createObservabilityRouter(ctx: AppContext) {
146
153
  });
147
154
  });
148
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
+
149
196
  router.get("/heartbeats/:runId/messages", async (req, res) => {
150
197
  const companyId = req.companyId!;
151
198
  const runId = req.params.runId;
@@ -266,6 +313,65 @@ export function createObservabilityRouter(ctx: AppContext) {
266
313
  }
267
314
  });
268
315
 
316
+ router.get("/memory/:agentId/context-preview", async (req, res) => {
317
+ const companyId = req.companyId!;
318
+ const agentId = req.params.agentId;
319
+ const projectIds = typeof req.query.projectIds === "string"
320
+ ? req.query.projectIds
321
+ .split(",")
322
+ .map((entry) => entry.trim())
323
+ .filter(Boolean)
324
+ : [];
325
+ const queryText = typeof req.query.query === "string" ? req.query.query.trim() : "";
326
+ const [agents, goals, companies] = await Promise.all([
327
+ listAgents(ctx.db, companyId),
328
+ listGoals(ctx.db, companyId),
329
+ listCompanies(ctx.db)
330
+ ]);
331
+ const agent = agents.find((entry) => entry.id === agentId);
332
+ if (!agent) {
333
+ return sendError(res, "Agent not found", 404);
334
+ }
335
+ const company = companies.find((entry) => entry.id === companyId);
336
+ const memoryContext = await loadAgentMemoryContext({
337
+ companyId,
338
+ agentId,
339
+ projectIds,
340
+ queryText: queryText.length > 0 ? queryText : undefined
341
+ });
342
+ const activeCompanyGoals = goals
343
+ .filter((goal) => goal.status === "active" && goal.level === "company")
344
+ .map((goal) => goal.title);
345
+ const activeProjectGoals = goals
346
+ .filter((goal) => goal.status === "active" && goal.level === "project" && goal.projectId && projectIds.includes(goal.projectId))
347
+ .map((goal) => goal.title);
348
+ const activeAgentGoals = goals
349
+ .filter((goal) => goal.status === "active" && goal.level === "agent")
350
+ .map((goal) => goal.title);
351
+ const compiledPreview = [
352
+ `Agent: ${agent.name} (${agent.role})`,
353
+ `Company mission: ${company?.mission ?? "No mission set"}`,
354
+ `Company goals: ${activeCompanyGoals.length > 0 ? activeCompanyGoals.join(" | ") : "None"}`,
355
+ `Project goals: ${activeProjectGoals.length > 0 ? activeProjectGoals.join(" | ") : "None"}`,
356
+ `Agent goals: ${activeAgentGoals.length > 0 ? activeAgentGoals.join(" | ") : "None"}`,
357
+ `Tacit notes: ${memoryContext.tacitNotes ?? "None"}`,
358
+ `Durable facts: ${memoryContext.durableFacts.join(" | ") || "None"}`,
359
+ `Daily notes: ${memoryContext.dailyNotes.join(" | ") || "None"}`
360
+ ].join("\n");
361
+ return sendOk(res, {
362
+ agentId,
363
+ projectIds,
364
+ companyMission: company?.mission ?? null,
365
+ goalContext: {
366
+ companyGoals: activeCompanyGoals,
367
+ projectGoals: activeProjectGoals,
368
+ agentGoals: activeAgentGoals
369
+ },
370
+ memoryContext,
371
+ compiledPreview
372
+ });
373
+ });
374
+
269
375
  router.get("/plugins/runs", async (req, res) => {
270
376
  const companyId = req.companyId!;
271
377
  const pluginId = typeof req.query.pluginId === "string" && req.query.pluginId.trim() ? req.query.pluginId.trim() : undefined;
@@ -298,6 +404,85 @@ function toRecord(value: unknown) {
298
404
  return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : null;
299
405
  }
300
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
+
301
486
  function serializeRunRow(
302
487
  run: {
303
488
  id: string;
@@ -308,14 +493,27 @@ function serializeRunRow(
308
493
  finishedAt: Date | null;
309
494
  message: string | null;
310
495
  },
311
- outcome: unknown
496
+ details: Record<string, unknown> | null | undefined
312
497
  ) {
313
- 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";
314
511
  return {
315
512
  id: run.id,
316
513
  companyId: run.companyId,
317
514
  agentId: run.agentId,
318
515
  status: run.status,
516
+ publicStatus,
319
517
  startedAt: run.startedAt.toISOString(),
320
518
  finishedAt: run.finishedAt?.toISOString() ?? null,
321
519
  message: run.message ?? null,
@@ -328,14 +526,25 @@ function resolveRunType(
328
526
  status: string;
329
527
  message: string | null;
330
528
  },
331
- outcome: unknown
529
+ details: Record<string, unknown> | null | undefined
332
530
  ): "work" | "no_assigned_work" | "budget_skip" | "overlap_skip" | "other_skip" | "failed" | "running" {
333
531
  if (run.status === "started") {
334
532
  return "running";
335
533
  }
336
- 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") {
337
537
  return "failed";
338
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
+ }
339
548
  const normalizedMessage = (run.message ?? "").toLowerCase();
340
549
  if (normalizedMessage.includes("already in progress")) {
341
550
  return "overlap_skip";
@@ -346,7 +555,7 @@ function resolveRunType(
346
555
  if (isNoAssignedWorkMessage(run.message)) {
347
556
  return "no_assigned_work";
348
557
  }
349
- if (isNoAssignedWorkOutcome(outcome)) {
558
+ if (isNoAssignedWorkOutcome(details?.outcome)) {
350
559
  return "no_assigned_work";
351
560
  }
352
561
  if (run.status === "skipped") {
@@ -2,6 +2,7 @@ import { Router } from "express";
2
2
  import { mkdir, stat } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { z } from "zod";
5
+ import { ProjectSchema } from "bopodev-contracts";
5
6
  import {
6
7
  appendAuditEvent,
7
8
  createProject,
@@ -15,7 +16,7 @@ import {
15
16
  updateProjectWorkspace
16
17
  } from "bopodev-db";
17
18
  import type { AppContext } from "../context";
18
- import { sendError, sendOk } from "../http";
19
+ import { sendError, sendOk, sendOkValidated } from "../http";
19
20
  import { normalizeCompanyWorkspacePath, resolveProjectWorkspacePath } from "../lib/instance-paths";
20
21
  import { requireCompanyScope } from "../middleware/company-scope";
21
22
  import { requirePermission } from "../middleware/request-actor";
@@ -50,6 +51,7 @@ const createProjectSchema = z.object({
50
51
  description: z.string().optional(),
51
52
  status: projectStatusSchema.default("planned"),
52
53
  plannedStartAt: z.string().optional(),
54
+ monthlyBudgetUsd: z.number().positive().default(100),
53
55
  executionWorkspacePolicy: executionWorkspacePolicySchema.optional().nullable(),
54
56
  workspace: z
55
57
  .object({
@@ -69,6 +71,7 @@ const updateProjectSchema = z
69
71
  description: z.string().nullable().optional(),
70
72
  status: projectStatusSchema.optional(),
71
73
  plannedStartAt: z.string().nullable().optional(),
74
+ monthlyBudgetUsd: z.number().positive().optional(),
72
75
  executionWorkspacePolicy: executionWorkspacePolicySchema.nullable().optional(),
73
76
  goalIds: z.array(z.string().min(1)).optional()
74
77
  })
@@ -122,7 +125,7 @@ export function createProjectsRouter(ctx: AppContext) {
122
125
  router.get("/", async (req, res) => {
123
126
  const projects = await listProjects(ctx.db, req.companyId!);
124
127
  const withDiagnostics = await Promise.all(projects.map((project) => enrichProjectDiagnostics(req.companyId!, project)));
125
- return sendOk(res, withDiagnostics);
128
+ return sendOkValidated(res, ProjectSchema.array(), withDiagnostics, "projects.list");
126
129
  });
127
130
 
128
131
  router.post("/", async (req, res) => {
@@ -140,6 +143,7 @@ export function createProjectsRouter(ctx: AppContext) {
140
143
  description: parsed.data.description,
141
144
  status: parsed.data.status,
142
145
  plannedStartAt: parsePlannedStartAt(parsed.data.plannedStartAt),
146
+ monthlyBudgetUsd: parsed.data.monthlyBudgetUsd.toFixed(4),
143
147
  executionWorkspacePolicy: parsed.data.executionWorkspacePolicy ?? null
144
148
  });
145
149
  if (!project) {
@@ -216,6 +220,7 @@ export function createProjectsRouter(ctx: AppContext) {
216
220
  status: parsed.data.status,
217
221
  plannedStartAt:
218
222
  parsed.data.plannedStartAt === undefined ? undefined : parsePlannedStartAt(parsed.data.plannedStartAt),
223
+ monthlyBudgetUsd: parsed.data.monthlyBudgetUsd === undefined ? undefined : parsed.data.monthlyBudgetUsd.toFixed(4),
219
224
  executionWorkspacePolicy: parsed.data.executionWorkspacePolicy
220
225
  });
221
226
  if (!project) {
@@ -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";
@@ -101,7 +100,7 @@ export async function ensureOnboardingSeed(input: {
101
100
  const resolvedCompanyName = companyRow.name;
102
101
  await ensureCompanyBuiltinTemplateDefaults(db, companyId);
103
102
  const agents = await listAgents(db, companyId);
104
- const existingCeo = agents.find((agent) => agent.role === "CEO" || agent.name === "CEO");
103
+ const existingCeo = agents.find((agent) => agent.roleKey === "ceo" || agent.role === "CEO" || agent.name === "CEO");
105
104
  let ceoCreated = false;
106
105
  let ceoMigrated = false;
107
106
  let ceoProviderType: AgentProvider = parseAgentProvider(existingCeo?.providerType) ?? agentProvider;
@@ -130,6 +129,8 @@ export async function ensureOnboardingSeed(input: {
130
129
  const ceo = await createAgent(db, {
131
130
  companyId,
132
131
  role: "CEO",
132
+ roleKey: "ceo",
133
+ title: "CEO",
133
134
  name: "CEO",
134
135
  providerType: agentProvider,
135
136
  heartbeatCron: "*/5 * * * *",
@@ -303,15 +304,15 @@ async function ensureCeoStartupTask(
303
304
  typeof issue.body === "string" &&
304
305
  issue.body.includes(CEO_STARTUP_TASK_MARKER)
305
306
  );
306
- const ceoWorkspaceRoot = resolveAgentFallbackWorkspacePath(input.companyId, input.ceoId);
307
- const ceoOperatingFolder = `${ceoWorkspaceRoot}/operating`;
308
- const ceoTmpFolder = `${ceoWorkspaceRoot}/tmp`;
307
+ const companyScopedCeoRoot = `workspace/${input.companyId}/agents/${input.ceoId}`;
308
+ const ceoOperatingFolder = `${companyScopedCeoRoot}/operating`;
309
+ const ceoTmpFolder = `${companyScopedCeoRoot}/tmp`;
309
310
  const body = [
310
311
  CEO_STARTUP_TASK_MARKER,
311
312
  "",
312
313
  "Stand up your leadership operating baseline before taking on additional delivery work.",
313
314
  "",
314
- `1. Create your operating folder at \`${ceoOperatingFolder}/\` (system path, outside project workspaces).`,
315
+ `1. Create your operating folder at \`${ceoOperatingFolder}/\`.`,
315
316
  "2. Author these files with your own voice and responsibilities:",
316
317
  ` - \`${ceoOperatingFolder}/AGENTS.md\``,
317
318
  ` - \`${ceoOperatingFolder}/HEARTBEAT.md\``,
@@ -333,7 +334,7 @@ async function ensureCeoStartupTask(
333
334
  "7. Do not use unsupported hire fields such as `adapterType`, `adapterConfig`, or `reportsTo`.",
334
335
  "",
335
336
  "Safety checks before requesting hire:",
336
- "- Do not write operating/system files under any project workspace folder.",
337
+ `- Keep operating/system files inside \`workspace/${input.companyId}/agents/${input.ceoId}/\` only.`,
337
338
  "- Do not request duplicates if a Founding Engineer already exists.",
338
339
  "- Do not request duplicates if a pending approval for the same role is already open.",
339
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`.",
package/src/server.ts CHANGED
@@ -10,6 +10,7 @@ import { createApp } from "./app";
10
10
  import { loadGovernanceRealtimeSnapshot } from "./realtime/governance";
11
11
  import { loadOfficeSpaceRealtimeSnapshot } from "./realtime/office-space";
12
12
  import { loadHeartbeatRunsRealtimeSnapshot } from "./realtime/heartbeat-runs";
13
+ import { loadAttentionRealtimeSnapshot } from "./realtime/attention";
13
14
  import { attachRealtimeHub } from "./realtime/hub";
14
15
  import {
15
16
  isAuthenticatedMode,
@@ -106,7 +107,8 @@ async function main() {
106
107
  bootstrapLoaders: {
107
108
  governance: (companyId) => loadGovernanceRealtimeSnapshot(db, companyId),
108
109
  "office-space": (companyId) => loadOfficeSpaceRealtimeSnapshot(db, companyId),
109
- "heartbeat-runs": (companyId) => loadHeartbeatRunsRealtimeSnapshot(db, companyId)
110
+ "heartbeat-runs": (companyId) => loadHeartbeatRunsRealtimeSnapshot(db, companyId),
111
+ attention: (companyId) => loadAttentionRealtimeSnapshot(db, companyId)
110
112
  }
111
113
  });
112
114
  const app = createApp({ db, deploymentMode, allowedOrigins, getRuntimeHealth, realtimeHub });