bopodev-api 0.1.26 → 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.
package/README.md ADDED
@@ -0,0 +1,44 @@
1
+ # `apps/api`
2
+
3
+ Express API, websocket hub, and heartbeat scheduler runtime for Bopo.
4
+
5
+ ## Responsibilities
6
+
7
+ - Serve HTTP routes for planning, execution, governance, observability, plugins, and templates.
8
+ - Attach websocket hub on `/realtime` for company-scoped live updates.
9
+ - Run heartbeat scheduler/queue worker based on `BOPO_SCHEDULER_ROLE`.
10
+
11
+ ## Runtime Entry Points
12
+
13
+ - `src/server.ts` - startup, env loading, realtime bootstrap, scheduler ownership.
14
+ - `src/app.ts` - middleware, route mounting, error handling.
15
+ - `src/routes/*.ts` - route groups.
16
+ - `src/worker/scheduler.ts` - periodic sweep orchestration.
17
+
18
+ ## Local Development
19
+
20
+ From repository root (recommended):
21
+
22
+ - `pnpm dev` or `pnpm start`
23
+
24
+ From this package directly:
25
+
26
+ - `pnpm --filter bopodev-api dev`
27
+ - `pnpm --filter bopodev-api start`
28
+ - `pnpm --filter bopodev-api db:init`
29
+ - `pnpm --filter bopodev-api onboard:seed`
30
+ - `pnpm --filter bopodev-api workspaces:backfill`
31
+
32
+ Default port is `4020` (`API_PORT`/`PORT`).
33
+
34
+ ## Key Route Groups
35
+
36
+ - `/auth`, `/attention`, `/companies`, `/projects`, `/issues`, `/goals`
37
+ - `/agents`, `/governance`, `/heartbeats`, `/observability`
38
+ - `/plugins`, `/templates`
39
+
40
+ ## Related Docs
41
+
42
+ - `docs/developer/api-reference.md`
43
+ - `docs/developer/configuration-reference.md`
44
+ - `docs/operations/deployment.md`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bopodev-api",
3
- "version": "0.1.26",
3
+ "version": "0.1.27",
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-db": "0.1.26",
21
- "bopodev-agent-sdk": "0.1.26",
22
- "bopodev-contracts": "0.1.26"
20
+ "bopodev-contracts": "0.1.27",
21
+ "bopodev-agent-sdk": "0.1.27",
22
+ "bopodev-db": "0.1.27"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/cors": "^2.8.19",
@@ -0,0 +1,80 @@
1
+ import { isAbsolute, resolve } from "node:path";
2
+ import { isInsidePath, resolveCompanyWorkspaceRootPath } from "./instance-paths";
3
+
4
+ /**
5
+ * Resolves a run-report artifact to an absolute path under the company workspace, or null if invalid.
6
+ * Shared by observability download and heartbeat artifact verification.
7
+ */
8
+ export function resolveRunArtifactAbsolutePath(companyId: string, artifact: Record<string, unknown>) {
9
+ const companyWorkspaceRoot = resolveCompanyWorkspaceRootPath(companyId);
10
+ const absolutePathRaw = normalizeAbsoluteArtifactPath(
11
+ typeof artifact.absolutePath === "string" ? artifact.absolutePath.trim() : ""
12
+ );
13
+ const relativePathRaw = normalizeWorkspaceRelativeArtifactPath(
14
+ typeof artifact.relativePath === "string"
15
+ ? artifact.relativePath.trim()
16
+ : typeof artifact.path === "string"
17
+ ? artifact.path.trim()
18
+ : ""
19
+ );
20
+ const candidate = relativePathRaw
21
+ ? resolve(companyWorkspaceRoot, relativePathRaw)
22
+ : absolutePathRaw
23
+ ? absolutePathRaw
24
+ : "";
25
+ if (!candidate) {
26
+ return null;
27
+ }
28
+ const resolved = isAbsolute(candidate) ? resolve(candidate) : resolve(companyWorkspaceRoot, candidate);
29
+ if (!isInsidePath(companyWorkspaceRoot, resolved)) {
30
+ return null;
31
+ }
32
+ return resolved;
33
+ }
34
+
35
+ function normalizeAbsoluteArtifactPath(value: string) {
36
+ const trimmed = value.trim();
37
+ if (!trimmed || !isAbsolute(trimmed)) {
38
+ return "";
39
+ }
40
+ return resolve(trimmed);
41
+ }
42
+
43
+ function normalizeWorkspaceRelativeArtifactPath(value: string) {
44
+ const trimmed = value.trim();
45
+ if (!trimmed) {
46
+ return "";
47
+ }
48
+ const unixSeparated = trimmed.replace(/\\/g, "/");
49
+ if (isAbsolute(unixSeparated)) {
50
+ return "";
51
+ }
52
+ const parts: string[] = [];
53
+ for (const part of unixSeparated.split("/")) {
54
+ if (!part || part === ".") {
55
+ continue;
56
+ }
57
+ if (part === "..") {
58
+ if (parts.length > 0 && parts[parts.length - 1] !== "..") {
59
+ parts.pop();
60
+ } else {
61
+ parts.push(part);
62
+ }
63
+ continue;
64
+ }
65
+ parts.push(part);
66
+ }
67
+ const normalized = parts.join("/");
68
+ if (!normalized) {
69
+ return "";
70
+ }
71
+ const workspaceScopedMatch = normalized.match(/(?:^|\/)workspace\/([^/]+)\/(.+)$/);
72
+ if (!workspaceScopedMatch) {
73
+ return normalized;
74
+ }
75
+ const scopedRelativePath = workspaceScopedMatch[2];
76
+ if (!scopedRelativePath) {
77
+ return "";
78
+ }
79
+ return scopedRelativePath;
80
+ }
@@ -0,0 +1,23 @@
1
+ import type { ModelPricingCatalogRow } from "./types";
2
+
3
+ export const ANTHROPIC_MODEL_PRICING: ModelPricingCatalogRow[] = [
4
+ { modelId: "claude-opus-4-6", displayName: "Claude Opus 4.6", inputUsdPer1M: 5, outputUsdPer1M: 25 },
5
+ { modelId: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6", inputUsdPer1M: 3, outputUsdPer1M: 15 },
6
+ { modelId: "claude-sonnet-4-6-1m", displayName: "Claude Sonnet 4.6 (1M context)", inputUsdPer1M: 6, outputUsdPer1M: 22.5 },
7
+ { modelId: "claude-opus-4-6-1m", displayName: "Claude Opus 4.6 (1M context)", inputUsdPer1M: 10, outputUsdPer1M: 37.5 },
8
+ { modelId: "claude-haiku-4-5", displayName: "Claude Haiku 4.5", inputUsdPer1M: 1, outputUsdPer1M: 5 },
9
+ { modelId: "claude-sonnet-4-5-20250929", displayName: "Claude Sonnet 4.5", inputUsdPer1M: 3, outputUsdPer1M: 15 },
10
+ { modelId: "claude-haiku-4-5-20251001", displayName: "Claude Haiku 4.5", inputUsdPer1M: 1, outputUsdPer1M: 5 },
11
+ { modelId: "claude-opus-4.6", displayName: "Claude Opus 4.6", inputUsdPer1M: 5, outputUsdPer1M: 25 },
12
+ { modelId: "claude-opus-4.5", displayName: "Claude Opus 4.5", inputUsdPer1M: 5, outputUsdPer1M: 25 },
13
+ { modelId: "claude-opus-4.1", displayName: "Claude Opus 4.1", inputUsdPer1M: 15, outputUsdPer1M: 75 },
14
+ { modelId: "claude-opus-4", displayName: "Claude Opus 4", inputUsdPer1M: 15, outputUsdPer1M: 75 },
15
+ { modelId: "claude-sonnet-4.6", displayName: "Claude Sonnet 4.6", inputUsdPer1M: 3, outputUsdPer1M: 15 },
16
+ { modelId: "claude-sonnet-4.5", displayName: "Claude Sonnet 4.5", inputUsdPer1M: 3, outputUsdPer1M: 15 },
17
+ { modelId: "claude-sonnet-4", displayName: "Claude Sonnet 4", inputUsdPer1M: 3, outputUsdPer1M: 15 },
18
+ { modelId: "claude-sonnet-3.7", displayName: "Claude Sonnet 3.7", inputUsdPer1M: 3, outputUsdPer1M: 15 },
19
+ { modelId: "claude-haiku-4.5", displayName: "Claude Haiku 4.5", inputUsdPer1M: 1, outputUsdPer1M: 5 },
20
+ { modelId: "claude-haiku-3.5", displayName: "Claude Haiku 3.5", inputUsdPer1M: 0.8, outputUsdPer1M: 4 },
21
+ { modelId: "claude-opus-3", displayName: "Claude Opus 3", inputUsdPer1M: 15, outputUsdPer1M: 75 },
22
+ { modelId: "claude-haiku-3", displayName: "Claude Haiku 3", inputUsdPer1M: 0.25, outputUsdPer1M: 1.25 }
23
+ ];
@@ -0,0 +1,11 @@
1
+ import type { ModelPricingCatalogRow } from "./types";
2
+
3
+ export const GEMINI_MODEL_PRICING: ModelPricingCatalogRow[] = [
4
+ { modelId: "gemini-3.1-flash-lite", displayName: "Gemini 3.1 Flash Lite", inputUsdPer1M: 0.25, outputUsdPer1M: 1.5 },
5
+ { modelId: "gemini-3-flash", displayName: "Gemini 3 Flash", inputUsdPer1M: 0.5, outputUsdPer1M: 3 },
6
+ { modelId: "gemini-3-pro", displayName: "Gemini 3 Pro", inputUsdPer1M: 2, outputUsdPer1M: 12 },
7
+ { modelId: "gemini-3-pro-200k", displayName: "Gemini 3 Pro (>200k context)", inputUsdPer1M: 4, outputUsdPer1M: 18 },
8
+ { modelId: "gemini-2.5-flash-lite", displayName: "Gemini 2.5 Flash Lite", inputUsdPer1M: 0.1, outputUsdPer1M: 0.4 },
9
+ { modelId: "gemini-2.5-flash", displayName: "Gemini 2.5 Flash", inputUsdPer1M: 0.3, outputUsdPer1M: 2.5 },
10
+ { modelId: "gemini-2.5-pro", displayName: "Gemini 2.5 Pro", inputUsdPer1M: 1.25, outputUsdPer1M: 10 }
11
+ ];
@@ -0,0 +1,29 @@
1
+ import type { CanonicalPricingProvider, ModelPricingCatalogRow } from "./types";
2
+ import { OPENAI_MODEL_PRICING } from "./openai";
3
+ import { ANTHROPIC_MODEL_PRICING } from "./anthropic";
4
+ import { GEMINI_MODEL_PRICING } from "./gemini";
5
+ import { OPENCODE_MODEL_PRICING } from "./opencode";
6
+
7
+ const CATALOG_BY_PROVIDER: Record<CanonicalPricingProvider, ReadonlyMap<string, ModelPricingCatalogRow>> = {
8
+ openai_api: buildProviderCatalog(OPENAI_MODEL_PRICING),
9
+ anthropic_api: buildProviderCatalog(ANTHROPIC_MODEL_PRICING),
10
+ gemini_api: buildProviderCatalog(GEMINI_MODEL_PRICING),
11
+ opencode: buildProviderCatalog(OPENCODE_MODEL_PRICING)
12
+ };
13
+
14
+ export function getModelPricingCatalogRow(input: {
15
+ providerType: CanonicalPricingProvider;
16
+ modelId: string;
17
+ }) {
18
+ return CATALOG_BY_PROVIDER[input.providerType].get(input.modelId.trim()) ?? null;
19
+ }
20
+
21
+ function buildProviderCatalog(rows: ModelPricingCatalogRow[]) {
22
+ const normalizedRows = rows.map((row) => ({
23
+ ...row,
24
+ modelId: row.modelId.trim()
25
+ }));
26
+ return new Map(normalizedRows.map((row) => [row.modelId, row]));
27
+ }
28
+
29
+ export * from "./types";
@@ -0,0 +1,47 @@
1
+ import type { ModelPricingCatalogRow } from "./types";
2
+
3
+ export const OPENAI_MODEL_PRICING: ModelPricingCatalogRow[] = [
4
+ { modelId: "gpt-5.2", displayName: "GPT-5.2", inputUsdPer1M: 1.75, outputUsdPer1M: 14 },
5
+ { modelId: "gpt-5.1", displayName: "GPT-5.1", inputUsdPer1M: 1.25, outputUsdPer1M: 10 },
6
+ { modelId: "gpt-5", displayName: "GPT-5", inputUsdPer1M: 1.25, outputUsdPer1M: 10 },
7
+ { modelId: "gpt-5-mini", displayName: "GPT-5 Mini", inputUsdPer1M: 0.25, outputUsdPer1M: 2 },
8
+ { modelId: "gpt-5-nano", displayName: "GPT-5 Nano", inputUsdPer1M: 0.05, outputUsdPer1M: 0.4 },
9
+ { modelId: "gpt-5.3-chat-latest", displayName: "GPT-5.3 Chat Latest", inputUsdPer1M: 1.75, outputUsdPer1M: 14 },
10
+ { modelId: "gpt-5.2-chat-latest", displayName: "GPT-5.2 Chat Latest", inputUsdPer1M: 1.75, outputUsdPer1M: 14 },
11
+ { modelId: "gpt-5.1-chat-latest", displayName: "GPT-5.1 Chat Latest", inputUsdPer1M: 1.25, outputUsdPer1M: 10 },
12
+ { modelId: "gpt-5-chat-latest", displayName: "GPT-5 Chat Latest", inputUsdPer1M: 1.25, outputUsdPer1M: 10 },
13
+ { modelId: "gpt-5.4", displayName: "GPT-5.4", inputUsdPer1M: 1.75, outputUsdPer1M: 14 },
14
+ { modelId: "gpt-5.3-codex", displayName: "GPT-5.3 Codex", inputUsdPer1M: 1.75, outputUsdPer1M: 14 },
15
+ { modelId: "gpt-5.3-codex-spark", displayName: "GPT-5.3 Codex Spark", inputUsdPer1M: 1.75, outputUsdPer1M: 14 },
16
+ { modelId: "gpt-5.2-codex", displayName: "GPT-5.2 Codex", inputUsdPer1M: 1.75, outputUsdPer1M: 14 },
17
+ { modelId: "gpt-5.1-codex-max", displayName: "GPT-5.1 Codex Max", inputUsdPer1M: 1.25, outputUsdPer1M: 10 },
18
+ { modelId: "gpt-5.1-codex-mini", displayName: "GPT-5.1 Codex Mini", inputUsdPer1M: 0.25, outputUsdPer1M: 2 },
19
+ { modelId: "gpt-5.1-codex", displayName: "GPT-5.1 Codex", inputUsdPer1M: 1.25, outputUsdPer1M: 10 },
20
+ { modelId: "gpt-5-codex", displayName: "GPT-5 Codex", inputUsdPer1M: 1.25, outputUsdPer1M: 10 },
21
+ { modelId: "gpt-5.2-pro", displayName: "GPT-5.2 Pro", inputUsdPer1M: 21, outputUsdPer1M: 168 },
22
+ { modelId: "gpt-5-pro", displayName: "GPT-5 Pro", inputUsdPer1M: 15, outputUsdPer1M: 120 },
23
+ { modelId: "gpt-4.1", displayName: "GPT-4.1", inputUsdPer1M: 2, outputUsdPer1M: 8 },
24
+ { modelId: "gpt-4.1-mini", displayName: "GPT-4.1 Mini", inputUsdPer1M: 0.4, outputUsdPer1M: 1.6 },
25
+ { modelId: "gpt-4.1-nano", displayName: "GPT-4.1 Nano", inputUsdPer1M: 0.1, outputUsdPer1M: 0.4 },
26
+ { modelId: "gpt-4o", displayName: "GPT-4o", inputUsdPer1M: 2.5, outputUsdPer1M: 10 },
27
+ { modelId: "gpt-4o-2024-05-13", displayName: "GPT-4o 2024-05-13", inputUsdPer1M: 5, outputUsdPer1M: 15 },
28
+ { modelId: "gpt-4o-mini", displayName: "GPT-4o Mini", inputUsdPer1M: 0.15, outputUsdPer1M: 0.6 },
29
+ { modelId: "gpt-realtime", displayName: "GPT Realtime", inputUsdPer1M: 4, outputUsdPer1M: 16 },
30
+ { modelId: "gpt-realtime-1.5", displayName: "GPT Realtime 1.5", inputUsdPer1M: 4, outputUsdPer1M: 16 },
31
+ { modelId: "gpt-realtime-mini", displayName: "GPT Realtime Mini", inputUsdPer1M: 0.6, outputUsdPer1M: 2.4 },
32
+ { modelId: "gpt-4o-realtime-preview", displayName: "GPT-4o Realtime Preview", inputUsdPer1M: 5, outputUsdPer1M: 20 },
33
+ { modelId: "gpt-4o-mini-realtime-preview", displayName: "GPT-4o Mini Realtime Preview", inputUsdPer1M: 0.6, outputUsdPer1M: 2.4 },
34
+ { modelId: "gpt-audio", displayName: "GPT Audio", inputUsdPer1M: 2.5, outputUsdPer1M: 10 },
35
+ { modelId: "gpt-audio-1.5", displayName: "GPT Audio 1.5", inputUsdPer1M: 2.5, outputUsdPer1M: 10 },
36
+ { modelId: "gpt-audio-mini", displayName: "GPT Audio Mini", inputUsdPer1M: 0.6, outputUsdPer1M: 2.4 },
37
+ { modelId: "gpt-4o-audio-preview", displayName: "GPT-4o Audio Preview", inputUsdPer1M: 2.5, outputUsdPer1M: 10 },
38
+ { modelId: "gpt-4o-mini-audio-preview", displayName: "GPT-4o Mini Audio Preview", inputUsdPer1M: 0.15, outputUsdPer1M: 0.6 },
39
+ { modelId: "o1", displayName: "o1", inputUsdPer1M: 15, outputUsdPer1M: 60 },
40
+ { modelId: "o1-pro", displayName: "o1-pro", inputUsdPer1M: 150, outputUsdPer1M: 600 },
41
+ { modelId: "o3-pro", displayName: "o3-pro", inputUsdPer1M: 20, outputUsdPer1M: 80 },
42
+ { modelId: "o3", displayName: "o3", inputUsdPer1M: 2, outputUsdPer1M: 8 },
43
+ { modelId: "o3-deep-research", displayName: "o3 Deep Research", inputUsdPer1M: 10, outputUsdPer1M: 40 },
44
+ { modelId: "o4-mini", displayName: "o4-mini", inputUsdPer1M: 1.1, outputUsdPer1M: 4.4 },
45
+ { modelId: "o4-mini-deep-research", displayName: "o4-mini Deep Research", inputUsdPer1M: 2, outputUsdPer1M: 8 },
46
+ { modelId: "o3-mini", displayName: "o3-mini", inputUsdPer1M: 1.1, outputUsdPer1M: 4.4 }
47
+ ];
@@ -0,0 +1,5 @@
1
+ import type { ModelPricingCatalogRow } from "./types";
2
+
3
+ // OpenCode runtime pricing is not publicly published in a stable token-price format.
4
+ // Keep this catalog explicit and empty until canonical rates are available.
5
+ export const OPENCODE_MODEL_PRICING: ModelPricingCatalogRow[] = [];
@@ -0,0 +1,8 @@
1
+ export type CanonicalPricingProvider = "openai_api" | "anthropic_api" | "gemini_api" | "opencode";
2
+
3
+ export interface ModelPricingCatalogRow {
4
+ modelId: string;
5
+ displayName: string;
6
+ inputUsdPer1M: number;
7
+ outputUsdPer1M: number;
8
+ }
@@ -11,7 +11,6 @@ import { buildDefaultCeoBootstrapPrompt } from "../lib/ceo-bootstrap-prompt";
11
11
  import { resolveOpencodeRuntimeModel } from "../lib/opencode-model";
12
12
  import { resolveDefaultRuntimeCwdForCompany } from "../lib/workspace-policy";
13
13
  import { canAccessCompany, requireBoardRole, requirePermission } from "../middleware/request-actor";
14
- import { ensureCompanyModelPricingDefaults } from "../services/model-pricing";
15
14
  import { ensureCompanyBuiltinPluginDefaults } from "../services/plugin-runtime";
16
15
  import { ensureCompanyBuiltinTemplateDefaults } from "../services/template-catalog";
17
16
 
@@ -109,7 +108,6 @@ export function createCompaniesRouter(ctx: AppContext) {
109
108
  });
