bopodev-api 0.1.26 → 0.1.28

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.28",
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-agent-sdk": "0.1.28",
21
+ "bopodev-db": "0.1.28",
22
+ "bopodev-contracts": "0.1.28"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/cors": "^2.8.19",
package/src/app.ts CHANGED
@@ -1,8 +1,7 @@
1
1
  import cors from "cors";
2
2
  import express from "express";
3
3
  import type { NextFunction, Request, Response } from "express";
4
- import { sql } from "drizzle-orm";
5
- import { RepositoryValidationError } from "bopodev-db";
4
+ import { RepositoryValidationError, sql } from "bopodev-db";
6
5
  import { nanoid } from "nanoid";
7
6
  import type { AppContext } from "./context";
8
7
  import { createAgentsRouter } from "./routes/agents";
@@ -0,0 +1,36 @@
1
+ export type DrainableWorkTracker = {
2
+ beginShutdown: () => void;
3
+ isShuttingDown: () => boolean;
4
+ track: <T>(promise: Promise<T>) => Promise<T>;
5
+ drain: () => Promise<void>;
6
+ resetForTests: () => void;
7
+ };
8
+
9
+ export function createDrainableWorkTracker(): DrainableWorkTracker {
10
+ let shuttingDown = false;
11
+ const pending = new Set<Promise<unknown>>();
12
+
13
+ return {
14
+ beginShutdown() {
15
+ shuttingDown = true;
16
+ },
17
+ isShuttingDown() {
18
+ return shuttingDown;
19
+ },
20
+ track<T>(promise: Promise<T>) {
21
+ let tracked: Promise<T>;
22
+ tracked = promise.finally(() => {
23
+ pending.delete(tracked);
24
+ });
25
+ pending.add(tracked);
26
+ return tracked;
27
+ },
28
+ async drain() {
29
+ await Promise.allSettled([...pending]);
30
+ },
31
+ resetForTests() {
32
+ shuttingDown = false;
33
+ pending.clear();
34
+ }
35
+ };
36
+ }
@@ -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
+ }
@@ -1,6 +1,5 @@
1
- import { and, eq, inArray } from "drizzle-orm";
2
1
  import type { BopoDb } from "bopodev-db";
3
- import { projectWorkspaces, projects } from "bopodev-db";
2
+ import { and, eq, inArray, projectWorkspaces, projects } from "bopodev-db";
4
3
  import {
5
4
  assertPathInsideCompanyWorkspaceRoot,
6
5
  isInsidePath,
@@ -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
+ }
@@ -1,9 +1,11 @@
1
- import { and, desc, eq } from "drizzle-orm";
2
1
  import type { OfficeOccupant, RealtimeEventEnvelope, RealtimeMessage } from "bopodev-contracts";
3
2
  import { AGENT_ROLE_LABELS, AgentRoleKeySchema } from "bopodev-contracts";
4
3
  import {
4
+ and,
5
5
  agents,
6
6
  approvalRequests,
7
+ desc,
8
+ eq,
7
9
  getApprovalRequest,
8
10
  heartbeatRuns,
9
11
  issues,
@@ -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
 
@@ -1,7 +1,6 @@
1
1
  import { Router } from "express";
2
2
  import { z } from "zod";
3
- import { and, eq } from "drizzle-orm";
4
- import { agents, heartbeatRuns, listHeartbeatQueueJobs } from "bopodev-db";
3
+ import { agents, and, eq, heartbeatRuns, listHeartbeatQueueJobs } from "bopodev-db";
5
4
  import type { AppContext } from "../context";
6
5
  import { sendError, sendOk } from "../http";
7
6
  import { requireCompanyScope } from "../middleware/company-scope";
@@ -1,22 +1,26 @@
1
1
  import { Router } from "express";
2
2
  import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
3
3
  import { basename, extname, join, resolve } from "node:path";
4
- import { and, desc, eq, inArray } from "drizzle-orm";
5
4
  import multer from "multer";
6
5
  import { z } from "zod";
7
- import { IssueSchema } from "bopodev-contracts";
6
+ import { IssueDetailSchema, IssueSchema } from "bopodev-contracts";
8
7
  import {
9
8
  addIssueAttachment,
10
9
  addIssueComment,
11
10
  agents,
11
+ and,
12
12
  appendActivity,
13
13
  appendAuditEvent,
14
14
  createIssue,
15
15
  deleteIssueAttachment,
16
16
  deleteIssueComment,
17
17
  deleteIssue,
18
+ desc,
19
+ eq,
20
+ getIssue,
18
21
  heartbeatRuns,
19
22
  getIssueAttachment,
23
+ inArray,
20
24
  issues,
21
25
  listIssueAttachments,
22
26
  listIssueActivity,
@@ -190,6 +194,20 @@ export function createIssuesRouter(ctx: AppContext) {
190
194
  );
191
195
  });
192
196
 
197
+ router.get("/:issueId", async (req, res) => {
198
+ const issueId = req.params.issueId;
199
+ const issueRow = await getIssue(ctx.db, req.companyId!, issueId);
200
+ if (!issueRow) {
201
+ return sendError(res, "Issue not found.", 404);
202
+ }
203
+ const base = toIssueResponse(issueRow as unknown as Record<string, unknown>);
204
+ const attachmentRows = await listIssueAttachments(ctx.db, req.companyId!, issueId);
205
+ const attachments = attachmentRows.map((row) =>
206
+ toIssueAttachmentResponse(row as unknown as Record<string, unknown>, issueId)
207
+ );
208
+ return sendOkValidated(res, IssueDetailSchema, { ...base, attachments }, "issues.detail");
209
+ });
210
+
193
211
  router.post("/", async (req, res) => {
194
212
  requirePermission("issues:write")(req, res, () => {});
195
213
  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\``,