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/.env.example +4 -1
- package/agent-tool-config.mjs +14 -3
- package/bosun-skills.mjs +59 -4
- package/desktop/launch.mjs +18 -0
- package/desktop/main.mjs +52 -13
- package/fleet-coordinator.mjs +34 -1
- package/kanban-adapter.mjs +30 -3
- package/library-manager.mjs +48 -0
- package/maintenance.mjs +30 -5
- package/monitor.mjs +56 -0
- package/package.json +2 -1
- package/setup-web-server.mjs +71 -10
- package/ui/app.js +40 -3
- package/ui/components/session-list.js +25 -7
- package/ui/components/workspace-switcher.js +48 -1
- package/ui/demo.html +110 -0
- package/ui/modules/mic-track-registry.js +83 -0
- package/ui/modules/settings-schema.js +3 -0
- package/ui/modules/state.js +25 -0
- package/ui/modules/streaming.js +1 -1
- package/ui/modules/voice-barge-in.js +27 -0
- package/ui/modules/voice-client-sdk.js +260 -38
- package/ui/modules/voice-client.js +662 -58
- package/ui/modules/voice-overlay.js +829 -47
- package/ui/setup.html +151 -9
- package/ui/styles.css +258 -0
- package/ui/tabs/chat.js +11 -0
- package/ui/tabs/library.js +219 -9
- package/ui/tabs/settings.js +51 -11
- package/ui/tabs/telemetry.js +327 -105
- package/ui/tabs/workflows.js +22 -5
- package/ui-server.mjs +961 -103
- package/voice-relay.mjs +119 -11
- package/workflow-engine.mjs +54 -0
- package/workflow-nodes.mjs +177 -28
- package/workflow-templates/github.mjs +205 -94
- package/workflow-templates/task-batch.mjs +247 -0
- package/workflow-templates.mjs +15 -0
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
|
|
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 (
|
|
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:
|
|
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
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
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:
|
|
1243
|
-
output_audio_transcription: { model: REALTIME_TRANSCRIBE_MODEL },
|
|
1325
|
+
...(transcriptionEnabled ? { input_audio_transcription: { model: transcriptionModel } } : {}),
|
|
1244
1326
|
tools: toolDefinitions,
|
|
1245
1327
|
};
|
|
1246
1328
|
|
|
1247
|
-
|
|
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
|
-
|
|
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,
|
package/workflow-engine.mjs
CHANGED
|
@@ -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())
|
package/workflow-nodes.mjs
CHANGED
|
@@ -798,7 +798,9 @@ registerNodeType("trigger.manual", {
|
|
|
798
798
|
});
|
|
799
799
|
|
|
800
800
|
registerNodeType("trigger.task_low", {
|
|
801
|
-
describe: () =>
|
|
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
|
|
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: () =>
|
|
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
|
|
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: "
|
|
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
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
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: () =>
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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")
|