@nanobpm/workflow 0.8.2 → 0.10.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/README.md +73 -2
- package/dist/declarative.d.ts +178 -10
- package/dist/declarative.js +216 -356
- package/dist/nodes/branch.d.ts +11 -0
- package/dist/nodes/branch.js +40 -0
- package/dist/nodes/break.d.ts +8 -0
- package/dist/nodes/break.js +19 -0
- package/dist/nodes/continue.d.ts +8 -0
- package/dist/nodes/continue.js +18 -0
- package/dist/nodes/forEach.d.ts +24 -0
- package/dist/nodes/forEach.js +109 -0
- package/dist/nodes/index.d.ts +12 -0
- package/dist/nodes/index.js +19 -0
- package/dist/nodes/loop.d.ts +9 -0
- package/dist/nodes/loop.js +25 -0
- package/dist/nodes/parallel.d.ts +9 -0
- package/dist/nodes/parallel.js +40 -0
- package/dist/nodes/race.d.ts +54 -0
- package/dist/nodes/race.js +134 -0
- package/dist/nodes/registry.d.ts +33 -0
- package/dist/nodes/registry.js +71 -0
- package/dist/nodes/run.d.ts +10 -0
- package/dist/nodes/run.js +21 -0
- package/dist/nodes/signal.d.ts +11 -0
- package/dist/nodes/signal.js +26 -0
- package/dist/nodes/switch.d.ts +11 -0
- package/dist/nodes/switch.js +51 -0
- package/dist/nodes/task.d.ts +47 -0
- package/dist/nodes/task.js +97 -0
- package/dist/nodes/timer.d.ts +9 -0
- package/dist/nodes/timer.js +34 -0
- package/dist/test-support/diff.d.ts +8 -0
- package/dist/test-support/diff.js +70 -0
- package/dist/test-support/index.d.ts +8 -0
- package/dist/test-support/index.js +49 -0
- package/dist/test-support/normalize.d.ts +19 -0
- package/dist/test-support/normalize.js +245 -0
- package/dist/test-support/parity.d.ts +28 -0
- package/dist/test-support/parity.js +61 -0
- package/dist/test-support/xml.d.ts +17 -0
- package/dist/test-support/xml.js +135 -0
- package/dist/types.d.ts +20 -56
- package/package.json +12 -4
- package/src/declarative.ts +339 -360
- package/src/nodes/README.md +123 -0
- package/src/nodes/branch.ts +48 -0
- package/src/nodes/break.ts +25 -0
- package/src/nodes/continue.ts +24 -0
- package/src/nodes/forEach.ts +142 -0
- package/src/nodes/index.ts +20 -0
- package/src/nodes/loop.ts +34 -0
- package/src/nodes/parallel.ts +48 -0
- package/src/nodes/race.ts +185 -0
- package/src/nodes/registry.ts +105 -0
- package/src/nodes/run.ts +29 -0
- package/src/nodes/signal.ts +35 -0
- package/src/nodes/switch.ts +56 -0
- package/src/nodes/task.ts +150 -0
- package/src/nodes/timer.ts +42 -0
- package/src/test-support/diff.ts +69 -0
- package/src/test-support/index.ts +53 -0
- package/src/test-support/normalize.ts +274 -0
- package/src/test-support/parity.ts +85 -0
- package/src/test-support/xml.ts +140 -0
- package/src/types.ts +20 -30
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# `src/nodes/` — the flow-node kind extension seam
|
|
2
|
+
|
|
3
|
+
Every flow-node **kind** in the declarative surface (`run`, `task`, `signal`,
|
|
4
|
+
`timer`, `switch`, `branch`, `loop`, `break`, `continue`, `parallel`, `forEach`,
|
|
5
|
+
and any kind a feature slice adds) lives in its **own module** here. Adding a
|
|
6
|
+
kind is: **drop one `src/nodes/<kind>.ts` file.** You never edit a shared union,
|
|
7
|
+
a central `switch`, or the barrel.
|
|
8
|
+
|
|
9
|
+
This exists so the four wave-1 slices (S1–S4, #316–#319) can each add a kind in
|
|
10
|
+
parallel without textually colliding on — and without any single PR's CI failing
|
|
11
|
+
to exercise — the dispatch that used to be a hand-edited `switch` in two files.
|
|
12
|
+
|
|
13
|
+
## What a kind module contributes (three things, one file)
|
|
14
|
+
|
|
15
|
+
A kind module `src/nodes/<kind>.ts` declaration-merges its type surface and
|
|
16
|
+
registers its runtime behaviour with a single `registerNodeKind(...)` call:
|
|
17
|
+
|
|
18
|
+
1. **Its `FlowNode` variant** — by declaration-merging one property into the
|
|
19
|
+
augmentable `FlowNodeRegistry` interface (in `../types.js`). `FlowNode` is
|
|
20
|
+
derived from that registry (`FlowNodeRegistry[keyof FlowNodeRegistry]`), so
|
|
21
|
+
the union grows automatically — no edit to a central union.
|
|
22
|
+
- **Invariant:** the property value MUST be an object type whose `kind`
|
|
23
|
+
discriminant equals the property key, so `FlowNode` stays a discriminated
|
|
24
|
+
union.
|
|
25
|
+
|
|
26
|
+
2. **Its builder method** — by declaration-merging the method signature into the
|
|
27
|
+
`FlowBuilder<C>` interface (in `../declarative.js`) **and** returning a
|
|
28
|
+
`build` factory from `registerNodeKind`. The factory receives the per-body
|
|
29
|
+
`BuildApi` and returns the method installed on the builder as
|
|
30
|
+
`w.<builderMethod ?? kind>(…)`.
|
|
31
|
+
|
|
32
|
+
3. **Its `walk` + `emit` handlers** — registered in the same call. `walkNodes`
|
|
33
|
+
and the BPMN emitter DISPATCH through this table instead of a central switch:
|
|
34
|
+
- `walk(node, recurse)` — recurse into the node's nested bodies (omit for a
|
|
35
|
+
leaf kind with no nested `FlowNode[]`).
|
|
36
|
+
- `emit(node, incoming, loop, api)` — place the node's BPMN element(s) via the
|
|
37
|
+
`EmitApi` primitives and return the outgoing (dangling) `Edge[]` its
|
|
38
|
+
continuation attaches to.
|
|
39
|
+
|
|
40
|
+
## The canonical example — `run.ts`
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import type { NodeEnvelopes, StepHandler } from "../types.js";
|
|
44
|
+
import { registerNodeKind } from "./registry.js";
|
|
45
|
+
|
|
46
|
+
// (1) the FlowNode variant — declaration-merged into the open registry
|
|
47
|
+
declare module "../types.js" {
|
|
48
|
+
interface FlowNodeRegistry {
|
|
49
|
+
run: { kind: "run"; name: string; envelopes?: NodeEnvelopes };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
registerNodeKind("run", {
|
|
54
|
+
// (2) the builder method — installed as `w.run(...)`
|
|
55
|
+
build: (api) => (name: string, handler: StepHandler) => {
|
|
56
|
+
api.claim(name);
|
|
57
|
+
if (typeof handler !== "function") throw new Error(`run("${name}") needs a handler`);
|
|
58
|
+
api.handlers[name] = handler;
|
|
59
|
+
api.out.push({ kind: "run", name, envelopes: api.contractEnvelopes(name) });
|
|
60
|
+
return api.self();
|
|
61
|
+
},
|
|
62
|
+
// (3a) walk — omitted here: `run` is a leaf (no nested bodies)
|
|
63
|
+
// (3b) emit — place the BPMN element, wire the edges
|
|
64
|
+
emit: (node, incoming, _loop, api) => {
|
|
65
|
+
api.addServiceTask(node);
|
|
66
|
+
api.connect(incoming, node.name);
|
|
67
|
+
return [api.newEdge(node.name)];
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
A kind with its OWN richer, generically-typed builder signature (like the
|
|
73
|
+
built-ins) also augments `FlowBuilder<C>`:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
declare module "../declarative.js" {
|
|
77
|
+
interface FlowBuilder<C extends object> {
|
|
78
|
+
myKind<K extends string>(name: K, opts: MyOpts): FlowBuilder<C>;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
A kind with nested bodies (like `switch`/`branch`/`loop`) supplies `walk` and
|
|
84
|
+
uses `api.child(fn, inLoop)` / `api.childScoped(fn)` in its `build`, and
|
|
85
|
+
`api.emitList(body, incoming, loop)` in its `emit` — see `switch.ts`, `loop.ts`,
|
|
86
|
+
`forEach.ts`.
|
|
87
|
+
|
|
88
|
+
## The APIs you get
|
|
89
|
+
|
|
90
|
+
- **`BuildApi`** (authoring time, in `build`): `id`, `isRoot`, `out`,
|
|
91
|
+
`contracts`, `handlers`, `loopDepth`, `self()`, `child(fn, inLoop)`,
|
|
92
|
+
`childScoped(fn)`, `claim(name)`, `contractEnvelopes(name)`, `setStartTimer(t)`.
|
|
93
|
+
- **`EmitApi`** (compile time, in `emit`): `flowId`, `newEdge`, `connect`,
|
|
94
|
+
`recordEnvelope`, `emitList`, `addNode`, `addServiceTask`, `addCatchEvent`,
|
|
95
|
+
`addTimerCatchEvent`, `addGateway`, `addParallelGateway`, `addSubProcess`,
|
|
96
|
+
`addPlainStart`, `addPlainEnd`, `nextGw()`, `currentScope()`, `pushScope`,
|
|
97
|
+
`popScope`. Node-render string helpers (`incomingOutgoing`, `escapeXml`, …) are
|
|
98
|
+
exported from `../declarative.js` / `../xml.js` for a custom `RenderNode.render`.
|
|
99
|
+
|
|
100
|
+
All are exported from `../declarative.js`; the registration function and its
|
|
101
|
+
`NodeKindHandlers<N>` type are in `./registry.js`.
|
|
102
|
+
|
|
103
|
+
## Rules
|
|
104
|
+
|
|
105
|
+
- **Never** edit `FlowNodeRegistry` in `types.ts`, the `FlowBuilder<C>` interface
|
|
106
|
+
outside your own `declare module`, or `index.ts` (it is generated — see below).
|
|
107
|
+
- **Import types with `import type`** (the package uses
|
|
108
|
+
`verbatimModuleSyntax`).
|
|
109
|
+
- **No `as T` assertions** (repo-wide ban — see the root `AGENTS.md`); narrow with
|
|
110
|
+
a type guard, an annotation, or `satisfies`.
|
|
111
|
+
- Register **exactly once** per kind (`registerNodeKind` throws on a duplicate
|
|
112
|
+
kind; builder assembly in `makeBuilder` additionally throws when two kinds
|
|
113
|
+
contribute the same duplicate builder-method name).
|
|
114
|
+
|
|
115
|
+
## The generated barrel
|
|
116
|
+
|
|
117
|
+
`index.ts` is **generated** by `scripts/gen-node-registry.mjs` (a `build`/
|
|
118
|
+
`typecheck` prebuild step): it scans this directory and emits a sorted list of
|
|
119
|
+
side-effect imports so every kind's `registerNodeKind(...)` runs. Because it is
|
|
120
|
+
regenerated and alphabetically ordered, two slices that each add a different
|
|
121
|
+
`<kind>.ts` file merge without conflict and are wired automatically — **do not
|
|
122
|
+
hand-edit `index.ts`.** (`package.json`'s `sideEffects` allowlist keeps a
|
|
123
|
+
downstream bundler from tree-shaking these registrations away.)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Built-in flow-node kind: `branch` — a two-way exclusive choice on a FEEL
|
|
2
|
+
// boolean condition (a BPMN exclusive gateway); the `then` arm is guarded, the
|
|
3
|
+
// `else` arm is the gateway default. Contributed through the extension seam.
|
|
4
|
+
|
|
5
|
+
import type { FlowNode } from "../types.js";
|
|
6
|
+
import type { Block } from "../declarative.js";
|
|
7
|
+
import { feel } from "../declarative.js";
|
|
8
|
+
import { registerNodeKind } from "./registry.js";
|
|
9
|
+
|
|
10
|
+
declare module "../types.js" {
|
|
11
|
+
interface FlowNodeRegistry {
|
|
12
|
+
branch: { kind: "branch"; condition: string; then: FlowNode[]; else?: FlowNode[] };
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
registerNodeKind("branch", {
|
|
17
|
+
build: (api) => (condition: string, arms: { then: Block; else?: Block }) => {
|
|
18
|
+
if (typeof condition !== "string" || condition.trim() === "") {
|
|
19
|
+
throw new Error(`branch() needs a non-empty FEEL condition`);
|
|
20
|
+
}
|
|
21
|
+
if (!arms || typeof arms.then !== "function") throw new Error(`branch("${condition}") needs a then arm`);
|
|
22
|
+
if (arms.else !== undefined && typeof arms.else !== "function") {
|
|
23
|
+
throw new Error(`branch("${condition}") else arm must be a block (b) => {…}`);
|
|
24
|
+
}
|
|
25
|
+
api.out.push({
|
|
26
|
+
kind: "branch",
|
|
27
|
+
condition,
|
|
28
|
+
then: api.child(arms.then, false),
|
|
29
|
+
else: arms.else ? api.child(arms.else, false) : undefined,
|
|
30
|
+
});
|
|
31
|
+
return api.self();
|
|
32
|
+
},
|
|
33
|
+
walk: (node, recurse) => {
|
|
34
|
+
recurse(node.then);
|
|
35
|
+
if (node.else) recurse(node.else);
|
|
36
|
+
},
|
|
37
|
+
emit: (node, incoming, loop, api) => {
|
|
38
|
+
const id = `Gw_${api.nextGw()}`;
|
|
39
|
+
const gw = api.addGateway(id);
|
|
40
|
+
api.connect(incoming, id);
|
|
41
|
+
const te = api.newEdge(id, { condition: feel(node.condition), name: "then" });
|
|
42
|
+
const out = api.emitList(node.then, [te], loop);
|
|
43
|
+
const ee = api.newEdge(id, { name: "else" });
|
|
44
|
+
gw.defaultFlow = ee.id;
|
|
45
|
+
out.push(...api.emitList(node.else ?? [], [ee], loop));
|
|
46
|
+
return out;
|
|
47
|
+
},
|
|
48
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Built-in flow-node kind: `break` — exit the enclosing loop (routes to whatever
|
|
2
|
+
// follows it). Only valid inside a `loop`. Contributed through the extension seam.
|
|
3
|
+
|
|
4
|
+
import { registerNodeKind } from "./registry.js";
|
|
5
|
+
|
|
6
|
+
declare module "../types.js" {
|
|
7
|
+
interface FlowNodeRegistry {
|
|
8
|
+
break: { kind: "break" };
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
registerNodeKind("break", {
|
|
13
|
+
build: (api) => () => {
|
|
14
|
+
if (api.loopDepth === 0) throw new Error(`break() is only valid inside a loop`);
|
|
15
|
+
api.out.push({ kind: "break" });
|
|
16
|
+
return api.self();
|
|
17
|
+
},
|
|
18
|
+
emit: (_node, incoming, loop, api) => {
|
|
19
|
+
if (!loop) throw new Error(`break outside a loop in flow "${api.flowId}"`);
|
|
20
|
+
// The incoming edges (from the previous node) become the loop's exit
|
|
21
|
+
// danglers; this path does not fall through.
|
|
22
|
+
loop.breaks.push(...incoming);
|
|
23
|
+
return [];
|
|
24
|
+
},
|
|
25
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Built-in flow-node kind: `continue` — jump straight back to the top of the
|
|
2
|
+
// enclosing loop, skipping the rest of the body. Only valid inside a `loop`.
|
|
3
|
+
// Contributed through the extension seam (epic #314, S0/#315).
|
|
4
|
+
|
|
5
|
+
import { registerNodeKind } from "./registry.js";
|
|
6
|
+
|
|
7
|
+
declare module "../types.js" {
|
|
8
|
+
interface FlowNodeRegistry {
|
|
9
|
+
continue: { kind: "continue" };
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
registerNodeKind("continue", {
|
|
14
|
+
build: (api) => () => {
|
|
15
|
+
if (api.loopDepth === 0) throw new Error(`continue() is only valid inside a loop`);
|
|
16
|
+
api.out.push({ kind: "continue" });
|
|
17
|
+
return api.self();
|
|
18
|
+
},
|
|
19
|
+
emit: (_node, incoming, loop, api) => {
|
|
20
|
+
if (!loop) throw new Error(`continue outside a loop in flow "${api.flowId}"`);
|
|
21
|
+
api.connect(incoming, loop.headId);
|
|
22
|
+
return [];
|
|
23
|
+
},
|
|
24
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Built-in flow-node kind: `forEach` — a data-driven fan-out over a runtime
|
|
2
|
+
// collection (a BPMN parallel/sequential multi-instance activity). A single
|
|
3
|
+
// service-task body lifts the MI characteristics directly; a multi-step body is
|
|
4
|
+
// wrapped in an embedded MI sub-process. Contributed through the extension seam.
|
|
5
|
+
|
|
6
|
+
import type { FlowNode } from "../types.js";
|
|
7
|
+
import type { Block } from "../declarative.js";
|
|
8
|
+
import { assertIdent, escapeXml } from "../xml.js";
|
|
9
|
+
import { registerNodeKind } from "./registry.js";
|
|
10
|
+
|
|
11
|
+
/** The `forEach` node shape (its `FlowNode` variant). */
|
|
12
|
+
interface ForEachNode {
|
|
13
|
+
kind: "forEach";
|
|
14
|
+
/** FEEL expression evaluating to the list to fan out over. */
|
|
15
|
+
collection: string;
|
|
16
|
+
/** Variable each item is bound to in the child's scope (`inputElement`). */
|
|
17
|
+
itemVar: string;
|
|
18
|
+
body: FlowNode[];
|
|
19
|
+
/** Run children one at a time (sequential MI) instead of all at once. */
|
|
20
|
+
sequential?: boolean;
|
|
21
|
+
/** List variable that each child's `outputElement` is collected into. */
|
|
22
|
+
outputCollection?: string;
|
|
23
|
+
/** FEEL expression producing each child's contribution to `outputCollection`. */
|
|
24
|
+
outputElement?: string;
|
|
25
|
+
/** FEEL boolean that, once true, completes the body early. */
|
|
26
|
+
completionCondition?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
declare module "../types.js" {
|
|
30
|
+
interface FlowNodeRegistry {
|
|
31
|
+
forEach: ForEachNode;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface ForEachOpts {
|
|
36
|
+
sequential?: boolean;
|
|
37
|
+
outputCollection?: string;
|
|
38
|
+
outputElement?: string;
|
|
39
|
+
completionCondition?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The `<bpmn:multiInstanceLoopCharacteristics>` block for a `forEach` node — the
|
|
43
|
+
* same shape whether lifted onto a leaf service task or a sub-process. */
|
|
44
|
+
function miCharacteristics(node: ForEachNode): string {
|
|
45
|
+
const seq = node.sequential ? "true" : "false";
|
|
46
|
+
const attrs = [
|
|
47
|
+
`inputCollection="${escapeXml(`=${node.collection}`)}"`,
|
|
48
|
+
`inputElement="${escapeXml(node.itemVar)}"`,
|
|
49
|
+
];
|
|
50
|
+
if (node.outputCollection) attrs.push(`outputCollection="${escapeXml(node.outputCollection)}"`);
|
|
51
|
+
if (node.outputElement) attrs.push(`outputElement="${escapeXml(`=${node.outputElement}`)}"`);
|
|
52
|
+
const completion = node.completionCondition
|
|
53
|
+
? ` <bpmn:completionCondition>${escapeXml(`=${node.completionCondition}`)}</bpmn:completionCondition>\n`
|
|
54
|
+
: "";
|
|
55
|
+
return (
|
|
56
|
+
` <bpmn:multiInstanceLoopCharacteristics isSequential="${seq}">\n` +
|
|
57
|
+
` <bpmn:extensionElements>\n` +
|
|
58
|
+
` <zeebe:loopCharacteristics ${attrs.join(" ")} />\n` +
|
|
59
|
+
` </bpmn:extensionElements>\n` +
|
|
60
|
+
completion +
|
|
61
|
+
` </bpmn:multiInstanceLoopCharacteristics>\n`
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
registerNodeKind("forEach", {
|
|
66
|
+
build: (api) => (collection: string, itemVar: string, body: Block, opts?: ForEachOpts) => {
|
|
67
|
+
if (typeof collection !== "string" || collection.trim() === "") {
|
|
68
|
+
throw new Error(`forEach() needs a non-empty FEEL collection expression`);
|
|
69
|
+
}
|
|
70
|
+
// Authors may paste a leading "=" (common in Zeebe FEEL examples). Strip it
|
|
71
|
+
// so the emitter's own "=" prefix never produces an invalid "==...".
|
|
72
|
+
const collExpr = collection.trim().replace(/^=/, "").trim();
|
|
73
|
+
if (collExpr === "") {
|
|
74
|
+
throw new Error(`forEach() collection expression is empty after stripping the leading "="`);
|
|
75
|
+
}
|
|
76
|
+
assertIdent("forEach itemVar", itemVar);
|
|
77
|
+
if (typeof body !== "function") throw new Error(`forEach("${itemVar}") needs a body function`);
|
|
78
|
+
const nodes = api.childScoped(body);
|
|
79
|
+
if (nodes.length === 0) throw new Error(`forEach("${itemVar}") body declared no steps`);
|
|
80
|
+
const node: ForEachNode = { kind: "forEach", collection: collExpr, itemVar, body: nodes };
|
|
81
|
+
if (opts?.sequential) node.sequential = true;
|
|
82
|
+
if (opts?.outputCollection !== undefined) {
|
|
83
|
+
assertIdent("forEach outputCollection", opts.outputCollection);
|
|
84
|
+
node.outputCollection = opts.outputCollection;
|
|
85
|
+
}
|
|
86
|
+
if (opts?.outputElement !== undefined) {
|
|
87
|
+
if (typeof opts.outputElement !== "string" || opts.outputElement.trim() === "") {
|
|
88
|
+
throw new Error(`forEach("${itemVar}") outputElement must be a non-empty FEEL expression`);
|
|
89
|
+
}
|
|
90
|
+
const outEl = opts.outputElement.trim().replace(/^=/, "").trim();
|
|
91
|
+
if (outEl === "") {
|
|
92
|
+
throw new Error(`forEach("${itemVar}") outputElement is empty after stripping the leading "="`);
|
|
93
|
+
}
|
|
94
|
+
node.outputElement = outEl;
|
|
95
|
+
}
|
|
96
|
+
if (opts?.completionCondition !== undefined) {
|
|
97
|
+
if (typeof opts.completionCondition !== "string" || opts.completionCondition.trim() === "") {
|
|
98
|
+
throw new Error(`forEach("${itemVar}") completionCondition must be a non-empty FEEL expression`);
|
|
99
|
+
}
|
|
100
|
+
const compCond = opts.completionCondition.trim().replace(/^=/, "").trim();
|
|
101
|
+
if (compCond === "") {
|
|
102
|
+
throw new Error(`forEach("${itemVar}") completionCondition is empty after stripping the leading "="`);
|
|
103
|
+
}
|
|
104
|
+
node.completionCondition = compCond;
|
|
105
|
+
}
|
|
106
|
+
if (node.outputElement && !node.outputCollection) {
|
|
107
|
+
throw new Error(`forEach("${itemVar}") outputElement needs an outputCollection to collect into`);
|
|
108
|
+
}
|
|
109
|
+
api.out.push(node);
|
|
110
|
+
return api.self();
|
|
111
|
+
},
|
|
112
|
+
walk: (node, recurse) => recurse(node.body),
|
|
113
|
+
emit: (node, incoming, _loop, api) => {
|
|
114
|
+
const mi = miCharacteristics(node);
|
|
115
|
+
const only = node.body.length === 1 ? node.body[0] : undefined;
|
|
116
|
+
// A single service-task body carries the MI characteristics directly; a
|
|
117
|
+
// multi-step body is wrapped in an embedded MI sub-process (its own token
|
|
118
|
+
// scope, with its own start/end). A prompt-carrying `task` is deliberately
|
|
119
|
+
// EXCLUDED from this fast path: computing its `zeebe:linkedResources`/
|
|
120
|
+
// `appendPrompt` delta lives in the `task` kind's own emitter, so lifting it
|
|
121
|
+
// here would silently drop that binding. It falls through to the sub-process
|
|
122
|
+
// path, where the `task` emitter renders the agent service task (prompt
|
|
123
|
+
// intact) inside the MI scope.
|
|
124
|
+
if (only && (only.kind === "run" || (only.kind === "task" && only.prompt === undefined))) {
|
|
125
|
+
api.addServiceTask(only, { mi });
|
|
126
|
+
api.connect(incoming, only.name);
|
|
127
|
+
return [api.newEdge(only.name)];
|
|
128
|
+
}
|
|
129
|
+
const subId = `Sub_${api.nextGw()}`;
|
|
130
|
+
api.addSubProcess(subId, mi);
|
|
131
|
+
api.connect(incoming, subId);
|
|
132
|
+
api.pushScope(subId);
|
|
133
|
+
const startId = `${subId}_start`;
|
|
134
|
+
api.addPlainStart(startId);
|
|
135
|
+
const innerOut = api.emitList(node.body, [api.newEdge(startId)], null);
|
|
136
|
+
const endId = `${subId}_end`;
|
|
137
|
+
api.addPlainEnd(endId);
|
|
138
|
+
api.connect(innerOut, endId);
|
|
139
|
+
api.popScope();
|
|
140
|
+
return [api.newEdge(subId)];
|
|
141
|
+
},
|
|
142
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// GENERATED by scripts/gen-node-registry.mjs — do not edit by hand.
|
|
2
|
+
//
|
|
3
|
+
// The barrel of built-in flow-node kind modules. Importing this module runs
|
|
4
|
+
// every kind's `registerNodeKind(...)` side effect, populating the dispatch
|
|
5
|
+
// table `makeBuilder`/`walkNodes`/the emitter route through. A new kind is
|
|
6
|
+
// picked up automatically by re-running the generator (a prebuild step) — no
|
|
7
|
+
// hand-edit here.
|
|
8
|
+
|
|
9
|
+
import "./branch.js";
|
|
10
|
+
import "./break.js";
|
|
11
|
+
import "./continue.js";
|
|
12
|
+
import "./forEach.js";
|
|
13
|
+
import "./loop.js";
|
|
14
|
+
import "./parallel.js";
|
|
15
|
+
import "./race.js";
|
|
16
|
+
import "./run.js";
|
|
17
|
+
import "./signal.js";
|
|
18
|
+
import "./switch.js";
|
|
19
|
+
import "./task.js";
|
|
20
|
+
import "./timer.js";
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Built-in flow-node kind: `loop` — a durable loop (a back-edge to the loop
|
|
2
|
+
// head): the body runs, then control returns to the top unless a branch calls
|
|
3
|
+
// `break()`. Contributed through the extension seam (epic #314, S0/#315).
|
|
4
|
+
|
|
5
|
+
import type { FlowNode } from "../types.js";
|
|
6
|
+
import type { Block, LoopCtx } from "../declarative.js";
|
|
7
|
+
import { registerNodeKind } from "./registry.js";
|
|
8
|
+
|
|
9
|
+
declare module "../types.js" {
|
|
10
|
+
interface FlowNodeRegistry {
|
|
11
|
+
loop: { kind: "loop"; body: FlowNode[] };
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
registerNodeKind("loop", {
|
|
16
|
+
build: (api) => (body: Block) => {
|
|
17
|
+
if (typeof body !== "function") throw new Error(`loop() needs a body function`);
|
|
18
|
+
api.out.push({ kind: "loop", body: api.child(body, true) });
|
|
19
|
+
return api.self();
|
|
20
|
+
},
|
|
21
|
+
walk: (node, recurse) => recurse(node.body),
|
|
22
|
+
emit: (node, incoming, _loop, api) => {
|
|
23
|
+
const headId = `Loop_${api.nextGw()}`;
|
|
24
|
+
api.addGateway(headId);
|
|
25
|
+
api.connect(incoming, headId);
|
|
26
|
+
const ctx: LoopCtx = { headId, breaks: [] };
|
|
27
|
+
const headOut = api.newEdge(headId);
|
|
28
|
+
const bodyOut = api.emitList(node.body, [headOut], ctx);
|
|
29
|
+
// Normal fall-through of the body loops back to the head (continue).
|
|
30
|
+
api.connect(bodyOut, headId);
|
|
31
|
+
// The loop exits only via break edges.
|
|
32
|
+
return ctx.breaks;
|
|
33
|
+
},
|
|
34
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Built-in flow-node kind: `parallel` — a static parallel fork/join (a pair of
|
|
2
|
+
// BPMN parallel gateways): every block runs concurrently on its own branch, and
|
|
3
|
+
// control continues only once ALL branches reach the AND-join. Contributed
|
|
4
|
+
// through the extension seam (epic #314, S0/#315).
|
|
5
|
+
|
|
6
|
+
import type { FlowNode } from "../types.js";
|
|
7
|
+
import type { Block } from "../declarative.js";
|
|
8
|
+
import { registerNodeKind } from "./registry.js";
|
|
9
|
+
|
|
10
|
+
declare module "../types.js" {
|
|
11
|
+
interface FlowNodeRegistry {
|
|
12
|
+
parallel: { kind: "parallel"; branches: FlowNode[][] };
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
registerNodeKind("parallel", {
|
|
17
|
+
build: (api) => (branches: Block[]) => {
|
|
18
|
+
if (!Array.isArray(branches) || branches.length < 2) {
|
|
19
|
+
throw new Error(`parallel() needs at least two branch blocks`);
|
|
20
|
+
}
|
|
21
|
+
for (const fn of branches) {
|
|
22
|
+
if (typeof fn !== "function") throw new Error(`parallel() branches must all be blocks (b) => {…}`);
|
|
23
|
+
}
|
|
24
|
+
// Each branch runs in its own token scope up to the AND-join, so a
|
|
25
|
+
// `break`/`continue` must not escape into an enclosing loop. Scope branches
|
|
26
|
+
// (loopDepth resets to 0) so cross-boundary break/continue is rejected.
|
|
27
|
+
api.out.push({ kind: "parallel", branches: branches.map((fn) => api.childScoped(fn)) });
|
|
28
|
+
return api.self();
|
|
29
|
+
},
|
|
30
|
+
walk: (node, recurse) => {
|
|
31
|
+
for (const branch of node.branches) recurse(branch);
|
|
32
|
+
},
|
|
33
|
+
emit: (node, incoming, loop, api) => {
|
|
34
|
+
// A diverging parallel gateway forks a token onto every branch; a converging
|
|
35
|
+
// one joins them (AND-join) into a single continuation.
|
|
36
|
+
const splitId = `Gw_${api.nextGw()}`;
|
|
37
|
+
api.addParallelGateway(splitId);
|
|
38
|
+
api.connect(incoming, splitId);
|
|
39
|
+
const joinId = `Gw_${api.nextGw()}`;
|
|
40
|
+
api.addParallelGateway(joinId);
|
|
41
|
+
for (const branch of node.branches) {
|
|
42
|
+
const e = api.newEdge(splitId);
|
|
43
|
+
const branchOut = api.emitList(branch, [e], loop);
|
|
44
|
+
api.connect(branchOut, joinId);
|
|
45
|
+
}
|
|
46
|
+
return [api.newEdge(joinId)];
|
|
47
|
+
},
|
|
48
|
+
});
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Flow-node kind: `race` — a first-of event race (a BPMN event-based gateway).
|
|
2
|
+
// The gateway forks to one intermediate catch event per arm — a MESSAGE catch for
|
|
3
|
+
// a `signal` arm (correlated on a process variable) or a TIMER catch for a `timer`
|
|
4
|
+
// arm — and the engine arms them all at once. The FIRST event to fire wins: its
|
|
5
|
+
// arm's `do` body runs and the losing catch events are cancelled; each arm's body
|
|
6
|
+
// then converges on whatever follows the race.
|
|
7
|
+
//
|
|
8
|
+
// Contributed entirely from this module through the wave-0 extension seam (epic
|
|
9
|
+
// #314, S1/#316): its `FlowNode` variant (declaration-merged into the augmentable
|
|
10
|
+
// `FlowNodeRegistry`), its `w.race(...)` builder method (merged into
|
|
11
|
+
// `FlowBuilder<C>`), and its `walk`/`emit` handlers — with NO edit to the shared
|
|
12
|
+
// `FlowNode` union, the `walkNodes`/`emitNode` dispatch, or the generated barrel.
|
|
13
|
+
//
|
|
14
|
+
// The catch arms are modelled as nested `signal`/`timer` `FlowNode`s so the whole
|
|
15
|
+
// machine reuses the built-ins: `walk` recurses into them (so the shared message
|
|
16
|
+
// emitter lifts each `signal` arm's `<bpmn:message>` + correlation subscription
|
|
17
|
+
// automatically, and `WorkflowClient.signal` can correlate to it), and `emit`
|
|
18
|
+
// dispatches each arm's catch through its own registered handler (so a race's
|
|
19
|
+
// catch is byte-identical to a standalone `w.signal`/`w.timer`). Only the
|
|
20
|
+
// event-based gateway itself is placed here.
|
|
21
|
+
|
|
22
|
+
import type { FlowNode } from "../types.js";
|
|
23
|
+
import type { Block, Edge, RenderNode } from "../declarative.js";
|
|
24
|
+
import { incomingOutgoing } from "../declarative.js";
|
|
25
|
+
import { assertIdent, assertTimerDate, assertTimerDuration, escapeXml } from "../xml.js";
|
|
26
|
+
import { registerNodeKind, requireNodeKind } from "./registry.js";
|
|
27
|
+
|
|
28
|
+
/** One arm of a {@link FlowBuilder.race}: exactly one waited-for event — a
|
|
29
|
+
* `signal` (a message catch correlated on a process variable) OR a `timer` (a
|
|
30
|
+
* delay `{ after }` or an instant `{ at }`) — plus the `do` body that runs when
|
|
31
|
+
* that event wins the race. */
|
|
32
|
+
export type RaceArm = ({ signal: { correlationKey: string } } | { timer: { after: string } | { at: string } }) & {
|
|
33
|
+
do: Block;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** A compiled race arm: its catch event (a `signal` or `timer` `FlowNode`, reused
|
|
37
|
+
* from the built-ins) and the body that runs when it wins. */
|
|
38
|
+
interface CompiledArm {
|
|
39
|
+
event: FlowNode;
|
|
40
|
+
body: FlowNode[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
declare module "../types.js" {
|
|
44
|
+
interface FlowNodeRegistry {
|
|
45
|
+
race: { kind: "race"; arms: CompiledArm[] };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
declare module "../declarative.js" {
|
|
50
|
+
interface FlowBuilder<C extends object> {
|
|
51
|
+
/**
|
|
52
|
+
* A first-of event race — a BPMN event-based gateway. Each named arm waits on
|
|
53
|
+
* exactly one event: a `signal` (a durable message catch correlated on a
|
|
54
|
+
* process variable) or a `timer` (`{ after }` a delay, or `{ at }` an
|
|
55
|
+
* instant). The engine arms every catch at once; the FIRST to fire wins, its
|
|
56
|
+
* arm's `do` body runs, and the losing catch events are cancelled. Every arm's
|
|
57
|
+
* body converges on whatever follows the race.
|
|
58
|
+
*
|
|
59
|
+
* ```ts
|
|
60
|
+
* w.race({
|
|
61
|
+
* "review-ready": { signal: { correlationKey: "prKey" }, do: (b) => b.run("re-review", reReview) },
|
|
62
|
+
* "review-timeout": { timer: { after: "=reviewWaitTimeout" }, do: (b) => b.run("escalate", escalate) },
|
|
63
|
+
* });
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
race(arms: Record<string, RaceArm>): FlowBuilder<C>;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function hasSignal(spec: RaceArm): spec is { signal: { correlationKey: string } } & { do: Block } {
|
|
71
|
+
return "signal" in spec && spec.signal !== null && typeof spec.signal === "object";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function hasTimer(spec: RaceArm): spec is { timer: { after: string } | { at: string } } & { do: Block } {
|
|
75
|
+
return "timer" in spec && spec.timer !== null && typeof spec.timer === "object";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
registerNodeKind("race", {
|
|
79
|
+
build: (api) => (arms: Record<string, RaceArm>) => {
|
|
80
|
+
if (arms === null || typeof arms !== "object") {
|
|
81
|
+
throw new Error(`race() needs an arms object { "<name>": { signal | timer, do } }`);
|
|
82
|
+
}
|
|
83
|
+
const entries = Object.entries(arms);
|
|
84
|
+
if (entries.length < 2) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`race() needs at least two arms (an event-based gateway with fewer than two events is degenerate)`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
const compiled: CompiledArm[] = [];
|
|
90
|
+
for (const [name, spec] of entries) {
|
|
91
|
+
if (spec === null || typeof spec !== "object") {
|
|
92
|
+
throw new Error(`race() arm "${name}" must be an object { signal | timer, do }`);
|
|
93
|
+
}
|
|
94
|
+
const isSignal = hasSignal(spec);
|
|
95
|
+
const isTimer = hasTimer(spec);
|
|
96
|
+
if (isSignal === isTimer) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`race() arm "${name}" needs exactly one of { signal: { correlationKey } } or { timer: { after | at } }`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
if (typeof spec.do !== "function") {
|
|
102
|
+
throw new Error(`race() arm "${name}" needs a do block (b) => {…}`);
|
|
103
|
+
}
|
|
104
|
+
api.claim(name);
|
|
105
|
+
let event: FlowNode;
|
|
106
|
+
if (isSignal) {
|
|
107
|
+
const rawKey = spec.signal.correlationKey;
|
|
108
|
+
if (typeof rawKey !== "string" || rawKey.trim() === "") {
|
|
109
|
+
throw new Error(`race() arm "${name}" signal needs a non-empty { correlationKey }`);
|
|
110
|
+
}
|
|
111
|
+
// Trim before validating/storing, in parity with the timer arm ({ after }/
|
|
112
|
+
// { at } are trimmed above): a value like " prKey " is usable after
|
|
113
|
+
// trimming, so it must not fail the NCName check on stray whitespace. The
|
|
114
|
+
// assertIdent context names the arm so a failure says WHICH arm is invalid.
|
|
115
|
+
const correlationKey = rawKey.trim();
|
|
116
|
+
assertIdent(`race() arm "${name}" signal correlationKey`, correlationKey);
|
|
117
|
+
event = { kind: "signal", name, correlationKey, payload: api.contracts[name]?.in };
|
|
118
|
+
} else {
|
|
119
|
+
const timer = spec.timer;
|
|
120
|
+
// Enforce exactly-one on KEY PRESENCE, not string-ness: a runtime-invalid
|
|
121
|
+
// arm that supplies both keys where only one is a string (e.g.
|
|
122
|
+
// `{ after: 1, at: "…" }`) must be rejected here, not silently dispatched
|
|
123
|
+
// into `after.trim()` — which would throw an opaque TypeError. Once the
|
|
124
|
+
// branch is chosen, validate the value is a string before trimming.
|
|
125
|
+
const hasAfterKey = "after" in timer;
|
|
126
|
+
const hasAtKey = "at" in timer;
|
|
127
|
+
if (hasAfterKey === hasAtKey) {
|
|
128
|
+
throw new Error(`race() arm "${name}" timer needs exactly one of { after } (a delay) or { at } (an instant)`);
|
|
129
|
+
}
|
|
130
|
+
if ("after" in timer) {
|
|
131
|
+
if (typeof timer.after !== "string") {
|
|
132
|
+
throw new Error(`race() arm "${name}" timer { after } must be a string duration`);
|
|
133
|
+
}
|
|
134
|
+
const after = timer.after.trim();
|
|
135
|
+
assertTimerDuration(`race() arm "${name}" timer after`, after);
|
|
136
|
+
event = { kind: "timer", name, after };
|
|
137
|
+
} else {
|
|
138
|
+
if (typeof timer.at !== "string") {
|
|
139
|
+
throw new Error(`race() arm "${name}" timer { at } must be a string instant`);
|
|
140
|
+
}
|
|
141
|
+
const at = timer.at.trim();
|
|
142
|
+
assertTimerDate(`race() arm "${name}" timer at`, at);
|
|
143
|
+
event = { kind: "timer", name, at };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
compiled.push({ event, body: api.child(spec.do, false) });
|
|
147
|
+
}
|
|
148
|
+
api.out.push({ kind: "race", arms: compiled });
|
|
149
|
+
return api.self();
|
|
150
|
+
},
|
|
151
|
+
walk: (node, recurse) => {
|
|
152
|
+
// Recurse into each arm's catch event AND its body: the catch `signal` nodes
|
|
153
|
+
// must be visited so the shared message emitter lifts their `<bpmn:message>`
|
|
154
|
+
// + correlation subscription, and body `task` nodes so their external job
|
|
155
|
+
// types are discovered — exactly as a standalone `signal`/`task` would be.
|
|
156
|
+
for (const arm of node.arms) recurse([arm.event, ...arm.body]);
|
|
157
|
+
},
|
|
158
|
+
emit: (node, incoming, loop, api) => {
|
|
159
|
+
const gwId = `Gw_${api.nextGw()}`;
|
|
160
|
+
api.addNode(eventBasedGateway(gwId, api.currentScope()));
|
|
161
|
+
api.connect(incoming, gwId);
|
|
162
|
+
const out: Edge[] = [];
|
|
163
|
+
for (const arm of node.arms) {
|
|
164
|
+
// Fork the gateway straight to the arm's catch event (BPMN requires an
|
|
165
|
+
// event-based gateway's targets to be intermediate catch events), reusing
|
|
166
|
+
// the arm kind's own registered emit so the catch is identical to a
|
|
167
|
+
// standalone `w.signal`/`w.timer`; then wire its `do` body onward.
|
|
168
|
+
const fork = api.newEdge(gwId);
|
|
169
|
+
const afterCatch = requireNodeKind(arm.event.kind).emit(arm.event, [fork], loop, api);
|
|
170
|
+
out.push(...api.emitList(arm.body, afterCatch, loop));
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
/** A `<bpmn:eventBasedGateway>` render node (no typed `EmitApi` primitive exists —
|
|
177
|
+
* built as a custom {@link RenderNode} from the exported string helpers). */
|
|
178
|
+
function eventBasedGateway(id: string, scope: string | undefined): RenderNode {
|
|
179
|
+
return {
|
|
180
|
+
id,
|
|
181
|
+
scope,
|
|
182
|
+
render: (inc: string[], outg: string[]) =>
|
|
183
|
+
` <bpmn:eventBasedGateway id="${escapeXml(id)}">\n` + incomingOutgoing(inc, outg) + ` </bpmn:eventBasedGateway>`,
|
|
184
|
+
};
|
|
185
|
+
}
|