@kindgi/flow 0.0.0-bootstrap.0 → 0.1.0

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/loader.js ADDED
@@ -0,0 +1,1051 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+ import { compileInlineSchema, createSpecRegistry } from '@kindgi/schema';
4
+ import flowSchema from './flow.schema.json' with { type: 'json' };
5
+ import { CONVERGENCE_MODES, END_NODE, LOOP_END_NODE, LOOP_START_NODE, OPERATORS, SENTINEL_IDS, START_NODE, SUBGRAPH_CONVERGENCE_MODES, isFanoutNode, isLeafNode, isLoopNode, isSubflowNode, } from './types.js';
6
+ const GRAPH_SCHEMA_ID = 'https://kindgi.com/schemas/v1/flow.schema.json';
7
+ // Registry is lazy — first loadFlow() call builds it once, subsequent calls reuse.
8
+ let cachedRegistry;
9
+ function registry() {
10
+ if (cachedRegistry !== undefined)
11
+ return cachedRegistry;
12
+ const built = createSpecRegistry([flowSchema]);
13
+ if (built.kind === 'err') {
14
+ throw new Error(`@kindgi/flow: bundled schema failed to compile: ${built.error.message}`);
15
+ }
16
+ cachedRegistry = built.value;
17
+ return cachedRegistry;
18
+ }
19
+ /**
20
+ * The semantic passes after schema conformance, in order (fail-fast);
21
+ * see `loadFlow` for what each covers.
22
+ */
23
+ const SEMANTIC_CHECKS = [
24
+ checkReservedIds,
25
+ checkGlobalDuplicateNodes,
26
+ checkGlobalDuplicateEdges,
27
+ checkOuterEdgeReferences,
28
+ checkAllPredicates,
29
+ checkAllEdgePolicies,
30
+ checkAllLoopMaxIterations,
31
+ checkAllLoopVariants,
32
+ checkAllLoopOutputSchemas,
33
+ checkAllLoopConcurrency,
34
+ checkAllFanoutNodes,
35
+ checkAllSubflowNodes,
36
+ checkAllMappings,
37
+ checkFlowOutputSchema,
38
+ checkStartEdge,
39
+ detectOuterCycle,
40
+ validateAllLoopBodies,
41
+ ];
42
+ /**
43
+ * Validate a flow definition and return a strongly-typed `Flow` on success.
44
+ *
45
+ * Runs in this order (fail-fast; `SEMANTIC_CHECKS` after step 1):
46
+ * 1. JSON-schema conformance against flow.schema.json.
47
+ * 2. Reserved-id check — sentinels ($start/$end/$loop-start/$loop-end) may
48
+ * not be node ids, at any nesting level.
49
+ * 3. GLOBAL uniqueness of node ids across outer flow AND every loop body
50
+ * at every nesting level.
51
+ * 4. Duplicate edge ids across outer + all bodies.
52
+ * 5. Outer-edge endpoint resolution (from/to must be a known outer-scope
53
+ * node or $start/$end).
54
+ * 6. Predicate structural check on every `when` (outer + body edges) and
55
+ * every loop node's `exitCondition`.
56
+ * 7. Edge-policy bounds (`retry`, `timeoutMs`, `concurrencyKey`,
57
+ * `priority`).
58
+ * 8. `maxIterations` range check for every loop node.
59
+ * 9. Loop variant integrity (`while` vs `foreach` fields).
60
+ * 10. Every loop `outputSchema` compiles.
61
+ * 11. Foreach `concurrency` bounds.
62
+ * 12. Fanout node rules (branches, `concurrency`, `convergence`,
63
+ * per-branch `outputSchema`).
64
+ * 13. Subgraph node rules (`flowRef`, `inputMapping`, `outputSchema`,
65
+ * `convergence`).
66
+ * 14. Data-flow mappings (1.8.0): every leaf `inputMapping` and the
67
+ * flow's `output.mapping` names only nodes of the flow, never the
68
+ * node's own output, and no loop-only root.
69
+ * 15. The flow's `output.schema` compiles.
70
+ * 16. Outer flow must have at least one $start-rooted edge.
71
+ * 17. Outer flow cycle detection (Kahn's algorithm on the outer DAG).
72
+ * 18. Per loop-body: has a $loop-start-rooted edge; body-edge endpoints
73
+ * resolve to body nodes or $loop-start / $loop-end sentinels; body
74
+ * sub-DAG cycle detection.
75
+ *
76
+ * The loader is pure: no I/O, no side effects, safe to call from anywhere.
77
+ */
78
+ export function loadFlow(input) {
79
+ const schemaResult = registry().validate(GRAPH_SCHEMA_ID, input);
80
+ if (schemaResult.kind === 'err') {
81
+ const err = schemaResult.error;
82
+ if (err.code !== 'validation-error') {
83
+ throw new Error(`@kindgi/flow: unexpected schema error ${err.code}: ${err.message}`);
84
+ }
85
+ return { kind: 'err', error: toSchemaValidationError(err) };
86
+ }
87
+ const flow = schemaResult.value;
88
+ for (const check of SEMANTIC_CHECKS) {
89
+ const error = check(flow);
90
+ if (error !== undefined)
91
+ return { kind: 'err', error };
92
+ }
93
+ return { kind: 'ok', value: flow };
94
+ }
95
+ function toSchemaValidationError(err) {
96
+ const issues = err.errors.map((e) => {
97
+ const ajvErr = e;
98
+ return {
99
+ path: typeof ajvErr.instancePath === 'string' ? ajvErr.instancePath : '',
100
+ message: typeof ajvErr.message === 'string' ? ajvErr.message : 'validation failed',
101
+ };
102
+ });
103
+ return {
104
+ code: 'schema-validation-failed',
105
+ message: err.message,
106
+ issues,
107
+ };
108
+ }
109
+ // ============ reserved ids ============
110
+ function checkReservedIds(flow) {
111
+ const sentinels = new Set(SENTINEL_IDS);
112
+ for (const node of collectAllNodes(flow)) {
113
+ if (sentinels.has(node.id)) {
114
+ return {
115
+ code: 'reserved-id',
116
+ message: `Node id "${node.id}" is reserved as a flow sentinel and may not name a node`,
117
+ flowId: flow.id,
118
+ nodeId: node.id,
119
+ };
120
+ }
121
+ }
122
+ return undefined;
123
+ }
124
+ // ============ global duplicate detection (walks into loop bodies) ============
125
+ function checkGlobalDuplicateNodes(flow) {
126
+ const seen = new Set();
127
+ for (const node of collectAllNodes(flow)) {
128
+ if (seen.has(node.id)) {
129
+ return {
130
+ code: 'duplicate-node-id',
131
+ message: `Node id "${node.id}" is used more than once (across outer flow and loop bodies)`,
132
+ flowId: flow.id,
133
+ nodeId: node.id,
134
+ };
135
+ }
136
+ seen.add(node.id);
137
+ }
138
+ return undefined;
139
+ }
140
+ function checkGlobalDuplicateEdges(flow) {
141
+ const seen = new Set();
142
+ for (const edge of flow.edges) {
143
+ if (seen.has(edge.id))
144
+ return dupEdge(flow, edge.id);
145
+ seen.add(edge.id);
146
+ }
147
+ for (const loop of collectAllLoopNodes(flow)) {
148
+ for (const edge of loop.body.edges) {
149
+ if (seen.has(edge.id))
150
+ return dupEdge(flow, edge.id);
151
+ seen.add(edge.id);
152
+ }
153
+ }
154
+ return undefined;
155
+ }
156
+ function dupEdge(flow, edgeId) {
157
+ return {
158
+ code: 'duplicate-edge-id',
159
+ message: `Edge id "${edgeId}" is used more than once (across outer flow and loop bodies)`,
160
+ flowId: flow.id,
161
+ edgeId: edgeId,
162
+ };
163
+ }
164
+ /** Flatten every node in the flow (outer + all loop bodies at any nesting). */
165
+ function collectAllNodes(flow) {
166
+ const acc = [];
167
+ const walk = (nodes) => {
168
+ for (const node of nodes) {
169
+ acc.push(node);
170
+ if (isLoopNode(node))
171
+ walk(node.body.nodes);
172
+ }
173
+ };
174
+ walk(flow.nodes);
175
+ return acc;
176
+ }
177
+ function collectAllLoopNodes(flow) {
178
+ const acc = [];
179
+ for (const node of collectAllNodes(flow)) {
180
+ if (isLoopNode(node))
181
+ acc.push(node);
182
+ }
183
+ return acc;
184
+ }
185
+ // ============ outer-flow edge reference resolution ============
186
+ function checkOuterEdgeReferences(flow) {
187
+ const outerNodeIds = new Set(flow.nodes.map((n) => n.id));
188
+ for (const edge of flow.edges) {
189
+ if (edge.from !== START_NODE && !outerNodeIds.has(edge.from)) {
190
+ return unknownEdgeRef(flow, edge, 'from', edge.from);
191
+ }
192
+ if (edge.to !== END_NODE && !outerNodeIds.has(edge.to)) {
193
+ return unknownEdgeRef(flow, edge, 'to', edge.to);
194
+ }
195
+ }
196
+ return undefined;
197
+ }
198
+ function unknownEdgeRef(flow, edge, endpoint, referencedId) {
199
+ return {
200
+ code: 'unknown-node-reference',
201
+ message: `Edge "${edge.id}" ${endpoint} unknown node "${referencedId}"`,
202
+ flowId: flow.id,
203
+ edgeId: edge.id,
204
+ endpoint,
205
+ referencedId,
206
+ };
207
+ }
208
+ // ============ start-edge check (outer flow) ============
209
+ function checkStartEdge(flow) {
210
+ const hasStart = flow.edges.some((e) => e.from === START_NODE);
211
+ if (hasStart)
212
+ return undefined;
213
+ return {
214
+ code: 'missing-start-edge',
215
+ message: `Flow "${flow.id}" has no edge originating at $start`,
216
+ flowId: flow.id,
217
+ };
218
+ }
219
+ // ============ predicate validation (outer edges + body edges + loop exit conditions) ============
220
+ function checkAllPredicates(flow) {
221
+ for (const edge of flow.edges) {
222
+ if (edge.when === undefined)
223
+ continue;
224
+ const reason = validateExprStructure(edge.when);
225
+ if (reason !== undefined) {
226
+ return {
227
+ code: 'invalid-predicate',
228
+ message: `Edge "${edge.id}" has an invalid predicate: ${reason}`,
229
+ flowId: flow.id,
230
+ edgeId: edge.id,
231
+ reason,
232
+ };
233
+ }
234
+ }
235
+ for (const loop of collectAllLoopNodes(flow)) {
236
+ if (loop.loopKind === 'while') {
237
+ const exitReason = validateExprStructure(loop.exitCondition);
238
+ if (exitReason !== undefined) {
239
+ return {
240
+ code: 'invalid-predicate',
241
+ message: `Loop node "${loop.id}" has an invalid exitCondition: ${exitReason}`,
242
+ flowId: flow.id,
243
+ edgeId: loop.id,
244
+ reason: exitReason,
245
+ };
246
+ }
247
+ }
248
+ for (const edge of loop.body.edges) {
249
+ if (edge.when === undefined)
250
+ continue;
251
+ const reason = validateExprStructure(edge.when);
252
+ if (reason !== undefined) {
253
+ return {
254
+ code: 'invalid-predicate',
255
+ message: `Loop "${loop.id}" body edge "${edge.id}" has an invalid predicate: ${reason}`,
256
+ flowId: flow.id,
257
+ edgeId: edge.id,
258
+ reason,
259
+ };
260
+ }
261
+ }
262
+ }
263
+ return undefined;
264
+ }
265
+ function validateExprStructure(expr) {
266
+ if (!OPERATORS.includes(expr.op)) {
267
+ return `unknown operator "${expr.op}"`;
268
+ }
269
+ switch (expr.op) {
270
+ case 'eq':
271
+ case 'ne':
272
+ case 'lt':
273
+ case 'lte':
274
+ case 'gt':
275
+ case 'gte':
276
+ return validateBinaryOperands(expr.left, expr.right);
277
+ case 'in':
278
+ case 'notIn':
279
+ return validateMembership(expr.value, expr.set);
280
+ case 'exists':
281
+ case 'notExists':
282
+ case 'truthy':
283
+ case 'falsy':
284
+ return validateOperand(expr.value);
285
+ case 'and':
286
+ case 'or':
287
+ return validateChildren(expr.op, expr.children);
288
+ case 'not':
289
+ return validateExprStructure(expr.child);
290
+ }
291
+ }
292
+ function validateBinaryOperands(left, right) {
293
+ const l = validateOperand(left);
294
+ if (l)
295
+ return `left: ${l}`;
296
+ const r = validateOperand(right);
297
+ if (r)
298
+ return `right: ${r}`;
299
+ return undefined;
300
+ }
301
+ function validateMembership(value, set) {
302
+ const v = validateOperand(value);
303
+ if (v)
304
+ return `value: ${v}`;
305
+ const s = validateOperand(set);
306
+ if (s)
307
+ return `set: ${s}`;
308
+ return undefined;
309
+ }
310
+ function validateChildren(op, children) {
311
+ if (children.length === 0)
312
+ return `${op} requires at least one child`;
313
+ for (const child of children) {
314
+ const reason = validateExprStructure(child);
315
+ if (reason)
316
+ return reason;
317
+ }
318
+ return undefined;
319
+ }
320
+ function validateOperand(operand) {
321
+ if ('literal' in operand)
322
+ return undefined;
323
+ if ('path' in operand) {
324
+ if (typeof operand.path !== 'string' || operand.path.length === 0) {
325
+ return 'path must be a non-empty string';
326
+ }
327
+ return undefined;
328
+ }
329
+ return 'operand must be a {literal} or {path}';
330
+ }
331
+ // ============ edge policy validation (retry, timeoutMs, concurrencyKey, priority) ============
332
+ /**
333
+ * Validate `FlowEdge.policy` on outer + loop-body edges. Schema catches the
334
+ * shape (wrong types, unknown fields); this pass enforces the semantic
335
+ * rules the schema can't express:
336
+ * - `retry.maxAttempts` in `[1, 10]`.
337
+ * - `retry.delayMs` non-negative (the schema also enforces integer ≥ 0;
338
+ * repeated so this check stands on its own).
339
+ * - `retry.backoff` in `{'fixed', 'linear', 'exponential'}`.
340
+ * - `retry.backoff = 'exponential'` requires `retry.maxDelayMs`.
341
+ * - `timeoutMs` in `[1, 3_600_000]` (1 hour cap).
342
+ * - `concurrencyKey` non-empty, ≤ 256 chars, printable ASCII only.
343
+ * - `priority` integer in `[-100, 100]` (default 0 when omitted).
344
+ *
345
+ * `EdgePolicy` is a closed surface — `retry`, `timeoutMs`,
346
+ * `concurrencyKey`, and `priority` are the complete set. There is no
347
+ * `suspend` field; declarative suspension uses `ctx.waitForToken` at
348
+ * handler level. Unknown edge-policy keys are
349
+ * rejected at the schema step (`additionalProperties: false`).
350
+ */
351
+ function checkAllEdgePolicies(flow) {
352
+ for (const edge of flow.edges) {
353
+ const err = checkEdgePolicy(flow, edge.id, edge.policy);
354
+ if (err)
355
+ return err;
356
+ }
357
+ // Loop-body edges (LoopEdge) carry no `policy`: the JSON schema's
358
+ // `LoopEdge` has `additionalProperties: false`, so a `policy` on a body
359
+ // edge is rejected at the schema step.
360
+ return undefined;
361
+ }
362
+ function checkEdgePolicy(flow, edgeId, policy) {
363
+ if (policy === undefined)
364
+ return undefined;
365
+ if (policy.retry !== undefined) {
366
+ const retryErr = checkRetryPolicy(flow, edgeId, policy.retry);
367
+ if (retryErr)
368
+ return retryErr;
369
+ }
370
+ if (policy.timeoutMs !== undefined) {
371
+ const timeoutErr = checkTimeoutMs(flow, edgeId, policy.timeoutMs);
372
+ if (timeoutErr)
373
+ return timeoutErr;
374
+ }
375
+ if (policy.concurrencyKey !== undefined) {
376
+ const keyErr = checkConcurrencyKey(flow, edgeId, policy.concurrencyKey);
377
+ if (keyErr)
378
+ return keyErr;
379
+ }
380
+ if (policy.priority !== undefined) {
381
+ const priorityErr = checkPriority(flow, edgeId, policy.priority);
382
+ if (priorityErr)
383
+ return priorityErr;
384
+ }
385
+ return undefined;
386
+ }
387
+ const TIMEOUT_MS_MAX = 3_600_000;
388
+ const CONCURRENCY_KEY_MAX_LENGTH = 256;
389
+ const PRINTABLE_ASCII_REGEX = /^[\x20-\x7E]+$/;
390
+ const PRIORITY_MIN = -100;
391
+ const PRIORITY_MAX = 100;
392
+ function checkPriority(flow, edgeId, priority) {
393
+ if (typeof priority !== 'number') {
394
+ return policyErr(flow, edgeId, 'priority', `must be a number (got ${typeof priority})`);
395
+ }
396
+ if (!Number.isInteger(priority)) {
397
+ return policyErr(flow, edgeId, 'priority', `must be an integer (got ${priority})`);
398
+ }
399
+ if (priority < PRIORITY_MIN || priority > PRIORITY_MAX) {
400
+ return policyErr(flow, edgeId, 'priority', `must be within [${PRIORITY_MIN}, ${PRIORITY_MAX}] (got ${priority})`);
401
+ }
402
+ return undefined;
403
+ }
404
+ function checkConcurrencyKey(flow, edgeId, key) {
405
+ if (typeof key !== 'string') {
406
+ return policyErr(flow, edgeId, 'concurrencyKey', 'must be a string');
407
+ }
408
+ if (key.length === 0) {
409
+ return policyErr(flow, edgeId, 'concurrencyKey', 'must not be empty');
410
+ }
411
+ if (key.length > CONCURRENCY_KEY_MAX_LENGTH) {
412
+ return policyErr(flow, edgeId, 'concurrencyKey', `must be <= ${CONCURRENCY_KEY_MAX_LENGTH} chars (got ${key.length})`);
413
+ }
414
+ if (!PRINTABLE_ASCII_REGEX.test(key)) {
415
+ return policyErr(flow, edgeId, 'concurrencyKey', 'must contain only printable ASCII (0x20..0x7E)');
416
+ }
417
+ return undefined;
418
+ }
419
+ function checkTimeoutMs(flow, edgeId, timeoutMs) {
420
+ if (!Number.isInteger(timeoutMs)) {
421
+ return policyErr(flow, edgeId, 'timeoutMs', `must be an integer (got ${timeoutMs})`);
422
+ }
423
+ if (timeoutMs < 1) {
424
+ return policyErr(flow, edgeId, 'timeoutMs', `must be >= 1 (got ${timeoutMs})`);
425
+ }
426
+ if (timeoutMs > TIMEOUT_MS_MAX) {
427
+ return policyErr(flow, edgeId, 'timeoutMs', `must be <= ${TIMEOUT_MS_MAX} (1 hour cap; got ${timeoutMs})`);
428
+ }
429
+ return undefined;
430
+ }
431
+ function checkRetryPolicy(flow, edgeId, retry) {
432
+ if (!Number.isInteger(retry.maxAttempts)) {
433
+ return policyErr(flow, edgeId, 'retry.maxAttempts', 'must be an integer');
434
+ }
435
+ if (retry.maxAttempts < 1 || retry.maxAttempts > 10) {
436
+ return policyErr(flow, edgeId, 'retry.maxAttempts', `must be within [1, 10] (got ${retry.maxAttempts})`);
437
+ }
438
+ if (retry.delayMs !== undefined) {
439
+ if (!Number.isInteger(retry.delayMs) || retry.delayMs < 0) {
440
+ return policyErr(flow, edgeId, 'retry.delayMs', `must be a non-negative integer (got ${retry.delayMs})`);
441
+ }
442
+ }
443
+ if (retry.backoff !== undefined) {
444
+ if (retry.backoff !== 'fixed' &&
445
+ retry.backoff !== 'linear' &&
446
+ retry.backoff !== 'exponential') {
447
+ return policyErr(flow, edgeId, 'retry.backoff', `must be one of "fixed" | "linear" | "exponential" (got ${String(retry.backoff)})`);
448
+ }
449
+ }
450
+ if (retry.maxDelayMs !== undefined) {
451
+ if (!Number.isInteger(retry.maxDelayMs) || retry.maxDelayMs < 0) {
452
+ return policyErr(flow, edgeId, 'retry.maxDelayMs', `must be a non-negative integer (got ${retry.maxDelayMs})`);
453
+ }
454
+ }
455
+ if (retry.backoff === 'exponential' && retry.maxDelayMs === undefined) {
456
+ return policyErr(flow, edgeId, 'retry.maxDelayMs', 'is required when retry.backoff is "exponential" (unbounded growth otherwise)');
457
+ }
458
+ return undefined;
459
+ }
460
+ function policyErr(flow, edgeId, field, reason) {
461
+ return {
462
+ code: 'invalid-edge-policy',
463
+ message: `Edge "${edgeId}" has an invalid policy at ${field}: ${reason}`,
464
+ flowId: flow.id,
465
+ edgeId: edgeId,
466
+ field,
467
+ reason,
468
+ };
469
+ }
470
+ // ============ loop-specific bounds check ============
471
+ function checkAllLoopMaxIterations(flow) {
472
+ for (const loop of collectAllLoopNodes(flow)) {
473
+ if (!Number.isInteger(loop.maxIterations)) {
474
+ return loopBoundsErr(flow, loop, 'maxIterations must be an integer');
475
+ }
476
+ if (loop.maxIterations < 1) {
477
+ return loopBoundsErr(flow, loop, 'maxIterations must be >= 1');
478
+ }
479
+ if (loop.maxIterations > 10000) {
480
+ return loopBoundsErr(flow, loop, 'maxIterations must be <= 10000');
481
+ }
482
+ }
483
+ return undefined;
484
+ }
485
+ function loopBoundsErr(flow, loop, reason) {
486
+ return {
487
+ code: 'loop-invalid-max-iterations',
488
+ message: `Loop node "${loop.id}": ${reason} (got ${loop.maxIterations})`,
489
+ flowId: flow.id,
490
+ loopNodeId: loop.id,
491
+ value: loop.maxIterations,
492
+ };
493
+ }
494
+ // ============ loop variant integrity (direct check alongside the JSON-schema oneOf) ============
495
+ function checkAllLoopVariants(flow) {
496
+ for (const loop of collectAllLoopNodes(flow)) {
497
+ const err = checkLoopVariant(flow, loop);
498
+ if (err)
499
+ return err;
500
+ }
501
+ return undefined;
502
+ }
503
+ function checkLoopVariant(flow, loop) {
504
+ const raw = loop;
505
+ if (loop.loopKind === 'while') {
506
+ if (raw.exitCondition === undefined) {
507
+ return variantErr(flow, loop, 'while', 'while-missing-exit-condition');
508
+ }
509
+ if (raw.iterateOver !== undefined) {
510
+ return variantErr(flow, loop, 'while', 'while-has-iterate-over');
511
+ }
512
+ if (raw.concurrency !== undefined) {
513
+ // Parallel while loops are semantically undefined (each iteration
514
+ // depends on the previous iteration's output). Only foreach loops
515
+ // carry `concurrency`.
516
+ return variantErr(flow, loop, 'while', 'while-has-concurrency');
517
+ }
518
+ if (loop.evaluationTiming !== undefined &&
519
+ loop.evaluationTiming !== 'before' &&
520
+ loop.evaluationTiming !== 'after') {
521
+ return variantErr(flow, loop, 'while', 'while-has-invalid-evaluation-timing');
522
+ }
523
+ return undefined;
524
+ }
525
+ if (loop.loopKind === 'foreach') {
526
+ if (raw.iterateOver === undefined) {
527
+ return variantErr(flow, loop, 'foreach', 'foreach-missing-iterate-over');
528
+ }
529
+ if (raw.exitCondition !== undefined) {
530
+ return variantErr(flow, loop, 'foreach', 'foreach-has-exit-condition');
531
+ }
532
+ if (raw.evaluationTiming !== undefined) {
533
+ return variantErr(flow, loop, 'foreach', 'foreach-has-evaluation-timing');
534
+ }
535
+ return undefined;
536
+ }
537
+ return undefined;
538
+ }
539
+ function variantErr(flow, loop, loopKind, problem) {
540
+ return {
541
+ code: 'loop-variant-mismatch',
542
+ message: `Loop node "${loop.id}" (loopKind=${loopKind}): ${problem}`,
543
+ flowId: flow.id,
544
+ loopNodeId: loop.id,
545
+ loopKind,
546
+ problem,
547
+ };
548
+ }
549
+ // ============ foreach concurrency bounds check ============
550
+ const LOOP_CONCURRENCY_CAP = 32;
551
+ function checkAllLoopConcurrency(flow) {
552
+ for (const loop of collectAllLoopNodes(flow)) {
553
+ if (loop.loopKind !== 'foreach')
554
+ continue;
555
+ if (loop.concurrency === undefined)
556
+ continue;
557
+ const err = checkLoopConcurrency(flow, loop.id, loop.concurrency, loop.maxIterations);
558
+ if (err)
559
+ return err;
560
+ }
561
+ return undefined;
562
+ }
563
+ function checkLoopConcurrency(flow, loopNodeId, value, maxIterations) {
564
+ if (!Number.isInteger(value)) {
565
+ return concurrencyErr(flow, loopNodeId, value, 'not-integer', 'must be an integer');
566
+ }
567
+ if (value < 1) {
568
+ return concurrencyErr(flow, loopNodeId, value, 'below-minimum', 'must be >= 1');
569
+ }
570
+ if (value > LOOP_CONCURRENCY_CAP) {
571
+ return concurrencyErr(flow, loopNodeId, value, 'exceeds-cap', `must be <= ${LOOP_CONCURRENCY_CAP} (safety cap)`);
572
+ }
573
+ if (value > maxIterations) {
574
+ return concurrencyErr(flow, loopNodeId, value, 'exceeds-max-iterations', `must be <= maxIterations (${maxIterations})`);
575
+ }
576
+ return undefined;
577
+ }
578
+ function concurrencyErr(flow, loopNodeId, value, reason, detail) {
579
+ return {
580
+ code: 'loop-invalid-concurrency',
581
+ message: `Loop node "${loopNodeId}": concurrency ${detail} (got ${value})`,
582
+ flowId: flow.id,
583
+ loopNodeId,
584
+ value,
585
+ reason,
586
+ };
587
+ }
588
+ // ============ fanout node validation (1.6.0) ============
589
+ function collectAllFanoutNodes(flow) {
590
+ const acc = [];
591
+ for (const node of collectAllNodes(flow)) {
592
+ if (isFanoutNode(node))
593
+ acc.push(node);
594
+ }
595
+ return acc;
596
+ }
597
+ function checkAllFanoutNodes(flow) {
598
+ for (const fanout of collectAllFanoutNodes(flow)) {
599
+ const err = checkFanoutNode(flow, fanout);
600
+ if (err)
601
+ return err;
602
+ }
603
+ return undefined;
604
+ }
605
+ function checkFanoutNode(flow, fanout) {
606
+ // JSON schema minItems: 2 already catches this at the schema step;
607
+ // repeated so this check stands on its own.
608
+ if (!Array.isArray(fanout.branches) || fanout.branches.length < 2) {
609
+ return fanoutErr(flow, fanout.id, 'branches', 'must declare at least 2 branches');
610
+ }
611
+ const seenBranchIds = new Set();
612
+ for (const branch of fanout.branches) {
613
+ if (typeof branch.branchId !== 'string' || branch.branchId.length === 0) {
614
+ return fanoutErr(flow, fanout.id, 'branch-id', 'each branch must declare a non-empty string branchId');
615
+ }
616
+ if (seenBranchIds.has(branch.branchId)) {
617
+ return fanoutErr(flow, fanout.id, 'branch-id', `duplicate branchId "${branch.branchId}" within fanout`, branch.branchId);
618
+ }
619
+ seenBranchIds.add(branch.branchId);
620
+ if (typeof branch.handler !== 'string' || branch.handler.length === 0) {
621
+ return fanoutErr(flow, fanout.id, 'branch-handler', `branch "${branch.branchId}" must declare a non-empty string handler`, branch.branchId);
622
+ }
623
+ if (branch.outputSchema === undefined || branch.outputSchema === null) {
624
+ return fanoutErr(flow, fanout.id, 'branch-output-schema', `branch "${branch.branchId}" must declare an outputSchema (typed escape contract)`, branch.branchId);
625
+ }
626
+ const compiled = compileInlineSchema(branch.outputSchema);
627
+ if (compiled.kind === 'err') {
628
+ const cause = compiled.error;
629
+ const primary = cause.code === 'schema-compile-error'
630
+ ? String(cause.cause?.message ?? cause.message)
631
+ : cause.message;
632
+ return {
633
+ code: 'invalid-fanout-node',
634
+ message: `Fanout node "${fanout.id}" branch "${branch.branchId}" outputSchema failed to compile: ${primary}`,
635
+ flowId: flow.id,
636
+ fanoutNodeId: fanout.id,
637
+ field: 'branch-output-schema',
638
+ reason: primary,
639
+ branchId: branch.branchId,
640
+ issues: [{ path: '', message: primary }],
641
+ };
642
+ }
643
+ }
644
+ if (typeof fanout.convergence !== 'string' ||
645
+ !CONVERGENCE_MODES.includes(fanout.convergence)) {
646
+ return fanoutErr(flow, fanout.id, 'convergence', `must be one of ${CONVERGENCE_MODES.join(' | ')} (got ${String(fanout.convergence)})`);
647
+ }
648
+ if (fanout.concurrency !== undefined) {
649
+ if (!Number.isInteger(fanout.concurrency)) {
650
+ return fanoutErr(flow, fanout.id, 'concurrency', `must be an integer (got ${String(fanout.concurrency)})`);
651
+ }
652
+ if (fanout.concurrency < 1) {
653
+ return fanoutErr(flow, fanout.id, 'concurrency', `must be >= 1 (got ${fanout.concurrency})`);
654
+ }
655
+ if (fanout.concurrency > fanout.branches.length) {
656
+ return fanoutErr(flow, fanout.id, 'concurrency', `must be <= branches.length (${fanout.branches.length}) — a fanout cannot request more concurrent workers than it has branches (got ${fanout.concurrency})`);
657
+ }
658
+ }
659
+ return undefined;
660
+ }
661
+ function fanoutErr(flow, fanoutNodeId, field, reason, branchId) {
662
+ const scope = branchId === undefined
663
+ ? `Fanout node "${fanoutNodeId}"`
664
+ : `Fanout node "${fanoutNodeId}" branch "${branchId}"`;
665
+ return {
666
+ code: 'invalid-fanout-node',
667
+ message: `${scope}: ${reason}`,
668
+ flowId: flow.id,
669
+ fanoutNodeId,
670
+ field,
671
+ reason,
672
+ ...(branchId !== undefined && { branchId }),
673
+ };
674
+ }
675
+ // Silence unused ConvergenceMode import warning (referenced only via schema literal check).
676
+ void 0;
677
+ // ============ subgraph node validation (1.7.0) ============
678
+ const SEMVER_EXACT_REGEX = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
679
+ function collectAllSubflowNodes(flow) {
680
+ const acc = [];
681
+ for (const node of collectAllNodes(flow)) {
682
+ if (isSubflowNode(node))
683
+ acc.push(node);
684
+ }
685
+ return acc;
686
+ }
687
+ function checkAllSubflowNodes(flow) {
688
+ for (const subgraph of collectAllSubflowNodes(flow)) {
689
+ const err = checkSubflowNode(flow, subgraph);
690
+ if (err)
691
+ return err;
692
+ }
693
+ return undefined;
694
+ }
695
+ function checkSubflowNode(flow, subgraph) {
696
+ // flowRef shape (the JSON schema also enforces this; repeated so this
697
+ // check stands on its own).
698
+ const ref = subgraph.flowRef;
699
+ if (ref === undefined || ref === null || typeof ref !== 'object') {
700
+ return subgraphErr(flow, subgraph.id, 'flowRef', 'must be an object');
701
+ }
702
+ const flowRef = ref;
703
+ if (typeof flowRef.flowId !== 'string' || flowRef.flowId.length === 0) {
704
+ return subgraphErr(flow, subgraph.id, 'flowRef.flowId', 'must be a non-empty string');
705
+ }
706
+ if (typeof flowRef.version !== 'string' || flowRef.version.length === 0) {
707
+ return subgraphErr(flow, subgraph.id, 'flowRef.version', 'must be a non-empty string');
708
+ }
709
+ if (!SEMVER_EXACT_REGEX.test(flowRef.version)) {
710
+ return subgraphErr(flow, subgraph.id, 'flowRef.version', `must be an exact semver (no ranges) — got "${flowRef.version}"`);
711
+ }
712
+ // inputMapping — plain object whose every value is a valid Operand.
713
+ const mapping = subgraph.inputMapping;
714
+ if (mapping === null ||
715
+ mapping === undefined ||
716
+ typeof mapping !== 'object' ||
717
+ Array.isArray(mapping)) {
718
+ return subgraphErr(flow, subgraph.id, 'inputMapping', 'must be an object mapping string keys to Operands');
719
+ }
720
+ for (const [key, value] of Object.entries(mapping)) {
721
+ if (value === null || typeof value !== 'object') {
722
+ return subgraphErr(flow, subgraph.id, 'inputMapping', `entry "${key}" must be an Operand ({ path } or { literal })`);
723
+ }
724
+ const operandReason = validateOperand(value);
725
+ if (operandReason !== undefined) {
726
+ return subgraphErr(flow, subgraph.id, 'inputMapping', `entry "${key}" is not a valid Operand: ${operandReason}`);
727
+ }
728
+ }
729
+ // outputSchema — must compile against draft-2020-12 meta-schema. Same
730
+ // gate as loop / fanout output schemas so the runtime can trust it.
731
+ if (subgraph.outputSchema === undefined || subgraph.outputSchema === null) {
732
+ return subgraphErr(flow, subgraph.id, 'output-schema', 'must be a JSON Schema (draft 2020-12) — the typed escape contract');
733
+ }
734
+ const compiled = compileInlineSchema(subgraph.outputSchema);
735
+ if (compiled.kind === 'err') {
736
+ const cause = compiled.error;
737
+ const primary = cause.code === 'schema-compile-error'
738
+ ? String(cause.cause?.message ?? cause.message)
739
+ : cause.message;
740
+ return {
741
+ code: 'invalid-subgraph-node',
742
+ message: `Subgraph node "${subgraph.id}" outputSchema failed to compile: ${primary}`,
743
+ flowId: flow.id,
744
+ subgraphNodeId: subgraph.id,
745
+ field: 'output-schema',
746
+ reason: primary,
747
+ issues: [{ path: '', message: primary }],
748
+ };
749
+ }
750
+ // convergence — closed enum.
751
+ const cv = subgraph.convergence;
752
+ if (typeof cv !== 'string' || !SUBGRAPH_CONVERGENCE_MODES.includes(cv)) {
753
+ return subgraphErr(flow, subgraph.id, 'convergence', `must be one of ${SUBGRAPH_CONVERGENCE_MODES.join(' | ')} (got ${String(cv)})`);
754
+ }
755
+ return undefined;
756
+ }
757
+ function subgraphErr(flow, subgraphNodeId, field, reason) {
758
+ return {
759
+ code: 'invalid-subgraph-node',
760
+ message: `Subgraph node "${subgraphNodeId}": ${reason}`,
761
+ flowId: flow.id,
762
+ subgraphNodeId,
763
+ field,
764
+ reason,
765
+ };
766
+ }
767
+ // Silence unused SubflowConvergence import warning (referenced via SUBGRAPH_CONVERGENCE_MODES literal check).
768
+ void 0;
769
+ // ============ loop outputSchema well-formedness (draft 2020-12 meta-schema compile) ============
770
+ function checkAllLoopOutputSchemas(flow) {
771
+ for (const loop of collectAllLoopNodes(flow)) {
772
+ const compiled = compileInlineSchema(loop.outputSchema);
773
+ if (compiled.kind === 'ok')
774
+ continue;
775
+ const cause = compiled.error;
776
+ const primaryMessage = cause.code === 'schema-compile-error'
777
+ ? String(cause.cause?.message ?? cause.message)
778
+ : cause.message;
779
+ return {
780
+ code: 'loop-invalid-output-schema',
781
+ message: `Loop node "${loop.id}" outputSchema failed to compile: ${primaryMessage}`,
782
+ flowId: flow.id,
783
+ loopNodeId: loop.id,
784
+ issues: [{ path: '', message: primaryMessage }],
785
+ };
786
+ }
787
+ return undefined;
788
+ }
789
+ // ============ data-flow mappings (leaf inputMapping + flow output, schema-version 1.8.0) ============
790
+ /**
791
+ * Stricter than edge predicates: a mapping feeds a handler or the run's
792
+ * output, so a path that can never resolve is a mistake, not a
793
+ * "false" — reject it at load time. The schema already limits path
794
+ * roots; this pass checks what it can't: `nodeOutputs.<id>` names a node
795
+ * of the flow (ids are globally unique, so every node counts), a node
796
+ * doesn't map its own output, and no loop-only root is used.
797
+ */
798
+ function checkAllMappings(flow) {
799
+ const allNodes = collectAllNodes(flow);
800
+ const nodeIds = new Set(allNodes.map((n) => n.id));
801
+ for (const node of allNodes) {
802
+ if (!isLeafNode(node) || node.inputMapping === undefined)
803
+ continue;
804
+ const bad = firstInvalidEntry(node.inputMapping, nodeIds, node.id);
805
+ if (bad !== undefined)
806
+ return mappingErr(flow, bad, node.id);
807
+ }
808
+ if (flow.output !== undefined) {
809
+ const bad = firstInvalidEntry(flow.output.mapping, nodeIds, undefined);
810
+ if (bad !== undefined)
811
+ return mappingErr(flow, bad, undefined);
812
+ }
813
+ return undefined;
814
+ }
815
+ function firstInvalidEntry(mapping, nodeIds, ownNodeId) {
816
+ for (const [key, operand] of Object.entries(mapping)) {
817
+ const reason = validateMappingOperand(operand, nodeIds, ownNodeId);
818
+ if (reason !== undefined)
819
+ return { key, reason };
820
+ }
821
+ return undefined;
822
+ }
823
+ function validateMappingOperand(operand, nodeIds, ownNodeId) {
824
+ if (!('path' in operand))
825
+ return undefined;
826
+ const [root, nodeId = ''] = operand.path.split('.');
827
+ if (root === 'iterationIndex' || root === 'iterationOutput') {
828
+ return `${root} is only available in a loop's exitCondition`;
829
+ }
830
+ if (root !== 'nodeOutputs')
831
+ return undefined;
832
+ if (!nodeIds.has(nodeId))
833
+ return `nodeOutputs.${nodeId} names no node in this flow`;
834
+ if (nodeId === ownNodeId)
835
+ return 'a node cannot map its own output';
836
+ return undefined;
837
+ }
838
+ function mappingErr(flow, bad, nodeId) {
839
+ const where = nodeId === undefined ? 'The flow output' : `Node "${nodeId}" inputMapping`;
840
+ return {
841
+ code: 'invalid-mapping',
842
+ message: `${where} key "${bad.key}": ${bad.reason}`,
843
+ flowId: flow.id,
844
+ ...(nodeId !== undefined && { nodeId }),
845
+ key: bad.key,
846
+ reason: bad.reason,
847
+ };
848
+ }
849
+ function checkFlowOutputSchema(flow) {
850
+ const schema = flow.output?.schema;
851
+ if (schema === undefined)
852
+ return undefined;
853
+ const compiled = compileInlineSchema(schema);
854
+ if (compiled.kind === 'ok')
855
+ return undefined;
856
+ const cause = compiled.error;
857
+ const primaryMessage = cause.code === 'schema-compile-error'
858
+ ? String(cause.cause?.message ?? cause.message)
859
+ : cause.message;
860
+ return {
861
+ code: 'invalid-flow-output',
862
+ message: `Flow output schema failed to compile: ${primaryMessage}`,
863
+ flowId: flow.id,
864
+ issues: [{ path: '', message: primaryMessage }],
865
+ };
866
+ }
867
+ // ============ per-loop-body validation ============
868
+ function validateAllLoopBodies(flow) {
869
+ for (const loop of collectAllLoopNodes(flow)) {
870
+ const missingStart = checkLoopBodyStartEdge(flow, loop);
871
+ if (missingStart)
872
+ return missingStart;
873
+ const badRef = checkLoopBodyEdgeReferences(flow, loop);
874
+ if (badRef)
875
+ return badRef;
876
+ const cycle = detectLoopBodyCycle(flow, loop);
877
+ if (cycle)
878
+ return cycle;
879
+ }
880
+ return undefined;
881
+ }
882
+ function checkLoopBodyStartEdge(flow, loop) {
883
+ const hasStart = loop.body.edges.some((e) => e.from === LOOP_START_NODE);
884
+ if (hasStart)
885
+ return undefined;
886
+ return {
887
+ code: 'loop-body-missing-start-edge',
888
+ message: `Loop node "${loop.id}" body has no edge originating at $loop-start`,
889
+ flowId: flow.id,
890
+ loopNodeId: loop.id,
891
+ };
892
+ }
893
+ function checkLoopBodyEdgeReferences(flow, loop) {
894
+ const bodyNodeIds = new Set(loop.body.nodes.map((n) => n.id));
895
+ for (const edge of loop.body.edges) {
896
+ if (edge.from !== LOOP_START_NODE && !bodyNodeIds.has(edge.from)) {
897
+ return loopBodyEdgeRef(flow, loop, edge, 'from', edge.from);
898
+ }
899
+ if (edge.to !== LOOP_END_NODE && !bodyNodeIds.has(edge.to)) {
900
+ return loopBodyEdgeRef(flow, loop, edge, 'to', edge.to);
901
+ }
902
+ }
903
+ return undefined;
904
+ }
905
+ function loopBodyEdgeRef(flow, loop, edge, endpoint, referencedId) {
906
+ return {
907
+ code: 'loop-body-unknown-node-reference',
908
+ message: `Loop "${loop.id}" body edge "${edge.id}" ${endpoint} unknown node "${referencedId}"`,
909
+ flowId: flow.id,
910
+ loopNodeId: loop.id,
911
+ edgeId: edge.id,
912
+ endpoint,
913
+ referencedId,
914
+ };
915
+ }
916
+ function detectLoopBodyCycle(flow, loop) {
917
+ const { inDegree, adjacency } = buildLoopBodyAdjacency(loop.body);
918
+ const processed = topoDrain(inDegree, adjacency);
919
+ if (processed === inDegree.size)
920
+ return undefined;
921
+ const remaining = new Set();
922
+ for (const [id, deg] of inDegree)
923
+ if (deg > 0)
924
+ remaining.add(id);
925
+ const cycle = extractCycleWitness(adjacency, remaining);
926
+ return {
927
+ code: 'loop-body-cycle-detected',
928
+ message: `Loop node "${loop.id}" body contains a cycle: ${cycle.join(' → ')}`,
929
+ flowId: flow.id,
930
+ loopNodeId: loop.id,
931
+ cycle: cycle,
932
+ };
933
+ }
934
+ function buildLoopBodyAdjacency(body) {
935
+ const inDegree = new Map();
936
+ const adjacency = new Map();
937
+ for (const node of body.nodes) {
938
+ inDegree.set(node.id, 0);
939
+ adjacency.set(node.id, []);
940
+ }
941
+ for (const edge of body.edges) {
942
+ if (edge.from === LOOP_START_NODE || edge.to === LOOP_END_NODE)
943
+ continue;
944
+ const successors = adjacency.get(edge.from);
945
+ if (successors === undefined)
946
+ continue;
947
+ successors.push(edge.to);
948
+ inDegree.set(edge.to, (inDegree.get(edge.to) ?? 0) + 1);
949
+ }
950
+ return { inDegree, adjacency };
951
+ }
952
+ // ============ outer cycle detection ============
953
+ function detectOuterCycle(flow) {
954
+ const { inDegree, adjacency } = buildOuterAdjacency(flow);
955
+ const processed = topoDrain(inDegree, adjacency);
956
+ if (processed === inDegree.size)
957
+ return undefined;
958
+ const remaining = new Set();
959
+ for (const [id, deg] of inDegree)
960
+ if (deg > 0)
961
+ remaining.add(id);
962
+ const cycle = extractCycleWitness(adjacency, remaining);
963
+ return {
964
+ code: 'cycle-detected',
965
+ message: `Flow "${flow.id}" contains a cycle: ${cycle.join(' → ')}`,
966
+ flowId: flow.id,
967
+ cycle: cycle,
968
+ };
969
+ }
970
+ function buildOuterAdjacency(flow) {
971
+ const inDegree = new Map();
972
+ const adjacency = new Map();
973
+ for (const node of flow.nodes) {
974
+ inDegree.set(node.id, 0);
975
+ adjacency.set(node.id, []);
976
+ }
977
+ for (const edge of flow.edges) {
978
+ if (edge.from === START_NODE || edge.to === END_NODE)
979
+ continue;
980
+ const successors = adjacency.get(edge.from);
981
+ if (successors === undefined)
982
+ continue;
983
+ successors.push(edge.to);
984
+ inDegree.set(edge.to, (inDegree.get(edge.to) ?? 0) + 1);
985
+ }
986
+ return { inDegree, adjacency };
987
+ }
988
+ // ============ shared topo / cycle helpers ============
989
+ function topoDrain(inDegree, adjacency) {
990
+ const ready = [];
991
+ for (const [id, deg] of inDegree)
992
+ if (deg === 0)
993
+ ready.push(id);
994
+ let processed = 0;
995
+ while (ready.length > 0) {
996
+ const current = ready.shift();
997
+ if (current === undefined)
998
+ break;
999
+ processed += 1;
1000
+ for (const next of adjacency.get(current) ?? []) {
1001
+ const nextDeg = (inDegree.get(next) ?? 0) - 1;
1002
+ inDegree.set(next, nextDeg);
1003
+ if (nextDeg === 0)
1004
+ ready.push(next);
1005
+ }
1006
+ }
1007
+ return processed;
1008
+ }
1009
+ function extractCycleWitness(adjacency, remaining) {
1010
+ const start = remaining.values().next().value;
1011
+ if (start === undefined)
1012
+ return [];
1013
+ const path = [];
1014
+ const onPath = new Set();
1015
+ const stack = [start];
1016
+ while (stack.length > 0) {
1017
+ const node = stack[stack.length - 1];
1018
+ if (node === undefined)
1019
+ break;
1020
+ if (!onPath.has(node)) {
1021
+ onPath.add(node);
1022
+ path.push(node);
1023
+ }
1024
+ const advance = tryAdvance(node, adjacency, remaining, onPath, path);
1025
+ if (advance.kind === 'cycle')
1026
+ return advance.witness;
1027
+ if (advance.kind === 'push') {
1028
+ stack.push(advance.next);
1029
+ continue;
1030
+ }
1031
+ stack.pop();
1032
+ onPath.delete(node);
1033
+ path.pop();
1034
+ }
1035
+ return path;
1036
+ }
1037
+ function tryAdvance(node, adjacency, remaining, onPath, path) {
1038
+ for (const next of adjacency.get(node) ?? []) {
1039
+ if (!remaining.has(next))
1040
+ continue;
1041
+ if (onPath.has(next)) {
1042
+ const cycleStart = path.indexOf(next);
1043
+ return { kind: 'cycle', witness: [...path.slice(cycleStart), next] };
1044
+ }
1045
+ return { kind: 'push', next };
1046
+ }
1047
+ return { kind: 'backtrack' };
1048
+ }
1049
+ /** The `$id` of the JSON Schema this loader validates against. */
1050
+ export const GRAPH_SCHEMA_URI = GRAPH_SCHEMA_ID;
1051
+ //# sourceMappingURL=loader.js.map