@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
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { InputDefinition } from "../model/index";
|
|
2
|
+
|
|
3
|
+
export function validateOutputSchema(
|
|
4
|
+
schema: Record<string, InputDefinition>,
|
|
5
|
+
result: Record<string, unknown>,
|
|
6
|
+
): void {
|
|
7
|
+
for (const [fieldName, fieldDef] of Object.entries(schema)) {
|
|
8
|
+
const value = result[fieldName];
|
|
9
|
+
|
|
10
|
+
if (value === undefined || value === null) {
|
|
11
|
+
if (fieldDef.required) {
|
|
12
|
+
throw new Error(`output validation failed: required field '${fieldName}' is missing`);
|
|
13
|
+
}
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
validateValueAgainstField(fieldName, value, fieldDef);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function validateValueAgainstField(
|
|
22
|
+
fieldName: string,
|
|
23
|
+
value: unknown,
|
|
24
|
+
fieldDef: InputDefinition,
|
|
25
|
+
): void {
|
|
26
|
+
if (fieldDef.type) {
|
|
27
|
+
validateType(fieldName, value, fieldDef.type);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (fieldDef.enum !== undefined) {
|
|
31
|
+
if (!fieldDef.enum.some((e) => e === value)) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`output validation failed: field '${fieldName}' value ${JSON.stringify(value)} is not in enum [${fieldDef.enum.map((e) => JSON.stringify(e)).join(", ")}]`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (typeof value === "string") {
|
|
39
|
+
if (fieldDef.minLength !== undefined && value.length < fieldDef.minLength) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`output validation failed: field '${fieldName}' length ${value.length} is less than minLength ${fieldDef.minLength}`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
if (fieldDef.maxLength !== undefined && value.length > fieldDef.maxLength) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`output validation failed: field '${fieldName}' length ${value.length} exceeds maxLength ${fieldDef.maxLength}`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
if (fieldDef.pattern !== undefined && !new RegExp(fieldDef.pattern).test(value)) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`output validation failed: field '${fieldName}' does not match pattern '${fieldDef.pattern}'`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (typeof value === "number") {
|
|
57
|
+
if (fieldDef.minimum !== undefined && value < fieldDef.minimum) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`output validation failed: field '${fieldName}' value ${value} is less than minimum ${fieldDef.minimum}`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
if (fieldDef.maximum !== undefined && value > fieldDef.maximum) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`output validation failed: field '${fieldName}' value ${value} exceeds maximum ${fieldDef.maximum}`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (Array.isArray(value) && fieldDef.items) {
|
|
70
|
+
for (let i = 0; i < value.length; i++) {
|
|
71
|
+
validateValueAgainstField(`${fieldName}[${i}]`, value[i], fieldDef.items);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function validateType(fieldName: string, value: unknown, expectedType: string): void {
|
|
77
|
+
switch (expectedType) {
|
|
78
|
+
case "string":
|
|
79
|
+
if (typeof value !== "string") {
|
|
80
|
+
throw new Error(`output validation failed: field '${fieldName}' expected string, got ${typeof value}`);
|
|
81
|
+
}
|
|
82
|
+
break;
|
|
83
|
+
case "boolean":
|
|
84
|
+
if (typeof value !== "boolean") {
|
|
85
|
+
throw new Error(`output validation failed: field '${fieldName}' expected boolean, got ${typeof value}`);
|
|
86
|
+
}
|
|
87
|
+
break;
|
|
88
|
+
case "integer":
|
|
89
|
+
if (typeof value !== "number" || !Number.isInteger(value)) {
|
|
90
|
+
throw new Error(`output validation failed: field '${fieldName}' expected integer, got ${typeof value === "number" ? value : typeof value}`);
|
|
91
|
+
}
|
|
92
|
+
break;
|
|
93
|
+
case "number":
|
|
94
|
+
if (typeof value !== "number") {
|
|
95
|
+
throw new Error(`output validation failed: field '${fieldName}' expected number, got ${typeof value}`);
|
|
96
|
+
}
|
|
97
|
+
break;
|
|
98
|
+
case "array":
|
|
99
|
+
if (!Array.isArray(value)) {
|
|
100
|
+
throw new Error(`output validation failed: field '${fieldName}' expected array, got ${typeof value}`);
|
|
101
|
+
}
|
|
102
|
+
break;
|
|
103
|
+
case "object":
|
|
104
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
105
|
+
throw new Error(`output validation failed: field '${fieldName}' expected object, got ${Array.isArray(value) ? "array" : typeof value}`);
|
|
106
|
+
}
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import { resolve as resolvePath, isAbsolute } from "node:path";
|
|
2
|
+
import { evaluateCel, evalMaybeCel } from "../cel/index";
|
|
3
|
+
import type { EventHub } from "../eventhub/types";
|
|
4
|
+
import type {
|
|
5
|
+
ErrorStrategy,
|
|
6
|
+
Participant,
|
|
7
|
+
StepResult,
|
|
8
|
+
Workflow,
|
|
9
|
+
} from "../model/index";
|
|
10
|
+
import { executeParticipant } from "../participant/index";
|
|
11
|
+
import type { WorkflowEngineExecutor } from "../participant/workflow";
|
|
12
|
+
import { executeWithRetry, resolveErrorStrategy } from "./errors";
|
|
13
|
+
import { validateOutputSchema } from "./output";
|
|
14
|
+
import type { WorkflowState } from "./state";
|
|
15
|
+
import { resolveTimeout, withTimeout } from "./timeout";
|
|
16
|
+
|
|
17
|
+
function isControlFlowStep(step: unknown): boolean {
|
|
18
|
+
if (typeof step !== "object" || step == null || Array.isArray(step)) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const keys = Object.keys(step as Record<string, unknown>);
|
|
23
|
+
return keys.length === 1 && ["loop", "parallel", "if", "wait"].includes(keys[0]);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isInlineParticipant(step: unknown): boolean {
|
|
27
|
+
if (typeof step !== "object" || step == null || Array.isArray(step)) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
return "type" in (step as Record<string, unknown>);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function resolveParticipantInput(
|
|
34
|
+
participantInput: string | Record<string, string> | undefined,
|
|
35
|
+
state: WorkflowState,
|
|
36
|
+
): unknown {
|
|
37
|
+
if (participantInput === undefined) {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (typeof participantInput === "string") {
|
|
42
|
+
return evaluateCel(participantInput, state.toCelContext());
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const resolved: Record<string, unknown> = {};
|
|
46
|
+
for (const [key, expr] of Object.entries(participantInput)) {
|
|
47
|
+
resolved[key] = evaluateCel(expr, state.toCelContext());
|
|
48
|
+
}
|
|
49
|
+
return resolved;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function mergeChainedInput(chain: unknown, explicit: unknown): unknown {
|
|
53
|
+
if (explicit === undefined || explicit === null) return chain;
|
|
54
|
+
if (chain === undefined || chain === null) return explicit;
|
|
55
|
+
|
|
56
|
+
const chainIsMap = typeof chain === "object" && !Array.isArray(chain);
|
|
57
|
+
const explicitIsMap = typeof explicit === "object" && !Array.isArray(explicit);
|
|
58
|
+
|
|
59
|
+
if (chainIsMap && explicitIsMap) {
|
|
60
|
+
return { ...(chain as Record<string, unknown>), ...(explicit as Record<string, unknown>) };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (typeof chain === "string" && typeof explicit === "string") {
|
|
64
|
+
return explicit;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Spec §5.7: incompatible types (string vs map or vice versa) must raise an error
|
|
68
|
+
throw new Error(
|
|
69
|
+
`I/O chain type conflict: cannot merge ${Array.isArray(chain) ? "array" : typeof chain} chain with ${Array.isArray(explicit) ? "array" : typeof explicit} input`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type FlowOverride = {
|
|
74
|
+
timeout?: string;
|
|
75
|
+
onError?: ErrorStrategy;
|
|
76
|
+
when?: string;
|
|
77
|
+
input?: string | Record<string, string>;
|
|
78
|
+
retry?: {
|
|
79
|
+
max: number;
|
|
80
|
+
backoff?: string;
|
|
81
|
+
factor?: number;
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export async function executeStep(
|
|
86
|
+
workflow: Workflow,
|
|
87
|
+
state: WorkflowState,
|
|
88
|
+
step: unknown,
|
|
89
|
+
basePath = process.cwd(),
|
|
90
|
+
engineExecutor?: WorkflowEngineExecutor,
|
|
91
|
+
fallbackStack: string[] = [],
|
|
92
|
+
chain?: unknown,
|
|
93
|
+
hub?: EventHub,
|
|
94
|
+
signal?: AbortSignal,
|
|
95
|
+
): Promise<unknown> {
|
|
96
|
+
if (signal?.aborted) {
|
|
97
|
+
throw new Error("execution aborted");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (isControlFlowStep(step)) {
|
|
101
|
+
throw new Error("control-flow construct passed to executeStep");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let stepName: string | undefined;
|
|
105
|
+
let participant: Participant;
|
|
106
|
+
let override: FlowOverride | undefined;
|
|
107
|
+
|
|
108
|
+
if (typeof step === "string") {
|
|
109
|
+
// Named participant reference
|
|
110
|
+
stepName = step;
|
|
111
|
+
const p = workflow.participants?.[stepName];
|
|
112
|
+
if (!p) {
|
|
113
|
+
throw new Error(`participant '${stepName}' not found`);
|
|
114
|
+
}
|
|
115
|
+
participant = p;
|
|
116
|
+
} else if (isInlineParticipant(step)) {
|
|
117
|
+
// Inline participant
|
|
118
|
+
const inline = step as Participant & { as?: string; when?: string };
|
|
119
|
+
stepName = inline.as;
|
|
120
|
+
participant = inline;
|
|
121
|
+
} else if (typeof step === "object" && step != null && !Array.isArray(step)) {
|
|
122
|
+
// Participant override
|
|
123
|
+
const keys = Object.keys(step);
|
|
124
|
+
if (keys.length !== 1) {
|
|
125
|
+
throw new Error("invalid flow step override");
|
|
126
|
+
}
|
|
127
|
+
stepName = keys[0];
|
|
128
|
+
override = (step as Record<string, FlowOverride>)[stepName];
|
|
129
|
+
const p = workflow.participants?.[stepName];
|
|
130
|
+
if (!p) {
|
|
131
|
+
throw new Error(`participant '${stepName}' not found`);
|
|
132
|
+
}
|
|
133
|
+
participant = p;
|
|
134
|
+
} else {
|
|
135
|
+
throw new Error("invalid flow step");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const mergedParticipant: Participant = {
|
|
139
|
+
...participant,
|
|
140
|
+
...(override ?? {}),
|
|
141
|
+
} as Participant;
|
|
142
|
+
|
|
143
|
+
// When guard - boolean strictness
|
|
144
|
+
const whenExpression = override?.when ?? participant.when;
|
|
145
|
+
if (whenExpression) {
|
|
146
|
+
const shouldRun = evaluateCel(whenExpression, state.toCelContext());
|
|
147
|
+
if (shouldRun !== true) {
|
|
148
|
+
if (stepName) {
|
|
149
|
+
state.setResult(stepName, {
|
|
150
|
+
status: "skipped",
|
|
151
|
+
output: "",
|
|
152
|
+
duration: 0,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
return chain; // Skipped steps preserve chain
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Resolve participant base input and flow override input separately,
|
|
160
|
+
// then merge per v0.5 spec: chain < participant base input < flow override input.
|
|
161
|
+
const baseInput = resolveParticipantInput(participant.input, state);
|
|
162
|
+
const overrideInput = override?.input !== undefined
|
|
163
|
+
? resolveParticipantInput(override.input, state)
|
|
164
|
+
: undefined;
|
|
165
|
+
const mergedWithBase = mergeChainedInput(chain, baseInput);
|
|
166
|
+
const mergedInput = mergeChainedInput(mergedWithBase, overrideInput);
|
|
167
|
+
|
|
168
|
+
// Set participant-scoped input in state
|
|
169
|
+
state.currentInput = mergedInput;
|
|
170
|
+
|
|
171
|
+
const strategy = resolveErrorStrategy(override ?? null, participant, workflow.defaults ?? null);
|
|
172
|
+
const timeoutMs = resolveTimeout(override ?? null, participant, workflow.defaults ?? null);
|
|
173
|
+
const retryConfig = strategy === "retry" ? mergedParticipant.retry : undefined;
|
|
174
|
+
|
|
175
|
+
const startedAt = new Date().toISOString();
|
|
176
|
+
|
|
177
|
+
// Build CEL context for participants that need it (e.g. emit, http CEL fields)
|
|
178
|
+
const celContext = state.toCelContext();
|
|
179
|
+
|
|
180
|
+
// Resolve CEL expressions in HTTP participant fields (url, headers, body)
|
|
181
|
+
if (mergedParticipant.type === "http") {
|
|
182
|
+
const http = mergedParticipant as import("../model/index").HttpParticipant;
|
|
183
|
+
http.url = String(evalMaybeCel(http.url, celContext));
|
|
184
|
+
if (http.headers) {
|
|
185
|
+
const resolvedHeaders: Record<string, string> = {};
|
|
186
|
+
for (const [k, v] of Object.entries(http.headers)) {
|
|
187
|
+
resolvedHeaders[k] = String(evalMaybeCel(v, celContext));
|
|
188
|
+
}
|
|
189
|
+
http.headers = resolvedHeaders;
|
|
190
|
+
}
|
|
191
|
+
if (http.body !== undefined) {
|
|
192
|
+
http.body = evalMaybeCel(http.body, celContext) as string | Record<string, unknown>;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Resolve CEL expressions in CWD (exec participant)
|
|
197
|
+
// Spec §8.1: participant.cwd > defaults.cwd > CLI --cwd > process cwd
|
|
198
|
+
if (mergedParticipant.type === "exec") {
|
|
199
|
+
const exec = mergedParticipant as import("../model/index").ExecParticipant;
|
|
200
|
+
let resolvedCwd: string | undefined;
|
|
201
|
+
if (exec.cwd) {
|
|
202
|
+
resolvedCwd = String(evalMaybeCel(exec.cwd, celContext));
|
|
203
|
+
} else if (workflow.defaults?.cwd) {
|
|
204
|
+
resolvedCwd = String(evalMaybeCel(workflow.defaults.cwd, celContext));
|
|
205
|
+
} else if (state.executionMeta.cwd && state.executionMeta.cwd !== process.cwd()) {
|
|
206
|
+
// Fall back to CLI --cwd (stored in execution.cwd)
|
|
207
|
+
resolvedCwd = state.executionMeta.cwd;
|
|
208
|
+
}
|
|
209
|
+
if (resolvedCwd && !isAbsolute(resolvedCwd)) {
|
|
210
|
+
resolvedCwd = resolvePath(basePath, resolvedCwd);
|
|
211
|
+
}
|
|
212
|
+
if (resolvedCwd) {
|
|
213
|
+
exec.cwd = resolvedCwd;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const executeOnce = async (): Promise<StepResult> => {
|
|
218
|
+
const invoke = async (): Promise<StepResult> => {
|
|
219
|
+
const result = await executeParticipant(
|
|
220
|
+
mergedParticipant,
|
|
221
|
+
mergedInput,
|
|
222
|
+
{},
|
|
223
|
+
basePath,
|
|
224
|
+
engineExecutor,
|
|
225
|
+
hub,
|
|
226
|
+
celContext,
|
|
227
|
+
state.ancestorPaths,
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
if (result.status === "failure") {
|
|
231
|
+
throw new Error(
|
|
232
|
+
result.error ||
|
|
233
|
+
`participant '${stepName ?? "anonymous"}' (type: ${mergedParticipant.type}) failed`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return result;
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
if (timeoutMs === undefined) {
|
|
241
|
+
return invoke();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return withTimeout(invoke, timeoutMs);
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
let result: StepResult;
|
|
249
|
+
let retries = 0;
|
|
250
|
+
if (strategy === "retry") {
|
|
251
|
+
const retryResult = await executeWithRetry(executeOnce, retryConfig, stepName ?? "<anonymous>");
|
|
252
|
+
result = retryResult.result;
|
|
253
|
+
retries = retryResult.attempts;
|
|
254
|
+
} else {
|
|
255
|
+
result = await executeOnce();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
result.startedAt = result.startedAt ?? startedAt;
|
|
259
|
+
result.finishedAt = result.finishedAt ?? new Date().toISOString();
|
|
260
|
+
result.retries = retries;
|
|
261
|
+
|
|
262
|
+
// Validate participant output schema (§5.6)
|
|
263
|
+
const outputSchema = mergedParticipant.output as Record<string, import("../model/index").InputDefinition> | undefined;
|
|
264
|
+
if (outputSchema && Object.keys(outputSchema).length > 0) {
|
|
265
|
+
const outputData = result.parsedOutput ?? result.output;
|
|
266
|
+
if (typeof outputData === "object" && outputData !== null) {
|
|
267
|
+
validateOutputSchema(outputSchema, outputData as Record<string, unknown>);
|
|
268
|
+
} else {
|
|
269
|
+
// Scalar output but schema expects object fields — validation error
|
|
270
|
+
throw new Error(
|
|
271
|
+
`output validation failed: expected object with fields [${Object.keys(outputSchema).join(", ")}] but got ${typeof outputData}`,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (stepName) {
|
|
277
|
+
state.setResult(stepName, result);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Update participant-scoped output and chain
|
|
281
|
+
const outputValue = result.parsedOutput ?? result.output;
|
|
282
|
+
state.currentOutput = outputValue;
|
|
283
|
+
return outputValue;
|
|
284
|
+
} catch (error) {
|
|
285
|
+
const message = String((error as Error)?.message ?? error);
|
|
286
|
+
const finishedAt = new Date().toISOString();
|
|
287
|
+
// Capture HTTP metadata from error if available
|
|
288
|
+
const httpMeta: { httpStatus?: number; responseBody?: string } = {};
|
|
289
|
+
if (error && typeof error === "object") {
|
|
290
|
+
const errObj = error as Record<string, unknown>;
|
|
291
|
+
if (typeof errObj.httpStatus === "number") httpMeta.httpStatus = errObj.httpStatus;
|
|
292
|
+
if (typeof errObj.responseBody === "string") httpMeta.responseBody = errObj.responseBody;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (strategy === "skip") {
|
|
296
|
+
const skipResult: StepResult = {
|
|
297
|
+
status: "skipped",
|
|
298
|
+
output: "",
|
|
299
|
+
error: message,
|
|
300
|
+
duration: 0,
|
|
301
|
+
startedAt,
|
|
302
|
+
finishedAt,
|
|
303
|
+
...httpMeta,
|
|
304
|
+
};
|
|
305
|
+
if (stepName) {
|
|
306
|
+
state.setResult(stepName, skipResult);
|
|
307
|
+
}
|
|
308
|
+
return chain; // Skipped steps preserve chain
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (strategy !== "fail" && strategy !== "retry") {
|
|
312
|
+
// Fallback to another participant
|
|
313
|
+
const fallbackName = strategy;
|
|
314
|
+
if (fallbackStack.includes(fallbackName)) {
|
|
315
|
+
throw new Error(`fallback cycle detected on participant '${fallbackName}'`);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Keep original step as failure (Go runner behavior)
|
|
319
|
+
if (stepName) {
|
|
320
|
+
state.setResult(stepName, {
|
|
321
|
+
status: "failure",
|
|
322
|
+
output: "",
|
|
323
|
+
error: message,
|
|
324
|
+
duration: 0,
|
|
325
|
+
startedAt,
|
|
326
|
+
finishedAt,
|
|
327
|
+
...httpMeta,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Execute fallback
|
|
332
|
+
const fallbackResult = await executeStep(
|
|
333
|
+
workflow,
|
|
334
|
+
state,
|
|
335
|
+
fallbackName,
|
|
336
|
+
basePath,
|
|
337
|
+
engineExecutor,
|
|
338
|
+
[...fallbackStack, stepName ?? "<anonymous>"],
|
|
339
|
+
chain,
|
|
340
|
+
hub,
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
return fallbackResult;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (stepName) {
|
|
347
|
+
state.setResult(stepName, {
|
|
348
|
+
status: "failure",
|
|
349
|
+
output: "",
|
|
350
|
+
error: message,
|
|
351
|
+
duration: 0,
|
|
352
|
+
startedAt,
|
|
353
|
+
finishedAt,
|
|
354
|
+
...httpMeta,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
throw error;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export async function executeSequential(
|
|
363
|
+
workflow: Workflow,
|
|
364
|
+
state: WorkflowState,
|
|
365
|
+
steps: unknown[],
|
|
366
|
+
basePath = process.cwd(),
|
|
367
|
+
engineExecutor?: WorkflowEngineExecutor,
|
|
368
|
+
chain?: unknown,
|
|
369
|
+
hub?: EventHub,
|
|
370
|
+
signal?: AbortSignal,
|
|
371
|
+
): Promise<unknown> {
|
|
372
|
+
let currentChain = chain;
|
|
373
|
+
for (const step of steps) {
|
|
374
|
+
// Import executeControlStep dynamically to avoid circular dependency
|
|
375
|
+
const { executeControlStep } = await import("./control");
|
|
376
|
+
currentChain = await executeControlStep(workflow, state, step, basePath, engineExecutor, currentChain, hub, signal);
|
|
377
|
+
}
|
|
378
|
+
return currentChain;
|
|
379
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { env } from "node:process";
|
|
2
|
+
import type { StepResult } from "../model/index";
|
|
3
|
+
|
|
4
|
+
export type { StepResult };
|
|
5
|
+
|
|
6
|
+
export class WorkflowState {
|
|
7
|
+
readonly inputs: Record<string, unknown>;
|
|
8
|
+
private results: Map<string, StepResult>;
|
|
9
|
+
private loopStack: Array<{ index: number; as?: string; last?: boolean }>;
|
|
10
|
+
|
|
11
|
+
// v0.3 fields
|
|
12
|
+
workflowInputs: Record<string, unknown>;
|
|
13
|
+
workflowMeta: { id?: string; name?: string; version?: string | number };
|
|
14
|
+
executionMeta: {
|
|
15
|
+
id: string;
|
|
16
|
+
number?: number;
|
|
17
|
+
startedAt: string;
|
|
18
|
+
status: string;
|
|
19
|
+
context?: Record<string, unknown>;
|
|
20
|
+
cwd: string;
|
|
21
|
+
};
|
|
22
|
+
currentInput: unknown;
|
|
23
|
+
currentOutput: unknown;
|
|
24
|
+
chainValue: unknown;
|
|
25
|
+
eventPayload: unknown;
|
|
26
|
+
/** @internal Tracks ancestor workflow paths for circular sub-workflow detection */
|
|
27
|
+
ancestorPaths: Set<string>;
|
|
28
|
+
|
|
29
|
+
constructor(inputs: Record<string, unknown> = {}) {
|
|
30
|
+
this.inputs = { ...inputs };
|
|
31
|
+
this.workflowInputs = { ...inputs };
|
|
32
|
+
this.workflowMeta = {};
|
|
33
|
+
this.executionMeta = {
|
|
34
|
+
id: crypto.randomUUID(),
|
|
35
|
+
startedAt: new Date().toISOString(),
|
|
36
|
+
status: "running",
|
|
37
|
+
cwd: process.cwd(),
|
|
38
|
+
};
|
|
39
|
+
this.currentInput = undefined;
|
|
40
|
+
this.currentOutput = undefined;
|
|
41
|
+
this.chainValue = undefined;
|
|
42
|
+
this.eventPayload = undefined;
|
|
43
|
+
this.ancestorPaths = new Set();
|
|
44
|
+
this.results = new Map();
|
|
45
|
+
this.loopStack = [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
setResult(stepName: string, result: StepResult): void {
|
|
49
|
+
this.results.set(stepName, result);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
getResult(stepName: string): StepResult | undefined {
|
|
53
|
+
return this.results.get(stepName);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
getAllResults(): Record<string, StepResult> {
|
|
57
|
+
const out: Record<string, StepResult> = {};
|
|
58
|
+
for (const [k, v] of this.results.entries()) out[k] = v;
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
pushLoop(as?: string): void {
|
|
63
|
+
this.loopStack.push({ index: 0, as });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
incrementLoop(): void {
|
|
67
|
+
const top = this.loopStack[this.loopStack.length - 1];
|
|
68
|
+
if (top) top.index += 1;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
popLoop(): void {
|
|
72
|
+
this.loopStack.pop();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
currentLoopIndex(): number {
|
|
76
|
+
const top = this.loopStack[this.loopStack.length - 1];
|
|
77
|
+
return top ? top.index : 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
setLoopLast(last: boolean): void {
|
|
81
|
+
const top = this.loopStack[this.loopStack.length - 1];
|
|
82
|
+
if (top) top.last = last;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
currentLoopContext(): { index: number; iteration: number; first: boolean; last: boolean; as?: string } {
|
|
86
|
+
const top = this.loopStack[this.loopStack.length - 1];
|
|
87
|
+
if (!top) return { index: 0, iteration: 1, first: true, last: false };
|
|
88
|
+
return {
|
|
89
|
+
index: top.index,
|
|
90
|
+
iteration: top.index + 1,
|
|
91
|
+
first: top.index === 0,
|
|
92
|
+
last: top.last ?? false,
|
|
93
|
+
as: top.as,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
toCelContext(): Record<string, unknown> {
|
|
98
|
+
const ctx: Record<string, unknown> = {};
|
|
99
|
+
|
|
100
|
+
// Step results (v0.3: <step>.output, <step>.status, etc.)
|
|
101
|
+
for (const [name, res] of this.results.entries()) {
|
|
102
|
+
ctx[name] = {
|
|
103
|
+
output: res.parsedOutput ?? res.output,
|
|
104
|
+
status: res.status,
|
|
105
|
+
startedAt: res.startedAt,
|
|
106
|
+
finishedAt: res.finishedAt,
|
|
107
|
+
duration: res.duration,
|
|
108
|
+
retries: res.retries ?? 0,
|
|
109
|
+
error: res.error,
|
|
110
|
+
cwd: res.cwd,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// v0.3 namespaces
|
|
115
|
+
ctx["workflow"] = {
|
|
116
|
+
id: this.workflowMeta.id,
|
|
117
|
+
name: this.workflowMeta.name,
|
|
118
|
+
version: this.workflowMeta.version,
|
|
119
|
+
inputs: this.workflowInputs,
|
|
120
|
+
output: null, // resolved at end
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
ctx["execution"] = { ...this.executionMeta };
|
|
124
|
+
|
|
125
|
+
// Participant-scoped input/output
|
|
126
|
+
ctx["input"] = this.currentInput ?? {};
|
|
127
|
+
ctx["output"] = this.currentOutput ?? {};
|
|
128
|
+
|
|
129
|
+
// Environment
|
|
130
|
+
ctx["env"] = { ...env };
|
|
131
|
+
|
|
132
|
+
// Loop context
|
|
133
|
+
const loopCtx = this.currentLoopContext();
|
|
134
|
+
const loopObj = {
|
|
135
|
+
index: loopCtx.index,
|
|
136
|
+
iteration: loopCtx.iteration,
|
|
137
|
+
first: loopCtx.first,
|
|
138
|
+
last: loopCtx.last,
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// If loop has custom `as`, expose under that name; also keep `loop` for compat
|
|
142
|
+
if (loopCtx.as) {
|
|
143
|
+
ctx[`_${loopCtx.as}`] = loopObj;
|
|
144
|
+
}
|
|
145
|
+
ctx["_loop"] = loopObj;
|
|
146
|
+
ctx["loop"] = loopObj;
|
|
147
|
+
|
|
148
|
+
// Event payload
|
|
149
|
+
ctx["event"] = this.eventPayload ?? {};
|
|
150
|
+
|
|
151
|
+
// Now — epoch seconds to match timestamp() and Go runner behavior (Spec §12.9)
|
|
152
|
+
ctx["now"] = Math.floor(Date.now() / 1000);
|
|
153
|
+
|
|
154
|
+
return ctx;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
resolveOutput(
|
|
158
|
+
outputDef: string | Record<string, string> | { schema: Record<string, unknown>; map: Record<string, string> },
|
|
159
|
+
celEvaluator: (expr: string, ctx: Record<string, unknown>) => unknown,
|
|
160
|
+
): unknown {
|
|
161
|
+
const ctx = this.toCelContext();
|
|
162
|
+
|
|
163
|
+
// schema+map variant
|
|
164
|
+
if (typeof outputDef === "object" && "map" in outputDef && "schema" in outputDef) {
|
|
165
|
+
const result: Record<string, unknown> = {};
|
|
166
|
+
for (const [k, expr] of Object.entries(outputDef.map)) {
|
|
167
|
+
result[k] = celEvaluator(expr, ctx);
|
|
168
|
+
}
|
|
169
|
+
return result;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (typeof outputDef === "string") {
|
|
173
|
+
return celEvaluator(outputDef, ctx);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const result: Record<string, unknown> = {};
|
|
177
|
+
for (const k of Object.keys(outputDef)) {
|
|
178
|
+
const expr = (outputDef as Record<string, string>)[k];
|
|
179
|
+
result[k] = celEvaluator(expr, ctx);
|
|
180
|
+
}
|
|
181
|
+
return result;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export default WorkflowState;
|