@nanobpm/workflow 0.13.1 → 0.15.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
@@ -73,13 +73,19 @@ approval gate rendered on a task list), composed with control-flow combinators:
73
73
  (a message- or timer-event-definition), the arm bodies rejoining downstream by an
74
74
  implicit XOR merge. This is the shape a convergence loop uses to bound a
75
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
76
+ - `w.run(...).boundary({ timer, onTimeout, interrupting?, fireAndForget?, name? })`
77
+ — attach an SLA (a timer boundary event) to the PRECEDING
78
+ activity: when `timer` elapses the boundary fires and the token routes to
79
+ the `onTimeout` escalation body, which then converges with the activity's normal
80
+ continuation (and, for an interrupting boundary — the default — the host activity
81
+ is cancelled). `timer` is an ISO-8601 duration (`PT24H`) or a FEEL
81
82
  `=`-expression (`=escalationSlaTimeout`). `interrupting` defaults to `true`
82
83
  (pass `false` for a non-interrupting boundary that leaves the activity running).
84
+ Pass `fireAndForget: true` for a NON-CONVERGING escape path — the `onTimeout`
85
+ body ends in a `<bpmn:endEvent>` and contributes no danglers to the
86
+ continuation (the correct shape for a non-interrupting reviewer nudge: a
87
+ side-effect that ends its own token and must not re-enter the reviewed
88
+ activity).
83
89
 
