bosun 0.37.1 → 0.37.2

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/voice-relay.mjs CHANGED
@@ -26,7 +26,7 @@ const OPENAI_REALTIME_MODEL = "gpt-realtime-1.5";
26
26
  const OPENAI_AUDIO_RESPONSES_MODEL = "gpt-audio-1.5";
27
27
  const OPENAI_RESPONSES_URL = "https://api.openai.com/v1/responses";
28
28
  const OPENAI_DEFAULT_VISION_MODEL = "gpt-4.1-nano";
29
- const REALTIME_TRANSCRIBE_MODEL = "gpt-4o-transcribe";
29
+ const DEFAULT_TRANSCRIBE_MODEL = "gpt-4o-transcribe";
30
30
 
31
31
  const AZURE_API_VERSION = "2025-04-01-preview";
32
32
 
@@ -73,6 +73,8 @@ function buildOpenAIRealtimeWebRtcUrl(model, overrideBase = "") {
73
73
 
74
74
  // GA models (gpt-realtime, gpt-realtime-1.5, gpt-realtime-mini, etc.) use /openai/v1/ paths.
75
75
  // Preview models (for example gpt-4o-realtime-preview-*) use legacy /openai/realtimeapi/ paths.
76
+ // NOTE: Azure AI Foundry "Global Standard" deployments may only support preview paths
77
+ // even for GA model names. We try GA first. If it 404s the caller falls back to preview.
76
78
  function isAzureGaProtocol(deployment) {
77
79
  const d = String(deployment || "").toLowerCase().trim();
78
80
  return d.startsWith("gpt-realtime") && !d.startsWith("gpt-4o-realtime");
@@ -93,6 +95,13 @@ function normalizeAzureRealtimeDeployment(rawDeployment) {
93
95
  return deployment;
94
96
  }
95
97
 
98
+ function parseOptionalBoolean(rawValue) {
99
+ if (rawValue == null) return null;
100
+ const normalized = String(rawValue).trim().toLowerCase();
101
+ if (!normalized) return null;
102
+ return !["0", "false", "no", "off"].includes(normalized);
103
+ }
104
+
96
105
  function isOpenAIAudioResponsesModel(rawModel) {
97
106
  const model = String(rawModel || "").trim().toLowerCase();
98
107
  return /^gpt-audio/.test(model);
@@ -305,18 +314,42 @@ function sanitizeVoiceCallContext(context = {}) {
305
314
  const rawExecutor = String(context?.executor || "").trim().toLowerCase();
306
315
  const rawMode = String(context?.mode || "").trim().toLowerCase();
307
316
  const rawModel = String(context?.model || "").trim();
317
+ const rawVoiceAgentId = String(context?.voiceAgentId || "").trim();
318
+ const rawVoiceAgentName = String(context?.voiceAgentName || "").trim();
319
+ const rawVoiceAgentInstructions = String(context?.voiceAgentInstructions || "").trim();
320
+ const rawVoiceToolCapabilityPrompt = String(context?.voiceToolCapabilityPrompt || "").trim();
321
+ const rawVoiceAgentSkills = Array.isArray(context?.voiceAgentSkills)
322
+ ? context.voiceAgentSkills.map((s) => String(s || "").trim()).filter(Boolean)
323
+ : [];
324
+ const rawEnabledMcpServers = Array.isArray(context?.enabledMcpServers)
325
+ ? context.enabledMcpServers.map((s) => String(s || "").trim()).filter(Boolean)
326
+ : [];
308
327
 
309
328
  return {
310
329
  sessionId: rawSessionId || null,
311
330
  executor: VALID_EXECUTORS.has(rawExecutor) ? rawExecutor : null,
312
331
  mode: VALID_AGENT_MODES.has(rawMode) ? rawMode : null,
313
332
  model: rawModel || null,
333
+ voiceAgentId: rawVoiceAgentId || null,
334
+ voiceAgentName: rawVoiceAgentName || null,
335
+ voiceAgentInstructions: rawVoiceAgentInstructions || null,
336
+ voiceToolCapabilityPrompt: rawVoiceToolCapabilityPrompt || null,
337
+ voiceAgentSkills: rawVoiceAgentSkills,
338
+ enabledMcpServers: rawEnabledMcpServers,
314
339
  };
315
340
  }
316
341
 
317
342
  async function buildSessionScopedInstructions(baseInstructions, callContext = {}) {
318
343
  const context = sanitizeVoiceCallContext(callContext);
319
- if (!context.sessionId && !context.executor && !context.mode && !context.model) {
344
+ if (
345
+ !context.sessionId
346
+ && !context.executor
347
+ && !context.mode
348
+ && !context.model
349
+ && !context.voiceAgentId
350
+ && !context.voiceAgentInstructions
351
+ && !context.voiceToolCapabilityPrompt
352
+ ) {
320
353
  return baseInstructions;
321
354
  }
322
355
 
@@ -381,6 +414,22 @@ async function buildSessionScopedInstructions(baseInstructions, callContext = {}
381
414
  "",
382
415
  "## Bosun Voice Call Context",
383
416
  `Active chat session id: ${context.sessionId || "none"}.`,
417
+ context.voiceAgentId
418
+ ? `Active voice agent id: ${context.voiceAgentId}.`
419
+ : "Active voice agent id: default.",
420
+ context.voiceAgentName
421
+ ? `Active voice agent name: ${context.voiceAgentName}.`
422
+ : "",
423
+ context.voiceAgentInstructions
424
+ ? `Voice agent instruction emphasis: ${context.voiceAgentInstructions}`
425
+ : "",
426
+ context.voiceToolCapabilityPrompt || "",
427
+ context.enabledMcpServers?.length
428
+ ? `Enabled MCP servers for this session: ${context.enabledMcpServers.join(", ")}.`
429
+ : "",
430
+ context.voiceAgentSkills?.length
431
+ ? `Voice agent skills: ${context.voiceAgentSkills.join(", ")}.`
432
+ : "",
384
433
  context.executor
385
434
  ? `Preferred executor for delegated work: ${context.executor}.`
386
435
  : "Preferred executor for delegated work: use configured default.",
@@ -783,6 +832,12 @@ export function getVoiceConfig(forceReload = false) {
783
832
  azureDeployment: String(ep.deployment || ep.azureDeployment || "").trim() || null,
784
833
  voiceId: String(ep.voiceId || "").trim() || null,
785
834
  visionModel: String(ep.visionModel || "").trim() || null,
835
+ transcriptionModel: String(ep.transcriptionModel || "").trim() || null,
836
+ // Azure defaults to transcription OFF unless explicitly enabled because
837
+ // item-level ASR failures can spam and destabilize long-running calls.
838
+ transcriptionEnabled: String(ep.provider || "").toLowerCase() === "azure"
839
+ ? (ep.transcriptionEnabled === true)
840
+ : (ep.transcriptionEnabled !== false),
786
841
  role: String(ep.role || "primary").trim() || "primary",
787
842
  weight: typeof ep.weight === "number" ? ep.weight : 100,
788
843
  name: String(ep.name || "").trim() || null,
@@ -861,6 +916,19 @@ export function getVoiceConfig(forceReload = false) {
861
916
  : OPENAI_DEFAULT_VISION_MODEL;
862
917
  const visionModel =
863
918
  voice.visionModel || process.env.VOICE_VISION_MODEL || defaultVisionModel;
919
+ const transcriptionModel =
920
+ voice.transcriptionModel || process.env.VOICE_TRANSCRIPTION_MODEL || DEFAULT_TRANSCRIBE_MODEL;
921
+ const transcriptionEnabledRaw =
922
+ voice.transcriptionEnabled ?? process.env.VOICE_TRANSCRIPTION_ENABLED;
923
+ const transcriptionEnabled =
924
+ transcriptionEnabledRaw == null
925
+ ? true
926
+ : !["0", "false", "no", "off"].includes(
927
+ String(transcriptionEnabledRaw).trim().toLowerCase(),
928
+ );
929
+ const azureTranscriptionEnabled = parseOptionalBoolean(
930
+ voice.azureTranscriptionEnabled ?? process.env.VOICE_AZURE_TRANSCRIPTION_ENABLED,
931
+ );
864
932
  const fallbackMode =
865
933
  voice.fallbackMode || process.env.VOICE_FALLBACK_MODE || "browser";
866
934
  const delegateExecutor =
@@ -906,6 +974,9 @@ For complex operations like writing code or creating PRs, delegate to the approp
906
974
  turnDetection,
907
975
  visionModel,
908
976
  instructions,
977
+ transcriptionModel,
978
+ transcriptionEnabled,
979
+ azureTranscriptionEnabled,
909
980
  fallbackMode,
910
981
  delegateExecutor,
911
982
  enabled,
@@ -1120,6 +1191,13 @@ async function createOpenAIEphemeralToken(cfg, toolDefinitions = [], callContext
1120
1191
  const instructions = await buildSessionScopedInstructions(cfg.instructions, context);
1121
1192
  const model = normalizeOpenAIRealtimeModel(candidate?.model || cfg.model || OPENAI_REALTIME_MODEL);
1122
1193
  const voiceId = String(candidate?.voiceId || cfg.voiceId || "alloy").trim() || "alloy";
1194
+ // Per-endpoint transcription overrides
1195
+ const transcriptionModel = String(candidate?.transcriptionModel || "").trim() || cfg.transcriptionModel;
1196
+ const transcriptionEnabled = candidate?.transcriptionEnabled !== undefined
1197
+ ? candidate.transcriptionEnabled !== false
1198
+ : cfg.azureTranscriptionEnabled != null
1199
+ ? cfg.azureTranscriptionEnabled !== false
1200
+ : false;
1123
1201
 
1124
1202
  const sessionConfig = {
1125
1203
  model,
@@ -1144,8 +1222,7 @@ async function createOpenAIEphemeralToken(cfg, toolDefinitions = [], callContext
1144
1222
  interrupt_response: true,
1145
1223
  } : {}),
1146
1224
  },
1147
- input_audio_transcription: { model: REALTIME_TRANSCRIBE_MODEL },
1148
- output_audio_transcription: { model: REALTIME_TRANSCRIBE_MODEL },
1225
+ ...(transcriptionEnabled ? { input_audio_transcription: { model: transcriptionModel } } : {}),
1149
1226
  tools: toolDefinitions,
1150
1227
  };
1151
1228
 
@@ -1198,11 +1275,17 @@ async function createAzureEphemeralToken(cfg, toolDefinitions = [], callContext
1198
1275
  candidate?.azureDeployment || candidate?.model || cfg.azureDeployment || OPENAI_REALTIME_MODEL,
1199
1276
  );
1200
1277
  const voiceId = String(candidate?.voiceId || cfg.voiceId || "alloy").trim() || "alloy";
1278
+ // Per-endpoint transcription overrides
1279
+ const transcriptionModel = String(candidate?.transcriptionModel || "").trim() || cfg.transcriptionModel;
1280
+ const transcriptionEnabled = candidate?.transcriptionEnabled !== undefined ? candidate.transcriptionEnabled !== false : cfg.transcriptionEnabled;
1201
1281
  // GA protocol (gpt-realtime-1.5, gpt-realtime, etc.) uses /openai/v1/realtime/sessions?api-version=...
1202
1282
  // Preview protocol uses /openai/realtimeapi/sessions?api-version=...
1203
- const url = isAzureGaProtocol(deployment)
1204
- ? `${resolvedEndpoint}/openai/v1/realtime/sessions?api-version=${AZURE_API_VERSION}`
1205
- : `${resolvedEndpoint}/openai/realtimeapi/sessions?api-version=${AZURE_API_VERSION}&deployment=${encodeURIComponent(deployment)}`;
1283
+ // Azure AI Foundry "Global Standard" resources may not support GA paths even for GA model names,
1284
+ // so we build both and try GA first with automatic fallback to preview.
1285
+ const gaUrl = `${resolvedEndpoint}/openai/v1/realtime/sessions?api-version=${AZURE_API_VERSION}`;
1286
+ const previewUrl = `${resolvedEndpoint}/openai/realtimeapi/sessions?api-version=${AZURE_API_VERSION}&deployment=${encodeURIComponent(deployment)}`;
1287
+ const useGa = isAzureGaProtocol(deployment);
1288
+ const url = useGa ? gaUrl : previewUrl;
1206
1289
 
1207
1290
  const headers = {
1208
1291
  "Content-Type": "application/json",
@@ -1239,17 +1322,28 @@ async function createAzureEphemeralToken(cfg, toolDefinitions = [], callContext
1239
1322
  interrupt_response: true,
1240
1323
  } : {}),
1241
1324
  },
1242
- input_audio_transcription: { model: REALTIME_TRANSCRIBE_MODEL },
1243
- output_audio_transcription: { model: REALTIME_TRANSCRIBE_MODEL },
1325
+ ...(transcriptionEnabled ? { input_audio_transcription: { model: transcriptionModel } } : {}),
1244
1326
  tools: toolDefinitions,
1245
1327
  };
1246
1328
 
1247
- const response = await fetch(url, {
1329
+ let response = await fetch(url, {
1248
1330
  method: "POST",
1249
1331
  headers,
1250
1332
  body: JSON.stringify(sessionConfig),
1251
1333
  });
1252
1334
 
1335
+ // Azure AI Foundry "Global Standard" deployments may 404 on the GA path.
1336
+ // Automatically fall back to the preview path before giving up.
1337
+ if (!response.ok && response.status === 404 && useGa) {
1338
+ const previewConfig = { ...sessionConfig };
1339
+ delete previewConfig.type; // preview path does not accept type: "realtime"
1340
+ response = await fetch(previewUrl, {
1341
+ method: "POST",
1342
+ headers,
1343
+ body: JSON.stringify(previewConfig),
1344
+ });
1345
+ }
1346
+
1253
1347
  if (!response.ok) {
1254
1348
  const errorText = await buildProviderErrorDetails(response, "unknown");
1255
1349
  throw new Error(`Azure Realtime session failed (${response.status}): ${errorText}`);
@@ -1257,9 +1351,22 @@ async function createAzureEphemeralToken(cfg, toolDefinitions = [], callContext
1257
1351
 
1258
1352
  const data = await response.json();
1259
1353
  // WebRTC URL diverges from /sessions URL: GA uses /openai/v1/realtime, preview uses /openai/realtime.
1260
- const webrtcUrl = isAzureGaProtocol(deployment)
1354
+ // If the GA session was created via fallback to preview, use preview WebRTC URL too.
1355
+ const gaSessionSucceeded = useGa && response.url?.includes("/v1/realtime");
1356
+ const webrtcUrl = (useGa && gaSessionSucceeded)
1261
1357
  ? `${resolvedEndpoint}/openai/v1/realtime?api-version=${AZURE_API_VERSION}`
1262
1358
  : `${resolvedEndpoint}/openai/realtime?api-version=${AZURE_API_VERSION}&deployment=${encodeURIComponent(deployment)}`;
1359
+
1360
+ // WebSocket fallback URL — Azure Realtime API always supports WebSocket even
1361
+ // when WebRTC SDP is unavailable (404). The api-key query parameter provides
1362
+ // authentication since browsers cannot set custom headers on WebSocket.
1363
+ const wsAuthParam = resolvedOAuthToken
1364
+ ? `access_token=${encodeURIComponent(resolvedOAuthToken)}`
1365
+ : `api-key=${encodeURIComponent(resolvedApiKey)}`;
1366
+ const wsUrl = (useGa && gaSessionSucceeded)
1367
+ ? `wss://${new URL(resolvedEndpoint).host}/openai/v1/realtime?api-version=${AZURE_API_VERSION}&${wsAuthParam}`
1368
+ : `wss://${new URL(resolvedEndpoint).host}/openai/realtime?api-version=${AZURE_API_VERSION}&deployment=${encodeURIComponent(deployment)}&${wsAuthParam}`;
1369
+
1263
1370
  return {
1264
1371
  token: data.client_secret?.value || data.token,
1265
1372
  expiresAt: data.client_secret?.expires_at || (Date.now() / 1000 + 60),
@@ -1267,6 +1374,7 @@ async function createAzureEphemeralToken(cfg, toolDefinitions = [], callContext
1267
1374
  voiceId,
1268
1375
  provider: "azure",
1269
1376
  url: webrtcUrl,
1377
+ wsUrl,
1270
1378
  sessionConfig,
1271
1379
  azureEndpoint: resolvedEndpoint,
1272
1380
  azureDeployment: deployment,
@@ -899,6 +899,60 @@ export class WorkflowEngine extends EventEmitter {
899
899
  return triggered;
900
900
  }
901
901
 
902
+ // ── Schedule trigger evaluation ──────────────────────────────────────────
903
+
904
+ /**
905
+ * Evaluate all workflows that use `trigger.schedule` or `trigger.scheduled_once`.
906
+ * Unlike evaluateTriggers() (event-driven), this is polling-based and should
907
+ * be called periodically (e.g. every 60s) by the monitor.
908
+ *
909
+ * Returns an array of { workflowId, triggeredBy } for workflows whose
910
+ * schedule interval has elapsed since their last completed run.
911
+ */
912
+ evaluateScheduleTriggers() {
913
+ if (!this._loaded) this.load();
914
+
915
+ const triggered = [];
916
+ const runIndex = this._readRunIndex();
917
+
918
+ for (const [id, def] of this._workflows) {
919
+ if (def.enabled === false) continue;
920
+
921
+ // Skip workflows that are already running
922
+ const alreadyRunning = Array.from(this._activeRuns.values()).some(
923
+ (info) => info?.workflowId === id,
924
+ );
925
+ if (alreadyRunning) continue;
926
+
927
+ const triggerNodes = (def.nodes || []).filter(
928
+ (n) => n.type === "trigger.schedule" || n.type === "trigger.scheduled_once",
929
+ );
930
+
931
+ for (const tNode of triggerNodes) {
932
+ const intervalMs = Number(tNode.config?.intervalMs) || 3600000;
933
+
934
+ // Find the most recent completed run for this workflow
935
+ let lastRunAt = 0;
936
+ for (const entry of runIndex) {
937
+ if (entry?.workflowId !== id) continue;
938
+ const ts = Number(entry?.startedAt || entry?.completedAt || 0);
939
+ if (ts > lastRunAt) lastRunAt = ts;
940
+ }
941
+
942
+ const elapsed = Date.now() - lastRunAt;
943
+ if (elapsed >= intervalMs) {
944
+ triggered.push({ workflowId: id, triggeredBy: tNode.id });
945
+
946
+ // For scheduled_once, only fire if never run before
947
+ if (tNode.type === "trigger.scheduled_once" && lastRunAt > 0) {
948
+ triggered.pop(); // undo — already ran once
949
+ }
950
+ }
951
+ }
952
+ }
953
+ return triggered;
954
+ }
955
+
902
956
  /** Get status of active runs */
903
957
  getActiveRuns() {
904
958
  return Array.from(this._activeRuns.entries())
@@ -798,7 +798,9 @@ registerNodeType("trigger.manual", {
798
798
  });
799
799
 
800
800
  registerNodeType("trigger.task_low", {
801
- describe: () => "Fires when backlog task count drops below threshold",
801
+ describe: () =>
802
+ "Fires when backlog task count drops below threshold. Self-queries kanban " +
803
+ "when todoCount is not pre-populated in context data.",
802
804
  schema: {
803
805
  type: "object",
804
806
  properties: {
@@ -809,7 +811,29 @@ registerNodeType("trigger.task_low", {
809
811
  },
810
812
  async execute(node, ctx) {
811
813
  const threshold = node.config?.threshold ?? 3;
812
- const todoCount = ctx.data?.todoCount ?? ctx.data?.backlogCount ?? 0;
814
+ const status = node.config?.status ?? "todo";
815
+ let todoCount = ctx.data?.todoCount ?? ctx.data?.backlogCount ?? null;
816
+
817
+ // Self-query kanban if todoCount not pre-populated
818
+ if (todoCount == null) {
819
+ try {
820
+ const projectId = cfgOrCtx(node, ctx, "projectId") || undefined;
821
+ const kanban = ctx.data?._services?.kanban;
822
+ let tasks;
823
+ if (kanban?.listTasks) {
824
+ tasks = await kanban.listTasks(projectId, { status });
825
+ } else {
826
+ const ka = await ensureKanbanAdapterMod();
827
+ tasks = await ka.listTasks(projectId, { status });
828
+ }
829
+ todoCount = Array.isArray(tasks) ? tasks.length : 0;
830
+ ctx.log(node.id, `Self-queried kanban: ${todoCount} task(s) with status "${status}"`);
831
+ } catch (err) {
832
+ ctx.log(node.id, `Kanban query failed: ${err?.message || err} — using 0`);
833
+ todoCount = 0;
834
+ }
835
+ }
836
+
813
837
  const triggered = todoCount < threshold;
814
838
  ctx.log(node.id, `Task count: ${todoCount}, threshold: ${threshold}, triggered: ${triggered}`);
815
839
  return { triggered, todoCount, threshold };
@@ -2322,7 +2346,9 @@ registerNodeType("action.git_operations", {
2322
2346
  });
2323
2347
 
2324
2348
  registerNodeType("action.create_pr", {
2325
- describe: () => "Hand off pull-request lifecycle to Bosun management (direct creation disabled)",
2349
+ describe: () =>
2350
+ "Create a pull request via GitHub CLI. Falls back to Bosun-managed handoff " +
2351
+ "when gh is unavailable or the operation fails with failOnError=false.",
2326
2352
  schema: {
2327
2353
  type: "object",
2328
2354
  properties: {
@@ -2330,10 +2356,18 @@ registerNodeType("action.create_pr", {
2330
2356
  body: { type: "string", description: "PR body" },
2331
2357
  base: { type: "string", description: "Base branch" },
2332
2358
  baseBranch: { type: "string", description: "Legacy alias for base branch" },
2333
- branch: { type: "string", description: "Head branch for Bosun lifecycle handoff context" },
2359
+ branch: { type: "string", description: "Head branch (source)" },
2334
2360
  draft: { type: "boolean", default: false },
2361
+ labels: {
2362
+ oneOf: [{ type: "string" }, { type: "array", items: { type: "string" } }],
2363
+ description: "Comma-separated or array of labels",
2364
+ },
2365
+ reviewers: {
2366
+ oneOf: [{ type: "string" }, { type: "array", items: { type: "string" } }],
2367
+ description: "Comma-separated or array of reviewer handles",
2368
+ },
2335
2369
  cwd: { type: "string" },
2336
- failOnError: { type: "boolean", default: false, description: "Retained for compatibility; direct PR commands are disabled" },
2370
+ failOnError: { type: "boolean", default: false, description: "If true, throw on gh failure instead of falling back" },
2337
2371
  },
2338
2372
  required: ["title"],
2339
2373
  },
@@ -2342,23 +2376,77 @@ registerNodeType("action.create_pr", {
2342
2376
  const body = ctx.resolve(node.config?.body || "");
2343
2377
  const base = ctx.resolve(node.config?.base || node.config?.baseBranch || "main");
2344
2378
  const branch = ctx.resolve(node.config?.branch || "");
2379
+ const draft = node.config?.draft === true;
2380
+ const failOnError = node.config?.failOnError === true;
2345
2381
  const cwd = ctx.resolve(node.config?.cwd || ctx.data?.worktreePath || process.cwd());
2346
- ctx.log(
2347
- node.id,
2348
- `PR lifecycle handoff recorded for "${title}" (direct PR commands are disabled)`,
2349
- );
2350
- return {
2351
- success: true,
2352
- handedOff: true,
2353
- lifecycle: "bosun_managed",
2354
- action: "pr_handoff",
2355
- message: "Direct PR commands are disabled; Bosun manages pull-request lifecycle.",
2356
- title,
2357
- body,
2358
- base,
2359
- branch: branch || null,
2360
- cwd,
2382
+
2383
+ // Normalize labels/reviewers to arrays
2384
+ const toList = (v) => {
2385
+ if (!v) return [];
2386
+ if (Array.isArray(v)) return v.map(String).filter(Boolean);
2387
+ return String(v).split(",").map((s) => s.trim()).filter(Boolean);
2361
2388
  };
2389
+ const labels = toList(ctx.resolve(node.config?.labels || ""));
2390
+ const reviewers = toList(ctx.resolve(node.config?.reviewers || ""));
2391
+
2392
+ // Build gh pr create command
2393
+ const args = ["gh", "pr", "create"];
2394
+ args.push("--title", JSON.stringify(title));
2395
+ if (body) args.push("--body", JSON.stringify(body));
2396
+ if (base) args.push("--base", base);
2397
+ if (branch) args.push("--head", branch);
2398
+ if (draft) args.push("--draft");
2399
+ if (labels.length) args.push("--label", labels.join(","));
2400
+ if (reviewers.length) args.push("--reviewer", reviewers.join(","));
2401
+
2402
+ const cmd = args.join(" ");
2403
+ ctx.log(node.id, `Creating PR: ${cmd}`);
2404
+
2405
+ try {
2406
+ const output = execSync(cmd, { cwd, encoding: "utf8", timeout: 60000 });
2407
+ const trimmed = (output || "").trim();
2408
+ // gh pr create prints the PR URL on success
2409
+ const urlMatch = trimmed.match(/https:\/\/github\.com\/[^\s]+\/pull\/(\d+)/);
2410
+ const prNumber = urlMatch ? parseInt(urlMatch[1], 10) : null;
2411
+ const prUrl = urlMatch ? urlMatch[0] : trimmed;
2412
+ ctx.log(node.id, `PR created: ${prUrl}`);
2413
+ return {
2414
+ success: true,
2415
+ prUrl,
2416
+ prNumber,
2417
+ title,
2418
+ base,
2419
+ branch: branch || null,
2420
+ draft,
2421
+ labels,
2422
+ reviewers,
2423
+ output: trimmed,
2424
+ };
2425
+ } catch (err) {
2426
+ const errorMsg = err?.stderr?.toString?.()?.trim() || err?.message || String(err);
2427
+ ctx.log(node.id, `PR creation failed: ${errorMsg}`);
2428
+ if (failOnError) {
2429
+ return { success: false, error: errorMsg, command: cmd };
2430
+ }
2431
+ // Graceful fallback — record handoff for Bosun management
2432
+ ctx.log(node.id, `Falling back to Bosun-managed PR lifecycle handoff`);
2433
+ return {
2434
+ success: true,
2435
+ handedOff: true,
2436
+ lifecycle: "bosun_managed",
2437
+ action: "pr_handoff",
2438
+ message: "gh CLI failed; Bosun manages pull-request lifecycle.",
2439
+ title,
2440
+ body,
2441
+ base,
2442
+ branch: branch || null,
2443
+ draft,
2444
+ labels,
2445
+ reviewers,
2446
+ cwd,
2447
+ ghError: errorMsg,
2448
+ };
2449
+ }
2362
2450
  },
2363
2451
  });
2364
2452
 
@@ -3484,17 +3572,23 @@ registerNodeType("flow.gate", {
3484
3572
  // ═══════════════════════════════════════════════════════════════════════════
3485
3573
 
3486
3574
  registerNodeType("loop.for_each", {
3487
- describe: () => "Iterate over an array, executing downstream nodes for each item",
3575
+ describe: () =>
3576
+ "Iterate over an array, executing a sub-workflow for each item. " +
3577
+ "Supports parallel fan-out via maxConcurrent and provides per-item " +
3578
+ "context injection under the configured variable name.",
3488
3579
  schema: {
3489
3580
  type: "object",
3490
3581
  properties: {
3491
3582
  items: { type: "string", description: "Expression that resolves to an array" },
3492
3583
  variable: { type: "string", default: "item", description: "Variable name for current item" },
3493
- maxIterations: { type: "number", default: 50 },
3584
+ indexVariable: { type: "string", default: "index", description: "Variable name for current index" },
3585
+ maxIterations: { type: "number", default: 50, description: "Cap on total iterations" },
3586
+ maxConcurrent: { type: "number", default: 1, description: "Parallel fan-out width (1 = sequential)" },
3587
+ workflowId: { type: "string", description: "Sub-workflow to execute for each item (optional)" },
3494
3588
  },
3495
3589
  required: ["items"],
3496
3590
  },
3497
- async execute(node, ctx) {
3591
+ async execute(node, ctx, engine) {
3498
3592
  const expr = node.config?.items || "[]";
3499
3593
  let items;
3500
3594
  try {
@@ -3507,12 +3601,65 @@ registerNodeType("loop.for_each", {
3507
3601
  const max = node.config?.maxIterations || 50;
3508
3602
  items = items.slice(0, max);
3509
3603
  const varName = node.config?.variable || "item";
3604
+ const indexVar = node.config?.indexVariable || "index";
3605
+ const maxConcurrent = Math.max(1, node.config?.maxConcurrent || 1);
3606
+ const subWorkflowId = node.config?.workflowId || "";
3510
3607
 
3511
- // Store items for downstream processing
3608
+ // Store items for downstream processing (backward compat)
3512
3609
  ctx.data[`_loop_${node.id}_items`] = items;
3513
3610
  ctx.data[`_loop_${node.id}_count`] = items.length;
3514
3611
 
3515
- return { items, count: items.length, variable: varName };
3612
+ const results = [];
3613
+
3614
+ // If a sub-workflow is specified, fan-out execution across items
3615
+ if (subWorkflowId && engine?.execute) {
3616
+ ctx.log(node.id, `Fan-out: ${items.length} item(s), concurrency=${maxConcurrent}, workflow=${subWorkflowId}`);
3617
+
3618
+ // Process items in batches of maxConcurrent
3619
+ for (let batchStart = 0; batchStart < items.length; batchStart += maxConcurrent) {
3620
+ const batch = items.slice(batchStart, batchStart + maxConcurrent);
3621
+ const batchPromises = batch.map(async (item, batchIdx) => {
3622
+ const itemIndex = batchStart + batchIdx;
3623
+ const itemData = {
3624
+ ...ctx.data,
3625
+ [varName]: item,
3626
+ [indexVar]: itemIndex,
3627
+ _loopParentNodeId: node.id,
3628
+ _loopIteration: itemIndex,
3629
+ _loopTotal: items.length,
3630
+ };
3631
+ try {
3632
+ const runCtx = await engine.execute(subWorkflowId, itemData);
3633
+ const ok = !runCtx?.errors?.length;
3634
+ return { index: itemIndex, item, success: ok, runId: runCtx?.id || null };
3635
+ } catch (err) {
3636
+ return { index: itemIndex, item, success: false, error: err?.message || String(err) };
3637
+ }
3638
+ });
3639
+ const batchResults = await Promise.all(batchPromises);
3640
+ results.push(...batchResults);
3641
+ }
3642
+ } else {
3643
+ // No sub-workflow — store items for downstream node access (legacy mode)
3644
+ for (let i = 0; i < items.length; i++) {
3645
+ ctx.data[varName] = items[i];
3646
+ ctx.data[indexVar] = i;
3647
+ results.push({ index: i, item: items[i], success: true });
3648
+ }
3649
+ }
3650
+
3651
+ const successCount = results.filter((r) => r.success).length;
3652
+ const failCount = results.length - successCount;
3653
+ ctx.log(node.id, `Loop complete: ${successCount} succeeded, ${failCount} failed out of ${items.length}`);
3654
+
3655
+ return {
3656
+ items,
3657
+ count: items.length,
3658
+ variable: varName,
3659
+ results,
3660
+ successCount,
3661
+ failCount,
3662
+ };
3516
3663
  },
3517
3664
  });
3518
3665
 
@@ -5088,17 +5235,19 @@ registerNodeType("action.detect_new_commits", {
5088
5235
  schema: {
5089
5236
  type: "object",
5090
5237
  properties: {
5091
- worktreePath: { type: "string", description: "Worktree path" },
5238
+ worktreePath: { type: "string", description: "Worktree path (soft-fails if not set)" },
5092
5239
  preExecHead: { type: "string", description: "HEAD hash before agent (auto from ctx)" },
5093
5240
  baseBranch: { type: "string", description: "Base branch for diff stats" },
5094
5241
  },
5095
- required: ["worktreePath"],
5096
5242
  },
5097
5243
  async execute(node, ctx) {
5098
5244
  const worktreePath = cfgOrCtx(node, ctx, "worktreePath");
5099
5245
  const baseBranch = cfgOrCtx(node, ctx, "baseBranch", "origin/main");
5100
5246
 
5101
- if (!worktreePath) throw new Error("action.detect_new_commits: worktreePath is required");
5247
+ if (!worktreePath) {
5248
+ ctx.log(node.id, "action.detect_new_commits: worktreePath not set — skipping commit detection");
5249
+ return { success: false, error: "worktreePath required", hasCommits: false, hasNewCommits: false, unpushedCount: 0 };
5250
+ }
5102
5251
 
5103
5252
  // Read preExecHead from record-head node output or ctx
5104
5253
  const preExecHead = cfgOrCtx(node, ctx, "preExecHead")