@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.
@@ -0,0 +1,541 @@
1
+ import { constants } from "node:fs";
2
+ import { access } from "node:fs/promises";
3
+ import { resolve } from "node:path";
4
+ import { validateCelExpression } from "../cel/index";
5
+ import type { ValidationError, ValidationResult, Workflow } from "../model/index";
6
+
7
+ const RESERVED_NAMES = new Set(["workflow", "execution", "input", "output", "env", "loop", "event"]);
8
+ const BUILTIN_ONERROR = new Set(["fail", "skip", "retry"]);
9
+
10
+ function isInlineParticipant(step: unknown): step is Record<string, unknown> & { type: string } {
11
+ return typeof step === "object" && step !== null && "type" in step;
12
+ }
13
+
14
+ function isWaitStep(step: unknown): step is { wait: Record<string, unknown> } {
15
+ return typeof step === "object" && step !== null && "wait" in step && Object.keys(step).length === 1;
16
+ }
17
+
18
+ function collectParticipantReferences(
19
+ flow: unknown[],
20
+ refs: Array<{ name: string; path: string }>,
21
+ inlineNames: Array<{ name: string; path: string }>,
22
+ basePath = "flow",
23
+ ): void {
24
+ for (const [index, step] of flow.entries()) {
25
+ const stepPath = `${basePath}[${index}]`;
26
+
27
+ if (typeof step === "string") {
28
+ refs.push({ name: step, path: stepPath });
29
+ continue;
30
+ }
31
+
32
+ if (!step || typeof step !== "object") {
33
+ continue;
34
+ }
35
+
36
+ // Inline participant
37
+ if (isInlineParticipant(step)) {
38
+ if (step.as && typeof step.as === "string") {
39
+ inlineNames.push({ name: step.as, path: stepPath });
40
+ }
41
+ continue;
42
+ }
43
+
44
+ // Wait step
45
+ if (isWaitStep(step)) {
46
+ continue;
47
+ }
48
+
49
+ const obj = step as Record<string, unknown>;
50
+
51
+ if (obj.parallel) {
52
+ collectParticipantReferences(obj.parallel as unknown[], refs, inlineNames, `${stepPath}.parallel`);
53
+ continue;
54
+ }
55
+
56
+ // Set step — skip, not a participant reference
57
+ if ("set" in obj && Object.keys(obj).length === 1) {
58
+ continue;
59
+ }
60
+
61
+ if (obj.loop) {
62
+ const loopDef = obj.loop as Record<string, unknown>;
63
+ collectParticipantReferences(
64
+ (loopDef.steps ?? []) as unknown[],
65
+ refs,
66
+ inlineNames,
67
+ `${stepPath}.loop.steps`,
68
+ );
69
+ continue;
70
+ }
71
+
72
+ if (obj.if) {
73
+ const ifDef = obj.if as Record<string, unknown>;
74
+ collectParticipantReferences(
75
+ (ifDef.then ?? []) as unknown[],
76
+ refs,
77
+ inlineNames,
78
+ `${stepPath}.if.then`,
79
+ );
80
+ if (ifDef.else) {
81
+ collectParticipantReferences(
82
+ ifDef.else as unknown[],
83
+ refs,
84
+ inlineNames,
85
+ `${stepPath}.if.else`,
86
+ );
87
+ }
88
+ continue;
89
+ }
90
+
91
+ // Participant override
92
+ const keys = Object.keys(obj);
93
+ if (keys.length === 1) {
94
+ refs.push({ name: keys[0], path: `${stepPath}.${keys[0]}` });
95
+ }
96
+ }
97
+ }
98
+
99
+ function validateCel(expr: string, errors: ValidationError[], path: string): void {
100
+ const validation = validateCelExpression(expr);
101
+ if (!validation.valid) {
102
+ errors.push({ path, message: validation.error ?? "invalid CEL expression" });
103
+ }
104
+ }
105
+
106
+ function validateFlowCel(flow: unknown[], errors: ValidationError[], basePath = "flow"): void {
107
+ for (const [index, step] of flow.entries()) {
108
+ const stepPath = `${basePath}[${index}]`;
109
+
110
+ if (!step || typeof step !== "object") {
111
+ continue;
112
+ }
113
+
114
+ const obj = step as Record<string, unknown>;
115
+
116
+ // Wait step CEL
117
+ if (isWaitStep(step)) {
118
+ const waitDef = obj.wait as Record<string, unknown>;
119
+ if (waitDef.match && typeof waitDef.match === "string") {
120
+ validateCel(waitDef.match, errors, `${stepPath}.wait.match`);
121
+ }
122
+ if (waitDef.until && typeof waitDef.until === "string") {
123
+ validateCel(waitDef.until, errors, `${stepPath}.wait.until`);
124
+ }
125
+ continue;
126
+ }
127
+
128
+ // Inline participant CEL
129
+ if (isInlineParticipant(step)) {
130
+ if (obj.when && typeof obj.when === "string") {
131
+ validateCel(obj.when, errors, `${stepPath}.when`);
132
+ }
133
+ if (typeof obj.input === "string") {
134
+ validateCel(obj.input, errors, `${stepPath}.input`);
135
+ }
136
+ if (obj.input && typeof obj.input === "object") {
137
+ for (const [key, value] of Object.entries(obj.input as Record<string, unknown>)) {
138
+ if (typeof value === "string") {
139
+ validateCel(value, errors, `${stepPath}.input.${key}`);
140
+ }
141
+ }
142
+ }
143
+ // Emit payload CEL
144
+ if (obj.type === "emit" && obj.payload && typeof obj.payload === "string") {
145
+ validateCel(obj.payload, errors, `${stepPath}.payload`);
146
+ }
147
+ continue;
148
+ }
149
+
150
+ // Set step CEL
151
+ if ("set" in obj && Object.keys(obj).length === 1) {
152
+ const setDef = obj.set as Record<string, string>;
153
+ for (const [key, expr] of Object.entries(setDef)) {
154
+ if (RESERVED_NAMES.has(key)) {
155
+ errors.push({
156
+ path: `${stepPath}.set.${key}`,
157
+ message: `set key '${key}' uses a reserved name`,
158
+ });
159
+ }
160
+ if (typeof expr === "string") {
161
+ validateCel(expr, errors, `${stepPath}.set.${key}`);
162
+ }
163
+ }
164
+ continue;
165
+ }
166
+
167
+ if (obj.loop) {
168
+ const loopDef = obj.loop as Record<string, unknown>;
169
+ if (loopDef.until && typeof loopDef.until === "string") {
170
+ validateCel(loopDef.until, errors, `${stepPath}.loop.until`);
171
+ }
172
+ validateFlowCel(
173
+ (loopDef.steps ?? []) as unknown[],
174
+ errors,
175
+ `${stepPath}.loop.steps`,
176
+ );
177
+ continue;
178
+ }
179
+
180
+ if (obj.parallel) {
181
+ validateFlowCel(obj.parallel as unknown[], errors, `${stepPath}.parallel`);
182
+ continue;
183
+ }
184
+
185
+ if (obj.if) {
186
+ const ifDef = obj.if as Record<string, unknown>;
187
+ if (ifDef.condition && typeof ifDef.condition === "string") {
188
+ validateCel(ifDef.condition, errors, `${stepPath}.if.condition`);
189
+ }
190
+ validateFlowCel((ifDef.then ?? []) as unknown[], errors, `${stepPath}.if.then`);
191
+ if (ifDef.else) {
192
+ validateFlowCel(ifDef.else as unknown[], errors, `${stepPath}.if.else`);
193
+ }
194
+ continue;
195
+ }
196
+
197
+ // Participant override
198
+ const keys = Object.keys(obj);
199
+ if (keys.length !== 1) continue;
200
+
201
+ const participantName = keys[0];
202
+ const override = obj[participantName];
203
+ if (!override || typeof override !== "object") continue;
204
+
205
+ const ov = override as Record<string, unknown>;
206
+ if (ov.when && typeof ov.when === "string") {
207
+ validateCel(ov.when, errors, `${stepPath}.${participantName}.when`);
208
+ }
209
+ if (typeof ov.input === "string") {
210
+ validateCel(ov.input, errors, `${stepPath}.${participantName}.input`);
211
+ }
212
+ if (ov.input && typeof ov.input === "object") {
213
+ for (const [key, value] of Object.entries(ov.input as Record<string, unknown>)) {
214
+ if (typeof value === "string") {
215
+ validateCel(value, errors, `${stepPath}.${participantName}.input.${key}`);
216
+ }
217
+ }
218
+ }
219
+ }
220
+ }
221
+
222
+ function validateLoopConstraints(flow: unknown[], errors: ValidationError[], basePath = "flow"): void {
223
+ for (const [index, step] of flow.entries()) {
224
+ const stepPath = `${basePath}[${index}]`;
225
+
226
+ if (!step || typeof step !== "object") continue;
227
+
228
+ const obj = step as Record<string, unknown>;
229
+
230
+ if (obj.loop) {
231
+ const loopDef = obj.loop as Record<string, unknown>;
232
+ if (loopDef.until == null && loopDef.max == null) {
233
+ errors.push({
234
+ path: `${stepPath}.loop`,
235
+ message: "loop must define at least one of 'until' or 'max'",
236
+ });
237
+ }
238
+ // Validate loop.as doesn't conflict with reserved names
239
+ if (loopDef.as && typeof loopDef.as === "string" && RESERVED_NAMES.has(loopDef.as)) {
240
+ errors.push({
241
+ path: `${stepPath}.loop.as`,
242
+ message: `loop.as '${loopDef.as}' conflicts with reserved name`,
243
+ });
244
+ }
245
+ validateLoopConstraints(
246
+ (loopDef.steps ?? []) as unknown[],
247
+ errors,
248
+ `${stepPath}.loop.steps`,
249
+ );
250
+ continue;
251
+ }
252
+
253
+ if (obj.parallel) {
254
+ validateLoopConstraints(obj.parallel as unknown[], errors, `${stepPath}.parallel`);
255
+ continue;
256
+ }
257
+
258
+ if (obj.if) {
259
+ const ifDef = obj.if as Record<string, unknown>;
260
+ validateLoopConstraints((ifDef.then ?? []) as unknown[], errors, `${stepPath}.if.then`);
261
+ if (ifDef.else) {
262
+ validateLoopConstraints(ifDef.else as unknown[], errors, `${stepPath}.if.else`);
263
+ }
264
+ }
265
+ }
266
+ }
267
+
268
+ function validateWaitSteps(flow: unknown[], errors: ValidationError[], basePath = "flow"): void {
269
+ for (const [index, step] of flow.entries()) {
270
+ const stepPath = `${basePath}[${index}]`;
271
+
272
+ if (!step || typeof step !== "object") continue;
273
+
274
+ if (isWaitStep(step)) {
275
+ const waitDef = (step as { wait: Record<string, unknown> }).wait;
276
+
277
+ // Validate wait modes
278
+ const hasEvent = !!waitDef.event;
279
+ const hasUntil = !!waitDef.until;
280
+
281
+ if (hasEvent && hasUntil) {
282
+ errors.push({
283
+ path: `${stepPath}.wait`,
284
+ message: "wait step cannot have both 'event' and 'until'",
285
+ });
286
+ }
287
+
288
+ // Event mode: event required
289
+ if (waitDef.match && !hasEvent) {
290
+ errors.push({
291
+ path: `${stepPath}.wait.match`,
292
+ message: "wait.match requires wait.event",
293
+ });
294
+ }
295
+
296
+ // Polling mode: until required for poll
297
+ if (waitDef.poll && !hasUntil) {
298
+ errors.push({
299
+ path: `${stepPath}.wait.poll`,
300
+ message: "wait.poll requires wait.until",
301
+ });
302
+ }
303
+
304
+ // onTimeout validation
305
+ if (waitDef.onTimeout && typeof waitDef.onTimeout === "string") {
306
+ if (waitDef.onTimeout !== "fail" && waitDef.onTimeout !== "skip") {
307
+ // Could be a participant name - validated elsewhere
308
+ }
309
+ }
310
+ continue;
311
+ }
312
+
313
+ const obj = step as Record<string, unknown>;
314
+ if (obj.loop) {
315
+ validateWaitSteps(
316
+ ((obj.loop as Record<string, unknown>).steps ?? []) as unknown[],
317
+ errors,
318
+ `${stepPath}.loop.steps`,
319
+ );
320
+ }
321
+ if (obj.parallel) {
322
+ validateWaitSteps(obj.parallel as unknown[], errors, `${stepPath}.parallel`);
323
+ }
324
+ if (obj.if) {
325
+ const ifDef = obj.if as Record<string, unknown>;
326
+ validateWaitSteps((ifDef.then ?? []) as unknown[], errors, `${stepPath}.if.then`);
327
+ if (ifDef.else) {
328
+ validateWaitSteps(ifDef.else as unknown[], errors, `${stepPath}.if.else`);
329
+ }
330
+ }
331
+ }
332
+ }
333
+
334
+ function validateInlineParticipantFields(
335
+ flow: unknown[],
336
+ errors: ValidationError[],
337
+ basePath = "flow",
338
+ ): void {
339
+ for (const [index, step] of flow.entries()) {
340
+ const stepPath = `${basePath}[${index}]`;
341
+
342
+ if (!step || typeof step !== "object") continue;
343
+
344
+ if (isInlineParticipant(step)) {
345
+ validateSingleParticipantRequiredFields(
346
+ step as { type: string; [key: string]: unknown },
347
+ stepPath,
348
+ errors,
349
+ );
350
+ continue;
351
+ }
352
+
353
+ const obj = step as Record<string, unknown>;
354
+ if (obj.loop) {
355
+ const loopDef = obj.loop as Record<string, unknown>;
356
+ validateInlineParticipantFields(
357
+ (loopDef.steps ?? []) as unknown[],
358
+ errors,
359
+ `${stepPath}.loop.steps`,
360
+ );
361
+ }
362
+ if (obj.parallel) {
363
+ validateInlineParticipantFields(obj.parallel as unknown[], errors, `${stepPath}.parallel`);
364
+ }
365
+ if (obj.if) {
366
+ const ifDef = obj.if as Record<string, unknown>;
367
+ validateInlineParticipantFields((ifDef.then ?? []) as unknown[], errors, `${stepPath}.if.then`);
368
+ if (ifDef.else) {
369
+ validateInlineParticipantFields(ifDef.else as unknown[], errors, `${stepPath}.if.else`);
370
+ }
371
+ }
372
+ }
373
+ }
374
+
375
+ function validateParticipantRequiredFields(
376
+ participants: Record<string, { type: string; [key: string]: unknown }>,
377
+ errors: ValidationError[],
378
+ ): void {
379
+ for (const [name, p] of Object.entries(participants)) {
380
+ validateSingleParticipantRequiredFields(p, `participants.${name}`, errors);
381
+ }
382
+ }
383
+
384
+ function validateSingleParticipantRequiredFields(
385
+ p: { type: string; [key: string]: unknown },
386
+ path: string,
387
+ errors: ValidationError[],
388
+ ): void {
389
+ switch (p.type) {
390
+ case "exec":
391
+ if (!p.run) {
392
+ errors.push({ path: `${path}.run`, message: "exec participant requires 'run' field" });
393
+ }
394
+ break;
395
+ case "http":
396
+ if (!p.url) {
397
+ errors.push({ path: `${path}.url`, message: "http participant requires 'url' field" });
398
+ }
399
+ break;
400
+ case "workflow":
401
+ if (!p.path) {
402
+ errors.push({ path: `${path}.path`, message: "workflow participant requires 'path' field" });
403
+ }
404
+ break;
405
+ case "emit":
406
+ if (!p.event) {
407
+ errors.push({ path: `${path}.event`, message: "emit participant requires 'event' field" });
408
+ }
409
+ break;
410
+ }
411
+ }
412
+
413
+ function validateEmitParticipants(
414
+ participants: Record<string, { type: string; event?: string }>,
415
+ errors: ValidationError[],
416
+ ): void {
417
+ for (const [name, p] of Object.entries(participants)) {
418
+ if (p.type === "emit" && !p.event) {
419
+ errors.push({
420
+ path: `participants.${name}.event`,
421
+ message: "emit participant requires 'event' field",
422
+ });
423
+ }
424
+ }
425
+ }
426
+
427
+ export async function validateSemantic(workflow: Workflow, basePath: string): Promise<ValidationResult> {
428
+ const errors: ValidationError[] = [];
429
+ const participants = workflow.participants ?? {};
430
+ const participantNames = new Set(Object.keys(participants));
431
+
432
+ // Validate reserved names
433
+ for (const name of participantNames) {
434
+ if (RESERVED_NAMES.has(name)) {
435
+ errors.push({ path: `participants.${name}`, message: "participant name is reserved" });
436
+ }
437
+ }
438
+
439
+ // Validate flow is non-empty
440
+ if (!workflow.flow || workflow.flow.length === 0) {
441
+ errors.push({ path: "flow", message: "flow must contain at least one step" });
442
+ }
443
+
444
+ // Collect references and inline names
445
+ const refs: Array<{ name: string; path: string }> = [];
446
+ const inlineNames: Array<{ name: string; path: string }> = [];
447
+ collectParticipantReferences(workflow.flow ?? [], refs, inlineNames);
448
+
449
+ // Validate participant references exist
450
+ for (const ref of refs) {
451
+ if (!participantNames.has(ref.name)) {
452
+ errors.push({ path: ref.path, message: `participant '${ref.name}' does not exist` });
453
+ }
454
+ }
455
+
456
+ // Validate inline `as` uniqueness
457
+ const seenInlineNames = new Set<string>();
458
+ for (const inline of inlineNames) {
459
+ if (participantNames.has(inline.name)) {
460
+ errors.push({
461
+ path: inline.path,
462
+ message: `inline participant 'as: ${inline.name}' conflicts with top-level participant`,
463
+ });
464
+ }
465
+ if (RESERVED_NAMES.has(inline.name)) {
466
+ errors.push({
467
+ path: inline.path,
468
+ message: `inline participant 'as: ${inline.name}' uses a reserved name`,
469
+ });
470
+ }
471
+ if (seenInlineNames.has(inline.name)) {
472
+ errors.push({
473
+ path: inline.path,
474
+ message: `inline participant 'as: ${inline.name}' is not unique`,
475
+ });
476
+ }
477
+ seenInlineNames.add(inline.name);
478
+ }
479
+
480
+ // Validate inline participants cannot be reused as string references (§3.1)
481
+ for (const ref of refs) {
482
+ if (seenInlineNames.has(ref.name)) {
483
+ errors.push({
484
+ path: ref.path,
485
+ message: `participant reference '${ref.name}' refers to an inline participant, which cannot be reused`,
486
+ });
487
+ }
488
+ }
489
+
490
+ // Validate onError references
491
+ const defaultsOnError = workflow.defaults?.onError;
492
+ if (defaultsOnError && !BUILTIN_ONERROR.has(defaultsOnError) && !participantNames.has(defaultsOnError)) {
493
+ errors.push({
494
+ path: "defaults.onError",
495
+ message: `onError fallback '${defaultsOnError}' does not reference an existing participant`,
496
+ });
497
+ }
498
+
499
+ for (const [name, participant] of Object.entries(participants)) {
500
+ if (participant.onError && !BUILTIN_ONERROR.has(participant.onError) && !participantNames.has(participant.onError)) {
501
+ errors.push({
502
+ path: `participants.${name}.onError`,
503
+ message: `onError fallback '${participant.onError}' does not reference an existing participant`,
504
+ });
505
+ }
506
+ }
507
+
508
+ // Validate type-specific required fields (top-level participants)
509
+ validateParticipantRequiredFields(participants, errors);
510
+
511
+ // Validate inline participant required fields in flow
512
+ validateInlineParticipantFields(workflow.flow ?? [], errors);
513
+
514
+ // Validate emit participants
515
+ validateEmitParticipants(participants as Record<string, { type: string; event?: string }>, errors);
516
+
517
+ // Validate loop, wait, CEL
518
+ validateLoopConstraints(workflow.flow ?? [], errors);
519
+ validateWaitSteps(workflow.flow ?? [], errors);
520
+ validateFlowCel(workflow.flow ?? [], errors);
521
+
522
+ // Validate sub-workflow paths
523
+ for (const [name, participant] of Object.entries(participants)) {
524
+ if (participant.type !== "workflow") continue;
525
+
526
+ const resolvedPath = resolve(basePath, (participant as { path: string }).path);
527
+ const exists = await access(resolvedPath, constants.F_OK).then(
528
+ () => true,
529
+ () => false,
530
+ );
531
+
532
+ if (!exists) {
533
+ errors.push({
534
+ path: `participants.${name}.path`,
535
+ message: `sub-workflow path does not exist: ${resolvedPath}`,
536
+ });
537
+ }
538
+ }
539
+
540
+ return { valid: errors.length === 0, errors };
541
+ }