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.
@@ -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: state.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
- if (issueIds.length > 0 && execution.status === "ok") {
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)) {