@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/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
+ }
@@ -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";