bosun 0.41.0 → 0.41.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.
Files changed (64) hide show
  1. package/.env.example +8 -0
  2. package/README.md +20 -0
  3. package/agent/agent-event-bus.mjs +248 -6
  4. package/agent/agent-pool.mjs +125 -28
  5. package/agent/agent-work-analyzer.mjs +8 -16
  6. package/agent/retry-queue.mjs +164 -0
  7. package/bosun.config.example.json +25 -0
  8. package/bosun.schema.json +825 -183
  9. package/cli.mjs +59 -5
  10. package/config/config.mjs +130 -3
  11. package/infra/monitor.mjs +693 -67
  12. package/infra/runtime-accumulator.mjs +376 -84
  13. package/infra/session-tracker.mjs +82 -25
  14. package/lib/codebase-audit.mjs +133 -18
  15. package/package.json +23 -4
  16. package/server/setup-web-server.mjs +25 -0
  17. package/server/ui-server.mjs +248 -29
  18. package/setup.mjs +27 -24
  19. package/shell/codex-shell.mjs +34 -3
  20. package/shell/copilot-shell.mjs +50 -8
  21. package/task/msg-hub.mjs +193 -0
  22. package/task/pipeline.mjs +544 -0
  23. package/task/task-cli.mjs +38 -2
  24. package/task/task-executor-pipeline.mjs +143 -0
  25. package/task/task-executor.mjs +36 -27
  26. package/telegram/get-telegram-chat-id.mjs +57 -47
  27. package/ui/components/workspace-switcher.js +7 -7
  28. package/ui/demo-defaults.js +15694 -10573
  29. package/ui/modules/settings-schema.js +2 -0
  30. package/ui/modules/state.js +54 -57
  31. package/ui/modules/voice-client-sdk.js +375 -36
  32. package/ui/modules/voice-client.js +140 -31
  33. package/ui/setup.html +68 -2
  34. package/ui/styles/components.css +57 -0
  35. package/ui/styles.css +201 -1
  36. package/ui/tabs/dashboard.js +74 -0
  37. package/ui/tabs/logs.js +10 -0
  38. package/ui/tabs/settings.js +178 -99
  39. package/ui/tabs/tasks.js +31 -1
  40. package/ui/tabs/telemetry.js +34 -0
  41. package/ui/tabs/workflow-canvas-utils.mjs +8 -1
  42. package/ui/tabs/workflows.js +532 -275
  43. package/voice/voice-agents-sdk.mjs +1 -1
  44. package/voice/voice-relay.mjs +6 -6
  45. package/workflow/declarative-workflows.mjs +145 -0
  46. package/workflow/msg-hub.mjs +237 -0
  47. package/workflow/pipeline-workflows.mjs +287 -0
  48. package/workflow/pipeline.mjs +828 -315
  49. package/workflow/workflow-cli.mjs +128 -0
  50. package/workflow/workflow-engine.mjs +329 -17
  51. package/workflow/workflow-nodes/custom-loader.mjs +250 -0
  52. package/workflow/workflow-nodes.mjs +1955 -223
  53. package/workflow/workflow-templates.mjs +26 -8
  54. package/workflow-templates/agents.mjs +0 -1
  55. package/workflow-templates/bosun-native.mjs +212 -2
  56. package/workflow-templates/continuation-loop.mjs +339 -0
  57. package/workflow-templates/github.mjs +516 -40
  58. package/workflow-templates/planning.mjs +446 -17
  59. package/workflow-templates/reliability.mjs +65 -12
  60. package/workflow-templates/task-batch.mjs +24 -8
  61. package/workflow-templates/task-lifecycle.mjs +83 -6
  62. package/workspace/context-cache.mjs +66 -18
  63. package/workspace/workspace-manager.mjs +2 -1
  64. package/workflow-templates/issue-continuation.mjs +0 -243
