@nanobpm/nano-workforce 0.130.0 → 0.131.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -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 +166 -0
- package/app/deliveryGraphCompiler.ts +279 -52
- package/app/deliveryGraphDeploy.test.ts +148 -0
- package/app/deliveryRunner.test.ts +35 -1
- package/app/deliveryRunner.ts +13 -3
- 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/resources/forms/delivery-human-generic.form +24 -0
- package/resources/processes/delivery-human.bpmn +4 -0
|
@@ -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>");
|
|
@@ -665,6 +823,16 @@ function ioMappingLines(w: NodeWiring, boundInputs: readonly BoundInput[]): stri
|
|
|
665
823
|
case "human":
|
|
666
824
|
inputs.push({ source: cfg("escalationSlaTimeout"), target: "escalationSlaTimeout" });
|
|
667
825
|
inputs.push({ source: cfg("escalationAssignee"), target: "escalationAssignee" });
|
|
826
|
+
// Seed the authored instruction + node identity + emit context so the generic human form renders
|
|
827
|
+
// "now do X", names the parked node, and labels/hides its emit field (issue #499). `emits` is the
|
|
828
|
+
// single source of truth; the emit label/mode are derived from it in FEEL here (no duplicate seed).
|
|
829
|
+
inputs.push({ source: cfg("prompt"), target: "prompt" });
|
|
830
|
+
inputs.push({ source: cfg("nodeId"), target: "nodeId" });
|
|
831
|
+
inputs.push({ source: `=if count(${cfg("emits").slice(1)}) = 0 then "none" else "typed"`, target: "emitMode" });
|
|
832
|
+
inputs.push({
|
|
833
|
+
source: `=string join(for _e in ${cfg("emits").slice(1)} return _e.name + " (" + _e.type + ")", ", ")`,
|
|
834
|
+
target: "emitLabel",
|
|
835
|
+
});
|
|
668
836
|
break;
|
|
669
837
|
case "connector":
|
|
670
838
|
inputs.push({ source: cfg("target"), target: "target" });
|
|
@@ -710,9 +878,9 @@ function innerBodyLines(w: NodeWiring): string[] {
|
|
|
710
878
|
const node = w.node;
|
|
711
879
|
switch (node.kind) {
|
|
712
880
|
case "agent":
|
|
713
|
-
return serviceBodyLines(el, node.id, attr("type", node.agent.jobType), []);
|
|
881
|
+
return serviceBodyLines(el, node.id, attr("type", node.agent.jobType), [], node.agent.jobType);
|
|
714
882
|
case "connector":
|
|
715
|
-
return serviceBodyLines(el, node.id, `type="${DELEGATE_TASK_TYPE.connector}"`, []);
|
|
883
|
+
return serviceBodyLines(el, node.id, `type="${DELEGATE_TASK_TYPE.connector}"`, [], `connector → ${node.connector.target}`);
|
|
716
884
|
case "wait":
|
|
717
885
|
return waitBodyLines(el, node.id);
|
|
718
886
|
case "human":
|
|
@@ -724,8 +892,15 @@ function innerBodyLines(w: NodeWiring): string[] {
|
|
|
724
892
|
|
|
725
893
|
/** `agent`/`connector` body: `start → serviceTask → end`, with a bounded `=nodeTimeout` boundary that
|
|
726
894
|
* escalates the stalled node onto a human-completable user task. `taskDefAttr` is the pre-rendered
|
|
727
|
-
* `type="…"` attribute; `taskProps` are optional `<zeebe:property>` envelope lines
|
|
728
|
-
|
|
895
|
+
* `type="…"` attribute; `taskProps` are optional `<zeebe:property>` envelope lines; `descriptor`
|
|
896
|
+
* names the stalled work (job type / connector target) for the escalation task's context line (#499). */
|
|
897
|
+
function serviceBodyLines(
|
|
898
|
+
el: string,
|
|
899
|
+
nodeId: string,
|
|
900
|
+
taskDefAttr: string,
|
|
901
|
+
taskProps: readonly string[],
|
|
902
|
+
descriptor: string,
|
|
903
|
+
): string[] {
|
|
729
904
|
const esc = escalationTaskElement(el);
|
|
730
905
|
const taskExt =
|
|
731
906
|
taskProps.length > 0
|
|
@@ -753,7 +928,18 @@ function serviceBodyLines(el: string, nodeId: string, taskDefAttr: string, taskP
|
|
|
753
928
|
` <bpmn:outgoing>${el}_i2</bpmn:outgoing>`,
|
|
754
929
|
` <bpmn:timerEventDefinition id="${el}_ted"><bpmn:timeDuration xsi:type="bpmn:tFormalExpression">=nodeTimeout</bpmn:timeDuration></bpmn:timerEventDefinition>`,
|
|
755
930
|
" </bpmn:boundaryEvent>",
|
|
756
|
-
...escalationTaskLines(
|
|
931
|
+
...escalationTaskLines(
|
|
932
|
+
esc,
|
|
933
|
+
nodeId,
|
|
934
|
+
[`${el}_i2`],
|
|
935
|
+
`${el}_i3`,
|
|
936
|
+
escalationContextFeel(
|
|
937
|
+
nodeId,
|
|
938
|
+
descriptor,
|
|
939
|
+
"nodeTimeout",
|
|
940
|
+
"; in-flight work may already exist — check for a draft PR or partial state before retrying or reassigning.",
|
|
941
|
+
),
|
|
942
|
+
),
|
|
757
943
|
` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_i1</bpmn:incoming><bpmn:incoming>${el}_i3</bpmn:incoming></bpmn:endEvent>`,
|
|
758
944
|
flow(`${el}_i0`, `${el}_start`, `${el}_task`),
|
|
759
945
|
flow(`${el}_i1`, `${el}_task`, `${el}_end`),
|
|
@@ -833,7 +1019,13 @@ function waitBodyLines(el: string, nodeId: string): string[] {
|
|
|
833
1019
|
` <bpmn:outgoing>${el}_i7</bpmn:outgoing>`,
|
|
834
1020
|
` <bpmn:outgoing>${el}_i4</bpmn:outgoing>`,
|
|
835
1021
|
" </bpmn:exclusiveGateway>",
|
|
836
|
-
...escalationTaskLines(
|
|
1022
|
+
...escalationTaskLines(
|
|
1023
|
+
esc,
|
|
1024
|
+
nodeId,
|
|
1025
|
+
[`${el}_i4`],
|
|
1026
|
+
`${el}_i5`,
|
|
1027
|
+
escalationContextFeel(nodeId, "readiness gate", "probeTimeout", " before its ReadinessProbe went green — decide how to proceed."),
|
|
1028
|
+
),
|
|
837
1029
|
` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_i1</bpmn:incoming><bpmn:incoming>${el}_i5</bpmn:incoming><bpmn:incoming>${el}_i7</bpmn:incoming></bpmn:endEvent>`,
|
|
838
1030
|
flow(`${el}_i0`, `${el}_start`, `${el}_probeLoop`),
|
|
839
1031
|
flow(`${el}_i1`, `${el}_probeLoop`, `${el}_end`),
|
|
@@ -889,14 +1081,29 @@ function humanBodyLines(el: string, nodeId: string): string[] {
|
|
|
889
1081
|
}
|
|
890
1082
|
|
|
891
1083
|
/** A bounded node's escalation user task — a human-completable stop (`isDeliveryHumanElement`
|
|
892
|
-
* convention) that a human OR an agent (ADR 0046) answers to unstick a stalled node.
|
|
893
|
-
|
|
1084
|
+
* convention) that a human OR an agent (ADR 0046) answers to unstick a stalled node. `contextFeel` is
|
|
1085
|
+
* a FEEL expression yielding the context line seeded onto the generic form's read-only prompt field
|
|
1086
|
+
* (issue #499) — e.g. "Node n1 (senior:feature) exceeded its SLA (PT30M); …" — so the operator can see
|
|
1087
|
+
* WHICH node timed out and that in-flight work may already exist, instead of a blank form. The emit
|
|
1088
|
+
* field is labelled "none" so the generic form hides its (inert on an escalation) typed-value input. */
|
|
1089
|
+
function escalationTaskLines(
|
|
1090
|
+
esc: string,
|
|
1091
|
+
nodeId: string,
|
|
1092
|
+
incoming: readonly string[],
|
|
1093
|
+
outgoing: string,
|
|
1094
|
+
contextFeel: string,
|
|
1095
|
+
): string[] {
|
|
894
1096
|
return [
|
|
895
1097
|
` <bpmn:userTask id="${esc}" name="Escalate: ${escapeXml(nodeId)}">`,
|
|
896
1098
|
" <bpmn:extensionElements>",
|
|
897
1099
|
` <zeebe:formDefinition formId="${GENERIC_HUMAN_FORM}" />`,
|
|
898
1100
|
" <zeebe:userTask />",
|
|
899
1101
|
' <zeebe:assignmentDefinition candidateGroups="operators" />',
|
|
1102
|
+
" <zeebe:ioMapping>",
|
|
1103
|
+
` <zeebe:input ${attr("source", contextFeel)} target="prompt" />`,
|
|
1104
|
+
` <zeebe:input ${attr("source", `=${feelStr(nodeId)}`)} target="nodeId" />`,
|
|
1105
|
+
` <zeebe:input ${attr("source", '="none"')} target="emitMode" />`,
|
|
1106
|
+
" </zeebe:ioMapping>",
|
|
900
1107
|
" </bpmn:extensionElements>",
|
|
901
1108
|
...incoming.map((id) => ` <bpmn:incoming>${id}</bpmn:incoming>`),
|
|
902
1109
|
` <bpmn:outgoing>${outgoing}</bpmn:outgoing>`,
|
|
@@ -904,6 +1111,15 @@ function escalationTaskLines(esc: string, nodeId: string, incoming: readonly str
|
|
|
904
1111
|
];
|
|
905
1112
|
}
|
|
906
1113
|
|
|
1114
|
+
/** Build the FEEL context line seeded onto an escalation task's read-only prompt field (issue #499).
|
|
1115
|
+
* The node id + descriptor (job type / connector target / "readiness gate") are baked as compile-time
|
|
1116
|
+
* literals; the elapsed SLA is read from the node body's runtime `timeoutVar` (`nodeTimeout` for a
|
|
1117
|
+
* bounded service node, `probeTimeout` for a `wait` gate). `tail` closes the sentence per kind. */
|
|
1118
|
+
function escalationContextFeel(nodeId: string, descriptor: string, timeoutVar: string, tail: string): string {
|
|
1119
|
+
const head = feelStr(`Node ${nodeId} (${descriptor}) exceeded its SLA (`);
|
|
1120
|
+
return `=${head} + string(${timeoutVar}) + ${feelStr(`)${tail}`)}`;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
907
1123
|
/** A plain `<bpmn:sequenceFlow>` (6-space indented). */
|
|
908
1124
|
function flow(id: string, source: string, target: string): string {
|
|
909
1125
|
return ` <bpmn:sequenceFlow id="${id}" sourceRef="${source}" targetRef="${target}" />`;
|
|
@@ -926,8 +1142,19 @@ function renderMermaid(
|
|
|
926
1142
|
for (const edge of edges) {
|
|
927
1143
|
const from = mustGet(elementById, edge.fromNode);
|
|
928
1144
|
const to = mustGet(elementById, edge.to);
|
|
929
|
-
|
|
930
|
-
|
|
1145
|
+
// Label a guarded edge with its predicate (`fact == value`) and a default with `default` (S7), a
|
|
1146
|
+
// fact-qualified edge with the fact name, else an unlabelled arrow.
|
|
1147
|
+
let label: string | undefined;
|
|
1148
|
+
if (edge.when !== undefined && edge.default !== true) {
|
|
1149
|
+
const guardFact = edge.when.includes(".") ? edge.when.slice(edge.when.lastIndexOf(".") + 1) : edge.when;
|
|
1150
|
+
label = `${guardFact} == ${feelLiteral(edge.equals)}`;
|
|
1151
|
+
} else if (edge.default === true) {
|
|
1152
|
+
label = "default";
|
|
1153
|
+
} else if (edge.fromFact !== undefined) {
|
|
1154
|
+
label = edge.fromFact;
|
|
1155
|
+
}
|
|
1156
|
+
if (label !== undefined) {
|
|
1157
|
+
lines.push(` ${from} -- "${escapeMermaid(label)}" --> ${to}`);
|
|
931
1158
|
} else {
|
|
932
1159
|
lines.push(` ${from} --> ${to}`);
|
|
933
1160
|
}
|