@fungi.computer/braid 0.0.0-stage → 0.0.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 +21 -0
- package/README.md +260 -2
- package/dist/cloudflare/graph-engine.d.ts +25 -0
- package/dist/cloudflare/graph-engine.js +46 -0
- package/dist/cloudflare/graph-program.d.ts +6 -0
- package/dist/cloudflare/graph-program.js +528 -0
- package/dist/cloudflare/index.d.ts +1 -0
- package/dist/cloudflare/index.js +1 -0
- package/dist/code-execution.d.ts +16 -0
- package/dist/code-execution.js +50 -0
- package/dist/code-result.d.ts +11 -0
- package/dist/code-result.js +19 -0
- package/dist/code-sandbox.d.ts +9 -0
- package/dist/code-sandbox.js +13 -0
- package/dist/compile.d.ts +41 -0
- package/dist/compile.js +240 -0
- package/dist/execution.d.ts +113 -0
- package/dist/execution.js +108 -0
- package/dist/expressions.d.ts +16 -0
- package/dist/expressions.js +322 -0
- package/dist/git-access.d.ts +59 -0
- package/dist/git-access.js +106 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/ports.d.ts +58 -0
- package/dist/ports.js +256 -0
- package/dist/runtime/arming-authority.d.ts +83 -0
- package/dist/runtime/arming-authority.js +90 -0
- package/dist/runtime/bindings.d.ts +25 -0
- package/dist/runtime/bindings.js +41 -0
- package/dist/runtime/capture.d.ts +18 -0
- package/dist/runtime/capture.js +99 -0
- package/dist/runtime/git-pass-contract.d.ts +63 -0
- package/dist/runtime/git-pass-contract.js +36 -0
- package/dist/runtime/git-pass-store.d.ts +32 -0
- package/dist/runtime/git-pass-store.js +82 -0
- package/dist/runtime/git-pass.d.ts +41 -0
- package/dist/runtime/git-pass.js +72 -0
- package/dist/runtime/graph-contract.d.ts +784 -0
- package/dist/runtime/graph-contract.js +122 -0
- package/dist/runtime/graph-dispatch.d.ts +31 -0
- package/dist/runtime/graph-dispatch.js +111 -0
- package/dist/runtime/graph-run-store.d.ts +73 -0
- package/dist/runtime/graph-run-store.js +267 -0
- package/dist/runtime/graph-start.d.ts +90 -0
- package/dist/runtime/graph-start.js +124 -0
- package/dist/runtime/index.d.ts +20 -0
- package/dist/runtime/index.js +20 -0
- package/dist/runtime/ports.d.ts +153 -0
- package/dist/runtime/ports.js +1 -0
- package/dist/runtime/run-authority.d.ts +60 -0
- package/dist/runtime/run-authority.js +145 -0
- package/dist/runtime/run-ceiling.d.ts +106 -0
- package/dist/runtime/run-ceiling.js +23 -0
- package/dist/runtime/run-member.d.ts +3 -0
- package/dist/runtime/run-member.js +12 -0
- package/dist/runtime/run-owner.d.ts +1628 -0
- package/dist/runtime/run-owner.js +513 -0
- package/dist/runtime/run-report.d.ts +14 -0
- package/dist/runtime/run-report.js +23 -0
- package/dist/runtime/schedules.d.ts +39 -0
- package/dist/runtime/schedules.js +149 -0
- package/dist/runtime/stored-trigger-rows.d.ts +12 -0
- package/dist/runtime/stored-trigger-rows.js +17 -0
- package/dist/runtime/trigger-owner.d.ts +590 -0
- package/dist/runtime/trigger-owner.js +280 -0
- package/dist/runtime/trigger-plans.d.ts +766 -0
- package/dist/runtime/trigger-plans.js +62 -0
- package/dist/runtime/trigger-router.d.ts +34 -0
- package/dist/runtime/trigger-router.js +143 -0
- package/dist/runtime/trigger-run-reservations.d.ts +28 -0
- package/dist/runtime/trigger-run-reservations.js +114 -0
- package/dist/schema.d.ts +1111 -0
- package/dist/schema.js +227 -0
- package/dist/triggers.d.ts +46 -0
- package/dist/triggers.js +75 -0
- package/dist/validate.d.ts +25 -0
- package/dist/validate.js +679 -0
- package/dist/workflow-id.d.ts +3 -0
- package/dist/workflow-id.js +3 -0
- package/package.json +95 -3
package/dist/validate.js
ADDED
|
@@ -0,0 +1,679 @@
|
|
|
1
|
+
import { escapePointer } from "@cfworker/json-schema";
|
|
2
|
+
import * as S from "effect/Schema";
|
|
3
|
+
import * as Result from "effect/Result";
|
|
4
|
+
import { nextOccurrence } from "@fungi.computer/almanac";
|
|
5
|
+
import { workflowDefinitionSchema, catalogSchema, TRIGGER_EVENT_PORTS, } from "./schema.js";
|
|
6
|
+
import { parseProjection, methodInputPorts, methodOutputPorts, portProjection, ports, accepts, assignable, } from "./ports.js";
|
|
7
|
+
import { nodeRefs, mappingRefs, mappingProjection, predicateRefs, ExpressionSyntaxError, } from "./expressions.js";
|
|
8
|
+
/** CF permits 1024 durable steps; this private ceiling leaves execution headroom. */
|
|
9
|
+
export const STEP_BUDGET = 800;
|
|
10
|
+
const signal = {};
|
|
11
|
+
/** A trigger reads only its event and must produce the run input's declared fields. */
|
|
12
|
+
function triggerIssues(definition, catalog) {
|
|
13
|
+
const issues = [];
|
|
14
|
+
let runInput;
|
|
15
|
+
try {
|
|
16
|
+
runInput = parseProjection(definition.input);
|
|
17
|
+
}
|
|
18
|
+
catch (cause) {
|
|
19
|
+
// The root graph pass already reports the invalid input projection.
|
|
20
|
+
if (!S.isSchemaError(cause))
|
|
21
|
+
throw cause;
|
|
22
|
+
}
|
|
23
|
+
const ids = new Set();
|
|
24
|
+
for (const trigger of definition.triggers ?? []) {
|
|
25
|
+
const issue = (code, message, port) => issues.push(port === undefined
|
|
26
|
+
? { code, message, trigger: trigger.id }
|
|
27
|
+
: { code, message, trigger: trigger.id, port });
|
|
28
|
+
if (ids.has(trigger.id))
|
|
29
|
+
issue("duplicate", "Duplicate trigger id");
|
|
30
|
+
ids.add(trigger.id);
|
|
31
|
+
if (trigger.on === "schedule") {
|
|
32
|
+
const first = nextOccurrence(trigger.cron, Date.UTC(2026, 0, 1));
|
|
33
|
+
const second = Result.flatMap(first, (at) => nextOccurrence(trigger.cron, at));
|
|
34
|
+
if (Result.isFailure(second))
|
|
35
|
+
issue("trigger", second.failure.message);
|
|
36
|
+
else if (Result.isSuccess(first) &&
|
|
37
|
+
second.success - first.success < 300_000)
|
|
38
|
+
issue("trigger", "Schedule occurrences must be at least five minutes apart");
|
|
39
|
+
}
|
|
40
|
+
let eventPayload;
|
|
41
|
+
if (trigger.on === "event") {
|
|
42
|
+
const app = definition.apps[trigger.app];
|
|
43
|
+
if (app === undefined)
|
|
44
|
+
issue("unknown_app", "Unknown event App");
|
|
45
|
+
const event = app === undefined
|
|
46
|
+
? undefined
|
|
47
|
+
: catalog[app.appId]?.events?.[trigger.event];
|
|
48
|
+
if (event === undefined)
|
|
49
|
+
issue("unknown_event", "App does not declare this event");
|
|
50
|
+
else {
|
|
51
|
+
eventPayload = event.payload;
|
|
52
|
+
if (event.digest !== trigger.eventDigest)
|
|
53
|
+
issue("digest_drift", "App changed event; review mapping");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// Index only guaranteed object properties. The maintained encoder owns JSON Pointer
|
|
57
|
+
// spelling; arrays, optional paths and unsupported projections remain unproven.
|
|
58
|
+
const payloadPaths = new Map();
|
|
59
|
+
const indexPayload = (schema, path) => {
|
|
60
|
+
payloadPaths.set(path, schema);
|
|
61
|
+
if (schema.type !== "object" ||
|
|
62
|
+
!S.is(S.JsonObject)(schema.properties) ||
|
|
63
|
+
!S.is(S.Array(S.String))(schema.required))
|
|
64
|
+
return;
|
|
65
|
+
for (const key of schema.required) {
|
|
66
|
+
const child = schema.properties[key];
|
|
67
|
+
if (S.is(S.JsonObject)(child))
|
|
68
|
+
indexPayload(child, `${path}/${escapePointer(key)}`);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
if (eventPayload !== undefined)
|
|
72
|
+
indexPayload(eventPayload, "");
|
|
73
|
+
const payloadProjection = (path) => {
|
|
74
|
+
const schema = payloadPaths.get(path);
|
|
75
|
+
if (schema === undefined)
|
|
76
|
+
return undefined;
|
|
77
|
+
try {
|
|
78
|
+
// The dialect annotation does not change the value's declared ports.
|
|
79
|
+
const view = Object.fromEntries(Object.entries(schema).filter(([key]) => key !== "$schema" && key !== "const"));
|
|
80
|
+
return parseProjection(schema.const === undefined ? view : { ...view, enum: [schema.const] });
|
|
81
|
+
}
|
|
82
|
+
catch (cause) {
|
|
83
|
+
if (!S.isSchemaError(cause))
|
|
84
|
+
throw cause;
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const compatible = (mapping, target) => {
|
|
89
|
+
if ("literal" in mapping)
|
|
90
|
+
return accepts(target, mapping.literal);
|
|
91
|
+
if ("template" in mapping)
|
|
92
|
+
return assignable({ type: "string" }, target);
|
|
93
|
+
if ("coalesce" in mapping) {
|
|
94
|
+
const candidates = mapping.coalesce.filter((part) => !("literal" in part && part.literal === null));
|
|
95
|
+
return (candidates.length > 0 &&
|
|
96
|
+
candidates.every((part) => compatible(part, target)));
|
|
97
|
+
}
|
|
98
|
+
const unproven = [];
|
|
99
|
+
let projected;
|
|
100
|
+
try {
|
|
101
|
+
projected = mappingProjection(mapping, (ref, path) => {
|
|
102
|
+
const source = ref.node !== "$input"
|
|
103
|
+
? undefined
|
|
104
|
+
: ref.port === "payload"
|
|
105
|
+
? payloadProjection(path ?? "")
|
|
106
|
+
: ref.port === "eventId" && (path === undefined || path === "")
|
|
107
|
+
? { type: "string" }
|
|
108
|
+
: undefined;
|
|
109
|
+
if (source === undefined)
|
|
110
|
+
unproven.push(ref);
|
|
111
|
+
return source;
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
catch (cause) {
|
|
115
|
+
if (!(cause instanceof ExpressionSyntaxError))
|
|
116
|
+
throw cause;
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
return unproven.length === 0 && assignable(projected, target);
|
|
120
|
+
};
|
|
121
|
+
const triggerEventPorts = new Set(TRIGGER_EVENT_PORTS[trigger.on]);
|
|
122
|
+
let refs = [];
|
|
123
|
+
try {
|
|
124
|
+
refs = [
|
|
125
|
+
...(trigger.on !== "schedule" && trigger.when !== undefined
|
|
126
|
+
? predicateRefs(trigger.when)
|
|
127
|
+
: []),
|
|
128
|
+
...Object.values(trigger.input ?? {}).flatMap(mappingRefs),
|
|
129
|
+
...(trigger.on === "event" && trigger.repository !== undefined
|
|
130
|
+
? mappingRefs(trigger.repository)
|
|
131
|
+
: []),
|
|
132
|
+
];
|
|
133
|
+
}
|
|
134
|
+
catch (cause) {
|
|
135
|
+
if (!(cause instanceof ExpressionSyntaxError))
|
|
136
|
+
throw cause;
|
|
137
|
+
issue("template", "Invalid interpolation or JSON pointer");
|
|
138
|
+
}
|
|
139
|
+
for (const ref of refs)
|
|
140
|
+
if (ref.node !== "$input" || !triggerEventPorts.has(ref.port))
|
|
141
|
+
issue("trigger", "A trigger reads only its declared event ports", ref.port);
|
|
142
|
+
if (trigger.on === "event" &&
|
|
143
|
+
trigger.repository !== undefined &&
|
|
144
|
+
!compatible(trigger.repository, { type: "string" }))
|
|
145
|
+
issue("trigger", "Trigger repository must be a string from the event");
|
|
146
|
+
if (runInput === undefined)
|
|
147
|
+
continue;
|
|
148
|
+
if (trigger.input === undefined) {
|
|
149
|
+
if (trigger.on === "event") {
|
|
150
|
+
const source = payloadProjection("");
|
|
151
|
+
if (source === undefined || !assignable(source, runInput))
|
|
152
|
+
issue("trigger", "Event payload does not match the run input schema");
|
|
153
|
+
}
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const targets = ports(runInput);
|
|
157
|
+
for (const key of runInput.required ?? [])
|
|
158
|
+
if (!Object.hasOwn(trigger.input, key))
|
|
159
|
+
issue("trigger", "Required run input is not mapped", key);
|
|
160
|
+
for (const [key, mapping] of Object.entries(trigger.input)) {
|
|
161
|
+
const target = Object.hasOwn(targets, key) ? targets[key] : undefined;
|
|
162
|
+
if (target === undefined)
|
|
163
|
+
issue("trigger", "Run input does not declare this field", key);
|
|
164
|
+
else if (trigger.on === "event"
|
|
165
|
+
? !compatible(mapping, target)
|
|
166
|
+
: "literal" in mapping
|
|
167
|
+
? !accepts(target, mapping.literal)
|
|
168
|
+
: "template" in mapping && !assignable({ type: "string" }, target))
|
|
169
|
+
issue("trigger", "Value does not match the run input schema", key);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return issues;
|
|
173
|
+
}
|
|
174
|
+
export function dependencies(graph, node) {
|
|
175
|
+
return [
|
|
176
|
+
...graph.edges
|
|
177
|
+
.filter((edge) => edge.to.node === node.id)
|
|
178
|
+
.map((edge) => edge.from),
|
|
179
|
+
...nodeRefs(node),
|
|
180
|
+
];
|
|
181
|
+
}
|
|
182
|
+
export function topological(graph) {
|
|
183
|
+
const remaining = new Map(graph.nodes.map((node) => [node.id, node]));
|
|
184
|
+
const sorted = [];
|
|
185
|
+
const done = new Set(["$input"]);
|
|
186
|
+
while (remaining.size) {
|
|
187
|
+
const ready = [...remaining.values()]
|
|
188
|
+
.filter((node) => dependencies(graph, node).every((ref) => done.has(ref.node)))
|
|
189
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
190
|
+
if (!ready.length)
|
|
191
|
+
break;
|
|
192
|
+
for (const node of ready) {
|
|
193
|
+
remaining.delete(node.id);
|
|
194
|
+
done.add(node.id);
|
|
195
|
+
sorted.push(node);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return sorted;
|
|
199
|
+
}
|
|
200
|
+
/** Decode JSON boundaries once, then collect domain issues for the editor. */
|
|
201
|
+
export function analyze(raw, rawCatalog) {
|
|
202
|
+
let definition;
|
|
203
|
+
let catalog;
|
|
204
|
+
try {
|
|
205
|
+
definition = S.decodeUnknownSync(workflowDefinitionSchema)(raw, {
|
|
206
|
+
onExcessProperty: "error",
|
|
207
|
+
});
|
|
208
|
+
catalog = S.decodeUnknownSync(catalogSchema)(rawCatalog, {
|
|
209
|
+
onExcessProperty: "error",
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
catch (cause) {
|
|
213
|
+
if (!S.isSchemaError(cause))
|
|
214
|
+
throw cause;
|
|
215
|
+
return {
|
|
216
|
+
issues: [
|
|
217
|
+
{
|
|
218
|
+
code: "schema",
|
|
219
|
+
message: "Definition or catalog failed v1 decoding and bounds",
|
|
220
|
+
},
|
|
221
|
+
],
|
|
222
|
+
estimatedSteps: 0,
|
|
223
|
+
order: {},
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
const issues = [];
|
|
227
|
+
const order = {};
|
|
228
|
+
const graphs = new Map(Object.entries({ root: definition, ...definition.subgraphs }));
|
|
229
|
+
const totalNodes = [...graphs.values()].reduce((sum, graph) => sum + graph.nodes.length, 0);
|
|
230
|
+
const totalEdges = [...graphs.values()].reduce((sum, graph) => sum + graph.edges.length, 0);
|
|
231
|
+
if (totalNodes > 64 || totalEdges > 256)
|
|
232
|
+
issues.push({
|
|
233
|
+
code: "schema",
|
|
234
|
+
message: "Total graph exceeds 64 nodes or 256 edges",
|
|
235
|
+
});
|
|
236
|
+
if (definition.subgraphs?.root)
|
|
237
|
+
issues.push({ code: "subgraph", message: "root is reserved" });
|
|
238
|
+
const used = new Set();
|
|
239
|
+
const outputSchemas = new Map();
|
|
240
|
+
const priorResults = [];
|
|
241
|
+
// A control body's declared outputs must be known before its enclosing graph
|
|
242
|
+
// maps a repeat result. Visiting once also leaves recursion to the cycle law.
|
|
243
|
+
const bodyOrder = [];
|
|
244
|
+
const visitedBodies = new Set();
|
|
245
|
+
const visitBody = (graphId) => {
|
|
246
|
+
if (visitedBodies.has(graphId))
|
|
247
|
+
return;
|
|
248
|
+
visitedBodies.add(graphId);
|
|
249
|
+
const graph = graphs.get(graphId);
|
|
250
|
+
if (!graph)
|
|
251
|
+
return;
|
|
252
|
+
for (const node of graph.nodes)
|
|
253
|
+
if (node.kind === "map" || node.kind === "repeat")
|
|
254
|
+
visitBody(node.body);
|
|
255
|
+
bodyOrder.push([graphId, graph]);
|
|
256
|
+
};
|
|
257
|
+
for (const graphId of graphs.keys())
|
|
258
|
+
visitBody(graphId);
|
|
259
|
+
for (const [graphId, graph] of bodyOrder) {
|
|
260
|
+
const issue = (code, message, location = {}) => issues.push({ code, message, graph: graphId, ...location });
|
|
261
|
+
const projected = (schema) => {
|
|
262
|
+
try {
|
|
263
|
+
return parseProjection(schema);
|
|
264
|
+
}
|
|
265
|
+
catch (cause) {
|
|
266
|
+
if (!S.isSchemaError(cause))
|
|
267
|
+
throw cause;
|
|
268
|
+
issue("schema", "Unsupported or invalid JSON Schema projection");
|
|
269
|
+
return {};
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
const input = projected(graph.input);
|
|
273
|
+
if (input.type !== "object")
|
|
274
|
+
issue("schema", "Graph input must be an object schema");
|
|
275
|
+
const ins = new Map();
|
|
276
|
+
const outs = new Map();
|
|
277
|
+
outs.set("$input", ports(input));
|
|
278
|
+
const ids = new Set();
|
|
279
|
+
const declareDataPorts = (node, inputProjection, outputProjection) => {
|
|
280
|
+
const declaredInput = methodInputPorts(projected(inputProjection));
|
|
281
|
+
const declaredOutput = methodOutputPorts(projected(outputProjection));
|
|
282
|
+
if (Object.hasOwn(ports(declaredInput), "after") ||
|
|
283
|
+
Object.hasOwn(declaredOutput, "after"))
|
|
284
|
+
issue("schema", "after is reserved for the node ordering signal", {
|
|
285
|
+
node: node.id,
|
|
286
|
+
port: "after",
|
|
287
|
+
});
|
|
288
|
+
ins.set(node.id, declaredInput);
|
|
289
|
+
outs.set(node.id, { ...declaredOutput, after: signal });
|
|
290
|
+
};
|
|
291
|
+
for (const node of graph.nodes) {
|
|
292
|
+
if (ids.has(node.id))
|
|
293
|
+
issue("duplicate", "Duplicate node id", { node: node.id });
|
|
294
|
+
ids.add(node.id);
|
|
295
|
+
if (node.kind === "code")
|
|
296
|
+
declareDataPorts(node, node.input, node.output);
|
|
297
|
+
if (node.kind === "app") {
|
|
298
|
+
const app = Object.hasOwn(definition.apps, node.app)
|
|
299
|
+
? definition.apps[node.app]
|
|
300
|
+
: undefined;
|
|
301
|
+
const entry = app && Object.hasOwn(catalog, app.appId)
|
|
302
|
+
? catalog[app.appId]
|
|
303
|
+
: undefined;
|
|
304
|
+
if (!app || !entry) {
|
|
305
|
+
issue("unknown_app", "Unknown App", { node: node.id });
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
const method = Object.hasOwn(entry.methods, node.method)
|
|
309
|
+
? entry.methods[node.method]
|
|
310
|
+
: undefined;
|
|
311
|
+
if (!method) {
|
|
312
|
+
issue("unknown_method", "Unknown App method", { node: node.id });
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (method.digest !== node.methodDigest)
|
|
316
|
+
issue("digest_drift", "App changed method; review mapping", {
|
|
317
|
+
node: node.id,
|
|
318
|
+
});
|
|
319
|
+
if (method.effect === "external" && (node.retry?.attempts ?? 1) > 1)
|
|
320
|
+
issue("schema", "External effects cannot retry automatically", {
|
|
321
|
+
node: node.id,
|
|
322
|
+
});
|
|
323
|
+
declareDataPorts(node, method.input, method.output);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
let sorted = [];
|
|
327
|
+
try {
|
|
328
|
+
sorted = topological(graph);
|
|
329
|
+
}
|
|
330
|
+
catch (cause) {
|
|
331
|
+
if (!(cause instanceof ExpressionSyntaxError))
|
|
332
|
+
throw cause;
|
|
333
|
+
issue("template", "Invalid interpolation or JSON pointer");
|
|
334
|
+
}
|
|
335
|
+
order[graphId] = sorted;
|
|
336
|
+
if (sorted.length !== graph.nodes.length)
|
|
337
|
+
issue("cycle", "Cycle or unresolved dependency");
|
|
338
|
+
const source = (ref) => {
|
|
339
|
+
const declared = outs.get(ref.node);
|
|
340
|
+
return declared && Object.hasOwn(declared, ref.port)
|
|
341
|
+
? declared[ref.port]
|
|
342
|
+
: undefined;
|
|
343
|
+
};
|
|
344
|
+
const mapped = (mapping) => {
|
|
345
|
+
try {
|
|
346
|
+
return mappingProjection(mapping, (ref, path) => portProjection(source(ref), path));
|
|
347
|
+
}
|
|
348
|
+
catch (cause) {
|
|
349
|
+
if (!(cause instanceof ExpressionSyntaxError))
|
|
350
|
+
throw cause;
|
|
351
|
+
issue("template", "Invalid interpolation or JSON pointer");
|
|
352
|
+
return {};
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
for (const node of [
|
|
356
|
+
...sorted,
|
|
357
|
+
...graph.nodes.filter((candidate) => !sorted.some((item) => item.id === candidate.id)),
|
|
358
|
+
]) {
|
|
359
|
+
switch (node.kind) {
|
|
360
|
+
case "condition":
|
|
361
|
+
outs.set(node.id, { true: signal, false: signal, after: signal });
|
|
362
|
+
break;
|
|
363
|
+
case "review": {
|
|
364
|
+
const shown = node.show[0];
|
|
365
|
+
const item = shown ? (source(shown) ?? {}) : {};
|
|
366
|
+
if (node.choices === undefined) {
|
|
367
|
+
outs.set(node.id, {
|
|
368
|
+
approved: item,
|
|
369
|
+
rejected: item,
|
|
370
|
+
choice: { type: "string", enum: ["approved", "rejected"] },
|
|
371
|
+
note: { type: "string", maxLength: 1000 },
|
|
372
|
+
after: signal,
|
|
373
|
+
});
|
|
374
|
+
break;
|
|
375
|
+
}
|
|
376
|
+
const ids = node.choices.map((choice) => choice.id);
|
|
377
|
+
if (ids.length < 2)
|
|
378
|
+
issue("schema", "A review offers at least two choices", {
|
|
379
|
+
node: node.id,
|
|
380
|
+
});
|
|
381
|
+
if (new Set(ids).size !== ids.length)
|
|
382
|
+
issue("duplicate", "Duplicate review choice", { node: node.id });
|
|
383
|
+
for (const reserved of ["after", "choice", "note"])
|
|
384
|
+
if (ids.includes(reserved))
|
|
385
|
+
issue("schema", `${reserved} is a reserved review output port`, {
|
|
386
|
+
node: node.id,
|
|
387
|
+
port: reserved,
|
|
388
|
+
});
|
|
389
|
+
outs.set(node.id, {
|
|
390
|
+
...Object.fromEntries(ids.map((id) => [id, item])),
|
|
391
|
+
choice: { type: "string", enum: ids },
|
|
392
|
+
note: { type: "string", maxLength: 1000 },
|
|
393
|
+
after: signal,
|
|
394
|
+
});
|
|
395
|
+
break;
|
|
396
|
+
}
|
|
397
|
+
case "wait":
|
|
398
|
+
outs.set(node.id, { after: signal });
|
|
399
|
+
break;
|
|
400
|
+
case "transform":
|
|
401
|
+
if (Object.hasOwn(node.fields, "after"))
|
|
402
|
+
issue("schema", "after is reserved for the node ordering signal", {
|
|
403
|
+
node: node.id,
|
|
404
|
+
port: "after",
|
|
405
|
+
});
|
|
406
|
+
outs.set(node.id, {
|
|
407
|
+
...Object.fromEntries(Object.entries(node.fields).map(([key, value]) => [
|
|
408
|
+
key,
|
|
409
|
+
mapped(value),
|
|
410
|
+
])),
|
|
411
|
+
after: signal,
|
|
412
|
+
});
|
|
413
|
+
break;
|
|
414
|
+
case "map": {
|
|
415
|
+
used.add(node.body);
|
|
416
|
+
const body = definition.subgraphs?.[node.body];
|
|
417
|
+
const over = source(node.over);
|
|
418
|
+
if (!body)
|
|
419
|
+
issue("subgraph", "Unknown map body", { node: node.id });
|
|
420
|
+
if (over?.type !== "array")
|
|
421
|
+
issue("unassignable_edge", "Map requires a list", {
|
|
422
|
+
node: node.id,
|
|
423
|
+
});
|
|
424
|
+
if (body) {
|
|
425
|
+
const bodyInput = projected(body.input);
|
|
426
|
+
if (!assignable({
|
|
427
|
+
type: "object",
|
|
428
|
+
properties: {
|
|
429
|
+
item: over?.items ?? {},
|
|
430
|
+
index: { type: "integer" },
|
|
431
|
+
},
|
|
432
|
+
required: ["item", "index"],
|
|
433
|
+
additionalProperties: false,
|
|
434
|
+
}, bodyInput))
|
|
435
|
+
issue("unassignable_edge", "Map item does not fit body input", {
|
|
436
|
+
node: node.id,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
outs.set(node.id, {
|
|
440
|
+
items: { type: "array", items: { type: "object" } },
|
|
441
|
+
after: signal,
|
|
442
|
+
});
|
|
443
|
+
break;
|
|
444
|
+
}
|
|
445
|
+
case "repeat": {
|
|
446
|
+
used.add(node.body);
|
|
447
|
+
const body = definition.subgraphs?.[node.body];
|
|
448
|
+
if (!body)
|
|
449
|
+
issue("subgraph", "Unknown repeat body", { node: node.id });
|
|
450
|
+
if (body) {
|
|
451
|
+
const bodyInput = projected(body.input);
|
|
452
|
+
if (!assignable({
|
|
453
|
+
type: "object",
|
|
454
|
+
properties: {
|
|
455
|
+
item: source({ node: "$input", port: "item" }) ?? {},
|
|
456
|
+
round: {
|
|
457
|
+
type: "integer",
|
|
458
|
+
minimum: 0,
|
|
459
|
+
maximum: node.maxRounds - 1,
|
|
460
|
+
},
|
|
461
|
+
previous: {
|
|
462
|
+
type: "object",
|
|
463
|
+
properties: {},
|
|
464
|
+
additionalProperties: false,
|
|
465
|
+
},
|
|
466
|
+
},
|
|
467
|
+
required: ["item", "round", "previous"],
|
|
468
|
+
additionalProperties: false,
|
|
469
|
+
}, bodyInput))
|
|
470
|
+
issue("unassignable_edge", "Repeat item, round and previous result do not fit body input", { node: node.id });
|
|
471
|
+
const previous = ports(bodyInput).previous;
|
|
472
|
+
if (previous !== undefined)
|
|
473
|
+
priorResults.push({
|
|
474
|
+
graph: graphId,
|
|
475
|
+
node: node.id,
|
|
476
|
+
body: node.body,
|
|
477
|
+
target: previous,
|
|
478
|
+
});
|
|
479
|
+
try {
|
|
480
|
+
for (const ref of predicateRefs(node.until))
|
|
481
|
+
if (ref.node !== "$input" ||
|
|
482
|
+
!Object.hasOwn(body.output, ref.port))
|
|
483
|
+
issue("unknown_port", "Repeat stop predicate reads only the body's declared output", { node: node.id, port: ref.port });
|
|
484
|
+
}
|
|
485
|
+
catch (cause) {
|
|
486
|
+
if (!(cause instanceof ExpressionSyntaxError))
|
|
487
|
+
throw cause;
|
|
488
|
+
issue("template", "Invalid repeat stop predicate", {
|
|
489
|
+
node: node.id,
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
outs.set(node.id, {
|
|
494
|
+
result: outputSchemas.get(node.body) ?? { type: "object" },
|
|
495
|
+
exhausted: outputSchemas.get(node.body) ?? { type: "object" },
|
|
496
|
+
after: signal,
|
|
497
|
+
});
|
|
498
|
+
break;
|
|
499
|
+
}
|
|
500
|
+
case "app":
|
|
501
|
+
case "code":
|
|
502
|
+
break;
|
|
503
|
+
}
|
|
504
|
+
try {
|
|
505
|
+
for (const ref of nodeRefs(node))
|
|
506
|
+
if (!source(ref))
|
|
507
|
+
issue("unknown_port", "Unknown referenced port", {
|
|
508
|
+
node: node.id,
|
|
509
|
+
port: ref.port,
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
catch (cause) {
|
|
513
|
+
if (!(cause instanceof ExpressionSyntaxError))
|
|
514
|
+
throw cause;
|
|
515
|
+
issue("template", "Invalid interpolation or JSON pointer", {
|
|
516
|
+
node: node.id,
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
if (node.kind === "app" || node.kind === "code") {
|
|
520
|
+
const schema = ins.get(node.id) ?? {};
|
|
521
|
+
const targets = ports(schema);
|
|
522
|
+
for (const key of schema.required ?? [])
|
|
523
|
+
if (!Object.hasOwn(node.values, key) &&
|
|
524
|
+
!graph.edges.some((edge) => edge.to.node === node.id && edge.to.port === key))
|
|
525
|
+
issue("required_port", "Required port needs an edge or value", {
|
|
526
|
+
node: node.id,
|
|
527
|
+
port: key,
|
|
528
|
+
});
|
|
529
|
+
for (const [key, value] of Object.entries(node.values)) {
|
|
530
|
+
const target = Object.hasOwn(targets, key) ? targets[key] : undefined;
|
|
531
|
+
if (!target)
|
|
532
|
+
issue("unknown_port", "Unknown literal input port", {
|
|
533
|
+
node: node.id,
|
|
534
|
+
port: key,
|
|
535
|
+
});
|
|
536
|
+
else if ("literal" in value
|
|
537
|
+
? !accepts(target, value.literal)
|
|
538
|
+
: !assignable(mapped(value), target))
|
|
539
|
+
issue("literal", "Value does not match input schema", {
|
|
540
|
+
node: node.id,
|
|
541
|
+
port: key,
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
const destinations = new Set();
|
|
547
|
+
graph.edges.forEach((edge, index) => {
|
|
548
|
+
const node = graph.nodes.find((item) => item.id === edge.to.node);
|
|
549
|
+
const src = source(edge.from);
|
|
550
|
+
const inputs = node ? ports(ins.get(node.id) ?? {}) : {};
|
|
551
|
+
const target = edge.to.port === "after"
|
|
552
|
+
? signal
|
|
553
|
+
: (node?.kind === "app" || node?.kind === "code") &&
|
|
554
|
+
Object.hasOwn(inputs, edge.to.port)
|
|
555
|
+
? inputs[edge.to.port]
|
|
556
|
+
: undefined;
|
|
557
|
+
if (!node || !src || !target)
|
|
558
|
+
issue("unknown_port", "Unknown edge node or port", { edge: index });
|
|
559
|
+
else if (!assignable(src, target))
|
|
560
|
+
issue("unassignable_edge", "Source schema is not assignable to input", {
|
|
561
|
+
edge: index,
|
|
562
|
+
node: node.id,
|
|
563
|
+
port: edge.to.port,
|
|
564
|
+
});
|
|
565
|
+
const key = edge.to.node + "." + edge.to.port;
|
|
566
|
+
if (destinations.has(key) ||
|
|
567
|
+
((node?.kind === "app" || node?.kind === "code") &&
|
|
568
|
+
node.values[edge.to.port]))
|
|
569
|
+
issue("duplicate", "Input has multiple producers", { edge: index });
|
|
570
|
+
destinations.add(key);
|
|
571
|
+
});
|
|
572
|
+
for (const ref of Object.values(graph.output))
|
|
573
|
+
if (!source(ref))
|
|
574
|
+
issue("unknown_port", "Unknown graph output", {
|
|
575
|
+
node: ref.node,
|
|
576
|
+
port: ref.port,
|
|
577
|
+
});
|
|
578
|
+
outputSchemas.set(graphId, {
|
|
579
|
+
type: "object",
|
|
580
|
+
properties: Object.fromEntries(Object.entries(graph.output).map(([key, ref]) => [
|
|
581
|
+
key,
|
|
582
|
+
source(ref) ?? {},
|
|
583
|
+
])),
|
|
584
|
+
additionalProperties: false,
|
|
585
|
+
});
|
|
586
|
+
const observable = new Set(Object.values(graph.output).map((ref) => ref.node));
|
|
587
|
+
for (const node of graph.nodes)
|
|
588
|
+
if (node.kind === "app") {
|
|
589
|
+
const app = definition.apps[node.app];
|
|
590
|
+
const effect = app
|
|
591
|
+
? catalog[app.appId]?.methods[node.method]?.effect
|
|
592
|
+
: undefined;
|
|
593
|
+
if (effect && effect !== "read")
|
|
594
|
+
observable.add(node.id);
|
|
595
|
+
}
|
|
596
|
+
for (const node of [...sorted].reverse())
|
|
597
|
+
if (observable.has(node.id)) {
|
|
598
|
+
try {
|
|
599
|
+
for (const ref of dependencies(graph, node))
|
|
600
|
+
observable.add(ref.node);
|
|
601
|
+
}
|
|
602
|
+
catch (cause) {
|
|
603
|
+
if (!(cause instanceof ExpressionSyntaxError))
|
|
604
|
+
throw cause;
|
|
605
|
+
issue("template", "Invalid interpolation or JSON pointer", {
|
|
606
|
+
node: node.id,
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
for (const node of graph.nodes)
|
|
611
|
+
if (!observable.has(node.id))
|
|
612
|
+
issue("unreachable", "Node cannot contribute to an output or side effect", { node: node.id });
|
|
613
|
+
}
|
|
614
|
+
for (const prior of priorResults) {
|
|
615
|
+
const supplied = outputSchemas.get(prior.body);
|
|
616
|
+
if (supplied !== undefined && !assignable(supplied, prior.target))
|
|
617
|
+
issues.push({
|
|
618
|
+
code: "unassignable_edge",
|
|
619
|
+
graph: prior.graph,
|
|
620
|
+
node: prior.node,
|
|
621
|
+
message: "Repeat body output does not fit the next round's previous result",
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
issues.push(...triggerIssues(definition, catalog));
|
|
625
|
+
for (const id of Object.keys(definition.subgraphs ?? {}))
|
|
626
|
+
if (!used.has(id))
|
|
627
|
+
issues.push({
|
|
628
|
+
code: "unreachable",
|
|
629
|
+
graph: id,
|
|
630
|
+
message: "Unused subgraph",
|
|
631
|
+
});
|
|
632
|
+
const estimate = (id, visiting) => {
|
|
633
|
+
if (visiting.has(id)) {
|
|
634
|
+
issues.push({
|
|
635
|
+
code: "cycle",
|
|
636
|
+
graph: id,
|
|
637
|
+
message: "Recursive control body",
|
|
638
|
+
});
|
|
639
|
+
return STEP_BUDGET + 1;
|
|
640
|
+
}
|
|
641
|
+
const graph = graphs.get(id);
|
|
642
|
+
if (!graph)
|
|
643
|
+
return 0;
|
|
644
|
+
const next = new Set([...visiting, id]);
|
|
645
|
+
return graph.nodes.reduce((sum, node) => Math.min(STEP_BUDGET + 1, sum +
|
|
646
|
+
3 +
|
|
647
|
+
(node.kind === "map"
|
|
648
|
+
? node.maxItems * (estimate(node.body, next) + 3)
|
|
649
|
+
: node.kind === "repeat"
|
|
650
|
+
? node.maxRounds * (estimate(node.body, next) + 3)
|
|
651
|
+
: 0)), 0);
|
|
652
|
+
};
|
|
653
|
+
const estimatedSteps = estimate("root", new Set());
|
|
654
|
+
if (estimatedSteps > STEP_BUDGET)
|
|
655
|
+
issues.push({
|
|
656
|
+
code: "step_budget",
|
|
657
|
+
message: "Static maximum exceeds engine step budget with headroom",
|
|
658
|
+
});
|
|
659
|
+
return { issues, definition, catalog, estimatedSteps, order };
|
|
660
|
+
}
|
|
661
|
+
export function validate(definition, catalog) {
|
|
662
|
+
try {
|
|
663
|
+
const parsedDefinition = S.decodeUnknownSync(workflowDefinitionSchema)(definition, { onExcessProperty: "error" });
|
|
664
|
+
const parsedCatalog = S.decodeUnknownSync(catalogSchema)(catalog, {
|
|
665
|
+
onExcessProperty: "error",
|
|
666
|
+
});
|
|
667
|
+
return analyze(parsedDefinition, parsedCatalog).issues;
|
|
668
|
+
}
|
|
669
|
+
catch (cause) {
|
|
670
|
+
if (!S.isSchemaError(cause))
|
|
671
|
+
throw cause;
|
|
672
|
+
return [
|
|
673
|
+
{
|
|
674
|
+
code: "schema",
|
|
675
|
+
message: "Definition or catalog failed v1 decoding and bounds",
|
|
676
|
+
},
|
|
677
|
+
];
|
|
678
|
+
}
|
|
679
|
+
}
|