@nanobpm/nano-workforce 0.112.0 → 0.114.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 +14 -0
- package/app/agentCompletion.ts +23 -1
- package/app/deliveryConnector.test.ts +215 -0
- package/app/deliveryConnector.ts +210 -0
- package/app/deliveryGraph.ts +2 -2
- package/app/deliveryGraphCompiler.test.ts +35 -13
- package/app/deliveryGraphCompiler.ts +394 -47
- package/app/deliveryHuman.test.ts +306 -0
- package/app/deliveryHuman.ts +384 -0
- package/app/deliveryRunner.test.ts +111 -0
- package/app/deliveryRunner.ts +169 -0
- package/app/userTasks.test.ts +36 -0
- package/app/userTasks.ts +14 -2
- package/db/migrations/055_delivery_connector_dedupe.sql +26 -0
- package/e2e/delivery-graph.e2e.ts +197 -0
- package/nano.app.json +4 -0
- package/package.json +1 -1
- package/resources/forms/delivery-human-ack.form +19 -0
- package/resources/forms/delivery-human-generic.form +28 -0
- package/resources/forms/delivery-human-publish.form +28 -0
- package/resources/processes/delivery-human.bpmn +87 -0
- package/test/derivation-parity/derivation-parity.test.ts +11 -3
- package/test/derivation-parity/flows.ts +7 -3
- package/workers/delivery-connector/worker.test.ts +44 -0
- package/workers/delivery-connector/worker.ts +83 -0
|
@@ -34,29 +34,63 @@ import type {
|
|
|
34
34
|
ResolvedDeliveryEdge,
|
|
35
35
|
ResolvedDeliveryNode,
|
|
36
36
|
} from "../nano-generated/api-io.d.ts";
|
|
37
|
+
import { DELIVERY_CONNECTOR_TASK_TYPE } from "./deliveryConnector.ts";
|
|
37
38
|
import {
|
|
38
39
|
type DeliveryGraphError,
|
|
39
40
|
deliveryNodeFacts,
|
|
40
41
|
resolveDeliveryFrom,
|
|
41
42
|
validateDeliveryGraph,
|
|
42
43
|
} from "./deliveryGraph.ts";
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* the
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
44
|
+
import { DELIVERY_HUMAN_ELEMENT, GENERIC_HUMAN_FORM } from "./deliveryHuman.ts";
|
|
45
|
+
|
|
46
|
+
/** The engine-native BODY every node kind delegates to (Decision 2 — the graph SCHEDULES, it does not
|
|
47
|
+
* re-implement execution). Each node compiles to an EMBEDDED `bpmn:subProcess` (call activities are a
|
|
48
|
+
* no-op on the pinned WASM engine — the child is never instantiated — so, like the rest of the
|
|
49
|
+
* codebase, `plan-fanout`'s `readiness-preflight` included, delegation is an inlined subProcess that
|
|
50
|
+
* shares the parent variable scope). The inner task delegates to a real, already-registered worker /
|
|
51
|
+
* user-task body:
|
|
52
|
+
* • `agent` → the `senior:*` job the node names (the implementation-task body).
|
|
53
|
+
* • `wait` → the `pr.readiness-probe` service task (the reusable ReadinessProbe poll gate; the
|
|
54
|
+
* `pr` kind is S2). Polling its own target is what makes an unrelated upstream event
|
|
55
|
+
* unable to falsely resolve the wait (#274/S2 concurrency-correctness).
|
|
56
|
+
* • `human` → the S3 scheduled user-task + generic form + SLA (`delivery-human-task__<el>`,
|
|
57
|
+
* recognised by the `isDeliveryHumanElement` convention so it routes through the ONE
|
|
58
|
+
* canonical completer and the Tasks inbox).
|
|
59
|
+
* • `connector` → the `pr.delivery-connector` dedupe stub (forward-declared; real I/O deferred per
|
|
60
|
+
* the ADR non-goals — but a real, idempotent node).
|
|
61
|
+
* Kept as the single source of truth so the compiler, the resolved-preview and the runner agree on
|
|
62
|
+
* the delegation target each node names. */
|
|
63
|
+
const DELEGATE_TASK_TYPE: Record<Exclude<DeliveryNode["kind"], "agent" | "human">, string> = {
|
|
64
|
+
wait: "pr.readiness-probe",
|
|
65
|
+
connector: DELIVERY_CONNECTOR_TASK_TYPE,
|
|
54
66
|
};
|
|
55
67
|
|
|
68
|
+
/** The BPMN `bpmn:process` id of the compiled one-shot definition (S1). Stable across compiles of the
|
|
69
|
+
* same graph — the pure S1 preview always emits this base id. The S4 runner (`deliveryRunner.ts`)
|
|
70
|
+
* derives a CONTENT-ADDRESSED deploy id from it (`delivery-graph-<sha>`), so re-deploying the same
|
|
71
|
+
* graph is idempotent and stale definitions are GC-identifiable; exported here as the single source of
|
|
72
|
+
* truth so the runner never hardcodes the literal it substitutes. */
|
|
73
|
+
export const DELIVERY_GRAPH_PROCESS_ID = "delivery-graph";
|
|
74
|
+
|
|
75
|
+
/** The BPMN element id a `human` node's inlined user task carries. One user task per human node (the
|
|
76
|
+
* compiled one-shot inlines each), so the id is per-node (`delivery-human-task__<element>`) — the
|
|
77
|
+
* `isDeliveryHumanElement` convention (single source of truth in `deliveryHuman.ts`) is what keeps it
|
|
78
|
+
* recognised by `ESCALATION_TASK_ELEMENTS` / the Tasks inbox despite the per-node suffix. */
|
|
79
|
+
function humanTaskElement(element: string): string {
|
|
80
|
+
return `${DELIVERY_HUMAN_ELEMENT}__${element}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The BPMN element id a service node's bounded-timeout escalation user task carries — same
|
|
84
|
+
* human-completable convention as a human node, so a stalled `agent`/`wait`/`connector` escalates onto
|
|
85
|
+
* the Tasks inbox and is answerable by a human OR an agent (ADR 0046). */
|
|
86
|
+
function escalationTaskElement(element: string): string {
|
|
87
|
+
return `${DELIVERY_HUMAN_ELEMENT}__${element}__esc`;
|
|
88
|
+
}
|
|
89
|
+
|
|
56
90
|
/** A never-reached exhaustiveness guard: `compileNode`'s `switch` covers every allowlisted kind, so
|
|
57
91
|
* the closed union narrows to `never` here. If a future kind is added to the vocabulary without a
|
|
58
92
|
* compiler arm, `tsc` flags this call — the compile-time half of the trust bound. */
|
|
59
|
-
function assertNever(value: never, context: string): never {
|
|
93
|
+
export function assertNever(value: never, context: string): never {
|
|
60
94
|
throw new Error(`${context}: unreachable — non-allowlisted delivery node kind ${JSON.stringify(value)}`);
|
|
61
95
|
}
|
|
62
96
|
|
|
@@ -70,6 +104,20 @@ function escapeXml(value: string): string {
|
|
|
70
104
|
.replace(/'/g, "'");
|
|
71
105
|
}
|
|
72
106
|
|
|
107
|
+
/** Render a `name=value` XML attribute, choosing the delimiter so FEEL string literals survive the
|
|
108
|
+
* WASM engine's deploy path. That path does NOT decode `"`/`"` entities before FEEL parsing,
|
|
109
|
+
* so a FEEL expression containing a string literal MUST use a SINGLE-QUOTE attribute delimiter with
|
|
110
|
+
* literal double-quotes inside (verified empirically — an entity-escaped `"` silently yields no value,
|
|
111
|
+
* not an incident). When the value has no `"`, the ordinary double-quote form (with full entity
|
|
112
|
+
* escaping) is used. Deterministic. */
|
|
113
|
+
function attr(name: string, value: string): string {
|
|
114
|
+
if (value.includes('"')) {
|
|
115
|
+
const inner = value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/'/g, "'");
|
|
116
|
+
return `${name}='${inner}'`;
|
|
117
|
+
}
|
|
118
|
+
return `${name}="${escapeXml(value)}"`;
|
|
119
|
+
}
|
|
120
|
+
|
|
73
121
|
/** Escape a string for use inside a mermaid quoted label. Mermaid uses `#` HTML-entity escapes; a
|
|
74
122
|
* double quote inside a `"…"` label must become `#quot;` so the label stays well-formed. */
|
|
75
123
|
function escapeMermaid(value: string): string {
|
|
@@ -89,6 +137,36 @@ function byCodeUnit(a: string, b: string): number {
|
|
|
89
137
|
return a < b ? -1 : a > b ? 1 : 0;
|
|
90
138
|
}
|
|
91
139
|
|
|
140
|
+
/** The engine variable a producer node's OUTPUT mapping reads to publish a declared emitted `fact`
|
|
141
|
+
* (S4 late-binding). Each node kind's real body exposes the observed value under a canonical name:
|
|
142
|
+
* • `wait` (readiness-gate) — a `mergedSha` fact reads the merge oid; an `artifact` fact reads the
|
|
143
|
+
* `resolvedArtifact` bind (mirroring the `capability`/`pr` probe binds); anything else reads the
|
|
144
|
+
* probe's `detail`.
|
|
145
|
+
* • `human` (delivery-human) — an `artifact` fact reads `humanEmitArtifact`; anything else reads
|
|
146
|
+
* `humanEmitValue` (the generic typed-emit form's captured value).
|
|
147
|
+
* • `agent`/`connector` — the body's job worker returns the value under the fact's own name.
|
|
148
|
+
* Deterministic and total over the closed kind set. */
|
|
149
|
+
function factSourceVar(kind: DeliveryNode["kind"], fact: DeliveryFact): string {
|
|
150
|
+
switch (kind) {
|
|
151
|
+
case "wait":
|
|
152
|
+
return fact.name === "mergedSha" ? "mergedSha" : fact.type === "artifact" ? "resolvedArtifact" : "detail";
|
|
153
|
+
case "human":
|
|
154
|
+
return fact.type === "artifact" ? "humanEmitArtifact" : "humanEmitValue";
|
|
155
|
+
case "agent":
|
|
156
|
+
case "connector":
|
|
157
|
+
return fact.name;
|
|
158
|
+
default:
|
|
159
|
+
return assertNever(kind, "factSourceVar");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** A FEEL string literal (raw, with literal double-quotes). XML-attribute escaping and delimiter
|
|
164
|
+
* choice are handled by `attr` at emit time — do NOT pre-escape here, or the quote is hidden from
|
|
165
|
+
* `attr`'s single-quote-delimiter heuristic and gets double-encoded. */
|
|
166
|
+
function feelStr(value: string): string {
|
|
167
|
+
return JSON.stringify(value);
|
|
168
|
+
}
|
|
169
|
+
|
|
92
170
|
/** One sequence flow in the compiled process — `source`/`target` are element ids, `name` an optional
|
|
93
171
|
* (fact) label. */
|
|
94
172
|
interface Flow {
|
|
@@ -98,6 +176,15 @@ interface Flow {
|
|
|
98
176
|
name?: string;
|
|
99
177
|
}
|
|
100
178
|
|
|
179
|
+
/** One late-binding input a consumer node receives (S4): the producer node's business id, the
|
|
180
|
+
* referenced emitted fact name, and the flat parent variable (`<producerElement>_<fact>`) the
|
|
181
|
+
* producer's output mapping publishes the observed value into. */
|
|
182
|
+
interface BoundInput {
|
|
183
|
+
fromNode: string;
|
|
184
|
+
fact: string;
|
|
185
|
+
producerElement: string;
|
|
186
|
+
}
|
|
187
|
+
|
|
101
188
|
/** A compiled node's structural fixtures: its own BPMN `element` id, and — when it has >1 downstream
|
|
102
189
|
* or >1 upstream — the parallel fork/join gateway that fans its flow out/in. `entry` is the id
|
|
103
190
|
* upstream flows target (the join, else the element); `exit` is the id downstream flows leave from
|
|
@@ -250,7 +337,24 @@ export function compileDeliveryGraph(graph: unknown): CompileDeliveryGraphResult
|
|
|
250
337
|
}
|
|
251
338
|
const numberedFlows: Flow[] = flows.map((f, i) => ({ id: `f${i}`, ...f }));
|
|
252
339
|
|
|
253
|
-
|
|
340
|
+
// Per-consumer late-binding inputs (S4): for every FACT-QUALIFIED edge, the consumer node receives
|
|
341
|
+
// the producer's emitted fact as a `boundFacts` list entry (`{from,name,value}`), threaded from the
|
|
342
|
+
// flat `<producerElement>_<fact>` variable the producer's output mapping publishes. Grouped by the
|
|
343
|
+
// consumer's element id and sorted (producer element, then fact) for determinism.
|
|
344
|
+
const boundInputsByElement = new Map<string, BoundInput[]>();
|
|
345
|
+
for (const edge of resolvedEdges) {
|
|
346
|
+
if (edge.fromFact === undefined) continue;
|
|
347
|
+
const consumerEl = mustGet(elementById, edge.to);
|
|
348
|
+
const producerEl = mustGet(elementById, edge.fromNode);
|
|
349
|
+
const list = boundInputsByElement.get(consumerEl) ?? [];
|
|
350
|
+
list.push({ fromNode: edge.fromNode, fact: edge.fromFact, producerElement: producerEl });
|
|
351
|
+
boundInputsByElement.set(consumerEl, list);
|
|
352
|
+
}
|
|
353
|
+
for (const list of boundInputsByElement.values()) {
|
|
354
|
+
list.sort((a, b) => byCodeUnit(a.producerElement, b.producerElement) || byCodeUnit(a.fact, b.fact));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const bpmn = renderBpmn(typed, wirings, numberedFlows, startForkGateway, endJoinGateway, boundInputsByElement);
|
|
254
358
|
const diagram = renderMermaid(typed, wirings, resolvedEdges, elementById);
|
|
255
359
|
const resolved = buildResolved(typed, wirings, resolvedEdges, producersById);
|
|
256
360
|
const humanNodes = buildHumanNodes(nodes);
|
|
@@ -280,13 +384,33 @@ function buildResolved(
|
|
|
280
384
|
element: w.element,
|
|
281
385
|
emits: normaliseEmits(w.node),
|
|
282
386
|
dependsOn: [...(producersById.get(w.node.id) ?? [])],
|
|
387
|
+
calledElement: delegateTarget(w.node, w.element),
|
|
283
388
|
};
|
|
284
|
-
return
|
|
389
|
+
return base;
|
|
285
390
|
});
|
|
286
391
|
const resolved: CompileDeliveryGraphResult["resolved"] = { nodes, edges: [...edges] };
|
|
287
392
|
return graph.name !== undefined ? { name: graph.name, ...resolved } : resolved;
|
|
288
393
|
}
|
|
289
394
|
|
|
395
|
+
/** The engine-native delegation target a node's inlined subProcess drives — its job `taskType`
|
|
396
|
+
* (`agent` → the named `senior:*` job; `wait` → `pr.readiness-probe`; `connector` →
|
|
397
|
+
* `pr.delivery-connector`) or, for a `human` node, its per-node user-task element id. Surfaced on the
|
|
398
|
+
* resolved preview so a co-designing agent sees exactly which worker/user-task each node fans out to.
|
|
399
|
+
* Deterministic and total over the closed kind set. */
|
|
400
|
+
function delegateTarget(node: DeliveryNode, element: string): string {
|
|
401
|
+
switch (node.kind) {
|
|
402
|
+
case "agent":
|
|
403
|
+
return node.agent.jobType;
|
|
404
|
+
case "human":
|
|
405
|
+
return humanTaskElement(element);
|
|
406
|
+
case "wait":
|
|
407
|
+
case "connector":
|
|
408
|
+
return DELEGATE_TASK_TYPE[node.kind];
|
|
409
|
+
default:
|
|
410
|
+
return assertNever(node, "delegateTarget");
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
290
414
|
/** Extract the human STOP-points (sorted by id) — where the graph pauses for a person/agent, with the
|
|
291
415
|
* instruction, optional attached form, and the typed facts the node will emit. */
|
|
292
416
|
function buildHumanNodes(nodes: readonly DeliveryNode[]): DeliveryHumanStop[] {
|
|
@@ -334,6 +458,7 @@ function renderBpmn(
|
|
|
334
458
|
flows: readonly Flow[],
|
|
335
459
|
startForkGateway: string | undefined,
|
|
336
460
|
endJoinGateway: string | undefined,
|
|
461
|
+
boundInputsByElement: ReadonlyMap<string, BoundInput[]>,
|
|
337
462
|
): string {
|
|
338
463
|
// Precompute incoming/outgoing flow-id maps once (single pass over flows) so BPMN rendering stays
|
|
339
464
|
// linear in the number of flows instead of O(elements * flows) from repeated full-array filtering.
|
|
@@ -359,10 +484,11 @@ function renderBpmn(
|
|
|
359
484
|
lines.push(
|
|
360
485
|
'<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" ' +
|
|
361
486
|
'xmlns:zeebe="http://camunda.org/schema/zeebe/1.0" ' +
|
|
487
|
+
'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" ' +
|
|
362
488
|
'id="Definitions_delivery_graph" targetNamespace="http://nanobpm.io/nano-workforce">',
|
|
363
489
|
);
|
|
364
490
|
const processName = graph.name ?? "Delivery graph";
|
|
365
|
-
lines.push(` <bpmn:process id="
|
|
491
|
+
lines.push(` <bpmn:process id="${DELIVERY_GRAPH_PROCESS_ID}" name="${escapeXml(processName)}" isExecutable="true">`);
|
|
366
492
|
|
|
367
493
|
// Start event.
|
|
368
494
|
lines.push(' <bpmn:startEvent id="Start" name="Graph opened">');
|
|
@@ -385,7 +511,7 @@ function renderBpmn(
|
|
|
385
511
|
lines.push(refs("outgoing", outgoing(w.joinGateway)));
|
|
386
512
|
lines.push(" </bpmn:parallelGateway>");
|
|
387
513
|
}
|
|
388
|
-
lines.push(renderNodeElement(w, incoming(w.element), outgoing(w.element)));
|
|
514
|
+
lines.push(renderNodeElement(w, incoming(w.element), outgoing(w.element), boundInputsByElement.get(w.element) ?? []));
|
|
389
515
|
if (w.forkGateway) {
|
|
390
516
|
lines.push(` <bpmn:parallelGateway id="${w.forkGateway}" name="fan out of ${escapeXml(w.node.id)}">`);
|
|
391
517
|
lines.push(refs("incoming", incoming(w.forkGateway)));
|
|
@@ -419,47 +545,268 @@ function renderBpmn(
|
|
|
419
545
|
return `${lines.filter((l) => l.length > 0).join("\n")}\n`;
|
|
420
546
|
}
|
|
421
547
|
|
|
422
|
-
/** Render one node
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
426
|
-
|
|
548
|
+
/** Render one node as an EMBEDDED `bpmn:subProcess` — the engine-native delegation unit (Decision 2).
|
|
549
|
+
* Call activities are a no-op on the pinned WASM engine (the child is never instantiated), so — like
|
|
550
|
+
* `plan-fanout`'s `readiness-preflight` — every node inlines a subProcess that shares the parent
|
|
551
|
+
* variable scope. A single outer in/out keeps the compiler's fan-out/fan-in topology clean; the
|
|
552
|
+
* subProcess's own `zeebe:ioMapping` (a) seeds the body its config from `nodeInputs.<element>` (runner-
|
|
553
|
+
* set), (b) threads late-binding `boundFacts` from upstream producers' emitted-fact variables, and (c)
|
|
554
|
+
* publishes this node's declared emits into flat `<element>_<fact>` variables a downstream consumer
|
|
555
|
+
* binds. Every node is bounded (a timeout escalates onto a human-completable user task) and resumable
|
|
556
|
+
* (engine-persisted). The `switch` is EXHAUSTIVE over the closed kind union — the compile-time trust
|
|
557
|
+
* bound. */
|
|
558
|
+
function renderNodeElement(
|
|
559
|
+
w: NodeWiring,
|
|
560
|
+
incoming: readonly string[],
|
|
561
|
+
outgoing: readonly string[],
|
|
562
|
+
boundInputs: readonly BoundInput[],
|
|
563
|
+
): string {
|
|
564
|
+
const el = w.element;
|
|
565
|
+
const name = escapeXml(`${w.node.kind}: ${w.node.id}`);
|
|
566
|
+
const flowRefs = [
|
|
567
|
+
...incoming.map((id) => ` <bpmn:incoming>${id}</bpmn:incoming>`),
|
|
568
|
+
...outgoing.map((id) => ` <bpmn:outgoing>${id}</bpmn:outgoing>`),
|
|
569
|
+
];
|
|
570
|
+
const io = ioMappingLines(w, boundInputs);
|
|
571
|
+
const inner = innerBodyLines(w);
|
|
572
|
+
const lines = [
|
|
573
|
+
` <bpmn:subProcess id="${el}" name="${name}">`,
|
|
574
|
+
...flowRefs,
|
|
575
|
+
" <bpmn:extensionElements>",
|
|
576
|
+
...io,
|
|
577
|
+
" </bpmn:extensionElements>",
|
|
578
|
+
...inner,
|
|
579
|
+
" </bpmn:subProcess>",
|
|
580
|
+
];
|
|
581
|
+
return lines.join("\n");
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** The subProcess `<zeebe:ioMapping>` lines (8-space indented, inside `extensionElements`). Inputs
|
|
585
|
+
* pull the body's config from `nodeInputs.<element>` (runner-seeded) plus any late-binding
|
|
586
|
+
* `boundFacts`; outputs publish the node's declared emits into flat `<element>_<fact>` variables.
|
|
587
|
+
* Deterministic — fixed input/output order, positional fact targets. FEEL sources go through `attr`
|
|
588
|
+
* (single-quote delimiter) so embedded string literals survive the engine's deploy path. */
|
|
589
|
+
function ioMappingLines(w: NodeWiring, boundInputs: readonly BoundInput[]): string[] {
|
|
590
|
+
const el = w.element;
|
|
427
591
|
const node = w.node;
|
|
428
|
-
const
|
|
429
|
-
const
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
outgoing.map((id) => ` <bpmn:outgoing>${id}</bpmn:outgoing>`).join("\n");
|
|
433
|
-
const body = flowRefs.length > 0 ? `\n${flowRefs}\n ` : "";
|
|
592
|
+
const inputs: { source: string; target: string }[] = [];
|
|
593
|
+
const outputs: { source: string; target: string }[] = [];
|
|
594
|
+
const cfg = (field: string): string => `=nodeInputs.${el}.${field}`;
|
|
595
|
+
const guarded = (src: string): string => `=if (is defined(${src})) then ${src} else null`;
|
|
434
596
|
|
|
435
597
|
switch (node.kind) {
|
|
436
598
|
case "agent":
|
|
599
|
+
inputs.push({ source: cfg("jobType"), target: "jobType" });
|
|
600
|
+
inputs.push({ source: cfg("appendPrompt"), target: "appendPrompt" });
|
|
601
|
+
inputs.push({ source: cfg("timeout"), target: "nodeTimeout" });
|
|
602
|
+
break;
|
|
437
603
|
case "wait":
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
` <bpmn:extensionElements>\n` +
|
|
443
|
-
` <zeebe:calledElement processId="${called}" propagateAllChildVariables="false" />\n` +
|
|
444
|
-
` </bpmn:extensionElements>${body ? "" : "\n"}` +
|
|
445
|
-
(body ? body : "") +
|
|
446
|
-
`</bpmn:callActivity>`
|
|
447
|
-
);
|
|
448
|
-
}
|
|
604
|
+
inputs.push({ source: cfg("gateKey"), target: "gateKey" });
|
|
605
|
+
inputs.push({ source: cfg("probe"), target: "probe" });
|
|
606
|
+
inputs.push({ source: cfg("probeTimeout"), target: "probeTimeout" });
|
|
607
|
+
break;
|
|
449
608
|
case "human":
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
);
|
|
609
|
+
inputs.push({ source: cfg("escalationSlaTimeout"), target: "escalationSlaTimeout" });
|
|
610
|
+
inputs.push({ source: cfg("escalationAssignee"), target: "escalationAssignee" });
|
|
611
|
+
break;
|
|
612
|
+
case "connector":
|
|
613
|
+
inputs.push({ source: cfg("target"), target: "target" });
|
|
614
|
+
inputs.push({ source: cfg("dedupeKey"), target: "dedupeKey" });
|
|
615
|
+
inputs.push({ source: cfg("payload"), target: "payload" });
|
|
616
|
+
inputs.push({ source: cfg("timeout"), target: "nodeTimeout" });
|
|
617
|
+
break;
|
|
618
|
+
default:
|
|
619
|
+
return assertNever(node, "ioMappingLines");
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Late-binding: a deterministic FEEL list literal of the upstream producers' emitted facts, keyed
|
|
623
|
+
// exactly as the edge references them (`<producerNode>.<fact>`), read from the flat parent variable
|
|
624
|
+
// each producer publishes. Guarded so an as-yet-unobserved fact threads as null, not a FEEL error.
|
|
625
|
+
if (boundInputs.length > 0) {
|
|
626
|
+
const entries = boundInputs.map((b) => {
|
|
627
|
+
const varName = `${b.producerElement}_${b.fact}`;
|
|
628
|
+
return `{from: ${feelStr(b.fromNode)}, name: ${feelStr(b.fact)}, value: if (is defined(${varName})) then ${varName} else null}`;
|
|
629
|
+
});
|
|
630
|
+
inputs.push({ source: `=[${entries.join(", ")}]`, target: "boundFacts" });
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Outputs: publish each declared emit into `<element>_<fact>` for a downstream consumer to bind.
|
|
634
|
+
for (const fact of normaliseEmits(node)) {
|
|
635
|
+
outputs.push({ source: guarded(factSourceVar(node.kind, fact)), target: `${el}_${fact.name}` });
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const lines: string[] = [" <zeebe:ioMapping>"];
|
|
639
|
+
for (const i of inputs) lines.push(` <zeebe:input ${attr("source", i.source)} target="${i.target}" />`);
|
|
640
|
+
for (const o of outputs) lines.push(` <zeebe:output ${attr("source", o.source)} target="${o.target}" />`);
|
|
641
|
+
lines.push(" </zeebe:ioMapping>");
|
|
642
|
+
return lines;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** The inner flow of a node's subProcess (6-space indented). `agent`/`connector` delegate to a job
|
|
646
|
+
* worker; `wait` polls the ReadinessProbe gate (blocking until its target is observed ready — polling
|
|
647
|
+
* its OWN target is what makes an unrelated upstream event unable to falsely resolve it); `human` is
|
|
648
|
+
* the S3 scheduled user-task + generic form + SLA. Each is a single-entry / single-exit subgraph with
|
|
649
|
+
* a bounded timeout that escalates onto a human-completable user task (or, for `human`, records an
|
|
650
|
+
* escalated outcome). */
|
|
651
|
+
function innerBodyLines(w: NodeWiring): string[] {
|
|
652
|
+
const el = w.element;
|
|
653
|
+
const node = w.node;
|
|
654
|
+
switch (node.kind) {
|
|
655
|
+
case "agent":
|
|
656
|
+
return serviceBodyLines(el, node.id, attr("type", node.agent.jobType), []);
|
|
657
|
+
case "connector":
|
|
658
|
+
return serviceBodyLines(el, node.id, `type="${DELEGATE_TASK_TYPE.connector}"`, []);
|
|
659
|
+
case "wait":
|
|
660
|
+
return waitBodyLines(el, node.id);
|
|
661
|
+
case "human":
|
|
662
|
+
return humanBodyLines(el, node.id);
|
|
458
663
|
default:
|
|
459
|
-
return assertNever(node, "
|
|
664
|
+
return assertNever(node, "innerBodyLines");
|
|
460
665
|
}
|
|
461
666
|
}
|
|
462
667
|
|
|
668
|
+
/** `agent`/`connector` body: `start → serviceTask → end`, with a bounded `=nodeTimeout` boundary that
|
|
669
|
+
* escalates the stalled node onto a human-completable user task. `taskDefAttr` is the pre-rendered
|
|
670
|
+
* `type="…"` attribute; `taskProps` are optional `<zeebe:property>` envelope lines. */
|
|
671
|
+
function serviceBodyLines(el: string, nodeId: string, taskDefAttr: string, taskProps: readonly string[]): string[] {
|
|
672
|
+
const esc = escalationTaskElement(el);
|
|
673
|
+
const taskExt =
|
|
674
|
+
taskProps.length > 0
|
|
675
|
+
? [
|
|
676
|
+
" <bpmn:extensionElements>",
|
|
677
|
+
` <zeebe:taskDefinition ${taskDefAttr} />`,
|
|
678
|
+
" <zeebe:properties>",
|
|
679
|
+
...taskProps,
|
|
680
|
+
" </zeebe:properties>",
|
|
681
|
+
" </bpmn:extensionElements>",
|
|
682
|
+
]
|
|
683
|
+
: [
|
|
684
|
+
" <bpmn:extensionElements>",
|
|
685
|
+
` <zeebe:taskDefinition ${taskDefAttr} />`,
|
|
686
|
+
" </bpmn:extensionElements>",
|
|
687
|
+
];
|
|
688
|
+
return [
|
|
689
|
+
` <bpmn:startEvent id="${el}_start"><bpmn:outgoing>${el}_i0</bpmn:outgoing></bpmn:startEvent>`,
|
|
690
|
+
` <bpmn:serviceTask id="${el}_task" name="${escapeXml(nodeId)}">`,
|
|
691
|
+
...taskExt,
|
|
692
|
+
` <bpmn:incoming>${el}_i0</bpmn:incoming>`,
|
|
693
|
+
` <bpmn:outgoing>${el}_i1</bpmn:outgoing>`,
|
|
694
|
+
" </bpmn:serviceTask>",
|
|
695
|
+
` <bpmn:boundaryEvent id="${el}_be" name="Node timed out" attachedToRef="${el}_task">`,
|
|
696
|
+
` <bpmn:outgoing>${el}_i2</bpmn:outgoing>`,
|
|
697
|
+
` <bpmn:timerEventDefinition id="${el}_ted"><bpmn:timeDuration xsi:type="bpmn:tFormalExpression">=nodeTimeout</bpmn:timeDuration></bpmn:timerEventDefinition>`,
|
|
698
|
+
" </bpmn:boundaryEvent>",
|
|
699
|
+
...escalationTaskLines(esc, nodeId, [`${el}_i2`], `${el}_i3`),
|
|
700
|
+
` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_i1</bpmn:incoming><bpmn:incoming>${el}_i3</bpmn:incoming></bpmn:endEvent>`,
|
|
701
|
+
flow(`${el}_i0`, `${el}_start`, `${el}_task`),
|
|
702
|
+
flow(`${el}_i1`, `${el}_task`, `${el}_end`),
|
|
703
|
+
flow(`${el}_i2`, `${el}_be`, esc),
|
|
704
|
+
flow(`${el}_i3`, esc, `${el}_end`),
|
|
705
|
+
];
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/** `wait` body: `start → pr.readiness-probe (poll) → ready? → end`, escalating on not-ready or on the
|
|
709
|
+
* `=probeTimeout` engine bound. The probe polls its OWN target, so an unrelated upstream event can
|
|
710
|
+
* never flip it to ready (#274/S2 concurrency-correctness); the `pr` kind (S2) binds `mergedSha`. */
|
|
711
|
+
function waitBodyLines(el: string, nodeId: string): string[] {
|
|
712
|
+
const esc = escalationTaskElement(el);
|
|
713
|
+
return [
|
|
714
|
+
` <bpmn:startEvent id="${el}_start"><bpmn:outgoing>${el}_i0</bpmn:outgoing></bpmn:startEvent>`,
|
|
715
|
+
` <bpmn:serviceTask id="${el}_task" name="Probe readiness: ${escapeXml(nodeId)}">`,
|
|
716
|
+
" <bpmn:extensionElements>",
|
|
717
|
+
` <zeebe:taskDefinition type="${DELEGATE_TASK_TYPE.wait}" />`,
|
|
718
|
+
" <zeebe:properties>",
|
|
719
|
+
' <zeebe:property name="io.nanobpm.dataEnvelope.in" value="ReadinessProbeIn" />',
|
|
720
|
+
' <zeebe:property name="io.nanobpm.dataEnvelope.out" value="ReadinessProbeOut" />',
|
|
721
|
+
" </zeebe:properties>",
|
|
722
|
+
" </bpmn:extensionElements>",
|
|
723
|
+
` <bpmn:incoming>${el}_i0</bpmn:incoming>`,
|
|
724
|
+
` <bpmn:outgoing>${el}_i1</bpmn:outgoing>`,
|
|
725
|
+
" </bpmn:serviceTask>",
|
|
726
|
+
` <bpmn:boundaryEvent id="${el}_be" name="Gate timed out" attachedToRef="${el}_task">`,
|
|
727
|
+
` <bpmn:outgoing>${el}_i2</bpmn:outgoing>`,
|
|
728
|
+
` <bpmn:timerEventDefinition id="${el}_ted"><bpmn:timeDuration xsi:type="bpmn:tFormalExpression">=probeTimeout</bpmn:timeDuration></bpmn:timerEventDefinition>`,
|
|
729
|
+
" </bpmn:boundaryEvent>",
|
|
730
|
+
` <bpmn:exclusiveGateway id="${el}_gw" name="ready?" default="${el}_i4">`,
|
|
731
|
+
` <bpmn:incoming>${el}_i1</bpmn:incoming>`,
|
|
732
|
+
` <bpmn:outgoing>${el}_i3</bpmn:outgoing>`,
|
|
733
|
+
` <bpmn:outgoing>${el}_i4</bpmn:outgoing>`,
|
|
734
|
+
" </bpmn:exclusiveGateway>",
|
|
735
|
+
...escalationTaskLines(esc, nodeId, [`${el}_i2`, `${el}_i4`], `${el}_i5`),
|
|
736
|
+
` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_i3</bpmn:incoming><bpmn:incoming>${el}_i5</bpmn:incoming></bpmn:endEvent>`,
|
|
737
|
+
flow(`${el}_i0`, `${el}_start`, `${el}_task`),
|
|
738
|
+
flow(`${el}_i1`, `${el}_task`, `${el}_gw`),
|
|
739
|
+
flow(`${el}_i2`, `${el}_be`, esc),
|
|
740
|
+
` <bpmn:sequenceFlow id="${el}_i3" name="ready" sourceRef="${el}_gw" targetRef="${el}_end"><bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=ready = true</bpmn:conditionExpression></bpmn:sequenceFlow>`,
|
|
741
|
+
` <bpmn:sequenceFlow id="${el}_i4" name="not ready" sourceRef="${el}_gw" targetRef="${esc}" />`,
|
|
742
|
+
flow(`${el}_i5`, esc, `${el}_end`),
|
|
743
|
+
];
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/** `human` body: the S3 scheduled user-task (`delivery-human-task__<el>`) + generic form + assignment
|
|
747
|
+
* + SLA. On completion the form's captured typed value is output (`humanEmitValue`/`humanEmitArtifact`
|
|
748
|
+
* — the subProcess ioMapping then publishes it as the node's fact); on SLA expiry the node records an
|
|
749
|
+
* `escalated` outcome and settles (bounded — the graph cannot silently wedge). Mirrors the standalone
|
|
750
|
+
* `delivery-human.bpmn` shape, reusing the S3 form + emit-var contract (`deliveryHuman.ts`). */
|
|
751
|
+
function humanBodyLines(el: string, nodeId: string): string[] {
|
|
752
|
+
const task = humanTaskElement(el);
|
|
753
|
+
const assignee =
|
|
754
|
+
'=if (is defined(escalationAssignee) and escalationAssignee != null and trim(string(escalationAssignee)) != "") then escalationAssignee else null';
|
|
755
|
+
return [
|
|
756
|
+
` <bpmn:startEvent id="${el}_start"><bpmn:outgoing>${el}_i0</bpmn:outgoing></bpmn:startEvent>`,
|
|
757
|
+
` <bpmn:userTask id="${task}" name="Delivery: human step — ${escapeXml(nodeId)}">`,
|
|
758
|
+
" <bpmn:extensionElements>",
|
|
759
|
+
` <zeebe:formDefinition formId="${GENERIC_HUMAN_FORM}" />`,
|
|
760
|
+
" <zeebe:userTask />",
|
|
761
|
+
` <zeebe:assignmentDefinition candidateGroups="operators" ${attr("assignee", assignee)} />`,
|
|
762
|
+
" <zeebe:ioMapping>",
|
|
763
|
+
` <zeebe:output ${attr("source", '="completed"')} target="humanOutcome" />`,
|
|
764
|
+
` <zeebe:output ${attr("source", "=if (is defined(value)) then value else null")} target="humanEmitValue" />`,
|
|
765
|
+
` <zeebe:output ${attr("source", "=if (is defined(resolvedArtifact)) then resolvedArtifact else null")} target="humanEmitArtifact" />`,
|
|
766
|
+
` <zeebe:output ${attr("source", "=if (is defined(note)) then note else null")} target="humanNote" />`,
|
|
767
|
+
" </zeebe:ioMapping>",
|
|
768
|
+
" </bpmn:extensionElements>",
|
|
769
|
+
` <bpmn:incoming>${el}_i0</bpmn:incoming>`,
|
|
770
|
+
` <bpmn:outgoing>${el}_i1</bpmn:outgoing>`,
|
|
771
|
+
" </bpmn:userTask>",
|
|
772
|
+
` <bpmn:boundaryEvent id="${el}_sla" name="SLA elapsed" attachedToRef="${task}">`,
|
|
773
|
+
` <bpmn:outgoing>${el}_i2</bpmn:outgoing>`,
|
|
774
|
+
` <bpmn:timerEventDefinition id="${el}_ted"><bpmn:timeDuration xsi:type="bpmn:tFormalExpression">=escalationSlaTimeout</bpmn:timeDuration></bpmn:timerEventDefinition>`,
|
|
775
|
+
" </bpmn:boundaryEvent>",
|
|
776
|
+
` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_i1</bpmn:incoming></bpmn:endEvent>`,
|
|
777
|
+
` <bpmn:endEvent id="${el}_escEnd" name="Escalated">`,
|
|
778
|
+
" <bpmn:extensionElements>",
|
|
779
|
+
` <zeebe:ioMapping><zeebe:input ${attr("source", '="escalated"')} target="humanOutcome" /></zeebe:ioMapping>`,
|
|
780
|
+
" </bpmn:extensionElements>",
|
|
781
|
+
` <bpmn:incoming>${el}_i2</bpmn:incoming>`,
|
|
782
|
+
" </bpmn:endEvent>",
|
|
783
|
+
flow(`${el}_i0`, `${el}_start`, task),
|
|
784
|
+
flow(`${el}_i1`, task, `${el}_end`),
|
|
785
|
+
flow(`${el}_i2`, `${el}_sla`, `${el}_escEnd`),
|
|
786
|
+
];
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/** A bounded node's escalation user task — a human-completable stop (`isDeliveryHumanElement`
|
|
790
|
+
* convention) that a human OR an agent (ADR 0046) answers to unstick a stalled node. */
|
|
791
|
+
function escalationTaskLines(esc: string, nodeId: string, incoming: readonly string[], outgoing: string): string[] {
|
|
792
|
+
return [
|
|
793
|
+
` <bpmn:userTask id="${esc}" name="Escalate: ${escapeXml(nodeId)}">`,
|
|
794
|
+
" <bpmn:extensionElements>",
|
|
795
|
+
` <zeebe:formDefinition formId="${GENERIC_HUMAN_FORM}" />`,
|
|
796
|
+
" <zeebe:userTask />",
|
|
797
|
+
' <zeebe:assignmentDefinition candidateGroups="operators" />',
|
|
798
|
+
" </bpmn:extensionElements>",
|
|
799
|
+
...incoming.map((id) => ` <bpmn:incoming>${id}</bpmn:incoming>`),
|
|
800
|
+
` <bpmn:outgoing>${outgoing}</bpmn:outgoing>`,
|
|
801
|
+
" </bpmn:userTask>",
|
|
802
|
+
];
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** A plain `<bpmn:sequenceFlow>` (6-space indented). */
|
|
806
|
+
function flow(id: string, source: string, target: string): string {
|
|
807
|
+
return ` <bpmn:sequenceFlow id="${id}" sourceRef="${source}" targetRef="${target}" />`;
|
|
808
|
+
}
|
|
809
|
+
|
|
463
810
|
/** Render a human-readable mermaid `flowchart` of the resolved graph — one node per box labelled
|
|
464
811
|
* `<kind>: <id>`, one arrow per edge (labelled with the referenced fact when qualified). Deterministic
|
|
465
812
|
* (nodes/edges already sorted). */
|