bopodev-api 0.1.10 → 0.1.12
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 +4 -4
- package/src/lib/agent-config.ts +7 -1
- package/src/middleware/request-actor.ts +25 -15
- package/src/routes/agents.ts +43 -93
- package/src/routes/heartbeats.ts +144 -1
- package/src/routes/observability.ts +24 -1
- package/src/scripts/onboard-seed.ts +23 -13
- package/src/server.ts +1 -1
- package/src/services/governance-service.ts +1 -1
- package/src/services/heartbeat-service.ts +596 -90
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.12",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
"nanoid": "^5.1.5",
|
|
17
17
|
"ws": "^8.19.0",
|
|
18
18
|
"zod": "^4.1.5",
|
|
19
|
-
"bopodev-
|
|
20
|
-
"bopodev-
|
|
21
|
-
"bopodev-
|
|
19
|
+
"bopodev-contracts": "0.1.12",
|
|
20
|
+
"bopodev-db": "0.1.12",
|
|
21
|
+
"bopodev-agent-sdk": "0.1.12"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
24
|
"@types/cors": "^2.8.19",
|
package/src/lib/agent-config.ts
CHANGED
|
@@ -34,7 +34,13 @@ export type NormalizedRuntimeConfig = {
|
|
|
34
34
|
};
|
|
35
35
|
|
|
36
36
|
export function requiresRuntimeCwd(providerType: string) {
|
|
37
|
-
return
|
|
37
|
+
return (
|
|
38
|
+
providerType === "codex" ||
|
|
39
|
+
providerType === "claude_code" ||
|
|
40
|
+
providerType === "cursor" ||
|
|
41
|
+
providerType === "opencode" ||
|
|
42
|
+
providerType === "shell"
|
|
43
|
+
);
|
|
38
44
|
}
|
|
39
45
|
|
|
40
46
|
export function normalizeRuntimeConfig(input: {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { NextFunction, Request, Response } from "express";
|
|
2
|
+
import { RequestActorHeadersSchema } from "bopodev-contracts";
|
|
2
3
|
import { sendError } from "../http";
|
|
3
4
|
|
|
4
5
|
export type RequestActor = {
|
|
@@ -18,27 +19,36 @@ declare global {
|
|
|
18
19
|
}
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
export function attachRequestActor(req: Request,
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
22
|
+
export function attachRequestActor(req: Request, res: Response, next: NextFunction) {
|
|
23
|
+
const actorHeadersResult = RequestActorHeadersSchema.safeParse({
|
|
24
|
+
"x-actor-type": req.header("x-actor-type")?.trim().toLowerCase(),
|
|
25
|
+
"x-actor-id": req.header("x-actor-id")?.trim(),
|
|
26
|
+
"x-actor-companies": req.header("x-actor-companies")?.trim(),
|
|
27
|
+
"x-actor-permissions": req.header("x-actor-permissions")?.trim()
|
|
28
|
+
});
|
|
29
|
+
if (!actorHeadersResult.success) {
|
|
30
|
+
return sendError(
|
|
31
|
+
res,
|
|
32
|
+
`Invalid actor headers: ${actorHeadersResult.error.issues
|
|
33
|
+
.map((issue) => `${issue.path.join(".") || "<root>"} ${issue.message}`)
|
|
34
|
+
.join("; ")}`,
|
|
35
|
+
400
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
const actorHeaders = actorHeadersResult.data;
|
|
39
|
+
const companyIds = parseCommaList(actorHeaders["x-actor-companies"]);
|
|
40
|
+
const permissions = parseCommaList(actorHeaders["x-actor-permissions"]) ?? [];
|
|
41
|
+
const hasActorHeaders = Boolean(
|
|
42
|
+
actorHeaders["x-actor-type"] || actorHeaders["x-actor-id"] || companyIds || permissions.length > 0
|
|
43
|
+
);
|
|
34
44
|
const allowLocalBoardFallback = process.env.NODE_ENV !== "production" && process.env.BOPO_ALLOW_LOCAL_BOARD_FALLBACK !== "0";
|
|
35
45
|
const actorType = hasActorHeaders
|
|
36
|
-
?
|
|
46
|
+
? actorHeaders["x-actor-type"] ?? "member"
|
|
37
47
|
: allowLocalBoardFallback
|
|
38
48
|
? "board"
|
|
39
49
|
: "member";
|
|
40
50
|
const actorId = hasActorHeaders
|
|
41
|
-
?
|
|
51
|
+
? actorHeaders["x-actor-id"] || "unknown-actor"
|
|
42
52
|
: allowLocalBoardFallback
|
|
43
53
|
? "local-board"
|
|
44
54
|
: "anonymous-member";
|
package/src/routes/agents.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { Router } from "express";
|
|
2
2
|
import { mkdir } from "node:fs/promises";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
getAdapterMetadata,
|
|
6
|
+
getAdapterModels,
|
|
7
|
+
runAdapterEnvironmentTest
|
|
8
|
+
} from "bopodev-agent-sdk";
|
|
5
9
|
import { AgentCreateRequestSchema, AgentUpdateRequestSchema } from "bopodev-contracts";
|
|
6
10
|
import {
|
|
7
11
|
appendAuditEvent,
|
|
@@ -59,12 +63,10 @@ const updateAgentSchema = AgentUpdateRequestSchema.extend({
|
|
|
59
63
|
});
|
|
60
64
|
|
|
61
65
|
const runtimePreflightSchema = z.object({
|
|
62
|
-
providerType: z.enum(["claude_code", "codex", "http", "shell"]),
|
|
66
|
+
providerType: z.enum(["claude_code", "codex", "cursor", "opencode", "http", "shell"]),
|
|
63
67
|
runtimeConfig: z.record(z.string(), z.unknown()).optional(),
|
|
64
68
|
...legacyRuntimeConfigSchema.shape
|
|
65
69
|
});
|
|
66
|
-
const CODEX_AUTH_REQUIRED_RE =
|
|
67
|
-
/(not\s+logged\s+in|login\s+required|authentication\s+required|unauthorized|invalid(?:\s+or\s+missing)?\s+api(?:[_\s-]?key)?|openai[_\s-]?api[_\s-]?key|api[_\s-]?key.*required|missing bearer|missing bearer or basic authentication)/i;
|
|
68
70
|
const UPDATE_AGENT_ALLOWED_KEYS = new Set([
|
|
69
71
|
"managerAgentId",
|
|
70
72
|
"role",
|
|
@@ -127,6 +129,35 @@ export function createAgentsRouter(ctx: AppContext) {
|
|
|
127
129
|
return sendOk(res, { runtimeCwd });
|
|
128
130
|
});
|
|
129
131
|
|
|
132
|
+
router.get("/adapter-metadata", async (_req, res) => {
|
|
133
|
+
return sendOk(res, { adapters: getAdapterMetadata() });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
router.get("/adapter-models/:providerType", async (req, res) => {
|
|
137
|
+
const providerType = req.params.providerType;
|
|
138
|
+
if (!runtimePreflightSchema.shape.providerType.safeParse(providerType).success) {
|
|
139
|
+
return sendError(res, `Unsupported provider type: ${providerType}`, 422);
|
|
140
|
+
}
|
|
141
|
+
const defaultRuntimeCwd = await resolveDefaultRuntimeCwdForCompany(ctx.db, req.companyId!);
|
|
142
|
+
const runtimeConfig = normalizeRuntimeConfig({
|
|
143
|
+
runtimeConfig: req.body?.runtimeConfig,
|
|
144
|
+
defaultRuntimeCwd
|
|
145
|
+
});
|
|
146
|
+
const typedProviderType = providerType as "claude_code" | "codex" | "cursor" | "opencode" | "http" | "shell";
|
|
147
|
+
const models = await getAdapterModels(typedProviderType, {
|
|
148
|
+
command: runtimeConfig.runtimeCommand,
|
|
149
|
+
args: runtimeConfig.runtimeArgs,
|
|
150
|
+
cwd: runtimeConfig.runtimeCwd,
|
|
151
|
+
env: runtimeConfig.runtimeEnv,
|
|
152
|
+
model: runtimeConfig.runtimeModel,
|
|
153
|
+
thinkingEffort: runtimeConfig.runtimeThinkingEffort,
|
|
154
|
+
timeoutMs: runtimeConfig.runtimeTimeoutSec > 0 ? runtimeConfig.runtimeTimeoutSec * 1000 : undefined,
|
|
155
|
+
interruptGraceSec: runtimeConfig.interruptGraceSec,
|
|
156
|
+
runPolicy: runtimeConfig.runPolicy
|
|
157
|
+
});
|
|
158
|
+
return sendOk(res, { providerType: typedProviderType, models });
|
|
159
|
+
});
|
|
160
|
+
|
|
130
161
|
router.post("/runtime-preflight", async (req, res) => {
|
|
131
162
|
const parsed = runtimePreflightSchema.safeParse(req.body);
|
|
132
163
|
if (!parsed.success) {
|
|
@@ -151,42 +182,13 @@ export function createAgentsRouter(ctx: AppContext) {
|
|
|
151
182
|
defaultRuntimeCwd
|
|
152
183
|
});
|
|
153
184
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
level: "info" | "warn" | "error";
|
|
157
|
-
message: string;
|
|
158
|
-
detail?: string;
|
|
159
|
-
hint?: string;
|
|
160
|
-
}> = [];
|
|
161
|
-
|
|
162
|
-
if (parsed.data.providerType !== "codex") {
|
|
163
|
-
return sendOk(res, {
|
|
164
|
-
status: "pass",
|
|
165
|
-
testedAt: new Date().toISOString(),
|
|
166
|
-
checks: [
|
|
167
|
-
{
|
|
168
|
-
code: "preflight_not_required",
|
|
169
|
-
level: "info",
|
|
170
|
-
message: "Preflight probe is currently required only for Codex runtime."
|
|
171
|
-
}
|
|
172
|
-
]
|
|
173
|
-
});
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
if (!runtimeConfig.runtimeCwd) {
|
|
177
|
-
checks.push({
|
|
178
|
-
code: "codex_cwd_missing",
|
|
179
|
-
level: "error",
|
|
180
|
-
message: "Runtime working directory is required for Codex preflight."
|
|
181
|
-
});
|
|
182
|
-
return sendOk(res, { status: "fail", testedAt: new Date().toISOString(), checks });
|
|
185
|
+
if (runtimeConfig.runtimeCwd) {
|
|
186
|
+
await mkdir(runtimeConfig.runtimeCwd, { recursive: true });
|
|
183
187
|
}
|
|
184
188
|
|
|
185
|
-
await mkdir(runtimeConfig.runtimeCwd, { recursive: true });
|
|
186
|
-
|
|
187
189
|
const timeoutMs =
|
|
188
|
-
runtimeConfig.runtimeTimeoutSec > 0 ? Math.min(runtimeConfig.runtimeTimeoutSec * 1000, 45_000) :
|
|
189
|
-
const
|
|
190
|
+
runtimeConfig.runtimeTimeoutSec > 0 ? Math.min(runtimeConfig.runtimeTimeoutSec * 1000, 45_000) : undefined;
|
|
191
|
+
const result = await runAdapterEnvironmentTest(parsed.data.providerType, {
|
|
190
192
|
command: runtimeConfig.runtimeCommand,
|
|
191
193
|
args: runtimeConfig.runtimeArgs,
|
|
192
194
|
cwd: runtimeConfig.runtimeCwd,
|
|
@@ -195,65 +197,13 @@ export function createAgentsRouter(ctx: AppContext) {
|
|
|
195
197
|
thinkingEffort: runtimeConfig.runtimeThinkingEffort,
|
|
196
198
|
runPolicy: runtimeConfig.runPolicy,
|
|
197
199
|
timeoutMs,
|
|
200
|
+
interruptGraceSec: runtimeConfig.interruptGraceSec,
|
|
198
201
|
retryCount: 0
|
|
199
202
|
});
|
|
200
|
-
|
|
201
|
-
if (probe.ok) {
|
|
202
|
-
const summary = (probe.parsedUsage?.summary ?? "").trim();
|
|
203
|
-
const hasHello = /\bhello\b/i.test(summary || probe.stdout);
|
|
204
|
-
checks.push({
|
|
205
|
-
code: hasHello ? "codex_hello_probe_passed" : "codex_hello_probe_unexpected_output",
|
|
206
|
-
level: hasHello ? "info" : "warn",
|
|
207
|
-
message: hasHello ? "Codex preflight probe succeeded." : "Codex probe succeeded but response was unexpected.",
|
|
208
|
-
...(summary ? { detail: summary.slice(0, 240) } : {})
|
|
209
|
-
});
|
|
210
|
-
} else {
|
|
211
|
-
const detail = `${probe.stderr || ""}\n${probe.stdout || ""}`.trim().slice(0, 500);
|
|
212
|
-
if (probe.failureType === "spawn_error") {
|
|
213
|
-
checks.push({
|
|
214
|
-
code: "codex_command_unresolvable",
|
|
215
|
-
level: "error",
|
|
216
|
-
message: "Codex command is not executable from this runtime configuration.",
|
|
217
|
-
detail,
|
|
218
|
-
hint: "Install Codex CLI or set runtime command to an executable path."
|
|
219
|
-
});
|
|
220
|
-
} else if (probe.timedOut) {
|
|
221
|
-
checks.push({
|
|
222
|
-
code: "codex_hello_probe_timed_out",
|
|
223
|
-
level: "warn",
|
|
224
|
-
message: "Codex preflight timed out.",
|
|
225
|
-
detail,
|
|
226
|
-
hint: "Retry preflight. If this repeats, check runtime command/cwd and local Codex health."
|
|
227
|
-
});
|
|
228
|
-
} else if (CODEX_AUTH_REQUIRED_RE.test(detail)) {
|
|
229
|
-
checks.push({
|
|
230
|
-
code: "codex_auth_required",
|
|
231
|
-
level: "warn",
|
|
232
|
-
message: "Codex authentication is not ready for this runtime.",
|
|
233
|
-
detail,
|
|
234
|
-
hint: "Run `codex login` locally or set a global `BOPO_OPENAI_API_KEY`/`OPENAI_API_KEY`."
|
|
235
|
-
});
|
|
236
|
-
} else {
|
|
237
|
-
checks.push({
|
|
238
|
-
code: "codex_hello_probe_failed",
|
|
239
|
-
level: "error",
|
|
240
|
-
message: "Codex preflight failed.",
|
|
241
|
-
detail,
|
|
242
|
-
hint: "Run `codex exec --json -` manually in the runtime directory with prompt `Respond with hello.`."
|
|
243
|
-
});
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
const status =
|
|
248
|
-
checks.some((check) => check.level === "error")
|
|
249
|
-
? "fail"
|
|
250
|
-
: checks.some((check) => check.level === "warn")
|
|
251
|
-
? "warn"
|
|
252
|
-
: "pass";
|
|
253
203
|
return sendOk(res, {
|
|
254
|
-
status,
|
|
255
|
-
testedAt:
|
|
256
|
-
checks
|
|
204
|
+
status: result.status,
|
|
205
|
+
testedAt: result.testedAt,
|
|
206
|
+
checks: result.checks
|
|
257
207
|
});
|
|
258
208
|
});
|
|
259
209
|
|
package/src/routes/heartbeats.ts
CHANGED
|
@@ -6,11 +6,14 @@ import type { AppContext } from "../context";
|
|
|
6
6
|
import { sendError, sendOk } from "../http";
|
|
7
7
|
import { requireCompanyScope } from "../middleware/company-scope";
|
|
8
8
|
import { requirePermission } from "../middleware/request-actor";
|
|
9
|
-
import { runHeartbeatForAgent, runHeartbeatSweep } from "../services/heartbeat-service";
|
|
9
|
+
import { runHeartbeatForAgent, runHeartbeatSweep, stopHeartbeatRun } from "../services/heartbeat-service";
|
|
10
10
|
|
|
11
11
|
const runAgentSchema = z.object({
|
|
12
12
|
agentId: z.string().min(1)
|
|
13
13
|
});
|
|
14
|
+
const runIdParamsSchema = z.object({
|
|
15
|
+
runId: z.string().min(1)
|
|
16
|
+
});
|
|
14
17
|
|
|
15
18
|
export function createHeartbeatRouter(ctx: AppContext) {
|
|
16
19
|
const router = Router();
|
|
@@ -64,6 +67,146 @@ export function createHeartbeatRouter(ctx: AppContext) {
|
|
|
64
67
|
});
|
|
65
68
|
});
|
|
66
69
|
|
|
70
|
+
router.post("/:runId/stop", async (req, res) => {
|
|
71
|
+
requirePermission("heartbeats:run")(req, res, () => {});
|
|
72
|
+
if (res.headersSent) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const parsed = runIdParamsSchema.safeParse(req.params);
|
|
76
|
+
if (!parsed.success) {
|
|
77
|
+
return sendError(res, parsed.error.message, 422);
|
|
78
|
+
}
|
|
79
|
+
const stopResult = await stopHeartbeatRun(ctx.db, req.companyId!, parsed.data.runId, {
|
|
80
|
+
requestId: req.requestId,
|
|
81
|
+
trigger: "manual",
|
|
82
|
+
actorId: req.actor?.id ?? undefined
|
|
83
|
+
});
|
|
84
|
+
if (!stopResult.ok) {
|
|
85
|
+
if (stopResult.reason === "not_found") {
|
|
86
|
+
return sendError(res, "Heartbeat run not found.", 404);
|
|
87
|
+
}
|
|
88
|
+
return sendError(res, `Heartbeat run is not stoppable in status '${stopResult.status}'.`, 409);
|
|
89
|
+
}
|
|
90
|
+
return sendOk(res, {
|
|
91
|
+
runId: stopResult.runId,
|
|
92
|
+
requestId: req.requestId,
|
|
93
|
+
status: "stop_requested"
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
async function rerunFromHistory(input: {
|
|
98
|
+
mode: "resume" | "redo";
|
|
99
|
+
runId: string;
|
|
100
|
+
companyId: string;
|
|
101
|
+
requestId?: string;
|
|
102
|
+
}) {
|
|
103
|
+
const [run] = await ctx.db
|
|
104
|
+
.select({
|
|
105
|
+
id: heartbeatRuns.id,
|
|
106
|
+
status: heartbeatRuns.status,
|
|
107
|
+
agentId: heartbeatRuns.agentId
|
|
108
|
+
})
|
|
109
|
+
.from(heartbeatRuns)
|
|
110
|
+
.where(and(eq(heartbeatRuns.companyId, input.companyId), eq(heartbeatRuns.id, input.runId)))
|
|
111
|
+
.limit(1);
|
|
112
|
+
if (!run) {
|
|
113
|
+
return { ok: false as const, statusCode: 404, message: "Heartbeat run not found." };
|
|
114
|
+
}
|
|
115
|
+
if (run.status === "started") {
|
|
116
|
+
return { ok: false as const, statusCode: 409, message: "Run is still in progress and cannot be replayed yet." };
|
|
117
|
+
}
|
|
118
|
+
const [agent] = await ctx.db
|
|
119
|
+
.select({ id: agents.id, status: agents.status })
|
|
120
|
+
.from(agents)
|
|
121
|
+
.where(and(eq(agents.companyId, input.companyId), eq(agents.id, run.agentId)))
|
|
122
|
+
.limit(1);
|
|
123
|
+
if (!agent) {
|
|
124
|
+
return { ok: false as const, statusCode: 404, message: "Agent not found." };
|
|
125
|
+
}
|
|
126
|
+
if (agent.status === "paused" || agent.status === "terminated") {
|
|
127
|
+
return { ok: false as const, statusCode: 409, message: `Agent is not invokable in status '${agent.status}'.` };
|
|
128
|
+
}
|
|
129
|
+
const nextRunId = await runHeartbeatForAgent(ctx.db, input.companyId, run.agentId, {
|
|
130
|
+
requestId: input.requestId,
|
|
131
|
+
trigger: "manual",
|
|
132
|
+
realtimeHub: ctx.realtimeHub,
|
|
133
|
+
mode: input.mode,
|
|
134
|
+
sourceRunId: run.id
|
|
135
|
+
});
|
|
136
|
+
if (!nextRunId) {
|
|
137
|
+
return { ok: false as const, statusCode: 409, message: "Heartbeat could not be started for this agent." };
|
|
138
|
+
}
|
|
139
|
+
const [runRow] = await ctx.db
|
|
140
|
+
.select({ id: heartbeatRuns.id, status: heartbeatRuns.status, message: heartbeatRuns.message })
|
|
141
|
+
.from(heartbeatRuns)
|
|
142
|
+
.where(and(eq(heartbeatRuns.companyId, input.companyId), eq(heartbeatRuns.id, nextRunId)))
|
|
143
|
+
.limit(1);
|
|
144
|
+
const invokeStatus =
|
|
145
|
+
runRow?.status === "skipped" && String(runRow.message ?? "").includes("already in progress")
|
|
146
|
+
? "skipped_overlap"
|
|
147
|
+
: runRow?.status === "skipped"
|
|
148
|
+
? "skipped"
|
|
149
|
+
: "started";
|
|
150
|
+
return {
|
|
151
|
+
ok: true as const,
|
|
152
|
+
runId: nextRunId,
|
|
153
|
+
status: invokeStatus,
|
|
154
|
+
message: runRow?.message ?? null
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
router.post("/:runId/resume", async (req, res) => {
|
|
159
|
+
requirePermission("heartbeats:run")(req, res, () => {});
|
|
160
|
+
if (res.headersSent) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const parsed = runIdParamsSchema.safeParse(req.params);
|
|
164
|
+
if (!parsed.success) {
|
|
165
|
+
return sendError(res, parsed.error.message, 422);
|
|
166
|
+
}
|
|
167
|
+
const result = await rerunFromHistory({
|
|
168
|
+
mode: "resume",
|
|
169
|
+
runId: parsed.data.runId,
|
|
170
|
+
companyId: req.companyId!,
|
|
171
|
+
requestId: req.requestId
|
|
172
|
+
});
|
|
173
|
+
if (!result.ok) {
|
|
174
|
+
return sendError(res, result.message, result.statusCode);
|
|
175
|
+
}
|
|
176
|
+
return sendOk(res, {
|
|
177
|
+
runId: result.runId,
|
|
178
|
+
requestId: req.requestId,
|
|
179
|
+
status: result.status,
|
|
180
|
+
message: result.message
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
router.post("/:runId/redo", async (req, res) => {
|
|
185
|
+
requirePermission("heartbeats:run")(req, res, () => {});
|
|
186
|
+
if (res.headersSent) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const parsed = runIdParamsSchema.safeParse(req.params);
|
|
190
|
+
if (!parsed.success) {
|
|
191
|
+
return sendError(res, parsed.error.message, 422);
|
|
192
|
+
}
|
|
193
|
+
const result = await rerunFromHistory({
|
|
194
|
+
mode: "redo",
|
|
195
|
+
runId: parsed.data.runId,
|
|
196
|
+
companyId: req.companyId!,
|
|
197
|
+
requestId: req.requestId
|
|
198
|
+
});
|
|
199
|
+
if (!result.ok) {
|
|
200
|
+
return sendError(res, result.message, result.statusCode);
|
|
201
|
+
}
|
|
202
|
+
return sendOk(res, {
|
|
203
|
+
runId: result.runId,
|
|
204
|
+
requestId: req.requestId,
|
|
205
|
+
status: result.status,
|
|
206
|
+
message: result.message
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
67
210
|
router.post("/sweep", async (req, res) => {
|
|
68
211
|
requirePermission("heartbeats:sweep")(req, res, () => {});
|
|
69
212
|
if (res.headersSent) {
|
|
@@ -31,7 +31,30 @@ export function createObservabilityRouter(ctx: AppContext) {
|
|
|
31
31
|
});
|
|
32
32
|
|
|
33
33
|
router.get("/heartbeats", async (req, res) => {
|
|
34
|
-
|
|
34
|
+
const companyId = req.companyId!;
|
|
35
|
+
const [runs, auditRows] = await Promise.all([
|
|
36
|
+
listHeartbeatRuns(ctx.db, companyId),
|
|
37
|
+
listAuditEvents(ctx.db, companyId)
|
|
38
|
+
]);
|
|
39
|
+
const outcomeByRunId = new Map<string, unknown>();
|
|
40
|
+
for (const row of auditRows) {
|
|
41
|
+
if (
|
|
42
|
+
row.entityType === "heartbeat_run" &&
|
|
43
|
+
(row.eventType === "heartbeat.completed" || row.eventType === "heartbeat.failed")
|
|
44
|
+
) {
|
|
45
|
+
const payload = parsePayload(row.payloadJson);
|
|
46
|
+
if (payload && typeof payload === "object" && "outcome" in payload) {
|
|
47
|
+
outcomeByRunId.set(row.entityId, (payload as Record<string, unknown>).outcome ?? null);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return sendOk(
|
|
52
|
+
res,
|
|
53
|
+
runs.map((run) => ({
|
|
54
|
+
...run,
|
|
55
|
+
outcome: outcomeByRunId.get(run.id) ?? null
|
|
56
|
+
}))
|
|
57
|
+
);
|
|
35
58
|
});
|
|
36
59
|
|
|
37
60
|
return router;
|
|
@@ -29,11 +29,11 @@ export interface OnboardSeedSummary {
|
|
|
29
29
|
const DEFAULT_COMPANY_NAME_ENV = "BOPO_DEFAULT_COMPANY_NAME";
|
|
30
30
|
const DEFAULT_COMPANY_ID_ENV = "BOPO_DEFAULT_COMPANY_ID";
|
|
31
31
|
const DEFAULT_AGENT_PROVIDER_ENV = "BOPO_DEFAULT_AGENT_PROVIDER";
|
|
32
|
-
type AgentProvider = "codex" | "claude_code" | "shell";
|
|
32
|
+
type AgentProvider = "codex" | "claude_code" | "cursor" | "opencode" | "shell";
|
|
33
33
|
const CEO_BOOTSTRAP_SUMMARY = "ceo bootstrap heartbeat";
|
|
34
34
|
const STARTUP_PROJECT_NAME = "Leadership Setup";
|
|
35
35
|
const CEO_STARTUP_TASK_TITLE = "Set up CEO operating files and hire founding engineer";
|
|
36
|
-
const CEO_STARTUP_TASK_MARKER = "[
|
|
36
|
+
const CEO_STARTUP_TASK_MARKER = "[bopodev:onboarding:ceo-startup:v1]";
|
|
37
37
|
|
|
38
38
|
export async function ensureOnboardingSeed(input: {
|
|
39
39
|
dbPath?: string;
|
|
@@ -188,8 +188,8 @@ async function ensureCeoStartupTask(
|
|
|
188
188
|
" - If using `runtimeConfig`, only `runtimeConfig.bootstrapPrompt` is supported there.",
|
|
189
189
|
" - Use a temp JSON file or heredoc with `curl --data @file`; do not hand-escape multiline JSON.",
|
|
190
190
|
"4. To inspect your own agent record, use `GET /agents` and filter by your agent id. Do not call `GET /agents/:agentId`.",
|
|
191
|
-
" - `GET /agents`
|
|
192
|
-
" -
|
|
191
|
+
" - `GET /agents` uses envelope shape `{ \"ok\": true, \"data\": [...] }`; treat any other shape as failure.",
|
|
192
|
+
" - Deterministic filter: `jq -er --arg id \"$BOPODEV_AGENT_ID\" '.data | if type==\"array\" then . else error(\"invalid_agents_payload\") end | map(select((.id? // \"\") == $id))[0] | {id,name,role,bootstrapPrompt}'`",
|
|
193
193
|
"5. Heartbeat-assigned issues are already claimed for the current run. Do not call a checkout endpoint; update status with `PUT /issues/:issueId` only.",
|
|
194
194
|
"6. After your operating files are active, submit a hire request for a Founding Engineer via `POST /agents` using supported fields:",
|
|
195
195
|
" - `name`, `role`, `providerType`, `heartbeatCron`, `monthlyBudgetUsd`",
|
|
@@ -200,6 +200,7 @@ async function ensureCeoStartupTask(
|
|
|
200
200
|
"Safety checks before requesting hire:",
|
|
201
201
|
"- Do not request duplicates if a Founding Engineer already exists.",
|
|
202
202
|
"- Do not request duplicates if a pending approval for the same role is already open.",
|
|
203
|
+
"- For control-plane calls, prefer direct header env vars (`BOPODEV_COMPANY_ID`, `BOPODEV_ACTOR_TYPE`, `BOPODEV_ACTOR_ID`, `BOPODEV_ACTOR_COMPANIES`, `BOPODEV_ACTOR_PERMISSIONS`) instead of parsing `BOPODEV_REQUEST_HEADERS_JSON`.",
|
|
203
204
|
"- Do not assume `python` is installed in the runtime shell; prefer direct headers, `node`, or `jq` when scripting.",
|
|
204
205
|
"- Shell commands run under `zsh`; avoid Bash-only features such as `local -n`, `declare -n`, `mapfile`, and `readarray`."
|
|
205
206
|
].join("\n");
|
|
@@ -247,23 +248,32 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
|
|
|
247
248
|
}
|
|
248
249
|
|
|
249
250
|
function parseAgentProvider(value: unknown): AgentProvider | null {
|
|
250
|
-
if (value === "codex" || value === "claude_code" || value === "shell") {
|
|
251
|
+
if (value === "codex" || value === "claude_code" || value === "cursor" || value === "opencode" || value === "shell") {
|
|
251
252
|
return value;
|
|
252
253
|
}
|
|
253
254
|
return null;
|
|
254
255
|
}
|
|
255
256
|
|
|
256
257
|
function resolveSeedRuntimeEnv(agentProvider: AgentProvider): Record<string, string> {
|
|
257
|
-
if (agentProvider
|
|
258
|
-
|
|
258
|
+
if (agentProvider === "codex") {
|
|
259
|
+
const key = (process.env.BOPO_OPENAI_API_KEY ?? process.env.OPENAI_API_KEY)?.trim();
|
|
260
|
+
if (!key) {
|
|
261
|
+
return {};
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
OPENAI_API_KEY: key
|
|
265
|
+
};
|
|
259
266
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
267
|
+
if (agentProvider === "claude_code") {
|
|
268
|
+
const key = (process.env.BOPO_ANTHROPIC_API_KEY ?? process.env.ANTHROPIC_API_KEY)?.trim();
|
|
269
|
+
if (!key) {
|
|
270
|
+
return {};
|
|
271
|
+
}
|
|
272
|
+
return {
|
|
273
|
+
ANTHROPIC_API_KEY: key
|
|
274
|
+
};
|
|
263
275
|
}
|
|
264
|
-
return {
|
|
265
|
-
OPENAI_API_KEY: key
|
|
266
|
-
};
|
|
276
|
+
return {};
|
|
267
277
|
}
|
|
268
278
|
|
|
269
279
|
function isBootstrapCeoRuntime(providerType: string, stateBlob: string | null) {
|
package/src/server.ts
CHANGED
|
@@ -61,7 +61,7 @@ async function main() {
|
|
|
61
61
|
server.on("request", app);
|
|
62
62
|
server.listen(port, () => {
|
|
63
63
|
// eslint-disable-next-line no-console
|
|
64
|
-
console.log(`
|
|
64
|
+
console.log(`BopoDev API running on http://localhost:${port}`);
|
|
65
65
|
});
|
|
66
66
|
|
|
67
67
|
const defaultCompanyId = process.env.BOPO_DEFAULT_COMPANY_ID;
|
|
@@ -57,7 +57,7 @@ const activateGoalPayloadSchema = z.object({
|
|
|
57
57
|
description: z.string().optional()
|
|
58
58
|
});
|
|
59
59
|
const AGENT_STARTUP_PROJECT_NAME = "Agent Onboarding";
|
|
60
|
-
const AGENT_STARTUP_TASK_MARKER = "[
|
|
60
|
+
const AGENT_STARTUP_TASK_MARKER = "[bopodev:onboarding:agent-startup:v1]";
|
|
61
61
|
|
|
62
62
|
export class GovernanceError extends Error {
|
|
63
63
|
constructor(message: string) {
|