@nanobpm/workflow 0.7.0 → 0.8.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 CHANGED
@@ -45,6 +45,14 @@ human-in-the-loop path), composed with control-flow combinators:
45
45
  - `w.branch(condition, { then, else? })` — a two-way choice on a FEEL boolean.
46
46
  - `w.loop(body)` — a durable loop (back-edge to the loop head).
47
47
  - `w.break()` / `w.continue()` — exit the enclosing loop, or jump back to its head.
48
+ - `w.parallel([blockA, blockB, …])` — a static fork/join: every block runs
49
+ concurrently on its own branch, rejoined by an AND-join (all branches must
50
+ arrive before the flow continues).
51
+ - `w.forEach(collection, itemVar, body, opts?)` — a data-driven fan-out over a
52
+ FEEL `collection`: one child instance of `body` runs per item (the item bound to
53
+ `itemVar`). `{ sequential }` runs children one at a time; `{ outputCollection,
54
+ outputElement }` collects each child's result into a list; `{ completionCondition }`
55
+ completes the body early.
48
56
 
49
57
  ```ts
50
58
  import { defineFlow, WorkflowClient, Worker } from "@nanobpm/workflow";
@@ -89,8 +97,10 @@ const convergence = defineFlow("convergence-loop", (w) => {
89
97
  Each combinator compiles to a BPMN primitive the engine already runs: `switch` /
90
98
  `branch` → an exclusive gateway (in-order conditions, first match wins, default =
91
99
  unconditional flow); `loop` → a convergent gateway whose body falls through back to
92
- the head; nodes with multiple incoming flows are an implicit XOR merge. See
93
- **ADR 0047**.
100
+ the head; nodes with multiple incoming flows are an implicit XOR merge; `parallel`
101
+ → a diverging/converging **parallel gateway** pair (AND fork/join); `forEach` → a
102
+ **parallel multi-instance** activity (a single-step body) or an embedded
103
+ multi-instance sub-process (a multi-step body). See **ADR 0047**.
94
104
 
95
105
  #### Typed data envelopes (eject to model-first with contracts intact)
96
106
 
@@ -178,7 +188,7 @@ once.
178
188
  | Export | Purpose |
179
189
  | --- | --- |
180
190
  | `defineWorkflow(id, orchFn)` | Imperative (replayed) workflow — experimental/internal. |
181
- | `defineFlow(id, [contracts,] build)` | Declarative flow: `run`/`task`/`signal` + `switch`/`branch`/`loop`/`break`/`continue`, with an optional typed contracts map. |
191
+ | `defineFlow(id, [contracts,] build)` | Declarative flow: `run`/`task`/`signal`/`timer` + `switch`/`branch`/`loop`/`break`/`continue` + `parallel`/`forEach`, with an optional typed contracts map. |
182
192
  | `envelope(name, fields)` | A typed data envelope; lifted into the model as a `nano:shape` + `dataEnvelope` wiring. |
183
193
  | `externalJobTypes(flow)` | The job types of a flow's external `task` steps (each overridable per-step via `w.task(name, { jobType })`). |
184
194
  | `WorkflowClient` | `deploy`, `start`, `signal`, `getInstance` over REST v2. |
@@ -22,8 +22,19 @@ type TypedHandler<V, R> = (job: {
22
22
  variables: V;
23
23
  }) => Promise<R | void> | R | void;
24
24
  /** A block: the callback that populates a nested body (a case, arm, or loop). */
25
- type Block<C extends FlowContracts> = (b: FlowBuilder<C>) => void;
26
- export interface FlowBuilder<C extends FlowContracts = Record<string, never>> {
25
+ type Block<C extends object> = (b: FlowBuilder<C>) => void;
26
+ /** The typed builder for a flow body.
27
+ *
28
+ * `C` is the flow's contracts map (step name → `{ in, out }` envelopes). The
29
+ * default is `object`, NOT `Record<string, never>`: with a `never`-valued record
30
+ * `keyof C` is `string`, so `VarsOf`/`ResultOf` route EVERY step name into
31
+ * `InPayload<never>`/`OutPayload<never>`, which distribute over `never` to
32
+ * `never` — typing an untyped flow's `job.variables` as `never` and forcing its
33
+ * handler return to `void` (so no untyped flow that returns data compiles). With
34
+ * `object`, `keyof C` is `never`, so every step falls back to the intended
35
+ * `JsonObject`. The public `defineFlow<C extends FlowContracts>` overload keeps
36
+ * the stricter contracts constraint; this is only the untyped-builder default. */
37
+ export interface FlowBuilder<C extends object = object> {
27
38
  /**
28
39
  * A durable activity served by a worker THIS program hosts (a BPMN service
29
40
  * task; the handler runs in the in-process `Worker`). If `name` is a key in
@@ -109,6 +120,36 @@ export interface FlowBuilder<C extends FlowContracts = Record<string, never>> {
109
120
  * the loop run once `break()` is reached.
110
121
  */
111
122
  loop(body: Block<C>): FlowBuilder<C>;
123
+ /**
124
+ * A static parallel fork/join (a pair of BPMN parallel gateways). Every block
125
+ * runs concurrently on its own branch; control continues past `parallel` only
126
+ * once ALL branches have reached the joining gateway (AND-join). Pass at least
127
+ * two branch blocks. Unlike `switch`/`branch` (exclusive choice), no branch is
128
+ * conditional — all of them run.
129
+ */
130
+ parallel(branches: Block<C>[]): FlowBuilder<C>;
131
+ /**
132
+ * A data-driven fan-out over a runtime collection (a BPMN parallel
133
+ * multi-instance activity). `collection` is a FEEL expression (no leading `=`)
134
+ * evaluating to a list; one child instance of `body` runs per item, with the
135
+ * item bound to the `itemVar` variable in the child's scope. A single-activity
136
+ * body attaches the multi-instance characteristics to that activity; a
137
+ * multi-step body is wrapped in an embedded multi-instance sub-process. Options:
138
+ * - `sequential` — run children one at a time (a sequential MI) instead of all
139
+ * at once (the default: a parallel MI).
140
+ * - `outputCollection` / `outputElement` — collect each child's `outputElement`
141
+ * (a FEEL expression, no leading `=`) into the `outputCollection` list
142
+ * variable, in item order.
143
+ * - `completionCondition` — a FEEL boolean (no leading `=`); when it holds
144
+ * after a child completes, the remaining children are cancelled and the body
145
+ * completes early.
146
+ */
147
+ forEach(collection: string, itemVar: string, body: Block<C>, opts?: {
148
+ sequential?: boolean;
149
+ outputCollection?: string;
150
+ outputElement?: string;
151
+ completionCondition?: string;
152
+ }): FlowBuilder<C>;
112
153
  /** Exit the enclosing loop (routes to whatever follows it). Only valid inside
113
154
  * a `loop`. */
114
155
  break(): FlowBuilder<C>;
@@ -3,7 +3,9 @@
3
3
  // (`switch`/`branch`/`loop`) that compile to exclusive gateways + back-edges the
4
4
  // engine already runs (engine-core supports XOR gateways with in-order condition
5
5
  // evaluation and a default flow, and tasks with multiple incoming flows act as
6
- // an implicit XOR merge).
6
+ // an implicit XOR merge), plus concurrency combinators — `parallel` (an AND
7
+ // fork/join over parallel gateways) and `forEach` (a data-driven fan-out lowering
8
+ // to a parallel/sequential multi-instance activity or sub-process).
7
9
  //
8
10
  // const convergence = defineFlow(
9
11
  // "convergence-loop",
@@ -38,14 +40,14 @@ import { assertIdent, assertJobType, assertTimerCycle, assertTimerDate, assertTi
38
40
  /** Ids the emitter generates for structural nodes / flows / messages. A step
39
41
  * name that collides with one of these would produce a duplicate BPMN id and an
40
42
  * invalid model, so reject them at authoring time. */
41
- const RESERVED_PREFIXES = /^(Gw_|Loop_|Msg_|f_)/;
43
+ const RESERVED_PREFIXES = /^(Gw_|Loop_|Sub_|Msg_|f_)/;
42
44
  function claimName(ctx, id, name) {
43
45
  assertIdent("step name", name);
44
46
  if (name === "Start" || name === "End" || name === id) {
45
47
  throw new Error(`step name "${name}" is reserved (collides with a generated BPMN id) in flow "${id}"`);
46
48
  }
47
49
  if (RESERVED_PREFIXES.test(name)) {
48
- throw new Error(`step name "${name}" uses a reserved prefix (Gw_/Loop_/Msg_/f_ are generated ids) in flow "${id}"`);
50
+ throw new Error(`step name "${name}" uses a reserved prefix (Gw_/Loop_/Sub_/Msg_/f_ are generated ids) in flow "${id}"`);
49
51
  }
50
52
  if (ctx.seen.has(name))
51
53
  throw new Error(`duplicate step name "${name}" in flow "${id}"`);
@@ -68,6 +70,15 @@ function makeBuilder(id, out, ctx, isRoot = false) {
68
70
  fn(makeBuilder(id, body, { ...ctx, loopDepth: depth }));
69
71
  return body;
70
72
  };
73
+ // A body that runs in its OWN token scope (an embedded sub-process): the
74
+ // enclosing loop is not reachable across the scope boundary, so `break`/
75
+ // `continue` inside it are rejected (loopDepth resets to 0). A `loop` declared
76
+ // INSIDE the body still works — it creates its own scope.
77
+ const childScoped = (fn) => {
78
+ const body = [];
79
+ fn(makeBuilder(id, body, { ...ctx, loopDepth: 0 }));
80
+ return body;
81
+ };
71
82
  const b = {
72
83
  run(name, handler) {
73
84
  claimName(ctx, id, name);
@@ -173,6 +184,75 @@ function makeBuilder(id, out, ctx, isRoot = false) {
173
184
  out.push({ kind: "loop", body: child(body, true) });
174
185
  return b;
175
186
  },
187
+ parallel(branches) {
188
+ if (!Array.isArray(branches) || branches.length < 2) {
189
+ throw new Error(`parallel() needs at least two branch blocks`);
190
+ }
191
+ for (const fn of branches) {
192
+ if (typeof fn !== "function")
193
+ throw new Error(`parallel() branches must all be blocks (b) => {…}`);
194
+ }
195
+ // Each branch runs in its own token scope up to the AND-join, so a
196
+ // `break`/`continue` must not escape the fork/join into an enclosing
197
+ // loop. Scope branches (loopDepth resets to 0) so cross-boundary
198
+ // break/continue is rejected at build time, matching forEach.
199
+ out.push({ kind: "parallel", branches: branches.map((fn) => childScoped(fn)) });
200
+ return b;
201
+ },
202
+ forEach(collection, itemVar, body, opts) {
203
+ if (typeof collection !== "string" || collection.trim() === "") {
204
+ throw new Error(`forEach() needs a non-empty FEEL collection expression`);
205
+ }
206
+ // Authors may paste a leading "=" (common in Zeebe FEEL examples). Strip
207
+ // it so the emitter's own "=" prefix never produces an invalid "==...".
208
+ const collExpr = collection.trim().replace(/^=/, "").trim();
209
+ if (collExpr === "") {
210
+ throw new Error(`forEach() collection expression is empty after stripping the leading "="`);
211
+ }
212
+ assertIdent("forEach itemVar", itemVar);
213
+ if (typeof body !== "function")
214
+ throw new Error(`forEach("${itemVar}") needs a body function`);
215
+ const nodes = childScoped(body);
216
+ if (nodes.length === 0)
217
+ throw new Error(`forEach("${itemVar}") body declared no steps`);
218
+ const node = {
219
+ kind: "forEach",
220
+ collection: collExpr,
221
+ itemVar,
222
+ body: nodes,
223
+ };
224
+ if (opts?.sequential)
225
+ node.sequential = true;
226
+ if (opts?.outputCollection !== undefined) {
227
+ assertIdent("forEach outputCollection", opts.outputCollection);
228
+ node.outputCollection = opts.outputCollection;
229
+ }
230
+ if (opts?.outputElement !== undefined) {
231
+ if (typeof opts.outputElement !== "string" || opts.outputElement.trim() === "") {
232
+ throw new Error(`forEach("${itemVar}") outputElement must be a non-empty FEEL expression`);
233
+ }
234
+ const outEl = opts.outputElement.trim().replace(/^=/, "").trim();
235
+ if (outEl === "") {
236
+ throw new Error(`forEach("${itemVar}") outputElement is empty after stripping the leading "="`);
237
+ }
238
+ node.outputElement = outEl;
239
+ }
240
+ if (opts?.completionCondition !== undefined) {
241
+ if (typeof opts.completionCondition !== "string" || opts.completionCondition.trim() === "") {
242
+ throw new Error(`forEach("${itemVar}") completionCondition must be a non-empty FEEL expression`);
243
+ }
244
+ const compCond = opts.completionCondition.trim().replace(/^=/, "").trim();
245
+ if (compCond === "") {
246
+ throw new Error(`forEach("${itemVar}") completionCondition is empty after stripping the leading "="`);
247
+ }
248
+ node.completionCondition = compCond;
249
+ }
250
+ if (node.outputElement && !node.outputCollection) {
251
+ throw new Error(`forEach("${itemVar}") outputElement needs an outputCollection to collect into`);
252
+ }
253
+ out.push(node);
254
+ return b;
255
+ },
176
256
  break() {
177
257
  if (ctx.loopDepth === 0)
178
258
  throw new Error(`break() is only valid inside a loop`);
@@ -230,6 +310,13 @@ export function walkNodes(nodes, visit) {
230
310
  case "loop":
231
311
  walkNodes(n.body, visit);
232
312
  break;
313
+ case "parallel":
314
+ for (const branch of n.branches)
315
+ walkNodes(branch, visit);
316
+ break;
317
+ case "forEach":
318
+ walkNodes(n.body, visit);
319
+ break;
233
320
  default:
234
321
  break;
235
322
  }
@@ -260,13 +347,28 @@ class Compiler {
260
347
  edges = [];
261
348
  /** envelope name → its fields, deduped for lifting to a single nano:shape. */
262
349
  envelopes = new Map();
350
+ /** The stack of open sub-process scopes; `.at(-1)` is the current scope (a
351
+ * node/edge created now nests inside it). Empty at the top level. */
352
+ scopeStack = [];
353
+ /** Edges with a resolved target, indexed in `compile()` before rendering so a
354
+ * sub-process can render its own nested scope. */
355
+ live = [];
263
356
  seq = 0;
264
357
  gw = 0;
265
358
  constructor(flow) {
266
359
  this.flow = flow;
267
360
  }
361
+ currentScope() {
362
+ return this.scopeStack.length ? this.scopeStack[this.scopeStack.length - 1] : undefined;
363
+ }
268
364
  newEdge(from, opts = {}) {
269
- const e = { id: `f_${this.seq++}`, from, condition: opts.condition, name: opts.name };
365
+ const e = {
366
+ id: `f_${this.seq++}`,
367
+ from,
368
+ condition: opts.condition,
369
+ name: opts.name,
370
+ scope: this.currentScope(),
371
+ };
270
372
  this.edges.push(e);
271
373
  return e;
272
374
  }
@@ -286,7 +388,7 @@ class Compiler {
286
388
  }
287
389
  this.envelopes.set(env.name, env.fields);
288
390
  }
289
- addServiceTask(node) {
391
+ addServiceTask(node, mi = "") {
290
392
  const type = node.jobType ?? jobType(this.flow.id, node.name);
291
393
  this.recordEnvelope(node.envelopes?.in);
292
394
  this.recordEnvelope(node.envelopes?.out);
@@ -302,10 +404,12 @@ class Compiler {
302
404
  const id = node.name;
303
405
  this.nodes.push({
304
406
  id,
407
+ scope: this.currentScope(),
305
408
  render: (inc, outg) => ` <bpmn:serviceTask id="${escapeXml(id)}" name="${escapeXml(id)}">\n` +
306
409
  ext +
307
410
  "\n" +
308
411
  incomingOutgoing(inc, outg) +
412
+ mi +
309
413
  ` </bpmn:serviceTask>`,
310
414
  });
311
415
  }
@@ -314,6 +418,7 @@ class Compiler {
314
418
  const msgId = `Msg_${id}`;
315
419
  this.nodes.push({
316
420
  id,
421
+ scope: this.currentScope(),
317
422
  render: (inc, outg) => ` <bpmn:intermediateCatchEvent id="${escapeXml(id)}" name="${escapeXml(id)}">\n` +
318
423
  incomingOutgoing(inc, outg) +
319
424
  ` <bpmn:messageEventDefinition messageRef="${msgId}" />\n` +
@@ -327,6 +432,7 @@ class Compiler {
327
432
  : ` <bpmn:timeDate>${escapeXml(node.at)}</bpmn:timeDate>\n`;
328
433
  this.nodes.push({
329
434
  id,
435
+ scope: this.currentScope(),
330
436
  render: (inc, outg) => ` <bpmn:intermediateCatchEvent id="${escapeXml(id)}" name="${escapeXml(id)}">\n` +
331
437
  incomingOutgoing(inc, outg) +
332
438
  ` <bpmn:timerEventDefinition>\n` +
@@ -338,6 +444,7 @@ class Compiler {
338
444
  addGateway(id, name) {
339
445
  const gwNode = {
340
446
  id,
447
+ scope: this.currentScope(),
341
448
  render: (inc, outg) => {
342
449
  const def = gwNode.defaultFlow ? ` default="${gwNode.defaultFlow}"` : "";
343
450
  const nm = name ? ` name="${escapeXml(name)}"` : "";
@@ -349,6 +456,66 @@ class Compiler {
349
456
  this.nodes.push(gwNode);
350
457
  return gwNode;
351
458
  }
459
+ addParallelGateway(id) {
460
+ const node = {
461
+ id,
462
+ scope: this.currentScope(),
463
+ render: (inc, outg) => ` <bpmn:parallelGateway id="${id}">\n` + incomingOutgoing(inc, outg) + ` </bpmn:parallelGateway>`,
464
+ };
465
+ this.nodes.push(node);
466
+ return node;
467
+ }
468
+ addPlainStart(id) {
469
+ this.nodes.push({
470
+ id,
471
+ scope: this.currentScope(),
472
+ render: (_inc, outg) => ` <bpmn:startEvent id="${escapeXml(id)}">${outgoingOnly(outg)}</bpmn:startEvent>`,
473
+ });
474
+ }
475
+ addPlainEnd(id) {
476
+ this.nodes.push({
477
+ id,
478
+ scope: this.currentScope(),
479
+ render: (inc) => ` <bpmn:endEvent id="${escapeXml(id)}">${incomingOnly(inc)}</bpmn:endEvent>`,
480
+ });
481
+ }
482
+ /** An embedded sub-process whose body renders nested in its own scope, with the
483
+ * given multi-instance characteristics lifted onto it. */
484
+ addSubProcess(id, mi) {
485
+ const node = {
486
+ id,
487
+ scope: this.currentScope(),
488
+ render: (inc, outg) => ` <bpmn:subProcess id="${escapeXml(id)}">\n` +
489
+ incomingOutgoing(inc, outg) +
490
+ mi +
491
+ this.renderScope(id) +
492
+ `\n </bpmn:subProcess>`,
493
+ };
494
+ this.nodes.push(node);
495
+ return node;
496
+ }
497
+ /** The `<bpmn:multiInstanceLoopCharacteristics>` block for a `forEach` node —
498
+ * the same shape whether lifted onto a leaf service task or a sub-process. */
499
+ miCharacteristics(node) {
500
+ const seq = node.sequential ? "true" : "false";
501
+ const attrs = [
502
+ `inputCollection="${escapeXml(`=${node.collection}`)}"`,
503
+ `inputElement="${escapeXml(node.itemVar)}"`,
504
+ ];
505
+ if (node.outputCollection)
506
+ attrs.push(`outputCollection="${escapeXml(node.outputCollection)}"`);
507
+ if (node.outputElement)
508
+ attrs.push(`outputElement="${escapeXml(`=${node.outputElement}`)}"`);
509
+ const completion = node.completionCondition
510
+ ? ` <bpmn:completionCondition>${escapeXml(`=${node.completionCondition}`)}</bpmn:completionCondition>\n`
511
+ : "";
512
+ return (` <bpmn:multiInstanceLoopCharacteristics isSequential="${seq}">\n` +
513
+ ` <bpmn:extensionElements>\n` +
514
+ ` <zeebe:loopCharacteristics ${attrs.join(" ")} />\n` +
515
+ ` </bpmn:extensionElements>\n` +
516
+ completion +
517
+ ` </bpmn:multiInstanceLoopCharacteristics>\n`);
518
+ }
352
519
  emitList(list, incoming, loop) {
353
520
  let cur = incoming;
354
521
  for (const node of list)
@@ -426,8 +593,63 @@ class Compiler {
426
593
  this.connect(incoming, loop.headId);
427
594
  return [];
428
595
  }
596
+ case "parallel": {
597
+ // A diverging parallel gateway forks a token onto every branch; a
598
+ // converging one joins them (AND-join) into a single continuation.
599
+ const splitId = `Gw_${this.gw++}`;
600
+ this.addParallelGateway(splitId);
601
+ this.connect(incoming, splitId);
602
+ const joinId = `Gw_${this.gw++}`;
603
+ this.addParallelGateway(joinId);
604
+ for (const branch of node.branches) {
605
+ const e = this.newEdge(splitId);
606
+ const branchOut = this.emitList(branch, [e], loop);
607
+ this.connect(branchOut, joinId);
608
+ }
609
+ return [this.newEdge(joinId)];
610
+ }
611
+ case "forEach": {
612
+ const mi = this.miCharacteristics(node);
613
+ const only = node.body.length === 1 ? node.body[0] : undefined;
614
+ // A single service-task body carries the MI characteristics directly; a
615
+ // multi-step body is wrapped in an embedded MI sub-process (its own token
616
+ // scope, with its own start/end).
617
+ if (only && (only.kind === "run" || only.kind === "task")) {
618
+ this.addServiceTask(only, mi);
619
+ this.connect(incoming, only.name);
620
+ return [this.newEdge(only.name)];
621
+ }
622
+ const subId = `Sub_${this.gw++}`;
623
+ this.addSubProcess(subId, mi);
624
+ this.connect(incoming, subId);
625
+ this.scopeStack.push(subId);
626
+ const startId = `${subId}_start`;
627
+ this.addPlainStart(startId);
628
+ const innerOut = this.emitList(node.body, [this.newEdge(startId)], null);
629
+ const endId = `${subId}_end`;
630
+ this.addPlainEnd(endId);
631
+ this.connect(innerOut, endId);
632
+ this.scopeStack.pop();
633
+ return [this.newEdge(subId)];
634
+ }
429
635
  }
430
636
  }
637
+ /** Render every node + sequence flow that belongs to `scope` (undefined = the
638
+ * root process, a sub-process id otherwise). Called for the process body and,
639
+ * recursively, from each sub-process's render closure. */
640
+ renderScope(scope) {
641
+ const incomingOf = (id) => this.live.filter((e) => e.to === id).map((e) => e.id);
642
+ const outgoingOf = (id) => this.live.filter((e) => e.from === id).map((e) => e.id);
643
+ const nodeXml = this.nodes
644
+ .filter((n) => n.scope === scope)
645
+ .map((n) => n.render(incomingOf(n.id), outgoingOf(n.id)))
646
+ .join("\n");
647
+ const flowXml = this.live
648
+ .filter((e) => e.scope === scope)
649
+ .map((e) => sequenceFlow(e))
650
+ .join("\n");
651
+ return nodeXml + (nodeXml && flowXml ? "\n" : "") + flowXml;
652
+ }
431
653
  /** Render the Start event — a plain none start, or a timer start when the
432
654
  * flow declared `startOn(...)`. */
433
655
  renderStart(outg) {
@@ -460,11 +682,8 @@ class Compiler {
460
682
  });
461
683
  this.connect(finalDanglers, "End");
462
684
  // A sequence flow needs both ends; drop any edge that never got a target.
463
- const live = this.edges.filter((e) => e.to !== undefined);
464
- const incomingOf = (id) => live.filter((e) => e.to === id).map((e) => e.id);
465
- const outgoingOf = (id) => live.filter((e) => e.from === id).map((e) => e.id);
466
- const nodeXml = this.nodes.map((n) => n.render(incomingOf(n.id), outgoingOf(n.id))).join("\n");
467
- const flowXml = live.map((e) => sequenceFlow(e)).join("\n");
685
+ this.live = this.edges.filter((e) => e.to !== undefined);
686
+ const bodyXml = this.renderScope(undefined);
468
687
  const messageXml = this.emitMessages();
469
688
  const shapeXml = this.emitShapes();
470
689
  return (`<?xml version="1.0" encoding="UTF-8"?>\n` +
@@ -474,9 +693,7 @@ class Compiler {
474
693
  `id="Definitions_${escapeXml(this.flow.id)}" targetNamespace="http://bpmn.io/schema/bpmn">\n` +
475
694
  ` <bpmn:process id="${escapeXml(this.flow.id)}" name="${escapeXml(this.flow.id)}" isExecutable="true">\n` +
476
695
  (shapeXml ? shapeXml + "\n" : "") +
477
- nodeXml +
478
- "\n" +
479
- flowXml +
696
+ bodyXml +
480
697
  "\n" +
481
698
  ` </bpmn:process>\n` +
482
699
  messageXml +
package/dist/types.d.ts CHANGED
@@ -72,6 +72,24 @@ export type FlowNode = {
72
72
  kind: "break";
73
73
  } | {
74
74
  kind: "continue";
75
+ } | {
76
+ kind: "parallel";
77
+ branches: FlowNode[][];
78
+ } | {
79
+ kind: "forEach";
80
+ /** FEEL expression evaluating to the list to fan out over. */
81
+ collection: string;
82
+ /** Variable each item is bound to in the child's scope (`inputElement`). */
83
+ itemVar: string;
84
+ body: FlowNode[];
85
+ /** Run children one at a time (sequential MI) instead of all at once. */
86
+ sequential?: boolean;
87
+ /** List variable that each child's `outputElement` is collected into. */
88
+ outputCollection?: string;
89
+ /** FEEL expression producing each child's contribution to `outputCollection`. */
90
+ outputElement?: string;
91
+ /** FEEL boolean that, once true, completes the body early. */
92
+ completionCondition?: string;
75
93
  };
76
94
  /** A timer intermediate-catch definition: exactly one of `after` (an ISO-8601
77
95
  * delay or FEEL expression) or `at` (an absolute instant or FEEL expression).
package/dist/worker.js CHANGED
@@ -60,8 +60,9 @@ export class Worker {
60
60
  else {
61
61
  // Walk the flow tree; only `run` steps are hosted locally. `signal`
62
62
  // (message catch) and `task` (external worker) contribute no in-process
63
- // route, and structural combinators (`switch`/`branch`/`loop`) are pure
64
- // routing with no job of their own.
63
+ // route, and structural combinators (`switch`/`branch`/`loop`/`parallel`/
64
+ // `forEach`) are pure routing with no job of their own — but the `run`
65
+ // steps NESTED inside them are hosted, since `walkNodes` recurses.
65
66
  walkNodes(wf.steps, (s) => {
66
67
  if (s.kind !== "run")
67
68
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/workflow",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Code-first durable orchestration for nanobpmn (ADR 0044/0045). Author a durable workflow as a declarative flow — `defineFlow(id, w => { w.run(...); w.signal(...); w.task(...) })` — and the SDK derives the executable BPMN model, the job types, and the message/correlation wiring, then hosts a generic worker. `w.run` is a local step, `w.signal` is a durable human-in-the-loop wait, `w.task` is a step serviced by an external worker. `defineFlow` is the single code-first surface; the imperative `defineWorkflow` replay function is experimental/internal, not a recommended API. No diagram, no task-type wiring, no correlation plumbing authored by hand — and you can optionally auto-generate a diagram (DI) for the derived model with `declarativeToLayoutedBpmn` via the optional `bpmn-auto-layout` peer dependency. Talks to a running nanobpmn gateway through @nanobpm/nano-sdk (the shared engine-transport spine — ADR 0055), which negotiates the Falcon protocol on a Nano server and REST elsewhere.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -28,11 +28,11 @@
28
28
  },
29
29
  "sideEffects": false,
30
30
  "dependencies": {
31
- "@nanobpm/nano-sdk": "^1.2.0"
31
+ "@nanobpm/nano-sdk": "^1.2.3"
32
32
  },
33
33
  "scripts": {
34
34
  "build": "tsc -p tsconfig.json",
35
- "typecheck": "tsc -p tsconfig.json --noEmit",
35
+ "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.types.json",
36
36
  "test": "npm run build && npm run test:ci",
37
37
  "test:ci": "node --experimental-strip-types --test test/*.test.ts",
38
38
  "test:integration": "node --experimental-strip-types --test test/integration.test.ts",
@@ -3,7 +3,9 @@
3
3
  // (`switch`/`branch`/`loop`) that compile to exclusive gateways + back-edges the
4
4
  // engine already runs (engine-core supports XOR gateways with in-order condition
5
5
  // evaluation and a default flow, and tasks with multiple incoming flows act as
6
- // an implicit XOR merge).
6
+ // an implicit XOR merge), plus concurrency combinators — `parallel` (an AND
7
+ // fork/join over parallel gateways) and `forEach` (a data-driven fan-out lowering
8
+ // to a parallel/sequential multi-instance activity or sub-process).
7
9
  //
8
10
  // const convergence = defineFlow(
9
11
  // "convergence-loop",
@@ -71,9 +73,20 @@ type TypedHandler<V, R> = (job: {
71
73
  }) => Promise<R | void> | R | void;
72
74
 
73
75
  /** A block: the callback that populates a nested body (a case, arm, or loop). */
74
- type Block<C extends FlowContracts> = (b: FlowBuilder<C>) => void;
75
-
76
- export interface FlowBuilder<C extends FlowContracts = Record<string, never>> {
76
+ type Block<C extends object> = (b: FlowBuilder<C>) => void;
77
+
78
+ /** The typed builder for a flow body.
79
+ *
80
+ * `C` is the flow's contracts map (step name → `{ in, out }` envelopes). The
81
+ * default is `object`, NOT `Record<string, never>`: with a `never`-valued record
82
+ * `keyof C` is `string`, so `VarsOf`/`ResultOf` route EVERY step name into
83
+ * `InPayload<never>`/`OutPayload<never>`, which distribute over `never` to
84
+ * `never` — typing an untyped flow's `job.variables` as `never` and forcing its
85
+ * handler return to `void` (so no untyped flow that returns data compiles). With
86
+ * `object`, `keyof C` is `never`, so every step falls back to the intended
87
+ * `JsonObject`. The public `defineFlow<C extends FlowContracts>` overload keeps
88
+ * the stricter contracts constraint; this is only the untyped-builder default. */
89
+ export interface FlowBuilder<C extends object = object> {
77
90
  /**
78
91
  * A durable activity served by a worker THIS program hosts (a BPMN service
79
92
  * task; the handler runs in the in-process `Worker`). If `name` is a key in
@@ -140,6 +153,41 @@ export interface FlowBuilder<C extends FlowContracts = Record<string, never>> {
140
153
  * the loop run once `break()` is reached.
141
154
  */
142
155
  loop(body: Block<C>): FlowBuilder<C>;
156
+ /**
157
+ * A static parallel fork/join (a pair of BPMN parallel gateways). Every block
158
+ * runs concurrently on its own branch; control continues past `parallel` only
159
+ * once ALL branches have reached the joining gateway (AND-join). Pass at least
160
+ * two branch blocks. Unlike `switch`/`branch` (exclusive choice), no branch is
161
+ * conditional — all of them run.
162
+ */
163
+ parallel(branches: Block<C>[]): FlowBuilder<C>;
164
+ /**
165
+ * A data-driven fan-out over a runtime collection (a BPMN parallel
166
+ * multi-instance activity). `collection` is a FEEL expression (no leading `=`)
167
+ * evaluating to a list; one child instance of `body` runs per item, with the
168
+ * item bound to the `itemVar` variable in the child's scope. A single-activity
169
+ * body attaches the multi-instance characteristics to that activity; a
170
+ * multi-step body is wrapped in an embedded multi-instance sub-process. Options:
171
+ * - `sequential` — run children one at a time (a sequential MI) instead of all
172
+ * at once (the default: a parallel MI).
173
+ * - `outputCollection` / `outputElement` — collect each child's `outputElement`
174
+ * (a FEEL expression, no leading `=`) into the `outputCollection` list
175
+ * variable, in item order.
176
+ * - `completionCondition` — a FEEL boolean (no leading `=`); when it holds
177
+ * after a child completes, the remaining children are cancelled and the body
178
+ * completes early.
179
+ */
180
+ forEach(
181
+ collection: string,
182
+ itemVar: string,
183
+ body: Block<C>,
184
+ opts?: {
185
+ sequential?: boolean;
186
+ outputCollection?: string;
187
+ outputElement?: string;
188
+ completionCondition?: string;
189
+ },
190
+ ): FlowBuilder<C>;
143
191
  /** Exit the enclosing loop (routes to whatever follows it). Only valid inside
144
192
  * a `loop`. */
145
193
  break(): FlowBuilder<C>;
@@ -160,7 +208,7 @@ interface BuilderCtx {
160
208
  /** Ids the emitter generates for structural nodes / flows / messages. A step
161
209
  * name that collides with one of these would produce a duplicate BPMN id and an
162
210
  * invalid model, so reject them at authoring time. */
163
- const RESERVED_PREFIXES = /^(Gw_|Loop_|Msg_|f_)/;
211
+ const RESERVED_PREFIXES = /^(Gw_|Loop_|Sub_|Msg_|f_)/;
164
212
 
165
213
  function claimName(ctx: BuilderCtx, id: string, name: string): void {
166
214
  assertIdent("step name", name);
@@ -169,7 +217,7 @@ function claimName(ctx: BuilderCtx, id: string, name: string): void {
169
217
  }
170
218
  if (RESERVED_PREFIXES.test(name)) {
171
219
  throw new Error(
172
- `step name "${name}" uses a reserved prefix (Gw_/Loop_/Msg_/f_ are generated ids) in flow "${id}"`,
220
+ `step name "${name}" uses a reserved prefix (Gw_/Loop_/Sub_/Msg_/f_ are generated ids) in flow "${id}"`,
173
221
  );
174
222
  }
175
223
  if (ctx.seen.has(name)) throw new Error(`duplicate step name "${name}" in flow "${id}"`);
@@ -186,7 +234,7 @@ function contractEnvelopes(ctx: BuilderCtx, name: string): { in?: Envelope; out?
186
234
  /** Build a FlowBuilder that appends its nodes to `out`, sharing the flow-wide
187
235
  * `ctx` (contracts, handler registry, name set, loop nesting). Structural
188
236
  * combinators recurse with a fresh `out` array for each nested body. */
189
- function makeBuilder<C extends FlowContracts>(
237
+ function makeBuilder<C extends object>(
190
238
  id: string,
191
239
  out: FlowNode[],
192
240
  ctx: BuilderCtx,
@@ -198,6 +246,15 @@ function makeBuilder<C extends FlowContracts>(
198
246
  fn(makeBuilder<C>(id, body, { ...ctx, loopDepth: depth }));
199
247
  return body;
200
248
  };
249
+ // A body that runs in its OWN token scope (an embedded sub-process): the
250
+ // enclosing loop is not reachable across the scope boundary, so `break`/
251
+ // `continue` inside it are rejected (loopDepth resets to 0). A `loop` declared
252
+ // INSIDE the body still works — it creates its own scope.
253
+ const childScoped = (fn: Block<C>): FlowNode[] => {
254
+ const body: FlowNode[] = [];
255
+ fn(makeBuilder<C>(id, body, { ...ctx, loopDepth: 0 }));
256
+ return body;
257
+ };
201
258
  const b = {
202
259
  run(name: string, handler: StepHandler): FlowBuilder<C> {
203
260
  claimName(ctx, id, name);
@@ -292,6 +349,81 @@ function makeBuilder<C extends FlowContracts>(
292
349
  out.push({ kind: "loop", body: child(body, true) });
293
350
  return b as unknown as FlowBuilder<C>;
294
351
  },
352
+ parallel(branches: Block<C>[]): FlowBuilder<C> {
353
+ if (!Array.isArray(branches) || branches.length < 2) {
354
+ throw new Error(`parallel() needs at least two branch blocks`);
355
+ }
356
+ for (const fn of branches) {
357
+ if (typeof fn !== "function") throw new Error(`parallel() branches must all be blocks (b) => {…}`);
358
+ }
359
+ // Each branch runs in its own token scope up to the AND-join, so a
360
+ // `break`/`continue` must not escape the fork/join into an enclosing
361
+ // loop. Scope branches (loopDepth resets to 0) so cross-boundary
362
+ // break/continue is rejected at build time, matching forEach.
363
+ out.push({ kind: "parallel", branches: branches.map((fn) => childScoped(fn)) });
364
+ return b as unknown as FlowBuilder<C>;
365
+ },
366
+ forEach(
367
+ collection: string,
368
+ itemVar: string,
369
+ body: Block<C>,
370
+ opts?: {
371
+ sequential?: boolean;
372
+ outputCollection?: string;
373
+ outputElement?: string;
374
+ completionCondition?: string;
375
+ },
376
+ ): FlowBuilder<C> {
377
+ if (typeof collection !== "string" || collection.trim() === "") {
378
+ throw new Error(`forEach() needs a non-empty FEEL collection expression`);
379
+ }
380
+ // Authors may paste a leading "=" (common in Zeebe FEEL examples). Strip
381
+ // it so the emitter's own "=" prefix never produces an invalid "==...".
382
+ const collExpr = collection.trim().replace(/^=/, "").trim();
383
+ if (collExpr === "") {
384
+ throw new Error(`forEach() collection expression is empty after stripping the leading "="`);
385
+ }
386
+ assertIdent("forEach itemVar", itemVar);
387
+ if (typeof body !== "function") throw new Error(`forEach("${itemVar}") needs a body function`);
388
+ const nodes = childScoped(body);
389
+ if (nodes.length === 0) throw new Error(`forEach("${itemVar}") body declared no steps`);
390
+ const node: Extract<FlowNode, { kind: "forEach" }> = {
391
+ kind: "forEach",
392
+ collection: collExpr,
393
+ itemVar,
394
+ body: nodes,
395
+ };
396
+ if (opts?.sequential) node.sequential = true;
397
+ if (opts?.outputCollection !== undefined) {
398
+ assertIdent("forEach outputCollection", opts.outputCollection);
399
+ node.outputCollection = opts.outputCollection;
400
+ }
401
+ if (opts?.outputElement !== undefined) {
402
+ if (typeof opts.outputElement !== "string" || opts.outputElement.trim() === "") {
403
+ throw new Error(`forEach("${itemVar}") outputElement must be a non-empty FEEL expression`);
404
+ }
405
+ const outEl = opts.outputElement.trim().replace(/^=/, "").trim();
406
+ if (outEl === "") {
407
+ throw new Error(`forEach("${itemVar}") outputElement is empty after stripping the leading "="`);
408
+ }
409
+ node.outputElement = outEl;
410
+ }
411
+ if (opts?.completionCondition !== undefined) {
412
+ if (typeof opts.completionCondition !== "string" || opts.completionCondition.trim() === "") {
413
+ throw new Error(`forEach("${itemVar}") completionCondition must be a non-empty FEEL expression`);
414
+ }
415
+ const compCond = opts.completionCondition.trim().replace(/^=/, "").trim();
416
+ if (compCond === "") {
417
+ throw new Error(`forEach("${itemVar}") completionCondition is empty after stripping the leading "="`);
418
+ }
419
+ node.completionCondition = compCond;
420
+ }
421
+ if (node.outputElement && !node.outputCollection) {
422
+ throw new Error(`forEach("${itemVar}") outputElement needs an outputCollection to collect into`);
423
+ }
424
+ out.push(node);
425
+ return b as unknown as FlowBuilder<C>;
426
+ },
295
427
  break(): FlowBuilder<C> {
296
428
  if (ctx.loopDepth === 0) throw new Error(`break() is only valid inside a loop`);
297
429
  out.push({ kind: "break" });
@@ -360,6 +492,12 @@ export function walkNodes(nodes: FlowNode[], visit: (n: FlowNode) => void): void
360
492
  case "loop":
361
493
  walkNodes(n.body, visit);
362
494
  break;
495
+ case "parallel":
496
+ for (const branch of n.branches) walkNodes(branch, visit);
497
+ break;
498
+ case "forEach":
499
+ walkNodes(n.body, visit);
500
+ break;
363
501
  default:
364
502
  break;
365
503
  }
@@ -391,6 +529,9 @@ interface RenderNode {
391
529
  render(incoming: string[], outgoing: string[]): string;
392
530
  /** For exclusive gateways: the flow id of the unconditional default edge. */
393
531
  defaultFlow?: string;
532
+ /** Id of the embedded sub-process this node lives in, or undefined at the
533
+ * top level (the root process). */
534
+ scope?: string;
394
535
  }
395
536
 
396
537
  interface Edge {
@@ -399,6 +540,9 @@ interface Edge {
399
540
  to?: string;
400
541
  condition?: string;
401
542
  name?: string;
543
+ /** The scope (sub-process id, or undefined = root process) this flow is
544
+ * nested in — so it renders inside the right container. */
545
+ scope?: string;
402
546
  }
403
547
 
404
548
  interface LoopCtx {
@@ -411,13 +555,29 @@ class Compiler {
411
555
  private readonly edges: Edge[] = [];
412
556
  /** envelope name → its fields, deduped for lifting to a single nano:shape. */
413
557
  private readonly envelopes = new Map<string, EnvelopeField[]>();
558
+ /** The stack of open sub-process scopes; `.at(-1)` is the current scope (a
559
+ * node/edge created now nests inside it). Empty at the top level. */
560
+ private readonly scopeStack: string[] = [];
561
+ /** Edges with a resolved target, indexed in `compile()` before rendering so a
562
+ * sub-process can render its own nested scope. */
563
+ private live: Edge[] = [];
414
564
  private seq = 0;
415
565
  private gw = 0;
416
566
 
417
567
  constructor(private readonly flow: DeclarativeFlow) {}
418
568
 
569
+ private currentScope(): string | undefined {
570
+ return this.scopeStack.length ? this.scopeStack[this.scopeStack.length - 1] : undefined;
571
+ }
572
+
419
573
  private newEdge(from: string, opts: { condition?: string; name?: string } = {}): Edge {
420
- const e: Edge = { id: `f_${this.seq++}`, from, condition: opts.condition, name: opts.name };
574
+ const e: Edge = {
575
+ id: `f_${this.seq++}`,
576
+ from,
577
+ condition: opts.condition,
578
+ name: opts.name,
579
+ scope: this.currentScope(),
580
+ };
421
581
  this.edges.push(e);
422
582
  return e;
423
583
  }
@@ -440,7 +600,7 @@ class Compiler {
440
600
  this.envelopes.set(env.name, env.fields);
441
601
  }
442
602
 
443
- private addServiceTask(node: { name: string; envelopes?: NodeEnvelopes; jobType?: string }): void {
603
+ private addServiceTask(node: { name: string; envelopes?: NodeEnvelopes; jobType?: string }, mi = ""): void {
444
604
  const type = node.jobType ?? jobType(this.flow.id, node.name);
445
605
  this.recordEnvelope(node.envelopes?.in);
446
606
  this.recordEnvelope(node.envelopes?.out);
@@ -455,11 +615,13 @@ class Compiler {
455
615
  const id = node.name;
456
616
  this.nodes.push({
457
617
  id,
618
+ scope: this.currentScope(),
458
619
  render: (inc, outg) =>
459
620
  ` <bpmn:serviceTask id="${escapeXml(id)}" name="${escapeXml(id)}">\n` +
460
621
  ext +
461
622
  "\n" +
462
623
  incomingOutgoing(inc, outg) +
624
+ mi +
463
625
  ` </bpmn:serviceTask>`,
464
626
  });
465
627
  }
@@ -469,6 +631,7 @@ class Compiler {
469
631
  const msgId = `Msg_${id}`;
470
632
  this.nodes.push({
471
633
  id,
634
+ scope: this.currentScope(),
472
635
  render: (inc, outg) =>
473
636
  ` <bpmn:intermediateCatchEvent id="${escapeXml(id)}" name="${escapeXml(id)}">\n` +
474
637
  incomingOutgoing(inc, outg) +
@@ -485,6 +648,7 @@ class Compiler {
485
648
  : ` <bpmn:timeDate>${escapeXml(node.at as string)}</bpmn:timeDate>\n`;
486
649
  this.nodes.push({
487
650
  id,
651
+ scope: this.currentScope(),
488
652
  render: (inc, outg) =>
489
653
  ` <bpmn:intermediateCatchEvent id="${escapeXml(id)}" name="${escapeXml(id)}">\n` +
490
654
  incomingOutgoing(inc, outg) +
@@ -498,6 +662,7 @@ class Compiler {
498
662
  private addGateway(id: string, name?: string): RenderNode {
499
663
  const gwNode: RenderNode = {
500
664
  id,
665
+ scope: this.currentScope(),
501
666
  render: (inc, outg) => {
502
667
  const def = gwNode.defaultFlow ? ` default="${gwNode.defaultFlow}"` : "";
503
668
  const nm = name ? ` name="${escapeXml(name)}"` : "";
@@ -512,6 +677,73 @@ class Compiler {
512
677
  return gwNode;
513
678
  }
514
679
 
680
+ private addParallelGateway(id: string): RenderNode {
681
+ const node: RenderNode = {
682
+ id,
683
+ scope: this.currentScope(),
684
+ render: (inc, outg) =>
685
+ ` <bpmn:parallelGateway id="${id}">\n` + incomingOutgoing(inc, outg) + ` </bpmn:parallelGateway>`,
686
+ };
687
+ this.nodes.push(node);
688
+ return node;
689
+ }
690
+
691
+ private addPlainStart(id: string): void {
692
+ this.nodes.push({
693
+ id,
694
+ scope: this.currentScope(),
695
+ render: (_inc, outg) => ` <bpmn:startEvent id="${escapeXml(id)}">${outgoingOnly(outg)}</bpmn:startEvent>`,
696
+ });
697
+ }
698
+
699
+ private addPlainEnd(id: string): void {
700
+ this.nodes.push({
701
+ id,
702
+ scope: this.currentScope(),
703
+ render: (inc) => ` <bpmn:endEvent id="${escapeXml(id)}">${incomingOnly(inc)}</bpmn:endEvent>`,
704
+ });
705
+ }
706
+
707
+ /** An embedded sub-process whose body renders nested in its own scope, with the
708
+ * given multi-instance characteristics lifted onto it. */
709
+ private addSubProcess(id: string, mi: string): RenderNode {
710
+ const node: RenderNode = {
711
+ id,
712
+ scope: this.currentScope(),
713
+ render: (inc, outg) =>
714
+ ` <bpmn:subProcess id="${escapeXml(id)}">\n` +
715
+ incomingOutgoing(inc, outg) +
716
+ mi +
717
+ this.renderScope(id) +
718
+ `\n </bpmn:subProcess>`,
719
+ };
720
+ this.nodes.push(node);
721
+ return node;
722
+ }
723
+
724
+ /** The `<bpmn:multiInstanceLoopCharacteristics>` block for a `forEach` node —
725
+ * the same shape whether lifted onto a leaf service task or a sub-process. */
726
+ private miCharacteristics(node: Extract<FlowNode, { kind: "forEach" }>): string {
727
+ const seq = node.sequential ? "true" : "false";
728
+ const attrs = [
729
+ `inputCollection="${escapeXml(`=${node.collection}`)}"`,
730
+ `inputElement="${escapeXml(node.itemVar)}"`,
731
+ ];
732
+ if (node.outputCollection) attrs.push(`outputCollection="${escapeXml(node.outputCollection)}"`);
733
+ if (node.outputElement) attrs.push(`outputElement="${escapeXml(`=${node.outputElement}`)}"`);
734
+ const completion = node.completionCondition
735
+ ? ` <bpmn:completionCondition>${escapeXml(`=${node.completionCondition}`)}</bpmn:completionCondition>\n`
736
+ : "";
737
+ return (
738
+ ` <bpmn:multiInstanceLoopCharacteristics isSequential="${seq}">\n` +
739
+ ` <bpmn:extensionElements>\n` +
740
+ ` <zeebe:loopCharacteristics ${attrs.join(" ")} />\n` +
741
+ ` </bpmn:extensionElements>\n` +
742
+ completion +
743
+ ` </bpmn:multiInstanceLoopCharacteristics>\n`
744
+ );
745
+ }
746
+
515
747
  private emitList(list: FlowNode[], incoming: Edge[], loop: LoopCtx | null): Edge[] {
516
748
  let cur = incoming;
517
749
  for (const node of list) cur = this.emitNode(node, cur, loop);
@@ -587,9 +819,65 @@ class Compiler {
587
819
  this.connect(incoming, loop.headId);
588
820
  return [];
589
821
  }
822
+ case "parallel": {
823
+ // A diverging parallel gateway forks a token onto every branch; a
824
+ // converging one joins them (AND-join) into a single continuation.
825
+ const splitId = `Gw_${this.gw++}`;
826
+ this.addParallelGateway(splitId);
827
+ this.connect(incoming, splitId);
828
+ const joinId = `Gw_${this.gw++}`;
829
+ this.addParallelGateway(joinId);
830
+ for (const branch of node.branches) {
831
+ const e = this.newEdge(splitId);
832
+ const branchOut = this.emitList(branch, [e], loop);
833
+ this.connect(branchOut, joinId);
834
+ }
835
+ return [this.newEdge(joinId)];
836
+ }
837
+ case "forEach": {
838
+ const mi = this.miCharacteristics(node);
839
+ const only = node.body.length === 1 ? node.body[0] : undefined;
840
+ // A single service-task body carries the MI characteristics directly; a
841
+ // multi-step body is wrapped in an embedded MI sub-process (its own token
842
+ // scope, with its own start/end).
843
+ if (only && (only.kind === "run" || only.kind === "task")) {
844
+ this.addServiceTask(only, mi);
845
+ this.connect(incoming, only.name);
846
+ return [this.newEdge(only.name)];
847
+ }
848
+ const subId = `Sub_${this.gw++}`;
849
+ this.addSubProcess(subId, mi);
850
+ this.connect(incoming, subId);
851
+ this.scopeStack.push(subId);
852
+ const startId = `${subId}_start`;
853
+ this.addPlainStart(startId);
854
+ const innerOut = this.emitList(node.body, [this.newEdge(startId)], null);
855
+ const endId = `${subId}_end`;
856
+ this.addPlainEnd(endId);
857
+ this.connect(innerOut, endId);
858
+ this.scopeStack.pop();
859
+ return [this.newEdge(subId)];
860
+ }
590
861
  }
591
862
  }
592
863
 
864
+ /** Render every node + sequence flow that belongs to `scope` (undefined = the
865
+ * root process, a sub-process id otherwise). Called for the process body and,
866
+ * recursively, from each sub-process's render closure. */
867
+ private renderScope(scope: string | undefined): string {
868
+ const incomingOf = (id: string) => this.live.filter((e) => e.to === id).map((e) => e.id);
869
+ const outgoingOf = (id: string) => this.live.filter((e) => e.from === id).map((e) => e.id);
870
+ const nodeXml = this.nodes
871
+ .filter((n) => n.scope === scope)
872
+ .map((n) => n.render(incomingOf(n.id), outgoingOf(n.id)))
873
+ .join("\n");
874
+ const flowXml = this.live
875
+ .filter((e) => e.scope === scope)
876
+ .map((e) => sequenceFlow(e))
877
+ .join("\n");
878
+ return nodeXml + (nodeXml && flowXml ? "\n" : "") + flowXml;
879
+ }
880
+
593
881
  /** Render the Start event — a plain none start, or a timer start when the
594
882
  * flow declared `startOn(...)`. */
595
883
  private renderStart(outg: string[]): string {
@@ -626,12 +914,9 @@ class Compiler {
626
914
  this.connect(finalDanglers, "End");
627
915
 
628
916
  // A sequence flow needs both ends; drop any edge that never got a target.
629
- const live = this.edges.filter((e) => e.to !== undefined);
630
- const incomingOf = (id: string) => live.filter((e) => e.to === id).map((e) => e.id);
631
- const outgoingOf = (id: string) => live.filter((e) => e.from === id).map((e) => e.id);
917
+ this.live = this.edges.filter((e) => e.to !== undefined);
632
918
 
633
- const nodeXml = this.nodes.map((n) => n.render(incomingOf(n.id), outgoingOf(n.id))).join("\n");
634
- const flowXml = live.map((e) => sequenceFlow(e)).join("\n");
919
+ const bodyXml = this.renderScope(undefined);
635
920
  const messageXml = this.emitMessages();
636
921
  const shapeXml = this.emitShapes();
637
922
 
@@ -643,9 +928,7 @@ class Compiler {
643
928
  `id="Definitions_${escapeXml(this.flow.id)}" targetNamespace="http://bpmn.io/schema/bpmn">\n` +
644
929
  ` <bpmn:process id="${escapeXml(this.flow.id)}" name="${escapeXml(this.flow.id)}" isExecutable="true">\n` +
645
930
  (shapeXml ? shapeXml + "\n" : "") +
646
- nodeXml +
647
- "\n" +
648
- flowXml +
931
+ bodyXml +
649
932
  "\n" +
650
933
  ` </bpmn:process>\n` +
651
934
  messageXml +
package/src/types.ts CHANGED
@@ -21,8 +21,9 @@ export interface Job<V extends JsonObject = JsonObject> {
21
21
  // --- Declarative surface (Strategy A: compile a step tree to a BPMN model) ----
22
22
  //
23
23
  // A flow is a TREE of nodes, not a flat list: leaf activities (`run`/`task`/
24
- // `signal`) plus structural combinators (`switch`/`branch`/`loop`) that compile
25
- // to real XOR gateways and back-edges. See declarative.ts for the compiler.
24
+ // `signal`) plus structural combinators (`switch`/`branch`/`loop` — XOR gateways
25
+ // + back-edges — and `parallel`/`forEach` — parallel gateways + multi-instance)
26
+ // that compile to real BPMN the engine runs. See declarative.ts for the compiler.
26
27
 
27
28
  /** The input/output data envelopes lifted onto an activity node. */
28
29
  export interface NodeEnvelopes {
@@ -58,7 +59,24 @@ export type FlowNode =
58
59
  | { kind: "branch"; condition: string; then: FlowNode[]; else?: FlowNode[] }
59
60
  | { kind: "loop"; body: FlowNode[] }
60
61
  | { kind: "break" }
61
- | { kind: "continue" };
62
+ | { kind: "continue" }
63
+ | { kind: "parallel"; branches: FlowNode[][] }
64
+ | {
65
+ kind: "forEach";
66
+ /** FEEL expression evaluating to the list to fan out over. */
67
+ collection: string;
68
+ /** Variable each item is bound to in the child's scope (`inputElement`). */
69
+ itemVar: string;
70
+ body: FlowNode[];
71
+ /** Run children one at a time (sequential MI) instead of all at once. */
72
+ sequential?: boolean;
73
+ /** List variable that each child's `outputElement` is collected into. */
74
+ outputCollection?: string;
75
+ /** FEEL expression producing each child's contribution to `outputCollection`. */
76
+ outputElement?: string;
77
+ /** FEEL boolean that, once true, completes the body early. */
78
+ completionCondition?: string;
79
+ };
62
80
 
63
81
  /** A timer intermediate-catch definition: exactly one of `after` (an ISO-8601
64
82
  * delay or FEEL expression) or `at` (an absolute instant or FEEL expression).
package/src/worker.ts CHANGED
@@ -112,8 +112,9 @@ export class Worker {
112
112
  } else {
113
113
  // Walk the flow tree; only `run` steps are hosted locally. `signal`
114
114
  // (message catch) and `task` (external worker) contribute no in-process
115
- // route, and structural combinators (`switch`/`branch`/`loop`) are pure
116
- // routing with no job of their own.
115
+ // route, and structural combinators (`switch`/`branch`/`loop`/`parallel`/
116
+ // `forEach`) are pure routing with no job of their own — but the `run`
117
+ // steps NESTED inside them are hosted, since `walkNodes` recurses.
117
118
  walkNodes(wf.steps, (s) => {
118
119
  if (s.kind !== "run") return;
119
120
  const handler = wf.handlers[s.name];