84
90
  ```ts
85
91
  import { defineFlow, WorkflowClient, Worker } from "@nanobpm/workflow";
@@ -2,16 +2,25 @@ import type { Block } from "../declarative.js";
2
2
  /** Options for `.boundary(...)`: an attached timer SLA on the preceding activity.
3
3
  * - `timer` — the timeout: an ISO-8601 duration (`PT24H`, `P1DT6H`) or a FEEL
4
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).
5
+ * - `onTimeout` — the escalation body run when the timer elapses. By default its
6
+ * danglers converge with the activity's normal continuation; with
7
+ * `fireAndForget: true` the body is capped by an end event instead (see below).
7
8
  * - `interrupting` — whether firing cancels the host activity. Defaults to
8
9
  * `true` (an interrupting boundary; `cancelActivity` omitted, the BPMN default).
9
10
  * Pass `false` for a non-interrupting boundary (`cancelActivity="false"`).
11
+ * - `fireAndForget` — when `true`, the `onTimeout` escalation body is a
12
+ * NON-CONVERGING escape path: its danglers terminate in a `<bpmn:endEvent>`
13
+ * rather than merging back into the host activity's continuation. This is the
14
+ * correct model for a non-interrupting reviewer nudge (an SLA side-effect that
15
+ * ends its own token and must NOT re-enter the reviewed activity). Defaults to
16
+ * `false` (the converging boundary). Typically paired with
17
+ * `interrupting: false`.
10
18
  * - `name` — an optional label for the boundary event. */
11
19
  export interface BoundaryOptions<C extends object = object> {
12
20
  timer: string;
13
21
  onTimeout: Block<C>;
14
22
  interrupting?: boolean;
23
+ fireAndForget?: boolean;
15
24
  name?: string;
16
25
  }
17
26
  declare module "../types.js" {
@@ -24,6 +33,9 @@ declare module "../types.js" {
24
33
  timer: string;
25
34
  /** Whether firing cancels the host (interrupting). */
26
35
  interrupting: boolean;
36
+ /** When true, the escalation body is capped by an end event and does NOT
37
+ * converge with the host activity's continuation (fire-and-forget). */
38
+ fireAndForget: boolean;
27
39
  /** The escalation body wired from the boundary event's outgoing flow. */
28
40
  onTimeout: FlowNode[];
29
41
  /** Optional boundary-event label. */
@@ -40,6 +52,11 @@ declare module "../declarative.js" {
40
52
  * `onTimeout` escalation body; that body then converges with the activity's
41
53
  * normal continuation. `timer` is an ISO-8601 duration or a FEEL
42
54
  * `=`-expression. Chains as `w.run(...).boundary({ timer, onTimeout })`.
55
+ *
56
+ * Pass `fireAndForget: true` for a NON-CONVERGING escape path: the
57
+ * `onTimeout` body is capped by a `<bpmn:endEvent>` and contributes no
58
+ * danglers to the host continuation — the correct model for a
59
+ * non-interrupting reviewer nudge (typically with `interrupting: false`).
43
60
  */
44
61
  boundary(opts: BoundaryOptions<C>): FlowBuilder<C>;
45
62
  }
@@ -1,11 +1,15 @@
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
1
+ // Flow-node kind: `boundary` — an activity-level attached timer boundary event
2
+ // (an SLA). Chained onto the activity it guards, it attaches a
3
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.
4
+ // activity: when the timer elapses the boundary fires and the token routes to
5
+ // the `onTimeout` escalation body (and, for an interrupting boundary — the
6
+ // default — the host activity is cancelled; a non-interrupting boundary leaves
7
+ // it running). By default the escalation body then converges with
8
+ // the activity's normal continuation — OR, with `fireAndForget: true`, ends in a
9
+ // `<bpmn:endEvent>` without converging (a non-converging escape path). Contributed
10
+ // entirely from this module through the extension seam (epic #314, S2/#317) — NO
11
+ // edit to the shared `FlowNode` union, the `walkNodes`/`emitNode` dispatch, or the
12
+ // generated barrel.
9
13
  import { outgoingOnly } from "../declarative.js";
10
14
  import { assertTimerDuration, escapeXml } from "../xml.js";
11
15
  import { registerNodeKind } from "./registry.js";
@@ -48,6 +52,9 @@ registerNodeKind("boundary", {
48
52
  if (opts.interrupting !== undefined && typeof opts.interrupting !== "boolean") {
49
53
  throw new Error(`boundary(...) { interrupting } must be a boolean`);
50
54
  }
55
+ if (opts.fireAndForget !== undefined && typeof opts.fireAndForget !== "boolean") {
56
+ throw new Error(`boundary(...) { fireAndForget } must be a boolean`);
57
+ }
51
58
  if (opts.name !== undefined && typeof opts.name !== "string") {
52
59
  throw new Error(`boundary(...) { name } must be a string`);
53
60
  }
@@ -67,6 +74,7 @@ registerNodeKind("boundary", {
67
74
  attachedTo: host.name,
68
75
  timer: duration,
69
76
  interrupting: opts.interrupting ?? true,
77
+ fireAndForget: opts.fireAndForget ?? false,
70
78
  onTimeout,
71
79
  label: opts.name,
72
80
  });
@@ -78,8 +86,7 @@ registerNodeKind("boundary", {
78
86
  emit: (node, incoming, loop, api) => {
79
87
  // A boundary event has NO incoming sequence flow — it attaches to its host
80
88
  // 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.
89
+ // then either converge or fire-and-forget (see below).
83
90
  const beId = `Be_${api.nextGw()}`;
84
91
  api.addNode({
85
92
  id: beId,
@@ -87,6 +94,21 @@ registerNodeKind("boundary", {
87
94
  });
88
95
  const escalation = api.newEdge(beId);
89
96
  const escOut = api.emitList(node.onTimeout, [escalation], loop);
97
+ if (node.fireAndForget) {
98
+ // Fire-and-forget: cap the escalation body with a plain `<bpmn:endEvent>` so
99
+ // its token ENDS instead of converging into the host activity's
100
+ // continuation. Return only `incoming` (the host's own danglers), so the
101
+ // escape path contributes nothing to whatever follows the activity — no
102
+ // spurious second incoming on the reviewed task / downstream gateway.
103
+ if (escOut.length > 0) {
104
+ const endId = `${beId}_End`;
105
+ api.addPlainEnd(endId);
106
+ api.connect(escOut, endId);
107
+ }
108
+ return [...incoming];
109
+ }
110
+ // Converging boundary: return the host's normal continuation (`incoming`)
111
+ // PLUS the escalation danglers, so both converge on whatever follows.
90
112
  return [...incoming, ...escOut];
91
113
  },
92
114
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/workflow",
3
- "version": "0.13.1",
3
+ "version": "0.15.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",
@@ -1,11 +1,15 @@
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
1
+ // Flow-node kind: `boundary` — an activity-level attached timer boundary event
2
+ // (an SLA). Chained onto the activity it guards, it attaches a
3
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.
4
+ // activity: when the timer elapses the boundary fires and the token routes to
5
+ // the `onTimeout` escalation body (and, for an interrupting boundary — the
6
+ // default — the host activity is cancelled; a non-interrupting boundary leaves
7
+ // it running). By default the escalation body then converges with
8
+ // the activity's normal continuation — OR, with `fireAndForget: true`, ends in a
9
+ // `<bpmn:endEvent>` without converging (a non-converging escape path). Contributed
10
+ // entirely from this module through the extension seam (epic #314, S2/#317) — NO
11
+ // edit to the shared `FlowNode` union, the `walkNodes`/`emitNode` dispatch, or the
12
+ // generated barrel.
9
13
 
10
14
  import type { FlowNode } from "../types.js";
11
15
  import type { Block, Edge } from "../declarative.js";
@@ -16,16 +20,25 @@ import { registerNodeKind } from "./registry.js";
16
20
  /** Options for `.boundary(...)`: an attached timer SLA on the preceding activity.
17
21
  * - `timer` — the timeout: an ISO-8601 duration (`PT24H`, `P1DT6H`) or a FEEL
18
22
  * `=`-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).
23
+ * - `onTimeout` — the escalation body run when the timer elapses. By default its
24
+ * danglers converge with the activity's normal continuation; with
25
+ * `fireAndForget: true` the body is capped by an end event instead (see below).
21
26
  * - `interrupting` — whether firing cancels the host activity. Defaults to
22
27
  * `true` (an interrupting boundary; `cancelActivity` omitted, the BPMN default).
23
28
  * Pass `false` for a non-interrupting boundary (`cancelActivity="false"`).
29
+ * - `fireAndForget` — when `true`, the `onTimeout` escalation body is a
30
+ * NON-CONVERGING escape path: its danglers terminate in a `<bpmn:endEvent>`
31
+ * rather than merging back into the host activity's continuation. This is the
32
+ * correct model for a non-interrupting reviewer nudge (an SLA side-effect that
33
+ * ends its own token and must NOT re-enter the reviewed activity). Defaults to
34
+ * `false` (the converging boundary). Typically paired with
35
+ * `interrupting: false`.
24
36
  * - `name` — an optional label for the boundary event. */
25
37
  export interface BoundaryOptions<C extends object = object> {
26
38
  timer: string;
27
39
  onTimeout: Block<C>;
28
40
  interrupting?: boolean;
41
+ fireAndForget?: boolean;
29
42
  name?: string;
30
43
  }
31
44
 
@@ -39,6 +52,9 @@ declare module "../types.js" {
39
52
  timer: string;
40
53
  /** Whether firing cancels the host (interrupting). */
41
54
  interrupting: boolean;
55
+ /** When true, the escalation body is capped by an end event and does NOT
56
+ * converge with the host activity's continuation (fire-and-forget). */
57
+ fireAndForget: boolean;
42
58
  /** The escalation body wired from the boundary event's outgoing flow. */
43
59
  onTimeout: FlowNode[];
44
60
  /** Optional boundary-event label. */
@@ -56,6 +72,11 @@ declare module "../declarative.js" {
56
72
  * `onTimeout` escalation body; that body then converges with the activity's
57
73
  * normal continuation. `timer` is an ISO-8601 duration or a FEEL
58
74
  * `=`-expression. Chains as `w.run(...).boundary({ timer, onTimeout })`.
75
+ *
76
+ * Pass `fireAndForget: true` for a NON-CONVERGING escape path: the
77
+ * `onTimeout` body is capped by a `<bpmn:endEvent>` and contributes no
78
+ * danglers to the host continuation — the correct model for a
79
+ * non-interrupting reviewer nudge (typically with `interrupting: false`).
59
80
  */
60
81
  boundary(opts: BoundaryOptions<C>): FlowBuilder<C>;
61
82
  }
@@ -105,6 +126,9 @@ registerNodeKind("boundary", {
105
126
  if (opts.interrupting !== undefined && typeof opts.interrupting !== "boolean") {
106
127
  throw new Error(`boundary(...) { interrupting } must be a boolean`);
107
128
  }
129
+ if (opts.fireAndForget !== undefined && typeof opts.fireAndForget !== "boolean") {
130
+ throw new Error(`boundary(...) { fireAndForget } must be a boolean`);
131
+ }
108
132
  if (opts.name !== undefined && typeof opts.name !== "string") {
109
133
  throw new Error(`boundary(...) { name } must be a string`);
110
134
  }
@@ -124,6 +148,7 @@ registerNodeKind("boundary", {
124
148
  attachedTo: host.name,
125
149
  timer: duration,
126
150
  interrupting: opts.interrupting ?? true,
151
+ fireAndForget: opts.fireAndForget ?? false,
127
152
  onTimeout,
128
153
  label: opts.name,
129
154
  });
@@ -135,8 +160,7 @@ registerNodeKind("boundary", {
135
160
  emit: (node, incoming, loop, api) => {
136
161
  // A boundary event has NO incoming sequence flow — it attaches to its host
137
162
  // 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.
163
+ // then either converge or fire-and-forget (see below).
140
164
  const beId = `Be_${api.nextGw()}`;
141
165
  api.addNode({
142
166
  id: beId,
@@ -144,6 +168,21 @@ registerNodeKind("boundary", {
144
168
  });
145
169
  const escalation: Edge = api.newEdge(beId);
146
170
  const escOut = api.emitList(node.onTimeout, [escalation], loop);
171
+ if (node.fireAndForget) {
172
+ // Fire-and-forget: cap the escalation body with a plain `<bpmn:endEvent>` so
173
+ // its token ENDS instead of converging into the host activity's
174
+ // continuation. Return only `incoming` (the host's own danglers), so the
175
+ // escape path contributes nothing to whatever follows the activity — no
176
+ // spurious second incoming on the reviewed task / downstream gateway.
177
+ if (escOut.length > 0) {
178
+ const endId = `${beId}_End`;
179
+ api.addPlainEnd(endId);
180
+ api.connect(escOut, endId);
181
+ }
182
+ return [...incoming];
183
+ }
184
+ // Converging boundary: return the host's normal continuation (`incoming`)
185
+ // PLUS the escalation danglers, so both converge on whatever follows.
147
186
  return [...incoming, ...escOut];
148
187
  },
149
188
  });