@nanobpm/workflow 0.10.0 → 0.12.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
@@ -36,13 +36,24 @@ Both compile to the same engine durability; pick per workflow.
36
36
  **The recommended surface.** Describe the flow as a **tree of nodes**: `w.run` (a
37
37
  locally-hosted service task), `w.task` (an external-worker service task — job
38
38
  type `${flowId}:${name}` by default, or pass `w.task(name, { jobType })` to
39
- target an existing worker pool, e.g. a `senior:pr-review` agent token), and
39
+ target an existing worker pool, e.g. a `senior:pr-review` agent token),
40
40
  `w.signal` (a durable message catch that resumes via a correlated message — the
41
- human-in-the-loop path), composed with control-flow combinators:
41
+ human-in-the-loop path), and `w.human` (a BPMN **user task** — a human-in-the-loop
42
+ approval gate rendered on a task list), composed with control-flow combinators:
42
43
 
43
44
  - `w.switch(subject, cases)` — a multi-way exclusive choice; each case key routes
44
45
  when `subject = value`; an optional `default` case is the fallback.
45
46
  - `w.branch(condition, { then, else? })` — a two-way choice on a FEEL boolean.
47
+ - `w.human(name, { form, assignee?, candidateGroups?, io? })` — a durable **user
48
+ task** (`<bpmn:userTask>` with the Zeebe user-task marker). `form` binds a
49
+ `zeebe:formDefinition` (the form id shown to the assignee). Add an assignment
50
+ with `assignee` (a static user id or a FEEL expression like
51
+ `=escalationAssignee`) and/or `candidateGroups` (who may claim it) — supply
52
+ either or both. `io` adds a `zeebe:ioMapping` of `{ input?, output? }` entries
53
+ (`{ source, target }`: a FEEL `source` copied to/from the process variable
54
+ `target`), e.g. to stamp the reviewer's decision back onto a variable on
55
+ completion. The token waits durably until the task is completed from a task
56
+ list / the Zeebe user-task API.
46
57
  - `w.loop(body)` — a durable loop (back-edge to the loop head).
47
58
  - `w.break()` / `w.continue()` — exit the enclosing loop, or jump back to its head.
48
59
  - `w.parallel([blockA, blockB, …])` — a static fork/join: every block runs
@@ -62,6 +73,13 @@ human-in-the-loop path), composed with control-flow combinators:
62
73
  (a message- or timer-event-definition), the arm bodies rejoining downstream by an
63
74
  implicit XOR merge. This is the shape a convergence loop uses to bound a
64
75
  human-in-the-loop wait with a timeout.
76
+ - `w.run(...).boundary({ timer, onTimeout, interrupting?, name? })` — attach an
77
+ SLA (an interrupting timer boundary event) to the PRECEDING activity: when
78
+ `timer` elapses the activity is cancelled and the token routes to the
79
+ `onTimeout` escalation body, which then converges with the activity's normal
80
+ continuation. `timer` is an ISO-8601 duration (`PT24H`) or a FEEL
81
+ `=`-expression (`=escalationSlaTimeout`). `interrupting` defaults to `true`
82
+ (pass `false` for a non-interrupting boundary that leaves the activity running).
65
83
 
66
84
  ```ts
67
85
  import { defineFlow, WorkflowClient, Worker } from "@nanobpm/workflow";
@@ -81,6 +99,25 @@ const { processInstanceKey } = await client.start(onboarding, {});
81
99
  await client.signal(onboarding, "approved", userId, { by: "alice" });
82
100
  ```
83
101
 
102
+ A human-in-the-loop **approval gate** with a form, an assignment, and an output
103
+ mapping that stamps the decision back onto a process variable:
104
+
105
+ ```ts
106
+ const review = defineFlow("plan-review", (w) => {
107
+ w.human("plan-review-decision", {
108
+ form: "plan-review-decision", // zeebe:formDefinition formId
109
+ assignee: "=escalationAssignee", // a FEEL expression (or a static user id)
110
+ candidateGroups: "operators", // who may claim it
111
+ io: { output: [{ source: "=verdict", target: "planVerdict" }] },
112
+ });
113
+ w.switch("planVerdict", {
114
+ approve: (c) => c.run("apply", applyPlan),
115
+ default: (c) => c.run("revise", revisePlan),
116
+ });
117
+ });
118
+ ```
119
+
120
+
84
121
  A durable convergence loop (the shape `urban-pr-review` uses) — a loop wrapping a
85
122
  status switch with a nested guard:
86
123
 
