@duckflux/core 0.6.8
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/dist/cel/index.js +12010 -0
- package/dist/engine/index.js +27937 -0
- package/dist/eventhub/index.js +151 -0
- package/dist/index.js +28044 -0
- package/dist/parser/index.js +26765 -0
- package/package.json +48 -0
- package/src/cel/index.ts +156 -0
- package/src/engine/control.ts +169 -0
- package/src/engine/engine.ts +127 -0
- package/src/engine/errors.ts +90 -0
- package/src/engine/index.ts +8 -0
- package/src/engine/output.ts +109 -0
- package/src/engine/sequential.ts +379 -0
- package/src/engine/state.ts +185 -0
- package/src/engine/timeout.ts +43 -0
- package/src/engine/wait.ts +102 -0
- package/src/eventhub/index.ts +24 -0
- package/src/eventhub/memory.ts +106 -0
- package/src/eventhub/types.ts +17 -0
- package/src/index.ts +51 -0
- package/src/model/index.ts +183 -0
- package/src/parser/index.ts +4 -0
- package/src/parser/parser.ts +13 -0
- package/src/parser/schema/duckflux.schema.json +573 -0
- package/src/parser/schema.ts +26 -0
- package/src/parser/validate.ts +541 -0
- package/src/parser/validate_inputs.ts +187 -0
- package/src/participant/emit.ts +63 -0
- package/src/participant/exec.ts +158 -0
- package/src/participant/http.ts +45 -0
- package/src/participant/index.ts +61 -0
- package/src/participant/mcp.ts +8 -0
- package/src/participant/workflow.ts +73 -0
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@duckflux/core",
|
|
3
|
+
"version": "0.6.8",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"bun": "./src/index.ts",
|
|
9
|
+
"import": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts"
|
|
11
|
+
},
|
|
12
|
+
"./parser": {
|
|
13
|
+
"bun": "./src/parser/index.ts",
|
|
14
|
+
"import": "./dist/parser/index.js",
|
|
15
|
+
"types": "./dist/parser/index.d.ts"
|
|
16
|
+
},
|
|
17
|
+
"./cel": {
|
|
18
|
+
"bun": "./src/cel/index.ts",
|
|
19
|
+
"import": "./dist/cel/index.js",
|
|
20
|
+
"types": "./dist/cel/index.d.ts"
|
|
21
|
+
},
|
|
22
|
+
"./engine": {
|
|
23
|
+
"bun": "./src/engine/index.ts",
|
|
24
|
+
"import": "./dist/engine/index.js",
|
|
25
|
+
"types": "./dist/engine/index.d.ts"
|
|
26
|
+
},
|
|
27
|
+
"./eventhub": {
|
|
28
|
+
"bun": "./src/eventhub/index.ts",
|
|
29
|
+
"import": "./dist/eventhub/index.js",
|
|
30
|
+
"types": "./dist/eventhub/index.d.ts"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"dist",
|
|
35
|
+
"src"
|
|
36
|
+
],
|
|
37
|
+
"scripts": {
|
|
38
|
+
"test": "bun test",
|
|
39
|
+
"build": "bun build src/index.ts --outdir dist --target node --format esm && tsc --project tsconfig.build.json",
|
|
40
|
+
"prepublishOnly": "bun run build"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"ajv": "^8.12.0",
|
|
44
|
+
"ajv-formats": "^2.1.1",
|
|
45
|
+
"cel-js": "^0.8.2",
|
|
46
|
+
"yaml": "^2.2.1"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/cel/index.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { evaluate, parse } from "cel-js";
|
|
2
|
+
|
|
3
|
+
// Expression cache: parsed AST reuse
|
|
4
|
+
const expressionCache = new Map<string, ReturnType<typeof parse>>();
|
|
5
|
+
|
|
6
|
+
function getParsed(expr: string): ReturnType<typeof parse> {
|
|
7
|
+
let cached = expressionCache.get(expr);
|
|
8
|
+
if (!cached) {
|
|
9
|
+
cached = parse(expr);
|
|
10
|
+
expressionCache.set(expr, cached);
|
|
11
|
+
}
|
|
12
|
+
return cached;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function validateCelExpression(expr: string): { valid: boolean; error?: string } {
|
|
16
|
+
const parsed = getParsed(expr);
|
|
17
|
+
if (parsed.isSuccess) {
|
|
18
|
+
return { valid: true };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
valid: false,
|
|
23
|
+
error: parsed.errors.join("; "),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Custom functions registered as cel-js macros (third parameter to evaluate).
|
|
28
|
+
// These are available as function-style calls in CEL expressions: e.g. timestamp("..."), matches("...", "...").
|
|
29
|
+
const customFunctions: Record<string, CallableFunction> = {
|
|
30
|
+
// Timestamp: converts ISO string to epoch seconds
|
|
31
|
+
timestamp: (s: string) => {
|
|
32
|
+
const d = new Date(s);
|
|
33
|
+
if (Number.isNaN(d.getTime())) throw new Error(`invalid timestamp: ${s}`);
|
|
34
|
+
return Math.floor(d.getTime() / 1000);
|
|
35
|
+
},
|
|
36
|
+
// Duration: converts duration string to seconds
|
|
37
|
+
duration: (s: string) => {
|
|
38
|
+
const match = s.match(/^(\d+)(ms|s|m|h|d)$/);
|
|
39
|
+
if (!match) throw new Error(`invalid duration: ${s}`);
|
|
40
|
+
const val = Number(match[1]);
|
|
41
|
+
switch (match[2]) {
|
|
42
|
+
case "ms": return val / 1000;
|
|
43
|
+
case "s": return val;
|
|
44
|
+
case "m": return val * 60;
|
|
45
|
+
case "h": return val * 3600;
|
|
46
|
+
case "d": return val * 86400;
|
|
47
|
+
default: throw new Error(`invalid duration unit: ${match[2]}`);
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
// CEL standard library — string functions (§11.1)
|
|
51
|
+
matches: (s: string, pattern: string) => new RegExp(pattern).test(s),
|
|
52
|
+
lowerAscii: (s: string) => String(s).toLowerCase(),
|
|
53
|
+
upperAscii: (s: string) => String(s).toUpperCase(),
|
|
54
|
+
replace: (s: string, old: string, replacement: string) =>
|
|
55
|
+
String(s).split(old).join(replacement),
|
|
56
|
+
split: (s: string, sep: string) => String(s).split(sep),
|
|
57
|
+
join: (list: unknown[], sep: string) => list.join(sep ?? ","),
|
|
58
|
+
contains: (s: string, substr: string) => String(s).includes(substr),
|
|
59
|
+
startsWith: (s: string, prefix: string) => String(s).startsWith(prefix),
|
|
60
|
+
endsWith: (s: string, suffix: string) => String(s).endsWith(suffix),
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Names of custom functions (for filtering in error messages)
|
|
64
|
+
const CUSTOM_FUNCTION_NAMES = new Set(Object.keys(customFunctions));
|
|
65
|
+
|
|
66
|
+
export function evaluateCel(expr: unknown, context: Record<string, unknown>): unknown {
|
|
67
|
+
if (typeof expr !== "string") {
|
|
68
|
+
return expr;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const parsed = getParsed(expr);
|
|
72
|
+
if (!parsed.isSuccess) {
|
|
73
|
+
const contextKeys = Object.keys(context).filter((k) => !CUSTOM_FUNCTION_NAMES.has(k));
|
|
74
|
+
throw new Error(
|
|
75
|
+
`CEL parse error in expression '${expr}': ${parsed.errors.join("; ")}. Available context keys: [${contextKeys.join(", ")}]`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
return evaluate(parsed.cst, context, customFunctions);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
const contextKeys = Object.keys(context).filter((k) => !CUSTOM_FUNCTION_NAMES.has(k));
|
|
83
|
+
// Strip context values from cel-js error messages to avoid leaking sensitive data
|
|
84
|
+
const rawMessage = (err as Error).message;
|
|
85
|
+
const sanitized = rawMessage.replace(/in context: \{[^}]*\}/g, "in context: {…}");
|
|
86
|
+
throw new Error(
|
|
87
|
+
`CEL evaluation error in '${expr}': ${sanitized}. Available context keys: [${contextKeys.join(", ")}]`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function evaluateCelStrict(expr: string, context: Record<string, unknown>): boolean {
|
|
93
|
+
const result = evaluateCel(expr, context);
|
|
94
|
+
if (typeof result !== "boolean") {
|
|
95
|
+
throw new Error(`CEL expression must evaluate to boolean, got ${typeof result}: ${expr}`);
|
|
96
|
+
}
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Tries to evaluate a value as CEL. If the value is a string and CEL parsing
|
|
102
|
+
* fails, returns the literal string. For objects/arrays, recurses into values.
|
|
103
|
+
* Non-string scalars are returned as-is.
|
|
104
|
+
*/
|
|
105
|
+
export function evalMaybeCel(value: unknown, ctx: Record<string, unknown>): unknown {
|
|
106
|
+
if (typeof value === "string") {
|
|
107
|
+
try {
|
|
108
|
+
return evaluateCel(value, ctx);
|
|
109
|
+
} catch {
|
|
110
|
+
return value;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (Array.isArray(value)) {
|
|
115
|
+
return value.map((item) => evalMaybeCel(item, ctx));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (value !== null && typeof value === "object") {
|
|
119
|
+
const result: Record<string, unknown> = {};
|
|
120
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
121
|
+
result[k] = evalMaybeCel(v, ctx);
|
|
122
|
+
}
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return value;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function buildCelContext(state: {
|
|
130
|
+
toCelContext?: () => Record<string, unknown>;
|
|
131
|
+
getAllResults?: () => Record<string, { output?: unknown; parsedOutput?: unknown; status: string }>;
|
|
132
|
+
inputs?: Record<string, unknown>;
|
|
133
|
+
currentLoopIndex?: () => number;
|
|
134
|
+
}): Record<string, unknown> {
|
|
135
|
+
if (typeof state.toCelContext === "function") {
|
|
136
|
+
return state.toCelContext();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const ctx: Record<string, unknown> = {};
|
|
140
|
+
|
|
141
|
+
const results = state.getAllResults ? state.getAllResults() : {};
|
|
142
|
+
for (const [name, result] of Object.entries(results)) {
|
|
143
|
+
ctx[name] = {
|
|
144
|
+
output: result.parsedOutput ?? result.output,
|
|
145
|
+
status: result.status,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
ctx.input = state.inputs ?? {};
|
|
150
|
+
ctx.env = { ...process.env };
|
|
151
|
+
ctx.loop = {
|
|
152
|
+
index: state.currentLoopIndex ? state.currentLoopIndex() : 0,
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
return ctx;
|
|
156
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { evaluateCel } from "../cel/index";
|
|
2
|
+
import type { EventHub } from "../eventhub/types";
|
|
3
|
+
import type { Workflow } from "../model/index";
|
|
4
|
+
import type { WorkflowEngineExecutor } from "../participant/workflow";
|
|
5
|
+
import { executeSequential, executeStep } from "./sequential";
|
|
6
|
+
import type { WorkflowState } from "./state";
|
|
7
|
+
|
|
8
|
+
const RESERVED_SET_KEYS = new Set(["workflow", "execution", "input", "output", "env", "loop", "event"]);
|
|
9
|
+
|
|
10
|
+
export async function executeControlStep(
|
|
11
|
+
workflow: Workflow,
|
|
12
|
+
state: WorkflowState,
|
|
13
|
+
step: unknown,
|
|
14
|
+
basePath = process.cwd(),
|
|
15
|
+
engineExecutor?: WorkflowEngineExecutor,
|
|
16
|
+
chain?: unknown,
|
|
17
|
+
hub?: EventHub,
|
|
18
|
+
signal?: AbortSignal,
|
|
19
|
+
): Promise<unknown> {
|
|
20
|
+
if (signal?.aborted) {
|
|
21
|
+
throw new Error("execution aborted");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (typeof step === "string") {
|
|
25
|
+
return executeStep(workflow, state, step, basePath, engineExecutor, [], chain, hub, signal);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!step || typeof step !== "object" || Array.isArray(step)) {
|
|
29
|
+
return executeStep(workflow, state, step, basePath, engineExecutor, [], chain, hub, signal);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const obj = step as Record<string, unknown>;
|
|
33
|
+
|
|
34
|
+
// Wait step
|
|
35
|
+
if ("wait" in obj && Object.keys(obj).length === 1) {
|
|
36
|
+
const { executeWait } = await import("./wait");
|
|
37
|
+
return executeWait(state, (obj as { wait: Record<string, unknown> }).wait, chain, hub, signal);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Set step — write values to execution.context
|
|
41
|
+
if ("set" in obj && Object.keys(obj).length === 1) {
|
|
42
|
+
const setDef = (obj as { set: Record<string, string> }).set;
|
|
43
|
+
const ctx = state.toCelContext();
|
|
44
|
+
|
|
45
|
+
if (!state.executionMeta.context) {
|
|
46
|
+
state.executionMeta.context = {};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
for (const [key, expr] of Object.entries(setDef)) {
|
|
50
|
+
if (RESERVED_SET_KEYS.has(key)) {
|
|
51
|
+
throw new Error(`set key '${key}' uses a reserved name`);
|
|
52
|
+
}
|
|
53
|
+
state.executionMeta.context[key] = evaluateCel(expr, ctx);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// set does not produce output; chain passes through unchanged
|
|
57
|
+
return chain;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Loop step
|
|
61
|
+
if ("loop" in obj && Object.keys(obj).length === 1) {
|
|
62
|
+
const loopDef = (obj as { loop: { as?: string; until?: string; max?: number | string; steps: unknown[] } }).loop;
|
|
63
|
+
const loopAs = loopDef.as;
|
|
64
|
+
|
|
65
|
+
// Resolve max (can be CEL string)
|
|
66
|
+
let maxIterations: number;
|
|
67
|
+
if (typeof loopDef.max === "string") {
|
|
68
|
+
const resolved = evaluateCel(loopDef.max, state.toCelContext());
|
|
69
|
+
maxIterations = Number(resolved);
|
|
70
|
+
if (!Number.isFinite(maxIterations)) {
|
|
71
|
+
throw new Error(`loop.max CEL expression resolved to non-number: ${resolved}`);
|
|
72
|
+
}
|
|
73
|
+
} else {
|
|
74
|
+
maxIterations = loopDef.max ?? Number.POSITIVE_INFINITY;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const hasMax = loopDef.max !== undefined;
|
|
78
|
+
|
|
79
|
+
state.pushLoop(loopAs);
|
|
80
|
+
let loopChain = chain;
|
|
81
|
+
try {
|
|
82
|
+
let iterations = 0;
|
|
83
|
+
|
|
84
|
+
while (iterations < maxIterations) {
|
|
85
|
+
if (signal?.aborted) {
|
|
86
|
+
throw new Error("execution aborted");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Set loop.last before executing steps
|
|
90
|
+
const isLast = hasMax && iterations + 1 === maxIterations;
|
|
91
|
+
state.setLoopLast(isLast);
|
|
92
|
+
|
|
93
|
+
loopChain = await executeSequential(
|
|
94
|
+
workflow,
|
|
95
|
+
state,
|
|
96
|
+
loopDef.steps,
|
|
97
|
+
basePath,
|
|
98
|
+
engineExecutor,
|
|
99
|
+
loopChain,
|
|
100
|
+
hub,
|
|
101
|
+
signal,
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
if (loopDef.until) {
|
|
105
|
+
const untilValue = evaluateCel(loopDef.until, state.toCelContext());
|
|
106
|
+
if (untilValue !== true && typeof untilValue !== "boolean") {
|
|
107
|
+
throw new Error(`loop.until must evaluate to boolean, got ${typeof untilValue}`);
|
|
108
|
+
}
|
|
109
|
+
if (untilValue === true) {
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
iterations += 1;
|
|
115
|
+
state.incrementLoop();
|
|
116
|
+
}
|
|
117
|
+
} finally {
|
|
118
|
+
state.popLoop();
|
|
119
|
+
}
|
|
120
|
+
return loopChain;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Parallel step
|
|
124
|
+
if ("parallel" in obj && Object.keys(obj).length === 1) {
|
|
125
|
+
const parallelSteps = (obj as { parallel: unknown[] }).parallel;
|
|
126
|
+
const controller = new AbortController();
|
|
127
|
+
// Combine parent signal with local controller
|
|
128
|
+
const branchSignal = signal
|
|
129
|
+
? AbortSignal.any([signal, controller.signal])
|
|
130
|
+
: controller.signal;
|
|
131
|
+
|
|
132
|
+
const results = await Promise.all(
|
|
133
|
+
parallelSteps.map(async (parallelStep) => {
|
|
134
|
+
try {
|
|
135
|
+
// Each branch starts with the same incoming chain
|
|
136
|
+
return await executeControlStep(workflow, state, parallelStep, basePath, engineExecutor, chain, hub, branchSignal);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
controller.abort();
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
// Chain after parallel is ordered array of branch outputs
|
|
145
|
+
return results;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// If step
|
|
149
|
+
if ("if" in obj && Object.keys(obj).length === 1) {
|
|
150
|
+
const ifDef = (obj as { if: { condition: string; then: unknown[]; else?: unknown[] } }).if;
|
|
151
|
+
const condition = evaluateCel(ifDef.condition, state.toCelContext());
|
|
152
|
+
|
|
153
|
+
if (typeof condition !== "boolean") {
|
|
154
|
+
throw new Error(`if.condition must evaluate to boolean, got ${typeof condition}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (condition) {
|
|
158
|
+
return executeSequential(workflow, state, ifDef.then, basePath, engineExecutor, chain, hub, signal);
|
|
159
|
+
} else if (ifDef.else) {
|
|
160
|
+
return executeSequential(workflow, state, ifDef.else, basePath, engineExecutor, chain, hub, signal);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// False without else: chain passes through
|
|
164
|
+
return chain;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Inline participant (has `type` field) or participant override
|
|
168
|
+
return executeStep(workflow, state, step, basePath, engineExecutor, [], chain, hub, signal);
|
|
169
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { dirname, resolve } from "node:path";
|
|
2
|
+
import { evaluateCel } from "../cel/index";
|
|
3
|
+
import type { Workflow, WorkflowResult } from "../model/index";
|
|
4
|
+
import { parseWorkflowFile } from "../parser/parser";
|
|
5
|
+
import { validateSchema } from "../parser/schema";
|
|
6
|
+
import { validateSemantic } from "../parser/validate";
|
|
7
|
+
import { validateInputs } from "../parser/validate_inputs";
|
|
8
|
+
import type { WorkflowEngineExecutor } from "../participant/workflow";
|
|
9
|
+
import { executeControlStep } from "./control";
|
|
10
|
+
import { validateOutputSchema } from "./output";
|
|
11
|
+
import { WorkflowState } from "./state";
|
|
12
|
+
|
|
13
|
+
export interface EventHub {
|
|
14
|
+
publish(event: string, payload: unknown): Promise<void>;
|
|
15
|
+
publishAndWaitAck(event: string, payload: unknown, timeoutMs: number): Promise<void>;
|
|
16
|
+
subscribe(event: string, signal?: AbortSignal): AsyncIterable<{ name: string; payload: unknown }>;
|
|
17
|
+
close(): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ExecuteOptions {
|
|
21
|
+
hub?: EventHub;
|
|
22
|
+
cwd?: string;
|
|
23
|
+
executionNumber?: number;
|
|
24
|
+
verbose?: boolean;
|
|
25
|
+
quiet?: boolean;
|
|
26
|
+
/** @internal Tracks ancestor workflow paths for circular detection */
|
|
27
|
+
_ancestorPaths?: Set<string>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function executeWorkflow(
|
|
31
|
+
workflow: Workflow,
|
|
32
|
+
inputs: Record<string, unknown> = {},
|
|
33
|
+
basePath = process.cwd(),
|
|
34
|
+
options: ExecuteOptions = {},
|
|
35
|
+
): Promise<WorkflowResult> {
|
|
36
|
+
const { result: inputResult, resolved } = validateInputs(workflow.inputs, inputs);
|
|
37
|
+
if (!inputResult.valid) {
|
|
38
|
+
throw new Error(`input validation failed: ${JSON.stringify(inputResult.errors)}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const state = new WorkflowState(resolved);
|
|
42
|
+
|
|
43
|
+
// Set workflow metadata
|
|
44
|
+
state.workflowMeta = {
|
|
45
|
+
id: workflow.id,
|
|
46
|
+
name: workflow.name,
|
|
47
|
+
version: workflow.version,
|
|
48
|
+
};
|
|
49
|
+
state.executionMeta.number = options.executionNumber ?? 1;
|
|
50
|
+
if (options.cwd) {
|
|
51
|
+
state.executionMeta.cwd = options.cwd;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const startedAt = performance.now();
|
|
55
|
+
|
|
56
|
+
// Propagate ancestor paths for circular sub-workflow detection
|
|
57
|
+
if (options._ancestorPaths) {
|
|
58
|
+
state.ancestorPaths = options._ancestorPaths;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const engineExecutor: WorkflowEngineExecutor = async (subWorkflow, subInputs, subBasePath) => {
|
|
62
|
+
// Sub-workflows share parent hub, propagate ancestor paths for circular detection
|
|
63
|
+
return executeWorkflow(subWorkflow, subInputs, subBasePath, {
|
|
64
|
+
...options,
|
|
65
|
+
_ancestorPaths: state.ancestorPaths,
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// Execute flow with chain threading
|
|
70
|
+
let chain: unknown;
|
|
71
|
+
for (const step of workflow.flow) {
|
|
72
|
+
chain = await executeControlStep(workflow, state, step, basePath, engineExecutor, chain, options.hub);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Output resolution
|
|
76
|
+
let output: unknown;
|
|
77
|
+
if (workflow.output !== undefined) {
|
|
78
|
+
output = state.resolveOutput(workflow.output, evaluateCel);
|
|
79
|
+
|
|
80
|
+
// Validate output against schema if defined
|
|
81
|
+
if (
|
|
82
|
+
typeof workflow.output === "object" &&
|
|
83
|
+
"schema" in workflow.output &&
|
|
84
|
+
"map" in workflow.output &&
|
|
85
|
+
typeof output === "object" &&
|
|
86
|
+
output !== null
|
|
87
|
+
) {
|
|
88
|
+
validateOutputSchema(workflow.output.schema, output as Record<string, unknown>);
|
|
89
|
+
}
|
|
90
|
+
} else {
|
|
91
|
+
// Default: return final chain value
|
|
92
|
+
output = chain;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const steps = state.getAllResults();
|
|
96
|
+
const success = !Object.values(steps).some((step) => step.status === "failure");
|
|
97
|
+
|
|
98
|
+
state.executionMeta.status = success ? "success" : "failure";
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
success,
|
|
102
|
+
output,
|
|
103
|
+
steps,
|
|
104
|
+
duration: Math.max(0, performance.now() - startedAt),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function runWorkflowFromFile(
|
|
109
|
+
filePath: string,
|
|
110
|
+
inputs: Record<string, unknown> = {},
|
|
111
|
+
options: ExecuteOptions = {},
|
|
112
|
+
): Promise<WorkflowResult> {
|
|
113
|
+
const workflow = await parseWorkflowFile(filePath);
|
|
114
|
+
|
|
115
|
+
const schemaValidation = validateSchema(workflow);
|
|
116
|
+
if (!schemaValidation.valid) {
|
|
117
|
+
throw new Error(`schema validation failed: ${JSON.stringify(schemaValidation.errors)}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const workflowBasePath = dirname(resolve(filePath));
|
|
121
|
+
const semanticValidation = await validateSemantic(workflow, workflowBasePath);
|
|
122
|
+
if (!semanticValidation.valid) {
|
|
123
|
+
throw new Error(`semantic validation failed: ${JSON.stringify(semanticValidation.errors)}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return executeWorkflow(workflow, inputs, workflowBasePath, options);
|
|
127
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { ErrorStrategy, RetryConfig } from "../model/index";
|
|
2
|
+
|
|
3
|
+
export class WorkflowError extends Error {
|
|
4
|
+
stepName: string;
|
|
5
|
+
strategy: ErrorStrategy;
|
|
6
|
+
retriesAttempted: number;
|
|
7
|
+
|
|
8
|
+
constructor(message: string, stepName: string, strategy: ErrorStrategy, retriesAttempted = 0) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "WorkflowError";
|
|
11
|
+
this.stepName = stepName;
|
|
12
|
+
this.strategy = strategy;
|
|
13
|
+
this.retriesAttempted = retriesAttempted;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function sleep(ms: number): Promise<void> {
|
|
18
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function parseDuration(duration: string): number {
|
|
22
|
+
const match = duration.match(/^\s*(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)\s*$/i);
|
|
23
|
+
if (!match) {
|
|
24
|
+
throw new Error(`unsupported duration format: ${duration}`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const value = Number(match[1]);
|
|
28
|
+
const unit = match[2].toLowerCase();
|
|
29
|
+
|
|
30
|
+
switch (unit) {
|
|
31
|
+
case "ms":
|
|
32
|
+
return Math.round(value);
|
|
33
|
+
case "s":
|
|
34
|
+
return Math.round(value * 1000);
|
|
35
|
+
case "m":
|
|
36
|
+
return Math.round(value * 60 * 1000);
|
|
37
|
+
case "h":
|
|
38
|
+
return Math.round(value * 60 * 60 * 1000);
|
|
39
|
+
case "d":
|
|
40
|
+
return Math.round(value * 24 * 60 * 60 * 1000);
|
|
41
|
+
default:
|
|
42
|
+
throw new Error(`unsupported duration unit: ${unit}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function resolveErrorStrategy(
|
|
47
|
+
stepOverride?: { onError?: ErrorStrategy } | null,
|
|
48
|
+
participant?: { onError?: ErrorStrategy } | null,
|
|
49
|
+
defaults?: { onError?: ErrorStrategy } | null,
|
|
50
|
+
): ErrorStrategy {
|
|
51
|
+
if (stepOverride?.onError) {
|
|
52
|
+
return stepOverride.onError;
|
|
53
|
+
}
|
|
54
|
+
if (participant?.onError) {
|
|
55
|
+
return participant.onError;
|
|
56
|
+
}
|
|
57
|
+
if (defaults?.onError) {
|
|
58
|
+
return defaults.onError;
|
|
59
|
+
}
|
|
60
|
+
return "fail";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function executeWithRetry<T>(
|
|
64
|
+
fn: () => Promise<T>,
|
|
65
|
+
retryConfig: RetryConfig | undefined,
|
|
66
|
+
stepName = "<unknown>",
|
|
67
|
+
): Promise<{ result: T; attempts: number }> {
|
|
68
|
+
const maxRetries = retryConfig?.max ?? 0;
|
|
69
|
+
const baseBackoffMs = retryConfig?.backoff ? parseDuration(retryConfig.backoff) : 0;
|
|
70
|
+
const factor = retryConfig?.factor ?? 1;
|
|
71
|
+
|
|
72
|
+
let attempt = 0;
|
|
73
|
+
while (true) {
|
|
74
|
+
try {
|
|
75
|
+
const result = await fn();
|
|
76
|
+
return { result, attempts: attempt };
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (attempt >= maxRetries) {
|
|
79
|
+
throw new WorkflowError(String((error as Error)?.message ?? error), stepName, "retry", attempt);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const delay = Math.round(baseBackoffMs * Math.pow(factor, attempt));
|
|
83
|
+
if (delay > 0) {
|
|
84
|
+
await sleep(delay);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
attempt += 1;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { executeWorkflow, runWorkflowFromFile } from "./engine";
|
|
2
|
+
export type { EventHub, ExecuteOptions } from "./engine";
|
|
3
|
+
export { executeControlStep } from "./control";
|
|
4
|
+
export { executeSequential, executeStep, mergeChainedInput } from "./sequential";
|
|
5
|
+
export { WorkflowState } from "./state";
|
|
6
|
+
export { WorkflowError, parseDuration, resolveErrorStrategy, executeWithRetry } from "./errors";
|
|
7
|
+
export { TimeoutError, withTimeout, resolveTimeout } from "./timeout";
|
|
8
|
+
export { executeWait } from "./wait";
|