110
109
  await ensureCompanyBuiltinPluginDefaults(ctx.db, company.id);
111
110
  await ensureCompanyBuiltinTemplateDefaults(ctx.db, company.id);
112
- await ensureCompanyModelPricingDefaults(ctx.db, company.id);
113
111
  return sendOk(res, company);
114
112
  });
115
113
 
@@ -4,7 +4,7 @@ import { basename, extname, join, resolve } from "node:path";
4
4
  import { and, desc, eq, inArray } from "drizzle-orm";
5
5
  import multer from "multer";
6
6
  import { z } from "zod";
7
- import { IssueSchema } from "bopodev-contracts";
7
+ import { IssueDetailSchema, IssueSchema } from "bopodev-contracts";
8
8
  import {
9
9
  addIssueAttachment,
10
10
  addIssueComment,
@@ -15,6 +15,7 @@ import {
15
15
  deleteIssueAttachment,
16
16
  deleteIssueComment,
17
17
  deleteIssue,
18
+ getIssue,
18
19
  heartbeatRuns,
19
20
  getIssueAttachment,
20
21
  issues,
@@ -190,6 +191,20 @@ export function createIssuesRouter(ctx: AppContext) {
190
191
  );
191
192
  });
192
193
 
194
+ router.get("/:issueId", async (req, res) => {
195
+ const issueId = req.params.issueId;
196
+ const issueRow = await getIssue(ctx.db, req.companyId!, issueId);
197
+ if (!issueRow) {
198
+ return sendError(res, "Issue not found.", 404);
199
+ }
200
+ const base = toIssueResponse(issueRow as unknown as Record<string, unknown>);
201
+ const attachmentRows = await listIssueAttachments(ctx.db, req.companyId!, issueId);
202
+ const attachments = attachmentRows.map((row) =>
203
+ toIssueAttachmentResponse(row as unknown as Record<string, unknown>, issueId)
204
+ );
205
+ return sendOkValidated(res, IssueDetailSchema, { ...base, attachments }, "issues.detail");
206
+ });
207
+
193
208
  router.post("/", async (req, res) => {
194
209
  requirePermission("issues:write")(req, res, () => {});
195
210
  if (res.headersSent) {
@@ -1,7 +1,6 @@
1
1
  import { Router } from "express";
2
2
  import { readFile, stat } from "node:fs/promises";
3
- import { basename, isAbsolute, resolve } from "node:path";
4
- import { z } from "zod";
3
+ import { basename, resolve } from "node:path";
5
4
  import {
6
5
  getHeartbeatRun,
7
6
  listCompanies,
@@ -11,15 +10,12 @@ import {
11
10
  listGoals,
12
11
  listHeartbeatRunMessages,
13
12
  listHeartbeatRuns,
14
- listModelPricing,
15
- listPluginRuns,
16
- upsertModelPricing
13
+ listPluginRuns
17
14
  } from "bopodev-db";
18
15
  import type { AppContext } from "../context";
19
16
  import { sendError, sendOk } from "../http";
20
- import { isInsidePath, resolveCompanyWorkspaceRootPath } from "../lib/instance-paths";
17
+ import { resolveRunArtifactAbsolutePath } from "../lib/run-artifact-paths";
21
18
  import { requireCompanyScope } from "../middleware/company-scope";
22
- import { requirePermission } from "../middleware/request-actor";
23
19
  import { listAgentMemoryFiles, loadAgentMemoryContext, readAgentMemoryFile } from "../services/memory-file-service";
24
20
 
25
21
  export function createObservabilityRouter(ctx: AppContext) {
@@ -48,56 +44,6 @@ export function createObservabilityRouter(ctx: AppContext) {
48
44
  );
49
45
  });
50
46
 
51
- const modelPricingUpdateSchema = z.object({
52
- providerType: z.string().min(1),
53
- modelId: z.string().min(1),
54
- displayName: z.string().min(1).optional(),
55
- inputUsdPer1M: z.number().min(0),
56
- outputUsdPer1M: z.number().min(0),
57
- currency: z.string().min(1).optional()
58
- });
59
-
60
- router.get("/models/pricing", async (req, res) => {
61
- const rows = await listModelPricing(ctx.db, req.companyId!);
62
- return sendOk(
63
- res,
64
- rows.map((row) => ({
65
- companyId: row.companyId,
66
- providerType: row.providerType,
67
- modelId: row.modelId,
68
- displayName: row.displayName,
69
- inputUsdPer1M: typeof row.inputUsdPer1M === "number" ? row.inputUsdPer1M : Number(row.inputUsdPer1M ?? 0),
70
- outputUsdPer1M: typeof row.outputUsdPer1M === "number" ? row.outputUsdPer1M : Number(row.outputUsdPer1M ?? 0),
71
- currency: row.currency,
72
- updatedAt: row.updatedAt?.toISOString?.() ?? null,
73
- updatedBy: row.updatedBy ?? null
74
- }))
75
- );
76
- });
77
-
78
- router.put("/models/pricing", async (req, res) => {
79
- requirePermission("observability:write")(req, res, () => {});
80
- if (res.headersSent) {
81
- return;
82
- }
83
- const parsed = modelPricingUpdateSchema.safeParse(req.body);
84
- if (!parsed.success) {
85
- return sendError(res, parsed.error.message, 422);
86
- }
87
- const payload = parsed.data;
88
- await upsertModelPricing(ctx.db, {
89
- companyId: req.companyId!,
90
- providerType: payload.providerType,
91
- modelId: payload.modelId,
92
- displayName: payload.displayName ?? null,
93
- inputUsdPer1M: payload.inputUsdPer1M.toFixed(6),
94
- outputUsdPer1M: payload.outputUsdPer1M.toFixed(6),
95
- currency: payload.currency ?? "USD",
96
- updatedBy: req.actor?.id ?? null
97
- });
98
- return sendOk(res, { ok: true });
99
- });
100
-
101
47
  router.get("/heartbeats", async (req, res) => {
102
48
  const companyId = req.companyId!;
103
49
  const rawLimit = Number(req.query.limit ?? 100);
@@ -404,85 +350,6 @@ function toRecord(value: unknown) {
404
350
  return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : null;
405
351
  }
406
352
 
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
-
486
353
  function serializeRunRow(
487
354
  run: {
488
355
  id: string;
@@ -30,7 +30,6 @@ import {
30
30
  } from "../lib/instance-paths";
31
31
  import { buildDefaultCeoBootstrapPrompt } from "../lib/ceo-bootstrap-prompt";
32
32
  import { resolveDefaultRuntimeCwdForCompany } from "../lib/workspace-policy";
33
- import { ensureCompanyModelPricingDefaults } from "../services/model-pricing";
34
33
  import { applyTemplateManifest } from "../services/template-apply-service";
35
34
  import { ensureCompanyBuiltinTemplateDefaults } from "../services/template-catalog";
36
35
 
@@ -211,8 +210,6 @@ export async function ensureOnboardingSeed(input: {
211
210
  templateApplied = true;
212
211
  appliedTemplateId = template.id;
213
212
  }
214
- await ensureCompanyModelPricingDefaults(db, companyId);
215
-
216
213
  return {
217
214
  companyId,
218
215
  companyName: resolvedCompanyName,
@@ -313,6 +310,7 @@ async function ensureCeoStartupTask(
313
310
  "Stand up your leadership operating baseline before taking on additional delivery work.",
314
311
  "",
315
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.",
316
314
  "2. Author these files with your own voice and responsibilities:",
317
315
  ` - \`${ceoOperatingFolder}/AGENTS.md\``,
318
316
  ` - \`${ceoOperatingFolder}/HEARTBEAT.md\``,
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
  }
@@ -777,6 +777,7 @@ function buildAgentStartupTaskBody(companyId: string, agentId: string) {
777
777
  `Create your operating baseline before starting feature delivery work.`,
778
778
  "",
779
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.",
780
781
  "2. Author these files with your own responsibilities and working style:",
781
782
  ` - \`${agentOperatingFolder}/AGENTS.md\``,
782
783
  ` - \`${agentOperatingFolder}/HEARTBEAT.md\``,
@@ -1,9 +1,9 @@
1
- import { mkdir } from "node:fs/promises";
1
+ import { mkdir, stat } from "node:fs/promises";
2
2
  import { isAbsolute, join, relative, resolve } from "node:path";
3
3
  import { and, desc, eq, inArray, sql } from "drizzle-orm";
4
4
  import { nanoid } from "nanoid";
5
5
  import { resolveAdapter } from "bopodev-agent-sdk";
6
- import type { AgentState, HeartbeatContext } from "bopodev-agent-sdk";
6
+ import type { AdapterExecutionResult, AgentState, HeartbeatContext } from "bopodev-agent-sdk";
7
7
  import {
8
8
  type AgentFinalRunOutput,
9
9
  ControlPlaneHeadersJsonSchema,
@@ -41,7 +41,13 @@ import {
41
41
  resolveCompanyWorkspaceRootPath,
42
42
  resolveProjectWorkspacePath
43
43
  } from "../lib/instance-paths";
44
- import { assertRuntimeCwdForCompany, getProjectWorkspaceContextMap, hasText, resolveAgentFallbackWorkspace } from "../lib/workspace-policy";
44
+ import { resolveRunArtifactAbsolutePath } from "../lib/run-artifact-paths";
45
+ import {
46
+ assertRuntimeCwdForCompany,
47
+ getProjectWorkspaceContextMap,
48
+ hasText,
49
+ resolveAgentFallbackWorkspace
50
+ } from "../lib/workspace-policy";
45
51
  import type { RealtimeHub } from "../realtime/hub";
46
52
  import { createHeartbeatRunsRealtimeEvent } from "../realtime/heartbeat-runs";
47
53
  import { publishAttentionSnapshot } from "../realtime/attention";
@@ -892,6 +898,7 @@ export async function runHeartbeatForAgent(
892
898
  failClosed: false
893
899
  });
894
900
  const isCommentOrderWake = options?.wakeContext?.reason === "issue_comment_recipient";
901
+ const heartbeatIdlePolicy = resolveHeartbeatIdlePolicy();
895
902
  const workItems = isCommentOrderWake ? [] : await claimIssuesForAgent(db, companyId, agentId, runId);
896
903
  const wakeWorkItems = await loadWakeContextWorkItems(db, companyId, options?.wakeContext?.issueIds);
897
904
  const contextWorkItems = resolveExecutionWorkItems(workItems, wakeWorkItems, options?.wakeContext);
@@ -970,6 +977,7 @@ export async function runHeartbeatForAgent(
970
977
  contextWorkItems,
971
978
  mergedRuntime
972
979
  );
980
+ await mkdir(join(resolveAgentFallbackWorkspace(companyId, agent.id), "operating"), { recursive: true });
973
981
  state = {
974
982
  ...state,
975
983
  runtime: workspaceResolution.runtime
@@ -1005,6 +1013,10 @@ export async function runHeartbeatForAgent(
1005
1013
  ...context,
1006
1014
  memoryContext
1007
1015
  };
1016
+ const isIdleNoWork = contextWorkItems.length === 0 && !isCommentOrderWake;
1017
+ if (heartbeatIdlePolicy === "micro_prompt" && isIdleNoWork) {
1018
+ context = { ...context, idleMicroPrompt: true };
1019
+ }
1008
1020
  if (workspaceResolution.warnings.length > 0) {
1009
1021
  await appendAuditEvent(db, {
1010
1022
  companyId,
@@ -1170,19 +1182,34 @@ export async function runHeartbeatForAgent(
1170
1182
  };
1171
1183
  }
1172
1184
 
1173
- const execution = await executeAdapterWithWatchdog({
1174
- execute: (abortSignal) =>
1175
- adapter.execute({
1176
- ...context,
1177
- runtime: {
1178
- ...(context.runtime ?? {}),
1179
- abortSignal
1185
+ const execution: AdapterExecutionResult =
1186
+ heartbeatIdlePolicy === "skip_adapter" && isIdleNoWork
1187
+ ? {
1188
+ status: "ok",
1189
+ summary:
1190
+ "Idle heartbeat: no assigned work items; adapter not invoked (BOPO_HEARTBEAT_IDLE_POLICY=skip_adapter).",
1191
+ tokenInput: 0,
1192
+ tokenOutput: 0,
1193
+ usdCost: 0,
1194
+ usage: {
1195
+ inputTokens: 0,
1196
+ cachedInputTokens: 0,
1197
+ outputTokens: 0
1198
+ }
1180
1199
  }
1181
- }),
1182
- providerType: agent.providerType as HeartbeatProviderType,
1183
- runtime: workspaceResolution.runtime,
1184
- externalAbortSignal: activeRunAbort.signal
1185
- });
1200
+ : await executeAdapterWithWatchdog({
1201
+ execute: (abortSignal) =>
1202
+ adapter.execute({
1203
+ ...context,
1204
+ runtime: {
1205
+ ...(context.runtime ?? {}),
1206
+ abortSignal
1207
+ }
1208
+ }),
1209
+ providerType: agent.providerType as HeartbeatProviderType,
1210
+ runtime: workspaceResolution.runtime,
1211
+ externalAbortSignal: activeRunAbort.signal
1212
+ });
1186
1213
  const usageLimitHint = execution.dispositionHint?.kind === "provider_usage_limited" ? execution.dispositionHint : null;
1187
1214
  if (usageLimitHint) {
1188
1215
  providerUsageLimitDisposition = {
@@ -1454,6 +1481,7 @@ export async function runHeartbeatForAgent(
1454
1481
  cost: runCost,
1455
1482
  runtimeCwd: workspaceResolution.runtime.cwd
1456
1483
  });
1484
+ await verifyRunArtifactsOnDisk(companyId, runReport.artifacts);
1457
1485
  emitCanonicalResultEvent(runReport.resultSummary, runReport.finalStatus);
1458
1486
  const runListMessage = buildRunListMessageFromReport(runReport);
1459
1487
  await db
@@ -1823,6 +1851,7 @@ export async function runHeartbeatForAgent(
1823
1851
  errorType: classified.type,
1824
1852
  errorMessage: classified.message
1825
1853
  });
1854
+ await verifyRunArtifactsOnDisk(companyId, runReport.artifacts);
1826
1855
  const runListMessage = buildRunListMessageFromReport(runReport);
1827
1856
  await db
1828
1857
  .update(heartbeatRuns)
@@ -2357,6 +2386,7 @@ async function buildHeartbeatContext(
2357
2386
  fileSizeBytes: number;
2358
2387
  relativePath: string;
2359
2388
  absolutePath: string;
2389
+ downloadPath: string;
2360
2390
  }>
2361
2391
  >();
2362
2392
  for (const row of attachmentRows) {
@@ -2372,7 +2402,8 @@ async function buildHeartbeatContext(
2372
2402
  mimeType: row.mimeType,
2373
2403
  fileSizeBytes: row.fileSizeBytes,
2374
2404
  relativePath: row.relativePath,
2375
- absolutePath
2405
+ absolutePath,
2406
+ downloadPath: `/issues/${row.issueId}/attachments/${row.id}/download`
2376
2407
  });
2377
2408
  attachmentsByIssue.set(row.issueId, existing);
2378
2409
  }
@@ -2400,12 +2431,14 @@ async function buildHeartbeatContext(
2400
2431
  .filter((goal) => goal.status === "active" && goal.level === "agent")
2401
2432
  .map((goal) => goal.title);
2402
2433
  const isCommentOrderWake = input.wakeContext?.reason === "issue_comment_recipient";
2434
+ const promptMode = resolveHeartbeatPromptMode();
2403
2435
 
2404
2436
  return {
2405
2437
  companyId,
2406
2438
  agentId: input.agentId,
2407
2439
  providerType: input.providerType,
2408
2440
  heartbeatRunId: input.heartbeatRunId,
2441
+ promptMode,
2409
2442
  company: {
2410
2443
  name: company?.name ?? "Unknown company",
2411
2444
  mission: company?.mission ?? null
@@ -3054,6 +3087,26 @@ function buildRunArtifacts(input: {
3054
3087
  });
3055
3088
  }
3056
3089
 
3090
+ async function verifyRunArtifactsOnDisk(companyId: string, artifacts: RunArtifact[]) {
3091
+ for (const artifact of artifacts) {
3092
+ const resolved = resolveRunArtifactAbsolutePath(companyId, {
3093
+ path: artifact.path,
3094
+ relativePath: artifact.relativePath ?? undefined,
3095
+ absolutePath: artifact.absolutePath ?? undefined
3096
+ });
3097
+ if (!resolved) {
3098
+ artifact.verifiedOnDisk = false;
3099
+ continue;
3100
+ }
3101
+ try {
3102
+ const stats = await stat(resolved);
3103
+ artifact.verifiedOnDisk = stats.isFile();
3104
+ } catch {
3105
+ artifact.verifiedOnDisk = false;
3106
+ }
3107
+ }
3108
+ }
3109
+
3057
3110
  function toNormalizedWorkspaceRelativePath(inputPath: string | null | undefined) {
3058
3111
  const trimmed = inputPath?.trim();
3059
3112
  if (!trimmed) {
@@ -3094,11 +3147,9 @@ function normalizeAgentOperatingArtifactRelativePath(pathValue: string | null, c
3094
3147
  if (!parsed) {
3095
3148
  return null;
3096
3149
  }
3097
- const embeddedCompanyId = parsed[1]?.trim() || companyId;
3098
3150
  const agentId = parsed[2];
3099
3151
  const suffix = parsed[3] ?? "";
3100
- const effectiveCompanyId = embeddedCompanyId;
3101
- return `workspace/${effectiveCompanyId}/agents/${agentId}/operating${suffix}`;
3152
+ return `workspace/${companyId}/agents/${agentId}/operating${suffix}`;
3102
3153
  }
3103
3154
  const directMatch = normalized.match(/^agents\/([^/]+)\/operating(\/.*)?$/);
3104
3155
  if (directMatch) {
@@ -3323,6 +3374,9 @@ function formatRunArtifactMarkdownLink(
3323
3374
  if (!label) {
3324
3375
  return "`artifact`";
3325
3376
  }
3377
+ if (artifact.verifiedOnDisk === false) {
3378
+ return `\`${label}\` (not found under company workspace at run completion)`;
3379
+ }
3326
3380
  if (!href) {
3327
3381
  return `\`${label}\``;
3328
3382
  }
@@ -4291,6 +4345,24 @@ function clearResumeState(
4291
4345
  };
4292
4346
  }
4293
4347
 
4348
+ function resolveHeartbeatPromptMode(): "full" | "compact" {
4349
+ const raw = process.env.BOPO_HEARTBEAT_PROMPT_MODE?.trim().toLowerCase();
4350
+ return raw === "compact" ? "compact" : "full";
4351
+ }
4352
+
4353
+ type HeartbeatIdlePolicy = "full" | "skip_adapter" | "micro_prompt";
4354
+
4355
+ function resolveHeartbeatIdlePolicy(): HeartbeatIdlePolicy {
4356
+ const raw = process.env.BOPO_HEARTBEAT_IDLE_POLICY?.trim().toLowerCase();
4357
+ if (raw === "skip_adapter") {
4358
+ return "skip_adapter";
4359
+ }
4360
+ if (raw === "micro_prompt") {
4361
+ return "micro_prompt";
4362
+ }
4363
+ return "full";
4364
+ }
4365
+
4294
4366
  function resolveControlPlaneEnv(runtimeEnv: Record<string, string>, suffix: string) {
4295
4367
  const next = runtimeEnv[`BOPODEV_${suffix}`];
4296
4368
  return hasText(next) ? (next as string) : "";
@@ -4307,8 +4379,13 @@ function buildHeartbeatRuntimeEnv(input: {
4307
4379
  canHireAgents: boolean;
4308
4380
  wakeContext?: HeartbeatWakeContext;
4309
4381
  }) {
4382
+ const companyWorkspaceRoot = resolveCompanyWorkspaceRootPath(input.companyId);
4383
+ const agentHome = resolveAgentFallbackWorkspace(input.companyId, input.agentId);
4384
+ const agentOperatingDir = join(agentHome, "operating");
4310
4385
  const apiBaseUrl = resolveControlPlaneApiBaseUrl();
4311
- const actorPermissions = ["issues:write", ...(input.canHireAgents ? ["agents:write"] : [])].join(",");
4386
+ // agents:write is required for PUT /agents/:self (bootstrapPrompt, runtimeConfig). Route handlers
4387
+ // still forbid agents from updating other agents' rows and from POST /agents unless canHireAgents.
4388
+ const actorPermissions = ["issues:write", "agents:write"].join(",");
4312
4389
  const actorHeaders = JSON.stringify({
4313
4390
  "x-company-id": input.companyId,
4314
4391
  "x-actor-type": "agent",
@@ -4322,7 +4399,12 @@ function buildHeartbeatRuntimeEnv(input: {
4322
4399
  return {
4323
4400
  BOPODEV_AGENT_ID: input.agentId,
4324
4401
  BOPODEV_COMPANY_ID: input.companyId,
4402
+ BOPODEV_COMPANY_WORKSPACE_ROOT: companyWorkspaceRoot,
4403
+ BOPODEV_AGENT_HOME: agentHome,
4404
+ BOPODEV_AGENT_OPERATING_DIR: agentOperatingDir,
4325
4405
  BOPODEV_RUN_ID: input.heartbeatRunId,
4406
+ BOPODEV_HEARTBEAT_PROMPT_MODE: resolveHeartbeatPromptMode(),
4407
+ BOPODEV_HEARTBEAT_IDLE_POLICY: resolveHeartbeatIdlePolicy(),
4326
4408
  BOPODEV_FORCE_MANAGED_CODEX_HOME: "false",
4327
4409
  BOPODEV_API_BASE_URL: apiBaseUrl,
4328
4410
  BOPODEV_API_URL: apiBaseUrl,
@@ -1,129 +1,6 @@
1
1
  import type { BopoDb } from "bopodev-db";
2
- import { getModelPricing, upsertModelPricing } from "bopodev-db";
3
-
4
- type SeedModelPricingRow = {
5
- providerType: "openai_api" | "anthropic_api" | "gemini_api";
6
- modelId: string;
7
- displayName: string;
8
- inputUsdPer1M: number;
9
- outputUsdPer1M: number;
10
- };
11
-
12
- const OPENAI_MODEL_BASE_PRICES: Array<{
13
- modelId: string;
14
- displayName: string;
15
- inputUsdPer1M: number;
16
- outputUsdPer1M: number;
17
- }> = [
18
- { modelId: "gpt-5.2", displayName: "GPT-5.2", inputUsdPer1M: 1.75, outputUsdPer1M: 14 },
19
- { modelId: "gpt-5.1", displayName: "GPT-5.1", inputUsdPer1M: 1.25, outputUsdPer1M: 10 },
20
- { modelId: "gpt-5", displayName: "GPT-5", inputUsdPer1M: 1.25, outputUsdPer1M: 10 },
21
- { modelId: "gpt-5-mini", displayName: "GPT-5 Mini", inputUsdPer1M: 0.25, outputUsdPer1M: 2 },
22
- { modelId: "gpt-5-nano", displayName: "GPT-5 Nano", inputUsdPer1M: 0.05, outputUsdPer1M: 0.4 },
23
- { modelId: "gpt-5.3-chat-latest", displayName: "GPT-5.3 Chat Latest", inputUsdPer1M: 1.75, outputUsdPer1M: 14 },
24
- { modelId: "gpt-5.2-chat-latest", displayName: "GPT-5.2 Chat Latest", inputUsdPer1M: 1.75, outputUsdPer1M: 14 },
25
- { modelId: "gpt-5.1-chat-latest", displayName: "GPT-5.1 Chat Latest", inputUsdPer1M: 1.25, outputUsdPer1M: 10 },
26
- { modelId: "gpt-5-chat-latest", displayName: "GPT-5 Chat Latest", inputUsdPer1M: 1.25, outputUsdPer1M: 10 },
27
- { modelId: "gpt-5.4", displayName: "GPT-5.4", inputUsdPer1M: 1.75, outputUsdPer1M: 14 },
28
- { modelId: "gpt-5.3-codex", displayName: "GPT-5.3 Codex", inputUsdPer1M: 1.75, outputUsdPer1M: 14 },
29
- { modelId: "gpt-5.3-codex-spark", displayName: "GPT-5.3 Codex Spark", inputUsdPer1M: 1.75, outputUsdPer1M: 14 },
30
- { modelId: "gpt-5.2-codex", displayName: "GPT-5.2 Codex", inputUsdPer1M: 1.75, outputUsdPer1M: 14 },
31
- { modelId: "gpt-5.1-codex-max", displayName: "GPT-5.1 Codex Max", inputUsdPer1M: 1.25, outputUsdPer1M: 10 },
32
- { modelId: "gpt-5.1-codex-mini", displayName: "GPT-5.1 Codex Mini", inputUsdPer1M: 0.25, outputUsdPer1M: 2 },
33
- { modelId: "gpt-5.1-codex", displayName: "GPT-5.1 Codex", inputUsdPer1M: 1.25, outputUsdPer1M: 10 },
34
- { modelId: "gpt-5-codex", displayName: "GPT-5 Codex", inputUsdPer1M: 1.25, outputUsdPer1M: 10 },
35
- { modelId: "gpt-5.2-pro", displayName: "GPT-5.2 Pro", inputUsdPer1M: 21, outputUsdPer1M: 168 },
36
- { modelId: "gpt-5-pro", displayName: "GPT-5 Pro", inputUsdPer1M: 15, outputUsdPer1M: 120 },
37
- { modelId: "gpt-4.1", displayName: "GPT-4.1", inputUsdPer1M: 2, outputUsdPer1M: 8 },
38
- { modelId: "gpt-4.1-mini", displayName: "GPT-4.1 Mini", inputUsdPer1M: 0.4, outputUsdPer1M: 1.6 },
39
- { modelId: "gpt-4.1-nano", displayName: "GPT-4.1 Nano", inputUsdPer1M: 0.1, outputUsdPer1M: 0.4 },
40
- { modelId: "gpt-4o", displayName: "GPT-4o", inputUsdPer1M: 2.5, outputUsdPer1M: 10 },
41
- { modelId: "gpt-4o-2024-05-13", displayName: "GPT-4o 2024-05-13", inputUsdPer1M: 5, outputUsdPer1M: 15 },
42
- { modelId: "gpt-4o-mini", displayName: "GPT-4o Mini", inputUsdPer1M: 0.15, outputUsdPer1M: 0.6 },
43
- { modelId: "gpt-realtime", displayName: "GPT Realtime", inputUsdPer1M: 4, outputUsdPer1M: 16 },
44
- { modelId: "gpt-realtime-1.5", displayName: "GPT Realtime 1.5", inputUsdPer1M: 4, outputUsdPer1M: 16 },
45
- { modelId: "gpt-realtime-mini", displayName: "GPT Realtime Mini", inputUsdPer1M: 0.6, outputUsdPer1M: 2.4 },
46
- { modelId: "gpt-4o-realtime-preview", displayName: "GPT-4o Realtime Preview", inputUsdPer1M: 5, outputUsdPer1M: 20 },
47
- { modelId: "gpt-4o-mini-realtime-preview", displayName: "GPT-4o Mini Realtime Preview", inputUsdPer1M: 0.6, outputUsdPer1M: 2.4 },
48
- { modelId: "gpt-audio", displayName: "GPT Audio", inputUsdPer1M: 2.5, outputUsdPer1M: 10 },
49
- { modelId: "gpt-audio-1.5", displayName: "GPT Audio 1.5", inputUsdPer1M: 2.5, outputUsdPer1M: 10 },
50
- { modelId: "gpt-audio-mini", displayName: "GPT Audio Mini", inputUsdPer1M: 0.6, outputUsdPer1M: 2.4 },
51
- { modelId: "gpt-4o-audio-preview", displayName: "GPT-4o Audio Preview", inputUsdPer1M: 2.5, outputUsdPer1M: 10 },
52
- { modelId: "gpt-4o-mini-audio-preview", displayName: "GPT-4o Mini Audio Preview", inputUsdPer1M: 0.15, outputUsdPer1M: 0.6 },
53
- { modelId: "o1", displayName: "o1", inputUsdPer1M: 15, outputUsdPer1M: 60 },
54
- { modelId: "o1-pro", displayName: "o1-pro", inputUsdPer1M: 150, outputUsdPer1M: 600 },
55
- { modelId: "o3-pro", displayName: "o3-pro", inputUsdPer1M: 20, outputUsdPer1M: 80 },
56
- { modelId: "o3", displayName: "o3", inputUsdPer1M: 2, outputUsdPer1M: 8 },
57
- { modelId: "o3-deep-research", displayName: "o3 Deep Research", inputUsdPer1M: 10, outputUsdPer1M: 40 },
58
- { modelId: "o4-mini", displayName: "o4-mini", inputUsdPer1M: 1.1, outputUsdPer1M: 4.4 },
59
- { modelId: "o4-mini-deep-research", displayName: "o4-mini Deep Research", inputUsdPer1M: 2, outputUsdPer1M: 8 },
60
- { modelId: "o3-mini", displayName: "o3-mini", inputUsdPer1M: 1.1, outputUsdPer1M: 4.4 }
61
- ];
62
-
63
- const CLAUDE_MODEL_BASE_PRICES: Array<{
64
- modelId: string;
65
- displayName: string;
66
- inputUsdPer1M: number;
67
- outputUsdPer1M: number;
68
- }> = [
69
- // Runtime ids currently used in provider model selectors.
70
- { modelId: "claude-opus-4-6", displayName: "Claude Opus 4.6", inputUsdPer1M: 5, outputUsdPer1M: 25 },
71
- { modelId: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6", inputUsdPer1M: 3, outputUsdPer1M: 15 },
72
- { modelId: "claude-sonnet-4-6-1m", displayName: "Claude Sonnet 4.6 (1M context)", inputUsdPer1M: 6, outputUsdPer1M: 22.5 },
73
- { modelId: "claude-opus-4-6-1m", displayName: "Claude Opus 4.6 (1M context)", inputUsdPer1M: 10, outputUsdPer1M: 37.5 },
74
- { modelId: "claude-haiku-4-5", displayName: "Claude Haiku 4.5", inputUsdPer1M: 1, outputUsdPer1M: 5 },
75
- // Legacy / alternate ids
76
- { modelId: "claude-sonnet-4-5-20250929", displayName: "Claude Sonnet 4.5", inputUsdPer1M: 3, outputUsdPer1M: 15 },
77
- { modelId: "claude-haiku-4-5-20251001", displayName: "Claude Haiku 4.5", inputUsdPer1M: 1, outputUsdPer1M: 5 },
78
- { modelId: "claude-opus-4.6", displayName: "Claude Opus 4.6", inputUsdPer1M: 5, outputUsdPer1M: 25 },
79
- { modelId: "claude-opus-4.5", displayName: "Claude Opus 4.5", inputUsdPer1M: 5, outputUsdPer1M: 25 },
80
- { modelId: "claude-opus-4.1", displayName: "Claude Opus 4.1", inputUsdPer1M: 15, outputUsdPer1M: 75 },
81
- { modelId: "claude-opus-4", displayName: "Claude Opus 4", inputUsdPer1M: 15, outputUsdPer1M: 75 },
82
- { modelId: "claude-sonnet-4.6", displayName: "Claude Sonnet 4.6", inputUsdPer1M: 3, outputUsdPer1M: 15 },
83
- { modelId: "claude-sonnet-4.5", displayName: "Claude Sonnet 4.5", inputUsdPer1M: 3, outputUsdPer1M: 15 },
84
- { modelId: "claude-sonnet-4", displayName: "Claude Sonnet 4", inputUsdPer1M: 3, outputUsdPer1M: 15 },
85
- { modelId: "claude-sonnet-3.7", displayName: "Claude Sonnet 3.7", inputUsdPer1M: 3, outputUsdPer1M: 15 },
86
- { modelId: "claude-haiku-4.5", displayName: "Claude Haiku 4.5", inputUsdPer1M: 1, outputUsdPer1M: 5 },
87
- { modelId: "claude-haiku-3.5", displayName: "Claude Haiku 3.5", inputUsdPer1M: 0.8, outputUsdPer1M: 4 },
88
- { modelId: "claude-opus-3", displayName: "Claude Opus 3", inputUsdPer1M: 15, outputUsdPer1M: 75 },
89
- { modelId: "claude-haiku-3", displayName: "Claude Haiku 3", inputUsdPer1M: 0.25, outputUsdPer1M: 1.25 }
90
- ];
91
-
92
- const GEMINI_MODEL_BASE_PRICES: Array<{
93
- modelId: string;
94
- displayName: string;
95
- inputUsdPer1M: number;
96
- outputUsdPer1M: number;
97
- }> = [
98
- { modelId: "gemini-3.1-flash-lite", displayName: "Gemini 3.1 Flash Lite", inputUsdPer1M: 0.25, outputUsdPer1M: 1.5 },
99
- { modelId: "gemini-3-flash", displayName: "Gemini 3 Flash", inputUsdPer1M: 0.5, outputUsdPer1M: 3 },
100
- { modelId: "gemini-3-pro", displayName: "Gemini 3 Pro", inputUsdPer1M: 2, outputUsdPer1M: 12 },
101
- { modelId: "gemini-3-pro-200k", displayName: "Gemini 3 Pro (>200k context)", inputUsdPer1M: 4, outputUsdPer1M: 18 },
102
- { modelId: "gemini-2.5-flash-lite", displayName: "Gemini 2.5 Flash Lite", inputUsdPer1M: 0.1, outputUsdPer1M: 0.4 },
103
- { modelId: "gemini-2.5-flash", displayName: "Gemini 2.5 Flash", inputUsdPer1M: 0.3, outputUsdPer1M: 2.5 },
104
- { modelId: "gemini-2.5-pro", displayName: "Gemini 2.5 Pro", inputUsdPer1M: 1.25, outputUsdPer1M: 10 }
105
- ];
106
-
107
- const DEFAULT_MODEL_PRICING_ROWS: SeedModelPricingRow[] = [
108
- ...OPENAI_MODEL_BASE_PRICES.map((row) => ({ ...row, providerType: "openai_api" as const })),
109
- ...CLAUDE_MODEL_BASE_PRICES.map((row) => ({ ...row, providerType: "anthropic_api" as const })),
110
- ...GEMINI_MODEL_BASE_PRICES.map((row) => ({ ...row, providerType: "gemini_api" as const }))
111
- ];
112
-
113
- export async function ensureCompanyModelPricingDefaults(db: BopoDb, companyId: string) {
114
- for (const row of DEFAULT_MODEL_PRICING_ROWS) {
115
- await upsertModelPricing(db, {
116
- companyId,
117
- providerType: row.providerType,
118
- modelId: row.modelId,
119
- displayName: row.displayName,
120
- inputUsdPer1M: row.inputUsdPer1M.toFixed(6),
121
- outputUsdPer1M: row.outputUsdPer1M.toFixed(6),
122
- currency: "USD",
123
- updatedBy: "system:onboarding-defaults"
124
- });
125
- }
126
- }
2
+ import type { CanonicalPricingProvider } from "../pricing";
3
+ import { getModelPricingCatalogRow } from "../pricing";
127
4
 
128
5
  export async function calculateModelPricedUsdCost(input: {
129
6
  db: BopoDb;
@@ -145,8 +22,7 @@ export async function calculateModelPricedUsdCost(input: {
145
22
  pricingModelId: normalizedModelId || null
146
23
  };
147
24
  }
148
- const pricing = await getModelPricing(input.db, {
149
- companyId: input.companyId,
25
+ const pricing = getModelPricingCatalogRow({
150
26
  providerType: canonicalPricingProviderType,
151
27
  modelId: normalizedModelId
152
28
  });
@@ -189,7 +65,7 @@ export async function calculateModelPricedUsdCost(input: {
189
65
  };
190
66
  }
191
67
 
192
- export function resolveCanonicalPricingProvider(providerType: string | null | undefined) {
68
+ export function resolveCanonicalPricingProvider(providerType: string | null | undefined): CanonicalPricingProvider | null {
193
69
  const normalizedProvider = providerType?.trim() ?? "";
194
70
  if (!normalizedProvider) {
195
71
  return null;