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 +44 -0
- package/package.json +4 -4
- package/src/app.ts +1 -2
- package/src/lib/drainable-work.ts +36 -0
- package/src/lib/run-artifact-paths.ts +80 -0
- package/src/lib/workspace-policy.ts +1 -2
- package/src/pricing/anthropic.ts +23 -0
- package/src/pricing/gemini.ts +11 -0
- package/src/pricing/index.ts +29 -0
- package/src/pricing/openai.ts +47 -0
- package/src/pricing/opencode.ts +5 -0
- package/src/pricing/types.ts +8 -0
- package/src/realtime/office-space.ts +3 -1
- package/src/routes/companies.ts +0 -2
- package/src/routes/heartbeats.ts +1 -2
- package/src/routes/issues.ts +20 -2
- package/src/routes/observability.ts +3 -136
- package/src/scripts/onboard-seed.ts +1 -3
- package/src/server.ts +112 -8
- package/src/services/attention-service.ts +90 -47
- package/src/services/budget-service.ts +1 -2
- package/src/services/comment-recipient-dispatch-service.ts +39 -2
- package/src/services/governance-service.ts +3 -1
- package/src/services/heartbeat-queue-service.ts +34 -3
- package/src/services/heartbeat-service.ts +140 -26
- package/src/services/model-pricing.ts +4 -128
- package/src/worker/scheduler.ts +20 -4
|
@@ -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 {
|
|
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
|
|
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 =
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
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
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 =
|
|
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;
|
package/src/worker/scheduler.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|