@nanobpm/workflow 0.5.0 → 0.7.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/dist/client.d.ts +88 -23
- package/dist/client.js +62 -67
- package/dist/declarative.d.ts +30 -0
- package/dist/declarative.js +94 -5
- package/dist/index.d.ts +2 -2
- package/dist/types.d.ts +39 -1
- package/dist/worker.d.ts +37 -11
- package/dist/worker.js +77 -58
- package/dist/xml.d.ts +3 -0
- package/dist/xml.js +53 -0
- package/package.json +7 -4
- package/src/client.ts +139 -102
- package/src/declarative.ts +122 -5
- package/src/index.ts +8 -1
- package/src/types.ts +25 -0
- package/src/worker.ts +108 -62
- package/src/xml.ts +65 -0
package/src/declarative.ts
CHANGED
|
@@ -44,9 +44,10 @@ import type {
|
|
|
44
44
|
StepContract,
|
|
45
45
|
StepHandler,
|
|
46
46
|
SwitchCase,
|
|
47
|
+
TimerStart,
|
|
47
48
|
} from "./types.js";
|
|
48
49
|
import type { Envelope, EnvelopeField } from "./envelope.js";
|
|
49
|
-
import { assertIdent, assertJobType, escapeXml, jobType, messageName } from "./xml.js";
|
|
50
|
+
import { assertIdent, assertJobType, assertTimerCycle, assertTimerDate, assertTimerDuration, escapeXml, jobType, messageName } from "./xml.js";
|
|
50
51
|
|
|
51
52
|
// --- Authoring surface -------------------------------------------------------
|
|
52
53
|
|
|
@@ -100,6 +101,26 @@ export interface FlowBuilder<C extends FlowContracts = Record<string, never>> {
|
|
|
100
101
|
* payload envelope, if any, comes from the contract's `in`.
|
|
101
102
|
*/
|
|
102
103
|
signal<K extends string>(name: K, opts: { correlationKey: string }): FlowBuilder<C>;
|
|
104
|
+
/**
|
|
105
|
+
* A durable wait for a point in time (a BPMN timer intermediate catch event).
|
|
106
|
+
* Pass exactly one of `{ after }` — an ISO-8601 delay (`PT1M30S`, `P1DT6H`) or
|
|
107
|
+
* FEEL `=`-expression measured from when the token arrives — or `{ at }` — an
|
|
108
|
+
* absolute ISO-8601 instant (or FEEL expression). The engine holds the token
|
|
109
|
+
* durably until the timer fires, then continues. Use it for in-flow delays and
|
|
110
|
+
* scheduled continuations (e.g. "wait 24h, then re-poll").
|
|
111
|
+
*/
|
|
112
|
+
timer<K extends string>(name: K, opts: { after: string } | { at: string }): FlowBuilder<C>;
|
|
113
|
+
/**
|
|
114
|
+
* Make this flow's start event a durable TIMER start rather than an explicit
|
|
115
|
+
* `client.start(...)`. Must be the first statement, at the top level. Pass
|
|
116
|
+
* exactly one of `{ cycle }` — a recurring ISO-8601 interval (`R/PT1H`,
|
|
117
|
+
* `R5/PT30M`) the engine re-fires each period — `{ after }` — a one-shot delay
|
|
118
|
+
* from deployment — or `{ at }` — a one-shot absolute instant. A `cycle` start
|
|
119
|
+
* is the model-native, durable, single-fire-per-cluster replacement for an
|
|
120
|
+
* app-side cron: the schedule lives in the deployable model, and the engine
|
|
121
|
+
* (not each app replica) owns firing it exactly once.
|
|
122
|
+
*/
|
|
123
|
+
startOn(spec: { cycle: string } | { after: string } | { at: string }): FlowBuilder<C>;
|
|
103
124
|
/**
|
|
104
125
|
* A multi-way exclusive choice (a BPMN exclusive gateway). `subject` is a FEEL
|
|
105
126
|
* expression (usually a variable name); each case routes when `subject` equals
|
|
@@ -132,6 +153,8 @@ interface BuilderCtx {
|
|
|
132
153
|
handlers: Record<string, StepHandler>;
|
|
133
154
|
seen: Set<string>;
|
|
134
155
|
loopDepth: number;
|
|
156
|
+
/** Set by `startOn()` (root builder only); lifted onto the flow. */
|
|
157
|
+
startTimer?: TimerStart;
|
|
135
158
|
}
|
|
136
159
|
|
|
137
160
|
/** Ids the emitter generates for structural nodes / flows / messages. A step
|
|
@@ -163,7 +186,12 @@ function contractEnvelopes(ctx: BuilderCtx, name: string): { in?: Envelope; out?
|
|
|
163
186
|
/** Build a FlowBuilder that appends its nodes to `out`, sharing the flow-wide
|
|
164
187
|
* `ctx` (contracts, handler registry, name set, loop nesting). Structural
|
|
165
188
|
* combinators recurse with a fresh `out` array for each nested body. */
|
|
166
|
-
function makeBuilder<C extends FlowContracts>(
|
|
189
|
+
function makeBuilder<C extends FlowContracts>(
|
|
190
|
+
id: string,
|
|
191
|
+
out: FlowNode[],
|
|
192
|
+
ctx: BuilderCtx,
|
|
193
|
+
isRoot = false,
|
|
194
|
+
): FlowBuilder<C> {
|
|
167
195
|
const child = (fn: Block<C>, inLoop: boolean): FlowNode[] => {
|
|
168
196
|
const body: FlowNode[] = [];
|
|
169
197
|
const depth = inLoop ? ctx.loopDepth + 1 : ctx.loopDepth;
|
|
@@ -192,6 +220,49 @@ function makeBuilder<C extends FlowContracts>(id: string, out: FlowNode[], ctx:
|
|
|
192
220
|
out.push({ kind: "signal", name, correlationKey: opts.correlationKey, payload: ctx.contracts[name]?.in });
|
|
193
221
|
return b as unknown as FlowBuilder<C>;
|
|
194
222
|
},
|
|
223
|
+
timer(name: string, opts: { after?: string; at?: string }): FlowBuilder<C> {
|
|
224
|
+
claimName(ctx, id, name);
|
|
225
|
+
const hasAfter = typeof opts?.after === "string";
|
|
226
|
+
const hasAt = typeof opts?.at === "string";
|
|
227
|
+
if (hasAfter === hasAt) {
|
|
228
|
+
throw new Error(`timer("${name}") needs exactly one of { after } (a delay) or { at } (an instant)`);
|
|
229
|
+
}
|
|
230
|
+
if (hasAfter) {
|
|
231
|
+
const after = (opts.after as string).trim();
|
|
232
|
+
assertTimerDuration(`timer("${name}") after`, after);
|
|
233
|
+
out.push({ kind: "timer", name, after });
|
|
234
|
+
} else {
|
|
235
|
+
const at = (opts.at as string).trim();
|
|
236
|
+
assertTimerDate(`timer("${name}") at`, at);
|
|
237
|
+
out.push({ kind: "timer", name, at });
|
|
238
|
+
}
|
|
239
|
+
return b as unknown as FlowBuilder<C>;
|
|
240
|
+
},
|
|
241
|
+
startOn(spec: { cycle?: string; after?: string; at?: string }): FlowBuilder<C> {
|
|
242
|
+
if (!isRoot) {
|
|
243
|
+
throw new Error(`startOn() is only valid at the top level of flow "${id}" (not inside switch/branch/loop)`);
|
|
244
|
+
}
|
|
245
|
+
if (out.length !== 0) throw new Error(`startOn() must be the first statement in flow "${id}"`);
|
|
246
|
+
if (ctx.startTimer) throw new Error(`startOn() may be called only once in flow "${id}"`);
|
|
247
|
+
const set = (["cycle", "after", "at"] as const).filter((k) => typeof spec?.[k] === "string");
|
|
248
|
+
if (set.length !== 1) {
|
|
249
|
+
throw new Error(`startOn() needs exactly one of { cycle }, { after }, or { at } in flow "${id}"`);
|
|
250
|
+
}
|
|
251
|
+
if (spec.cycle !== undefined) {
|
|
252
|
+
const cycle = spec.cycle.trim();
|
|
253
|
+
assertTimerCycle(`startOn cycle`, cycle);
|
|
254
|
+
ctx.startTimer = { cycle };
|
|
255
|
+
} else if (spec.after !== undefined) {
|
|
256
|
+
const after = spec.after.trim();
|
|
257
|
+
assertTimerDuration(`startOn after`, after);
|
|
258
|
+
ctx.startTimer = { after };
|
|
259
|
+
} else {
|
|
260
|
+
const at = (spec.at as string).trim();
|
|
261
|
+
assertTimerDate(`startOn at`, at);
|
|
262
|
+
ctx.startTimer = { at };
|
|
263
|
+
}
|
|
264
|
+
return b as unknown as FlowBuilder<C>;
|
|
265
|
+
},
|
|
195
266
|
switch(subject: string, cases: Record<string, Block<C>> & { default?: Block<C> }): FlowBuilder<C> {
|
|
196
267
|
if (typeof subject !== "string" || subject.trim() === "") {
|
|
197
268
|
throw new Error(`switch() needs a non-empty subject expression`);
|
|
@@ -263,9 +334,11 @@ export function defineFlow(
|
|
|
263
334
|
const steps: FlowNode[] = [];
|
|
264
335
|
const handlers: Record<string, StepHandler> = {};
|
|
265
336
|
const ctx: BuilderCtx = { contracts, handlers, seen: new Set(), loopDepth: 0 };
|
|
266
|
-
build(makeBuilder<FlowContracts>(id, steps, ctx));
|
|
337
|
+
build(makeBuilder<FlowContracts>(id, steps, ctx, true));
|
|
267
338
|
if (steps.length === 0) throw new Error(`flow "${id}" declared no steps`);
|
|
268
|
-
|
|
339
|
+
const flow: DeclarativeFlow = { kind: "declarative", id, steps, handlers };
|
|
340
|
+
if (ctx.startTimer) flow.startTimer = ctx.startTimer;
|
|
341
|
+
return flow;
|
|
269
342
|
}
|
|
270
343
|
|
|
271
344
|
// --- Tree walkers ------------------------------------------------------------
|
|
@@ -404,6 +477,24 @@ class Compiler {
|
|
|
404
477
|
});
|
|
405
478
|
}
|
|
406
479
|
|
|
480
|
+
private addTimerCatchEvent(node: { name: string; after?: string; at?: string }): void {
|
|
481
|
+
const id = node.name;
|
|
482
|
+
const body =
|
|
483
|
+
node.after !== undefined
|
|
484
|
+
? ` <bpmn:timeDuration>${escapeXml(node.after)}</bpmn:timeDuration>\n`
|
|
485
|
+
: ` <bpmn:timeDate>${escapeXml(node.at as string)}</bpmn:timeDate>\n`;
|
|
486
|
+
this.nodes.push({
|
|
487
|
+
id,
|
|
488
|
+
render: (inc, outg) =>
|
|
489
|
+
` <bpmn:intermediateCatchEvent id="${escapeXml(id)}" name="${escapeXml(id)}">\n` +
|
|
490
|
+
incomingOutgoing(inc, outg) +
|
|
491
|
+
` <bpmn:timerEventDefinition>\n` +
|
|
492
|
+
body +
|
|
493
|
+
` </bpmn:timerEventDefinition>\n` +
|
|
494
|
+
` </bpmn:intermediateCatchEvent>`,
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
|
|
407
498
|
private addGateway(id: string, name?: string): RenderNode {
|
|
408
499
|
const gwNode: RenderNode = {
|
|
409
500
|
id,
|
|
@@ -441,6 +532,11 @@ class Compiler {
|
|
|
441
532
|
this.recordEnvelope(node.payload);
|
|
442
533
|
return [this.newEdge(node.name)];
|
|
443
534
|
}
|
|
535
|
+
case "timer": {
|
|
536
|
+
this.addTimerCatchEvent(node);
|
|
537
|
+
this.connect(incoming, node.name);
|
|
538
|
+
return [this.newEdge(node.name)];
|
|
539
|
+
}
|
|
444
540
|
case "switch": {
|
|
445
541
|
const id = `Gw_${this.gw++}`;
|
|
446
542
|
const gw = this.addGateway(id, node.subject);
|
|
@@ -494,11 +590,32 @@ class Compiler {
|
|
|
494
590
|
}
|
|
495
591
|
}
|
|
496
592
|
|
|
593
|
+
/** Render the Start event — a plain none start, or a timer start when the
|
|
594
|
+
* flow declared `startOn(...)`. */
|
|
595
|
+
private renderStart(outg: string[]): string {
|
|
596
|
+
const t = this.flow.startTimer;
|
|
597
|
+
if (!t) return ` <bpmn:startEvent id="Start">${outgoingOnly(outg)}</bpmn:startEvent>`;
|
|
598
|
+
const body =
|
|
599
|
+
t.cycle !== undefined
|
|
600
|
+
? `<bpmn:timeCycle>${escapeXml(t.cycle)}</bpmn:timeCycle>`
|
|
601
|
+
: t.after !== undefined
|
|
602
|
+
? `<bpmn:timeDuration>${escapeXml(t.after)}</bpmn:timeDuration>`
|
|
603
|
+
: `<bpmn:timeDate>${escapeXml(t.at as string)}</bpmn:timeDate>`;
|
|
604
|
+
return (
|
|
605
|
+
` <bpmn:startEvent id="Start">\n` +
|
|
606
|
+
` ${outgoingOnly(outg)}\n` +
|
|
607
|
+
` <bpmn:timerEventDefinition>\n` +
|
|
608
|
+
` ${body}\n` +
|
|
609
|
+
` </bpmn:timerEventDefinition>\n` +
|
|
610
|
+
` </bpmn:startEvent>`
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
|
|
497
614
|
compile(): string {
|
|
498
615
|
// Start → top-level sequence → End.
|
|
499
616
|
this.nodes.push({
|
|
500
617
|
id: "Start",
|
|
501
|
-
render: (_inc, outg) =>
|
|
618
|
+
render: (_inc, outg) => this.renderStart(outg),
|
|
502
619
|
});
|
|
503
620
|
const s0 = this.newEdge("Start");
|
|
504
621
|
const finalDanglers = this.emitList(this.flow.steps, [s0], null);
|
package/src/index.ts
CHANGED
|
@@ -55,7 +55,13 @@ export type {
|
|
|
55
55
|
FieldTs,
|
|
56
56
|
} from "./envelope.js";
|
|
57
57
|
export { WorkflowClient, WorkflowError, toBpmn, toDeployableBpmn } from "./client.js";
|
|
58
|
-
export type {
|
|
58
|
+
export type {
|
|
59
|
+
WorkflowClientOptions,
|
|
60
|
+
NanoSdkClient,
|
|
61
|
+
NanoJobWorker,
|
|
62
|
+
JobWorkerConfig,
|
|
63
|
+
ActivatedJob,
|
|
64
|
+
} from "./client.js";
|
|
59
65
|
export { Worker } from "./worker.js";
|
|
60
66
|
export type { WorkerOptions, ActivityEvent } from "./worker.js";
|
|
61
67
|
export type {
|
|
@@ -67,6 +73,7 @@ export type {
|
|
|
67
73
|
DeclarativeStep,
|
|
68
74
|
FlowNode,
|
|
69
75
|
SwitchCase,
|
|
76
|
+
TimerStart,
|
|
70
77
|
NodeEnvelopes,
|
|
71
78
|
StepContract,
|
|
72
79
|
FlowContracts,
|
package/src/types.ts
CHANGED
|
@@ -53,12 +53,34 @@ export type FlowNode =
|
|
|
53
53
|
| { kind: "run"; name: string; envelopes?: NodeEnvelopes }
|
|
54
54
|
| { kind: "task"; name: string; envelopes?: NodeEnvelopes; jobType?: string }
|
|
55
55
|
| { kind: "signal"; name: string; correlationKey: string; payload?: Envelope }
|
|
56
|
+
| ({ kind: "timer"; name: string } & TimerAt)
|
|
56
57
|
| { kind: "switch"; subject: string; cases: SwitchCase[]; default?: FlowNode[] }
|
|
57
58
|
| { kind: "branch"; condition: string; then: FlowNode[]; else?: FlowNode[] }
|
|
58
59
|
| { kind: "loop"; body: FlowNode[] }
|
|
59
60
|
| { kind: "break" }
|
|
60
61
|
| { kind: "continue" };
|
|
61
62
|
|
|
63
|
+
/** A timer intermediate-catch definition: exactly one of `after` (an ISO-8601
|
|
64
|
+
* delay or FEEL expression) or `at` (an absolute instant or FEEL expression).
|
|
65
|
+
* Encoded as an XOR so downstream consumers can rely on exactly-one. */
|
|
66
|
+
export type TimerAt =
|
|
67
|
+
| { after: string; at?: never }
|
|
68
|
+
| { at: string; after?: never };
|
|
69
|
+
|
|
70
|
+
/** A flow's start-timer: the plain none start event becomes a durable timer
|
|
71
|
+
* start the engine fires on schedule. Exactly one of:
|
|
72
|
+
* - `cycle` — a recurring ISO-8601 interval (`R/PT1H`, `R5/PT30M`) or bare
|
|
73
|
+
* duration; the engine re-fires on each period. This is the model-native,
|
|
74
|
+
* durable, single-fire-per-cluster replacement for an app-side cron.
|
|
75
|
+
* - `after` — a one-shot ISO-8601 delay (`PT10S`) measured from deployment.
|
|
76
|
+
* - `at` — a one-shot absolute instant (an ISO-8601 date-time, or a FEEL
|
|
77
|
+
* `=` expression).
|
|
78
|
+
* Encoded as an XOR so the exactly-one invariant holds at the type level. */
|
|
79
|
+
export type TimerStart =
|
|
80
|
+
| { cycle: string; after?: never; at?: never }
|
|
81
|
+
| { after: string; cycle?: never; at?: never }
|
|
82
|
+
| { at: string; cycle?: never; after?: never };
|
|
83
|
+
|
|
62
84
|
/** One case of a `switch`: routed when `subject = value` (FEEL equality). */
|
|
63
85
|
export interface SwitchCase {
|
|
64
86
|
value: string;
|
|
@@ -75,6 +97,9 @@ export interface DeclarativeFlow {
|
|
|
75
97
|
/** The flow's node tree (top-level sequence). */
|
|
76
98
|
steps: FlowNode[];
|
|
77
99
|
handlers: Record<string, StepHandler>;
|
|
100
|
+
/** When set, the flow's start event is a durable timer start (see
|
|
101
|
+
* {@link TimerStart}) rather than a plain none start. */
|
|
102
|
+
startTimer?: TimerStart;
|
|
78
103
|
}
|
|
79
104
|
|
|
80
105
|
// --- Imperative surface (Strategy B: a replayed orchestration function) -------
|
package/src/worker.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The Worker runtime — hosts one or more workflows against a gateway and drives
|
|
2
|
-
// them:
|
|
3
|
-
//
|
|
2
|
+
// them: it registers one nano-sdk job worker per derived job type, dispatches
|
|
3
|
+
// each activated job to its handler, and completes it. It generalises the two
|
|
4
|
+
// surfaces:
|
|
4
5
|
//
|
|
5
6
|
// - a declarative flow contributes one job type per `run` step, dispatched to
|
|
6
7
|
// the user handler (external `task` steps are intentionally NOT hosted here —
|
|
@@ -8,11 +9,13 @@
|
|
|
8
9
|
// - an imperative workflow contributes its single orchestrator job type,
|
|
9
10
|
// dispatched to the replay engine (which advances the journal one step).
|
|
10
11
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
12
|
+
// Transport (activation, completion, failure, long-poll, backoff/reconnect) is
|
|
13
|
+
// the nano-sdk job worker (ADR 0055), reached through `WorkflowClient.sdk`. The
|
|
14
|
+
// nano-sdk worker reschedules its poll on an activation error, so the runtime
|
|
15
|
+
// survives an engine crash/restart — the property the ADR 0044 spike proved.
|
|
14
16
|
|
|
15
17
|
import { WorkflowClient } from "./client.js";
|
|
18
|
+
import type { ActivatedJob, NanoJobWorker } from "./client.js";
|
|
16
19
|
import { replayOnce } from "./imperative.js";
|
|
17
20
|
import { walkNodes } from "./declarative.js";
|
|
18
21
|
import type { Job, JsonObject, Workflow } from "./types.js";
|
|
@@ -28,46 +31,66 @@ export interface ActivityEvent {
|
|
|
28
31
|
step?: string;
|
|
29
32
|
}
|
|
30
33
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
baseUrl?: string;
|
|
34
|
-
client?: WorkflowClient;
|
|
34
|
+
/** Options common to both ways of building a `Worker`. */
|
|
35
|
+
interface WorkerCommon {
|
|
35
36
|
workflows: Workflow[];
|
|
36
37
|
/** Worker name reported to the gateway. Default "nanobpm-workflow". */
|
|
37
38
|
name?: string;
|
|
39
|
+
/** Transport for the worker's own client (used only when `client` is not
|
|
40
|
+
* supplied): "auto" | "falcon" | "rest". Defaults to "rest" — job serving is
|
|
41
|
+
* resilience-critical, and REST long-poll reconnects with backoff after an
|
|
42
|
+
* engine restart (the ADR 0044 property), whereas the Falcon push transport
|
|
43
|
+
* does not recover a mid-stream disconnect. Instance creation, by contrast,
|
|
44
|
+
* defaults to Falcon on its own `WorkflowClient` for throughput. */
|
|
45
|
+
transport?: "auto" | "falcon" | "rest";
|
|
38
46
|
/** Long-poll timeout per activation, ms. Also bounds stop() latency. Default 10000. */
|
|
39
47
|
pollTimeoutMs?: number;
|
|
40
48
|
/** Job lock timeout, ms. Default 30000. */
|
|
41
49
|
jobTimeoutMs?: number;
|
|
42
|
-
/**
|
|
43
|
-
|
|
50
|
+
/** Max jobs handled in parallel per derived job type. Default 1. */
|
|
51
|
+
maxParallelJobs?: number;
|
|
44
52
|
onActivity?: (e: ActivityEvent) => void | Promise<void>;
|
|
45
53
|
onError?: (err: Error, context: { type: string }) => void;
|
|
46
54
|
}
|
|
47
55
|
|
|
48
|
-
|
|
56
|
+
/** Build a `Worker` from **either** a `baseUrl` (a `WorkflowClient` is created)
|
|
57
|
+
* **or** an existing `client`. The union makes TypeScript enforce that exactly
|
|
58
|
+
* one is supplied, matching the constructor's runtime requirement. */
|
|
59
|
+
export type WorkerOptions =
|
|
60
|
+
| (WorkerCommon & {
|
|
61
|
+
/** Base URL of the nanobpmn gateway; a `WorkflowClient` is created for you. */
|
|
62
|
+
baseUrl: string;
|
|
63
|
+
client?: never;
|
|
64
|
+
})
|
|
65
|
+
| (WorkerCommon & {
|
|
66
|
+
/** An existing `WorkflowClient` to serve jobs through. */
|
|
67
|
+
client: WorkflowClient;
|
|
68
|
+
baseUrl?: never;
|
|
69
|
+
});
|
|
49
70
|
|
|
50
|
-
|
|
71
|
+
type Handler = (job: Job) => Promise<{ variables: JsonObject; step?: string }>;
|
|
51
72
|
|
|
52
73
|
export class Worker {
|
|
53
74
|
private readonly client: WorkflowClient;
|
|
54
75
|
private readonly name: string;
|
|
55
76
|
private readonly pollTimeoutMs: number;
|
|
56
77
|
private readonly jobTimeoutMs: number;
|
|
57
|
-
private readonly
|
|
78
|
+
private readonly maxParallelJobs: number;
|
|
58
79
|
private readonly onActivity?: WorkerOptions["onActivity"];
|
|
59
80
|
private readonly onError?: WorkerOptions["onError"];
|
|
60
81
|
/** job type → { workflowId, handle } */
|
|
61
82
|
private readonly routes = new Map<string, { workflowId: string; handle: Handler }>();
|
|
62
83
|
private running = false;
|
|
63
|
-
private
|
|
84
|
+
private workers: NanoJobWorker[] = [];
|
|
64
85
|
|
|
65
86
|
constructor(opts: WorkerOptions) {
|
|
66
|
-
this.client =
|
|
87
|
+
this.client =
|
|
88
|
+
opts.client ??
|
|
89
|
+
new WorkflowClient({ baseUrl: requireBaseUrl(opts), transport: opts.transport ?? "rest" });
|
|
67
90
|
this.name = opts.name ?? "nanobpm-workflow";
|
|
68
91
|
this.pollTimeoutMs = opts.pollTimeoutMs ?? 10000;
|
|
69
92
|
this.jobTimeoutMs = opts.jobTimeoutMs ?? 30000;
|
|
70
|
-
this.
|
|
93
|
+
this.maxParallelJobs = opts.maxParallelJobs ?? 1;
|
|
71
94
|
this.onActivity = opts.onActivity;
|
|
72
95
|
this.onError = opts.onError;
|
|
73
96
|
for (const wf of opts.workflows) this.register(wf);
|
|
@@ -152,61 +175,84 @@ export class Worker {
|
|
|
152
175
|
return [...this.routes.keys()];
|
|
153
176
|
}
|
|
154
177
|
|
|
155
|
-
/** Begin
|
|
178
|
+
/** Begin serving. Creates one nano-sdk job worker per derived job type with
|
|
179
|
+
* `autoStart: false`, then starts each explicitly, so the poll lifecycle is
|
|
180
|
+
* deterministic and owned here rather than left to the SDK's auto-start
|
|
181
|
+
* default (matching the `JobWorkerConfig.autoStart` contract). Nothing polls
|
|
182
|
+
* before `start()` is called. */
|
|
156
183
|
start(): void {
|
|
157
184
|
if (this.running) return;
|
|
158
185
|
this.running = true;
|
|
159
|
-
this.
|
|
186
|
+
this.workers = [...this.routes.entries()].map(([type, route]) => {
|
|
187
|
+
const worker = this.client.sdk.createJobWorker({
|
|
188
|
+
jobType: type,
|
|
189
|
+
workerName: this.name,
|
|
190
|
+
maxParallelJobs: this.maxParallelJobs,
|
|
191
|
+
jobTimeoutMs: this.jobTimeoutMs,
|
|
192
|
+
pollTimeoutMs: this.pollTimeoutMs,
|
|
193
|
+
autoStart: false,
|
|
194
|
+
jobHandler: (job) => this.handleJob(type, route, job),
|
|
195
|
+
});
|
|
196
|
+
worker.start();
|
|
197
|
+
return worker;
|
|
198
|
+
});
|
|
160
199
|
}
|
|
161
200
|
|
|
162
|
-
/** Stop
|
|
201
|
+
/** Stop serving and wait for in-flight jobs to drain. Stopping is best-effort
|
|
202
|
+
* per worker: the Falcon worker's `stop` throws if it never bound a transport
|
|
203
|
+
* (detection still pending), which must not fail the whole shutdown. */
|
|
163
204
|
async stop(): Promise<void> {
|
|
164
205
|
this.running = false;
|
|
165
|
-
await Promise.all(
|
|
166
|
-
|
|
206
|
+
await Promise.all(
|
|
207
|
+
this.workers.map(async (w) => {
|
|
208
|
+
try {
|
|
209
|
+
await (w.stopGracefully
|
|
210
|
+
? w.stopGracefully({ waitUpToMs: this.pollTimeoutMs })
|
|
211
|
+
: w.stop());
|
|
212
|
+
} catch {
|
|
213
|
+
/* worker never fully started (transport unbound); nothing to drain */
|
|
214
|
+
}
|
|
215
|
+
}),
|
|
216
|
+
);
|
|
217
|
+
this.workers = [];
|
|
167
218
|
}
|
|
168
219
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
220
|
+
/** Dispatch one activated job to its route handler and acknowledge it. Returns
|
|
221
|
+
* the nano-sdk action receipt (its `complete`/`fail` result). */
|
|
222
|
+
private async handleJob(
|
|
223
|
+
type: string,
|
|
224
|
+
route: { workflowId: string; handle: Handler },
|
|
225
|
+
job: ActivatedJob,
|
|
226
|
+
): Promise<unknown> {
|
|
227
|
+
try {
|
|
228
|
+
const { variables, step } = await route.handle(job);
|
|
229
|
+
const receipt = await job.complete(variables);
|
|
230
|
+
// Job is now completed; the observer hook is isolated (emitActivity
|
|
231
|
+
// swallows its own errors) so it can never fall into the failure path.
|
|
232
|
+
await this.emitActivity({
|
|
233
|
+
workflowId: route.workflowId,
|
|
234
|
+
type,
|
|
235
|
+
jobKey: job.jobKey,
|
|
236
|
+
elementId: job.elementId,
|
|
237
|
+
step,
|
|
238
|
+
});
|
|
239
|
+
return receipt;
|
|
240
|
+
} catch (e) {
|
|
241
|
+
// A handler or completion failed. Report; the engine will redeliver the
|
|
242
|
+
// job after its lock times out (at-least-once → handlers must be
|
|
243
|
+
// idempotent). Best-effort surface it as an incident-worthy failure.
|
|
244
|
+
// Normalise non-Error throws (strings, objects) so emitError always gets
|
|
245
|
+
// an Error and errorMessage is never undefined in the failure report.
|
|
246
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
247
|
+
this.emitError(err, type);
|
|
172
248
|
try {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
// Transport/gateway error (e.g. engine restarting): back off, reconnect.
|
|
181
|
-
this.emitError(e as Error, type);
|
|
182
|
-
await sleep(this.backoffMs);
|
|
183
|
-
continue;
|
|
184
|
-
}
|
|
185
|
-
for (const job of jobs) {
|
|
186
|
-
if (!this.running) break;
|
|
187
|
-
try {
|
|
188
|
-
const { variables, step } = await route.handle(job);
|
|
189
|
-
await this.client.completeJob(job.jobKey, variables);
|
|
190
|
-
// Job is now completed; the observer hook is isolated (emitActivity
|
|
191
|
-
// swallows its own errors) so it can never fall into the failure path.
|
|
192
|
-
await this.emitActivity({
|
|
193
|
-
workflowId: route.workflowId,
|
|
194
|
-
type,
|
|
195
|
-
jobKey: job.jobKey,
|
|
196
|
-
elementId: job.elementId,
|
|
197
|
-
step,
|
|
198
|
-
});
|
|
199
|
-
} catch (e) {
|
|
200
|
-
// A handler or completion failed. Report; the engine will redeliver the
|
|
201
|
-
// job after its lock times out (at-least-once → handlers must be
|
|
202
|
-
// idempotent). Best-effort surface it as an incident-worthy failure.
|
|
203
|
-
this.emitError(e as Error, type);
|
|
204
|
-
try {
|
|
205
|
-
await this.client.failJob(job.jobKey, (e as Error).message, 0);
|
|
206
|
-
} catch {
|
|
207
|
-
/* engine may be down; the lock will expire and redeliver */
|
|
208
|
-
}
|
|
209
|
-
}
|
|
249
|
+
return await job.fail({ errorMessage: err.message, retries: 0 });
|
|
250
|
+
} catch {
|
|
251
|
+
// Even reporting the failure failed (e.g. the gateway is unreachable).
|
|
252
|
+
// Awaited + swallowed so a rejected `fail` can't escape as an unhandled
|
|
253
|
+
// rejection out of the SDK worker's handler; the engine redelivers the
|
|
254
|
+
// job once its lock times out regardless.
|
|
255
|
+
return undefined;
|
|
210
256
|
}
|
|
211
257
|
}
|
|
212
258
|
}
|
package/src/xml.ts
CHANGED
|
@@ -40,6 +40,68 @@ export function assertJobType(kind: string, value: string): void {
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// --- Timer definitions (BPMN timerEventDefinition bodies) --------------------
|
|
44
|
+
//
|
|
45
|
+
// The engine (engine-core/src/bpmn.rs) accepts a nested `timeDuration`,
|
|
46
|
+
// `timeCycle`, or `timeDate` as either a static ISO-8601 literal or a FEEL
|
|
47
|
+
// expression (a leading `=`). We validate the literal forms at authoring time so
|
|
48
|
+
// a malformed timer fails fast in the DSL rather than at deploy.
|
|
49
|
+
|
|
50
|
+
/** An ISO-8601 duration with integer week/day/hour/minute/second components
|
|
51
|
+
* (no years/months — ambiguous length — matching the engine's parser). */
|
|
52
|
+
const ISO_DURATION = /^P(?:\d+W)?(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+S)?)?$/;
|
|
53
|
+
function isIsoDuration(v: string): boolean {
|
|
54
|
+
if (!ISO_DURATION.test(v) || v === "P" || v === "PT" || v.endsWith("T")) return false;
|
|
55
|
+
return /\d/.test(v);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A recurring ISO-8601 interval: an optional `R[n]/` repeat prefix + a
|
|
59
|
+
* duration, or a bare duration (the engine repeats it unboundedly). */
|
|
60
|
+
function isIsoCycle(v: string): boolean {
|
|
61
|
+
const slash = v.indexOf("/");
|
|
62
|
+
if (slash < 0) return isIsoDuration(v);
|
|
63
|
+
const repeat = v.slice(0, slash);
|
|
64
|
+
const interval = v.slice(slash + 1);
|
|
65
|
+
return /^R\d*$/.test(repeat) && isIsoDuration(interval);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** An ISO-8601 date-time instant (a `timeDate`). A `timeDate` is an absolute
|
|
69
|
+
* instant, so a zone designator (`Z` or an explicit `±HH:MM` offset) is
|
|
70
|
+
* required — a bare local datetime like `2026-01-01T09:00:00` is ambiguous. */
|
|
71
|
+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
72
|
+
|
|
73
|
+
/** A FEEL expression body (a leading `=`, non-empty). */
|
|
74
|
+
function isFeel(v: string): boolean {
|
|
75
|
+
return v.startsWith("=") && v.length > 1;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function assertTimerDuration(kind: string, value: string): void {
|
|
79
|
+
const v = typeof value === "string" ? value.trim() : "";
|
|
80
|
+
if (v === "" || (!isFeel(v) && !isIsoDuration(v))) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`${kind} "${value}" is not an ISO-8601 duration (e.g. PT1M30S, P1DT6H) or a FEEL expression (=...)`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function assertTimerCycle(kind: string, value: string): void {
|
|
88
|
+
const v = typeof value === "string" ? value.trim() : "";
|
|
89
|
+
if (v === "" || (!isFeel(v) && !isIsoCycle(v))) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`${kind} "${value}" is not an ISO-8601 repeating interval (e.g. R/PT1H, R5/PT30M), a bare duration (e.g. PT1H), or a FEEL expression (=...)`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function assertTimerDate(kind: string, value: string): void {
|
|
97
|
+
const v = typeof value === "string" ? value.trim() : "";
|
|
98
|
+
if (v === "" || (!isFeel(v) && !ISO_DATE.test(v))) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`${kind} "${value}" is not an ISO-8601 instant (e.g. 2026-01-01T09:00:00Z) or a FEEL expression (=...)`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
43
105
|
export function assertWorkflowIds(wf: Workflow): void {
|
|
44
106
|
assertIdent("workflow id", wf.id);
|
|
45
107
|
if (wf.kind === "declarative") {
|
|
@@ -54,6 +116,9 @@ function assertNodeNames(nodes: FlowNode[]): void {
|
|
|
54
116
|
case "signal":
|
|
55
117
|
assertIdent("step name", node.name);
|
|
56
118
|
break;
|
|
119
|
+
case "timer":
|
|
120
|
+
assertIdent("step name", node.name);
|
|
121
|
+
break;
|
|
57
122
|
case "task":
|
|
58
123
|
assertIdent("step name", node.name);
|
|
59
124
|
if (node.jobType !== undefined) assertJobType("job type", node.jobType);
|