bopodev-api 0.1.25 → 0.1.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.25",
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-contracts": "0.1.25",
21
- "bopodev-db": "0.1.25",
22
- "bopodev-agent-sdk": "0.1.25"
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
 
@@ -1,6 +1,7 @@
1
1
  import { Router } from "express";
2
2
  import { z } from "zod";
3
3
  import {
4
+ addIssueComment,
4
5
  appendAuditEvent,
5
6
  clearApprovalInboxDismissed,
6
7
  countPendingApprovalRequests,
@@ -209,6 +210,36 @@ export function createGovernanceRouter(ctx: AppContext) {
209
210
  if (approval.requestedByAgentId) {
210
211
  await publishOfficeOccupantForAgent(ctx.db, ctx.realtimeHub, req.companyId!, approval.requestedByAgentId);
211
212
  }
213
+ if (parsed.data.status === "approved" && resolution.action === "hire_agent" && resolution.execution.applied) {
214
+ const hireContext = parseHireApprovalCommentContext(approval.payloadJson);
215
+ if (hireContext.issueIds.length > 0) {
216
+ const commentBody = buildHireApprovalIssueComment(hireContext.roleLabel);
217
+ try {
218
+ for (const issueId of hireContext.issueIds) {
219
+ await addIssueComment(ctx.db, {
220
+ companyId: req.companyId!,
221
+ issueId,
222
+ body: commentBody,
223
+ authorType: auditActor.actorType === "agent" ? "agent" : "human",
224
+ authorId: auditActor.actorId
225
+ });
226
+ }
227
+ } catch (error) {
228
+ await appendAuditEvent(ctx.db, {
229
+ companyId: req.companyId!,
230
+ actorType: "system",
231
+ actorId: null,
232
+ eventType: "governance.hire_approval_comment_failed",
233
+ entityType: "approval_request",
234
+ entityId: approval.id,
235
+ payload: {
236
+ error: String(error),
237
+ issueIds: hireContext.issueIds
238
+ }
239
+ });
240
+ }
241
+ }
242
+ }
212
243
  }
213
244
 
214
245
  if (resolution.execution.entityType === "agent" && resolution.execution.entityId) {
@@ -231,11 +262,58 @@ function resolveAuditActor(actor: { type: "board" | "member" | "agent"; id: stri
231
262
  return { actorType: "human" as const, actorId: actor.id };
232
263
  }
233
264
 
234
- function parsePayload(payloadJson: string) {
265
+ function parsePayload(payloadJson: string): Record<string, unknown> {
235
266
  try {
236
267
  const parsed = JSON.parse(payloadJson) as unknown;
237
- return typeof parsed === "object" && parsed !== null ? parsed : {};
268
+ return typeof parsed === "object" && parsed !== null ? (parsed as Record<string, unknown>) : {};
238
269
  } catch {
239
270
  return {};
240
271
  }
241
272
  }
273
+
274
+ function parseHireApprovalCommentContext(payloadJson: string) {
275
+ const payload = parsePayload(payloadJson);
276
+ const issueIds = normalizeSourceIssueIds(
277
+ typeof payload.sourceIssueId === "string" ? payload.sourceIssueId : undefined,
278
+ Array.isArray(payload.sourceIssueIds) ? payload.sourceIssueIds : undefined
279
+ );
280
+ const roleLabel = resolveHireRoleLabel(payload);
281
+ return { issueIds, roleLabel };
282
+ }
283
+
284
+ function normalizeSourceIssueIds(sourceIssueId?: string, sourceIssueIds?: unknown[]) {
285
+ const normalized = new Set<string>();
286
+ for (const entry of [sourceIssueId, ...(sourceIssueIds ?? [])]) {
287
+ if (typeof entry !== "string") {
288
+ continue;
289
+ }
290
+ const trimmed = entry.trim();
291
+ if (trimmed.length > 0) {
292
+ normalized.add(trimmed);
293
+ }
294
+ }
295
+ return Array.from(normalized);
296
+ }
297
+
298
+ function resolveHireRoleLabel(payload: Record<string, unknown>) {
299
+ const title = typeof payload.title === "string" ? payload.title.trim() : "";
300
+ if (title.length > 0) {
301
+ return title;
302
+ }
303
+ const role = typeof payload.role === "string" ? payload.role.trim() : "";
304
+ if (role.length > 0) {
305
+ return role;
306
+ }
307
+ const roleKey = typeof payload.roleKey === "string" ? payload.roleKey.trim() : "";
308
+ if (roleKey.length > 0) {
309
+ return roleKey.replace(/_/g, " ");
310
+ }
311
+ return null;
312
+ }
313
+
314
+ function buildHireApprovalIssueComment(roleLabel: string | null) {
315
+ if (roleLabel) {
316
+ return `Approved hiring of ${roleLabel}.`;
317
+ }
318
+ return "Approved hiring request.";
319
+ }
@@ -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,5 +1,6 @@
1
1
  import { Router } from "express";
2
- import { z } from "zod";
2
+ import { readFile, stat } from "node:fs/promises";
3
+ import { basename, resolve } from "node:path";
3
4
  import {
4
5
  getHeartbeatRun,
5
6
  listCompanies,
@@ -9,14 +10,12 @@ import {
9
10
  listGoals,
10
11
  listHeartbeatRunMessages,
11
12
  listHeartbeatRuns,
12
- listModelPricing,
13
- listPluginRuns,
14
- upsertModelPricing
13
+ listPluginRuns
15
14
  } from "bopodev-db";
16
15
  import type { AppContext } from "../context";
17
16
  import { sendError, sendOk } from "../http";
17
+ import { resolveRunArtifactAbsolutePath } from "../lib/run-artifact-paths";
18
18
  import { requireCompanyScope } from "../middleware/company-scope";
19
- import { requirePermission } from "../middleware/request-actor";
20
19
  import { listAgentMemoryFiles, loadAgentMemoryContext, readAgentMemoryFile } from "../services/memory-file-service";
21
20
 
22
21
  export function createObservabilityRouter(ctx: AppContext) {
@@ -45,56 +44,6 @@ export function createObservabilityRouter(ctx: AppContext) {
45
44
  );
46
45
  });
47
46
 
48
- const modelPricingUpdateSchema = z.object({
49
- providerType: z.string().min(1),
50
- modelId: z.string().min(1),
51
- displayName: z.string().min(1).optional(),
52
- inputUsdPer1M: z.number().min(0),
53
- outputUsdPer1M: z.number().min(0),
54
- currency: z.string().min(1).optional()
55
- });
56
-
57
- router.get("/models/pricing", async (req, res) => {
58
- const rows = await listModelPricing(ctx.db, req.companyId!);
59
- return sendOk(
60
- res,
61
- rows.map((row) => ({
62
- companyId: row.companyId,
63
- providerType: row.providerType,
64
- modelId: row.modelId,
65
- displayName: row.displayName,
66
- inputUsdPer1M: typeof row.inputUsdPer1M === "number" ? row.inputUsdPer1M : Number(row.inputUsdPer1M ?? 0),
67
- outputUsdPer1M: typeof row.outputUsdPer1M === "number" ? row.outputUsdPer1M : Number(row.outputUsdPer1M ?? 0),
68
- currency: row.currency,
69
- updatedAt: row.updatedAt?.toISOString?.() ?? null,
70
- updatedBy: row.updatedBy ?? null
71
- }))
72
- );
73
- });
74
-
75
- router.put("/models/pricing", async (req, res) => {
76
- requirePermission("observability:write")(req, res, () => {});
77
- if (res.headersSent) {
78
- return;
79
- }
80
- const parsed = modelPricingUpdateSchema.safeParse(req.body);
81
- if (!parsed.success) {
82
- return sendError(res, parsed.error.message, 422);
83
- }
84
- const payload = parsed.data;
85
- await upsertModelPricing(ctx.db, {
86
- companyId: req.companyId!,
87
- providerType: payload.providerType,
88
- modelId: payload.modelId,
89
- displayName: payload.displayName ?? null,
90
- inputUsdPer1M: payload.inputUsdPer1M.toFixed(6),
91
- outputUsdPer1M: payload.outputUsdPer1M.toFixed(6),
92
- currency: payload.currency ?? "USD",
93
- updatedBy: req.actor?.id ?? null
94
- });
95
- return sendOk(res, { ok: true });
96
- });
97
-
98
47
  router.get("/heartbeats", async (req, res) => {
99
48
  const companyId = req.companyId!;
100
49
  const rawLimit = Number(req.query.limit ?? 100);
@@ -113,10 +62,12 @@ export function createObservabilityRouter(ctx: AppContext) {
113
62
  .filter((run) => (agentFilter ? run.agentId === agentFilter : true))
114
63
  .map((run) => {
115
64
  const details = runDetailsByRunId.get(run.id);
116
- const outcome = details?.outcome ?? null;
65
+ const report = toRecord(details?.report);
66
+ const outcome = details?.outcome ?? report?.outcome ?? null;
117
67
  return {
118
- ...serializeRunRow(run, outcome),
119
- outcome
68
+ ...serializeRunRow(run, details),
69
+ outcome,
70
+ report: report ?? null
120
71
  };
121
72
  })
122
73
  );
@@ -138,7 +89,7 @@ export function createObservabilityRouter(ctx: AppContext) {
138
89
  const trace = toRecord(details?.trace);
139
90
  const traceTranscript = Array.isArray(trace?.transcript) ? trace.transcript : [];
140
91
  return sendOk(res, {
141
- run: serializeRunRow(run, details?.outcome ?? null),
92
+ run: serializeRunRow(run, details),
142
93
  details,
143
94
  transcript: {
144
95
  hasPersistedMessages: transcriptResult.items.length > 0,
@@ -148,6 +99,46 @@ export function createObservabilityRouter(ctx: AppContext) {
148
99
  });
149
100
  });
150
101
 
102
+ router.get("/heartbeats/:runId/artifacts/:artifactIndex/download", async (req, res) => {
103
+ const companyId = req.companyId!;
104
+ const runId = req.params.runId;
105
+ const rawArtifactIndex = Number(req.params.artifactIndex);
106
+ const artifactIndex = Number.isFinite(rawArtifactIndex) ? Math.floor(rawArtifactIndex) : NaN;
107
+ if (!Number.isInteger(artifactIndex) || artifactIndex < 0) {
108
+ return sendError(res, "Artifact index must be a non-negative integer.", 422);
109
+ }
110
+ const [run, auditRows] = await Promise.all([getHeartbeatRun(ctx.db, companyId, runId), listAuditEvents(ctx.db, companyId, 500)]);
111
+ if (!run) {
112
+ return sendError(res, "Run not found", 404);
113
+ }
114
+ const details = buildRunDetailsMap(auditRows).get(runId) ?? null;
115
+ const report = toRecord(details?.report);
116
+ const artifacts = Array.isArray(report?.artifacts)
117
+ ? report.artifacts.filter((entry) => typeof entry === "object" && entry !== null)
118
+ : [];
119
+ const artifact = (artifacts[artifactIndex] ?? null) as Record<string, unknown> | null;
120
+ if (!artifact) {
121
+ return sendError(res, "Artifact not found.", 404);
122
+ }
123
+ const resolvedPath = resolveRunArtifactAbsolutePath(companyId, artifact);
124
+ if (!resolvedPath) {
125
+ return sendError(res, "Artifact path is invalid.", 422);
126
+ }
127
+ let stats: Awaited<ReturnType<typeof stat>>;
128
+ try {
129
+ stats = await stat(resolvedPath);
130
+ } catch {
131
+ return sendError(res, "Artifact not found on disk.", 404);
132
+ }
133
+ if (!stats.isFile()) {
134
+ return sendError(res, "Artifact is not a file.", 422);
135
+ }
136
+ const buffer = await readFile(resolvedPath);
137
+ res.setHeader("content-type", "application/octet-stream");
138
+ res.setHeader("content-disposition", `inline; filename="${encodeURIComponent(basename(resolvedPath))}"`);
139
+ return res.send(buffer);
140
+ });
141
+
151
142
  router.get("/heartbeats/:runId/messages", async (req, res) => {
152
143
  const companyId = req.companyId!;
153
144
  const runId = req.params.runId;
@@ -369,14 +360,27 @@ function serializeRunRow(
369
360
  finishedAt: Date | null;
370
361
  message: string | null;
371
362
  },
372
- outcome: unknown
363
+ details: Record<string, unknown> | null | undefined
373
364
  ) {
374
- const runType = resolveRunType(run, outcome);
365
+ const runType = resolveRunType(run, details);
366
+ const report = toRecord(details?.report);
367
+ const publicStatusRaw = typeof report?.finalStatus === "string" ? report.finalStatus : null;
368
+ const publicStatus =
369
+ publicStatusRaw === "completed" || publicStatusRaw === "failed"
370
+ ? publicStatusRaw
371
+ : run.status === "started"
372
+ ? "started"
373
+ : run.status === "failed"
374
+ ? "failed"
375
+ : run.status === "completed"
376
+ ? "completed"
377
+ : "failed";
375
378
  return {
376
379
  id: run.id,
377
380
  companyId: run.companyId,
378
381
  agentId: run.agentId,
379
382
  status: run.status,
383
+ publicStatus,
380
384
  startedAt: run.startedAt.toISOString(),
381
385
  finishedAt: run.finishedAt?.toISOString() ?? null,
382
386
  message: run.message ?? null,
@@ -389,14 +393,25 @@ function resolveRunType(
389
393
  status: string;
390
394
  message: string | null;
391
395
  },
392
- outcome: unknown
396
+ details: Record<string, unknown> | null | undefined
393
397
  ): "work" | "no_assigned_work" | "budget_skip" | "overlap_skip" | "other_skip" | "failed" | "running" {
394
398
  if (run.status === "started") {
395
399
  return "running";
396
400
  }
397
- if (run.status === "failed") {
401
+ const report = toRecord(details?.report);
402
+ const completionReason = typeof report?.completionReason === "string" ? report.completionReason : null;
403
+ if (run.status === "failed" || completionReason === "runtime_error" || completionReason === "provider_unavailable") {
398
404
  return "failed";
399
405
  }
406
+ if (completionReason === "no_assigned_work") {
407
+ return "no_assigned_work";
408
+ }
409
+ if (completionReason === "budget_hard_stop") {
410
+ return "budget_skip";
411
+ }
412
+ if (completionReason === "overlap_in_progress") {
413
+ return "overlap_skip";
414
+ }
400
415
  const normalizedMessage = (run.message ?? "").toLowerCase();
401
416
  if (normalizedMessage.includes("already in progress")) {
402
417
  return "overlap_skip";
@@ -407,7 +422,7 @@ function resolveRunType(
407
422
  if (isNoAssignedWorkMessage(run.message)) {
408
423
  return "no_assigned_work";
409
424
  }
410
- if (isNoAssignedWorkOutcome(outcome)) {
425
+ if (isNoAssignedWorkOutcome(details?.outcome)) {
411
426
  return "no_assigned_work";
412
427
  }
413
428
  if (run.status === "skipped") {