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