@@ -0,0 +1,128 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { loadConfig } from "../config/config.mjs";
4
+ import {
5
+ listConfiguredWorkflows,
6
+ loadWorkflowInputFromFile,
7
+ runConfiguredWorkflow,
8
+ } from "./declarative-workflows.mjs";
9
+
10
+ function hasFlag(args, ...flags) {
11
+ return flags.some((flag) => args.includes(flag));
12
+ }
13
+
14
+ function getArgValue(args, flag) {
15
+ const direct = args.find((arg) => arg.startsWith(`${flag}=`));
16
+ if (direct) return direct.slice(flag.length + 1).trim();
17
+ const index = args.indexOf(flag);
18
+ if (index >= 0 && index + 1 < args.length) return String(args[index + 1] || "").trim();
19
+ return "";
20
+ }
21
+
22
+ export function parseWorkflowInput(rawValue, cwd = process.cwd()) {
23
+ const trimmed = String(rawValue || "").trim();
24
+ if (!trimmed) return "";
25
+ const fullPath = resolve(cwd, trimmed);
26
+ if (existsSync(fullPath)) {
27
+ const raw = readFileSync(fullPath, "utf8");
28
+ try {
29
+ return JSON.parse(raw);
30
+ } catch {
31
+ return raw;
32
+ }
33
+ }
34
+ try {
35
+ return JSON.parse(trimmed);
36
+ } catch {
37
+ return trimmed;
38
+ }
39
+ }
40
+
41
+ function parseInput(args, cwd = process.cwd()) {
42
+ const inputFile = getArgValue(args, "--file");
43
+ if (inputFile) return loadWorkflowInputFromFile(inputFile);
44
+ const inlineJson = getArgValue(args, "--input-json");
45
+ if (inlineJson) return JSON.parse(inlineJson);
46
+ const inputText = getArgValue(args, "--input");
47
+ if (inputText) return parseWorkflowInput(inputText, cwd);
48
+ const positional = args.filter((arg) => !arg.startsWith("--"));
49
+ return positional.length > 2 ? positional.slice(2).join(" ") : "";
50
+ }
51
+
52
+ function showHelp(stdout = console.log) {
53
+ stdout(`
54
+ bosun workflow — Declarative multi-agent workflows
55
+
56
+ SUBCOMMANDS
57
+ list List configured and built-in workflows
58
+ run <name> [input] Run a workflow with fresh-context agents
59
+
60
+ OPTIONS
61
+ --json Emit JSON output
62
+ --dry-run Render prompts without executing agents
63
+ --input <text> Inline workflow input
64
+ --input-json <json> Structured JSON input
65
+ --file <path> Load workflow input from a file
66
+ `);
67
+ }
68
+
69
+ export function listWorkflowSummaries(config = loadConfig(process.argv)) {
70
+ return listConfiguredWorkflows(config);
71
+ }
72
+
73
+ export async function executeWorkflowCommand(args, options = {}) {
74
+ const normalizedArgs = Array.isArray(args) && args[0] === "workflow" ? args.slice(1) : args;
75
+ const subcommand = normalizedArgs?.[0] || "list";
76
+ const stdout = options.stdout || ((line) => console.log(line));
77
+ if (hasFlag(normalizedArgs, "--help", "-h") || subcommand === "help") {
78
+ showHelp(stdout);
79
+ return { ok: true, command: "help" };
80
+ }
81
+
82
+ const config = options.config || loadConfig(process.argv);
83
+ const asJson = hasFlag(normalizedArgs, "--json") || options.json === true;
84
+ if (subcommand === "list") {
85
+ const workflows = listConfiguredWorkflows(config);
86
+ if (asJson) {
87
+ stdout(JSON.stringify(workflows, null, 2));
88
+ } else {
89
+ for (const workflow of workflows) {
90
+ stdout(`${workflow.id}\t${workflow.type}\t${workflow.description}`);
91
+ }
92
+ }
93
+ return { ok: true, command: "list", workflows };
94
+ }
95
+
96
+ if (subcommand === "run") {
97
+ const name = normalizedArgs[1];
98
+ if (!name) throw new Error("Workflow name is required. Usage: bosun workflow run <name>");
99
+ const input = parseInput(normalizedArgs, options.cwd || process.cwd());
100
+ const result = await runConfiguredWorkflow(name, input, {
101
+ config,
102
+ dryRun: hasFlag(normalizedArgs, "--dry-run"),
103
+ services: options.services,
104
+ runOptions: options.runOptions,
105
+ });
106
+ if (asJson || options.forceJsonOutput === true) {
107
+ stdout(JSON.stringify(result, null, 2));
108
+ } else {
109
+ stdout(`workflow=${result.workflow.id} status=${result.status} outputs=${result.outputs.length} errors=${result.errors.length}`);
110
+ for (const output of result.outputs) {
111
+ const summary = String(output.summary || output.output || "").slice(0, 160);
112
+ stdout(`- ${output.agentId}: ${summary}`);
113
+ }
114
+ if (result.consensus?.text) {
115
+ stdout(`consensus=${result.consensus.text}`);
116
+ }
117
+ }
118
+ return { ok: true, command: "run", workflowName: name, result };
119
+ }
120
+
121
+ throw new Error(`Unknown workflow subcommand: ${subcommand}`);
122
+ }
123
+
124
+ export async function runWorkflowCli(args, options = {}) {
125
+ return executeWorkflowCommand(["workflow", ...(Array.isArray(args) ? args : [])], options);
126
+ }
127
+
128
+ export default runWorkflowCli;
@@ -34,7 +34,6 @@
34
34
  */
