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