@nanobpm/nano-workforce 0.130.0 → 0.131.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/app/agentic/cockpit/supply-boot.test.ts +49 -2
- package/app/agentic/cockpit/supply-boot.ts +44 -2
- package/app/agentic/cockpit/supply-render.test.ts +21 -0
- package/app/agentic/cockpit/supply-render.ts +19 -9
- package/app/agentic/cockpit/supply-view.test.ts +15 -0
- package/app/agentic/cockpit/supply-view.ts +9 -0
- package/app/deliveryGraph.test.ts +279 -0
- package/app/deliveryGraph.ts +381 -2
- package/app/deliveryGraphCompiler.test.ts +119 -0
- package/app/deliveryGraphCompiler.ts +213 -44
- package/app/deliveryGraphDeploy.test.ts +148 -0
- package/docs/adr/0005-agent-authored-delivery-graphs.md +25 -0
- package/openapi.yaml +43 -1
- package/package.json +1 -1
- package/pages/cockpit/cockpit.css +12 -0
- package/pages/cockpit/mount.js +47 -8
package/app/deliveryGraph.ts
CHANGED
|
@@ -36,6 +36,23 @@ export const DELIVERY_FACT_TYPES = ["string", "number", "boolean", "artifact", "
|
|
|
36
36
|
/** An emitted fact's declared `type`, narrowed to the closed allowlist. */
|
|
37
37
|
export type DeliveryFactType = (typeof DELIVERY_FACT_TYPES)[number];
|
|
38
38
|
|
|
39
|
+
/** The SCALAR emitted-fact types a guarded edge's `when` may reference (ADR 0005 S7). A guard is an
|
|
40
|
+
* equality test `fact == literal`, so only a scalar (single-valued, comparable) fact can be guarded —
|
|
41
|
+
* `artifact`/`version`/`url` are compound/opaque handles and are rejected as guard subjects. Kept as
|
|
42
|
+
* the single source of truth so the validator and the compiler agree on what is guardable. */
|
|
43
|
+
export const DELIVERY_GUARD_SCALAR_TYPES = ["string", "number", "boolean"] as const;
|
|
44
|
+
|
|
45
|
+
/** A guardable scalar fact type, narrowed from the closed emitted-fact allowlist. */
|
|
46
|
+
export type DeliveryGuardScalarType = (typeof DELIVERY_GUARD_SCALAR_TYPES)[number];
|
|
47
|
+
|
|
48
|
+
/** True when `type` is a guardable SCALAR (`string`/`number`/`boolean`) — the closed set a `when`
|
|
49
|
+
* guard may reference. */
|
|
50
|
+
export function isDeliveryGuardScalarType(type: unknown): type is DeliveryGuardScalarType {
|
|
51
|
+
if (typeof type !== "string") return false;
|
|
52
|
+
for (const t of DELIVERY_GUARD_SCALAR_TYPES) if (t === type) return true;
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
39
56
|
/** A machine-readable classification of a semantic failure, so a caller can branch on the error
|
|
40
57
|
* class (unknown-kind / dangling / cycle / bad-`from`) without string-matching the message. */
|
|
41
58
|
export type DeliveryGraphErrorCode =
|
|
@@ -53,7 +70,16 @@ export type DeliveryGraphErrorCode =
|
|
|
53
70
|
| "dangling-edge"
|
|
54
71
|
| "bad-from"
|
|
55
72
|
| "self-edge"
|
|
56
|
-
| "cycle"
|
|
73
|
+
| "cycle"
|
|
74
|
+
| "guard-missing-equals"
|
|
75
|
+
| "guard-missing-when"
|
|
76
|
+
| "guard-default-conflict"
|
|
77
|
+
| "bad-when"
|
|
78
|
+
| "guard-type-mismatch"
|
|
79
|
+
| "mixed-fan-out"
|
|
80
|
+
| "multiple-defaults"
|
|
81
|
+
| "non-exhaustive-split"
|
|
82
|
+
| "exclusive-merge-parity";
|
|
57
83
|
|
|
58
84
|
/** A single semantic validation failure. `path` is a JSON-path-qualified pointer at the offending
|
|
59
85
|
* input (`nodes[2].kind`, `edges[1].from`, `nodes[0].emits[1].name`), `message` is human-actionable,
|
|
@@ -189,8 +215,10 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
|
|
|
189
215
|
}
|
|
190
216
|
|
|
191
217
|
// Pass 1: node ids + kinds + per-kind config + declared facts. Build the id → declared-facts map
|
|
192
|
-
// used to resolve edge `from` references in pass 2
|
|
218
|
+
// used to resolve edge `from` references in pass 2, plus the id → (fact → declared type) map guard
|
|
219
|
+
// validation (pass 3) reads to enforce that a `when` references a SCALAR fact.
|
|
193
220
|
const nodeFacts = new Map<string, Set<string>>();
|
|
221
|
+
const nodeFactTypes = new Map<string, Map<string, DeliveryFactType>>();
|
|
194
222
|
nodes.forEach((rawNode, i) => {
|
|
195
223
|
const path = `nodes[${i}]`;
|
|
196
224
|
if (!isRecord(rawNode)) {
|
|
@@ -269,6 +297,7 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
|
|
|
269
297
|
// Collect + validate this node's typed emitted facts (uniqueness within the node). Registered
|
|
270
298
|
// under the id even when other fields are invalid, so downstream edge resolution is best-effort.
|
|
271
299
|
const facts = new Set<string>();
|
|
300
|
+
const factTypes = new Map<string, DeliveryFactType>();
|
|
272
301
|
if (rawNode.emits !== undefined) {
|
|
273
302
|
if (!Array.isArray(rawNode.emits)) {
|
|
274
303
|
errors.push({
|
|
@@ -319,6 +348,8 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
|
|
|
319
348
|
`${DELIVERY_FACT_TYPES.join(", ")}`,
|
|
320
349
|
code: "invalid-fact-type",
|
|
321
350
|
});
|
|
351
|
+
} else {
|
|
352
|
+
factTypes.set(rawFact.name, rawFact.type);
|
|
322
353
|
}
|
|
323
354
|
facts.add(rawFact.name);
|
|
324
355
|
});
|
|
@@ -326,6 +357,7 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
|
|
|
326
357
|
}
|
|
327
358
|
if (typeof id === "string" && id.length > 0 && !nodeFacts.has(id)) {
|
|
328
359
|
nodeFacts.set(id, facts);
|
|
360
|
+
nodeFactTypes.set(id, factTypes);
|
|
329
361
|
}
|
|
330
362
|
});
|
|
331
363
|
|
|
@@ -345,6 +377,18 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
|
|
|
345
377
|
}
|
|
346
378
|
// consumer (`to`) → set of upstream node ids (`from`'s node) — the dependency direction.
|
|
347
379
|
const adjacency = new Map<string, Set<string>>();
|
|
380
|
+
// Resolved, well-formed edges captured for the guard/topology pass (pass 3). Only edges whose BOTH
|
|
381
|
+
// endpoints resolve are kept — a dangling/self edge is already reported and must not reach pass 3.
|
|
382
|
+
const guardEdges: {
|
|
383
|
+
index: number;
|
|
384
|
+
fromNode: string;
|
|
385
|
+
to: string;
|
|
386
|
+
when?: unknown;
|
|
387
|
+
equals?: unknown;
|
|
388
|
+
hasWhen: boolean;
|
|
389
|
+
hasEquals: boolean;
|
|
390
|
+
isDefault: boolean;
|
|
391
|
+
}[] = [];
|
|
348
392
|
edges.forEach((rawEdge, i) => {
|
|
349
393
|
const path = `edges[${i}]`;
|
|
350
394
|
// A non-object entry or a missing/empty `from`/`to` is an edge *shape* error, not an
|
|
@@ -417,13 +461,282 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
|
|
|
417
461
|
const ups = adjacency.get(to) ?? new Set<string>();
|
|
418
462
|
ups.add(nodeId);
|
|
419
463
|
adjacency.set(to, ups);
|
|
464
|
+
guardEdges.push({
|
|
465
|
+
index: i,
|
|
466
|
+
fromNode: nodeId,
|
|
467
|
+
to,
|
|
468
|
+
when: rawEdge.when,
|
|
469
|
+
equals: rawEdge.equals,
|
|
470
|
+
hasWhen: rawEdge.when !== undefined,
|
|
471
|
+
hasEquals: rawEdge.equals !== undefined,
|
|
472
|
+
isDefault: rawEdge.default === true,
|
|
473
|
+
});
|
|
420
474
|
}
|
|
421
475
|
});
|
|
422
476
|
|
|
423
477
|
collectCycle(adjacency, errors);
|
|
478
|
+
// Pass 3: guard (S7) semantics — only when the graph is otherwise structurally sound (every edge
|
|
479
|
+
// resolved, no cycle). A malformed base graph is reported first; guard analysis assumes a DAG.
|
|
480
|
+
if (errors.length === 0) {
|
|
481
|
+
validateGuardedEdges(guardEdges, nodeFactTypes, errors);
|
|
482
|
+
}
|
|
424
483
|
return errors;
|
|
425
484
|
}
|
|
426
485
|
|
|
486
|
+
/** True when a guard `equals` literal's JSON type matches the referenced fact's declared scalar type. */
|
|
487
|
+
function equalsMatchesFactType(equals: unknown, factType: DeliveryGuardScalarType): boolean {
|
|
488
|
+
switch (factType) {
|
|
489
|
+
case "string":
|
|
490
|
+
return typeof equals === "string";
|
|
491
|
+
case "number":
|
|
492
|
+
return typeof equals === "number";
|
|
493
|
+
case "boolean":
|
|
494
|
+
return typeof equals === "boolean";
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** Pass 3 — validate the S7 guarded-edge (exclusive-split) semantics over the already-resolved edge
|
|
499
|
+
* set (ADR 0005 S7). Enforces, per edge: `when`⇔`equals` presence, `when`/`default` mutual exclusion,
|
|
500
|
+
* and that a `when` references a DECLARED SCALAR fact of its own producer whose type matches `equals`.
|
|
501
|
+
* Then, per split node: no fan-out that MIXES guarded and unconditional out-edges, at most one
|
|
502
|
+
* `default`, and exhaustiveness (a guarded split must carry a `default` unless it fully covers a
|
|
503
|
+
* boolean fact). Finally, the exclusive-MERGE parity the compiler relies on: a fan-in must be either
|
|
504
|
+
* a pure parallel join (all producers unconditional) or a clean exclusive merge (all producers on the
|
|
505
|
+
* branches of one split that reconverges here) — never a mix (a parallel AND-join fed by a conditional
|
|
506
|
+
* branch would deadlock; an exclusive merge fed by an always-firing producer would double-fire). */
|
|
507
|
+
function validateGuardedEdges(
|
|
508
|
+
guardEdges: {
|
|
509
|
+
index: number;
|
|
510
|
+
fromNode: string;
|
|
511
|
+
to: string;
|
|
512
|
+
when?: unknown;
|
|
513
|
+
equals?: unknown;
|
|
514
|
+
hasWhen: boolean;
|
|
515
|
+
hasEquals: boolean;
|
|
516
|
+
isDefault: boolean;
|
|
517
|
+
}[],
|
|
518
|
+
nodeFactTypes: ReadonlyMap<string, ReadonlyMap<string, DeliveryFactType>>,
|
|
519
|
+
errors: DeliveryGraphError[],
|
|
520
|
+
): void {
|
|
521
|
+
const nodeFacts = new Map<string, Set<string>>();
|
|
522
|
+
for (const [id, facts] of nodeFactTypes) nodeFacts.set(id, new Set(facts.keys()));
|
|
523
|
+
|
|
524
|
+
// Per-edge guard shape + reference validation. `guardFactType` is cached per edge for the split-level
|
|
525
|
+
// exhaustiveness check below.
|
|
526
|
+
const guardFactTypeByIndex = new Map<number, DeliveryGuardScalarType>();
|
|
527
|
+
for (const e of guardEdges) {
|
|
528
|
+
const path = `edges[${e.index}]`;
|
|
529
|
+
if (e.isDefault && (e.hasWhen || e.hasEquals)) {
|
|
530
|
+
errors.push({
|
|
531
|
+
path,
|
|
532
|
+
message: "a `default` edge cannot also carry `when`/`equals` — a default is the unguarded else-branch",
|
|
533
|
+
code: "guard-default-conflict",
|
|
534
|
+
});
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
if (e.hasWhen && !e.hasEquals) {
|
|
538
|
+
errors.push({
|
|
539
|
+
path: `${path}.equals`,
|
|
540
|
+
message: "a guarded edge with `when` requires an `equals` literal to compare the fact against",
|
|
541
|
+
code: "guard-missing-equals",
|
|
542
|
+
});
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
if (e.hasEquals && !e.hasWhen) {
|
|
546
|
+
errors.push({
|
|
547
|
+
path: `${path}.when`,
|
|
548
|
+
message: "`equals` is only meaningful with a `when` guard reference — add `when` or drop `equals`",
|
|
549
|
+
code: "guard-missing-when",
|
|
550
|
+
});
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
if (!e.hasWhen) continue; // plain or default edge — nothing more to check here.
|
|
554
|
+
|
|
555
|
+
const whenStr = e.when;
|
|
556
|
+
if (typeof whenStr !== "string" || whenStr.length === 0) {
|
|
557
|
+
errors.push({ path: `${path}.when`, message: "`when` must be a `<nodeId>.<fact>` string", code: "bad-when" });
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
const { nodeId: whenNode, fact: whenFact } = resolveFrom(whenStr, nodeFacts);
|
|
561
|
+
if (whenFact === undefined) {
|
|
562
|
+
errors.push({
|
|
563
|
+
path: `${path}.when`,
|
|
564
|
+
message: `guard \`when\` "${whenStr}" must be a qualified \`<nodeId>.<fact>\` reference to a declared fact`,
|
|
565
|
+
code: "bad-when",
|
|
566
|
+
});
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
if (whenNode !== e.fromNode) {
|
|
570
|
+
errors.push({
|
|
571
|
+
path: `${path}.when`,
|
|
572
|
+
message:
|
|
573
|
+
`guard \`when\` "${whenStr}" must reference a fact of this edge's producer "${e.fromNode}" ` +
|
|
574
|
+
`(the exclusive-split point), not "${whenNode}"`,
|
|
575
|
+
code: "bad-when",
|
|
576
|
+
});
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
const factType = nodeFactTypes.get(whenNode)?.get(whenFact);
|
|
580
|
+
if (factType === undefined || !isDeliveryGuardScalarType(factType)) {
|
|
581
|
+
errors.push({
|
|
582
|
+
path: `${path}.when`,
|
|
583
|
+
message:
|
|
584
|
+
`guard \`when\` "${whenStr}" must reference a declared SCALAR fact ` +
|
|
585
|
+
`(${DELIVERY_GUARD_SCALAR_TYPES.join(", ")}) of "${whenNode}"`,
|
|
586
|
+
code: "bad-when",
|
|
587
|
+
});
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
if (!equalsMatchesFactType(e.equals, factType)) {
|
|
591
|
+
errors.push({
|
|
592
|
+
path: `${path}.equals`,
|
|
593
|
+
message:
|
|
594
|
+
`guard \`equals\` for "${whenStr}" must be a ${factType} to match the fact's declared type`,
|
|
595
|
+
code: "guard-type-mismatch",
|
|
596
|
+
});
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
guardFactTypeByIndex.set(e.index, factType);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Group out-edges by producer node to check fan-out shape (mixing / defaults / exhaustiveness).
|
|
603
|
+
const outByNode = new Map<string, typeof guardEdges>();
|
|
604
|
+
for (const e of guardEdges) {
|
|
605
|
+
const list = outByNode.get(e.fromNode) ?? [];
|
|
606
|
+
list.push(e);
|
|
607
|
+
outByNode.set(e.fromNode, list);
|
|
608
|
+
}
|
|
609
|
+
const splitNodes = new Set<string>();
|
|
610
|
+
for (const [node, outs] of outByNode) {
|
|
611
|
+
const guarded = outs.filter((e) => e.hasWhen && !e.isDefault);
|
|
612
|
+
const defaults = outs.filter((e) => e.isDefault);
|
|
613
|
+
const plain = outs.filter((e) => !e.hasWhen && !e.isDefault);
|
|
614
|
+
const isSplit = guarded.length > 0 || defaults.length > 0;
|
|
615
|
+
if (!isSplit) continue;
|
|
616
|
+
// Only a GUARDED (`when`) fan-out to >=2 DISTINCT downstream targets is an exclusive split for
|
|
617
|
+
// topology. A lone `default: true` edge (no guarded sibling) always fires, and a node whose
|
|
618
|
+
// guarded + `default` edges all converge on ONE downstream node has no real fan-out — that node
|
|
619
|
+
// fires whenever its producer does. Adding either here would spuriously mark downstream
|
|
620
|
+
// nodes/leaves conditional and trip false exclusive-merge parity (or misselect the End join). The
|
|
621
|
+
// per-node mixing/exhaustiveness checks below still run for any `default` fan-out (they gate on
|
|
622
|
+
// `isSplit`); only the topology set is guard-derived and fan-out-shaped.
|
|
623
|
+
const branchTargets = new Set([...guarded, ...defaults].map((e) => e.to));
|
|
624
|
+
if (guarded.length > 0 && branchTargets.size > 1) splitNodes.add(node);
|
|
625
|
+
|
|
626
|
+
if (plain.length > 0) {
|
|
627
|
+
// No mixing: a node is a fork (all edges unconditional) OR an XOR-split (all edges guarded/
|
|
628
|
+
// default), never both — a plain edge always fires and would break exclusive-branch selection.
|
|
629
|
+
errors.push({
|
|
630
|
+
path: `edges[${plain[0].index}]`,
|
|
631
|
+
message:
|
|
632
|
+
`node "${node}" mixes guarded/default out-edges with an unconditional one — a split node's ` +
|
|
633
|
+
"out-edges must ALL be guarded (`when`) or `default`",
|
|
634
|
+
code: "mixed-fan-out",
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
if (defaults.length > 1) {
|
|
638
|
+
errors.push({
|
|
639
|
+
path: `edges[${defaults[1].index}]`,
|
|
640
|
+
message: `node "${node}" has more than one \`default\` out-edge — at most one else-branch per split`,
|
|
641
|
+
code: "multiple-defaults",
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Exhaustiveness: a guarded split must carry a `default`, UNLESS it fully covers a single boolean
|
|
646
|
+
// fact (both `true` and `false` guarded) — the only value domain equality guards can exhaust.
|
|
647
|
+
if (defaults.length === 0) {
|
|
648
|
+
const guardFactTypes = new Set(guarded.map((e) => guardFactTypeByIndex.get(e.index)));
|
|
649
|
+
const booleanFacts = new Set(
|
|
650
|
+
guarded.filter((e) => guardFactTypeByIndex.get(e.index) === "boolean").map((e) => String(e.when)),
|
|
651
|
+
);
|
|
652
|
+
let exhaustive = false;
|
|
653
|
+
if (guardFactTypes.size === 1 && booleanFacts.size === 1) {
|
|
654
|
+
const covered = new Set(guarded.map((e) => e.equals));
|
|
655
|
+
exhaustive = covered.has(true) && covered.has(false);
|
|
656
|
+
}
|
|
657
|
+
if (!exhaustive) {
|
|
658
|
+
errors.push({
|
|
659
|
+
path: `edges[${guarded[0]?.index ?? outs[0].index}]`,
|
|
660
|
+
message:
|
|
661
|
+
`guarded split "${node}" is not exhaustive — add a \`default\` else-branch (or cover both ` +
|
|
662
|
+
"values of a boolean fact) so no runtime value strands the token",
|
|
663
|
+
code: "non-exhaustive-split",
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Exclusive-merge parity. An edge is CONDITIONAL if it leaves a split (a guarded/default branch) or
|
|
670
|
+
// its producer is itself only conditionally reached; both are computed from the split set + forward
|
|
671
|
+
// reachability. A fan-in must be uniformly conditional (a clean exclusive merge) or uniformly
|
|
672
|
+
// unconditional (a parallel join) — a mix is the deadlock/double-fire shape the compiler cannot wire.
|
|
673
|
+
const forwardAdj = new Map<string, string[]>();
|
|
674
|
+
const allNodes = new Set<string>();
|
|
675
|
+
for (const [id] of nodeFactTypes) allNodes.add(id);
|
|
676
|
+
for (const e of guardEdges) {
|
|
677
|
+
allNodes.add(e.fromNode);
|
|
678
|
+
allNodes.add(e.to);
|
|
679
|
+
const list = forwardAdj.get(e.fromNode) ?? [];
|
|
680
|
+
if (!list.includes(e.to)) list.push(e.to);
|
|
681
|
+
forwardAdj.set(e.fromNode, list);
|
|
682
|
+
}
|
|
683
|
+
const topo = analyzeExclusiveTopology([...allNodes], forwardAdj, splitNodes);
|
|
684
|
+
|
|
685
|
+
// producers per consumer (node id), from resolved edges.
|
|
686
|
+
const producersByNode = new Map<string, Set<string>>();
|
|
687
|
+
for (const e of guardEdges) {
|
|
688
|
+
const set = producersByNode.get(e.to) ?? new Set<string>();
|
|
689
|
+
set.add(e.fromNode);
|
|
690
|
+
producersByNode.set(e.to, set);
|
|
691
|
+
}
|
|
692
|
+
const edgeConditional = (fromNode: string): boolean => splitNodes.has(fromNode) || topo.conditional.has(fromNode);
|
|
693
|
+
|
|
694
|
+
for (const [node, producers] of producersByNode) {
|
|
695
|
+
if (producers.size < 2) continue;
|
|
696
|
+
const conditional = [...producers].filter(edgeConditional);
|
|
697
|
+
const unconditional = [...producers].filter((p) => !edgeConditional(p));
|
|
698
|
+
if (conditional.length > 0 && unconditional.length > 0) {
|
|
699
|
+
errors.push({
|
|
700
|
+
path: "edges",
|
|
701
|
+
message:
|
|
702
|
+
`node "${node}" joins a conditional (exclusive-split) branch with an always-firing branch — ` +
|
|
703
|
+
"a parallel AND-join here deadlocks (the untaken branch never arrives). Route both through " +
|
|
704
|
+
"one exclusive split so they re-converge as an exclusive merge",
|
|
705
|
+
code: "exclusive-merge-parity",
|
|
706
|
+
});
|
|
707
|
+
} else if (conditional.length === producers.size && !topo.mergeNodes.has(node)) {
|
|
708
|
+
errors.push({
|
|
709
|
+
path: "edges",
|
|
710
|
+
message:
|
|
711
|
+
`node "${node}" merges conditional branches that do not re-converge from a single exclusive ` +
|
|
712
|
+
"split — its incoming branches are not provably mutually exclusive, so it cannot merge safely",
|
|
713
|
+
code: "exclusive-merge-parity",
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Same parity, now for the implicit End sink: the compiler joins every LEAF (a node with no
|
|
719
|
+
// out-edge) at the process End. A leaf is conditional iff it may not fire on a given run
|
|
720
|
+
// (`topo.conditional`). A leaf set that MIXES a conditional tail with an always-firing one is the
|
|
721
|
+
// exact deadlock/double-fire shape the End gateway cannot wire — a parallel AND-join waits forever
|
|
722
|
+
// for the untaken branch, an exclusive merge double-fires when both arrive — so reject it here (this
|
|
723
|
+
// is the invariant the compiler's End-gateway selection relies on).
|
|
724
|
+
const leaves = [...allNodes].filter((n) => (forwardAdj.get(n)?.length ?? 0) === 0);
|
|
725
|
+
if (leaves.length > 1) {
|
|
726
|
+
const conditionalLeaves = leaves.filter((n) => topo.conditional.has(n));
|
|
727
|
+
if (conditionalLeaves.length > 0 && conditionalLeaves.length < leaves.length) {
|
|
728
|
+
errors.push({
|
|
729
|
+
path: "edges",
|
|
730
|
+
message:
|
|
731
|
+
"the graph's terminal nodes mix a conditional (exclusive-split) tail with an always-firing " +
|
|
732
|
+
"tail — the End sink would deadlock as a parallel join (the untaken branch never arrives) or " +
|
|
733
|
+
"double-fire as an exclusive merge. Route the conditional tails so they re-converge before the end",
|
|
734
|
+
code: "exclusive-merge-parity",
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
427
740
|
/** Depth-first cycle detection over the consumer(`to`)→producer(`from`) graph. Pushes ONE
|
|
428
741
|
* {@link DeliveryGraphError} naming the offending cycle (the "reject at the offending edge"
|
|
429
742
|
* guarantee) — a pure in-memory walk, no I/O. Reports at most one cycle so the message stays
|
|
@@ -462,6 +775,72 @@ function collectCycle(adjacency: Map<string, Set<string>>, errors: DeliveryGraph
|
|
|
462
775
|
}
|
|
463
776
|
}
|
|
464
777
|
|
|
778
|
+
/** The exclusive-split topology derived from a graph's node-level forward adjacency and its set of
|
|
779
|
+
* exclusive-split node ids (ADR 0005 S7). This is the SINGLE canonical analysis both the semantic
|
|
780
|
+
* validator (parity enforcement) and the S1 compiler (gateway-type selection) consume, so the two
|
|
781
|
+
* never drift on which fan-in is an exclusive merge vs a parallel join:
|
|
782
|
+
*
|
|
783
|
+
* • `mergeNodes` — nodes where ≥2 DISTINCT branch targets of the SAME split re-converge (following
|
|
784
|
+
* edges forward). These fan-ins must compile to an exclusive/OR merge (first-token-proceeds), not
|
|
785
|
+
* a parallel AND-join, which would deadlock waiting for the untaken branch.
|
|
786
|
+
* • `conditional` — nodes that MAY NOT execute on a given run: reachable from some split's branch
|
|
787
|
+
* target and not yet re-established as always-firing by a re-convergence merge (a merge node and
|
|
788
|
+
* everything downstream of it is guaranteed again — exactly one branch always reaches the merge).
|
|
789
|
+
*
|
|
790
|
+
* Pure and deterministic — set iteration order does not affect membership, and callers sort before
|
|
791
|
+
* emitting. */
|
|
792
|
+
export interface ExclusiveTopology {
|
|
793
|
+
readonly mergeNodes: ReadonlySet<string>;
|
|
794
|
+
readonly conditional: ReadonlySet<string>;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
export function analyzeExclusiveTopology(
|
|
798
|
+
nodeIds: readonly string[],
|
|
799
|
+
forwardAdj: ReadonlyMap<string, readonly string[]>,
|
|
800
|
+
splitNodes: ReadonlySet<string>,
|
|
801
|
+
): ExclusiveTopology {
|
|
802
|
+
const reachFrom = (start: string): Set<string> => {
|
|
803
|
+
const seen = new Set<string>();
|
|
804
|
+
const stack = [start];
|
|
805
|
+
while (stack.length > 0) {
|
|
806
|
+
const n = stack.pop();
|
|
807
|
+
if (n === undefined || seen.has(n)) continue;
|
|
808
|
+
seen.add(n);
|
|
809
|
+
for (const next of forwardAdj.get(n) ?? []) if (!seen.has(next)) stack.push(next);
|
|
810
|
+
}
|
|
811
|
+
return seen;
|
|
812
|
+
};
|
|
813
|
+
|
|
814
|
+
const mergeNodes = new Set<string>();
|
|
815
|
+
const splitDownstream = new Set<string>();
|
|
816
|
+
for (const split of splitNodes) {
|
|
817
|
+
const branchTargets = forwardAdj.get(split) ?? [];
|
|
818
|
+
const reachCount = new Map<string, number>();
|
|
819
|
+
for (const target of branchTargets) {
|
|
820
|
+
const reach = reachFrom(target);
|
|
821
|
+
for (const n of reach) {
|
|
822
|
+
splitDownstream.add(n);
|
|
823
|
+
reachCount.set(n, (reachCount.get(n) ?? 0) + 1);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
for (const [n, count] of reachCount) if (count >= 2) mergeNodes.add(n);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// A merge node (and everything reachable from it) is guaranteed to fire again — exactly one branch of
|
|
830
|
+
// the split always reaches the merge — so it is NOT conditional even though it sits downstream of a
|
|
831
|
+
// split. Subtract that closure from the raw split-downstream set.
|
|
832
|
+
const guaranteedAgain = new Set<string>();
|
|
833
|
+
for (const merge of mergeNodes) for (const n of reachFrom(merge)) guaranteedAgain.add(n);
|
|
834
|
+
|
|
835
|
+
const conditional = new Set<string>();
|
|
836
|
+
for (const n of splitDownstream) if (!guaranteedAgain.has(n)) conditional.add(n);
|
|
837
|
+
|
|
838
|
+
// `nodeIds` participates only to keep the surface honest (every referenced node is known); the sets
|
|
839
|
+
// above are already complete over the reachable graph.
|
|
840
|
+
void nodeIds;
|
|
841
|
+
return { mergeNodes, conditional };
|
|
842
|
+
}
|
|
843
|
+
|
|
465
844
|
/** Build the `nodeId → declared-fact-names` map for a graph that has ALREADY passed
|
|
466
845
|
* {@link validateDeliveryGraph} (every id/emit is well-formed by then). This is the same map the
|
|
467
846
|
* validator builds internally for edge resolution; exported so a downstream consumer (the S1
|
|
@@ -323,3 +323,122 @@ test("DI (#440) is deterministic: identical JSON yields byte-identical laid-out
|
|
|
323
323
|
const b = await compileOk(RELEASE_RUNBOOK);
|
|
324
324
|
assertEquals(a.bpmn, b.bpmn);
|
|
325
325
|
});
|
|
326
|
+
|
|
327
|
+
// ── S7: guarded (conditional) edges compile to an exclusive gateway (ADR 0005 S7) ──────────────────
|
|
328
|
+
// A guarded split node's fan-out is an EXCLUSIVE gateway with a FEEL condition per guarded flow and a
|
|
329
|
+
// default flow; a fan-in re-converging its branches is an EXCLUSIVE merge (first-token-proceeds), not
|
|
330
|
+
// the parallel AND-join that would deadlock on the untaken branch. Byte-identical determinism holds.
|
|
331
|
+
const GUARDED_ADOPT = {
|
|
332
|
+
name: "adopt",
|
|
333
|
+
nodes: [
|
|
334
|
+
{ id: "bump", kind: "agent", agent: { jobType: "senior:feature" }, emits: [{ name: "result", type: "string" }] },
|
|
335
|
+
{ id: "migrate", kind: "agent", agent: { jobType: "senior:migrate" } },
|
|
336
|
+
{ id: "release", kind: "connector", connector: { target: "npm:publish" } },
|
|
337
|
+
],
|
|
338
|
+
edges: [
|
|
339
|
+
{ from: "bump", to: "migrate", when: "bump.result", equals: "breaking" },
|
|
340
|
+
{ from: "bump", to: "release", default: true },
|
|
341
|
+
{ from: "migrate", to: "release" },
|
|
342
|
+
],
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
test("S7 compiler: a guarded fan-out compiles to an exclusiveGateway with a FEEL condition + a default flow", async () => {
|
|
346
|
+
const r = await compileOk(GUARDED_ADOPT);
|
|
347
|
+
// The split's fork gateway is an EXCLUSIVE gateway (gwx), not the parallel fork (gwf).
|
|
348
|
+
assert(/<bpmn:exclusiveGateway id="gwx0"[^>]*name="fan out of bump"/.test(r.bpmn), "guarded split forks on an exclusiveGateway");
|
|
349
|
+
assert(!/<bpmn:parallelGateway id="gwf/.test(r.bpmn), "no parallel fork is emitted for a guarded split");
|
|
350
|
+
// The breaking flow carries a FEEL equality condition comparing the producer's published fact var.
|
|
351
|
+
// FEEL string literals in a conditionExpression use LITERAL double-quotes (the authored-BPMN
|
|
352
|
+
// convention), so the guard survives the engine deploy path.
|
|
353
|
+
assert(
|
|
354
|
+
r.bpmn.includes('<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=n0_result = "breaking"</bpmn:conditionExpression>'),
|
|
355
|
+
`expected the breaking guard condition, got: ${r.bpmn.match(/<bpmn:conditionExpression[^<]*<\/bpmn:conditionExpression>/g)?.join(" | ")}`,
|
|
356
|
+
);
|
|
357
|
+
// The gwx gateway names its default (else) flow, and that flow itself carries NO condition.
|
|
358
|
+
const defMatch = r.bpmn.match(/<bpmn:exclusiveGateway id="gwx0" default="(f\d+)"/);
|
|
359
|
+
assert(defMatch, "the exclusive split names a default flow");
|
|
360
|
+
const defFlow = new RegExp(`<bpmn:sequenceFlow id="${defMatch![1]}"[^>]*/>`);
|
|
361
|
+
assert(defFlow.test(r.bpmn), "the default flow is unconditional (self-closing, no conditionExpression)");
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("S7 compiler: the branches re-converge on an EXCLUSIVE merge, not a parallel AND-join", async () => {
|
|
365
|
+
const r = await compileOk(GUARDED_ADOPT);
|
|
366
|
+
assert(/<bpmn:exclusiveGateway id="gwm0"[^>]*name="join into release"/.test(r.bpmn), "release merges on an exclusiveGateway (gwm)");
|
|
367
|
+
assert(!/<bpmn:parallelGateway id="gwj/.test(r.bpmn), "no parallel AND-join is emitted for the exclusive re-convergence");
|
|
368
|
+
// The resolved preview carries the guard fields on the edges.
|
|
369
|
+
const guarded = r.resolved.edges.find((e) => e.to === "migrate" && e.fromNode === "bump");
|
|
370
|
+
assertEquals(guarded?.when, "bump.result");
|
|
371
|
+
assertEquals(guarded?.equals, "breaking");
|
|
372
|
+
const dflt = r.resolved.edges.find((e) => e.to === "release" && e.fromNode === "bump");
|
|
373
|
+
assertEquals(dflt?.default, true);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test("S7 compiler: multiple mutually-exclusive leaves join End on an exclusive merge (gwm_end)", async () => {
|
|
377
|
+
// Mode D: `adopt` routes the missing outcome to an escalate (human) leaf and the default to a `done`
|
|
378
|
+
// leaf. Only one leaf fires, so End must be an EXCLUSIVE merge, else the parallel join deadlocks.
|
|
379
|
+
const r = await compileOk({
|
|
380
|
+
name: "surface",
|
|
381
|
+
nodes: [
|
|
382
|
+
{ id: "adopt", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "surface", type: "string" }] },
|
|
383
|
+
{ id: "escalate", kind: "human", human: { prompt: "file upstream issue" } },
|
|
384
|
+
{ id: "done", kind: "connector", connector: { target: "npm:install" } },
|
|
385
|
+
],
|
|
386
|
+
edges: [
|
|
387
|
+
{ from: "adopt", to: "escalate", when: "adopt.surface", equals: "missing" },
|
|
388
|
+
{ from: "adopt", to: "done", default: true },
|
|
389
|
+
],
|
|
390
|
+
});
|
|
391
|
+
assert(r.bpmn.includes('id="gwm_end"'), "the exclusive-branch leaves join End on an exclusive merge");
|
|
392
|
+
assert(!r.bpmn.includes('id="gwj_end"'), "no parallel End join for mutually-exclusive leaves");
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test("S7 compiler: byte-identical determinism holds for a guarded graph (input order irrelevant)", async () => {
|
|
396
|
+
const a = await compileOk(GUARDED_ADOPT);
|
|
397
|
+
const b = await compileOk({ ...GUARDED_ADOPT, nodes: [...GUARDED_ADOPT.nodes].reverse(), edges: [...GUARDED_ADOPT.edges].reverse() });
|
|
398
|
+
assertEquals(a.bpmn, b.bpmn);
|
|
399
|
+
assertEquals(a.diagram, b.diagram);
|
|
400
|
+
assertEquals(JSON.stringify(a.resolved), JSON.stringify(b.resolved));
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test("S7 compiler: a non-exhaustive guarded split is rejected before compilation", async () => {
|
|
404
|
+
const errors = await compileFail({
|
|
405
|
+
nodes: [
|
|
406
|
+
{ id: "bump", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "result", type: "string" }] },
|
|
407
|
+
{ id: "migrate", kind: "agent", agent: { jobType: "j" } },
|
|
408
|
+
],
|
|
409
|
+
edges: [{ from: "bump", to: "migrate", when: "bump.result", equals: "breaking" }],
|
|
410
|
+
});
|
|
411
|
+
assert(errors.length > 0, "a non-exhaustive guarded split does not compile");
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("S7 compiler: a post-merge node with an extra always-firing producer joins on a PARALLEL gateway, not an exclusive merge", async () => {
|
|
415
|
+
// Regression (PR #495 review): `analyzeExclusiveTopology` marks EVERY node reachable from >=2 branch
|
|
416
|
+
// targets of a split as a merge node — including nodes DOWNSTREAM of the first re-convergence. Here
|
|
417
|
+
// `bump` splits to `migrate`/`release`, both re-converge on `release` (the real exclusive merge), and
|
|
418
|
+
// `release` -> `finalize`. `finalize` ALSO has an independent always-firing producer `warmup`, so its
|
|
419
|
+
// producers {release, warmup} are BOTH unconditional — the validator treats it as a parallel join.
|
|
420
|
+
// Deriving `joinExclusive` from `mergeNodes` alone wrongly made `finalize` an exclusive merge
|
|
421
|
+
// (first-token-proceeds), drifting from the validator. It must be a PARALLEL AND-join.
|
|
422
|
+
const r = await compileOk({
|
|
423
|
+
name: "postmerge",
|
|
424
|
+
nodes: [
|
|
425
|
+
{ id: "bump", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "result", type: "string" }] },
|
|
426
|
+
{ id: "warmup", kind: "connector", connector: { target: "npm:install" } },
|
|
427
|
+
{ id: "migrate", kind: "agent", agent: { jobType: "j" } },
|
|
428
|
+
{ id: "release", kind: "connector", connector: { target: "npm:publish" } },
|
|
429
|
+
{ id: "finalize", kind: "connector", connector: { target: "npm:pack" } },
|
|
430
|
+
],
|
|
431
|
+
edges: [
|
|
432
|
+
{ from: "bump", to: "migrate", when: "bump.result", equals: "breaking" },
|
|
433
|
+
{ from: "bump", to: "release", default: true },
|
|
434
|
+
{ from: "migrate", to: "release" },
|
|
435
|
+
{ from: "release", to: "finalize" },
|
|
436
|
+
{ from: "warmup", to: "finalize" },
|
|
437
|
+
],
|
|
438
|
+
});
|
|
439
|
+
// `release` is the genuine exclusive merge of the split's two branches.
|
|
440
|
+
assert(/<bpmn:exclusiveGateway id="gwm0"[^>]*name="join into release"/.test(r.bpmn), "release merges its split branches on an exclusive gateway");
|
|
441
|
+
// `finalize` joins two always-firing producers — it MUST be a parallel AND-join, never an exclusive merge.
|
|
442
|
+
assert(/<bpmn:parallelGateway id="gwj\d+"[^>]*name="join into finalize"/.test(r.bpmn), "finalize joins its always-firing producers on a parallel gateway");
|
|
443
|
+
assert(!/<bpmn:exclusiveGateway id="gwm\d+"[^>]*name="join into finalize"/.test(r.bpmn), "finalize is NOT compiled as an exclusive merge");
|
|
444
|
+
});
|