@nanobpm/workflow 0.13.0 → 0.14.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 +11 -5
- package/dist/client.d.ts +31 -0
- package/dist/client.js +66 -9
- package/dist/nodes/boundary.d.ts +19 -2
- package/dist/nodes/boundary.js +31 -9
- package/package.json +1 -1
- package/src/client.ts +82 -9
- package/src/nodes/boundary.ts +50 -11
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? })`
|
|
77
|
-
SLA (
|
|
78
|
-
`timer` elapses the
|
|
79
|
-
`onTimeout` escalation body, which then converges with the activity's normal
|
|
80
|
-
continuation
|
|
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";
|
package/dist/client.d.ts
CHANGED
|
@@ -66,6 +66,37 @@ export interface NanoJobWorker {
|
|
|
66
66
|
waitUpToMs?: number;
|
|
67
67
|
}): Promise<void>;
|
|
68
68
|
}
|
|
69
|
+
/** The subset of a raw `@nanobpm/nano-sdk` job worker the adapter drives. Its
|
|
70
|
+
* `start()` may be synchronous or async: on nano-sdk >=1.2.5's Falcon/auto path
|
|
71
|
+
* it is async and the SDK self-starts the worker after an asynchronous transport
|
|
72
|
+
* bind (see {@link adaptJobWorker}). */
|
|
73
|
+
export interface RawNanoJobWorker {
|
|
74
|
+
start(): void | Promise<void>;
|
|
75
|
+
stop(): void | Promise<void>;
|
|
76
|
+
stopGracefully(opts?: {
|
|
77
|
+
waitUpToMs?: number;
|
|
78
|
+
}): Promise<unknown>;
|
|
79
|
+
}
|
|
80
|
+
/** Adapt a raw `@nanobpm/nano-sdk` job worker to the {@link NanoJobWorker} handle
|
|
81
|
+
* the Worker runtime drives, making the eager `start()` NULL-SAFE.
|
|
82
|
+
*
|
|
83
|
+
* nano-sdk >=1.2.5's Falcon/auto worker SELF-STARTS after an ASYNCHRONOUS
|
|
84
|
+
* transport bind: `createJobWorker` hands back a worker whose transport is null
|
|
85
|
+
* until Nano is detected, at which point the SDK `bindTransport(t)`s it and
|
|
86
|
+
* `start()`s the worker ITSELF. Calling `start()` before that bind dereferences
|
|
87
|
+
* the null transport and — because `start()` is async — surfaces as an UNHANDLED
|
|
88
|
+
* REJECTION that crashes the process
|
|
89
|
+
* (`TypeError: Cannot read properties of null (reading 'subscribe')`, #415). The
|
|
90
|
+
* REST/manual worker, by contrast, never self-starts, so `start()` must still be
|
|
91
|
+
* called here. We resolve the version-skew by starting eagerly but NULL-SAFELY:
|
|
92
|
+
* swallow ONLY the pre-bind race (the SDK self-starts once the transport binds)
|
|
93
|
+
* and SURFACE any other start failure via `console.warn` rather than masking it.
|
|
94
|
+
* The SDK owns the start lifecycle end to end (it logs and falls back to REST on
|
|
95
|
+
* its own subscribe failure), so discarding the resolved result — as the previous
|
|
96
|
+
* `void worker.start()` already did — is safe. Pairs with the library-side
|
|
97
|
+
* null-safe, idempotent start (jwulf/nano-sdk-js#12) so neither an eager nor a
|
|
98
|
+
* duplicate start can crash. */
|
|
99
|
+
export declare function adaptJobWorker(worker: RawNanoJobWorker): NanoJobWorker;
|
|
69
100
|
/** An activated job as delivered to a nano-sdk job handler: the workflow `Job`
|
|
70
101
|
* fields plus the acknowledgement actions. */
|
|
71
102
|
export type ActivatedJob = Job & {
|
package/dist/client.js
CHANGED
|
@@ -96,6 +96,71 @@ export async function toDeployableBpmn(wf, opts = {}) {
|
|
|
96
96
|
return semantic;
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
|
+
/** Adapt a raw `@nanobpm/nano-sdk` job worker to the {@link NanoJobWorker} handle
|
|
100
|
+
* the Worker runtime drives, making the eager `start()` NULL-SAFE.
|
|
101
|
+
*
|
|
102
|
+
* nano-sdk >=1.2.5's Falcon/auto worker SELF-STARTS after an ASYNCHRONOUS
|
|
103
|
+
* transport bind: `createJobWorker` hands back a worker whose transport is null
|
|
104
|
+
* until Nano is detected, at which point the SDK `bindTransport(t)`s it and
|
|
105
|
+
* `start()`s the worker ITSELF. Calling `start()` before that bind dereferences
|
|
106
|
+
* the null transport and — because `start()` is async — surfaces as an UNHANDLED
|
|
107
|
+
* REJECTION that crashes the process
|
|
108
|
+
* (`TypeError: Cannot read properties of null (reading 'subscribe')`, #415). The
|
|
109
|
+
* REST/manual worker, by contrast, never self-starts, so `start()` must still be
|
|
110
|
+
* called here. We resolve the version-skew by starting eagerly but NULL-SAFELY:
|
|
111
|
+
* swallow ONLY the pre-bind race (the SDK self-starts once the transport binds)
|
|
112
|
+
* and SURFACE any other start failure via `console.warn` rather than masking it.
|
|
113
|
+
* The SDK owns the start lifecycle end to end (it logs and falls back to REST on
|
|
114
|
+
* its own subscribe failure), so discarding the resolved result — as the previous
|
|
115
|
+
* `void worker.start()` already did — is safe. Pairs with the library-side
|
|
116
|
+
* null-safe, idempotent start (jwulf/nano-sdk-js#12) so neither an eager nor a
|
|
117
|
+
* duplicate start can crash. */
|
|
118
|
+
export function adaptJobWorker(worker) {
|
|
119
|
+
return {
|
|
120
|
+
start: () => {
|
|
121
|
+
try {
|
|
122
|
+
const started = worker.start();
|
|
123
|
+
if (isPromiseLike(started))
|
|
124
|
+
started.then(undefined, handleStartError);
|
|
125
|
+
}
|
|
126
|
+
catch (e) {
|
|
127
|
+
handleStartError(e);
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
stop: () => worker.stop(),
|
|
131
|
+
stopGracefully: async (opts) => {
|
|
132
|
+
await worker.stopGracefully(opts);
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
function isPromiseLike(v) {
|
|
137
|
+
return isRecord(v) && typeof v.then === "function";
|
|
138
|
+
}
|
|
139
|
+
/** The SDK's pre-bind start race is a NULL/undefined-transport dereference the SDK
|
|
140
|
+
* recovers from via its own self-start: a `TypeError` from reading the transport's
|
|
141
|
+
* `subscribe` off a `null`/`undefined` transport
|
|
142
|
+
* (`Cannot read properties of null (reading 'subscribe')`, #415). Match it NARROWLY
|
|
143
|
+
* — the specific `subscribe` dereference signature, not any null/undefined deref —
|
|
144
|
+
* so a genuine start failure on the REST/manual path (or an unrelated null-deref
|
|
145
|
+
* bug during `start()`) is never mistaken for the race and silently masked. */
|
|
146
|
+
function isPreBindStartRace(e) {
|
|
147
|
+
return (e instanceof TypeError &&
|
|
148
|
+
/Cannot read propert(?:y|ies) of (?:null|undefined) \(reading ['"]subscribe['"]\)/.test(e.message));
|
|
149
|
+
}
|
|
150
|
+
/** Handle an eager-start error NULL-SAFELY. Swallow ONLY the SDK's known pre-bind
|
|
151
|
+
* transport race (it self-starts once its transport binds — see
|
|
152
|
+
* {@link adaptJobWorker}); any OTHER error is a real start failure (e.g. on the
|
|
153
|
+
* REST/manual path) and is SURFACED via `console.warn` so it is not silently
|
|
154
|
+
* masked. Never rethrows — callers rely on this to keep an async `start()` from
|
|
155
|
+
* escaping as an unhandled rejection that crashes the process. The SDK owns the
|
|
156
|
+
* start lifecycle end to end (it logs and falls back to REST on its own subscribe
|
|
157
|
+
* failure), so we surface-and-continue rather than propagate. */
|
|
158
|
+
function handleStartError(e) {
|
|
159
|
+
if (isPreBindStartRace(e))
|
|
160
|
+
return;
|
|
161
|
+
console.warn("[@nanobpm/workflow] job worker start() failed (not the known pre-bind transport " +
|
|
162
|
+
"race); the SDK owns start and REST fallback, so this is surfaced but not rethrown:", e);
|
|
163
|
+
}
|
|
99
164
|
export class WorkflowError extends Error {
|
|
100
165
|
status;
|
|
101
166
|
body;
|
|
@@ -153,15 +218,7 @@ export class WorkflowClient {
|
|
|
153
218
|
return JobActionReceiptSymbol;
|
|
154
219
|
},
|
|
155
220
|
});
|
|
156
|
-
return
|
|
157
|
-
start: () => {
|
|
158
|
-
void worker.start();
|
|
159
|
-
},
|
|
160
|
-
stop: () => worker.stop(),
|
|
161
|
-
stopGracefully: async (opts) => {
|
|
162
|
-
await worker.stopGracefully(opts);
|
|
163
|
-
},
|
|
164
|
-
};
|
|
221
|
+
return adaptJobWorker(worker);
|
|
165
222
|
},
|
|
166
223
|
};
|
|
167
224
|
}
|
package/dist/nodes/boundary.d.ts
CHANGED
|
@@ -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
|
|
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
|
}
|
package/dist/nodes/boundary.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
|
-
// Flow-node kind: `boundary` — an activity-level attached
|
|
2
|
-
//
|
|
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
|
|
5
|
-
// the
|
|
6
|
-
// the activity
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
-
//
|
|
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.
|
|
3
|
+
"version": "0.14.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/client.ts
CHANGED
|
@@ -154,6 +154,87 @@ export interface NanoJobWorker {
|
|
|
154
154
|
stopGracefully?(opts?: { waitUpToMs?: number }): Promise<void>;
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
/** The subset of a raw `@nanobpm/nano-sdk` job worker the adapter drives. Its
|
|
158
|
+
* `start()` may be synchronous or async: on nano-sdk >=1.2.5's Falcon/auto path
|
|
159
|
+
* it is async and the SDK self-starts the worker after an asynchronous transport
|
|
160
|
+
* bind (see {@link adaptJobWorker}). */
|
|
161
|
+
export interface RawNanoJobWorker {
|
|
162
|
+
start(): void | Promise<void>;
|
|
163
|
+
stop(): void | Promise<void>;
|
|
164
|
+
stopGracefully(opts?: { waitUpToMs?: number }): Promise<unknown>;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Adapt a raw `@nanobpm/nano-sdk` job worker to the {@link NanoJobWorker} handle
|
|
168
|
+
* the Worker runtime drives, making the eager `start()` NULL-SAFE.
|
|
169
|
+
*
|
|
170
|
+
* nano-sdk >=1.2.5's Falcon/auto worker SELF-STARTS after an ASYNCHRONOUS
|
|
171
|
+
* transport bind: `createJobWorker` hands back a worker whose transport is null
|
|
172
|
+
* until Nano is detected, at which point the SDK `bindTransport(t)`s it and
|
|
173
|
+
* `start()`s the worker ITSELF. Calling `start()` before that bind dereferences
|
|
174
|
+
* the null transport and — because `start()` is async — surfaces as an UNHANDLED
|
|
175
|
+
* REJECTION that crashes the process
|
|
176
|
+
* (`TypeError: Cannot read properties of null (reading 'subscribe')`, #415). The
|
|
177
|
+
* REST/manual worker, by contrast, never self-starts, so `start()` must still be
|
|
178
|
+
* called here. We resolve the version-skew by starting eagerly but NULL-SAFELY:
|
|
179
|
+
* swallow ONLY the pre-bind race (the SDK self-starts once the transport binds)
|
|
180
|
+
* and SURFACE any other start failure via `console.warn` rather than masking it.
|
|
181
|
+
* The SDK owns the start lifecycle end to end (it logs and falls back to REST on
|
|
182
|
+
* its own subscribe failure), so discarding the resolved result — as the previous
|
|
183
|
+
* `void worker.start()` already did — is safe. Pairs with the library-side
|
|
184
|
+
* null-safe, idempotent start (jwulf/nano-sdk-js#12) so neither an eager nor a
|
|
185
|
+
* duplicate start can crash. */
|
|
186
|
+
export function adaptJobWorker(worker: RawNanoJobWorker): NanoJobWorker {
|
|
187
|
+
return {
|
|
188
|
+
start: () => {
|
|
189
|
+
try {
|
|
190
|
+
const started = worker.start();
|
|
191
|
+
if (isPromiseLike(started)) started.then(undefined, handleStartError);
|
|
192
|
+
} catch (e) {
|
|
193
|
+
handleStartError(e);
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
stop: () => worker.stop(),
|
|
197
|
+
stopGracefully: async (opts) => {
|
|
198
|
+
await worker.stopGracefully(opts);
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function isPromiseLike(v: unknown): v is PromiseLike<unknown> {
|
|
204
|
+
return isRecord(v) && typeof v.then === "function";
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The SDK's pre-bind start race is a NULL/undefined-transport dereference the SDK
|
|
208
|
+
* recovers from via its own self-start: a `TypeError` from reading the transport's
|
|
209
|
+
* `subscribe` off a `null`/`undefined` transport
|
|
210
|
+
* (`Cannot read properties of null (reading 'subscribe')`, #415). Match it NARROWLY
|
|
211
|
+
* — the specific `subscribe` dereference signature, not any null/undefined deref —
|
|
212
|
+
* so a genuine start failure on the REST/manual path (or an unrelated null-deref
|
|
213
|
+
* bug during `start()`) is never mistaken for the race and silently masked. */
|
|
214
|
+
function isPreBindStartRace(e: unknown): boolean {
|
|
215
|
+
return (
|
|
216
|
+
e instanceof TypeError &&
|
|
217
|
+
/Cannot read propert(?:y|ies) of (?:null|undefined) \(reading ['"]subscribe['"]\)/.test(e.message)
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Handle an eager-start error NULL-SAFELY. Swallow ONLY the SDK's known pre-bind
|
|
222
|
+
* transport race (it self-starts once its transport binds — see
|
|
223
|
+
* {@link adaptJobWorker}); any OTHER error is a real start failure (e.g. on the
|
|
224
|
+
* REST/manual path) and is SURFACED via `console.warn` so it is not silently
|
|
225
|
+
* masked. Never rethrows — callers rely on this to keep an async `start()` from
|
|
226
|
+
* escaping as an unhandled rejection that crashes the process. The SDK owns the
|
|
227
|
+
* start lifecycle end to end (it logs and falls back to REST on its own subscribe
|
|
228
|
+
* failure), so we surface-and-continue rather than propagate. */
|
|
229
|
+
function handleStartError(e: unknown): void {
|
|
230
|
+
if (isPreBindStartRace(e)) return;
|
|
231
|
+
console.warn(
|
|
232
|
+
"[@nanobpm/workflow] job worker start() failed (not the known pre-bind transport " +
|
|
233
|
+
"race); the SDK owns start and REST fallback, so this is surfaced but not rethrown:",
|
|
234
|
+
e,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
157
238
|
/** An activated job as delivered to a nano-sdk job handler: the workflow `Job`
|
|
158
239
|
* fields plus the acknowledgement actions. */
|
|
159
240
|
export type ActivatedJob = Job & {
|
|
@@ -253,15 +334,7 @@ export class WorkflowClient {
|
|
|
253
334
|
return JobActionReceiptSymbol;
|
|
254
335
|
},
|
|
255
336
|
});
|
|
256
|
-
return
|
|
257
|
-
start: () => {
|
|
258
|
-
void worker.start();
|
|
259
|
-
},
|
|
260
|
-
stop: () => worker.stop(),
|
|
261
|
-
stopGracefully: async (opts) => {
|
|
262
|
-
await worker.stopGracefully(opts);
|
|
263
|
-
},
|
|
264
|
-
};
|
|
337
|
+
return adaptJobWorker(worker);
|
|
265
338
|
},
|
|
266
339
|
};
|
|
267
340
|
}
|
package/src/nodes/boundary.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
|
-
// Flow-node kind: `boundary` — an activity-level attached
|
|
2
|
-
//
|
|
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
|
|
5
|
-
// the
|
|
6
|
-
// the activity
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
});
|