@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
|
@@ -37,6 +37,7 @@ import type {
|
|
|
37
37
|
} from "../nano-generated/api-io.d.ts";
|
|
38
38
|
import { DELIVERY_CONNECTOR_TASK_TYPE } from "./deliveryConnector.ts";
|
|
39
39
|
import {
|
|
40
|
+
analyzeExclusiveTopology,
|
|
40
41
|
type DeliveryGraphError,
|
|
41
42
|
deliveryNodeFacts,
|
|
42
43
|
resolveDeliveryFrom,
|
|
@@ -105,6 +106,15 @@ function escapeXml(value: string): string {
|
|
|
105
106
|
.replace(/'/g, "'");
|
|
106
107
|
}
|
|
107
108
|
|
|
109
|
+
/** Escape a string for XML ELEMENT TEXT content while KEEPING literal double-quotes — the convention
|
|
110
|
+
* every authored `<bpmn:conditionExpression>` FEEL uses (e.g. `=status = "converged"`). Only `&`, `<`,
|
|
111
|
+
* `>` are entity-escaped (required for text-node well-formedness); quotes stay literal so a FEEL string
|
|
112
|
+
* literal survives to the engine. Safe because the compiler grafts DI onto its own semantic XML without
|
|
113
|
+
* re-serializing it, so these text nodes are never round-tripped/normalized. Deterministic and total. */
|
|
114
|
+
function escapeXmlText(value: string): string {
|
|
115
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
116
|
+
}
|
|
117
|
+
|
|
108
118
|
/** Render a `name=value` XML attribute, choosing the delimiter so FEEL string literals survive the
|
|
109
119
|
* WASM engine's deploy path. That path does NOT decode `"`/`"` entities before FEEL parsing,
|
|
110
120
|
* so a FEEL expression containing a string literal MUST use a SINGLE-QUOTE attribute delimiter with
|
|
@@ -168,13 +178,44 @@ function feelStr(value: string): string {
|
|
|
168
178
|
return JSON.stringify(value);
|
|
169
179
|
}
|
|
170
180
|
|
|
181
|
+
/** Render a guard `equals` literal (S7) as its FEEL form — a string becomes a `"…"` literal, a number
|
|
182
|
+
* its decimal, a boolean `true`/`false`. `undefined` renders as `""` so it can double as a stable sort
|
|
183
|
+
* key for edges without a guard. Deterministic and total over the `string|number|boolean` scalar set. */
|
|
184
|
+
function feelLiteral(value: string | number | boolean | undefined): string {
|
|
185
|
+
if (typeof value === "string") return feelStr(value);
|
|
186
|
+
if (typeof value === "number") return String(value);
|
|
187
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
188
|
+
return "";
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The FEEL predicate a guarded edge (S7) contributes to its exclusive-split flow condition — e.g.
|
|
192
|
+
* `n0_result = "breaking"` — comparing the producer's published `<element>_<fact>` variable to the
|
|
193
|
+
* edge's `equals` literal. `when` names `<fromNode>.<fact>` (validated: a scalar fact of this edge's
|
|
194
|
+
* producer), so the variable is `<producerElement>_<fact>`. Returns `undefined` for a plain or default
|
|
195
|
+
* edge (no `when`). Deterministic. */
|
|
196
|
+
function guardConditionPart(
|
|
197
|
+
edge: ResolvedDeliveryEdge,
|
|
198
|
+
elementById: ReadonlyMap<string, string>,
|
|
199
|
+
nodeFacts: ReadonlyMap<string, ReadonlySet<string>>,
|
|
200
|
+
): string | undefined {
|
|
201
|
+
if (edge.when === undefined || edge.default === true) return undefined;
|
|
202
|
+
const { fact } = resolveDeliveryFrom(edge.when, nodeFacts);
|
|
203
|
+
if (fact === undefined) return undefined;
|
|
204
|
+
const producerElement = elementById.get(edge.fromNode) ?? edge.fromNode;
|
|
205
|
+
return `${producerElement}_${fact} = ${feelLiteral(edge.equals)}`;
|
|
206
|
+
}
|
|
207
|
+
|
|
171
208
|
/** One sequence flow in the compiled process — `source`/`target` are element ids, `name` an optional
|
|
172
|
-
* (fact) label.
|
|
209
|
+
* (fact / guard) label. `condition` is a FEEL boolean guard rendered as a `<bpmn:conditionExpression>`
|
|
210
|
+
* child (S7 guarded edge); `isDefault` marks the exclusive split's default (else) flow, whose id the
|
|
211
|
+
* split gateway carries as its `default` attribute. */
|
|
173
212
|
interface Flow {
|
|
174
213
|
id: string;
|
|
175
214
|
source: string;
|
|
176
215
|
target: string;
|
|
177
216
|
name?: string;
|
|
217
|
+
condition?: string;
|
|
218
|
+
isDefault?: boolean;
|
|
178
219
|
}
|
|
179
220
|
|
|
180
221
|
/** One late-binding input a consumer node receives (S4): the producer node's business id, the
|
|
@@ -187,14 +228,18 @@ interface BoundInput {
|
|
|
187
228
|
}
|
|
188
229
|
|
|
189
230
|
/** A compiled node's structural fixtures: its own BPMN `element` id, and — when it has >1 downstream
|
|
190
|
-
* or >1 upstream — the
|
|
191
|
-
*
|
|
192
|
-
*
|
|
231
|
+
* or >1 upstream — the fork/join gateway that fans its flow out/in. `entry` is the id upstream flows
|
|
232
|
+
* target (the join, else the element); `exit` is the id downstream flows leave from (the fork, else
|
|
233
|
+
* the element). `forkExclusive`/`joinExclusive` select an EXCLUSIVE gateway (S7): a guarded-split
|
|
234
|
+
* source forks on an `exclusiveGateway` (data-based branch), and a fan-in reconverging exclusive
|
|
235
|
+
* branches joins on an `exclusiveGateway` (first-token-proceeds) rather than a parallel AND-join. */
|
|
193
236
|
interface NodeWiring {
|
|
194
237
|
node: DeliveryNode;
|
|
195
238
|
element: string;
|
|
196
239
|
forkGateway?: string;
|
|
197
240
|
joinGateway?: string;
|
|
241
|
+
forkExclusive: boolean;
|
|
242
|
+
joinExclusive: boolean;
|
|
198
243
|
entry: string;
|
|
199
244
|
exit: string;
|
|
200
245
|
}
|
|
@@ -239,19 +284,27 @@ export async function compileDeliveryGraph(
|
|
|
239
284
|
const edges = Array.isArray(typed.edges) ? typed.edges : [];
|
|
240
285
|
|
|
241
286
|
// Resolve every edge's `from` endpoint against the SAME node/fact map the validator checked (shared
|
|
242
|
-
// helper — no drift), then sort edges deterministically by (consumer, producer, fact).
|
|
287
|
+
// helper — no drift), then sort edges deterministically by (consumer, producer, fact). Guard fields
|
|
288
|
+
// (`when`/`equals`/`default`, S7) are carried through verbatim so the preview and the compiled
|
|
289
|
+
// gateway conditions derive from one resolved edge.
|
|
243
290
|
const nodeFacts = deliveryNodeFacts(typed);
|
|
244
291
|
const resolvedEdges: ResolvedDeliveryEdge[] = edges
|
|
245
292
|
.map((edge) => {
|
|
246
293
|
const { nodeId, fact } = resolveDeliveryFrom(edge.from, nodeFacts);
|
|
247
|
-
|
|
248
|
-
|
|
294
|
+
let resolved: ResolvedDeliveryEdge = { from: edge.from, to: edge.to, fromNode: nodeId };
|
|
295
|
+
if (fact !== undefined) resolved = { ...resolved, fromFact: fact };
|
|
296
|
+
if (edge.when !== undefined) resolved = { ...resolved, when: edge.when };
|
|
297
|
+
if (edge.equals !== undefined) resolved = { ...resolved, equals: edge.equals };
|
|
298
|
+
if (edge.default === true) resolved = { ...resolved, default: true };
|
|
299
|
+
return resolved;
|
|
249
300
|
})
|
|
250
301
|
.sort(
|
|
251
302
|
(a, b) =>
|
|
252
303
|
byCodeUnit(a.to, b.to) ||
|
|
253
304
|
byCodeUnit(a.fromNode, b.fromNode) ||
|
|
254
|
-
byCodeUnit(a.fromFact ?? "", b.fromFact ?? "")
|
|
305
|
+
byCodeUnit(a.fromFact ?? "", b.fromFact ?? "") ||
|
|
306
|
+
byCodeUnit(a.when ?? "", b.when ?? "") ||
|
|
307
|
+
byCodeUnit(feelLiteral(a.equals), feelLiteral(b.equals)),
|
|
255
308
|
);
|
|
256
309
|
|
|
257
310
|
// Per-node producer/consumer adjacency (by node id), each sorted + de-duplicated for determinism.
|
|
@@ -268,25 +321,74 @@ export async function compileDeliveryGraph(
|
|
|
268
321
|
for (const list of producersById.values()) list.sort(byCodeUnit);
|
|
269
322
|
for (const list of consumersById.values()) list.sort(byCodeUnit);
|
|
270
323
|
|
|
324
|
+
// Exclusive-split topology (S7): a node with a GUARDED (`when`) out-edge is an exclusive split; a
|
|
325
|
+
// fan-in that re-converges a split's branches is an exclusive merge. Derived from the ONE shared
|
|
326
|
+
// `analyzeExclusiveTopology` the validator also uses, so gateway-type selection never drifts from the
|
|
327
|
+
// parity the validator enforced. A lone `default: true` edge (no guarded sibling) always fires and is
|
|
328
|
+
// NOT a split; and a node whose guarded + `default` edges all converge on ONE downstream target has
|
|
329
|
+
// no real fan-out either — mirror the validator: key off a guarded `when` fanning to >=2 DISTINCT
|
|
330
|
+
// downstream targets only.
|
|
331
|
+
const splitNodes = new Set<string>();
|
|
332
|
+
const guardedNodes = new Set<string>();
|
|
333
|
+
const branchTargetsByNode = new Map<string, Set<string>>();
|
|
334
|
+
for (const edge of resolvedEdges) {
|
|
335
|
+
const guarded = edge.when !== undefined && edge.default !== true;
|
|
336
|
+
if (!guarded && edge.default !== true) continue;
|
|
337
|
+
if (guarded) guardedNodes.add(edge.fromNode);
|
|
338
|
+
const targets = branchTargetsByNode.get(edge.fromNode) ?? new Set<string>();
|
|
339
|
+
targets.add(edge.to);
|
|
340
|
+
branchTargetsByNode.set(edge.fromNode, targets);
|
|
341
|
+
}
|
|
342
|
+
for (const node of guardedNodes) {
|
|
343
|
+
if ((branchTargetsByNode.get(node)?.size ?? 0) > 1) splitNodes.add(node);
|
|
344
|
+
}
|
|
345
|
+
const forwardAdj = new Map<string, string[]>();
|
|
346
|
+
for (const node of nodes) forwardAdj.set(node.id, [...(consumersById.get(node.id) ?? [])]);
|
|
347
|
+
const topology = analyzeExclusiveTopology(
|
|
348
|
+
nodes.map((n) => n.id),
|
|
349
|
+
forwardAdj,
|
|
350
|
+
splitNodes,
|
|
351
|
+
);
|
|
352
|
+
|
|
271
353
|
// Assign the deterministic BPMN element id per node (`n0`, `n1`, … in sorted order) plus the
|
|
272
|
-
// fork/join gateway ids
|
|
354
|
+
// fork/join gateway ids any fan-out/fan-in node needs: a PARALLEL fork/join is `gwf<i>`/`gwj<i>`; an
|
|
355
|
+
// EXCLUSIVE split/merge (S7) is `gwx<i>`/`gwm<i>`. Each id space has its own positional counter so the
|
|
356
|
+
// scheme stays deterministic and non-colliding.
|
|
273
357
|
const elementById = new Map<string, string>();
|
|
274
358
|
const wirings: NodeWiring[] = [];
|
|
275
359
|
const wiringById = new Map<string, NodeWiring>();
|
|
276
360
|
let forkSeq = 0;
|
|
277
361
|
let joinSeq = 0;
|
|
362
|
+
let splitSeq = 0;
|
|
363
|
+
let mergeSeq = 0;
|
|
278
364
|
nodes.forEach((node, i) => {
|
|
279
365
|
const element = `n${i}`;
|
|
280
366
|
elementById.set(node.id, element);
|
|
281
367
|
const consumers = consumersById.get(node.id) ?? [];
|
|
282
368
|
const producers = producersById.get(node.id) ?? [];
|
|
283
|
-
const
|
|
284
|
-
|
|
369
|
+
const forkExclusive = splitNodes.has(node.id);
|
|
370
|
+
// A fan-in is an EXCLUSIVE merge (first-token-proceeds) iff EVERY incoming branch is conditional —
|
|
371
|
+
// a split's own guarded/default out-edge, or a producer only conditionally reached. This is the
|
|
372
|
+
// SAME parity predicate the validator enforces (`edgeConditional`), so gateway-type selection never
|
|
373
|
+
// drifts from it. Deriving `joinExclusive` from `mergeNodes` alone over-fires: `analyzeExclusive
|
|
374
|
+
// Topology` marks every node reachable from >=2 branch targets as a merge, including nodes DOWNSTREAM
|
|
375
|
+
// of the real re-convergence — so a post-merge node that ALSO joins an independent always-firing
|
|
376
|
+
// producer would wrongly compile to an exclusive merge instead of the parallel AND-join both the
|
|
377
|
+
// validator and the semantics demand.
|
|
378
|
+
const joinExclusive =
|
|
379
|
+
producers.length > 1 &&
|
|
380
|
+
producers.every((p) => splitNodes.has(p) || topology.conditional.has(p));
|
|
381
|
+
const forkGateway =
|
|
382
|
+
consumers.length > 1 ? (forkExclusive ? `gwx${splitSeq++}` : `gwf${forkSeq++}`) : undefined;
|
|
383
|
+
const joinGateway =
|
|
384
|
+
producers.length > 1 ? (joinExclusive ? `gwm${mergeSeq++}` : `gwj${joinSeq++}`) : undefined;
|
|
285
385
|
const wiring: NodeWiring = {
|
|
286
386
|
node,
|
|
287
387
|
element,
|
|
288
388
|
forkGateway,
|
|
289
389
|
joinGateway,
|
|
390
|
+
forkExclusive: forkGateway !== undefined && forkExclusive,
|
|
391
|
+
joinExclusive: joinGateway !== undefined && joinExclusive,
|
|
290
392
|
entry: joinGateway ?? element,
|
|
291
393
|
exit: forkGateway ?? element,
|
|
292
394
|
};
|
|
@@ -297,7 +399,11 @@ export async function compileDeliveryGraph(
|
|
|
297
399
|
const roots = nodes.filter((n) => (producersById.get(n.id) ?? []).length === 0);
|
|
298
400
|
const leaves = nodes.filter((n) => (consumersById.get(n.id) ?? []).length === 0);
|
|
299
401
|
const startForkGateway = roots.length > 1 ? "gwf_start" : undefined;
|
|
300
|
-
|
|
402
|
+
// The End sink is an exclusive merge when its leaves are mutually-exclusive branch tails (only one
|
|
403
|
+
// fires per run); a parallel AND-join there would deadlock on the untaken branch. The validator has
|
|
404
|
+
// already rejected a leaf set that MIXES conditional and always-firing tails.
|
|
405
|
+
const endExclusive = leaves.length > 1 && leaves.some((n) => topology.conditional.has(n.id));
|
|
406
|
+
const endJoinGateway = leaves.length > 1 ? (endExclusive ? "gwm_end" : "gwj_end") : undefined;
|
|
301
407
|
|
|
302
408
|
// ── Build the flow list in a DETERMINISTIC order, then assign `f0…` ids positionally ────────────
|
|
303
409
|
const flows: Omit<Flow, "id">[] = [];
|
|
@@ -320,30 +426,58 @@ export async function compileDeliveryGraph(
|
|
|
320
426
|
// two fact-qualified edges between the same pair (e.g. `a.x -> b` and `a.y -> b`) would otherwise
|
|
321
427
|
// emit parallel flows between endpoints with no diverging gateway — invalid BPMN that schedules
|
|
322
428
|
// the consumer more than once. `resolvedEdges` is already sorted by (to, fromNode, fromFact), so
|
|
323
|
-
// same-endpoint edges are contiguous and their fact labels accumulate in deterministic order.
|
|
324
|
-
|
|
429
|
+
// same-endpoint edges are contiguous and their fact labels accumulate in deterministic order. For
|
|
430
|
+
// a guarded split (S7) the collapsed flow carries the OR of its guard conditions (or is the split
|
|
431
|
+
// default); the producer's exit is its exclusive gateway.
|
|
432
|
+
const collapsedEdges: { fromNode: string; to: string; facts: string[]; conditions: string[]; isDefault: boolean }[] =
|
|
433
|
+
[];
|
|
325
434
|
for (const edge of resolvedEdges) {
|
|
326
435
|
const last = collapsedEdges[collapsedEdges.length - 1];
|
|
436
|
+
const guardPart = guardConditionPart(edge, elementById, nodeFacts);
|
|
327
437
|
if (last && last.fromNode === edge.fromNode && last.to === edge.to) {
|
|
328
438
|
if (edge.fromFact !== undefined && !last.facts.includes(edge.fromFact)) last.facts.push(edge.fromFact);
|
|
439
|
+
if (edge.default === true) last.isDefault = true;
|
|
440
|
+
if (guardPart !== undefined && !last.conditions.includes(guardPart)) last.conditions.push(guardPart);
|
|
329
441
|
} else {
|
|
330
|
-
collapsedEdges.push({
|
|
442
|
+
collapsedEdges.push({
|
|
443
|
+
fromNode: edge.fromNode,
|
|
444
|
+
to: edge.to,
|
|
445
|
+
facts: edge.fromFact !== undefined ? [edge.fromFact] : [],
|
|
446
|
+
conditions: guardPart !== undefined ? [guardPart] : [],
|
|
447
|
+
isDefault: edge.default === true,
|
|
448
|
+
});
|
|
331
449
|
}
|
|
332
450
|
}
|
|
333
451
|
for (const edge of collapsedEdges) {
|
|
334
452
|
const producer = mustGet(wiringById, edge.fromNode);
|
|
335
453
|
const consumer = mustGet(wiringById, edge.to);
|
|
336
|
-
|
|
337
|
-
|
|
454
|
+
let flow: Omit<Flow, "id"> = { source: producer.exit, target: consumer.entry };
|
|
455
|
+
// A guard LABEL for the diagram/preview: the fact name(s), else the rendered condition / "default".
|
|
456
|
+
const label =
|
|
457
|
+
edge.facts.length > 0
|
|
458
|
+
? edge.facts.join(", ")
|
|
459
|
+
: edge.isDefault
|
|
460
|
+
? "default"
|
|
461
|
+
: edge.conditions.length > 0
|
|
462
|
+
? edge.conditions.join(" or ")
|
|
463
|
+
: undefined;
|
|
464
|
+
if (label !== undefined) flow = { ...flow, name: label };
|
|
465
|
+
if (edge.isDefault) {
|
|
466
|
+
flow = { ...flow, isDefault: true };
|
|
467
|
+
} else if (edge.conditions.length > 0) {
|
|
468
|
+
flow = { ...flow, condition: `=${edge.conditions.join(" or ")}` };
|
|
469
|
+
}
|
|
470
|
+
flows.push(flow);
|
|
338
471
|
}
|
|
339
472
|
// 5. Leaf(s) → End.
|
|
340
473
|
if (leaves.length === 1) {
|
|
341
474
|
flows.push({ source: mustGet(wiringById, leaves[0].id).exit, target: "End" });
|
|
342
475
|
} else if (leaves.length > 1) {
|
|
476
|
+
const endGateway = endExclusive ? "gwm_end" : "gwj_end";
|
|
343
477
|
for (const leaf of leaves) {
|
|
344
|
-
flows.push({ source: mustGet(wiringById, leaf.id).exit, target:
|
|
478
|
+
flows.push({ source: mustGet(wiringById, leaf.id).exit, target: endGateway });
|
|
345
479
|
}
|
|
346
|
-
flows.push({ source:
|
|
480
|
+
flows.push({ source: endGateway, target: "End" });
|
|
347
481
|
}
|
|
348
482
|
const numberedFlows: Flow[] = flows.map((f, i) => ({ id: `f${i}`, ...f }));
|
|
349
483
|
|
|
@@ -532,6 +666,26 @@ function renderBpmn(
|
|
|
532
666
|
const refs = (tag: string, ids: readonly string[]): string =>
|
|
533
667
|
ids.map((id) => ` <bpmn:${tag}>${id}</bpmn:${tag}>`).join("\n");
|
|
534
668
|
|
|
669
|
+
// The default (else) flow id per exclusive-split gateway (S7) — the flow the gateway names in its
|
|
670
|
+
// `default` attribute so an unmatched runtime value takes the else-branch instead of erroring.
|
|
671
|
+
const defaultFlowBySource = new Map<string, string>();
|
|
672
|
+
for (const f of flows) if (f.isDefault) defaultFlowBySource.set(f.source, f.id);
|
|
673
|
+
|
|
674
|
+
// Render a diverging/converging gateway. `exclusive` picks `exclusiveGateway` (data-based XOR split /
|
|
675
|
+
// first-token merge, S7) over the parallel AND fork/join; a diverging exclusive gateway carries its
|
|
676
|
+
// `default` flow id when one exists.
|
|
677
|
+
const gateway = (id: string, exclusive: boolean, name: string): string[] => {
|
|
678
|
+
const tag = exclusive ? "exclusiveGateway" : "parallelGateway";
|
|
679
|
+
const def = defaultFlowBySource.get(id);
|
|
680
|
+
const defAttr = def !== undefined ? ` default="${def}"` : "";
|
|
681
|
+
return [
|
|
682
|
+
` <bpmn:${tag} id="${id}"${defAttr} name="${escapeXml(name)}">`,
|
|
683
|
+
refs("incoming", incoming(id)),
|
|
684
|
+
refs("outgoing", outgoing(id)),
|
|
685
|
+
` </bpmn:${tag}>`,
|
|
686
|
+
];
|
|
687
|
+
};
|
|
688
|
+
|
|
535
689
|
const lines: string[] = [];
|
|
536
690
|
lines.push('<?xml version="1.0" encoding="UTF-8"?>');
|
|
537
691
|
lines.push(
|
|
@@ -551,48 +705,52 @@ function renderBpmn(
|
|
|
551
705
|
lines.push(refs("outgoing", outgoing("Start")));
|
|
552
706
|
lines.push(" </bpmn:startEvent>");
|
|
553
707
|
|
|
554
|
-
// Start fork gateway (fan-out to multiple roots)
|
|
708
|
+
// Start fork gateway (fan-out to multiple roots) — always a PARALLEL fork: Start unconditionally
|
|
709
|
+
// activates every independent root.
|
|
555
710
|
if (startForkGateway) {
|
|
556
|
-
lines.push(
|
|
557
|
-
lines.push(refs("incoming", incoming(startForkGateway)));
|
|
558
|
-
lines.push(refs("outgoing", outgoing(startForkGateway)));
|
|
559
|
-
lines.push(" </bpmn:parallelGateway>");
|
|
711
|
+
lines.push(...gateway(startForkGateway, false, "fan out to roots"));
|
|
560
712
|
}
|
|
561
713
|
|
|
562
|
-
// Node elements (sorted), each preceded by its join gateway and followed by its fork gateway.
|
|
714
|
+
// Node elements (sorted), each preceded by its join gateway and followed by its fork gateway. An
|
|
715
|
+
// exclusive split's fork (S7) is an `exclusiveGateway` with guard conditions on its out-flows; an
|
|
716
|
+
// exclusive-merge's join is a first-token `exclusiveGateway`.
|
|
563
717
|
for (const w of wirings) {
|
|
564
718
|
if (w.joinGateway) {
|
|
565
|
-
lines.push(
|
|
566
|
-
lines.push(refs("incoming", incoming(w.joinGateway)));
|
|
567
|
-
lines.push(refs("outgoing", outgoing(w.joinGateway)));
|
|
568
|
-
lines.push(" </bpmn:parallelGateway>");
|
|
719
|
+
lines.push(...gateway(w.joinGateway, w.joinExclusive, `join into ${w.node.id}`));
|
|
569
720
|
}
|
|
570
721
|
lines.push(renderNodeElement(w, incoming(w.element), outgoing(w.element), boundInputsByElement.get(w.element) ?? []));
|
|
571
722
|
if (w.forkGateway) {
|
|
572
|
-
lines.push(
|
|
573
|
-
lines.push(refs("incoming", incoming(w.forkGateway)));
|
|
574
|
-
lines.push(refs("outgoing", outgoing(w.forkGateway)));
|
|
575
|
-
lines.push(" </bpmn:parallelGateway>");
|
|
723
|
+
lines.push(...gateway(w.forkGateway, w.forkExclusive, `fan out of ${w.node.id}`));
|
|
576
724
|
}
|
|
577
725
|
}
|
|
578
726
|
|
|
579
|
-
// End join gateway (fan-in from multiple leaves) + end event.
|
|
727
|
+
// End join gateway (fan-in from multiple leaves) + end event. Exclusive when the leaves are
|
|
728
|
+
// mutually-exclusive branch tails (S7), else a parallel AND-join.
|
|
580
729
|
if (endJoinGateway) {
|
|
581
|
-
lines.push(
|
|
582
|
-
lines.push(refs("incoming", incoming(endJoinGateway)));
|
|
583
|
-
lines.push(refs("outgoing", outgoing(endJoinGateway)));
|
|
584
|
-
lines.push(" </bpmn:parallelGateway>");
|
|
730
|
+
lines.push(...gateway(endJoinGateway, endJoinGateway.startsWith("gwm"), "join leaves"));
|
|
585
731
|
}
|
|
586
732
|
lines.push(' <bpmn:endEvent id="End" name="Graph complete">');
|
|
587
733
|
lines.push(refs("incoming", incoming("End")));
|
|
588
734
|
lines.push(" </bpmn:endEvent>");
|
|
589
735
|
|
|
590
|
-
// Sequence flows.
|
|
736
|
+
// Sequence flows. A guarded flow (S7) carries a `<bpmn:conditionExpression>` FEEL child; the default
|
|
737
|
+
// flow is unconditional (the gateway names it). Condition text uses LITERAL double-quotes for FEEL
|
|
738
|
+
// string literals (the authored-BPMN convention, e.g. `=status = "converged"`) — text content is not
|
|
739
|
+
// subject to the attribute entity-decoding hazard, and the compiler grafts DI without re-serializing
|
|
740
|
+
// this XML, so the literal quotes survive to deploy.
|
|
591
741
|
for (const f of flows) {
|
|
592
742
|
const nameAttr = f.name !== undefined ? ` name="${escapeXml(f.name)}"` : "";
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
743
|
+
if (f.condition !== undefined) {
|
|
744
|
+
lines.push(
|
|
745
|
+
` <bpmn:sequenceFlow id="${f.id}"${nameAttr} sourceRef="${f.source}" targetRef="${f.target}">` +
|
|
746
|
+
`<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">${escapeXmlText(f.condition)}</bpmn:conditionExpression>` +
|
|
747
|
+
"</bpmn:sequenceFlow>",
|
|
748
|
+
);
|
|
749
|
+
} else {
|
|
750
|
+
lines.push(
|
|
751
|
+
` <bpmn:sequenceFlow id="${f.id}"${nameAttr} sourceRef="${f.source}" targetRef="${f.target}" />`,
|
|
752
|
+
);
|
|
753
|
+
}
|
|
596
754
|
}
|
|
597
755
|
|
|
598
756
|
lines.push(" </bpmn:process>");
|
|
@@ -926,8 +1084,19 @@ function renderMermaid(
|
|
|
926
1084
|
for (const edge of edges) {
|
|
927
1085
|
const from = mustGet(elementById, edge.fromNode);
|
|
928
1086
|
const to = mustGet(elementById, edge.to);
|
|
929
|
-
|
|
930
|
-
|
|
1087
|
+
// Label a guarded edge with its predicate (`fact == value`) and a default with `default` (S7), a
|
|
1088
|
+
// fact-qualified edge with the fact name, else an unlabelled arrow.
|
|
1089
|
+
let label: string | undefined;
|
|
1090
|
+
if (edge.when !== undefined && edge.default !== true) {
|
|
1091
|
+
const guardFact = edge.when.includes(".") ? edge.when.slice(edge.when.lastIndexOf(".") + 1) : edge.when;
|
|
1092
|
+
label = `${guardFact} == ${feelLiteral(edge.equals)}`;
|
|
1093
|
+
} else if (edge.default === true) {
|
|
1094
|
+
label = "default";
|
|
1095
|
+
} else if (edge.fromFact !== undefined) {
|
|
1096
|
+
label = edge.fromFact;
|
|
1097
|
+
}
|
|
1098
|
+
if (label !== undefined) {
|
|
1099
|
+
lines.push(` ${from} -- "${escapeMermaid(label)}" --> ${to}`);
|
|
931
1100
|
} else {
|
|
932
1101
|
lines.push(` ${from} --> ${to}`);
|
|
933
1102
|
}
|
|
@@ -207,3 +207,151 @@ test("di coverage: every compiled flow node carries a BPMNShape and every sequen
|
|
|
207
207
|
function escapeRe(s: string): string {
|
|
208
208
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
209
209
|
}
|
|
210
|
+
|
|
211
|
+
// ── S7: guarded (conditional) routing DEPLOYS and ROUTES on the real engine (ADR 0005 S7) ──────────
|
|
212
|
+
// The compiler tests prove a guarded split emits an exclusiveGateway with FEEL conditions; only a live
|
|
213
|
+
// deploy proves the engine EVALUATES those conditions and takes exactly ONE branch. Here the `bump`
|
|
214
|
+
// agent's emitted scalar (`result`) is published to a process var and the exclusive gateway routes on
|
|
215
|
+
// it: the breaking outcome runs the `migrate` node, the green outcome skips straight to `release`, and
|
|
216
|
+
// BOTH re-converge on the exclusive merge to a COMPLETED instance (a parallel merge would deadlock the
|
|
217
|
+
// skipped branch). A guarded string fact needs a `default`, so green rides the else-flow.
|
|
218
|
+
const GUARDED_ADOPT: DeliveryGraph = {
|
|
219
|
+
name: "adopt runbook",
|
|
220
|
+
nodes: [
|
|
221
|
+
{ id: "bump", kind: "agent", agent: { jobType: "senior:bump" }, emits: [{ name: "result", type: "string" }] },
|
|
222
|
+
{ id: "migrate", kind: "agent", agent: { jobType: "senior:migrate" } },
|
|
223
|
+
{ id: "release", kind: "connector", connector: { target: "npm:publish", dedupeKey: "rel-1" } },
|
|
224
|
+
],
|
|
225
|
+
edges: [
|
|
226
|
+
{ from: "bump", to: "migrate", when: "bump.result", equals: "breaking" },
|
|
227
|
+
{ from: "bump", to: "release", default: true },
|
|
228
|
+
{ from: "migrate", to: "release" },
|
|
229
|
+
],
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
async function driveGuarded(outcome: "breaking" | "green"): Promise<{ state: string; migrateRan: boolean; releaseRan: boolean }> {
|
|
233
|
+
const engine = await createWasmEngineClient();
|
|
234
|
+
try {
|
|
235
|
+
let migrateRan = false;
|
|
236
|
+
let releaseRan = false;
|
|
237
|
+
// The split agent publishes its scalar outcome; the exclusive gateway routes on it.
|
|
238
|
+
await engine.registerWorker("senior:bump", async () => ({ result: outcome }));
|
|
239
|
+
await engine.registerWorker("senior:migrate", async () => {
|
|
240
|
+
migrateRan = true;
|
|
241
|
+
return {};
|
|
242
|
+
});
|
|
243
|
+
await engine.registerWorker(DELIVERY_CONNECTOR_TASK_TYPE, async () => {
|
|
244
|
+
releaseRan = true;
|
|
245
|
+
return {};
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const run = await runDeliveryGraph(engine, GUARDED_ADOPT);
|
|
249
|
+
assert(run.ok, `runDeliveryGraph failed: ${JSON.stringify(run)}`);
|
|
250
|
+
const key = run.handle.processInstanceKey;
|
|
251
|
+
|
|
252
|
+
let state = "?";
|
|
253
|
+
for (let round = 0; round < MAX_ROUNDS; round++) {
|
|
254
|
+
await engine.drain();
|
|
255
|
+
const [pi] = await engine.searchProcessInstances({ processInstanceKeys: [key] });
|
|
256
|
+
assert(pi, `no process instance snapshot for ${key}`);
|
|
257
|
+
state = pi.state ?? "?";
|
|
258
|
+
if (state === "COMPLETED" || state === "TERMINATED") break;
|
|
259
|
+
}
|
|
260
|
+
return { state, migrateRan, releaseRan };
|
|
261
|
+
} finally {
|
|
262
|
+
await engine.close();
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
test("S7 deploy+route: the breaking guard branch runs `migrate` before re-converging on the exclusive merge to COMPLETED", async () => {
|
|
267
|
+
const r = await driveGuarded("breaking");
|
|
268
|
+
assertEquals(r.state, "COMPLETED", "the breaking branch must run to a COMPLETED instance");
|
|
269
|
+
assert(r.migrateRan, "the breaking outcome must route through the guarded `migrate` node");
|
|
270
|
+
assert(r.releaseRan, "both branches must re-converge on `release`");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("S7 deploy+route: the green default branch SKIPS `migrate` and rides the else-flow straight to COMPLETED", async () => {
|
|
274
|
+
const r = await driveGuarded("green");
|
|
275
|
+
assertEquals(r.state, "COMPLETED", "the green branch must run to a COMPLETED instance");
|
|
276
|
+
assert(!r.migrateRan, "the green outcome must NOT route through `migrate` — it rides the default flow");
|
|
277
|
+
assert(r.releaseRan, "the green outcome still reaches `release` via the else-flow (proof the exclusive merge fires on one token)");
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("S7 deploy+route: mutually-exclusive leaves join End on an exclusive merge — the untaken leaf never blocks completion", async () => {
|
|
281
|
+
// Mode D: `adopt` routes a missing surface to an escalate (human) leaf, else to a `done` connector
|
|
282
|
+
// leaf. On the default path the escalate leaf never fires; an exclusive End merge must still let the
|
|
283
|
+
// instance COMPLETE (a parallel End join would wait forever on the untaken human leaf).
|
|
284
|
+
const graph: DeliveryGraph = {
|
|
285
|
+
name: "surface check",
|
|
286
|
+
nodes: [
|
|
287
|
+
{ id: "adopt", kind: "agent", agent: { jobType: "senior:adopt" }, emits: [{ name: "surface", type: "string" }] },
|
|
288
|
+
{ id: "escalate", kind: "human", human: { prompt: "file the upstream issue" } },
|
|
289
|
+
{ id: "done", kind: "connector", connector: { target: "npm:install", dedupeKey: "done-1" } },
|
|
290
|
+
],
|
|
291
|
+
edges: [
|
|
292
|
+
{ from: "adopt", to: "escalate", when: "adopt.surface", equals: "missing" },
|
|
293
|
+
{ from: "adopt", to: "done", default: true },
|
|
294
|
+
],
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
// Default path (surface present): the human leaf is skipped and the instance COMPLETES on its own.
|
|
298
|
+
{
|
|
299
|
+
const engine = await createWasmEngineClient();
|
|
300
|
+
try {
|
|
301
|
+
let doneRan = false;
|
|
302
|
+
await engine.registerWorker("senior:adopt", async () => ({ surface: "present" }));
|
|
303
|
+
await engine.registerWorker(DELIVERY_CONNECTOR_TASK_TYPE, async () => {
|
|
304
|
+
doneRan = true;
|
|
305
|
+
return {};
|
|
306
|
+
});
|
|
307
|
+
const run = await runDeliveryGraph(engine, graph, { escalationSlaTimeout: "PT1H" });
|
|
308
|
+
assert(run.ok, `runDeliveryGraph failed: ${JSON.stringify(run)}`);
|
|
309
|
+
const key = run.handle.processInstanceKey;
|
|
310
|
+
let state = "?";
|
|
311
|
+
for (let round = 0; round < MAX_ROUNDS; round++) {
|
|
312
|
+
await engine.drain();
|
|
313
|
+
const [pi] = await engine.searchProcessInstances({ processInstanceKeys: [key] });
|
|
314
|
+
state = pi?.state ?? "?";
|
|
315
|
+
if (state === "COMPLETED" || state === "TERMINATED") break;
|
|
316
|
+
const open = await engine.searchUserTasks({ processInstanceKey: key, state: "CREATED" });
|
|
317
|
+
assertEquals(open.length, 0, "the default path must never surface the escalate human leaf");
|
|
318
|
+
}
|
|
319
|
+
assertEquals(state, "COMPLETED", "the default (present) path completes without the human leaf");
|
|
320
|
+
assert(doneRan, "the default path routes to the `done` connector leaf");
|
|
321
|
+
} finally {
|
|
322
|
+
await engine.close();
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Guarded path (surface missing): the human leaf parks; `done` never runs.
|
|
327
|
+
{
|
|
328
|
+
const engine = await createWasmEngineClient();
|
|
329
|
+
try {
|
|
330
|
+
let doneRan = false;
|
|
331
|
+
await engine.registerWorker("senior:adopt", async () => ({ surface: "missing" }));
|
|
332
|
+
await engine.registerWorker(DELIVERY_CONNECTOR_TASK_TYPE, async () => {
|
|
333
|
+
doneRan = true;
|
|
334
|
+
return {};
|
|
335
|
+
});
|
|
336
|
+
const run = await runDeliveryGraph(engine, graph, { escalationSlaTimeout: "PT1H" });
|
|
337
|
+
assert(run.ok, `runDeliveryGraph failed: ${JSON.stringify(run)}`);
|
|
338
|
+
const key = run.handle.processInstanceKey;
|
|
339
|
+
let parked = "";
|
|
340
|
+
for (let round = 0; round < MAX_ROUNDS; round++) {
|
|
341
|
+
await engine.drain();
|
|
342
|
+
const open = await engine.searchUserTasks({ processInstanceKey: key, state: "CREATED" });
|
|
343
|
+
if (open.length > 0) {
|
|
344
|
+
parked = open[0].elementId ?? "";
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
assert(
|
|
349
|
+
parked.startsWith("delivery-human-task__") && !parked.endsWith("__esc"),
|
|
350
|
+
`the missing outcome must park on the escalate human leaf, saw ${JSON.stringify(parked)}`,
|
|
351
|
+
);
|
|
352
|
+
assert(!doneRan, "the guarded (missing) path must NOT run the `done` leaf");
|
|
353
|
+
} finally {
|
|
354
|
+
await engine.close();
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
});
|
|
@@ -225,6 +225,31 @@ resume cannot double-fire.
|
|
|
225
225
|
- **Non-npm emit facts** (OCI/github-release) and behavioural edges beyond the `command` escape hatch —
|
|
226
226
|
added when a real case lands.
|
|
227
227
|
|
|
228
|
+
> **Amendment (issue #492): conditional (guarded) edges landed (S7).** The "behavioural edges" deferral
|
|
229
|
+
> above is **partially lifted**: an edge may now carry an optional guard — `when: "<node>.<fact>"` +
|
|
230
|
+
> `equals: <scalar>` — or be the split's single `default: true` else-branch. A node whose out-edges
|
|
231
|
+
> carry guards is an **exclusive** (data-based) split instead of the default parallel fan-out; its guarded branches compile to a BPMN `exclusiveGateway` (`gwx<i>`) with one FEEL
|
|
232
|
+
> `conditionExpression` per guarded flow (`=<producerElement>_<fact> = <literal>`) and a named default
|
|
233
|
+
> flow, and where those branches re-converge they merge on an **exclusive** gateway (`gwm<i>`,
|
|
234
|
+
> first-token-proceeds) rather than the parallel AND-join that would deadlock the untaken branch. The
|
|
235
|
+
> validator enforces the guard's shape and closes the deadlock/ambiguity classes: a guard must
|
|
236
|
+
> reference a **scalar** fact **declared by the edge's own producer** (`bad-when`), carry an `equals`
|
|
237
|
+
> whose type matches the fact (`guard-missing-equals` / `guard-type-mismatch`), never combine `when`
|
|
238
|
+
> with `default` (`guard-default-conflict`); a split must not **mix** guarded and plain out-edges
|
|
239
|
+
> (`mixed-fan-out`) nor declare **two** defaults (`multiple-defaults`), must be **exhaustive** — a
|
|
240
|
+
> `default`, unless a single boolean fact is guarded on both `true` and `false` (`non-exhaustive-split`)
|
|
241
|
+
> — and a plain (parallel) AND-join may not be fed by an exclusive-split branch (`exclusive-merge-parity`),
|
|
242
|
+
> including the implicit **End sink**: the terminal nodes may not mix a conditional (exclusive-split)
|
|
243
|
+
> tail with an always-firing one, which would deadlock the End join or double-fire its exclusive merge.
|
|
244
|
+
> The exclusive-split topology (both the validator's parity analysis and the compiler's gateway
|
|
245
|
+
> selection) is derived from the **guarded (`when`) edges fanning out to \>=2 distinct downstream
|
|
246
|
+
> targets only** — a lone `default: true` edge with no guarded sibling always fires, and a node whose
|
|
247
|
+
> guarded + `default` edges all converge on **one** downstream target has no real fan-out, so in
|
|
248
|
+
> either case its producer is **not** a split and must not mark downstream nodes/leaves conditional;
|
|
249
|
+
> the per-node mixing/exhaustiveness checks still apply to any `default`.
|
|
250
|
+
> Determinism is preserved: gateway ids are positional over id-sorted nodes, so a graph with no guards
|
|
251
|
+
> compiles byte-for-byte as before.
|
|
252
|
+
|
|
228
253
|
## Open questions
|
|
229
254
|
|
|
230
255
|
- **Compiler target for the first cut** — confirm compile-to-native (diagram + native scheduling) vs a
|
package/openapi.yaml
CHANGED
|
@@ -1502,7 +1502,9 @@ components:
|
|
|
1502
1502
|
`from` is either a bare `<nodeId>` (wait for the upstream node's completion fact) or a
|
|
1503
1503
|
qualified `<nodeId>.<fact>` referencing a declared `emits` fact of that node. Both endpoints
|
|
1504
1504
|
must resolve to a node in the graph, the referenced fact must be declared, and the whole edge
|
|
1505
|
-
set must be a DAG — all enforced by `validateDeliveryGraph`.
|
|
1505
|
+
set must be a DAG — all enforced by `validateDeliveryGraph`. An OPTIONAL `when`/`equals` guard
|
|
1506
|
+
(or a `default` else-branch) makes the edge CONDITIONAL, turning its producer into an
|
|
1507
|
+
exclusive split (ADR 0005 S7) — a node's out-edges are then ALL guarded or ALL unconditional.
|
|
1506
1508
|
type: object
|
|
1507
1509
|
additionalProperties: false
|
|
1508
1510
|
required:
|
|
@@ -1517,6 +1519,33 @@ components:
|
|
|
1517
1519
|
type: string
|
|
1518
1520
|
minLength: 1
|
|
1519
1521
|
description: The dependent node's id — proceeds once `from` is observed.
|
|
1522
|
+
when:
|
|
1523
|
+
type: string
|
|
1524
|
+
minLength: 1
|
|
1525
|
+
description: >-
|
|
1526
|
+
OPTIONAL guard reference `<nodeId>.<fact>` naming a SCALAR emitted fact (`string`,
|
|
1527
|
+
`number`, or `boolean`) of the `from`-adjacent producer (ADR 0005 S7). Its presence makes
|
|
1528
|
+
this a GUARDED edge and turns the producer into an exclusive-split point: the edge is taken
|
|
1529
|
+
only when that runtime fact `equals` the literal below. Equality-only — no arbitrary
|
|
1530
|
+
expressions (the trust boundary). Mutually exclusive with `default`.
|
|
1531
|
+
equals:
|
|
1532
|
+
description: >-
|
|
1533
|
+
The literal value `when`'s fact must equal for this guarded edge to be taken (ADR 0005 S7).
|
|
1534
|
+
REQUIRED iff `when` is present, and its JSON type must match the referenced fact's declared
|
|
1535
|
+
type (`string`/`number`/`boolean`).
|
|
1536
|
+
oneOf:
|
|
1537
|
+
- type: string
|
|
1538
|
+
- type: number
|
|
1539
|
+
- type: boolean
|
|
1540
|
+
default:
|
|
1541
|
+
type: boolean
|
|
1542
|
+
enum: [true]
|
|
1543
|
+
description: >-
|
|
1544
|
+
OPTIONAL — marks this edge as the ELSE branch of the exclusive split (taken when no guarded
|
|
1545
|
+
edge matches at runtime). A FLAG: only `true` is meaningful, so it is constrained to `true`
|
|
1546
|
+
(omit the field entirely for a non-default edge — `default: false` is not a valid wire
|
|
1547
|
+
value). At most one `default` edge per split node. Mutually exclusive with `when`/`equals`
|
|
1548
|
+
(ADR 0005 S7).
|
|
1520
1549
|
DeliveryCompileError:
|
|
1521
1550
|
description: >-
|
|
1522
1551
|
One semantic-validation or compile failure, path-qualified at the offending input
|
|
@@ -1749,6 +1778,19 @@ components:
|
|
|
1749
1778
|
fromFact:
|
|
1750
1779
|
type: string
|
|
1751
1780
|
description: The referenced emitted fact, when the edge `from` was qualified.
|
|
1781
|
+
when:
|
|
1782
|
+
type: string
|
|
1783
|
+
description: The guard reference (`<nodeId>.<fact>`) verbatim, present only on a guarded edge (ADR 0005 S7).
|
|
1784
|
+
equals:
|
|
1785
|
+
description: The literal the guard fact must equal, present only on a guarded edge (ADR 0005 S7).
|
|
1786
|
+
oneOf:
|
|
1787
|
+
- type: string
|
|
1788
|
+
- type: number
|
|
1789
|
+
- type: boolean
|
|
1790
|
+
default:
|
|
1791
|
+
type: boolean
|
|
1792
|
+
enum: [true]
|
|
1793
|
+
description: True when this is the exclusive split's default (else) branch; a FLAG, only ever `true` and omitted otherwise (ADR 0005 S7).
|
|
1752
1794
|
ResolvedDeliveryGraph:
|
|
1753
1795
|
description: >-
|
|
1754
1796
|
The normalised graph the compiler resolved from the input (ADR 0005 slice S1) — nodes and
|