@@ -577,6 +577,7 @@ class Compiler {
577
577
  const shapeXml = this.emitShapes();
578
578
  return (`<?xml version="1.0" encoding="UTF-8"?>\n` +
579
579
  `<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" ` +
580
+ `xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" ` +
580
581
  `xmlns:zeebe="http://camunda.org/schema/zeebe/1.0" ` +
581
582
  `xmlns:nano="https://nanobpm.io/schema/shapes/1.0" ` +
582
583
  `id="Definitions_${escapeXml(this.flow.id)}" targetNamespace="http://bpmn.io/schema/bpmn">\n` +
package/dist/index.d.ts CHANGED
@@ -2,6 +2,8 @@ export { defineWorkflow, imperativeToBpmn, replayOnce } from "./imperative.js";
2
2
  export type { Journal, ReplayStep } from "./imperative.js";
3
3
  export { defineFlow, declarativeToBpmn, externalJobTypes, walkNodes } from "./declarative.js";
4
4
  export type { FlowBuilder } from "./declarative.js";
5
+ export type { BoundaryOptions } from "./nodes/boundary.js";
6
+ export type { HumanOptions, HumanIoMapping, HumanIoEntry } from "./nodes/human.js";
5
7
  export { layoutBpmn, declarativeToLayoutedBpmn } from "./layout.js";
6
8
  export { envelope } from "./envelope.js";
7
9
  export type { Envelope, EnvelopeType, EnvelopeField, FieldSpec, ScalarType, ScalarTs, FieldTs, } from "./envelope.js";
@@ -0,0 +1,46 @@
1
+ import type { Block } from "../declarative.js";
2
+ /** Options for `.boundary(...)`: an attached timer SLA on the preceding activity.
3
+ * - `timer` — the timeout: an ISO-8601 duration (`PT24H`, `P1DT6H`) or a FEEL
4
+ * `=`-expression (`=escalationSlaTimeout`); the duration need not be a literal.
5
+ * - `onTimeout` — the escalation body run when the timer elapses (its danglers
6
+ * converge with the activity's normal continuation).
7
+ * - `interrupting` — whether firing cancels the host activity. Defaults to
8
+ * `true` (an interrupting boundary; `cancelActivity` omitted, the BPMN default).
9
+ * Pass `false` for a non-interrupting boundary (`cancelActivity="false"`).
10
+ * - `name` — an optional label for the boundary event. */
11
+ export interface BoundaryOptions<C extends object = object> {
12
+ timer: string;
13
+ onTimeout: Block<C>;
14
+ interrupting?: boolean;
15
+ name?: string;
16
+ }
17
+ declare module "../types.js" {
18
+ interface FlowNodeRegistry {
19
+ boundary: {
20
+ kind: "boundary";
21
+ /** The derived element id of the activity this boundary attaches to. */
22
+ attachedTo: string;
23
+ /** The timeout — an ISO-8601 duration or a FEEL `=`-expression. */
24
+ timer: string;
25
+ /** Whether firing cancels the host (interrupting). */
26
+ interrupting: boolean;
27
+ /** The escalation body wired from the boundary event's outgoing flow. */
28
+ onTimeout: FlowNode[];
29
+ /** Optional boundary-event label. */
30
+ label?: string;
31
+ };
32
+ }
33
+ }
34
+ declare module "../declarative.js" {
35
+ interface FlowBuilder<C extends object> {
36
+ /**
37
+ * Attach an interrupting timer boundary event (an SLA) to the PRECEDING
38
+ * activity (a `run`/`task`). When `timer` elapses, the activity is
39
+ * cancelled (for an interrupting boundary) and the token routes to the
40
+ * `onTimeout` escalation body; that body then converges with the activity's
41
+ * normal continuation. `timer` is an ISO-8601 duration or a FEEL
42
+ * `=`-expression. Chains as `w.run(...).boundary({ timer, onTimeout })`.
43
+ */
44
+ boundary(opts: BoundaryOptions<C>): FlowBuilder<C>;
45
+ }
46
+ }
@@ -0,0 +1,92 @@
1
+ // Flow-node kind: `boundary` — an activity-level attached interrupting timer
2
+ // boundary event (an SLA). Chained onto the activity it guards, it attaches a
3
+ // `<bpmn:boundaryEvent … attachedToRef=… ><bpmn:timerEventDefinition>` to that
4
+ // activity: when the timer elapses the activity is cancelled (interrupting) and
5
+ // the token routes to the `onTimeout` escalation body, which then converges with
6
+ // the activity's normal continuation. Contributed entirely from this module
7
+ // through the extension seam (epic #314, S2/#317) — NO edit to the shared
8
+ // `FlowNode` union, the `walkNodes`/`emitNode` dispatch, or the generated barrel.
9
+ import { outgoingOnly } from "../declarative.js";
10
+ import { assertTimerDuration, escapeXml } from "../xml.js";
11
+ import { registerNodeKind } from "./registry.js";
12
+ /** The flow-node kinds a boundary event may attach to. BPMN restricts boundary
13
+ * events to ACTIVITIES, so this is the set of activity kinds (the ones that
14
+ * render as a `<bpmn:serviceTask>`) — NOT merely "any node with a `name`", which
15
+ * would also admit catch events like `signal`/`timer` and produce invalid BPMN. */
16
+ const BOUNDARY_HOST_KINDS = new Set(["run", "task"]);
17
+ /** True for a FEEL expression body (a leading `=`), which renders with an
18
+ * `xsi:type="bpmn:tFormalExpression"` marker on the `timeDuration`. */
19
+ function isFeel(value) {
20
+ return value.startsWith("=");
21
+ }
22
+ function renderBoundary(id, node, outg) {
23
+ const nameAttr = node.label ? ` name="${escapeXml(node.label)}"` : "";
24
+ // An interrupting boundary omits `cancelActivity` (its BPMN default is true),
25
+ // matching the hand-authored nwf goldens; a non-interrupting one is explicit.
26
+ const cancelAttr = node.interrupting ? "" : ` cancelActivity="false"`;
27
+ const durType = isFeel(node.timer) ? ` xsi:type="bpmn:tFormalExpression"` : "";
28
+ return (` <bpmn:boundaryEvent id="${id}"${nameAttr} attachedToRef="${escapeXml(node.attachedTo)}"${cancelAttr}>\n` +
29
+ ` ${outgoingOnly(outg)}\n` +
30
+ ` <bpmn:timerEventDefinition>\n` +
31
+ ` <bpmn:timeDuration${durType}>${escapeXml(node.timer)}</bpmn:timeDuration>\n` +
32
+ ` </bpmn:timerEventDefinition>\n` +
33
+ ` </bpmn:boundaryEvent>`);
34
+ }
35
+ registerNodeKind("boundary", {
36
+ build: (api) => (opts) => {
37
+ if (!opts || typeof opts !== "object") {
38
+ throw new Error(`boundary(...) needs { timer, onTimeout }`);
39
+ }
40
+ if (typeof opts.timer !== "string" || opts.timer.trim() === "") {
41
+ throw new Error(`boundary(...) needs a { timer } duration (an ISO-8601 duration or a FEEL =expression)`);
42
+ }
43
+ const duration = opts.timer.trim();
44
+ assertTimerDuration("boundary(...) timer", duration);
45
+ if (typeof opts.onTimeout !== "function") {
46
+ throw new Error(`boundary(...) needs an { onTimeout } escalation body (b) => {…}`);
47
+ }
48
+ if (opts.interrupting !== undefined && typeof opts.interrupting !== "boolean") {
49
+ throw new Error(`boundary(...) { interrupting } must be a boolean`);
50
+ }
51
+ if (opts.name !== undefined && typeof opts.name !== "string") {
52
+ throw new Error(`boundary(...) { name } must be a string`);
53
+ }
54
+ const host = api.out[api.out.length - 1];
55
+ if (!host) {
56
+ throw new Error(`boundary(...) must follow the activity it attaches to (e.g. w.run(...).boundary(...))`);
57
+ }
58
+ if (!BOUNDARY_HOST_KINDS.has(host.kind) || !("name" in host) || typeof host.name !== "string") {
59
+ throw new Error(`boundary(...) can only attach to a named activity (run/task), not a "${host.kind}"`);
60
+ }
61
+ const onTimeout = api.child(opts.onTimeout, false);
62
+ if (onTimeout.length === 0) {
63
+ throw new Error(`boundary(...) needs a non-empty { onTimeout } escalation body (its outgoing flow has nowhere to go)`);
64
+ }
65
+ api.out.push({
66
+ kind: "boundary",
67
+ attachedTo: host.name,
68
+ timer: duration,
69
+ interrupting: opts.interrupting ?? true,
70
+ onTimeout,
71
+ label: opts.name,
72
+ });
73
+ return api.self();
74
+ },
75
+ walk: (node, recurse) => {
76
+ recurse(node.onTimeout);
77
+ },
78
+ emit: (node, incoming, loop, api) => {
79
+ // A boundary event has NO incoming sequence flow — it attaches to its host
80
+ // via `attachedToRef`. Place it, wire its outgoing to the escalation body,
81
+ // and return the host's normal continuation (`incoming`, passed through)
82
+ // PLUS the escalation danglers, so both converge on whatever follows.
83
+ const beId = `Be_${api.nextGw()}`;
84
+ api.addNode({
85
+ id: beId,
86
+ render: (_inc, outg) => renderBoundary(beId, node, outg),
87
+ });
88
+ const escalation = api.newEdge(beId);
89
+ const escOut = api.emitList(node.onTimeout, [escalation], loop);
90
+ return [...incoming, ...escOut];
91
+ },
92
+ });
@@ -0,0 +1,57 @@
1
+ /** A single Zeebe I/O mapping entry — a FEEL `source` copied to/from a process
2
+ * variable `target`. */
3
+ export interface HumanIoEntry {
4
+ /** FEEL expression evaluated for the mapping (e.g. `=orderId`). */
5
+ source: string;
6
+ /** The process variable the value is written to. */
7
+ target: string;
8
+ }
9
+ /** The `<zeebe:ioMapping>` for a user task: input mappings applied on activation
10
+ * and output mappings applied on completion. */
11
+ export interface HumanIoMapping {
12
+ input?: HumanIoEntry[];
13
+ output?: HumanIoEntry[];
14
+ }
15
+ /** Options for {@link FlowBuilder.human}. `form` is the `zeebe:formDefinition`
16
+ * form id; `assignee` / `candidateGroups` populate a `zeebe:assignmentDefinition`
17
+ * (supply either or both); `io` populates a `zeebe:ioMapping`. */
18
+ export interface HumanOptions {
19
+ /** The `zeebe:formDefinition` form id bound to this task. */
20
+ form: string;
21
+ /** A single assignee (a static user id or a FEEL expression like
22
+ * `=escalationAssignee`). */
23
+ assignee?: string;
24
+ /** The candidate groups allowed to claim the task (a static group id or a FEEL
25
+ * expression). */
26
+ candidateGroups?: string;
27
+ /** Input/output variable mappings. */
28
+ io?: HumanIoMapping;
29
+ }
30
+ /** The `human` node shape (its `FlowNode` variant). */
31
+ interface HumanNode {
32
+ kind: "human";
33
+ name: string;
34
+ form: string;
35
+ assignee?: string;
36
+ candidateGroups?: string;
37
+ io?: HumanIoMapping;
38
+ }
39
+ declare module "../types.js" {
40
+ interface FlowNodeRegistry {
41
+ human: HumanNode;
42
+ }
43
+ }
44
+ declare module "../declarative.js" {
45
+ interface FlowBuilder<C extends object> {
46
+ /**
47
+ * A human-in-the-loop approval step (a BPMN `<bpmn:userTask>` with the Zeebe
48
+ * user-task marker). `opts.form` binds a `zeebe:formDefinition`; the optional
49
+ * `assignee` / `candidateGroups` populate a `zeebe:assignmentDefinition`
50
+ * (either or both), and `io` populates a `zeebe:ioMapping`. Resume it from a
51
+ * task list / the Zeebe user-task API; the token waits durably until the task
52
+ * is completed.
53
+ */
54
+ human<K extends string>(name: K, opts: HumanOptions): FlowBuilder<C>;
55
+ }
56
+ }
57
+ export {};
@@ -0,0 +1,111 @@
1
+ // Built-in flow-node kind: `human` — a human-in-the-loop approval step (a BPMN
2
+ // `<bpmn:userTask>` with the Zeebe user-task marker). It carries a form binding
3
+ // and, optionally, an assignment (assignee / candidate groups) and an I/O
4
+ // mapping — the surface every nwf approval gate (answer escalation, plan review,
5
+ // trial-merge decision) is built from. Contributed from this module through the
6
+ // extension seam (epic #314, S3/#318) — no central union or dispatch is edited.
7
+ import { escapeXml } from "../xml.js";
8
+ import { incomingOutgoing } from "../declarative.js";
9
+ import { registerNodeKind } from "./registry.js";
10
+ /** A present, non-empty, non-whitespace-only string — the shape every human()
11
+ * id / assignment / mapping-expression field must have. A blank value would
12
+ * emit a meaningless BPMN attribute (an empty formId, assignee, candidateGroups,
13
+ * or ioMapping source/target) that fails or misbehaves at deploy/run time, so we
14
+ * fail fast at build. Non-blank values are kept verbatim — FEEL expressions are
15
+ * never trimmed or normalized. */
16
+ function isNonBlankString(v) {
17
+ return typeof v === "string" && v.trim() !== "";
18
+ }
19
+ function isEntry(e) {
20
+ if (e === null || typeof e !== "object")
21
+ return false;
22
+ if (!("source" in e) || !("target" in e))
23
+ return false;
24
+ const { source, target } = e;
25
+ return isNonBlankString(source) && isNonBlankString(target);
26
+ }
27
+ function validateEntries(name, dir, entries) {
28
+ if (entries === undefined)
29
+ return;
30
+ if (!Array.isArray(entries))
31
+ throw new Error(`human("${name}") io.${dir} must be an array of { source, target }`);
32
+ for (const e of entries) {
33
+ if (!isEntry(e))
34
+ throw new Error(`human("${name}") io.${dir} entries must be { source: non-empty string, target: non-empty string }`);
35
+ }
36
+ }
37
+ function renderAssignment(node) {
38
+ if (node.assignee === undefined && node.candidateGroups === undefined)
39
+ return "";
40
+ const attrs = [
41
+ node.assignee !== undefined ? ` assignee="${escapeXml(node.assignee)}"` : "",
42
+ node.candidateGroups !== undefined ? ` candidateGroups="${escapeXml(node.candidateGroups)}"` : "",
43
+ ].join("");
44
+ return ` <zeebe:assignmentDefinition${attrs} />\n`;
45
+ }
46
+ function renderMappings(entries, tag) {
47
+ if (!entries || entries.length === 0)
48
+ return "";
49
+ return entries
50
+ .map((e) => ` <zeebe:${tag} source="${escapeXml(e.source)}" target="${escapeXml(e.target)}" />\n`)
51
+ .join("");
52
+ }
53
+ function renderIoMapping(node) {
54
+ const io = node.io;
55
+ if (!io || ((io.input?.length ?? 0) === 0 && (io.output?.length ?? 0) === 0))
56
+ return "";
57
+ return (` <zeebe:ioMapping>\n` +
58
+ renderMappings(io.input, "input") +
59
+ renderMappings(io.output, "output") +
60
+ ` </zeebe:ioMapping>\n`);
61
+ }
62
+ function renderUserTask(node, inc, outg) {
63
+ const id = node.name;
64
+ const ext = ` <bpmn:extensionElements>\n` +
65
+ ` <zeebe:formDefinition formId="${escapeXml(node.form)}" />\n` +
66
+ ` <zeebe:userTask />\n` +
67
+ renderAssignment(node) +
68
+ renderIoMapping(node) +
69
+ ` </bpmn:extensionElements>`;
70
+ return (` <bpmn:userTask id="${escapeXml(id)}" name="${escapeXml(id)}">\n` +
71
+ ext +
72
+ "\n" +
73
+ incomingOutgoing(inc, outg) +
74
+ ` </bpmn:userTask>`);
75
+ }
76
+ registerNodeKind("human", {
77
+ build: (api) => (name, opts) => {
78
+ api.claim(name);
79
+ if (opts === null || typeof opts !== "object") {
80
+ throw new Error(`human("${name}") needs an options object { form, … }`);
81
+ }
82
+ if (!isNonBlankString(opts.form)) {
83
+ throw new Error(`human("${name}") needs a non-empty { form } (the zeebe:formDefinition form id)`);
84
+ }
85
+ if (opts.assignee !== undefined && !isNonBlankString(opts.assignee)) {
86
+ throw new Error(`human("${name}") { assignee } must be a non-empty string`);
87
+ }
88
+ if (opts.candidateGroups !== undefined && !isNonBlankString(opts.candidateGroups)) {
89
+ throw new Error(`human("${name}") { candidateGroups } must be a non-empty string`);
90
+ }
91
+ if (opts.io !== undefined && (opts.io === null || typeof opts.io !== "object" || Array.isArray(opts.io))) {
92
+ throw new Error(`human("${name}") { io } must be an object { input?, output? }`);
93
+ }
94
+ validateEntries(name, "input", opts.io?.input);
95
+ validateEntries(name, "output", opts.io?.output);
96
+ api.out.push({
97
+ kind: "human",
98
+ name,
99
+ form: opts.form,
100
+ assignee: opts.assignee,
101
+ candidateGroups: opts.candidateGroups,
102
+ io: opts.io,
103
+ });
104
+ return api.self();
105
+ },
106
+ emit: (node, incoming, _loop, api) => {
107
+ api.addNode({ id: node.name, render: (inc, outg) => renderUserTask(node, inc, outg) });
108
+ api.connect(incoming, node.name);
109
+ return [api.newEdge(node.name)];
110
+ },
111
+ });
@@ -1,7 +1,9 @@
1
+ import "./boundary.js";
1
2
  import "./branch.js";
2
3
  import "./break.js";
3
4
  import "./continue.js";
4
5
  import "./forEach.js";
6
+ import "./human.js";
5
7
  import "./loop.js";
6
8
  import "./parallel.js";
7
9
  import "./race.js";
@@ -5,10 +5,12 @@
5
5
  // table `makeBuilder`/`walkNodes`/the emitter route through. A new kind is
6
6
  // picked up automatically by re-running the generator (a prebuild step) — no
7
7
  // hand-edit here.
8
+ import "./boundary.js";
8
9
  import "./branch.js";
9
10
  import "./break.js";
10
11
  import "./continue.js";
11
12
  import "./forEach.js";
13
+ import "./human.js";
12
14
  import "./loop.js";
13
15
  import "./parallel.js";
14
16
  import "./race.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/workflow",
3
- "version": "0.10.0",
3
+ "version": "0.12.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",
@@ -933,6 +933,7 @@ class Compiler {
933
933
  return (
934
934
  `<?xml version="1.0" encoding="UTF-8"?>\n` +
935
935
  `<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" ` +
936
+ `xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" ` +
936
937
  `xmlns:zeebe="http://camunda.org/schema/zeebe/1.0" ` +
937
938
  `xmlns:nano="https://nanobpm.io/schema/shapes/1.0" ` +
938
939
  `id="Definitions_${escapeXml(this.flow.id)}" targetNamespace="http://bpmn.io/schema/bpmn">\n` +
package/src/index.ts CHANGED
@@ -43,6 +43,10 @@ export { defineWorkflow, imperativeToBpmn, replayOnce } from "./imperative.js";
43
43
  export type { Journal, ReplayStep } from "./imperative.js";
44
44
  export { defineFlow, declarativeToBpmn, externalJobTypes, walkNodes } from "./declarative.js";
45
45
  export type { FlowBuilder } from "./declarative.js";
46
+ // The `.boundary(...)` option type (its builder method augments FlowBuilder from
47
+ // the boundary kind module — S2/#317).
48
+ export type { BoundaryOptions } from "./nodes/boundary.js";
49
+ export type { HumanOptions, HumanIoMapping, HumanIoEntry } from "./nodes/human.js";
46
50
  export { layoutBpmn, declarativeToLayoutedBpmn } from "./layout.js";
47
51
  export { envelope } from "./envelope.js";
48
52
  export type {
@@ -0,0 +1,149 @@
1
+ // Flow-node kind: `boundary` — an activity-level attached interrupting timer
2
+ // boundary event (an SLA). Chained onto the activity it guards, it attaches a
3
+ // `<bpmn:boundaryEvent … attachedToRef=… ><bpmn:timerEventDefinition>` to that
4
+ // activity: when the timer elapses the activity is cancelled (interrupting) and
5
+ // the token routes to the `onTimeout` escalation body, which then converges with
6
+ // the activity's normal continuation. Contributed entirely from this module
7
+ // through the extension seam (epic #314, S2/#317) — NO edit to the shared
8
+ // `FlowNode` union, the `walkNodes`/`emitNode` dispatch, or the generated barrel.
9
+
10
+ import type { FlowNode } from "../types.js";
11
+ import type { Block, Edge } from "../declarative.js";
12
+ import { outgoingOnly } from "../declarative.js";
13
+ import { assertTimerDuration, escapeXml } from "../xml.js";
14
+ import { registerNodeKind } from "./registry.js";
15
+
16
+ /** Options for `.boundary(...)`: an attached timer SLA on the preceding activity.
17
+ * - `timer` — the timeout: an ISO-8601 duration (`PT24H`, `P1DT6H`) or a FEEL
18
+ * `=`-expression (`=escalationSlaTimeout`); the duration need not be a literal.
19
+ * - `onTimeout` — the escalation body run when the timer elapses (its danglers
20
+ * converge with the activity's normal continuation).
21
+ * - `interrupting` — whether firing cancels the host activity. Defaults to
22
+ * `true` (an interrupting boundary; `cancelActivity` omitted, the BPMN default).
23
+ * Pass `false` for a non-interrupting boundary (`cancelActivity="false"`).
24
+ * - `name` — an optional label for the boundary event. */
25
+ export interface BoundaryOptions<C extends object = object> {
26
+ timer: string;
27
+ onTimeout: Block<C>;
28
+ interrupting?: boolean;
29
+ name?: string;
30
+ }
31
+
32
+ declare module "../types.js" {
33
+ interface FlowNodeRegistry {
34
+ boundary: {
35
+ kind: "boundary";
36
+ /** The derived element id of the activity this boundary attaches to. */
37
+ attachedTo: string;
38
+ /** The timeout — an ISO-8601 duration or a FEEL `=`-expression. */
39
+ timer: string;
40
+ /** Whether firing cancels the host (interrupting). */
41
+ interrupting: boolean;
42
+ /** The escalation body wired from the boundary event's outgoing flow. */
43
+ onTimeout: FlowNode[];
44
+ /** Optional boundary-event label. */
45
+ label?: string;
46
+ };
47
+ }
48
+ }
49
+
50
+ declare module "../declarative.js" {
51
+ interface FlowBuilder<C extends object> {
52
+ /**
53
+ * Attach an interrupting timer boundary event (an SLA) to the PRECEDING
54
+ * activity (a `run`/`task`). When `timer` elapses, the activity is
55
+ * cancelled (for an interrupting boundary) and the token routes to the
56
+ * `onTimeout` escalation body; that body then converges with the activity's
57
+ * normal continuation. `timer` is an ISO-8601 duration or a FEEL
58
+ * `=`-expression. Chains as `w.run(...).boundary({ timer, onTimeout })`.
59
+ */
60
+ boundary(opts: BoundaryOptions<C>): FlowBuilder<C>;
61
+ }
62
+ }
63
+
64
+ /** The flow-node kinds a boundary event may attach to. BPMN restricts boundary
65
+ * events to ACTIVITIES, so this is the set of activity kinds (the ones that
66
+ * render as a `<bpmn:serviceTask>`) — NOT merely "any node with a `name`", which
67
+ * would also admit catch events like `signal`/`timer` and produce invalid BPMN. */
68
+ const BOUNDARY_HOST_KINDS = new Set(["run", "task"]);
69
+
70
+ /** True for a FEEL expression body (a leading `=`), which renders with an
71
+ * `xsi:type="bpmn:tFormalExpression"` marker on the `timeDuration`. */
72
+ function isFeel(value: string): boolean {
73
+ return value.startsWith("=");
74
+ }
75
+
76
+ function renderBoundary(id: string, node: Extract<FlowNode, { kind: "boundary" }>, outg: string[]): string {
77
+ const nameAttr = node.label ? ` name="${escapeXml(node.label)}"` : "";
78
+ // An interrupting boundary omits `cancelActivity` (its BPMN default is true),
79
+ // matching the hand-authored nwf goldens; a non-interrupting one is explicit.
80
+ const cancelAttr = node.interrupting ? "" : ` cancelActivity="false"`;
81
+ const durType = isFeel(node.timer) ? ` xsi:type="bpmn:tFormalExpression"` : "";
82
+ return (
83
+ ` <bpmn:boundaryEvent id="${id}"${nameAttr} attachedToRef="${escapeXml(node.attachedTo)}"${cancelAttr}>\n` +
84
+ ` ${outgoingOnly(outg)}\n` +
85
+ ` <bpmn:timerEventDefinition>\n` +
86
+ ` <bpmn:timeDuration${durType}>${escapeXml(node.timer)}</bpmn:timeDuration>\n` +
87
+ ` </bpmn:timerEventDefinition>\n` +
88
+ ` </bpmn:boundaryEvent>`
89
+ );
90
+ }
91
+
92
+ registerNodeKind("boundary", {
93
+ build: (api) => (opts: BoundaryOptions) => {
94
+ if (!opts || typeof opts !== "object") {
95
+ throw new Error(`boundary(...) needs { timer, onTimeout }`);
96
+ }
97
+ if (typeof opts.timer !== "string" || opts.timer.trim() === "") {
98
+ throw new Error(`boundary(...) needs a { timer } duration (an ISO-8601 duration or a FEEL =expression)`);
99
+ }
100
+ const duration = opts.timer.trim();
101
+ assertTimerDuration("boundary(...) timer", duration);
102
+ if (typeof opts.onTimeout !== "function") {
103
+ throw new Error(`boundary(...) needs an { onTimeout } escalation body (b) => {…}`);
104
+ }
105
+ if (opts.interrupting !== undefined && typeof opts.interrupting !== "boolean") {
106
+ throw new Error(`boundary(...) { interrupting } must be a boolean`);
107
+ }
108
+ if (opts.name !== undefined && typeof opts.name !== "string") {
109
+ throw new Error(`boundary(...) { name } must be a string`);
110
+ }
111
+ const host = api.out[api.out.length - 1];
112
+ if (!host) {
113
+ throw new Error(`boundary(...) must follow the activity it attaches to (e.g. w.run(...).boundary(...))`);
114
+ }
115
+ if (!BOUNDARY_HOST_KINDS.has(host.kind) || !("name" in host) || typeof host.name !== "string") {
116
+ throw new Error(`boundary(...) can only attach to a named activity (run/task), not a "${host.kind}"`);
117
+ }
118
+ const onTimeout = api.child(opts.onTimeout, false);
119
+ if (onTimeout.length === 0) {
120
+ throw new Error(`boundary(...) needs a non-empty { onTimeout } escalation body (its outgoing flow has nowhere to go)`);
121
+ }
122
+ api.out.push({
123
+ kind: "boundary",
124
+ attachedTo: host.name,
125
+ timer: duration,
126
+ interrupting: opts.interrupting ?? true,
127
+ onTimeout,
128
+ label: opts.name,
129
+ });
130
+ return api.self();
131
+ },
132
+ walk: (node, recurse) => {
133
+ recurse(node.onTimeout);
134
+ },
135
+ emit: (node, incoming, loop, api) => {
136
+ // A boundary event has NO incoming sequence flow — it attaches to its host
137
+ // via `attachedToRef`. Place it, wire its outgoing to the escalation body,
138
+ // and return the host's normal continuation (`incoming`, passed through)
139
+ // PLUS the escalation danglers, so both converge on whatever follows.
140
+ const beId = `Be_${api.nextGw()}`;
141
+ api.addNode({
142
+ id: beId,
143
+ render: (_inc, outg) => renderBoundary(beId, node, outg),
144
+ });
145
+ const escalation: Edge = api.newEdge(beId);
146
+ const escOut = api.emitList(node.onTimeout, [escalation], loop);
147
+ return [...incoming, ...escOut];
148
+ },
149
+ });
@@ -0,0 +1,179 @@
1
+ // Built-in flow-node kind: `human` — a human-in-the-loop approval step (a BPMN
2
+ // `<bpmn:userTask>` with the Zeebe user-task marker). It carries a form binding
3
+ // and, optionally, an assignment (assignee / candidate groups) and an I/O
4
+ // mapping — the surface every nwf approval gate (answer escalation, plan review,
5
+ // trial-merge decision) is built from. Contributed from this module through the
6
+ // extension seam (epic #314, S3/#318) — no central union or dispatch is edited.
7
+
8
+ import { escapeXml } from "../xml.js";
9
+ import { incomingOutgoing } from "../declarative.js";
10
+ import { registerNodeKind } from "./registry.js";
11
+
12
+ /** A single Zeebe I/O mapping entry — a FEEL `source` copied to/from a process
13
+ * variable `target`. */
14
+ export interface HumanIoEntry {
15
+ /** FEEL expression evaluated for the mapping (e.g. `=orderId`). */
16
+ source: string;
17
+ /** The process variable the value is written to. */
18
+ target: string;
19
+ }
20
+
21
+ /** The `<zeebe:ioMapping>` for a user task: input mappings applied on activation
22
+ * and output mappings applied on completion. */
23
+ export interface HumanIoMapping {
24
+ input?: HumanIoEntry[];
25
+ output?: HumanIoEntry[];
26
+ }
27
+
28
+ /** Options for {@link FlowBuilder.human}. `form` is the `zeebe:formDefinition`
29
+ * form id; `assignee` / `candidateGroups` populate a `zeebe:assignmentDefinition`
30
+ * (supply either or both); `io` populates a `zeebe:ioMapping`. */
31
+ export interface HumanOptions {
32
+ /** The `zeebe:formDefinition` form id bound to this task. */
33
+ form: string;
34
+ /** A single assignee (a static user id or a FEEL expression like
35
+ * `=escalationAssignee`). */
36
+ assignee?: string;
37
+ /** The candidate groups allowed to claim the task (a static group id or a FEEL
38
+ * expression). */
39
+ candidateGroups?: string;
40
+ /** Input/output variable mappings. */
41
+ io?: HumanIoMapping;
42
+ }
43
+
44
+ /** The `human` node shape (its `FlowNode` variant). */
45
+ interface HumanNode {
46
+ kind: "human";
47
+ name: string;
48
+ form: string;
49
+ assignee?: string;
50
+ candidateGroups?: string;
51
+ io?: HumanIoMapping;
52
+ }
53
+
54
+ declare module "../types.js" {
55
+ interface FlowNodeRegistry {
56
+ human: HumanNode;
57
+ }
58
+ }
59
+
60
+ declare module "../declarative.js" {
61
+ interface FlowBuilder<C extends object> {
62
+ /**
63
+ * A human-in-the-loop approval step (a BPMN `<bpmn:userTask>` with the Zeebe
64
+ * user-task marker). `opts.form` binds a `zeebe:formDefinition`; the optional
65
+ * `assignee` / `candidateGroups` populate a `zeebe:assignmentDefinition`
66
+ * (either or both), and `io` populates a `zeebe:ioMapping`. Resume it from a
67
+ * task list / the Zeebe user-task API; the token waits durably until the task
68
+ * is completed.
69
+ */
70
+ human<K extends string>(name: K, opts: HumanOptions): FlowBuilder<C>;
71
+ }
72
+ }
73
+
74
+ /** A present, non-empty, non-whitespace-only string — the shape every human()
75
+ * id / assignment / mapping-expression field must have. A blank value would
76
+ * emit a meaningless BPMN attribute (an empty formId, assignee, candidateGroups,
77
+ * or ioMapping source/target) that fails or misbehaves at deploy/run time, so we
78
+ * fail fast at build. Non-blank values are kept verbatim — FEEL expressions are
79
+ * never trimmed or normalized. */
80
+ function isNonBlankString(v: unknown): v is string {
81
+ return typeof v === "string" && v.trim() !== "";
82
+ }
83
+
84
+ function isEntry(e: unknown): e is HumanIoEntry {
85
+ if (e === null || typeof e !== "object") return false;
86
+ if (!("source" in e) || !("target" in e)) return false;
87
+ const { source, target } = e;
88
+ return isNonBlankString(source) && isNonBlankString(target);
89
+ }
90
+
91
+ function validateEntries(name: string, dir: "input" | "output", entries: HumanIoEntry[] | undefined): void {
92
+ if (entries === undefined) return;
93
+ if (!Array.isArray(entries)) throw new Error(`human("${name}") io.${dir} must be an array of { source, target }`);
94
+ for (const e of entries) {
95
+ if (!isEntry(e)) throw new Error(`human("${name}") io.${dir} entries must be { source: non-empty string, target: non-empty string }`);
96
+ }
97
+ }
98
+
99
+ function renderAssignment(node: HumanNode): string {
100
+ if (node.assignee === undefined && node.candidateGroups === undefined) return "";
101
+ const attrs = [
102
+ node.assignee !== undefined ? ` assignee="${escapeXml(node.assignee)}"` : "",
103
+ node.candidateGroups !== undefined ? ` candidateGroups="${escapeXml(node.candidateGroups)}"` : "",
104
+ ].join("");
105
+ return ` <zeebe:assignmentDefinition${attrs} />\n`;
106
+ }
107
+
108
+ function renderMappings(entries: HumanIoEntry[] | undefined, tag: "input" | "output"): string {
109
+ if (!entries || entries.length === 0) return "";
110
+ return entries
111
+ .map((e) => ` <zeebe:${tag} source="${escapeXml(e.source)}" target="${escapeXml(e.target)}" />\n`)
112
+ .join("");
113
+ }
114
+
115
+ function renderIoMapping(node: HumanNode): string {
116
+ const io = node.io;
117
+ if (!io || ((io.input?.length ?? 0) === 0 && (io.output?.length ?? 0) === 0)) return "";
118
+ return (
119
+ ` <zeebe:ioMapping>\n` +
120
+ renderMappings(io.input, "input") +
121
+ renderMappings(io.output, "output") +
122
+ ` </zeebe:ioMapping>\n`
123
+ );
124
+ }
125
+
126
+ function renderUserTask(node: HumanNode, inc: string[], outg: string[]): string {
127
+ const id = node.name;
128
+ const ext =
129
+ ` <bpmn:extensionElements>\n` +
130
+ ` <zeebe:formDefinition formId="${escapeXml(node.form)}" />\n` +
131
+ ` <zeebe:userTask />\n` +
132
+ renderAssignment(node) +
133
+ renderIoMapping(node) +
134
+ ` </bpmn:extensionElements>`;
135
+ return (
136
+ ` <bpmn:userTask id="${escapeXml(id)}" name="${escapeXml(id)}">\n` +
137
+ ext +
138
+ "\n" +
139
+ incomingOutgoing(inc, outg) +
140
+ ` </bpmn:userTask>`
141
+ );
142
+ }
143
+
144
+ registerNodeKind("human", {
145
+ build: (api) => (name: string, opts: HumanOptions) => {
146
+ api.claim(name);
147
+ if (opts === null || typeof opts !== "object") {
148
+ throw new Error(`human("${name}") needs an options object { form, … }`);
149
+ }
150
+ if (!isNonBlankString(opts.form)) {
151
+ throw new Error(`human("${name}") needs a non-empty { form } (the zeebe:formDefinition form id)`);
152
+ }
153
+ if (opts.assignee !== undefined && !isNonBlankString(opts.assignee)) {
154
+ throw new Error(`human("${name}") { assignee } must be a non-empty string`);
155
+ }
156
+ if (opts.candidateGroups !== undefined && !isNonBlankString(opts.candidateGroups)) {
157
+ throw new Error(`human("${name}") { candidateGroups } must be a non-empty string`);
158
+ }
159
+ if (opts.io !== undefined && (opts.io === null || typeof opts.io !== "object" || Array.isArray(opts.io))) {
160
+ throw new Error(`human("${name}") { io } must be an object { input?, output? }`);
161
+ }
162
+ validateEntries(name, "input", opts.io?.input);
163
+ validateEntries(name, "output", opts.io?.output);
164
+ api.out.push({
165
+ kind: "human",
166
+ name,
167
+ form: opts.form,
168
+ assignee: opts.assignee,
169
+ candidateGroups: opts.candidateGroups,
170
+ io: opts.io,
171
+ });
172
+ return api.self();
173
+ },
174
+ emit: (node, incoming, _loop, api) => {
175
+ api.addNode({ id: node.name, render: (inc, outg) => renderUserTask(node, inc, outg) });
176
+ api.connect(incoming, node.name);
177
+ return [api.newEdge(node.name)];
178
+ },
179
+ });
@@ -6,10 +6,12 @@
6
6
  // picked up automatically by re-running the generator (a prebuild step) — no
7
7
  // hand-edit here.
8
8
 
9
+ import "./boundary.js";
9
10
  import "./branch.js";
10
11
  import "./break.js";
11
12
  import "./continue.js";
12
13
  import "./forEach.js";
14
+ import "./human.js";
13
15
  import "./loop.js";
14
16
  import "./parallel.js";
15
17
  import "./race.js";