bopodev-api 0.1.12 → 0.1.14
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/LICENSE +1 -1
- package/package.json +6 -4
- package/src/app.ts +2 -0
- package/src/lib/agent-config.ts +36 -1
- package/src/lib/instance-paths.ts +12 -0
- package/src/lib/opencode-model.ts +11 -0
- package/src/lib/workspace-policy.ts +5 -0
- package/src/realtime/heartbeat-runs.ts +78 -0
- package/src/realtime/hub.ts +37 -1
- package/src/realtime/office-space.ts +10 -1
- package/src/routes/agents.ts +111 -2
- package/src/routes/companies.ts +4 -0
- package/src/routes/governance.ts +9 -2
- package/src/routes/heartbeats.ts +2 -1
- package/src/routes/issues.ts +321 -0
- package/src/routes/observability.ts +595 -18
- package/src/routes/plugins.ts +257 -0
- package/src/scripts/onboard-seed.ts +60 -12
- package/src/server.ts +62 -3
- package/src/services/governance-service.ts +106 -23
- package/src/services/heartbeat-service.ts +750 -49
- package/src/services/memory-file-service.ts +249 -0
- package/src/services/model-pricing.ts +217 -0
- package/src/services/plugin-manifest-loader.ts +65 -0
- package/src/services/plugin-runtime.ts +580 -0
- package/src/services/plugin-webhook-executor.ts +94 -0
package/LICENSE
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bopodev-api",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.14",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -13,16 +13,18 @@
|
|
|
13
13
|
"dotenv": "^17.0.1",
|
|
14
14
|
"drizzle-orm": "^0.44.5",
|
|
15
15
|
"express": "^5.1.0",
|
|
16
|
+
"multer": "^2.1.1",
|
|
16
17
|
"nanoid": "^5.1.5",
|
|
17
18
|
"ws": "^8.19.0",
|
|
18
19
|
"zod": "^4.1.5",
|
|
19
|
-
"bopodev-
|
|
20
|
-
"bopodev-
|
|
21
|
-
"bopodev-agent-sdk": "0.1.
|
|
20
|
+
"bopodev-db": "0.1.14",
|
|
21
|
+
"bopodev-contracts": "0.1.14",
|
|
22
|
+
"bopodev-agent-sdk": "0.1.14"
|
|
22
23
|
},
|
|
23
24
|
"devDependencies": {
|
|
24
25
|
"@types/cors": "^2.8.19",
|
|
25
26
|
"@types/express": "^5.0.3",
|
|
27
|
+
"@types/multer": "^2.1.0",
|
|
26
28
|
"@types/ws": "^8.18.1",
|
|
27
29
|
"tsx": "^4.20.5"
|
|
28
30
|
},
|
package/src/app.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { createHeartbeatRouter } from "./routes/heartbeats";
|
|
|
13
13
|
import { createIssuesRouter } from "./routes/issues";
|
|
14
14
|
import { createObservabilityRouter } from "./routes/observability";
|
|
15
15
|
import { createProjectsRouter } from "./routes/projects";
|
|
16
|
+
import { createPluginsRouter } from "./routes/plugins";
|
|
16
17
|
import { sendError } from "./http";
|
|
17
18
|
import { attachRequestActor } from "./middleware/request-actor";
|
|
18
19
|
|
|
@@ -62,6 +63,7 @@ export function createApp(ctx: AppContext) {
|
|
|
62
63
|
app.use("/governance", createGovernanceRouter(ctx));
|
|
63
64
|
app.use("/heartbeats", createHeartbeatRouter(ctx));
|
|
64
65
|
app.use("/observability", createObservabilityRouter(ctx));
|
|
66
|
+
app.use("/plugins", createPluginsRouter(ctx));
|
|
65
67
|
|
|
66
68
|
app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
|
|
67
69
|
if (error instanceof RepositoryValidationError) {
|
package/src/lib/agent-config.ts
CHANGED
|
@@ -39,10 +39,39 @@ export function requiresRuntimeCwd(providerType: string) {
|
|
|
39
39
|
providerType === "claude_code" ||
|
|
40
40
|
providerType === "cursor" ||
|
|
41
41
|
providerType === "opencode" ||
|
|
42
|
+
providerType === "gemini_cli" ||
|
|
42
43
|
providerType === "shell"
|
|
43
44
|
);
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
export function resolveDefaultRuntimeModelForProvider(providerType: string | undefined) {
|
|
48
|
+
const normalizedProviderType = providerType?.trim() ?? "";
|
|
49
|
+
if (normalizedProviderType === "claude_code") {
|
|
50
|
+
return "claude-sonnet-4-6";
|
|
51
|
+
}
|
|
52
|
+
if (normalizedProviderType === "codex") {
|
|
53
|
+
return "gpt-5.3-codex";
|
|
54
|
+
}
|
|
55
|
+
if (normalizedProviderType === "opencode") {
|
|
56
|
+
return "opencode/big-pickle";
|
|
57
|
+
}
|
|
58
|
+
if (normalizedProviderType === "gemini_cli") {
|
|
59
|
+
return "gemini-2.5-pro";
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function resolveRuntimeModelForProvider(
|
|
65
|
+
providerType: string | undefined,
|
|
66
|
+
runtimeModel: string | undefined
|
|
67
|
+
) {
|
|
68
|
+
const normalizedRuntimeModel = runtimeModel?.trim() || undefined;
|
|
69
|
+
if (normalizedRuntimeModel) {
|
|
70
|
+
return normalizedRuntimeModel;
|
|
71
|
+
}
|
|
72
|
+
return resolveDefaultRuntimeModelForProvider(providerType);
|
|
73
|
+
}
|
|
74
|
+
|
|
46
75
|
export function normalizeRuntimeConfig(input: {
|
|
47
76
|
runtimeConfig?: Partial<AgentRuntimeConfig>;
|
|
48
77
|
legacy?: LegacyRuntimeFields;
|
|
@@ -116,12 +145,15 @@ export function parseRuntimeConfigFromAgentRow(agent: Record<string, unknown>):
|
|
|
116
145
|
? timeoutSecFromColumn
|
|
117
146
|
: (toSeconds(fallback.timeoutMs) ?? 0);
|
|
118
147
|
|
|
148
|
+
const providerType = toText(agent.providerType);
|
|
149
|
+
const runtimeModel = resolveRuntimeModelForProvider(providerType, toText(agent.runtimeModel) ?? fallback.model);
|
|
150
|
+
|
|
119
151
|
return {
|
|
120
152
|
runtimeCommand: toText(agent.runtimeCommand) ?? fallback.command,
|
|
121
153
|
runtimeArgs,
|
|
122
154
|
runtimeCwd: toText(agent.runtimeCwd) ?? fallback.cwd,
|
|
123
155
|
runtimeEnv,
|
|
124
|
-
runtimeModel
|
|
156
|
+
runtimeModel,
|
|
125
157
|
runtimeThinkingEffort: parseThinkingEffort(agent.runtimeThinkingEffort),
|
|
126
158
|
bootstrapPrompt: toText(agent.bootstrapPrompt),
|
|
127
159
|
runtimeTimeoutSec: Math.max(0, timeoutSec),
|
|
@@ -165,6 +197,7 @@ function parseRuntimeFromStateBlob(raw: unknown) {
|
|
|
165
197
|
args?: string[];
|
|
166
198
|
cwd?: string;
|
|
167
199
|
env?: Record<string, string>;
|
|
200
|
+
model?: string;
|
|
168
201
|
timeoutMs?: number;
|
|
169
202
|
};
|
|
170
203
|
}
|
|
@@ -175,6 +208,7 @@ function parseRuntimeFromStateBlob(raw: unknown) {
|
|
|
175
208
|
args?: unknown;
|
|
176
209
|
cwd?: unknown;
|
|
177
210
|
env?: unknown;
|
|
211
|
+
model?: unknown;
|
|
178
212
|
timeoutMs?: unknown;
|
|
179
213
|
};
|
|
180
214
|
};
|
|
@@ -184,6 +218,7 @@ function parseRuntimeFromStateBlob(raw: unknown) {
|
|
|
184
218
|
args: Array.isArray(runtime.args) ? runtime.args.map((entry) => String(entry)) : undefined,
|
|
185
219
|
cwd: typeof runtime.cwd === "string" ? runtime.cwd : undefined,
|
|
186
220
|
env: toRecord(runtime.env),
|
|
221
|
+
model: typeof runtime.model === "string" && runtime.model.trim().length > 0 ? runtime.model.trim() : undefined,
|
|
187
222
|
timeoutMs: toNumber(runtime.timeoutMs)
|
|
188
223
|
};
|
|
189
224
|
} catch {
|
|
@@ -62,6 +62,18 @@ export function resolveAgentFallbackWorkspacePath(companyId: string, agentId: st
|
|
|
62
62
|
return join(resolveBopoInstanceRoot(), "workspaces", companyId, "agents", agentId);
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
export function resolveAgentMemoryRootPath(companyId: string, agentId: string) {
|
|
66
|
+
return join(resolveAgentFallbackWorkspacePath(companyId, agentId), "memory");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function resolveAgentDurableMemoryPath(companyId: string, agentId: string) {
|
|
70
|
+
return join(resolveAgentMemoryRootPath(companyId, agentId), "life");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function resolveAgentDailyMemoryPath(companyId: string, agentId: string) {
|
|
74
|
+
return join(resolveAgentMemoryRootPath(companyId, agentId), "memory");
|
|
75
|
+
}
|
|
76
|
+
|
|
65
77
|
export function resolveStorageRoot() {
|
|
66
78
|
return join(resolveBopoInstanceRoot(), "data", "storage");
|
|
67
79
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { NormalizedRuntimeConfig } from "./agent-config";
|
|
2
|
+
|
|
3
|
+
export async function resolveOpencodeRuntimeModel(
|
|
4
|
+
providerType: string,
|
|
5
|
+
runtimeConfig: NormalizedRuntimeConfig
|
|
6
|
+
): Promise<string | undefined> {
|
|
7
|
+
if (providerType !== "opencode") {
|
|
8
|
+
return runtimeConfig.runtimeModel?.trim() || undefined;
|
|
9
|
+
}
|
|
10
|
+
return runtimeConfig.runtimeModel?.trim() || undefined;
|
|
11
|
+
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
isInsidePath,
|
|
6
6
|
normalizeAbsolutePath,
|
|
7
7
|
resolveAgentFallbackWorkspacePath,
|
|
8
|
+
resolveAgentMemoryRootPath,
|
|
8
9
|
resolveCompanyProjectsWorkspacePath
|
|
9
10
|
} from "./instance-paths";
|
|
10
11
|
|
|
@@ -73,3 +74,7 @@ export function ensureRuntimeWorkspaceCompatible(projectWorkspacePath: string, r
|
|
|
73
74
|
export function resolveAgentFallbackWorkspace(companyId: string, agentId: string) {
|
|
74
75
|
return resolveAgentFallbackWorkspacePath(companyId, agentId);
|
|
75
76
|
}
|
|
77
|
+
|
|
78
|
+
export function resolveAgentMemoryRoot(companyId: string, agentId: string) {
|
|
79
|
+
return resolveAgentMemoryRootPath(companyId, agentId);
|
|
80
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
HeartbeatRunRealtimeEvent,
|
|
3
|
+
RealtimeEventEnvelope,
|
|
4
|
+
RealtimeMessage
|
|
5
|
+
} from "bopodev-contracts";
|
|
6
|
+
import { listHeartbeatRunMessagesForRuns, listHeartbeatRuns, type BopoDb } from "bopodev-db";
|
|
7
|
+
|
|
8
|
+
export function createHeartbeatRunsRealtimeEvent(
|
|
9
|
+
companyId: string,
|
|
10
|
+
event: HeartbeatRunRealtimeEvent
|
|
11
|
+
): Extract<RealtimeMessage, { kind: "event" }> {
|
|
12
|
+
return createRealtimeEvent(companyId, {
|
|
13
|
+
channel: "heartbeat-runs",
|
|
14
|
+
event
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function loadHeartbeatRunsRealtimeSnapshot(
|
|
19
|
+
db: BopoDb,
|
|
20
|
+
companyId: string
|
|
21
|
+
): Promise<Extract<RealtimeMessage, { kind: "event" }>> {
|
|
22
|
+
const runs = await listHeartbeatRuns(db, companyId, 8);
|
|
23
|
+
const transcriptsByRunId = await listHeartbeatRunMessagesForRuns(db, {
|
|
24
|
+
companyId,
|
|
25
|
+
runIds: runs.map((run) => run.id),
|
|
26
|
+
perRunLimit: 60
|
|
27
|
+
});
|
|
28
|
+
const transcripts = runs.map((run) => {
|
|
29
|
+
const result = transcriptsByRunId.get(run.id) ?? { items: [], nextCursor: null };
|
|
30
|
+
return {
|
|
31
|
+
runId: run.id,
|
|
32
|
+
messages: result.items.map((message) => ({
|
|
33
|
+
id: message.id,
|
|
34
|
+
runId: message.runId,
|
|
35
|
+
sequence: message.sequence,
|
|
36
|
+
kind: message.kind as
|
|
37
|
+
| "system"
|
|
38
|
+
| "assistant"
|
|
39
|
+
| "thinking"
|
|
40
|
+
| "tool_call"
|
|
41
|
+
| "tool_result"
|
|
42
|
+
| "result"
|
|
43
|
+
| "stderr",
|
|
44
|
+
label: message.label,
|
|
45
|
+
text: message.text,
|
|
46
|
+
payload: message.payloadJson,
|
|
47
|
+
signalLevel: (message.signalLevel as "high" | "medium" | "low" | "noise" | null) ?? undefined,
|
|
48
|
+
groupKey: message.groupKey,
|
|
49
|
+
source: (message.source as "stdout" | "stderr" | "trace_fallback" | null) ?? undefined,
|
|
50
|
+
createdAt: message.createdAt.toISOString()
|
|
51
|
+
})),
|
|
52
|
+
nextCursor: result.nextCursor
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return createHeartbeatRunsRealtimeEvent(companyId, {
|
|
57
|
+
type: "runs.snapshot",
|
|
58
|
+
runs: runs.map((run) => ({
|
|
59
|
+
runId: run.id,
|
|
60
|
+
status: run.status as "started" | "completed" | "failed" | "skipped",
|
|
61
|
+
message: run.message ?? null,
|
|
62
|
+
startedAt: run.startedAt.toISOString(),
|
|
63
|
+
finishedAt: run.finishedAt?.toISOString() ?? null
|
|
64
|
+
})),
|
|
65
|
+
transcripts
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function createRealtimeEvent(
|
|
70
|
+
companyId: string,
|
|
71
|
+
envelope: Extract<RealtimeEventEnvelope, { channel: "heartbeat-runs" }>
|
|
72
|
+
): Extract<RealtimeMessage, { kind: "event" }> {
|
|
73
|
+
return {
|
|
74
|
+
kind: "event",
|
|
75
|
+
companyId,
|
|
76
|
+
...envelope
|
|
77
|
+
};
|
|
78
|
+
}
|
package/src/realtime/hub.ts
CHANGED
|
@@ -25,7 +25,7 @@ export function attachRealtimeHub(
|
|
|
25
25
|
|
|
26
26
|
wss.on("connection", async (socket, request) => {
|
|
27
27
|
const subscription = getSubscription(request.url);
|
|
28
|
-
if (!subscription) {
|
|
28
|
+
if (!subscription || !canSubscribeToCompany(request.headers, subscription.companyId)) {
|
|
29
29
|
socket.close(1008, "Invalid realtime subscription");
|
|
30
30
|
return;
|
|
31
31
|
}
|
|
@@ -131,6 +131,42 @@ function getSubscription(requestUrl: string | undefined) {
|
|
|
131
131
|
};
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
function canSubscribeToCompany(
|
|
135
|
+
headers: Record<string, string | string[] | undefined>,
|
|
136
|
+
companyId: string
|
|
137
|
+
) {
|
|
138
|
+
const actorType = readHeader(headers, "x-actor-type")?.toLowerCase();
|
|
139
|
+
const actorCompanies = parseCommaList(readHeader(headers, "x-actor-companies"));
|
|
140
|
+
const hasActorHeaders = Boolean(actorType || actorCompanies.length > 0);
|
|
141
|
+
const allowLocalBoardFallback = process.env.NODE_ENV !== "production" && process.env.BOPO_ALLOW_LOCAL_BOARD_FALLBACK !== "0";
|
|
142
|
+
|
|
143
|
+
if (!hasActorHeaders) {
|
|
144
|
+
return allowLocalBoardFallback;
|
|
145
|
+
}
|
|
146
|
+
if (actorType === "board") {
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
return actorCompanies.includes(companyId);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function readHeader(headers: Record<string, string | string[] | undefined>, name: string) {
|
|
153
|
+
const value = headers[name];
|
|
154
|
+
if (Array.isArray(value)) {
|
|
155
|
+
return value[0];
|
|
156
|
+
}
|
|
157
|
+
return value;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function parseCommaList(value: string | undefined) {
|
|
161
|
+
if (!value) {
|
|
162
|
+
return [] as string[];
|
|
163
|
+
}
|
|
164
|
+
return value
|
|
165
|
+
.split(",")
|
|
166
|
+
.map((entry) => entry.trim())
|
|
167
|
+
.filter((entry) => entry.length > 0);
|
|
168
|
+
}
|
|
169
|
+
|
|
134
170
|
function buildSubscriptionKey(companyId: string, channel: RealtimeChannel) {
|
|
135
171
|
return `${companyId}:${channel}`;
|
|
136
172
|
}
|
|
@@ -427,7 +427,16 @@ function sortOccupants(occupants: OfficeOccupant[]) {
|
|
|
427
427
|
}
|
|
428
428
|
|
|
429
429
|
function normalizeProviderType(value: string): OfficeOccupant["providerType"] {
|
|
430
|
-
return value === "claude_code" ||
|
|
430
|
+
return value === "claude_code" ||
|
|
431
|
+
value === "codex" ||
|
|
432
|
+
value === "cursor" ||
|
|
433
|
+
value === "opencode" ||
|
|
434
|
+
value === "openai_api" ||
|
|
435
|
+
value === "anthropic_api" ||
|
|
436
|
+
value === "http" ||
|
|
437
|
+
value === "shell"
|
|
438
|
+
? value
|
|
439
|
+
: null;
|
|
431
440
|
}
|
|
432
441
|
|
|
433
442
|
function formatActionLabel(action: string) {
|
package/src/routes/agents.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
deleteAgent,
|
|
15
15
|
getApprovalRequest,
|
|
16
16
|
listAgents,
|
|
17
|
+
listApprovalRequests,
|
|
17
18
|
updateAgent
|
|
18
19
|
} from "bopodev-db";
|
|
19
20
|
import type { AppContext } from "../context";
|
|
@@ -21,10 +22,12 @@ import { sendError, sendOk } from "../http";
|
|
|
21
22
|
import {
|
|
22
23
|
normalizeRuntimeConfig,
|
|
23
24
|
parseRuntimeConfigFromAgentRow,
|
|
25
|
+
resolveRuntimeModelForProvider,
|
|
24
26
|
requiresRuntimeCwd,
|
|
25
27
|
runtimeConfigToDb,
|
|
26
28
|
runtimeConfigToStateBlobPatch
|
|
27
29
|
} from "../lib/agent-config";
|
|
30
|
+
import { resolveOpencodeRuntimeModel } from "../lib/opencode-model";
|
|
28
31
|
import { hasText, resolveDefaultRuntimeCwdForCompany } from "../lib/workspace-policy";
|
|
29
32
|
import { requireCompanyScope } from "../middleware/company-scope";
|
|
30
33
|
import { requireBoardRole, requirePermission } from "../middleware/request-actor";
|
|
@@ -63,7 +66,17 @@ const updateAgentSchema = AgentUpdateRequestSchema.extend({
|
|
|
63
66
|
});
|
|
64
67
|
|
|
65
68
|
const runtimePreflightSchema = z.object({
|
|
66
|
-
providerType: z.enum([
|
|
69
|
+
providerType: z.enum([
|
|
70
|
+
"claude_code",
|
|
71
|
+
"codex",
|
|
72
|
+
"cursor",
|
|
73
|
+
"opencode",
|
|
74
|
+
"gemini_cli",
|
|
75
|
+
"openai_api",
|
|
76
|
+
"anthropic_api",
|
|
77
|
+
"http",
|
|
78
|
+
"shell"
|
|
79
|
+
]),
|
|
67
80
|
runtimeConfig: z.record(z.string(), z.unknown()).optional(),
|
|
68
81
|
...legacyRuntimeConfigSchema.shape
|
|
69
82
|
});
|
|
@@ -111,6 +124,17 @@ function toAgentResponse(agent: Record<string, unknown>) {
|
|
|
111
124
|
};
|
|
112
125
|
}
|
|
113
126
|
|
|
127
|
+
function providerRequiresNamedModel(providerType: string) {
|
|
128
|
+
return providerType !== "http" && providerType !== "shell";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function ensureNamedRuntimeModel(providerType: string, runtimeModel: string | undefined) {
|
|
132
|
+
if (!providerRequiresNamedModel(providerType)) {
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
return hasText(runtimeModel);
|
|
136
|
+
}
|
|
137
|
+
|
|
114
138
|
export function createAgentsRouter(ctx: AppContext) {
|
|
115
139
|
const router = Router();
|
|
116
140
|
router.use(requireCompanyScope);
|
|
@@ -143,7 +167,16 @@ export function createAgentsRouter(ctx: AppContext) {
|
|
|
143
167
|
runtimeConfig: req.body?.runtimeConfig,
|
|
144
168
|
defaultRuntimeCwd
|
|
145
169
|
});
|
|
146
|
-
const typedProviderType = providerType as
|
|
170
|
+
const typedProviderType = providerType as
|
|
171
|
+
| "claude_code"
|
|
172
|
+
| "codex"
|
|
173
|
+
| "cursor"
|
|
174
|
+
| "opencode"
|
|
175
|
+
| "gemini_cli"
|
|
176
|
+
| "openai_api"
|
|
177
|
+
| "anthropic_api"
|
|
178
|
+
| "http"
|
|
179
|
+
| "shell";
|
|
147
180
|
const models = await getAdapterModels(typedProviderType, {
|
|
148
181
|
command: runtimeConfig.runtimeCommand,
|
|
149
182
|
args: runtimeConfig.runtimeArgs,
|
|
@@ -245,6 +278,11 @@ export function createAgentsRouter(ctx: AppContext) {
|
|
|
245
278
|
},
|
|
246
279
|
defaultRuntimeCwd
|
|
247
280
|
});
|
|
281
|
+
runtimeConfig.runtimeModel = await resolveOpencodeRuntimeModel(parsed.data.providerType, runtimeConfig);
|
|
282
|
+
runtimeConfig.runtimeModel = resolveRuntimeModelForProvider(parsed.data.providerType, runtimeConfig.runtimeModel);
|
|
283
|
+
if (!ensureNamedRuntimeModel(parsed.data.providerType, runtimeConfig.runtimeModel)) {
|
|
284
|
+
return sendError(res, "A named runtime model is required for this provider.", 422);
|
|
285
|
+
}
|
|
248
286
|
if (requiresRuntimeCwd(parsed.data.providerType) && !hasText(runtimeConfig.runtimeCwd)) {
|
|
249
287
|
return sendError(res, "Runtime working directory is required for this runtime provider.", 422);
|
|
250
288
|
}
|
|
@@ -253,6 +291,19 @@ export function createAgentsRouter(ctx: AppContext) {
|
|
|
253
291
|
}
|
|
254
292
|
|
|
255
293
|
if (parsed.data.requestApproval && isApprovalRequired("hire_agent")) {
|
|
294
|
+
const duplicate = await findDuplicateHireRequest(ctx.db, req.companyId!, {
|
|
295
|
+
role: parsed.data.role,
|
|
296
|
+
managerAgentId: parsed.data.managerAgentId ?? null
|
|
297
|
+
});
|
|
298
|
+
if (duplicate) {
|
|
299
|
+
return sendOk(res, {
|
|
300
|
+
queuedForApproval: false,
|
|
301
|
+
duplicate: true,
|
|
302
|
+
existingAgentId: duplicate.existingAgentId ?? null,
|
|
303
|
+
pendingApprovalId: duplicate.pendingApprovalId ?? null,
|
|
304
|
+
message: duplicateMessage(duplicate)
|
|
305
|
+
});
|
|
306
|
+
}
|
|
256
307
|
const approvalId = await createApprovalRequest(ctx.db, {
|
|
257
308
|
companyId: req.companyId!,
|
|
258
309
|
action: "hire_agent",
|
|
@@ -363,6 +414,11 @@ export function createAgentsRouter(ctx: AppContext) {
|
|
|
363
414
|
})
|
|
364
415
|
: {})
|
|
365
416
|
};
|
|
417
|
+
nextRuntime.runtimeModel = await resolveOpencodeRuntimeModel(effectiveProviderType, nextRuntime);
|
|
418
|
+
nextRuntime.runtimeModel = resolveRuntimeModelForProvider(effectiveProviderType, nextRuntime.runtimeModel);
|
|
419
|
+
if (!ensureNamedRuntimeModel(effectiveProviderType, nextRuntime.runtimeModel)) {
|
|
420
|
+
return sendError(res, "A named runtime model is required for this provider.", 422);
|
|
421
|
+
}
|
|
366
422
|
if (!nextRuntime.runtimeCwd && defaultRuntimeCwd) {
|
|
367
423
|
nextRuntime.runtimeCwd = defaultRuntimeCwd;
|
|
368
424
|
}
|
|
@@ -522,3 +578,56 @@ function listUnsupportedAgentUpdateKeys(payload: unknown) {
|
|
|
522
578
|
}
|
|
523
579
|
return unsupported;
|
|
524
580
|
}
|
|
581
|
+
|
|
582
|
+
async function findDuplicateHireRequest(
|
|
583
|
+
db: AppContext["db"],
|
|
584
|
+
companyId: string,
|
|
585
|
+
input: { role: string; managerAgentId: string | null }
|
|
586
|
+
) {
|
|
587
|
+
const role = input.role.trim();
|
|
588
|
+
const managerAgentId = input.managerAgentId ?? null;
|
|
589
|
+
const agents = await listAgents(db, companyId);
|
|
590
|
+
const existingAgent = agents.find(
|
|
591
|
+
(agent) =>
|
|
592
|
+
agent.role === role &&
|
|
593
|
+
(agent.managerAgentId ?? null) === managerAgentId &&
|
|
594
|
+
agent.status !== "terminated"
|
|
595
|
+
);
|
|
596
|
+
const approvals = await listApprovalRequests(db, companyId);
|
|
597
|
+
const pendingApproval = approvals.find((approval) => {
|
|
598
|
+
if (approval.status !== "pending" || approval.action !== "hire_agent") {
|
|
599
|
+
return false;
|
|
600
|
+
}
|
|
601
|
+
const payload = parseApprovalPayload(approval.payloadJson);
|
|
602
|
+
return payload.role === role && (payload.managerAgentId ?? null) === managerAgentId;
|
|
603
|
+
});
|
|
604
|
+
if (!existingAgent && !pendingApproval) {
|
|
605
|
+
return null;
|
|
606
|
+
}
|
|
607
|
+
return {
|
|
608
|
+
existingAgentId: existingAgent?.id ?? null,
|
|
609
|
+
pendingApprovalId: pendingApproval?.id ?? null
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function parseApprovalPayload(payloadJson: string): { role?: string; managerAgentId?: string | null } {
|
|
614
|
+
try {
|
|
615
|
+
const parsed = JSON.parse(payloadJson) as Record<string, unknown>;
|
|
616
|
+
return {
|
|
617
|
+
role: typeof parsed.role === "string" ? parsed.role : undefined,
|
|
618
|
+
managerAgentId: typeof parsed.managerAgentId === "string" ? parsed.managerAgentId : null
|
|
619
|
+
};
|
|
620
|
+
} catch {
|
|
621
|
+
return {};
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function duplicateMessage(input: { existingAgentId: string | null; pendingApprovalId: string | null }) {
|
|
626
|
+
if (input.existingAgentId && input.pendingApprovalId) {
|
|
627
|
+
return `Duplicate hire request blocked: existing agent ${input.existingAgentId} and pending approval ${input.pendingApprovalId}.`;
|
|
628
|
+
}
|
|
629
|
+
if (input.existingAgentId) {
|
|
630
|
+
return `Duplicate hire request blocked: existing agent ${input.existingAgentId}.`;
|
|
631
|
+
}
|
|
632
|
+
return `Duplicate hire request blocked: pending approval ${input.pendingApprovalId}.`;
|
|
633
|
+
}
|
package/src/routes/companies.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { z } from "zod";
|
|
|
3
3
|
import { createCompany, deleteCompany, listCompanies, updateCompany } from "bopodev-db";
|
|
4
4
|
import type { AppContext } from "../context";
|
|
5
5
|
import { sendError, sendOk } from "../http";
|
|
6
|
+
import { ensureCompanyModelPricingDefaults } from "../services/model-pricing";
|
|
7
|
+
import { ensureCompanyBuiltinPluginDefaults } from "../services/plugin-runtime";
|
|
6
8
|
|
|
7
9
|
const createCompanySchema = z.object({
|
|
8
10
|
name: z.string().min(1),
|
|
@@ -30,6 +32,8 @@ export function createCompaniesRouter(ctx: AppContext) {
|
|
|
30
32
|
return sendError(res, parsed.error.message, 422);
|
|
31
33
|
}
|
|
32
34
|
const company = await createCompany(ctx.db, parsed.data);
|
|
35
|
+
await ensureCompanyBuiltinPluginDefaults(ctx.db, company.id);
|
|
36
|
+
await ensureCompanyModelPricingDefaults(ctx.db, company.id);
|
|
33
37
|
return sendOk(res, company);
|
|
34
38
|
});
|
|
35
39
|
|
package/src/routes/governance.ts
CHANGED
|
@@ -163,11 +163,18 @@ export function createGovernanceRouter(ctx: AppContext) {
|
|
|
163
163
|
});
|
|
164
164
|
|
|
165
165
|
if (resolution.execution.applied && resolution.execution.entityType && resolution.execution.entityId) {
|
|
166
|
+
const eventType =
|
|
167
|
+
resolution.action === "grant_plugin_capabilities"
|
|
168
|
+
? "plugin.capabilities_granted_from_approval"
|
|
169
|
+
: resolution.execution.entityType === "agent"
|
|
170
|
+
? "agent.hired_from_approval"
|
|
171
|
+
: resolution.execution.entityType === "goal"
|
|
172
|
+
? "goal.activated_from_approval"
|
|
173
|
+
: "memory.promoted_from_approval";
|
|
166
174
|
await appendAuditEvent(ctx.db, {
|
|
167
175
|
companyId: req.companyId!,
|
|
168
176
|
actorType: "human",
|
|
169
|
-
eventType
|
|
170
|
-
resolution.execution.entityType === "agent" ? "agent.hired_from_approval" : "goal.activated_from_approval",
|
|
177
|
+
eventType,
|
|
171
178
|
entityType: resolution.execution.entityType,
|
|
172
179
|
entityId: resolution.execution.entityId,
|
|
173
180
|
payload: resolution.execution.entity ?? { id: resolution.execution.entityId }
|
package/src/routes/heartbeats.ts
CHANGED
|
@@ -79,7 +79,8 @@ export function createHeartbeatRouter(ctx: AppContext) {
|
|
|
79
79
|
const stopResult = await stopHeartbeatRun(ctx.db, req.companyId!, parsed.data.runId, {
|
|
80
80
|
requestId: req.requestId,
|
|
81
81
|
trigger: "manual",
|
|
82
|
-
actorId: req.actor?.id ?? undefined
|
|
82
|
+
actorId: req.actor?.id ?? undefined,
|
|
83
|
+
realtimeHub: ctx.realtimeHub
|
|
83
84
|
});
|
|
84
85
|
if (!stopResult.ok) {
|
|
85
86
|
if (stopResult.reason === "not_found") {
|