bopodev-api 0.1.26 → 0.1.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -0
- package/package.json +4 -4
- package/src/lib/run-artifact-paths.ts +80 -0
- 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/routes/companies.ts +0 -2
- package/src/routes/issues.ts +16 -1
- package/src/routes/observability.ts +3 -136
- package/src/scripts/onboard-seed.ts +1 -3
- package/src/server.ts +89 -3
- package/src/services/attention-service.ts +86 -46
- package/src/services/governance-service.ts +1 -0
- package/src/services/heartbeat-service.ts +102 -20
- package/src/services/model-pricing.ts +4 -128
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.
|
|
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-
|
|
21
|
-
"bopodev-agent-sdk": "0.1.
|
|
22
|
-
"bopodev-
|
|
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[] = [];
|
package/src/routes/companies.ts
CHANGED
|
@@ -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
|
|
package/src/routes/issues.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { basename, extname, join, resolve } from "node:path";
|
|
|
4
4
|
import { and, desc, eq, inArray } from "drizzle-orm";
|
|
5
5
|
import multer from "multer";
|
|
6
6
|
import { z } from "zod";
|
|
7
|
-
import { IssueSchema } from "bopodev-contracts";
|
|
7
|
+
import { IssueDetailSchema, IssueSchema } from "bopodev-contracts";
|
|
8
8
|
import {
|
|
9
9
|
addIssueAttachment,
|
|
10
10
|
addIssueComment,
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
deleteIssueAttachment,
|
|
16
16
|
deleteIssueComment,
|
|
17
17
|
deleteIssue,
|
|
18
|
+
getIssue,
|
|
18
19
|
heartbeatRuns,
|
|
19
20
|
getIssueAttachment,
|
|
20
21
|
issues,
|
|
@@ -190,6 +191,20 @@ export function createIssuesRouter(ctx: AppContext) {
|
|
|
190
191
|
);
|
|
191
192
|
});
|
|
192
193
|
|
|
194
|
+
router.get("/:issueId", async (req, res) => {
|
|
195
|
+
const issueId = req.params.issueId;
|
|
196
|
+
const issueRow = await getIssue(ctx.db, req.companyId!, issueId);
|
|
197
|
+
if (!issueRow) {
|
|
198
|
+
return sendError(res, "Issue not found.", 404);
|
|
199
|
+
}
|
|
200
|
+
const base = toIssueResponse(issueRow as unknown as Record<string, unknown>);
|
|
201
|
+
const attachmentRows = await listIssueAttachments(ctx.db, req.companyId!, issueId);
|
|
202
|
+
const attachments = attachmentRows.map((row) =>
|
|
203
|
+
toIssueAttachmentResponse(row as unknown as Record<string, unknown>, issueId)
|
|
204
|
+
);
|
|
205
|
+
return sendOkValidated(res, IssueDetailSchema, { ...base, attachments }, "issues.detail");
|
|
206
|
+
});
|
|
207
|
+
|
|
193
208
|
router.post("/", async (req, res) => {
|
|
194
209
|
requirePermission("issues:write")(req, res, () => {});
|
|
195
210
|
if (res.headersSent) {
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { Router } from "express";
|
|
2
2
|
import { readFile, stat } from "node:fs/promises";
|
|
3
|
-
import { basename,
|
|
4
|
-
import { z } from "zod";
|
|
3
|
+
import { basename, resolve } from "node:path";
|
|
5
4
|
import {
|
|
6
5
|
getHeartbeatRun,
|
|
7
6
|
listCompanies,
|
|
@@ -11,15 +10,12 @@ import {
|
|
|
11
10
|
listGoals,
|
|
12
11
|
listHeartbeatRunMessages,
|
|
13
12
|
listHeartbeatRuns,
|
|
14
|
-
|
|
15
|
-
listPluginRuns,
|
|
16
|
-
upsertModelPricing
|
|
13
|
+
listPluginRuns
|
|
17
14
|
} from "bopodev-db";
|
|
18
15
|
import type { AppContext } from "../context";
|
|
19
16
|
import { sendError, sendOk } from "../http";
|
|
20
|
-
import {
|
|
17
|
+
import { resolveRunArtifactAbsolutePath } from "../lib/run-artifact-paths";
|
|
21
18
|
import { requireCompanyScope } from "../middleware/company-scope";
|
|
22
|
-
import { requirePermission } from "../middleware/request-actor";
|
|
23
19
|
import { listAgentMemoryFiles, loadAgentMemoryContext, readAgentMemoryFile } from "../services/memory-file-service";
|
|
24
20
|
|
|
25
21
|
export function createObservabilityRouter(ctx: AppContext) {
|
|
@@ -48,56 +44,6 @@ export function createObservabilityRouter(ctx: AppContext) {
|
|
|
48
44
|
);
|
|
49
45
|
});
|
|
50
46
|
|
|
51
|
-
const modelPricingUpdateSchema = z.object({
|
|
52
|
-
providerType: z.string().min(1),
|
|
53
|
-
modelId: z.string().min(1),
|
|
54
|
-
displayName: z.string().min(1).optional(),
|
|
55
|
-
inputUsdPer1M: z.number().min(0),
|
|
56
|
-
outputUsdPer1M: z.number().min(0),
|
|
57
|
-
currency: z.string().min(1).optional()
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
router.get("/models/pricing", async (req, res) => {
|
|
61
|
-
const rows = await listModelPricing(ctx.db, req.companyId!);
|
|
62
|
-
return sendOk(
|
|
63
|
-
res,
|
|
64
|
-
rows.map((row) => ({
|
|
65
|
-
companyId: row.companyId,
|
|
66
|
-
providerType: row.providerType,
|
|
67
|
-
modelId: row.modelId,
|
|
68
|
-
displayName: row.displayName,
|
|
69
|
-
inputUsdPer1M: typeof row.inputUsdPer1M === "number" ? row.inputUsdPer1M : Number(row.inputUsdPer1M ?? 0),
|
|
70
|
-
outputUsdPer1M: typeof row.outputUsdPer1M === "number" ? row.outputUsdPer1M : Number(row.outputUsdPer1M ?? 0),
|
|
71
|
-
currency: row.currency,
|
|
72
|
-
updatedAt: row.updatedAt?.toISOString?.() ?? null,
|
|
73
|
-
updatedBy: row.updatedBy ?? null
|
|
74
|
-
}))
|
|
75
|
-
);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
router.put("/models/pricing", async (req, res) => {
|
|
79
|
-
requirePermission("observability:write")(req, res, () => {});
|
|
80
|
-
if (res.headersSent) {
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
83
|
-
const parsed = modelPricingUpdateSchema.safeParse(req.body);
|
|
84
|
-
if (!parsed.success) {
|
|
85
|
-
return sendError(res, parsed.error.message, 422);
|
|
86
|
-
}
|
|
87
|
-
const payload = parsed.data;
|
|
88
|
-
await upsertModelPricing(ctx.db, {
|
|
89
|
-
companyId: req.companyId!,
|
|
90
|
-
providerType: payload.providerType,
|
|
91
|
-
modelId: payload.modelId,
|
|
92
|
-
displayName: payload.displayName ?? null,
|
|
93
|
-
inputUsdPer1M: payload.inputUsdPer1M.toFixed(6),
|
|
94
|
-
outputUsdPer1M: payload.outputUsdPer1M.toFixed(6),
|
|
95
|
-
currency: payload.currency ?? "USD",
|
|
96
|
-
updatedBy: req.actor?.id ?? null
|
|
97
|
-
});
|
|
98
|
-
return sendOk(res, { ok: true });
|
|
99
|
-
});
|
|
100
|
-
|
|
101
47
|
router.get("/heartbeats", async (req, res) => {
|
|
102
48
|
const companyId = req.companyId!;
|
|
103
49
|
const rawLimit = Number(req.query.limit ?? 100);
|
|
@@ -404,85 +350,6 @@ function toRecord(value: unknown) {
|
|
|
404
350
|
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : null;
|
|
405
351
|
}
|
|
406
352
|
|
|
407
|
-
function resolveRunArtifactAbsolutePath(companyId: string, artifact: Record<string, unknown>) {
|
|
408
|
-
const companyWorkspaceRoot = resolveCompanyWorkspaceRootPath(companyId);
|
|
409
|
-
const absolutePathRaw = normalizeAbsoluteArtifactPath(
|
|
410
|
-
typeof artifact.absolutePath === "string" ? artifact.absolutePath.trim() : ""
|
|
411
|
-
);
|
|
412
|
-
const relativePathRaw = normalizeWorkspaceRelativeArtifactPath(
|
|
413
|
-
typeof artifact.relativePath === "string"
|
|
414
|
-
? artifact.relativePath.trim()
|
|
415
|
-
: typeof artifact.path === "string"
|
|
416
|
-
? artifact.path.trim()
|
|
417
|
-
: "",
|
|
418
|
-
companyId
|
|
419
|
-
);
|
|
420
|
-
const candidate = relativePathRaw
|
|
421
|
-
? resolve(companyWorkspaceRoot, relativePathRaw)
|
|
422
|
-
: absolutePathRaw
|
|
423
|
-
? absolutePathRaw
|
|
424
|
-
: "";
|
|
425
|
-
if (!candidate) {
|
|
426
|
-
return null;
|
|
427
|
-
}
|
|
428
|
-
const resolved = isAbsolute(candidate) ? resolve(candidate) : resolve(companyWorkspaceRoot, candidate);
|
|
429
|
-
if (!isInsidePath(companyWorkspaceRoot, resolved)) {
|
|
430
|
-
return null;
|
|
431
|
-
}
|
|
432
|
-
return resolved;
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
function normalizeAbsoluteArtifactPath(value: string) {
|
|
436
|
-
const trimmed = value.trim();
|
|
437
|
-
if (!trimmed || !isAbsolute(trimmed)) {
|
|
438
|
-
return "";
|
|
439
|
-
}
|
|
440
|
-
return resolve(trimmed);
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
function normalizeWorkspaceRelativeArtifactPath(value: string, companyId: string) {
|
|
444
|
-
const trimmed = value.trim();
|
|
445
|
-
if (!trimmed) {
|
|
446
|
-
return "";
|
|
447
|
-
}
|
|
448
|
-
const unixSeparated = trimmed.replace(/\\/g, "/");
|
|
449
|
-
if (isAbsolute(unixSeparated)) {
|
|
450
|
-
return "";
|
|
451
|
-
}
|
|
452
|
-
const parts: string[] = [];
|
|
453
|
-
for (const part of unixSeparated.split("/")) {
|
|
454
|
-
if (!part || part === ".") {
|
|
455
|
-
continue;
|
|
456
|
-
}
|
|
457
|
-
if (part === "..") {
|
|
458
|
-
if (parts.length > 0 && parts[parts.length - 1] !== "..") {
|
|
459
|
-
parts.pop();
|
|
460
|
-
} else {
|
|
461
|
-
parts.push(part);
|
|
462
|
-
}
|
|
463
|
-
continue;
|
|
464
|
-
}
|
|
465
|
-
parts.push(part);
|
|
466
|
-
}
|
|
467
|
-
const normalized = parts.join("/");
|
|
468
|
-
if (!normalized) {
|
|
469
|
-
return "";
|
|
470
|
-
}
|
|
471
|
-
const workspaceScopedMatch = normalized.match(/(?:^|\/)workspace\/([^/]+)\/(.+)$/);
|
|
472
|
-
if (!workspaceScopedMatch) {
|
|
473
|
-
return normalized;
|
|
474
|
-
}
|
|
475
|
-
const scopedCompanyId = workspaceScopedMatch[1];
|
|
476
|
-
const scopedRelativePath = workspaceScopedMatch[2];
|
|
477
|
-
if (!scopedCompanyId || !scopedRelativePath) {
|
|
478
|
-
return "";
|
|
479
|
-
}
|
|
480
|
-
if (scopedCompanyId !== companyId) {
|
|
481
|
-
return "";
|
|
482
|
-
}
|
|
483
|
-
return scopedRelativePath;
|
|
484
|
-
}
|
|
485
|
-
|
|
486
353
|
function serializeRunRow(
|
|
487
354
|
run: {
|
|
488
355
|
id: string;
|
|
@@ -30,7 +30,6 @@ import {
|
|
|
30
30
|
} from "../lib/instance-paths";
|
|
31
31
|
import { buildDefaultCeoBootstrapPrompt } from "../lib/ceo-bootstrap-prompt";
|
|
32
32
|
import { resolveDefaultRuntimeCwdForCompany } from "../lib/workspace-policy";
|
|
33
|
-
import { ensureCompanyModelPricingDefaults } from "../services/model-pricing";
|
|
34
33
|
import { applyTemplateManifest } from "../services/template-apply-service";
|
|
35
34
|
import { ensureCompanyBuiltinTemplateDefaults } from "../services/template-catalog";
|
|
36
35
|
|
|
@@ -211,8 +210,6 @@ export async function ensureOnboardingSeed(input: {
|
|
|
211
210
|
templateApplied = true;
|
|
212
211
|
appliedTemplateId = template.id;
|
|
213
212
|
}
|
|
214
|
-
await ensureCompanyModelPricingDefaults(db, companyId);
|
|
215
|
-
|
|
216
213
|
return {
|
|
217
214
|
companyId,
|
|
218
215
|
companyName: resolvedCompanyName,
|
|
@@ -313,6 +310,7 @@ async function ensureCeoStartupTask(
|
|
|
313
310
|
"Stand up your leadership operating baseline before taking on additional delivery work.",
|
|
314
311
|
"",
|
|
315
312
|
`1. Create your operating folder at \`${ceoOperatingFolder}/\`.`,
|
|
313
|
+
" During heartbeats, prefer the absolute path in `$BOPODEV_AGENT_OPERATING_DIR` (set by the runtime) so files land under your agent folder even when the shell cwd is a project workspace.",
|
|
316
314
|
"2. Author these files with your own voice and responsibilities:",
|
|
317
315
|
` - \`${ceoOperatingFolder}/AGENTS.md\``,
|
|
318
316
|
` - \`${ceoOperatingFolder}/HEARTBEAT.md\``,
|
package/src/server.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { dirname, resolve } from "node:path";
|
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { sql } from "drizzle-orm";
|
|
5
5
|
import { config as loadDotenv } from "dotenv";
|
|
6
|
-
import { bootstrapDatabase, listCompanies } from "bopodev-db";
|
|
6
|
+
import { bootstrapDatabase, listCompanies, resolveDefaultDbPath } from "bopodev-db";
|
|
7
7
|
import { checkRuntimeCommandHealth } from "bopodev-agent-sdk";
|
|
8
8
|
import type { RuntimeCommandHealth } from "bopodev-agent-sdk";
|
|
9
9
|
import { createApp } from "./app";
|
|
@@ -33,7 +33,28 @@ async function main() {
|
|
|
33
33
|
validateDeploymentConfiguration(deploymentMode, allowedOrigins, allowedHostnames, publicBaseUrl);
|
|
34
34
|
const dbPath = normalizeOptionalDbPath(process.env.BOPO_DB_PATH);
|
|
35
35
|
const port = Number(process.env.PORT ?? 4020);
|
|
36
|
-
const
|
|
36
|
+
const effectiveDbPath = dbPath ?? resolveDefaultDbPath();
|
|
37
|
+
let db: Awaited<ReturnType<typeof bootstrapDatabase>>["db"];
|
|
38
|
+
let dbClient: Awaited<ReturnType<typeof bootstrapDatabase>>["client"];
|
|
39
|
+
try {
|
|
40
|
+
const boot = await bootstrapDatabase(dbPath);
|
|
41
|
+
db = boot.db;
|
|
42
|
+
dbClient = boot.client;
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (isProbablyPgliteWasmAbort(error)) {
|
|
45
|
+
// eslint-disable-next-line no-console
|
|
46
|
+
console.error(
|
|
47
|
+
"[startup] PGlite (embedded Postgres) failed during database bootstrap. This is unrelated to Codex or heartbeat prompt settings."
|
|
48
|
+
);
|
|
49
|
+
// eslint-disable-next-line no-console
|
|
50
|
+
console.error(`[startup] Data path in use: ${effectiveDbPath}`);
|
|
51
|
+
// eslint-disable-next-line no-console
|
|
52
|
+
console.error(
|
|
53
|
+
"[startup] Recovery: stop all API/node processes using this DB, back up the path above, delete the file/dir, then restart (schema will be recreated). Or set BOPO_DB_PATH to a fresh path. See docs/operations/troubleshooting.md (PGlite)."
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
37
58
|
const existingCompanies = await listCompanies(db);
|
|
38
59
|
await ensureBuiltinPluginsRegistered(
|
|
39
60
|
db,
|
|
@@ -120,16 +141,69 @@ async function main() {
|
|
|
120
141
|
|
|
121
142
|
const defaultCompanyId = process.env.BOPO_DEFAULT_COMPANY_ID;
|
|
122
143
|
const schedulerCompanyId = await resolveSchedulerCompanyId(db, defaultCompanyId ?? null);
|
|
144
|
+
let stopScheduler: (() => void) | undefined;
|
|
123
145
|
if (schedulerCompanyId && shouldStartScheduler()) {
|
|
124
|
-
createHeartbeatScheduler(db, schedulerCompanyId, realtimeHub);
|
|
146
|
+
stopScheduler = createHeartbeatScheduler(db, schedulerCompanyId, realtimeHub);
|
|
125
147
|
} else if (schedulerCompanyId) {
|
|
126
148
|
// eslint-disable-next-line no-console
|
|
127
149
|
console.log("[startup] Scheduler disabled for this instance (BOPO_SCHEDULER_ROLE is follower/off).");
|
|
128
150
|
}
|
|
151
|
+
|
|
152
|
+
let shutdownInFlight: Promise<void> | null = null;
|
|
153
|
+
function shutdown(signal: string) {
|
|
154
|
+
shutdownInFlight ??= (async () => {
|
|
155
|
+
// eslint-disable-next-line no-console
|
|
156
|
+
console.log(`[shutdown] ${signal} — closing realtime, HTTP server, and embedded DB…`);
|
|
157
|
+
stopScheduler?.();
|
|
158
|
+
try {
|
|
159
|
+
await realtimeHub.close();
|
|
160
|
+
} catch (closeError) {
|
|
161
|
+
// eslint-disable-next-line no-console
|
|
162
|
+
console.error("[shutdown] realtime hub close error", closeError);
|
|
163
|
+
}
|
|
164
|
+
await new Promise<void>((resolve, reject) => {
|
|
165
|
+
server.close((err) => {
|
|
166
|
+
if (err) {
|
|
167
|
+
reject(err);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
resolve();
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
try {
|
|
174
|
+
await closePgliteClient(dbClient);
|
|
175
|
+
} catch (closeDbError) {
|
|
176
|
+
// eslint-disable-next-line no-console
|
|
177
|
+
console.error("[shutdown] PGlite close error", closeDbError);
|
|
178
|
+
}
|
|
179
|
+
// eslint-disable-next-line no-console
|
|
180
|
+
console.log("[shutdown] clean exit");
|
|
181
|
+
process.exit(0);
|
|
182
|
+
})().catch((error) => {
|
|
183
|
+
// eslint-disable-next-line no-console
|
|
184
|
+
console.error("[shutdown] failed", error);
|
|
185
|
+
process.exit(1);
|
|
186
|
+
});
|
|
187
|
+
return shutdownInFlight;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
process.once("SIGINT", () => void shutdown("SIGINT"));
|
|
191
|
+
process.once("SIGTERM", () => void shutdown("SIGTERM"));
|
|
129
192
|
}
|
|
130
193
|
|
|
131
194
|
void main();
|
|
132
195
|
|
|
196
|
+
async function closePgliteClient(client: unknown) {
|
|
197
|
+
if (!client || typeof client !== "object") {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const closeFn = (client as { close?: unknown }).close;
|
|
201
|
+
if (typeof closeFn !== "function") {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
await (closeFn as () => Promise<void>)();
|
|
205
|
+
}
|
|
206
|
+
|
|
133
207
|
async function hasCodexAgentsConfigured(db: Awaited<ReturnType<typeof bootstrapDatabase>>["db"]) {
|
|
134
208
|
const result = await db.execute(sql`
|
|
135
209
|
SELECT id
|
|
@@ -262,3 +336,15 @@ function normalizeOptionalDbPath(value: string | undefined) {
|
|
|
262
336
|
const normalized = value?.trim();
|
|
263
337
|
return normalized && normalized.length > 0 ? normalized : undefined;
|
|
264
338
|
}
|
|
339
|
+
|
|
340
|
+
function isProbablyPgliteWasmAbort(error: unknown): boolean {
|
|
341
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
342
|
+
const cause = error instanceof Error ? error.cause : undefined;
|
|
343
|
+
const causeMessage = cause instanceof Error ? cause.message : String(cause ?? "");
|
|
344
|
+
return (
|
|
345
|
+
message.includes("Aborted") ||
|
|
346
|
+
causeMessage.includes("Aborted") ||
|
|
347
|
+
message.includes("pglite") ||
|
|
348
|
+
causeMessage.includes("RuntimeError")
|
|
349
|
+
);
|
|
350
|
+
}
|
|
@@ -17,6 +17,40 @@ import type { BoardAttentionItem } from "bopodev-contracts";
|
|
|
17
17
|
|
|
18
18
|
type AttentionStateRow = Awaited<ReturnType<typeof listAttentionInboxStates>>[number];
|
|
19
19
|
|
|
20
|
+
/** Keep in sync with `RESOLVED_APPROVAL_INBOX_WINDOW_DAYS` in governance routes (resolved history in Inbox). */
|
|
21
|
+
const RESOLVED_APPROVAL_ATTENTION_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
|
|
22
|
+
|
|
23
|
+
type StoredApprovalRow = Awaited<ReturnType<typeof listApprovalRequests>>[number];
|
|
24
|
+
|
|
25
|
+
function approvalIncludedInAttentionList(approval: Pick<StoredApprovalRow, "status" | "resolvedAt">): boolean {
|
|
26
|
+
if (approval.status === "pending") {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
if (!approval.resolvedAt) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
return Date.now() - approval.resolvedAt.getTime() <= RESOLVED_APPROVAL_ATTENTION_WINDOW_MS;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function finalizeApprovalAttentionItem(item: BoardAttentionItem, approval: Pick<StoredApprovalRow, "status" | "resolvedAt" | "action">): BoardAttentionItem {
|
|
36
|
+
if (approval.status === "pending") {
|
|
37
|
+
return item;
|
|
38
|
+
}
|
|
39
|
+
const outcome =
|
|
40
|
+
approval.status === "approved" ? "Approved" : approval.status === "rejected" ? "Rejected" : "Overridden";
|
|
41
|
+
const title =
|
|
42
|
+
approval.action === "override_budget" ? `Budget hard-stop · ${outcome}` : `Approval · ${outcome}`;
|
|
43
|
+
const resolvedAtIso = approval.resolvedAt?.toISOString() ?? item.resolvedAt;
|
|
44
|
+
return {
|
|
45
|
+
...item,
|
|
46
|
+
title,
|
|
47
|
+
state: "resolved",
|
|
48
|
+
resolvedAt: resolvedAtIso,
|
|
49
|
+
severity: "info",
|
|
50
|
+
sourceTimestamp: resolvedAtIso ?? item.sourceTimestamp
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
20
54
|
export async function listBoardAttentionItems(db: BopoDb, companyId: string, actorId: string): Promise<BoardAttentionItem[]> {
|
|
21
55
|
const [approvals, blockedIssues, heartbeatRuns, stateRows, boardComments] = await Promise.all([
|
|
22
56
|
listApprovalRequests(db, companyId),
|
|
@@ -42,7 +76,7 @@ export async function listBoardAttentionItems(db: BopoDb, companyId: string, act
|
|
|
42
76
|
const items: BoardAttentionItem[] = [];
|
|
43
77
|
|
|
44
78
|
for (const approval of approvals) {
|
|
45
|
-
if (approval
|
|
79
|
+
if (!approvalIncludedInAttentionList(approval)) {
|
|
46
80
|
continue;
|
|
47
81
|
}
|
|
48
82
|
const payload = parsePayload(approval.payloadJson);
|
|
@@ -55,25 +89,61 @@ export async function listBoardAttentionItems(db: BopoDb, companyId: string, act
|
|
|
55
89
|
const usedBudget = asNumber(payload.usedBudgetUsd);
|
|
56
90
|
const key = `budget:${approval.id}`;
|
|
57
91
|
items.push(
|
|
92
|
+
finalizeApprovalAttentionItem(
|
|
93
|
+
withState(
|
|
94
|
+
{
|
|
95
|
+
key,
|
|
96
|
+
category: "budget_hard_stop",
|
|
97
|
+
severity: ageHours >= 12 ? "critical" : "warning",
|
|
98
|
+
requiredActor: "board",
|
|
99
|
+
title: "Budget hard-stop requires board decision",
|
|
100
|
+
contextSummary: projectId
|
|
101
|
+
? `Project ${shortId(projectId)} is blocked by budget hard-stop.`
|
|
102
|
+
: agentId
|
|
103
|
+
? `Agent ${shortId(agentId)} is blocked by budget hard-stop.`
|
|
104
|
+
: "Agent work is blocked by budget hard-stop.",
|
|
105
|
+
actionLabel: "Review budget override",
|
|
106
|
+
actionHref: "/governance",
|
|
107
|
+
impactSummary: "Heartbeat work stays paused until budget override is approved or rejected.",
|
|
108
|
+
evidence: {
|
|
109
|
+
approvalId: approval.id,
|
|
110
|
+
projectId: projectId ?? undefined,
|
|
111
|
+
agentId: agentId ?? undefined
|
|
112
|
+
},
|
|
113
|
+
sourceTimestamp: approval.createdAt.toISOString(),
|
|
114
|
+
state: "open",
|
|
115
|
+
seenAt: null,
|
|
116
|
+
acknowledgedAt: null,
|
|
117
|
+
dismissedAt: null,
|
|
118
|
+
resolvedAt: null
|
|
119
|
+
},
|
|
120
|
+
stateByKey.get(key),
|
|
121
|
+
`Budget utilization ${formatPercent(utilizationPct)} (${formatUsd(usedBudget)} / ${formatUsd(currentBudget)}).`
|
|
122
|
+
),
|
|
123
|
+
approval
|
|
124
|
+
)
|
|
125
|
+
);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const key = `approval:${approval.id}`;
|
|
130
|
+
items.push(
|
|
131
|
+
finalizeApprovalAttentionItem(
|
|
58
132
|
withState(
|
|
59
133
|
{
|
|
60
134
|
key,
|
|
61
|
-
category: "
|
|
62
|
-
severity: ageHours >=
|
|
135
|
+
category: "approval_required",
|
|
136
|
+
severity: ageHours >= 24 ? "critical" : "warning",
|
|
63
137
|
requiredActor: "board",
|
|
64
|
-
title: "
|
|
65
|
-
contextSummary:
|
|
66
|
-
|
|
67
|
-
: agentId
|
|
68
|
-
? `Agent ${shortId(agentId)} is blocked by budget hard-stop.`
|
|
69
|
-
: "Agent work is blocked by budget hard-stop.",
|
|
70
|
-
actionLabel: "Review budget override",
|
|
138
|
+
title: "Approval required",
|
|
139
|
+
contextSummary: formatApprovalContext(approval.action, payload),
|
|
140
|
+
actionLabel: "Open approvals",
|
|
71
141
|
actionHref: "/governance",
|
|
72
|
-
impactSummary: "
|
|
142
|
+
impactSummary: "Execution remains blocked until this governance decision is resolved.",
|
|
73
143
|
evidence: {
|
|
74
144
|
approvalId: approval.id,
|
|
75
|
-
projectId: projectId ?? undefined,
|
|
76
|
-
agentId: agentId ?? undefined
|
|
145
|
+
projectId: asString(payload.projectId) ?? undefined,
|
|
146
|
+
agentId: asString(payload.agentId) ?? undefined
|
|
77
147
|
},
|
|
78
148
|
sourceTimestamp: approval.createdAt.toISOString(),
|
|
79
149
|
state: "open",
|
|
@@ -82,39 +152,9 @@ export async function listBoardAttentionItems(db: BopoDb, companyId: string, act
|
|
|
82
152
|
dismissedAt: null,
|
|
83
153
|
resolvedAt: null
|
|
84
154
|
},
|
|
85
|
-
stateByKey.get(key)
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
);
|
|
89
|
-
continue;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const key = `approval:${approval.id}`;
|
|
93
|
-
items.push(
|
|
94
|
-
withState(
|
|
95
|
-
{
|
|
96
|
-
key,
|
|
97
|
-
category: "approval_required",
|
|
98
|
-
severity: ageHours >= 24 ? "critical" : "warning",
|
|
99
|
-
requiredActor: "board",
|
|
100
|
-
title: "Approval required",
|
|
101
|
-
contextSummary: formatApprovalContext(approval.action, payload),
|
|
102
|
-
actionLabel: "Open approvals",
|
|
103
|
-
actionHref: "/governance",
|
|
104
|
-
impactSummary: "Execution remains blocked until this governance decision is resolved.",
|
|
105
|
-
evidence: {
|
|
106
|
-
approvalId: approval.id,
|
|
107
|
-
projectId: asString(payload.projectId) ?? undefined,
|
|
108
|
-
agentId: asString(payload.agentId) ?? undefined
|
|
109
|
-
},
|
|
110
|
-
sourceTimestamp: approval.createdAt.toISOString(),
|
|
111
|
-
state: "open",
|
|
112
|
-
seenAt: null,
|
|
113
|
-
acknowledgedAt: null,
|
|
114
|
-
dismissedAt: null,
|
|
115
|
-
resolvedAt: null
|
|
116
|
-
},
|
|
117
|
-
stateByKey.get(key)
|
|
155
|
+
stateByKey.get(key)
|
|
156
|
+
),
|
|
157
|
+
approval
|
|
118
158
|
)
|
|
119
159
|
);
|
|
120
160
|
}
|
|
@@ -777,6 +777,7 @@ function buildAgentStartupTaskBody(companyId: string, agentId: string) {
|
|
|
777
777
|
`Create your operating baseline before starting feature delivery work.`,
|
|
778
778
|
"",
|
|
779
779
|
`1. Create your operating folder at \`${agentOperatingFolder}/\`.`,
|
|
780
|
+
" During heartbeats, prefer the absolute path in `$BOPODEV_AGENT_OPERATING_DIR` (set by the runtime) so files land under your agent folder even when the shell cwd is a project workspace.",
|
|
780
781
|
"2. Author these files with your own responsibilities and working style:",
|
|
781
782
|
` - \`${agentOperatingFolder}/AGENTS.md\``,
|
|
782
783
|
` - \`${agentOperatingFolder}/HEARTBEAT.md\``,
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { mkdir } from "node:fs/promises";
|
|
1
|
+
import { mkdir, stat } from "node:fs/promises";
|
|
2
2
|
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
3
3
|
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
|
4
4
|
import { nanoid } from "nanoid";
|
|
5
5
|
import { resolveAdapter } from "bopodev-agent-sdk";
|
|
6
|
-
import type { AgentState, HeartbeatContext } from "bopodev-agent-sdk";
|
|
6
|
+
import type { AdapterExecutionResult, AgentState, HeartbeatContext } from "bopodev-agent-sdk";
|
|
7
7
|
import {
|
|
8
8
|
type AgentFinalRunOutput,
|
|
9
9
|
ControlPlaneHeadersJsonSchema,
|
|
@@ -41,7 +41,13 @@ import {
|
|
|
41
41
|
resolveCompanyWorkspaceRootPath,
|
|
42
42
|
resolveProjectWorkspacePath
|
|
43
43
|
} from "../lib/instance-paths";
|
|
44
|
-
import {
|
|
44
|
+
import { resolveRunArtifactAbsolutePath } from "../lib/run-artifact-paths";
|
|
45
|
+
import {
|
|
46
|
+
assertRuntimeCwdForCompany,
|
|
47
|
+
getProjectWorkspaceContextMap,
|
|
48
|
+
hasText,
|
|
49
|
+
resolveAgentFallbackWorkspace
|
|
50
|
+
} from "../lib/workspace-policy";
|
|
45
51
|
import type { RealtimeHub } from "../realtime/hub";
|
|
46
52
|
import { createHeartbeatRunsRealtimeEvent } from "../realtime/heartbeat-runs";
|
|
47
53
|
import { publishAttentionSnapshot } from "../realtime/attention";
|
|
@@ -892,6 +898,7 @@ export async function runHeartbeatForAgent(
|
|
|
892
898
|
failClosed: false
|
|
893
899
|
});
|
|
894
900
|
const isCommentOrderWake = options?.wakeContext?.reason === "issue_comment_recipient";
|
|
901
|
+
const heartbeatIdlePolicy = resolveHeartbeatIdlePolicy();
|
|
895
902
|
const workItems = isCommentOrderWake ? [] : await claimIssuesForAgent(db, companyId, agentId, runId);
|
|
896
903
|
const wakeWorkItems = await loadWakeContextWorkItems(db, companyId, options?.wakeContext?.issueIds);
|
|
897
904
|
const contextWorkItems = resolveExecutionWorkItems(workItems, wakeWorkItems, options?.wakeContext);
|
|
@@ -970,6 +977,7 @@ export async function runHeartbeatForAgent(
|
|
|
970
977
|
contextWorkItems,
|
|
971
978
|
mergedRuntime
|
|
972
979
|
);
|
|
980
|
+
await mkdir(join(resolveAgentFallbackWorkspace(companyId, agent.id), "operating"), { recursive: true });
|
|
973
981
|
state = {
|
|
974
982
|
...state,
|
|
975
983
|
runtime: workspaceResolution.runtime
|
|
@@ -1005,6 +1013,10 @@ export async function runHeartbeatForAgent(
|
|
|
1005
1013
|
...context,
|
|
1006
1014
|
memoryContext
|
|
1007
1015
|
};
|
|
1016
|
+
const isIdleNoWork = contextWorkItems.length === 0 && !isCommentOrderWake;
|
|
1017
|
+
if (heartbeatIdlePolicy === "micro_prompt" && isIdleNoWork) {
|
|
1018
|
+
context = { ...context, idleMicroPrompt: true };
|
|
1019
|
+
}
|
|
1008
1020
|
if (workspaceResolution.warnings.length > 0) {
|
|
1009
1021
|
await appendAuditEvent(db, {
|
|
1010
1022
|
companyId,
|
|
@@ -1170,19 +1182,34 @@ export async function runHeartbeatForAgent(
|
|
|
1170
1182
|
};
|
|
1171
1183
|
}
|
|
1172
1184
|
|
|
1173
|
-
const execution =
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1185
|
+
const execution: AdapterExecutionResult =
|
|
1186
|
+
heartbeatIdlePolicy === "skip_adapter" && isIdleNoWork
|
|
1187
|
+
? {
|
|
1188
|
+
status: "ok",
|
|
1189
|
+
summary:
|
|
1190
|
+
"Idle heartbeat: no assigned work items; adapter not invoked (BOPO_HEARTBEAT_IDLE_POLICY=skip_adapter).",
|
|
1191
|
+
tokenInput: 0,
|
|
1192
|
+
tokenOutput: 0,
|
|
1193
|
+
usdCost: 0,
|
|
1194
|
+
usage: {
|
|
1195
|
+
inputTokens: 0,
|
|
1196
|
+
cachedInputTokens: 0,
|
|
1197
|
+
outputTokens: 0
|
|
1198
|
+
}
|
|
1180
1199
|
}
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1200
|
+
: await executeAdapterWithWatchdog({
|
|
1201
|
+
execute: (abortSignal) =>
|
|
1202
|
+
adapter.execute({
|
|
1203
|
+
...context,
|
|
1204
|
+
runtime: {
|
|
1205
|
+
...(context.runtime ?? {}),
|
|
1206
|
+
abortSignal
|
|
1207
|
+
}
|
|
1208
|
+
}),
|
|
1209
|
+
providerType: agent.providerType as HeartbeatProviderType,
|
|
1210
|
+
runtime: workspaceResolution.runtime,
|
|
1211
|
+
externalAbortSignal: activeRunAbort.signal
|
|
1212
|
+
});
|
|
1186
1213
|
const usageLimitHint = execution.dispositionHint?.kind === "provider_usage_limited" ? execution.dispositionHint : null;
|
|
1187
1214
|
if (usageLimitHint) {
|
|
1188
1215
|
providerUsageLimitDisposition = {
|
|
@@ -1454,6 +1481,7 @@ export async function runHeartbeatForAgent(
|
|
|
1454
1481
|
cost: runCost,
|
|
1455
1482
|
runtimeCwd: workspaceResolution.runtime.cwd
|
|
1456
1483
|
});
|
|
1484
|
+
await verifyRunArtifactsOnDisk(companyId, runReport.artifacts);
|
|
1457
1485
|
emitCanonicalResultEvent(runReport.resultSummary, runReport.finalStatus);
|
|
1458
1486
|
const runListMessage = buildRunListMessageFromReport(runReport);
|
|
1459
1487
|
await db
|
|
@@ -1823,6 +1851,7 @@ export async function runHeartbeatForAgent(
|
|
|
1823
1851
|
errorType: classified.type,
|
|
1824
1852
|
errorMessage: classified.message
|
|
1825
1853
|
});
|
|
1854
|
+
await verifyRunArtifactsOnDisk(companyId, runReport.artifacts);
|
|
1826
1855
|
const runListMessage = buildRunListMessageFromReport(runReport);
|
|
1827
1856
|
await db
|
|
1828
1857
|
.update(heartbeatRuns)
|
|
@@ -2357,6 +2386,7 @@ async function buildHeartbeatContext(
|
|
|
2357
2386
|
fileSizeBytes: number;
|
|
2358
2387
|
relativePath: string;
|
|
2359
2388
|
absolutePath: string;
|
|
2389
|
+
downloadPath: string;
|
|
2360
2390
|
}>
|
|
2361
2391
|
>();
|
|
2362
2392
|
for (const row of attachmentRows) {
|
|
@@ -2372,7 +2402,8 @@ async function buildHeartbeatContext(
|
|
|
2372
2402
|
mimeType: row.mimeType,
|
|
2373
2403
|
fileSizeBytes: row.fileSizeBytes,
|
|
2374
2404
|
relativePath: row.relativePath,
|
|
2375
|
-
absolutePath
|
|
2405
|
+
absolutePath,
|
|
2406
|
+
downloadPath: `/issues/${row.issueId}/attachments/${row.id}/download`
|
|
2376
2407
|
});
|
|
2377
2408
|
attachmentsByIssue.set(row.issueId, existing);
|
|
2378
2409
|
}
|
|
@@ -2400,12 +2431,14 @@ async function buildHeartbeatContext(
|
|
|
2400
2431
|
.filter((goal) => goal.status === "active" && goal.level === "agent")
|
|
2401
2432
|
.map((goal) => goal.title);
|
|
2402
2433
|
const isCommentOrderWake = input.wakeContext?.reason === "issue_comment_recipient";
|
|
2434
|
+
const promptMode = resolveHeartbeatPromptMode();
|
|
2403
2435
|
|
|
2404
2436
|
return {
|
|
2405
2437
|
companyId,
|
|
2406
2438
|
agentId: input.agentId,
|
|
2407
2439
|
providerType: input.providerType,
|
|
2408
2440
|
heartbeatRunId: input.heartbeatRunId,
|
|
2441
|
+
promptMode,
|
|
2409
2442
|
company: {
|
|
2410
2443
|
name: company?.name ?? "Unknown company",
|
|
2411
2444
|
mission: company?.mission ?? null
|
|
@@ -3054,6 +3087,26 @@ function buildRunArtifacts(input: {
|
|
|
3054
3087
|
});
|
|
3055
3088
|
}
|
|
3056
3089
|
|
|
3090
|
+
async function verifyRunArtifactsOnDisk(companyId: string, artifacts: RunArtifact[]) {
|
|
3091
|
+
for (const artifact of artifacts) {
|
|
3092
|
+
const resolved = resolveRunArtifactAbsolutePath(companyId, {
|
|
3093
|
+
path: artifact.path,
|
|
3094
|
+
relativePath: artifact.relativePath ?? undefined,
|
|
3095
|
+
absolutePath: artifact.absolutePath ?? undefined
|
|
3096
|
+
});
|
|
3097
|
+
if (!resolved) {
|
|
3098
|
+
artifact.verifiedOnDisk = false;
|
|
3099
|
+
continue;
|
|
3100
|
+
}
|
|
3101
|
+
try {
|
|
3102
|
+
const stats = await stat(resolved);
|
|
3103
|
+
artifact.verifiedOnDisk = stats.isFile();
|
|
3104
|
+
} catch {
|
|
3105
|
+
artifact.verifiedOnDisk = false;
|
|
3106
|
+
}
|
|
3107
|
+
}
|
|
3108
|
+
}
|
|
3109
|
+
|
|
3057
3110
|
function toNormalizedWorkspaceRelativePath(inputPath: string | null | undefined) {
|
|
3058
3111
|
const trimmed = inputPath?.trim();
|
|
3059
3112
|
if (!trimmed) {
|
|
@@ -3094,11 +3147,9 @@ function normalizeAgentOperatingArtifactRelativePath(pathValue: string | null, c
|
|
|
3094
3147
|
if (!parsed) {
|
|
3095
3148
|
return null;
|
|
3096
3149
|
}
|
|
3097
|
-
const embeddedCompanyId = parsed[1]?.trim() || companyId;
|
|
3098
3150
|
const agentId = parsed[2];
|
|
3099
3151
|
const suffix = parsed[3] ?? "";
|
|
3100
|
-
|
|
3101
|
-
return `workspace/${effectiveCompanyId}/agents/${agentId}/operating${suffix}`;
|
|
3152
|
+
return `workspace/${companyId}/agents/${agentId}/operating${suffix}`;
|
|
3102
3153
|
}
|
|
3103
3154
|
const directMatch = normalized.match(/^agents\/([^/]+)\/operating(\/.*)?$/);
|
|
3104
3155
|
if (directMatch) {
|
|
@@ -3323,6 +3374,9 @@ function formatRunArtifactMarkdownLink(
|
|
|
3323
3374
|
if (!label) {
|
|
3324
3375
|
return "`artifact`";
|
|
3325
3376
|
}
|
|
3377
|
+
if (artifact.verifiedOnDisk === false) {
|
|
3378
|
+
return `\`${label}\` (not found under company workspace at run completion)`;
|
|
3379
|
+
}
|
|
3326
3380
|
if (!href) {
|
|
3327
3381
|
return `\`${label}\``;
|
|
3328
3382
|
}
|
|
@@ -4291,6 +4345,24 @@ function clearResumeState(
|
|
|
4291
4345
|
};
|
|
4292
4346
|
}
|
|
4293
4347
|
|
|
4348
|
+
function resolveHeartbeatPromptMode(): "full" | "compact" {
|
|
4349
|
+
const raw = process.env.BOPO_HEARTBEAT_PROMPT_MODE?.trim().toLowerCase();
|
|
4350
|
+
return raw === "compact" ? "compact" : "full";
|
|
4351
|
+
}
|
|
4352
|
+
|
|
4353
|
+
type HeartbeatIdlePolicy = "full" | "skip_adapter" | "micro_prompt";
|
|
4354
|
+
|
|
4355
|
+
function resolveHeartbeatIdlePolicy(): HeartbeatIdlePolicy {
|
|
4356
|
+
const raw = process.env.BOPO_HEARTBEAT_IDLE_POLICY?.trim().toLowerCase();
|
|
4357
|
+
if (raw === "skip_adapter") {
|
|
4358
|
+
return "skip_adapter";
|
|
4359
|
+
}
|
|
4360
|
+
if (raw === "micro_prompt") {
|
|
4361
|
+
return "micro_prompt";
|
|
4362
|
+
}
|
|
4363
|
+
return "full";
|
|
4364
|
+
}
|
|
4365
|
+
|
|
4294
4366
|
function resolveControlPlaneEnv(runtimeEnv: Record<string, string>, suffix: string) {
|
|
4295
4367
|
const next = runtimeEnv[`BOPODEV_${suffix}`];
|
|
4296
4368
|
return hasText(next) ? (next as string) : "";
|
|
@@ -4307,8 +4379,13 @@ function buildHeartbeatRuntimeEnv(input: {
|
|
|
4307
4379
|
canHireAgents: boolean;
|
|
4308
4380
|
wakeContext?: HeartbeatWakeContext;
|
|
4309
4381
|
}) {
|
|
4382
|
+
const companyWorkspaceRoot = resolveCompanyWorkspaceRootPath(input.companyId);
|
|
4383
|
+
const agentHome = resolveAgentFallbackWorkspace(input.companyId, input.agentId);
|
|
4384
|
+
const agentOperatingDir = join(agentHome, "operating");
|
|
4310
4385
|
const apiBaseUrl = resolveControlPlaneApiBaseUrl();
|
|
4311
|
-
|
|
4386
|
+
// agents:write is required for PUT /agents/:self (bootstrapPrompt, runtimeConfig). Route handlers
|
|
4387
|
+
// still forbid agents from updating other agents' rows and from POST /agents unless canHireAgents.
|
|
4388
|
+
const actorPermissions = ["issues:write", "agents:write"].join(",");
|
|
4312
4389
|
const actorHeaders = JSON.stringify({
|
|
4313
4390
|
"x-company-id": input.companyId,
|
|
4314
4391
|
"x-actor-type": "agent",
|
|
@@ -4322,7 +4399,12 @@ function buildHeartbeatRuntimeEnv(input: {
|
|
|
4322
4399
|
return {
|
|
4323
4400
|
BOPODEV_AGENT_ID: input.agentId,
|
|
4324
4401
|
BOPODEV_COMPANY_ID: input.companyId,
|
|
4402
|
+
BOPODEV_COMPANY_WORKSPACE_ROOT: companyWorkspaceRoot,
|
|
4403
|
+
BOPODEV_AGENT_HOME: agentHome,
|
|
4404
|
+
BOPODEV_AGENT_OPERATING_DIR: agentOperatingDir,
|
|
4325
4405
|
BOPODEV_RUN_ID: input.heartbeatRunId,
|
|
4406
|
+
BOPODEV_HEARTBEAT_PROMPT_MODE: resolveHeartbeatPromptMode(),
|
|
4407
|
+
BOPODEV_HEARTBEAT_IDLE_POLICY: resolveHeartbeatIdlePolicy(),
|
|
4326
4408
|
BOPODEV_FORCE_MANAGED_CODEX_HOME: "false",
|
|
4327
4409
|
BOPODEV_API_BASE_URL: apiBaseUrl,
|
|
4328
4410
|
BOPODEV_API_URL: apiBaseUrl,
|
|
@@ -1,129 +1,6 @@
|
|
|
1
1
|
import type { BopoDb } from "bopodev-db";
|
|
2
|
-
import {
|
|
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;
|