35
35
 
36
36
  import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, unlinkSync, statSync } from "node:fs";
37
- import { writeFile as writeFileAsync } from "node:fs/promises";
38
37
  import { resolve, basename, extname, join } from "node:path";
39
38
  import { randomUUID } from "node:crypto";
40
39
  import { EventEmitter } from "node:events";
@@ -170,17 +169,243 @@ export const WorkflowStatus = Object.freeze({
170
169
  // ── Node Type Registry ──────────────────────────────────────────────────────
171
170
 
172
171
  const _nodeTypeRegistry = new Map();
172
+ const _nodeTypeMetaRegistry = new Map();
173
+ const _normalizedHandlerCache = new WeakMap();
174
+
175
+ function clonePortDescriptor(port) {
176
+ if (!port || typeof port !== "object") return null;
177
+ return {
178
+ name: String(port.name || "default").trim() || "default",
179
+ label: String(port.label || port.name || "default").trim() || "default",
180
+ type: String(port.type || "Any").trim() || "Any",
181
+ description: String(port.description || "").trim(),
182
+ color: typeof port.color === "string" && port.color.trim() ? port.color.trim() : null,
183
+ accepts: Array.isArray(port.accepts)
184
+ ? Array.from(new Set(port.accepts.map((value) => String(value || "").trim()).filter(Boolean)))
185
+ : [],
186
+ };
187
+ }
188
+
189
+ function normalizePortDescriptor(port, direction, index) {
190
+ const fallbackName = index === 0 ? "default" : `${direction}-${index + 1}`;
191
+ if (typeof port === "string") {
192
+ return {
193
+ name: fallbackName,
194
+ label: fallbackName,
195
+ type: port,
196
+ description: "",
197
+ color: null,
198
+ accepts: [],
199
+ };
200
+ }
201
+
202
+ if (!port || typeof port !== "object") {
203
+ return {
204
+ name: fallbackName,
205
+ label: fallbackName,
206
+ type: "Any",
207
+ description: "",
208
+ color: null,
209
+ accepts: [],
210
+ };
211
+ }
212
+
213
+ return clonePortDescriptor({
214
+ ...port,
215
+ name: port.name || fallbackName,
216
+ label: port.label || port.name || fallbackName,
217
+ type: port.type || "Any",
218
+ });
219
+ }
220
+
221
+ function normalizePortList(ports, direction) {
222
+ if (!Array.isArray(ports)) return [];
223
+ return ports
224
+ .map((port, index) => normalizePortDescriptor(port, direction, index))
225
+ .filter(Boolean);
226
+ }
227
+
228
+ function normalizeNodeUi(ui = {}) {
229
+ const primaryFields = Array.isArray(ui?.primaryFields)
230
+ ? Array.from(new Set(ui.primaryFields.map((value) => String(value || "").trim()).filter(Boolean)))
231
+ : [];
232
+ return {
233
+ ...ui,
234
+ primaryFields,
235
+ };
236
+ }
237
+
238
+ function normalizeHandlerMetadata(handler) {
239
+ if (_normalizedHandlerCache.has(handler)) {
240
+ return _normalizedHandlerCache.get(handler);
241
+ }
242
+
243
+ const inputPorts = normalizePortList(handler?.inputs ?? handler?.ports?.inputs, "input");
244
+ const outputPorts = normalizePortList(handler?.outputs ?? handler?.ports?.outputs, "output");
245
+
246
+ const normalized = {
247
+ ...handler,
248
+ ports: {
249
+ inputs: inputPorts,
250
+ outputs: outputPorts,
251
+ },
252
+ ui: normalizeNodeUi(handler?.ui),
253
+ };
254
+ _normalizedHandlerCache.set(handler, normalized);
255
+ return normalized;
256
+ }
257
+
258
+ function resolveNodePorts(node) {
259
+ const handler = node?.type ? _nodeTypeRegistry.get(node.type) : null;
260
+ const handlerPorts = handler?.ports || {};
261
+ const inputPorts = normalizePortList(node?.inputPorts, "input");
262
+ const outputPorts = normalizePortList(node?.outputPorts, "output");
263
+
264
+ return {
265
+ inputs: inputPorts.length > 0 ? inputPorts : normalizePortList(handlerPorts.inputs, "input"),
266
+ outputs: outputPorts.length > 0 ? outputPorts : normalizePortList(handlerPorts.outputs, "output"),
267
+ };
268
+ }
269
+
270
+ function resolvePortByName(ports, requestedName, direction) {
271
+ if (!Array.isArray(ports) || ports.length === 0) return null;
272
+ const normalizedName = String(requestedName || "").trim();
273
+ if (!normalizedName) return ports[0];
274
+ const matched = ports.find((port) => port.name === normalizedName);
275
+ if (matched) return matched;
276
+ return normalizePortDescriptor({
277
+ name: normalizedName,
278
+ label: normalizedName,
279
+ type: "Any",
280
+ }, direction, 0);
281
+ }
282
+
283
+ function isWildcardPortType(type) {
284
+ const normalized = String(type || "").trim();
285
+ return normalized === "*" || normalized === "Any";
286
+ }
287
+
288
+ export function isPortConnectionCompatible(sourcePort, targetPort) {
289
+ if (!sourcePort || !targetPort) {
290
+ return { compatible: true, reason: null };
291
+ }
292
+
293
+ const sourceType = String(sourcePort.type || "Any").trim() || "Any";
294
+ const targetType = String(targetPort.type || "Any").trim() || "Any";
295
+ const accepted = new Set(
296
+ [targetType, ...(Array.isArray(targetPort.accepts) ? targetPort.accepts : [])]
297
+ .map((value) => String(value || "").trim())
298
+ .filter(Boolean),
299
+ );
300
+
301
+ if (isWildcardPortType(sourceType) || isWildcardPortType(targetType) || accepted.has("*") || accepted.has("Any")) {
302
+ return { compatible: true, reason: null };
303
+ }
304
+
305
+ if (sourceType === targetType || accepted.has(sourceType)) {
306
+ return { compatible: true, reason: null };
307
+ }
308
+
309
+ return {
310
+ compatible: false,
311
+ reason: `${sourcePort.label || sourcePort.name} emits ${sourceType}, but ${targetPort.label || targetPort.name} expects ${targetType}`,
312
+ };
313
+ }
314
+
315
+ function hydrateWorkflowDefinition(def, { strict = false } = {}) {
316
+ const normalized = {
317
+ ...(def || {}),
318
+ nodes: Array.isArray(def?.nodes) ? def.nodes.map((node) => ({ ...node })) : [],
319
+ edges: Array.isArray(def?.edges) ? def.edges.map((edge) => ({ ...edge })) : [],
320
+ metadata: { ...(def?.metadata || {}) },
321
+ };
322
+
323
+ const nodeMap = new Map();
324
+ normalized.nodes = normalized.nodes.map((node) => {
325
+ const ports = resolveNodePorts(node);
326
+ const explicitOutputs = Array.isArray(node?.outputs)
327
+ ? Array.from(new Set(node.outputs.map((value) => String(value || "").trim()).filter(Boolean)))
328
+ : undefined;
329
+ const nextNode = {
330
+ ...node,
331
+ inputPorts: ports.inputs.map((port) => clonePortDescriptor(port)),
332
+ outputPorts: ports.outputs.map((port) => clonePortDescriptor(port)),
333
+ ...(explicitOutputs !== undefined ? { outputs: explicitOutputs } : {}),
334
+ };
335
+ nodeMap.set(nextNode.id, nextNode);
336
+ return nextNode;
337
+ });
338
+
339
+ const issues = [];
340
+
341
+ normalized.edges = normalized.edges.map((edge) => {
342
+ const sourceNode = nodeMap.get(edge.source);
343
+ const targetNode = nodeMap.get(edge.target);
344
+ const sourcePorts = resolveNodePorts(sourceNode);
345
+ const targetPorts = resolveNodePorts(targetNode);
346
+ const sourcePort = resolvePortByName(sourcePorts.outputs, edge.sourcePort || "default", "output");
347
+ const targetPort = resolvePortByName(targetPorts.inputs, edge.targetPort || "default", "input");
348
+ const compatibility = isPortConnectionCompatible(sourcePort, targetPort);
349
+
350
+ if (!compatibility.compatible) {
351
+ issues.push({
352
+ edgeId: edge.id || `${edge.source}->${edge.target}`,
353
+ source: edge.source,
354
+ target: edge.target,
355
+ sourcePort: sourcePort?.name || "default",
356
+ targetPort: targetPort?.name || "default",
357
+ sourceType: sourcePort?.type || null,
358
+ targetType: targetPort?.type || null,
359
+ severity: "error",
360
+ message: compatibility.reason,
361
+ });
362
+ }
363
+
364
+ return {
365
+ ...edge,
366
+ sourcePort: sourcePort?.name || String(edge.sourcePort || "default").trim() || "default",
367
+ targetPort: targetPort?.name || String(edge.targetPort || "default").trim() || "default",
368
+ sourcePortType: sourcePort?.type || null,
369
+ targetPortType: targetPort?.type || null,
370
+ };
371
+ });
372
+
373
+ normalized.metadata.validationIssues = issues;
374
+
375
+ if (strict && issues.length > 0) {
376
+ throw new Error(`Workflow port validation failed: ${issues.map((issue) => issue.message).join("; ")}`);
377
+ }
378
+
379
+ return normalized;
380
+ }
173
381
 
174
382
  /**
175
383
  * Register a node type handler.
176
384
  * @param {string} type - Node type identifier (e.g., "trigger.task_low", "action.run_agent")
177
385
  * @param {object} handler - { execute(node, context, engine), validate?(node), describe?() }
178
386
  */
179
- export function registerNodeType(type, handler) {
387
+ export function registerNodeType(type, handler, options = {}) {
180
388
  if (!handler || typeof handler.execute !== "function") {
181
389
  throw new Error(`${TAG} Node type "${type}" must have an execute function`);
182
390
  }
183
- _nodeTypeRegistry.set(type, handler);
391
+ const normalized = normalizeHandlerMetadata(handler);
392
+ _nodeTypeRegistry.set(type, normalized);
393
+ _nodeTypeMetaRegistry.set(type, {
394
+ source: String(options.source || handler.source || "builtin"),
395
+ badge: options.badge || handler.badge || null,
396
+ isCustom: options.isCustom === true || handler.isCustom === true || String(options.source || handler.source || "").toLowerCase() === "custom",
397
+ filePath: options.filePath || handler.filePath || null,
398
+ inputs: Array.isArray(options.inputs)
399
+ ? options.inputs
400
+ : Array.isArray(handler.inputs)
401
+ ? handler.inputs
402
+ : (normalized.ports?.inputs || []).map((port) => port?.name || "default"),
403
+ outputs: Array.isArray(options.outputs)
404
+ ? options.outputs
405
+ : Array.isArray(handler.outputs)
406
+ ? handler.outputs
407
+ : (normalized.ports?.outputs || []).map((port) => port?.name || "default"),
408
+ });
184
409
  }
185
410
 
186
411
  /**
@@ -192,6 +417,15 @@ export function getNodeType(type) {
192
417
  return _nodeTypeRegistry.get(type) || null;
193
418
  }
194
419
 
420
+ export function getNodeTypeMeta(type) {
421
+ return _nodeTypeMetaRegistry.get(type) || null;
422
+ }
423
+
424
+ export function unregisterNodeType(type) {
425
+ _nodeTypeRegistry.delete(type);
426
+ _nodeTypeMetaRegistry.delete(type);
427
+ }
428
+
195
429
  /**
196
430
  * List all registered node types with metadata.
197
431
  * @returns {Array<{type: string, category: string, description: string}>}
@@ -200,11 +434,23 @@ export function listNodeTypes() {
200
434
  const result = [];
201
435
  for (const [type, handler] of _nodeTypeRegistry) {
202
436
  const [category] = type.split(".");
437
+ const metadata = _nodeTypeMetaRegistry.get(type) || {};
203
438
  result.push({
204
439
  type,
205
440
  category,
206
441
  description: handler.describe?.() || type,
207
442
  schema: handler.schema || null,
443
+ source: metadata.source || "builtin",
444
+ badge: metadata.badge || null,
445
+ isCustom: metadata.isCustom === true,
446
+ filePath: metadata.filePath || null,
447
+ inputs: Array.isArray(metadata.inputs) ? [...metadata.inputs] : [],
448
+ outputs: Array.isArray(metadata.outputs) ? [...metadata.outputs] : [],
449
+ ports: {
450
+ inputs: (handler.ports?.inputs || []).map((port) => clonePortDescriptor(port)),
451
+ outputs: (handler.ports?.outputs || []).map((port) => clonePortDescriptor(port)),
452
+ },
453
+ ui: normalizeNodeUi(handler.ui),
208
454
  });
209
455
  }
210
456
  return result;
@@ -689,7 +935,7 @@ export class WorkflowEngine extends EventEmitter {
689
935
  for (const file of files) {
690
936
  try {
691
937
  const raw = readFileSync(resolve(this.workflowDir, file), "utf8");
692
- const def = JSON.parse(raw);
938
+ const def = hydrateWorkflowDefinition(JSON.parse(raw));
693
939
  if (def.id) {
694
940
  this._workflows.set(def.id, def);
695
941
  }
@@ -741,6 +987,7 @@ export class WorkflowEngine extends EventEmitter {
741
987
 
742
988
  /** Save (create or update) a workflow definition */
743
989
  save(def) {
990
+ def = hydrateWorkflowDefinition(def, { strict: true });
744
991
  if (!def.id) def.id = randomUUID();
745
992
  if (!def.metadata) def.metadata = {};
746
993
  def.metadata.updatedAt = new Date().toISOString();
@@ -887,6 +1134,59 @@ export class WorkflowEngine extends EventEmitter {
887
1134
  }
888
1135
  }
889
1136
 
1137
+ /**
1138
+ * Execute an ephemeral workflow definition without saving it to the registry.
1139
+ * Useful for inline/embedded workflow composition where the child flow should
1140
+ * have its own run/context history but not become an installed workflow.
1141
+ *
1142
+ * @param {object} workflowDef
1143
+ * @param {object} inputData
1144
+ * @param {object} [opts]
1145
+ * @returns {Promise<WorkflowContext>}
1146
+ */
1147
+ async executeDefinition(workflowDef, inputData = {}, opts = {}) {
1148
+ const requestedId = String(workflowDef?.id || opts.inlineWorkflowId || "").trim();
1149
+ const workflowId = requestedId || `inline:${randomUUID()}`;
1150
+ const normalized = hydrateWorkflowDefinition({
1151
+ enabled: true,
1152
+ trigger: workflowDef?.trigger || "trigger.workflow_call",
1153
+ ...workflowDef,
1154
+ id: workflowId,
1155
+ name: String(workflowDef?.name || opts.inlineWorkflowName || workflowId).trim() || workflowId,
1156
+ metadata: {
1157
+ ...(workflowDef?.metadata || {}),
1158
+ inline: true,
1159
+ ephemeral: true,
1160
+ sourceNodeId: opts.sourceNodeId || workflowDef?.metadata?.sourceNodeId || null,
1161
+ },
1162
+ }, { strict: true });
1163
+
1164
+ if (normalized.enabled === false && !opts.force) {
1165
+ throw new Error(`${TAG} Inline workflow "${normalized.name}" is disabled`);
1166
+ }
1167
+
1168
+ if (this._runSlots >= MAX_CONCURRENT_RUNS) {
1169
+ this.emit("run:queued", { workflowId: normalized.id, name: normalized.name, queueDepth: this._runQueue.length + 1 });
1170
+ await new Promise((resolve, reject) => {
1171
+ this._runQueue.push({ resolve, reject });
1172
+ });
1173
+ }
1174
+ this._runSlots++;
1175
+
1176
+ try {
1177
+ return await this._executeInner(normalized, normalized.id, inputData, {
1178
+ ...opts,
1179
+ force: true,
1180
+ });
1181
+ } finally {
1182
+ this._runSlots--;
1183
+ if (this._runQueue.length > 0) {
1184
+ const next = this._runQueue.shift();
1185
+ next.resolve();
1186
+ }
1187
+ }
1188
+ }
1189
+
890
1190
  /**
891
1191
  * Inner execute logic — called only once a concurrency slot is acquired.
892
1192
  * @private
@@ -1392,14 +1692,25 @@ export class WorkflowEngine extends EventEmitter {
1392
1692
  || n.type === "trigger.task_low",
1393
1693
  );
1394
1694
 
1695
+ const scheduleCtx = new WorkflowContext({
1696
+ ...(def.variables || {}),
1697
+ ...(def.data || {}),
1698
+ });
1699
+
1700
+ const resolvePositiveInterval = (rawValue, fallbackMs) => {
1701
+ const resolved = scheduleCtx.resolve(rawValue);
1702
+ const parsed = Number(resolved);
1703
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallbackMs;
1704
+ };
1705
+
1395
1706
  for (const tNode of triggerNodes) {
1396
1707
  let intervalMs = 3600000;
1397
1708
  if (tNode.type === "trigger.task_available") {
1398
- intervalMs = Number(tNode.config?.pollIntervalMs) || 30000;
1709
+ intervalMs = resolvePositiveInterval(tNode.config?.pollIntervalMs, 30000);
1399
1710
  } else if (tNode.type === "trigger.task_low") {
1400
- intervalMs = Number(tNode.config?.pollIntervalMs) || 60000;
1711
+ intervalMs = resolvePositiveInterval(tNode.config?.pollIntervalMs, 60000);
1401
1712
  } else {
1402
- intervalMs = Number(tNode.config?.intervalMs) || 3600000;
1713
+ intervalMs = resolvePositiveInterval(tNode.config?.intervalMs, 3600000);
1403
1714
  }
1404
1715
 
1405
1716
  // Find the most recent completed run for this workflow
@@ -2592,8 +2903,7 @@ export class WorkflowEngine extends EventEmitter {
2592
2903
 
2593
2904
  // Write initial detail file so we can resume from it
2594
2905
  const detail = this._serializeRunContext(ctx, true);
2595
- const detailPath = resolve(this.runsDir, `${runId}.json`);
2596
- writeFileSync(detailPath, JSON.stringify(detail, null, 2), "utf8");
2906
+ this._writeRunDetail(runId, detail);
2597
2907
 
2598
2908
  // Also ensure the run appears in the main index (with RUNNING status)
2599
2909
  // so that getRunDetail() can find it even before completion.
@@ -2617,13 +2927,12 @@ export class WorkflowEngine extends EventEmitter {
2617
2927
  const timer = setTimeout(() => {
2618
2928
  this._checkpointTimers.delete(runId);
2619
2929
  try {
2930
+ // If the run has already been finalized/removed, skip writing a
2931
+ // late checkpoint snapshot that could overwrite terminal detail.
2932
+ if (!this._activeRuns.has(runId)) return;
2620
2933
  this._ensureDirs();
2621
2934
  const detail = this._serializeRunContext(ctx, true);
2622
- const detailPath = resolve(this.runsDir, `${runId}.json`);
2623
- // Async write — checkpoint is fire-and-forget, no need to block event loop
2624
- writeFileAsync(detailPath, JSON.stringify(detail, null, 2), "utf8").catch((err) => {
2625
- console.error(`${TAG} Checkpoint write failed for run ${runId}:`, err.message);
2626
- });
2935
+ this._writeRunDetail(runId, detail);
2627
2936
  } catch (err) {
2628
2937
  console.error(`${TAG} Checkpoint failed for run ${runId}:`, err.message);
2629
2938
  }
@@ -2984,12 +3293,16 @@ export class WorkflowEngine extends EventEmitter {
2984
3293
  writeFileSync(indexPath, JSON.stringify({ runs }, null, 2), "utf8");
2985
3294
 
2986
3295
  // Save full run detail
2987
- const detailPath = resolve(this.runsDir, `${runId}.json`);
2988
- writeFileSync(detailPath, JSON.stringify(detail, null, 2), "utf8");
3296
+ this._writeRunDetail(runId, detail);
2989
3297
  } catch (err) {
2990
3298
  console.error(`${TAG} Failed to persist run log:`, err.message);
2991
3299
  }
2992
3300
  }
3301
+
3302
+ _writeRunDetail(runId, detail) {
3303
+ const detailPath = resolve(this.runsDir, `${runId}.json`);
3304
+ writeFileSync(detailPath, JSON.stringify(detail, null, 2), "utf8");
3305
+ }
2993
3306
  }
2994
3307
 
2995
3308
  // ── Module-level convenience functions ──────────────────────────────────────
@@ -3055,4 +3368,3 @@ export function listWorkflows(opts) { return getWorkflowEngine(opts).list(); }
3055
3368
  export function getWorkflow(id, opts) { return getWorkflowEngine(opts).get(id); }
3056
3369
  export async function executeWorkflow(id, data, opts) { return getWorkflowEngine(opts).execute(id, data, opts); }
3057
3370
  export async function retryWorkflowRun(runId, retryOpts, engineOpts) { return getWorkflowEngine(engineOpts).retryRun(runId, retryOpts); }
3058
-