@nanobpm/workflow 0.11.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
@@ -88,6 +99,25 @@ const { processInstanceKey } = await client.start(onboarding, {});
88
99
  await client.signal(onboarding, "approved", userId, { by: "alice" });
89
100
  ```
90
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
+
91
121
  A durable convergence loop (the shape `urban-pr-review` uses) — a loop wrapping a
92
122
  status switch with a nested guard:
93
123
 
package/dist/index.d.ts CHANGED
@@ -3,6 +3,7 @@ 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
5
  export type { BoundaryOptions } from "./nodes/boundary.js";
6
+ export type { HumanOptions, HumanIoMapping, HumanIoEntry } from "./nodes/human.js";
6
7
  export { layoutBpmn, declarativeToLayoutedBpmn } from "./layout.js";
7
8
  export { envelope } from "./envelope.js";
8
9
  export type { Envelope, EnvelopeType, EnvelopeField, FieldSpec, ScalarType, ScalarTs, FieldTs, } from "./envelope.js";
@@ -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
+ });
@@ -3,6 +3,7 @@ import "./branch.js";
3
3
  import "./break.js";
4
4
  import "./continue.js";
5
5
  import "./forEach.js";
6
+ import "./human.js";
6
7
  import "./loop.js";
7
8
  import "./parallel.js";
8
9
  import "./race.js";
@@ -10,6 +10,7 @@ import "./branch.js";
10
10
  import "./break.js";
11
11
  import "./continue.js";
12
12
  import "./forEach.js";
13
+ import "./human.js";
13
14
  import "./loop.js";
14
15
  import "./parallel.js";
15
16
  import "./race.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/workflow",
3
- "version": "0.11.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",
package/src/index.ts CHANGED
@@ -46,6 +46,7 @@ export type { FlowBuilder } from "./declarative.js";
46
46
  // The `.boundary(...)` option type (its builder method augments FlowBuilder from
47
47
  // the boundary kind module — S2/#317).
48
48
  export type { BoundaryOptions } from "./nodes/boundary.js";
49
+ export type { HumanOptions, HumanIoMapping, HumanIoEntry } from "./nodes/human.js";
49
50
  export { layoutBpmn, declarativeToLayoutedBpmn } from "./layout.js";
50
51
  export { envelope } from "./envelope.js";
51
52
  export type {
@@ -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
+ });
@@ -11,6 +11,7 @@ import "./branch.js";
11
11
  import "./break.js";
12
12
  import "./continue.js";
13
13
  import "./forEach.js";
14
+ import "./human.js";
14
15
  import "./loop.js";
15
16
  import "./parallel.js";
16
17
  import "./race.js";