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.
@@ -1,9 +1,8 @@
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
- import { and, desc, eq, inArray, sql } from "drizzle-orm";
4
3
  import { nanoid } from "nanoid";
5
4
  import { resolveAdapter } from "bopodev-agent-sdk";
6
- import type { AgentState, HeartbeatContext } from "bopodev-agent-sdk";
5
+ import type { AdapterExecutionResult, AgentState, HeartbeatContext } from "bopodev-agent-sdk";
7
6
  import {
8
7
  type AgentFinalRunOutput,
9
8
  ControlPlaneHeadersJsonSchema,
@@ -19,18 +18,24 @@ import {
19
18
  import type { BopoDb } from "bopodev-db";
20
19
  import {
21
20
  addIssueComment,
21
+ and,
22
22
  approvalRequests,
23
23
  agents,
24
24
  appendActivity,
25
25
  appendHeartbeatRunMessages,
26
+ auditEvents,
26
27
  companies,
27
28
  createApprovalRequest,
29
+ desc,
30
+ eq,
28
31
  goals,
29
32
  heartbeatRuns,
33
+ inArray,
30
34
  issueComments,
31
35
  issueAttachments,
32
36
  issues,
33
- projects
37
+ projects,
38
+ sql
34
39
  } from "bopodev-db";
35
40
  import { appendAuditEvent, appendCost } from "bopodev-db";
36
41
  import { parseRuntimeConfigFromAgentRow } from "../lib/agent-config";
@@ -41,9 +46,15 @@ import {
41
46
  resolveCompanyWorkspaceRootPath,
42
47
  resolveProjectWorkspacePath
43
48
  } from "../lib/instance-paths";
44
- import { assertRuntimeCwdForCompany, getProjectWorkspaceContextMap, hasText, resolveAgentFallbackWorkspace } from "../lib/workspace-policy";
49
+ import { resolveRunArtifactAbsolutePath } from "../lib/run-artifact-paths";
50
+ import {
51
+ assertRuntimeCwdForCompany,
52
+ getProjectWorkspaceContextMap,
53
+ hasText,
54
+ resolveAgentFallbackWorkspace
55
+ } from "../lib/workspace-policy";
45
56
  import type { RealtimeHub } from "../realtime/hub";
46
- import { createHeartbeatRunsRealtimeEvent } from "../realtime/heartbeat-runs";
57
+ import { createHeartbeatRunsRealtimeEvent, loadHeartbeatRunsRealtimeSnapshot } from "../realtime/heartbeat-runs";
47
58
  import { publishAttentionSnapshot } from "../realtime/attention";
48
59
  import { publishOfficeOccupantForAgent } from "../realtime/office-space";
49
60
  import { appendProjectBudgetUsage, checkAgentBudget, checkProjectBudget } from "./budget-service";
@@ -152,7 +163,7 @@ export async function claimIssuesForAgent(
152
163
  RETURNING i.id, i.project_id, i.parent_issue_id, i.title, i.body, i.status, i.priority, i.labels_json, i.tags_json;
153
164
  `);
154
165
 
155
- return (result.rows ?? []) as Array<{
166
+ return result as unknown as Array<{
156
167
  id: string;
157
168
  project_id: string;
158
169
  parent_issue_id: string | null;
@@ -707,6 +718,8 @@ export async function runHeartbeatForAgent(
707
718
 
708
719
  let issueIds: string[] = [];
709
720
  let claimedIssueIds: string[] = [];
721
+ /** After transcript flush: remove DB row + audit noise for idle heartbeats with no issues. */
722
+ let discardIdleNoWorkRunAfterFlush = false;
710
723
  let executionWorkItemsForBudget: Array<{ issueId: string; projectId: string }> = [];
711
724
  let state: AgentState & {
712
725
  runtime?: {
@@ -892,6 +905,7 @@ export async function runHeartbeatForAgent(
892
905
  failClosed: false
893
906
  });
894
907
  const isCommentOrderWake = options?.wakeContext?.reason === "issue_comment_recipient";
908
+ const heartbeatIdlePolicy = resolveHeartbeatIdlePolicy();
895
909
  const workItems = isCommentOrderWake ? [] : await claimIssuesForAgent(db, companyId, agentId, runId);
896
910
  const wakeWorkItems = await loadWakeContextWorkItems(db, companyId, options?.wakeContext?.issueIds);
897
911
  const contextWorkItems = resolveExecutionWorkItems(workItems, wakeWorkItems, options?.wakeContext);
@@ -970,6 +984,7 @@ export async function runHeartbeatForAgent(
970
984
  contextWorkItems,
971
985
  mergedRuntime
972
986
  );
987
+ await mkdir(join(resolveAgentFallbackWorkspace(companyId, agent.id), "operating"), { recursive: true });
973
988
  state = {
974
989
  ...state,
975
990
  runtime: workspaceResolution.runtime
@@ -1005,6 +1020,10 @@ export async function runHeartbeatForAgent(
1005
1020
  ...context,
1006
1021
  memoryContext
1007
1022
  };
1023
+ const isIdleNoWork = contextWorkItems.length === 0 && !isCommentOrderWake;
1024
+ if (heartbeatIdlePolicy === "micro_prompt" && isIdleNoWork) {
1025
+ context = { ...context, idleMicroPrompt: true };
1026
+ }
1008
1027
  if (workspaceResolution.warnings.length > 0) {
1009
1028
  await appendAuditEvent(db, {
1010
1029
  companyId,
@@ -1170,19 +1189,34 @@ export async function runHeartbeatForAgent(
1170
1189
  };
1171
1190
  }
1172
1191
 
1173
- const execution = await executeAdapterWithWatchdog({
1174
- execute: (abortSignal) =>
1175
- adapter.execute({
1176
- ...context,
1177
- runtime: {
1178
- ...(context.runtime ?? {}),
1179
- abortSignal
1192
+ const execution: AdapterExecutionResult =
1193
+ heartbeatIdlePolicy === "skip_adapter" && isIdleNoWork
1194
+ ? {
1195
+ status: "ok",
1196
+ summary:
1197
+ "Idle heartbeat: no assigned work items; adapter not invoked (BOPO_HEARTBEAT_IDLE_POLICY=skip_adapter).",
1198
+ tokenInput: 0,
1199
+ tokenOutput: 0,
1200
+ usdCost: 0,
1201
+ usage: {
1202
+ inputTokens: 0,
1203
+ cachedInputTokens: 0,
1204
+ outputTokens: 0
1205
+ }
1180
1206
  }
1181
- }),
1182
- providerType: agent.providerType as HeartbeatProviderType,
1183
- runtime: workspaceResolution.runtime,
1184
- externalAbortSignal: activeRunAbort.signal
1185
- });
1207
+ : await executeAdapterWithWatchdog({
1208
+ execute: (abortSignal) =>
1209
+ adapter.execute({
1210
+ ...context,
1211
+ runtime: {
1212
+ ...(context.runtime ?? {}),
1213
+ abortSignal
1214
+ }
1215
+ }),
1216
+ providerType: agent.providerType as HeartbeatProviderType,
1217
+ runtime: workspaceResolution.runtime,
1218
+ externalAbortSignal: activeRunAbort.signal
1219
+ });
1186
1220
  const usageLimitHint = execution.dispositionHint?.kind === "provider_usage_limited" ? execution.dispositionHint : null;
1187
1221
  if (usageLimitHint) {
1188
1222
  providerUsageLimitDisposition = {
@@ -1454,6 +1488,7 @@ export async function runHeartbeatForAgent(
1454
1488
  cost: runCost,
1455
1489
  runtimeCwd: workspaceResolution.runtime.cwd
1456
1490
  });
1491
+ await verifyRunArtifactsOnDisk(companyId, runReport.artifacts);
1457
1492
  emitCanonicalResultEvent(runReport.resultSummary, runReport.finalStatus);
1458
1493
  const runListMessage = buildRunListMessageFromReport(runReport);
1459
1494
  await db
@@ -1688,6 +1723,11 @@ export async function runHeartbeatForAgent(
1688
1723
  }
1689
1724
  }
1690
1725
  });
1726
+ discardIdleNoWorkRunAfterFlush =
1727
+ issueIds.length === 0 &&
1728
+ !isCommentOrderWake &&
1729
+ (terminalPresentation.completionReason === "no_assigned_work" ||
1730
+ (isIdleNoWork && heartbeatIdlePolicy === "skip_adapter" && persistedRunStatus === "completed"));
1691
1731
  } catch (error) {
1692
1732
  const classified = classifyHeartbeatError(error);
1693
1733
  executionSummary =
@@ -1823,6 +1863,7 @@ export async function runHeartbeatForAgent(
1823
1863
  errorType: classified.type,
1824
1864
  errorMessage: classified.message
1825
1865
  });
1866
+ await verifyRunArtifactsOnDisk(companyId, runReport.artifacts);
1826
1867
  const runListMessage = buildRunListMessageFromReport(runReport);
1827
1868
  await db
1828
1869
  .update(heartbeatRuns)
@@ -1922,6 +1963,17 @@ export async function runHeartbeatForAgent(
1922
1963
  }
1923
1964
  } finally {
1924
1965
  await transcriptWriteQueue;
1966
+ if (discardIdleNoWorkRunAfterFlush) {
1967
+ try {
1968
+ await purgeIdleNoWorkHeartbeatRun(db, companyId, runId);
1969
+ if (options?.realtimeHub) {
1970
+ options.realtimeHub.publish(await loadHeartbeatRunsRealtimeSnapshot(db, companyId));
1971
+ }
1972
+ } catch (purgeError) {
1973
+ // eslint-disable-next-line no-console
1974
+ console.error("[heartbeat] failed to purge idle no-work run", runId, purgeError);
1975
+ }
1976
+ }
1925
1977
  unregisterActiveHeartbeatRun(runId);
1926
1978
  try {
1927
1979
  await releaseClaimedIssues(db, companyId, claimedIssueIds);
@@ -1955,6 +2007,15 @@ export async function runHeartbeatForAgent(
1955
2007
  return runId;
1956
2008
  }
1957
2009
 
2010
+ async function purgeIdleNoWorkHeartbeatRun(db: BopoDb, companyId: string, runId: string) {
2011
+ await db
2012
+ .delete(auditEvents)
2013
+ .where(
2014
+ and(eq(auditEvents.companyId, companyId), eq(auditEvents.entityType, "heartbeat_run"), eq(auditEvents.entityId, runId))
2015
+ );
2016
+ await db.delete(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, runId)));
2017
+ }
2018
+
1958
2019
  async function insertStartedRunAtomic(
1959
2020
  db: BopoDb,
1960
2021
  input: { id: string; companyId: string; agentId: string; message: string }
@@ -1965,7 +2026,7 @@ async function insertStartedRunAtomic(
1965
2026
  ON CONFLICT DO NOTHING
1966
2027
  RETURNING id
1967
2028
  `);
1968
- return (result.rows ?? []).length > 0;
2029
+ return result.length > 0;
1969
2030
  }
1970
2031
 
1971
2032
  async function recoverStaleHeartbeatRuns(
@@ -2109,7 +2170,7 @@ async function listLatestRunByAgent(db: BopoDb, companyId: string) {
2109
2170
  GROUP BY agent_id
2110
2171
  `);
2111
2172
  const latestRunByAgent = new Map<string, Date>();
2112
- for (const row of result.rows ?? []) {
2173
+ for (const row of result as Array<Record<string, unknown>>) {
2113
2174
  const agentId = typeof row.agent_id === "string" ? row.agent_id : null;
2114
2175
  if (!agentId) {
2115
2176
  continue;
@@ -2357,6 +2418,7 @@ async function buildHeartbeatContext(
2357
2418
  fileSizeBytes: number;
2358
2419
  relativePath: string;
2359
2420
  absolutePath: string;
2421
+ downloadPath: string;
2360
2422
  }>
2361
2423
  >();
2362
2424
  for (const row of attachmentRows) {
@@ -2372,7 +2434,8 @@ async function buildHeartbeatContext(
2372
2434
  mimeType: row.mimeType,
2373
2435
  fileSizeBytes: row.fileSizeBytes,
2374
2436
  relativePath: row.relativePath,
2375
- absolutePath
2437
+ absolutePath,
2438
+ downloadPath: `/issues/${row.issueId}/attachments/${row.id}/download`
2376
2439
  });
2377
2440
  attachmentsByIssue.set(row.issueId, existing);
2378
2441
  }
@@ -2400,12 +2463,14 @@ async function buildHeartbeatContext(
2400
2463
  .filter((goal) => goal.status === "active" && goal.level === "agent")
2401
2464
  .map((goal) => goal.title);
2402
2465
  const isCommentOrderWake = input.wakeContext?.reason === "issue_comment_recipient";
2466
+ const promptMode = resolveHeartbeatPromptMode();
2403
2467
 
2404
2468
  return {
2405
2469
  companyId,
2406
2470
  agentId: input.agentId,
2407
2471
  providerType: input.providerType,
2408
2472
  heartbeatRunId: input.heartbeatRunId,
2473
+ promptMode,
2409
2474
  company: {
2410
2475
  name: company?.name ?? "Unknown company",
2411
2476
  mission: company?.mission ?? null
@@ -3054,6 +3119,26 @@ function buildRunArtifacts(input: {
3054
3119
  });
3055
3120
  }
3056
3121
 
3122
+ async function verifyRunArtifactsOnDisk(companyId: string, artifacts: RunArtifact[]) {
3123
+ for (const artifact of artifacts) {
3124
+ const resolved = resolveRunArtifactAbsolutePath(companyId, {
3125
+ path: artifact.path,
3126
+ relativePath: artifact.relativePath ?? undefined,
3127
+ absolutePath: artifact.absolutePath ?? undefined
3128
+ });
3129
+ if (!resolved) {
3130
+ artifact.verifiedOnDisk = false;
3131
+ continue;
3132
+ }
3133
+ try {
3134
+ const stats = await stat(resolved);
3135
+ artifact.verifiedOnDisk = stats.isFile();
3136
+ } catch {
3137
+ artifact.verifiedOnDisk = false;
3138
+ }
3139
+ }
3140
+ }
3141
+
3057
3142
  function toNormalizedWorkspaceRelativePath(inputPath: string | null | undefined) {
3058
3143
  const trimmed = inputPath?.trim();
3059
3144
  if (!trimmed) {
@@ -3094,11 +3179,9 @@ function normalizeAgentOperatingArtifactRelativePath(pathValue: string | null, c
3094
3179
  if (!parsed) {
3095
3180
  return null;
3096
3181
  }
3097
- const embeddedCompanyId = parsed[1]?.trim() || companyId;
3098
3182
  const agentId = parsed[2];
3099
3183
  const suffix = parsed[3] ?? "";
3100
- const effectiveCompanyId = embeddedCompanyId;
3101
- return `workspace/${effectiveCompanyId}/agents/${agentId}/operating${suffix}`;
3184
+ return `workspace/${companyId}/agents/${agentId}/operating${suffix}`;
3102
3185
  }
3103
3186
  const directMatch = normalized.match(/^agents\/([^/]+)\/operating(\/.*)?$/);
3104
3187
  if (directMatch) {
@@ -3323,6 +3406,9 @@ function formatRunArtifactMarkdownLink(
3323
3406
  if (!label) {
3324
3407
  return "`artifact`";
3325
3408
  }
3409
+ if (artifact.verifiedOnDisk === false) {
3410
+ return `\`${label}\` (not found under company workspace at run completion)`;
3411
+ }
3326
3412
  if (!href) {
3327
3413
  return `\`${label}\``;
3328
3414
  }
@@ -4291,6 +4377,24 @@ function clearResumeState(
4291
4377
  };
4292
4378
  }
4293
4379
 
4380
+ function resolveHeartbeatPromptMode(): "full" | "compact" {
4381
+ const raw = process.env.BOPO_HEARTBEAT_PROMPT_MODE?.trim().toLowerCase();
4382
+ return raw === "compact" ? "compact" : "full";
4383
+ }
4384
+
4385
+ type HeartbeatIdlePolicy = "full" | "skip_adapter" | "micro_prompt";
4386
+
4387
+ function resolveHeartbeatIdlePolicy(): HeartbeatIdlePolicy {
4388
+ const raw = process.env.BOPO_HEARTBEAT_IDLE_POLICY?.trim().toLowerCase();
4389
+ if (raw === "skip_adapter") {
4390
+ return "skip_adapter";
4391
+ }
4392
+ if (raw === "micro_prompt") {
4393
+ return "micro_prompt";
4394
+ }
4395
+ return "full";
4396
+ }
4397
+
4294
4398
  function resolveControlPlaneEnv(runtimeEnv: Record<string, string>, suffix: string) {
4295
4399
  const next = runtimeEnv[`BOPODEV_${suffix}`];
4296
4400
  return hasText(next) ? (next as string) : "";
@@ -4307,8 +4411,13 @@ function buildHeartbeatRuntimeEnv(input: {
4307
4411
  canHireAgents: boolean;
4308
4412
  wakeContext?: HeartbeatWakeContext;
4309
4413
  }) {
4414
+ const companyWorkspaceRoot = resolveCompanyWorkspaceRootPath(input.companyId);
4415
+ const agentHome = resolveAgentFallbackWorkspace(input.companyId, input.agentId);
4416
+ const agentOperatingDir = join(agentHome, "operating");
4310
4417
  const apiBaseUrl = resolveControlPlaneApiBaseUrl();
4311
- const actorPermissions = ["issues:write", ...(input.canHireAgents ? ["agents:write"] : [])].join(",");
4418
+ // agents:write is required for PUT /agents/:self (bootstrapPrompt, runtimeConfig). Route handlers
4419
+ // still forbid agents from updating other agents' rows and from POST /agents unless canHireAgents.
4420
+ const actorPermissions = ["issues:write", "agents:write"].join(",");
4312
4421
  const actorHeaders = JSON.stringify({
4313
4422
  "x-company-id": input.companyId,
4314
4423
  "x-actor-type": "agent",
@@ -4322,7 +4431,12 @@ function buildHeartbeatRuntimeEnv(input: {
4322
4431
  return {
4323
4432
  BOPODEV_AGENT_ID: input.agentId,
4324
4433
  BOPODEV_COMPANY_ID: input.companyId,
4434
+ BOPODEV_COMPANY_WORKSPACE_ROOT: companyWorkspaceRoot,
4435
+ BOPODEV_AGENT_HOME: agentHome,
4436
+ BOPODEV_AGENT_OPERATING_DIR: agentOperatingDir,
4325
4437
  BOPODEV_RUN_ID: input.heartbeatRunId,
4438
+ BOPODEV_HEARTBEAT_PROMPT_MODE: resolveHeartbeatPromptMode(),
4439
+ BOPODEV_HEARTBEAT_IDLE_POLICY: resolveHeartbeatIdlePolicy(),
4326
4440
  BOPODEV_FORCE_MANAGED_CODEX_HOME: "false",
4327
4441
  BOPODEV_API_BASE_URL: apiBaseUrl,
4328
4442
  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;
@@ -4,6 +4,10 @@ import { runHeartbeatSweep } from "../services/heartbeat-service";
4
4
  import { runHeartbeatQueueSweep } from "../services/heartbeat-queue-service";
5
5
  import { runIssueCommentDispatchSweep } from "../services/comment-recipient-dispatch-service";
6
6
 
7
+ export type HeartbeatSchedulerHandle = {
8
+ stop: () => Promise<void>;
9
+ };
10
+
7
11
  export function createHeartbeatScheduler(db: BopoDb, companyId: string, realtimeHub?: RealtimeHub) {
8
12
  const heartbeatIntervalMs = Number(process.env.BOPO_HEARTBEAT_SWEEP_MS ?? 60_000);
9
13
  const queueIntervalMs = Number(process.env.BOPO_HEARTBEAT_QUEUE_SWEEP_MS ?? 2_000);
@@ -11,18 +15,22 @@ export function createHeartbeatScheduler(db: BopoDb, companyId: string, realtime
11
15
  let heartbeatRunning = false;
12
16
  let queueRunning = false;
13
17
  let commentDispatchRunning = false;
18
+ let heartbeatPromise: Promise<unknown> | null = null;
19
+ let queuePromise: Promise<unknown> | null = null;
20
+ let commentDispatchPromise: Promise<unknown> | null = null;
14
21
  const heartbeatTimer = setInterval(() => {
15
22
  if (heartbeatRunning) {
16
23
  return;
17
24
  }
18
25
  heartbeatRunning = true;
19
- void runHeartbeatSweep(db, companyId, { realtimeHub })
26
+ heartbeatPromise = runHeartbeatSweep(db, companyId, { realtimeHub })
20
27
  .catch((error) => {
21
28
  // eslint-disable-next-line no-console
22
29
  console.error("[scheduler] heartbeat sweep failed", error);
23
30
  })
24
31
  .finally(() => {
25
32
  heartbeatRunning = false;
33
+ heartbeatPromise = null;
26
34
  });
27
35
  }, heartbeatIntervalMs);
28
36
  const queueTimer = setInterval(() => {
@@ -30,13 +38,14 @@ export function createHeartbeatScheduler(db: BopoDb, companyId: string, realtime
30
38
  return;
31
39
  }
32
40
  queueRunning = true;
33
- void runHeartbeatQueueSweep(db, companyId, { realtimeHub })
41
+ queuePromise = runHeartbeatQueueSweep(db, companyId, { realtimeHub })
34
42
  .catch((error) => {
35
43
  // eslint-disable-next-line no-console
36
44
  console.error("[scheduler] queue sweep failed", error);
37
45
  })
38
46
  .finally(() => {
39
47
  queueRunning = false;
48
+ queuePromise = null;
40
49
  });
41
50
  }, queueIntervalMs);
42
51
  const commentDispatchTimer = setInterval(() => {
@@ -44,18 +53,25 @@ export function createHeartbeatScheduler(db: BopoDb, companyId: string, realtime
44
53
  return;
45
54
  }
46
55
  commentDispatchRunning = true;
47
- void runIssueCommentDispatchSweep(db, companyId, { realtimeHub })
56
+ commentDispatchPromise = runIssueCommentDispatchSweep(db, companyId, { realtimeHub })
48
57
  .catch((error) => {
49
58
  // eslint-disable-next-line no-console
50
59
  console.error("[scheduler] comment dispatch sweep failed", error);
51
60
  })
52
61
  .finally(() => {
53
62
  commentDispatchRunning = false;
63
+ commentDispatchPromise = null;
54
64
  });
55
65
  }, commentDispatchIntervalMs);
56
- return () => {
66
+ const stop = async () => {
57
67
  clearInterval(heartbeatTimer);
58
68
  clearInterval(queueTimer);
59
69
  clearInterval(commentDispatchTimer);
70
+ await Promise.allSettled(
71
+ [heartbeatPromise, queuePromise, commentDispatchPromise].filter(
72
+ (promise): promise is Promise<unknown> => promise !== null
73
+ )
74
+ );
60
75
  };
76
+ return { stop } satisfies HeartbeatSchedulerHandle;
61
77
  }