@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 +39 -2
- package/dist/declarative.js +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/nodes/boundary.d.ts +46 -0
- package/dist/nodes/boundary.js +92 -0
- package/dist/nodes/human.d.ts +57 -0
- package/dist/nodes/human.js +111 -0
- package/dist/nodes/index.d.ts +2 -0
- package/dist/nodes/index.js +2 -0
- package/package.json +1 -1
- package/src/declarative.ts +1 -0
- package/src/index.ts +4 -0
- package/src/nodes/boundary.ts +149 -0
- package/src/nodes/human.ts +179 -0
- package/src/nodes/index.ts +2 -0
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),
|
|
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),
|
|
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
|
|
package/dist/declarative.js
CHANGED
|
@@ -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
|
+
});
|
package/dist/nodes/index.d.ts
CHANGED
package/dist/nodes/index.js
CHANGED
|
@@ -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.
|
|
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/declarative.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/nodes/index.ts
CHANGED
|
@@ -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";
|