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.
- package/.env.example +8 -0
- package/README.md +20 -0
- package/agent/agent-event-bus.mjs +248 -6
- package/agent/agent-pool.mjs +125 -28
- package/agent/agent-work-analyzer.mjs +8 -16
- package/agent/retry-queue.mjs +164 -0
- package/bosun.config.example.json +25 -0
- package/bosun.schema.json +825 -183
- package/cli.mjs +59 -5
- package/config/config.mjs +130 -3
- package/infra/monitor.mjs +693 -67
- package/infra/runtime-accumulator.mjs +376 -84
- package/infra/session-tracker.mjs +82 -25
- package/lib/codebase-audit.mjs +133 -18
- package/package.json +23 -4
- package/server/setup-web-server.mjs +25 -0
- package/server/ui-server.mjs +248 -29
- package/setup.mjs +27 -24
- package/shell/codex-shell.mjs +34 -3
- package/shell/copilot-shell.mjs +50 -8
- package/task/msg-hub.mjs +193 -0
- package/task/pipeline.mjs +544 -0
- package/task/task-cli.mjs +38 -2
- package/task/task-executor-pipeline.mjs +143 -0
- package/task/task-executor.mjs +36 -27
- package/telegram/get-telegram-chat-id.mjs +57 -47
- package/ui/components/workspace-switcher.js +7 -7
- package/ui/demo-defaults.js +15694 -10573
- package/ui/modules/settings-schema.js +2 -0
- package/ui/modules/state.js +54 -57
- package/ui/modules/voice-client-sdk.js +375 -36
- package/ui/modules/voice-client.js +140 -31
- package/ui/setup.html +68 -2
- package/ui/styles/components.css +57 -0
- package/ui/styles.css +201 -1
- package/ui/tabs/dashboard.js +74 -0
- package/ui/tabs/logs.js +10 -0
- package/ui/tabs/settings.js +178 -99
- package/ui/tabs/tasks.js +31 -1
- package/ui/tabs/telemetry.js +34 -0
- package/ui/tabs/workflow-canvas-utils.mjs +8 -1
- package/ui/tabs/workflows.js +532 -275
- package/voice/voice-agents-sdk.mjs +1 -1
- package/voice/voice-relay.mjs +6 -6
- package/workflow/declarative-workflows.mjs +145 -0
- package/workflow/msg-hub.mjs +237 -0
- package/workflow/pipeline-workflows.mjs +287 -0
- package/workflow/pipeline.mjs +828 -315
- package/workflow/workflow-cli.mjs +128 -0
- package/workflow/workflow-engine.mjs +329 -17
- package/workflow/workflow-nodes/custom-loader.mjs +250 -0
- package/workflow/workflow-nodes.mjs +1955 -223
- package/workflow/workflow-templates.mjs +26 -8
- package/workflow-templates/agents.mjs +0 -1
- package/workflow-templates/bosun-native.mjs +212 -2
- package/workflow-templates/continuation-loop.mjs +339 -0
- package/workflow-templates/github.mjs +516 -40
- package/workflow-templates/planning.mjs +446 -17
- package/workflow-templates/reliability.mjs +65 -12
- package/workflow-templates/task-batch.mjs +24 -8
- package/workflow-templates/task-lifecycle.mjs +83 -6
- package/workspace/context-cache.mjs +66 -18
- package/workspace/workspace-manager.mjs +2 -1
- 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
|
-
|
|
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 =
|
|
1709
|
+
intervalMs = resolvePositiveInterval(tNode.config?.pollIntervalMs, 30000);
|
|
1399
1710
|
} else if (tNode.type === "trigger.task_low") {
|
|
1400
|
-
intervalMs =
|
|
1711
|
+
intervalMs = resolvePositiveInterval(tNode.config?.pollIntervalMs, 60000);
|
|
1401
1712
|
} else {
|
|
1402
|
-
intervalMs =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|