@yourtechbudstudio/isagi-workflow-verifier 0.0.1 → 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.
@@ -0,0 +1,624 @@
1
+ import { createHash } from 'node:crypto';
2
+ /**
3
+ * The single structural inspection algorithm, shared by the verifier CLI, the runtime loader, and
4
+ * tests. It is pure: it never calls `init`, `run`, `choose`, `parameters`, `onResult`, `output`,
5
+ * `plan`, or `label`. It only reads plain data off an already-imported module object.
6
+ *
7
+ * Importing a bundle still executes trusted module-level JavaScript. This is a structural contract
8
+ * check, not sandboxed static analysis of untrusted source.
9
+ */
10
+ export const workflowStructureDescriptorVersion = 2;
11
+ /**
12
+ * The contract version this release understands. `compatibility.test.ts` binds it to the SDK's
13
+ * `workflowContractVersion`.
14
+ *
15
+ * Recognition is reimplemented here rather than imported from the SDK on purpose: a workflow bundle
16
+ * embeds its own SDK copy, so recognition may only read plain data anyway, and keeping the check
17
+ * local leaves the packed verifier — which authors may install as a standalone CLI — free of a
18
+ * runtime SDK resolution.
19
+ */
20
+ const recognizedContractVersion = 5;
21
+ const limits = {
22
+ graphs: 512,
23
+ nodesPerGraph: 256,
24
+ edgesPerGraph: 256,
25
+ outcomesPerGraph: 64,
26
+ containmentDepth: 64,
27
+ };
28
+ const identifierPattern = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
29
+ function isObject(value) {
30
+ return typeof value === 'object' && value !== null;
31
+ }
32
+ function isBranded(value, kind) {
33
+ return (isObject(value) && value.isagiContract === recognizedContractVersion && value.isagiKind === kind);
34
+ }
35
+ function contractVersionOf(value) {
36
+ if (!isObject(value))
37
+ return null;
38
+ return typeof value.isagiContract === 'number' ? value.isagiContract : null;
39
+ }
40
+ function describeValue(value) {
41
+ if (Array.isArray(value))
42
+ return 'an array';
43
+ if (value === null)
44
+ return 'null';
45
+ return typeof value;
46
+ }
47
+ /** Collects diagnostics rather than throwing, so one pass reports every structural problem. */
48
+ class Diagnostics {
49
+ entries = [];
50
+ add(code, message, at = {}) {
51
+ this.entries.push({ code, message, at });
52
+ }
53
+ }
54
+ export function describeWorkflowModule(moduleNamespace) {
55
+ const diagnostics = new Diagnostics();
56
+ const namespace = isObject(moduleNamespace) ? moduleNamespace : undefined;
57
+ const workflow = namespace?.default;
58
+ if (!isBranded(workflow, 'workflow')) {
59
+ const contract = contractVersionOf(workflow);
60
+ if (contract !== null && contract !== recognizedContractVersion) {
61
+ diagnostics.add('unsupported_contract', `The bundle was built against workflow contract version ${contract}; this release supports version ${recognizedContractVersion}. Rebuild the workflow against the current SDK.`);
62
+ }
63
+ else {
64
+ diagnostics.add('invalid_export', `The bundle must default-export the object returned by defineWorkflow(); its default export is ${describeValue(workflow)}.`);
65
+ }
66
+ return { ok: false, diagnostics: diagnostics.entries };
67
+ }
68
+ if (typeof workflow.command !== 'function') {
69
+ diagnostics.add('missing_callback', 'The workflow definition needs a command() function.', {
70
+ field: 'command',
71
+ });
72
+ }
73
+ if (typeof workflow.parse !== 'function') {
74
+ diagnostics.add('missing_callback', 'The workflow definition needs a parse() function.', {
75
+ field: 'parse',
76
+ });
77
+ }
78
+ // Declared but malformed, not absent: `placement` is optional, so only a present non-function
79
+ // is a defect. A workflow that omits it is placed in the current worktree and surface.
80
+ if (workflow.placement !== undefined && typeof workflow.placement !== 'function') {
81
+ diagnostics.add('missing_callback', "The workflow definition's placement must be a function when present.", { field: 'placement' });
82
+ }
83
+ const rootGraph = workflow.graph;
84
+ if (!isBranded(rootGraph, 'graph')) {
85
+ diagnostics.add('invalid_export', `The workflow definition's graph must be the object returned by createGraph(); it is ${describeValue(rootGraph)}.`, { field: 'graph' });
86
+ return { ok: false, diagnostics: diagnostics.entries };
87
+ }
88
+ const collected = collectStructure(rootGraph, diagnostics);
89
+ // Every collected object is validated, including one whose key is unusable. Collecting by
90
+ // identity is what makes that possible: keying the collection by the declared key let a graph
91
+ // with a malformed key disappear from inspection and the bundle pass as valid.
92
+ for (const graph of collected.graphs)
93
+ validateGraph(graph, diagnostics);
94
+ // Depth is measured from the recorded adjacency rather than during traversal, so the verdict
95
+ // cannot depend on which branch reached a shared graph first. It is meaningless over a cyclic
96
+ // relation, and dishonest over a collection a resource limit truncated.
97
+ if (!collected.hasCycle && !collected.truncated) {
98
+ validateContainmentDepth(rootGraph, collected, diagnostics);
99
+ }
100
+ if (diagnostics.entries.length > 0)
101
+ return { ok: false, diagnostics: diagnostics.entries };
102
+ // Reached only with no diagnostics, so every key here has been validated as an identifier and no
103
+ // partial or truncated structure can escape as a successful descriptor.
104
+ const descriptor = {
105
+ descriptorVersion: workflowStructureDescriptorVersion,
106
+ workflowContractVersion: recognizedContractVersion,
107
+ rootGraphKey: String(rootGraph.key),
108
+ graphs: collected.graphs
109
+ .map((graph) => describeGraph(graph))
110
+ .sort((left, right) => compare(left.key, right.key)),
111
+ };
112
+ return { ok: true, descriptor };
113
+ }
114
+ const commandInputKinds = ['text', 'select', 'multi-select', 'confirm'];
115
+ /** Reads a property the way `value?.[name]` does, including off primitives, without a shape cast. */
116
+ function propertyOf(value, name) {
117
+ return value === null || value === undefined ? undefined : Reflect.get(Object(value), name);
118
+ }
119
+ /**
120
+ * Checks the manifest a workflow's `command()` returned: a non-empty title, an optional string
121
+ * description, and optional well-formed launch inputs. Returns one message per problem, and none
122
+ * when the manifest is valid. Like `describeWorkflowModule`, it only reads plain data; calling
123
+ * `command()` is the caller's job.
124
+ */
125
+ export function checkCommandManifest(manifest) {
126
+ if (!isObject(manifest) || Array.isArray(manifest)) {
127
+ return [`command() must return a manifest object; it returned ${describeValue(manifest)}.`];
128
+ }
129
+ const problems = [];
130
+ if (typeof manifest.title !== 'string' || !manifest.title) {
131
+ problems.push(`command() must return a manifest with a non-empty string title; found ${describeValue(manifest.title)}.`);
132
+ }
133
+ if (manifest.description !== undefined && typeof manifest.description !== 'string') {
134
+ problems.push(`command() manifest description must be a string when present; found ${describeValue(manifest.description)}.`);
135
+ }
136
+ if (manifest.inputs !== undefined && !Array.isArray(manifest.inputs)) {
137
+ problems.push(`command() manifest inputs must be an array when present; found ${describeValue(manifest.inputs)}.`);
138
+ }
139
+ const inputs = Array.isArray(manifest.inputs) ? manifest.inputs : [];
140
+ for (const [index, input] of inputs.entries()) {
141
+ const key = propertyOf(input, 'key');
142
+ const where = `inputs[${index}]${typeof key === 'string' && key ? ` (key "${key}")` : ''}`;
143
+ if (!isObject(input)) {
144
+ problems.push(`command() ${where} must be an input object; found ${describeValue(input)}.`);
145
+ continue;
146
+ }
147
+ if (!commandInputKinds.includes(input.kind)) {
148
+ problems.push(`command() ${where} has kind ${JSON.stringify(input.kind)}; expected "text", "select", "multi-select", or "confirm".`);
149
+ }
150
+ if (typeof input.key !== 'string' || !input.key) {
151
+ problems.push(`command() ${where} needs a non-empty string key.`);
152
+ }
153
+ if (typeof input.label !== 'string' || !input.label) {
154
+ problems.push(`command() ${where} needs a non-empty string label.`);
155
+ }
156
+ if (input.kind === 'select' || input.kind === 'multi-select') {
157
+ if (!Array.isArray(input.options)) {
158
+ problems.push(`command() ${where} is a ${input.kind} input and needs an options array.`);
159
+ }
160
+ else {
161
+ for (const [optionIndex, option] of input.options.entries()) {
162
+ if (typeof propertyOf(option, 'value') !== 'string') {
163
+ problems.push(`command() ${where} options[${optionIndex}] needs a string value.`);
164
+ }
165
+ }
166
+ }
167
+ }
168
+ }
169
+ return problems;
170
+ }
171
+ /**
172
+ * Walks the containment relation once, depth-first.
173
+ *
174
+ * Depth-first is the simpler shape here rather than a necessary one — a breadth-first walk can
175
+ * carry ancestor paths too. What it gives us is the current path as a stack, so a graph that
176
+ * contains itself is reported instead of walked forever.
177
+ *
178
+ * It deliberately does **not** validate nesting depth. A graph reached first through a shallow
179
+ * branch is not re-walked when a deeper branch reaches it, so a depth check made during traversal
180
+ * would depend on which branch happened to be visited first. Depth is computed afterwards from the
181
+ * adjacency this pass records.
182
+ *
183
+ * Per-graph registration limits are enforced here, before their collections are traversed, so one
184
+ * graph declaring an enormous number of nodes cannot evade the bound by sitting under the
185
+ * graph-count cap. This bounds the inspection work; it is not a memory guarantee about JavaScript
186
+ * property enumeration, and it is not sandboxing — importing the bundle already ran author code.
187
+ */
188
+ function collectStructure(rootGraph, diagnostics) {
189
+ const graphs = [];
190
+ const adjacency = new Map();
191
+ const byKey = new Map();
192
+ const visited = new Set();
193
+ const path = [];
194
+ let hasCycle = false;
195
+ let truncated = false;
196
+ const descend = (graph) => {
197
+ if (truncated)
198
+ return;
199
+ if (path.includes(graph)) {
200
+ hasCycle = true;
201
+ diagnostics.add('recursive_graph_containment', `Graph "${labelOf(graph)}" contains itself through ${path.map(labelOf).join(' → ')}. A graph may be reused, but it cannot be nested inside itself.`, locate(keyOf(graph)));
202
+ return;
203
+ }
204
+ const key = keyOf(graph);
205
+ if (key !== undefined) {
206
+ const existing = byKey.get(key);
207
+ if (existing !== undefined && existing !== graph) {
208
+ diagnostics.add('duplicate_graph_key', `Two different graphs both declare the key "${key}". Graph keys are unique across the whole composed structure.`, { graphKey: key });
209
+ }
210
+ else if (existing === undefined) {
211
+ byKey.set(key, graph);
212
+ }
213
+ }
214
+ if (visited.has(graph))
215
+ return;
216
+ if (visited.size >= limits.graphs) {
217
+ truncated = true;
218
+ diagnostics.add('too_many_graphs', `The composed structure contains more than ${limits.graphs} graphs. Further graph discovery stopped at this limit; reported diagnostics may be incomplete.`);
219
+ return;
220
+ }
221
+ visited.add(graph);
222
+ graphs.push(graph);
223
+ const children = childGraphsOf(graph, diagnostics);
224
+ adjacency.set(graph, children);
225
+ path.push(graph);
226
+ for (const child of children)
227
+ descend(child);
228
+ path.pop();
229
+ };
230
+ descend(rootGraph);
231
+ return { graphs, adjacency, hasCycle, truncated };
232
+ }
233
+ /**
234
+ * The child graphs one graph registers, with its registration collections bounded first.
235
+ *
236
+ * Shape is checked before anything is counted, and an over-limit collection is rejected without
237
+ * being sorted or traversed — inspecting it is exactly the work the limit exists to avoid. The
238
+ * limit diagnostics are emitted here and only here; per-graph validation does not repeat them.
239
+ */
240
+ function childGraphsOf(graph, diagnostics) {
241
+ const at = (extra = {}) => ({ ...locate(keyOf(graph)), ...extra });
242
+ // Edges are bounded separately from nodes. For a *valid* graph the one-router-per-node rule makes
243
+ // this limit redundant, but the extractor's input is unvalidated registrations: an oversized edge
244
+ // collection attached to a handful of nodes still has to be rejected without being inspected.
245
+ if (overLimit(graph.edges, limits.edgesPerGraph)) {
246
+ diagnostics.add('too_many_edges', `A graph may declare at most ${limits.edgesPerGraph} edges.`, at({ field: 'edges' }));
247
+ }
248
+ if (overLimit(graph.outcomes, limits.outcomesPerGraph)) {
249
+ diagnostics.add('too_many_outcomes', `A graph may declare at most ${limits.outcomesPerGraph} outcomes.`, at({ field: 'outcomes' }));
250
+ }
251
+ if (overLimit(graph.nodes, limits.nodesPerGraph)) {
252
+ diagnostics.add('too_many_nodes', `A graph may declare at most ${limits.nodesPerGraph} nodes.`, at({ field: 'nodes' }));
253
+ return [];
254
+ }
255
+ const nodes = isObject(graph.nodes) && !Array.isArray(graph.nodes) ? graph.nodes : {};
256
+ const children = [];
257
+ for (const nodeId of Object.keys(nodes).sort(compare)) {
258
+ const node = nodes[nodeId];
259
+ if (!isBranded(node, 'subgraph-node'))
260
+ continue;
261
+ const child = node.graph;
262
+ if (isBranded(child, 'graph'))
263
+ children.push(child);
264
+ }
265
+ return children;
266
+ }
267
+ /** True when a registration collection is a usable object that declares more entries than allowed. */
268
+ function overLimit(collection, allowed) {
269
+ if (!isObject(collection) || Array.isArray(collection))
270
+ return false;
271
+ return Object.keys(collection).length > allowed;
272
+ }
273
+ /**
274
+ * The longest containment chain, counted in graphs and **including the root**: a root with no
275
+ * subgraphs is depth 1, and a root plus 63 nested graphs is depth 64, the deepest accepted.
276
+ *
277
+ * Computed by memoized traversal of the adjacency already collected, so a heavily reused graph is
278
+ * measured once rather than once per path through it. This part of the analysis is linear in graphs
279
+ * and registrations; the extractor as a whole also sorts its collections when emitting a descriptor.
280
+ * Only called when the relation is acyclic and inspection was not truncated.
281
+ */
282
+ function validateContainmentDepth(root, collected, diagnostics) {
283
+ const depths = new Map();
284
+ const depthOf = (graph) => {
285
+ const memo = depths.get(graph);
286
+ if (memo !== undefined)
287
+ return memo;
288
+ let deepestChild = 0;
289
+ for (const child of collected.adjacency.get(graph) ?? []) {
290
+ deepestChild = Math.max(deepestChild, depthOf(child));
291
+ }
292
+ const depth = deepestChild + 1;
293
+ depths.set(graph, depth);
294
+ return depth;
295
+ };
296
+ if (depthOf(root) <= limits.containmentDepth)
297
+ return;
298
+ // Name the chain, so the author can see which nesting actually exceeded the limit.
299
+ const chain = [];
300
+ let current = root;
301
+ while (current) {
302
+ chain.push(labelOf(current));
303
+ const children = collected.adjacency.get(current) ?? [];
304
+ current = children.reduce((deepest, child) => deepest === undefined || depthOf(child) > depthOf(deepest) ? child : deepest, undefined);
305
+ }
306
+ diagnostics.add('containment_too_deep', `Graph containment is nested ${depthOf(root)} graphs deep, and at most ${limits.containmentDepth} are allowed: ${chain.join(' → ')}.`, locate(keyOf(root)));
307
+ }
308
+ function locate(graphKey) {
309
+ return graphKey === undefined ? {} : { graphKey };
310
+ }
311
+ function keyOf(graph) {
312
+ return typeof graph.key === 'string' ? graph.key : undefined;
313
+ }
314
+ function labelOf(graph) {
315
+ return keyOf(graph) ?? '<graph with no key>';
316
+ }
317
+ function validateGraph(graph, diagnostics) {
318
+ const graphKey = keyOf(graph);
319
+ const at = (extra = {}) => graphKey === undefined ? extra : { graphKey, ...extra };
320
+ if (graphKey === undefined || !identifierPattern.test(graphKey)) {
321
+ diagnostics.add('invalid_identifier', `Graph key ${JSON.stringify(graph.key)} must start with a letter and use only letters, digits, "_", or "-" (max 64 characters).`, at({ field: 'key' }));
322
+ }
323
+ if (typeof graph.title !== 'string' || graph.title.length === 0) {
324
+ diagnostics.add('missing_title', 'A graph needs a non-empty title; it is what a person reads in the inspector.', at({ field: 'title' }));
325
+ }
326
+ if (typeof graph.init !== 'function') {
327
+ diagnostics.add('missing_init', 'A graph needs an init() function.', at({ field: 'init' }));
328
+ }
329
+ if (graph.label !== undefined && typeof graph.label !== 'function') {
330
+ diagnostics.add('invalid_label', 'A graph label must be a function when present. Its result is evaluated at entry, not here.', at({ field: 'label' }));
331
+ }
332
+ // A graph that declares more registrations than the limit allows has already been rejected with
333
+ // the reason. Walking those collections anyway is the work the limit exists to avoid, and it
334
+ // would only add noise to a verdict that is already settled.
335
+ if (overLimit(graph.nodes, limits.nodesPerGraph) ||
336
+ overLimit(graph.edges, limits.edgesPerGraph) ||
337
+ overLimit(graph.outcomes, limits.outcomesPerGraph)) {
338
+ return;
339
+ }
340
+ const stateFields = validateState(graph, diagnostics, at);
341
+ const nodeIds = validateNodes(graph, diagnostics, at);
342
+ const outcomeIds = validateOutcomes(graph, diagnostics, at);
343
+ for (const collision of nodeIds.filter((id) => outcomeIds.includes(id))) {
344
+ diagnostics.add('identifier_collision', `"${collision}" names both a node and an outcome. They share one namespace so an edge destination is unambiguous.`, at({ nodeId: collision }));
345
+ }
346
+ validateEntry(graph, nodeIds, outcomeIds, diagnostics, at);
347
+ validateEdges(graph, nodeIds, outcomeIds, diagnostics, at);
348
+ void stateFields;
349
+ }
350
+ function validateState(graph, diagnostics, at) {
351
+ if (!isObject(graph.state) || Array.isArray(graph.state)) {
352
+ diagnostics.add('invalid_state_field', `A graph's state must be an object of field registrations; it is ${describeValue(graph.state)}.`, at({ field: 'state' }));
353
+ return [];
354
+ }
355
+ const names = Object.keys(graph.state);
356
+ if (names.length === 0) {
357
+ diagnostics.add('empty_state', 'A graph needs at least one state field; state is how a graph carries anything between nodes.', at({ field: 'state' }));
358
+ }
359
+ for (const name of names) {
360
+ const registration = graph.state[name];
361
+ if (!isBranded(registration, 'state-field') || typeof registration.reduce !== 'function') {
362
+ diagnostics.add('invalid_state_field', `State field "${name}" must be built with field() or one of the reduce.* helpers.`, at({ field: name }));
363
+ }
364
+ }
365
+ return names;
366
+ }
367
+ function validateNodes(graph, diagnostics, at) {
368
+ if (!isObject(graph.nodes) || Array.isArray(graph.nodes)) {
369
+ diagnostics.add('empty_graph', `A graph's nodes must be an object; it is ${describeValue(graph.nodes)}.`, at({ field: 'nodes' }));
370
+ return [];
371
+ }
372
+ const ids = Object.keys(graph.nodes);
373
+ if (ids.length === 0) {
374
+ diagnostics.add('empty_graph', 'A graph needs at least one node.', at({ field: 'nodes' }));
375
+ }
376
+ for (const id of ids) {
377
+ if (!identifierPattern.test(id)) {
378
+ diagnostics.add('invalid_identifier', `Node id "${id}" must start with a letter and use only letters, digits, "_", or "-" (max 64 characters).`, at({ nodeId: id }));
379
+ }
380
+ validateNode(graph.nodes[id], id, diagnostics, at);
381
+ }
382
+ return ids;
383
+ }
384
+ function validateNode(node, id, diagnostics, at) {
385
+ if (isBranded(node, 'operation-node')) {
386
+ if (typeof node.run !== 'function') {
387
+ diagnostics.add('missing_callback', `Operation node "${id}" needs a run() function.`, at({ nodeId: id }));
388
+ }
389
+ requireOptionalLabel(node, id, diagnostics, at);
390
+ return;
391
+ }
392
+ if (isBranded(node, 'subgraph-node')) {
393
+ if (typeof node.parameters !== 'function') {
394
+ diagnostics.add('missing_callback', `Subgraph node "${id}" needs a parameters() function.`, at({ nodeId: id }));
395
+ }
396
+ if (typeof node.onResult !== 'function') {
397
+ diagnostics.add('missing_callback', `Subgraph node "${id}" needs an onResult() function.`, at({ nodeId: id }));
398
+ }
399
+ if (!isBranded(node.graph, 'graph')) {
400
+ diagnostics.add('subgraph_missing_graph', `Subgraph node "${id}" must invoke a graph built with createGraph(); it references ${describeValue(node.graph)}.`, at({ nodeId: id }));
401
+ }
402
+ requireOptionalLabel(node, id, diagnostics, at);
403
+ return;
404
+ }
405
+ if (isBranded(node, 'checkpoint-node')) {
406
+ // `plan` is only checked to be a function; the verifier never calls it, because what a visit
407
+ // captures depends on state it does not have.
408
+ if (typeof node.plan !== 'function') {
409
+ diagnostics.add('missing_callback', `Checkpoint node "${id}" needs a plan() function.`, at({ nodeId: id }));
410
+ }
411
+ requireOptionalLabel(node, id, diagnostics, at);
412
+ return;
413
+ }
414
+ diagnostics.add('unknown_node_kind', `Node "${id}" is not an operation(), subgraph(), or checkpoint() registration.`, at({ nodeId: id }));
415
+ }
416
+ function requireOptionalLabel(node, id, diagnostics, at) {
417
+ if (node.label !== undefined && typeof node.label !== 'function') {
418
+ diagnostics.add('invalid_label', `Node "${id}" declares a label that is not a function. Its result is captured at run time, not here.`, at({ nodeId: id }));
419
+ }
420
+ }
421
+ function validateOutcomes(graph, diagnostics, at) {
422
+ if (!isObject(graph.outcomes) || Array.isArray(graph.outcomes)) {
423
+ diagnostics.add('no_outcomes', `A graph's outcomes must be an object; it is ${describeValue(graph.outcomes)}.`, at({ field: 'outcomes' }));
424
+ return [];
425
+ }
426
+ const ids = Object.keys(graph.outcomes);
427
+ if (ids.length === 0) {
428
+ diagnostics.add('no_outcomes', 'A graph needs at least one outcome, or nothing it routes to can end it.', at({ field: 'outcomes' }));
429
+ }
430
+ for (const id of ids) {
431
+ if (!identifierPattern.test(id)) {
432
+ diagnostics.add('invalid_identifier', `Outcome id "${id}" must start with a letter and use only letters, digits, "_", or "-" (max 64 characters).`, at({ outcomeId: id }));
433
+ }
434
+ const registration = graph.outcomes[id];
435
+ if (!isBranded(registration, 'outcome')) {
436
+ diagnostics.add('missing_callback', `Outcome "${id}" must be built with outcome().`, at({ outcomeId: id }));
437
+ continue;
438
+ }
439
+ if (typeof registration.output !== 'function') {
440
+ diagnostics.add('missing_callback', `Outcome "${id}" needs an output() function.`, at({ outcomeId: id }));
441
+ }
442
+ if (registration.kind !== 'success' && registration.kind !== 'failure') {
443
+ diagnostics.add('missing_callback', `Outcome "${id}" must declare kind "success" or "failure"; it declares ${JSON.stringify(registration.kind)}.`, at({ outcomeId: id }));
444
+ }
445
+ }
446
+ return ids;
447
+ }
448
+ function validateEntry(graph, nodeIds, outcomeIds, diagnostics, at) {
449
+ const entry = graph.entry;
450
+ if (typeof entry !== 'string' || !nodeIds.includes(entry)) {
451
+ if (typeof entry === 'string' && outcomeIds.includes(entry)) {
452
+ diagnostics.add('entry_not_executable', `The entry "${entry}" names an outcome. A graph must enter at a node.`, at({ field: 'entry' }));
453
+ return;
454
+ }
455
+ diagnostics.add('missing_entry', `The entry ${JSON.stringify(entry)} is not a registered node id.`, at({ field: 'entry' }));
456
+ }
457
+ }
458
+ function validateEdges(graph, nodeIds, outcomeIds, diagnostics, at) {
459
+ if (!isObject(graph.edges) || Array.isArray(graph.edges)) {
460
+ diagnostics.add('missing_outgoing_edge', `A graph's edges must be an object; it is ${describeValue(graph.edges)}.`, at({ field: 'edges' }));
461
+ return;
462
+ }
463
+ const edgeIds = Object.keys(graph.edges);
464
+ const sources = new Map();
465
+ for (const edgeId of edgeIds) {
466
+ if (!identifierPattern.test(edgeId)) {
467
+ diagnostics.add('invalid_identifier', `Edge id "${edgeId}" must start with a letter and use only letters, digits, "_", or "-" (max 64 characters).`, at({ edgeId }));
468
+ }
469
+ const registration = graph.edges[edgeId];
470
+ if (!isBranded(registration, 'edge')) {
471
+ diagnostics.add('missing_callback', `Edge "${edgeId}" must be built with edge().`, at({ edgeId }));
472
+ continue;
473
+ }
474
+ if (typeof registration.choose !== 'function') {
475
+ diagnostics.add('missing_callback', `Edge "${edgeId}" needs a choose() function.`, at({ edgeId }));
476
+ }
477
+ const from = registration.from;
478
+ if (typeof from !== 'string' || !nodeIds.includes(from)) {
479
+ diagnostics.add('edge_source_unknown', `Edge "${edgeId}" routes from ${JSON.stringify(from)}, which is not a registered node.`, at({ edgeId }));
480
+ }
481
+ else {
482
+ const existing = sources.get(from);
483
+ if (existing)
484
+ existing.push(edgeId);
485
+ else
486
+ sources.set(from, [edgeId]);
487
+ }
488
+ validateDestinations(registration, edgeId, nodeIds, outcomeIds, diagnostics, at);
489
+ }
490
+ for (const [from, owners] of sources) {
491
+ if (owners.length > 1) {
492
+ diagnostics.add('duplicate_edge_source', `Node "${from}" has ${owners.length} routers (${owners.join(', ')}). Exactly one edge may route from a node.`, at({ nodeId: from }));
493
+ }
494
+ }
495
+ for (const nodeId of nodeIds) {
496
+ if (!sources.has(nodeId)) {
497
+ diagnostics.add('missing_outgoing_edge', `Node "${nodeId}" has no router. Every node needs exactly one edge whose "from" is that node.`, at({ nodeId }));
498
+ }
499
+ }
500
+ }
501
+ function validateDestinations(registration, edgeId, nodeIds, outcomeIds, diagnostics, at) {
502
+ const to = registration.to;
503
+ if (!Array.isArray(to) || to.length === 0) {
504
+ diagnostics.add('empty_destination_set', `Edge "${edgeId}" declares no destinations. A router must declare every destination it may choose.`, at({ edgeId }));
505
+ return;
506
+ }
507
+ const seen = new Set();
508
+ for (const destination of to) {
509
+ if (typeof destination !== 'string') {
510
+ diagnostics.add('edge_destination_unknown', `Edge "${edgeId}" declares a destination that is ${describeValue(destination)} rather than a node or outcome id.`, at({ edgeId }));
511
+ continue;
512
+ }
513
+ if (seen.has(destination)) {
514
+ diagnostics.add('duplicate_destination', `Edge "${edgeId}" declares "${destination}" more than once.`, at({ edgeId }));
515
+ continue;
516
+ }
517
+ seen.add(destination);
518
+ if (!nodeIds.includes(destination) && !outcomeIds.includes(destination)) {
519
+ diagnostics.add('edge_destination_unknown', `Edge "${edgeId}" declares the destination "${destination}", which is neither a node nor an outcome in this graph.`, at({ edgeId }));
520
+ }
521
+ }
522
+ }
523
+ function describeGraph(graph) {
524
+ const nodes = isObject(graph.nodes) ? graph.nodes : {};
525
+ const edges = isObject(graph.edges) ? graph.edges : {};
526
+ const outcomes = isObject(graph.outcomes) ? graph.outcomes : {};
527
+ const state = isObject(graph.state) ? graph.state : {};
528
+ return {
529
+ key: String(graph.key),
530
+ title: String(graph.title),
531
+ ...optional('description', graph.description),
532
+ stateFields: Object.keys(state).sort(compare),
533
+ entry: String(graph.entry),
534
+ nodes: Object.keys(nodes)
535
+ .sort(compare)
536
+ .map((id) => describeNode(id, nodes[id])),
537
+ edges: Object.keys(edges)
538
+ .sort(compare)
539
+ .map((id) => describeEdge(id, edges[id])),
540
+ outcomes: Object.keys(outcomes)
541
+ .sort(compare)
542
+ .map((id) => describeOutcome(id, outcomes[id])),
543
+ };
544
+ }
545
+ function describeNode(id, node) {
546
+ const shared = {
547
+ id,
548
+ ...optional('title', node.title),
549
+ ...optional('description', node.description),
550
+ };
551
+ if (node.isagiKind === 'subgraph-node') {
552
+ return { ...shared, kind: 'subgraph', graphKey: String(node.graph.key) };
553
+ }
554
+ if (node.isagiKind === 'checkpoint-node') {
555
+ return { ...shared, kind: 'checkpoint' };
556
+ }
557
+ return { ...shared, kind: 'operation' };
558
+ }
559
+ function describeEdge(id, edge) {
560
+ const declared = Array.isArray(edge.to) ? edge.to : [];
561
+ const to = [];
562
+ for (const destination of declared) {
563
+ const value = String(destination);
564
+ if (!to.includes(value))
565
+ to.push(value);
566
+ }
567
+ return { id, from: String(edge.from), to, ...optional('title', edge.title) };
568
+ }
569
+ function describeOutcome(id, outcome) {
570
+ return {
571
+ id,
572
+ kind: outcome.kind === 'failure' ? 'failure' : 'success',
573
+ ...optional('reason', outcome.reason),
574
+ ...optional('title', outcome.title),
575
+ };
576
+ }
577
+ function optional(key, value) {
578
+ return typeof value === 'string' ? { [key]: value } : {};
579
+ }
580
+ /** Byte-stable ordering that does not depend on the host's locale. */
581
+ function compare(left, right) {
582
+ return left < right ? -1 : left > right ? 1 : 0;
583
+ }
584
+ /**
585
+ * Writes the descriptor with a fixed key order, already-sorted arrays, and absent optional keys
586
+ * omitted, so the verifier and the runtime loader hash identical bytes for identical structure.
587
+ */
588
+ export function canonicalizeDescriptor(descriptor) {
589
+ return JSON.stringify({
590
+ descriptorVersion: descriptor.descriptorVersion,
591
+ workflowContractVersion: descriptor.workflowContractVersion,
592
+ rootGraphKey: descriptor.rootGraphKey,
593
+ graphs: descriptor.graphs.map((graph) => ({
594
+ key: graph.key,
595
+ title: graph.title,
596
+ ...optional('description', graph.description),
597
+ stateFields: graph.stateFields,
598
+ entry: graph.entry,
599
+ nodes: graph.nodes.map((node) => ({
600
+ id: node.id,
601
+ kind: node.kind,
602
+ ...(node.kind === 'subgraph' ? { graphKey: node.graphKey } : {}),
603
+ ...optional('title', node.title),
604
+ ...optional('description', node.description),
605
+ })),
606
+ edges: graph.edges.map((edge) => ({
607
+ id: edge.id,
608
+ from: edge.from,
609
+ to: edge.to,
610
+ ...optional('title', edge.title),
611
+ })),
612
+ outcomes: graph.outcomes.map((outcome) => ({
613
+ id: outcome.id,
614
+ kind: outcome.kind,
615
+ ...optional('reason', outcome.reason),
616
+ ...optional('title', outcome.title),
617
+ })),
618
+ })),
619
+ });
620
+ }
621
+ export function hashDescriptor(descriptor) {
622
+ return createHash('sha256').update(canonicalizeDescriptor(descriptor), 'utf8').digest('hex');
623
+ }
624
+ //# sourceMappingURL=structure.js.map