bopodev-api 0.1.8 → 0.1.9
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/package.json +7 -4
- package/src/lib/agent-config.ts +255 -0
- package/src/lib/instance-paths.ts +88 -0
- package/src/lib/workspace-policy.ts +75 -0
- package/src/middleware/request-actor.ts +26 -5
- package/src/realtime/office-space.ts +7 -0
- package/src/routes/agents.ts +335 -66
- package/src/routes/heartbeats.ts +21 -2
- package/src/routes/issues.ts +122 -4
- package/src/routes/projects.ts +60 -3
- package/src/scripts/backfill-project-workspaces.ts +118 -0
- package/src/scripts/onboard-seed.ts +314 -0
- package/src/server.ts +43 -13
- package/src/services/governance-service.ts +144 -18
- package/src/services/heartbeat-service.ts +616 -3
- package/src/worker/scheduler.ts +6 -63
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
1
2
|
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
|
2
3
|
import { nanoid } from "nanoid";
|
|
3
4
|
import { resolveAdapter } from "bopodev-agent-sdk";
|
|
@@ -5,6 +6,8 @@ import type { AgentState, HeartbeatContext } from "bopodev-agent-sdk";
|
|
|
5
6
|
import type { BopoDb } from "bopodev-db";
|
|
6
7
|
import { agents, appendActivity, companies, goals, heartbeatRuns, issues, projects } from "bopodev-db";
|
|
7
8
|
import { appendAuditEvent, appendCost } from "bopodev-db";
|
|
9
|
+
import { parseRuntimeConfigFromAgentRow } from "../lib/agent-config";
|
|
10
|
+
import { getProjectWorkspaceMap, hasText, resolveAgentFallbackWorkspace } from "../lib/workspace-policy";
|
|
8
11
|
import type { RealtimeHub } from "../realtime/hub";
|
|
9
12
|
import { publishOfficeOccupantForAgent } from "../realtime/office-space";
|
|
10
13
|
import { checkAgentBudget } from "./budget-service";
|
|
@@ -187,8 +190,17 @@ export async function runHeartbeatForAgent(
|
|
|
187
190
|
args?: string[];
|
|
188
191
|
cwd?: string;
|
|
189
192
|
timeoutMs?: number;
|
|
193
|
+
interruptGraceSec?: number;
|
|
190
194
|
retryCount?: number;
|
|
191
195
|
retryBackoffMs?: number;
|
|
196
|
+
env?: Record<string, string>;
|
|
197
|
+
model?: string;
|
|
198
|
+
thinkingEffort?: "auto" | "low" | "medium" | "high";
|
|
199
|
+
bootstrapPrompt?: string;
|
|
200
|
+
runPolicy?: {
|
|
201
|
+
sandboxMode?: "workspace_write" | "full_access";
|
|
202
|
+
allowWebSearch?: boolean;
|
|
203
|
+
};
|
|
192
204
|
};
|
|
193
205
|
} = {};
|
|
194
206
|
let executionSummary = "";
|
|
@@ -203,6 +215,40 @@ export async function runHeartbeatForAgent(
|
|
|
203
215
|
const parsedState = parseAgentState(agent.stateBlob);
|
|
204
216
|
state = parsedState.state;
|
|
205
217
|
stateParseError = parsedState.parseError;
|
|
218
|
+
const persistedRuntime = parseRuntimeConfigFromAgentRow(agent as unknown as Record<string, unknown>);
|
|
219
|
+
const heartbeatRuntimeEnv = buildHeartbeatRuntimeEnv({
|
|
220
|
+
companyId,
|
|
221
|
+
agentId: agent.id,
|
|
222
|
+
heartbeatRunId: runId,
|
|
223
|
+
canHireAgents: agent.canHireAgents
|
|
224
|
+
});
|
|
225
|
+
const runtimeFromConfig = {
|
|
226
|
+
command: persistedRuntime.runtimeCommand,
|
|
227
|
+
args: persistedRuntime.runtimeArgs,
|
|
228
|
+
cwd: persistedRuntime.runtimeCwd,
|
|
229
|
+
timeoutMs: persistedRuntime.runtimeTimeoutSec > 0 ? persistedRuntime.runtimeTimeoutSec * 1000 : undefined,
|
|
230
|
+
env: {
|
|
231
|
+
...persistedRuntime.runtimeEnv,
|
|
232
|
+
...heartbeatRuntimeEnv
|
|
233
|
+
},
|
|
234
|
+
model: persistedRuntime.runtimeModel,
|
|
235
|
+
thinkingEffort: persistedRuntime.runtimeThinkingEffort,
|
|
236
|
+
bootstrapPrompt: persistedRuntime.bootstrapPrompt,
|
|
237
|
+
interruptGraceSec: persistedRuntime.interruptGraceSec,
|
|
238
|
+
runPolicy: persistedRuntime.runPolicy
|
|
239
|
+
};
|
|
240
|
+
const mergedRuntime = mergeRuntimeForExecution(runtimeFromConfig, state.runtime);
|
|
241
|
+
const workspaceResolution = await resolveRuntimeWorkspaceForWorkItems(
|
|
242
|
+
db,
|
|
243
|
+
companyId,
|
|
244
|
+
agent.id,
|
|
245
|
+
workItems,
|
|
246
|
+
mergedRuntime
|
|
247
|
+
);
|
|
248
|
+
state = {
|
|
249
|
+
...state,
|
|
250
|
+
runtime: workspaceResolution.runtime
|
|
251
|
+
};
|
|
206
252
|
|
|
207
253
|
const context = await buildHeartbeatContext(db, companyId, {
|
|
208
254
|
agentId,
|
|
@@ -212,9 +258,109 @@ export async function runHeartbeatForAgent(
|
|
|
212
258
|
providerType: agent.providerType as "claude_code" | "codex" | "http" | "shell",
|
|
213
259
|
heartbeatRunId: runId,
|
|
214
260
|
state,
|
|
215
|
-
runtime:
|
|
261
|
+
runtime: workspaceResolution.runtime,
|
|
216
262
|
workItems
|
|
217
263
|
});
|
|
264
|
+
if (workspaceResolution.warnings.length > 0) {
|
|
265
|
+
await appendAuditEvent(db, {
|
|
266
|
+
companyId,
|
|
267
|
+
actorType: "system",
|
|
268
|
+
eventType: "heartbeat.workspace_resolution_warning",
|
|
269
|
+
entityType: "heartbeat_run",
|
|
270
|
+
entityId: runId,
|
|
271
|
+
correlationId: options?.requestId ?? runId,
|
|
272
|
+
payload: {
|
|
273
|
+
agentId,
|
|
274
|
+
source: workspaceResolution.source,
|
|
275
|
+
runtimeCwd: workspaceResolution.runtime.cwd ?? null,
|
|
276
|
+
warnings: workspaceResolution.warnings
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
for (const issueId of issueIds) {
|
|
280
|
+
await appendActivity(db, {
|
|
281
|
+
companyId,
|
|
282
|
+
issueId,
|
|
283
|
+
actorType: "system",
|
|
284
|
+
eventType: "issue.workspace_fallback",
|
|
285
|
+
payload: {
|
|
286
|
+
heartbeatRunId: runId,
|
|
287
|
+
agentId,
|
|
288
|
+
source: workspaceResolution.source,
|
|
289
|
+
warnings: workspaceResolution.warnings
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const controlPlaneEnvValidation = validateControlPlaneRuntimeEnv(
|
|
296
|
+
workspaceResolution.runtime.env ?? {},
|
|
297
|
+
runId
|
|
298
|
+
);
|
|
299
|
+
if (!controlPlaneEnvValidation.ok) {
|
|
300
|
+
await appendAuditEvent(db, {
|
|
301
|
+
companyId,
|
|
302
|
+
actorType: "system",
|
|
303
|
+
eventType: "heartbeat.control_plane_env_invalid",
|
|
304
|
+
entityType: "heartbeat_run",
|
|
305
|
+
entityId: runId,
|
|
306
|
+
correlationId: options?.requestId ?? runId,
|
|
307
|
+
payload: {
|
|
308
|
+
agentId,
|
|
309
|
+
providerType: agent.providerType,
|
|
310
|
+
missingKeys: controlPlaneEnvValidation.missingKeys
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
throw new Error(
|
|
314
|
+
`Control-plane runtime env is incomplete. Missing keys: ${controlPlaneEnvValidation.missingKeys.join(", ")}`
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (
|
|
319
|
+
resolveControlPlanePreflightEnabled() &&
|
|
320
|
+
shouldRequireControlPlanePreflight(agent.providerType as "claude_code" | "codex" | "http" | "shell", workItems.length)
|
|
321
|
+
) {
|
|
322
|
+
const preflight = await runControlPlaneConnectivityPreflight({
|
|
323
|
+
apiBaseUrl: workspaceResolution.runtime.env?.BOPOHQ_API_BASE_URL ?? "",
|
|
324
|
+
requestHeadersJson: workspaceResolution.runtime.env?.BOPOHQ_REQUEST_HEADERS_JSON ?? "",
|
|
325
|
+
timeoutMs: resolveControlPlanePreflightTimeoutMs()
|
|
326
|
+
});
|
|
327
|
+
await appendAuditEvent(db, {
|
|
328
|
+
companyId,
|
|
329
|
+
actorType: "system",
|
|
330
|
+
eventType: preflight.ok ? "heartbeat.control_plane_preflight_passed" : "heartbeat.control_plane_preflight_failed",
|
|
331
|
+
entityType: "heartbeat_run",
|
|
332
|
+
entityId: runId,
|
|
333
|
+
correlationId: options?.requestId ?? runId,
|
|
334
|
+
payload: {
|
|
335
|
+
agentId,
|
|
336
|
+
providerType: agent.providerType,
|
|
337
|
+
...preflight
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
if (!preflight.ok) {
|
|
341
|
+
throw new Error(`Control-plane connectivity preflight failed: ${preflight.message}`);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
await appendAuditEvent(db, {
|
|
346
|
+
companyId,
|
|
347
|
+
actorType: "system",
|
|
348
|
+
eventType: "heartbeat.runtime_launch",
|
|
349
|
+
entityType: "heartbeat_run",
|
|
350
|
+
entityId: runId,
|
|
351
|
+
correlationId: options?.requestId ?? runId,
|
|
352
|
+
payload: {
|
|
353
|
+
agentId,
|
|
354
|
+
runtime: summarizeRuntimeLaunch(
|
|
355
|
+
agent.providerType as "claude_code" | "codex" | "http" | "shell",
|
|
356
|
+
workspaceResolution.runtime
|
|
357
|
+
),
|
|
358
|
+
diagnostics: {
|
|
359
|
+
requestId: options?.requestId ?? null,
|
|
360
|
+
trigger: options?.trigger ?? "manual"
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
});
|
|
218
364
|
|
|
219
365
|
const execution = await adapter.execute(context);
|
|
220
366
|
executionSummary = execution.summary;
|
|
@@ -251,7 +397,15 @@ export async function runHeartbeatForAgent(
|
|
|
251
397
|
.where(and(eq(agents.companyId, companyId), eq(agents.id, agentId)));
|
|
252
398
|
}
|
|
253
399
|
|
|
254
|
-
|
|
400
|
+
const shouldAdvanceIssuesToReview = shouldPromoteIssuesToReview({
|
|
401
|
+
summary: execution.summary,
|
|
402
|
+
tokenInput: execution.tokenInput,
|
|
403
|
+
tokenOutput: execution.tokenOutput,
|
|
404
|
+
usdCost: execution.usdCost,
|
|
405
|
+
trace: executionTrace
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
if (issueIds.length > 0 && execution.status === "ok" && shouldAdvanceIssuesToReview) {
|
|
255
409
|
await db
|
|
256
410
|
.update(issues)
|
|
257
411
|
.set({ status: "in_review", updatedAt: new Date() })
|
|
@@ -266,6 +420,35 @@ export async function runHeartbeatForAgent(
|
|
|
266
420
|
payload: { heartbeatRunId: runId, agentId }
|
|
267
421
|
});
|
|
268
422
|
}
|
|
423
|
+
} else if (issueIds.length > 0 && execution.status === "ok") {
|
|
424
|
+
for (const issueId of issueIds) {
|
|
425
|
+
await appendActivity(db, {
|
|
426
|
+
companyId,
|
|
427
|
+
issueId,
|
|
428
|
+
actorType: "system",
|
|
429
|
+
eventType: "issue.review_gate_blocked",
|
|
430
|
+
payload: { heartbeatRunId: runId, agentId, reason: "insufficient_real_execution_evidence" }
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
await appendAuditEvent(db, {
|
|
434
|
+
companyId,
|
|
435
|
+
actorType: "system",
|
|
436
|
+
eventType: "heartbeat.review_gate_blocked",
|
|
437
|
+
entityType: "heartbeat_run",
|
|
438
|
+
entityId: runId,
|
|
439
|
+
correlationId: options?.requestId ?? runId,
|
|
440
|
+
payload: {
|
|
441
|
+
agentId,
|
|
442
|
+
issueIds,
|
|
443
|
+
reason: "insufficient_real_execution_evidence",
|
|
444
|
+
summary: execution.summary,
|
|
445
|
+
usage: {
|
|
446
|
+
tokenInput: execution.tokenInput,
|
|
447
|
+
tokenOutput: execution.tokenOutput,
|
|
448
|
+
usdCost: execution.usdCost
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
});
|
|
269
452
|
}
|
|
270
453
|
|
|
271
454
|
await db
|
|
@@ -291,7 +474,8 @@ export async function runHeartbeatForAgent(
|
|
|
291
474
|
usage: {
|
|
292
475
|
tokenInput: execution.tokenInput,
|
|
293
476
|
tokenOutput: execution.tokenOutput,
|
|
294
|
-
usdCost: execution.usdCost
|
|
477
|
+
usdCost: execution.usdCost,
|
|
478
|
+
source: readTraceString(execution.trace, "usageSource") ?? "unknown"
|
|
295
479
|
},
|
|
296
480
|
trace: execution.trace ?? null,
|
|
297
481
|
diagnostics: {
|
|
@@ -324,6 +508,9 @@ export async function runHeartbeatForAgent(
|
|
|
324
508
|
issueIds,
|
|
325
509
|
errorType: classified.type,
|
|
326
510
|
errorMessage: classified.message,
|
|
511
|
+
usage: {
|
|
512
|
+
source: readTraceString(executionTrace, "usageSource") ?? "unknown"
|
|
513
|
+
},
|
|
327
514
|
trace: executionTrace,
|
|
328
515
|
diagnostics: {
|
|
329
516
|
stateParseError,
|
|
@@ -613,8 +800,17 @@ function parseAgentState(stateBlob: string | null) {
|
|
|
613
800
|
args?: string[];
|
|
614
801
|
cwd?: string;
|
|
615
802
|
timeoutMs?: number;
|
|
803
|
+
interruptGraceSec?: number;
|
|
616
804
|
retryCount?: number;
|
|
617
805
|
retryBackoffMs?: number;
|
|
806
|
+
env?: Record<string, string>;
|
|
807
|
+
model?: string;
|
|
808
|
+
thinkingEffort?: "auto" | "low" | "medium" | "high";
|
|
809
|
+
bootstrapPrompt?: string;
|
|
810
|
+
runPolicy?: {
|
|
811
|
+
sandboxMode?: "workspace_write" | "full_access";
|
|
812
|
+
allowWebSearch?: boolean;
|
|
813
|
+
};
|
|
618
814
|
};
|
|
619
815
|
},
|
|
620
816
|
parseError: null
|
|
@@ -638,6 +834,137 @@ function classifyHeartbeatError(error: unknown) {
|
|
|
638
834
|
return { type: "unknown", message };
|
|
639
835
|
}
|
|
640
836
|
|
|
837
|
+
function shouldPromoteIssuesToReview(input: {
|
|
838
|
+
summary: string;
|
|
839
|
+
tokenInput: number;
|
|
840
|
+
tokenOutput: number;
|
|
841
|
+
usdCost: number;
|
|
842
|
+
trace: unknown;
|
|
843
|
+
}) {
|
|
844
|
+
return !isBootstrapDemoSummary(input.summary) && hasRealExecutionEvidence(input);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function isBootstrapDemoSummary(summary: string) {
|
|
848
|
+
const normalized = summary.trim().toLowerCase();
|
|
849
|
+
return normalized === "ceo bootstrap heartbeat" || normalized.startsWith("ceo bootstrap heartbeat ");
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function hasRealExecutionEvidence(input: {
|
|
853
|
+
tokenInput: number;
|
|
854
|
+
tokenOutput: number;
|
|
855
|
+
usdCost: number;
|
|
856
|
+
trace: unknown;
|
|
857
|
+
}) {
|
|
858
|
+
if (input.tokenInput > 0 || input.tokenOutput > 0 || input.usdCost > 0) {
|
|
859
|
+
return true;
|
|
860
|
+
}
|
|
861
|
+
const stdoutPreview = readTraceString(input.trace, "stdoutPreview");
|
|
862
|
+
if (!stdoutPreview) {
|
|
863
|
+
return false;
|
|
864
|
+
}
|
|
865
|
+
return !looksLikeEchoedPrompt(stdoutPreview);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function looksLikeEchoedPrompt(stdoutPreview: string) {
|
|
869
|
+
const normalized = stdoutPreview.toLowerCase();
|
|
870
|
+
return (
|
|
871
|
+
normalized.includes("execution directives:") &&
|
|
872
|
+
normalized.includes("at the end of your response, include exactly one json object on a single line:")
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function readTraceString(trace: unknown, key: string) {
|
|
877
|
+
if (!trace || typeof trace !== "object") {
|
|
878
|
+
return null;
|
|
879
|
+
}
|
|
880
|
+
const value = (trace as Record<string, unknown>)[key];
|
|
881
|
+
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
async function resolveRuntimeWorkspaceForWorkItems(
|
|
885
|
+
db: BopoDb,
|
|
886
|
+
companyId: string,
|
|
887
|
+
agentId: string,
|
|
888
|
+
workItems: Array<{ project_id: string }>,
|
|
889
|
+
runtime:
|
|
890
|
+
| {
|
|
891
|
+
command?: string;
|
|
892
|
+
args?: string[];
|
|
893
|
+
cwd?: string;
|
|
894
|
+
timeoutMs?: number;
|
|
895
|
+
interruptGraceSec?: number;
|
|
896
|
+
retryCount?: number;
|
|
897
|
+
retryBackoffMs?: number;
|
|
898
|
+
env?: Record<string, string>;
|
|
899
|
+
model?: string;
|
|
900
|
+
thinkingEffort?: "auto" | "low" | "medium" | "high";
|
|
901
|
+
bootstrapPrompt?: string;
|
|
902
|
+
runPolicy?: {
|
|
903
|
+
sandboxMode?: "workspace_write" | "full_access";
|
|
904
|
+
allowWebSearch?: boolean;
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
| undefined
|
|
908
|
+
) {
|
|
909
|
+
const normalizedRuntimeCwd = runtime?.cwd?.trim();
|
|
910
|
+
const warnings: string[] = [];
|
|
911
|
+
const projectIds = Array.from(new Set(workItems.map((item) => item.project_id)));
|
|
912
|
+
const projectWorkspaceMap = await getProjectWorkspaceMap(db, companyId, projectIds);
|
|
913
|
+
|
|
914
|
+
let selectedProjectWorkspace: string | null = null;
|
|
915
|
+
for (const projectId of projectIds) {
|
|
916
|
+
const projectWorkspace = projectWorkspaceMap.get(projectId) ?? null;
|
|
917
|
+
if (hasText(projectWorkspace)) {
|
|
918
|
+
selectedProjectWorkspace = projectWorkspace;
|
|
919
|
+
break;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
if (selectedProjectWorkspace) {
|
|
924
|
+
await mkdir(selectedProjectWorkspace, { recursive: true });
|
|
925
|
+
if (hasText(normalizedRuntimeCwd) && normalizedRuntimeCwd !== selectedProjectWorkspace) {
|
|
926
|
+
warnings.push(
|
|
927
|
+
`Runtime cwd '${normalizedRuntimeCwd}' was overridden to project workspace '${selectedProjectWorkspace}' for assigned work.`
|
|
928
|
+
);
|
|
929
|
+
}
|
|
930
|
+
return {
|
|
931
|
+
source: "project_workspace",
|
|
932
|
+
warnings,
|
|
933
|
+
runtime: {
|
|
934
|
+
...runtime,
|
|
935
|
+
cwd: selectedProjectWorkspace
|
|
936
|
+
}
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
if (projectIds.length > 0) {
|
|
941
|
+
warnings.push("Assigned project has no local workspace path configured. Falling back to agent workspace.");
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
if (hasText(normalizedRuntimeCwd)) {
|
|
945
|
+
return {
|
|
946
|
+
source: "agent_runtime",
|
|
947
|
+
warnings,
|
|
948
|
+
runtime: {
|
|
949
|
+
...runtime,
|
|
950
|
+
cwd: normalizedRuntimeCwd
|
|
951
|
+
}
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
const fallbackWorkspace = resolveAgentFallbackWorkspace(companyId, agentId);
|
|
956
|
+
await mkdir(fallbackWorkspace, { recursive: true });
|
|
957
|
+
warnings.push(`Runtime cwd was not configured. Falling back to '${fallbackWorkspace}'.`);
|
|
958
|
+
return {
|
|
959
|
+
source: "agent_fallback",
|
|
960
|
+
warnings,
|
|
961
|
+
runtime: {
|
|
962
|
+
...runtime,
|
|
963
|
+
cwd: fallbackWorkspace
|
|
964
|
+
}
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
|
|
641
968
|
function resolveStaleRunThresholdMs() {
|
|
642
969
|
const parsed = Number(process.env.BOPO_HEARTBEAT_STALE_RUN_MS ?? 10 * 60 * 1000);
|
|
643
970
|
if (!Number.isFinite(parsed) || parsed < 1_000) {
|
|
@@ -646,6 +973,292 @@ function resolveStaleRunThresholdMs() {
|
|
|
646
973
|
return parsed;
|
|
647
974
|
}
|
|
648
975
|
|
|
976
|
+
function mergeRuntimeForExecution(
|
|
977
|
+
runtimeFromConfig:
|
|
978
|
+
| {
|
|
979
|
+
command?: string;
|
|
980
|
+
args?: string[];
|
|
981
|
+
cwd?: string;
|
|
982
|
+
timeoutMs?: number;
|
|
983
|
+
interruptGraceSec?: number;
|
|
984
|
+
retryCount?: number;
|
|
985
|
+
retryBackoffMs?: number;
|
|
986
|
+
env?: Record<string, string>;
|
|
987
|
+
model?: string;
|
|
988
|
+
thinkingEffort?: "auto" | "low" | "medium" | "high";
|
|
989
|
+
bootstrapPrompt?: string;
|
|
990
|
+
runPolicy?: {
|
|
991
|
+
sandboxMode?: "workspace_write" | "full_access";
|
|
992
|
+
allowWebSearch?: boolean;
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
| undefined,
|
|
996
|
+
runtimeFromState:
|
|
997
|
+
| {
|
|
998
|
+
command?: string;
|
|
999
|
+
args?: string[];
|
|
1000
|
+
cwd?: string;
|
|
1001
|
+
timeoutMs?: number;
|
|
1002
|
+
interruptGraceSec?: number;
|
|
1003
|
+
retryCount?: number;
|
|
1004
|
+
retryBackoffMs?: number;
|
|
1005
|
+
env?: Record<string, string>;
|
|
1006
|
+
model?: string;
|
|
1007
|
+
thinkingEffort?: "auto" | "low" | "medium" | "high";
|
|
1008
|
+
bootstrapPrompt?: string;
|
|
1009
|
+
runPolicy?: {
|
|
1010
|
+
sandboxMode?: "workspace_write" | "full_access";
|
|
1011
|
+
allowWebSearch?: boolean;
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
| undefined
|
|
1015
|
+
) {
|
|
1016
|
+
const merged = {
|
|
1017
|
+
...(runtimeFromConfig ?? {}),
|
|
1018
|
+
...(runtimeFromState ?? {})
|
|
1019
|
+
};
|
|
1020
|
+
return {
|
|
1021
|
+
...merged,
|
|
1022
|
+
// Keep system-injected BOPOHQ_* context even when state runtime carries env:{}.
|
|
1023
|
+
env: {
|
|
1024
|
+
...(runtimeFromState?.env ?? {}),
|
|
1025
|
+
...(runtimeFromConfig?.env ?? {})
|
|
1026
|
+
}
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function buildHeartbeatRuntimeEnv(input: {
|
|
1031
|
+
companyId: string;
|
|
1032
|
+
agentId: string;
|
|
1033
|
+
heartbeatRunId: string;
|
|
1034
|
+
canHireAgents: boolean;
|
|
1035
|
+
}) {
|
|
1036
|
+
const apiBaseUrl = resolveControlPlaneApiBaseUrl();
|
|
1037
|
+
const actorPermissions = ["issues:write", "agents:write"].join(",");
|
|
1038
|
+
const actorHeaders = JSON.stringify({
|
|
1039
|
+
"x-company-id": input.companyId,
|
|
1040
|
+
"x-actor-type": "agent",
|
|
1041
|
+
"x-actor-id": input.agentId,
|
|
1042
|
+
"x-actor-companies": input.companyId,
|
|
1043
|
+
"x-actor-permissions": actorPermissions
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
const codexApiKey = resolveCodexApiKey();
|
|
1047
|
+
return {
|
|
1048
|
+
BOPOHQ_AGENT_ID: input.agentId,
|
|
1049
|
+
BOPOHQ_COMPANY_ID: input.companyId,
|
|
1050
|
+
BOPOHQ_RUN_ID: input.heartbeatRunId,
|
|
1051
|
+
BOPOHQ_FORCE_MANAGED_CODEX_HOME: "false",
|
|
1052
|
+
BOPOHQ_API_BASE_URL: apiBaseUrl,
|
|
1053
|
+
BOPOHQ_ACTOR_TYPE: "agent",
|
|
1054
|
+
BOPOHQ_ACTOR_ID: input.agentId,
|
|
1055
|
+
BOPOHQ_ACTOR_COMPANIES: input.companyId,
|
|
1056
|
+
BOPOHQ_ACTOR_PERMISSIONS: actorPermissions,
|
|
1057
|
+
BOPOHQ_REQUEST_HEADERS_JSON: actorHeaders,
|
|
1058
|
+
BOPOHQ_REQUEST_APPROVAL_DEFAULT: "true",
|
|
1059
|
+
BOPOHQ_CAN_HIRE_AGENTS: input.canHireAgents ? "true" : "false",
|
|
1060
|
+
...(codexApiKey ? { OPENAI_API_KEY: codexApiKey } : {})
|
|
1061
|
+
} satisfies Record<string, string>;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
function resolveControlPlaneApiBaseUrl() {
|
|
1065
|
+
const configured = process.env.BOPOHQ_API_BASE_URL ?? process.env.NEXT_PUBLIC_API_URL;
|
|
1066
|
+
return normalizeControlPlaneApiBaseUrl(configured) ?? "http://127.0.0.1:4020";
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
function resolveCodexApiKey() {
|
|
1070
|
+
const configured = process.env.BOPO_OPENAI_API_KEY ?? process.env.OPENAI_API_KEY;
|
|
1071
|
+
const value = configured?.trim();
|
|
1072
|
+
return value && value.length > 0 ? value : null;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
function summarizeRuntimeLaunch(
|
|
1076
|
+
providerType: "claude_code" | "codex" | "http" | "shell",
|
|
1077
|
+
runtime:
|
|
1078
|
+
| {
|
|
1079
|
+
command?: string;
|
|
1080
|
+
args?: string[];
|
|
1081
|
+
cwd?: string;
|
|
1082
|
+
timeoutMs?: number;
|
|
1083
|
+
env?: Record<string, string>;
|
|
1084
|
+
runPolicy?: {
|
|
1085
|
+
sandboxMode?: "workspace_write" | "full_access";
|
|
1086
|
+
allowWebSearch?: boolean;
|
|
1087
|
+
};
|
|
1088
|
+
}
|
|
1089
|
+
| undefined
|
|
1090
|
+
) {
|
|
1091
|
+
const env = runtime?.env ?? {};
|
|
1092
|
+
const hasOpenAiKey = typeof env.OPENAI_API_KEY === "string" && env.OPENAI_API_KEY.trim().length > 0;
|
|
1093
|
+
const hasExplicitCodexHome = typeof env.CODEX_HOME === "string" && env.CODEX_HOME.trim().length > 0;
|
|
1094
|
+
const codexHomeMode =
|
|
1095
|
+
providerType !== "codex"
|
|
1096
|
+
? null
|
|
1097
|
+
: hasExplicitCodexHome
|
|
1098
|
+
? "explicit"
|
|
1099
|
+
: hasText(env.BOPOHQ_COMPANY_ID) && hasText(env.BOPOHQ_AGENT_ID)
|
|
1100
|
+
? "managed"
|
|
1101
|
+
: "default";
|
|
1102
|
+
const authMode = providerType !== "codex" ? null : hasOpenAiKey ? "api_key" : "session";
|
|
1103
|
+
|
|
1104
|
+
return {
|
|
1105
|
+
command: runtime?.command ?? null,
|
|
1106
|
+
args: runtime?.args ?? [],
|
|
1107
|
+
cwd: runtime?.cwd ?? null,
|
|
1108
|
+
timeoutMs: runtime?.timeoutMs ?? null,
|
|
1109
|
+
runPolicy: runtime?.runPolicy ?? null,
|
|
1110
|
+
authMode,
|
|
1111
|
+
codexHomeMode,
|
|
1112
|
+
envFlags: {
|
|
1113
|
+
hasOpenAiKey,
|
|
1114
|
+
hasExplicitCodexHome,
|
|
1115
|
+
hasControlPlaneBaseUrl: hasText(env.BOPOHQ_API_BASE_URL),
|
|
1116
|
+
hasRequestHeadersJson: hasText(env.BOPOHQ_REQUEST_HEADERS_JSON)
|
|
1117
|
+
}
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
function normalizeControlPlaneApiBaseUrl(raw: string | undefined) {
|
|
1122
|
+
const value = raw?.trim();
|
|
1123
|
+
if (!value) {
|
|
1124
|
+
return null;
|
|
1125
|
+
}
|
|
1126
|
+
try {
|
|
1127
|
+
const url = new URL(value);
|
|
1128
|
+
if (!url.protocol || (url.protocol !== "http:" && url.protocol !== "https:")) {
|
|
1129
|
+
return null;
|
|
1130
|
+
}
|
|
1131
|
+
// Keep local addresses canonical to avoid split diagnostics between localhost/127.0.0.1.
|
|
1132
|
+
if (url.hostname === "localhost") {
|
|
1133
|
+
url.hostname = "127.0.0.1";
|
|
1134
|
+
}
|
|
1135
|
+
url.pathname = "";
|
|
1136
|
+
url.search = "";
|
|
1137
|
+
url.hash = "";
|
|
1138
|
+
return url.toString().replace(/\/$/, "");
|
|
1139
|
+
} catch {
|
|
1140
|
+
return null;
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
function validateControlPlaneRuntimeEnv(runtimeEnv: Record<string, string>, runId: string) {
|
|
1145
|
+
const requiredKeys = [
|
|
1146
|
+
"BOPOHQ_AGENT_ID",
|
|
1147
|
+
"BOPOHQ_COMPANY_ID",
|
|
1148
|
+
"BOPOHQ_RUN_ID",
|
|
1149
|
+
"BOPOHQ_API_BASE_URL",
|
|
1150
|
+
"BOPOHQ_REQUEST_HEADERS_JSON"
|
|
1151
|
+
] as const;
|
|
1152
|
+
const missingKeys = requiredKeys.filter((key) => {
|
|
1153
|
+
const value = runtimeEnv[key];
|
|
1154
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
1155
|
+
return true;
|
|
1156
|
+
}
|
|
1157
|
+
return false;
|
|
1158
|
+
});
|
|
1159
|
+
const missing = [...missingKeys] as string[];
|
|
1160
|
+
if (runtimeEnv.BOPOHQ_RUN_ID?.trim() && runtimeEnv.BOPOHQ_RUN_ID !== runId) {
|
|
1161
|
+
missing.push("BOPOHQ_RUN_ID(mismatch)");
|
|
1162
|
+
}
|
|
1163
|
+
return {
|
|
1164
|
+
ok: missing.length === 0,
|
|
1165
|
+
missingKeys: missing
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
function shouldRequireControlPlanePreflight(
|
|
1170
|
+
providerType: "claude_code" | "codex" | "http" | "shell",
|
|
1171
|
+
workItemCount: number
|
|
1172
|
+
) {
|
|
1173
|
+
if (workItemCount < 1) {
|
|
1174
|
+
return false;
|
|
1175
|
+
}
|
|
1176
|
+
return providerType === "codex" || providerType === "claude_code";
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
function resolveControlPlanePreflightEnabled() {
|
|
1180
|
+
const value = String(process.env.BOPOHQ_COMMUNICATION_PREFLIGHT ?? "")
|
|
1181
|
+
.trim()
|
|
1182
|
+
.toLowerCase();
|
|
1183
|
+
return value === "1" || value === "true";
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
function resolveControlPlanePreflightTimeoutMs() {
|
|
1187
|
+
const parsed = Number(process.env.BOPOHQ_COMMUNICATION_PREFLIGHT_TIMEOUT_MS ?? "1500");
|
|
1188
|
+
if (!Number.isFinite(parsed) || parsed < 200) {
|
|
1189
|
+
return 1500;
|
|
1190
|
+
}
|
|
1191
|
+
return Math.floor(parsed);
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
async function runControlPlaneConnectivityPreflight(input: {
|
|
1195
|
+
apiBaseUrl: string;
|
|
1196
|
+
requestHeadersJson: string;
|
|
1197
|
+
timeoutMs: number;
|
|
1198
|
+
}) {
|
|
1199
|
+
const normalizedApiBaseUrl = normalizeControlPlaneApiBaseUrl(input.apiBaseUrl);
|
|
1200
|
+
if (!normalizedApiBaseUrl) {
|
|
1201
|
+
return {
|
|
1202
|
+
ok: false as const,
|
|
1203
|
+
message: `Invalid BOPOHQ_API_BASE_URL '${input.apiBaseUrl || "<empty>"}'.`,
|
|
1204
|
+
endpoint: null
|
|
1205
|
+
};
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
let requestHeaders: Record<string, string> = {};
|
|
1209
|
+
try {
|
|
1210
|
+
const parsed = JSON.parse(input.requestHeadersJson) as Record<string, unknown>;
|
|
1211
|
+
requestHeaders = Object.fromEntries(
|
|
1212
|
+
Object.entries(parsed).filter((entry): entry is [string, string] => typeof entry[1] === "string")
|
|
1213
|
+
);
|
|
1214
|
+
} catch {
|
|
1215
|
+
return {
|
|
1216
|
+
ok: false as const,
|
|
1217
|
+
message: "Invalid BOPOHQ_REQUEST_HEADERS_JSON; expected JSON object of string headers.",
|
|
1218
|
+
endpoint: `${normalizedApiBaseUrl}/agents`
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
if (!hasText(requestHeaders["x-company-id"])) {
|
|
1223
|
+
return {
|
|
1224
|
+
ok: false as const,
|
|
1225
|
+
message: "Missing x-company-id in BOPOHQ_REQUEST_HEADERS_JSON.",
|
|
1226
|
+
endpoint: `${normalizedApiBaseUrl}/agents`
|
|
1227
|
+
};
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
const controller = new AbortController();
|
|
1231
|
+
const timeout = setTimeout(() => controller.abort(), input.timeoutMs);
|
|
1232
|
+
const endpoint = `${normalizedApiBaseUrl}/agents`;
|
|
1233
|
+
try {
|
|
1234
|
+
const response = await fetch(endpoint, {
|
|
1235
|
+
method: "GET",
|
|
1236
|
+
headers: requestHeaders,
|
|
1237
|
+
signal: controller.signal
|
|
1238
|
+
});
|
|
1239
|
+
if (!response.ok) {
|
|
1240
|
+
return {
|
|
1241
|
+
ok: false as const,
|
|
1242
|
+
message: `Control plane responded ${response.status} ${response.statusText}.`,
|
|
1243
|
+
endpoint
|
|
1244
|
+
};
|
|
1245
|
+
}
|
|
1246
|
+
return {
|
|
1247
|
+
ok: true as const,
|
|
1248
|
+
message: "Control-plane preflight passed.",
|
|
1249
|
+
endpoint
|
|
1250
|
+
};
|
|
1251
|
+
} catch (error) {
|
|
1252
|
+
return {
|
|
1253
|
+
ok: false as const,
|
|
1254
|
+
message: String(error),
|
|
1255
|
+
endpoint
|
|
1256
|
+
};
|
|
1257
|
+
} finally {
|
|
1258
|
+
clearTimeout(timeout);
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
|
|
649
1262
|
function isHeartbeatDue(cronExpression: string, lastRunAt: Date | null, now: Date) {
|
|
650
1263
|
const normalizedNow = truncateToMinute(now);
|
|
651
1264
|
if (!matchesCronExpression(cronExpression, normalizedNow)) {
|