@nanobpm/workflow 0.9.0 → 0.11.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.
@@ -2,19 +2,95 @@
2
2
  // OUTSIDE this program (no locally-hosted handler). Its job type defaults to the
3
3
  // derived `${flowId}:${name}` unless overridden with `{ jobType }`. Contributed
4
4
  // from this module through the extension seam (epic #314, S0/#315).
5
- import { assertJobType } from "../xml.js";
5
+ //
6
+ // This module also owns the AGENT PROMPT BINDING (epic #314, S4/#319): an
7
+ // optional `{ prompt }` option binds an LLM prompt resource to the worker via a
8
+ // `<zeebe:linkedResource … resourceType="GenericScript" linkName="prompt">`,
9
+ // mirroring the nano-workforce agent service tasks. The extension is contributed
10
+ // entirely from this module — the `task` node's own registered emit handler and
11
+ // its option type — WITHOUT re-opening the shared `FlowNode` union, the central
12
+ // `walkNodes`/`emitNode` dispatch, or the generated barrel.
13
+ import { assertJobType, escapeXml } from "../xml.js";
6
14
  import { registerNodeKind } from "./registry.js";
15
+ /** Normalize + validate a prompt binding at authoring time, applying the
16
+ * `bindingType` default. Emitted string fields are trimmed before storing (in
17
+ * parity with how `timer` normalizes `{ after }`/`{ at }`), so a value like
18
+ * `resourceId: " retro.md "` passes validation AND is emitted without stray
19
+ * surrounding whitespace. Returns a fully-resolved binding stored on the node. */
20
+ function resolvePrompt(name, prompt) {
21
+ if (typeof prompt !== "object" || prompt === null) {
22
+ throw new Error(`task("${name}") prompt must be an object with a non-empty resourceId`);
23
+ }
24
+ const resourceId = prompt.resourceId;
25
+ if (typeof resourceId !== "string" || resourceId.trim() === "") {
26
+ throw new Error(`task("${name}") prompt.resourceId must be a non-empty string`);
27
+ }
28
+ // Only an ABSENT bindingType defaults to "latest"; `??` would also swallow an
29
+ // explicit `null`, masking an authoring mistake. Distinguish `undefined` (use
30
+ // the default) from any other non-string (reject), in parity with how `append`
31
+ // below treats `!== undefined` as "provided, so validate strictly".
32
+ const bindingType = prompt.bindingType === undefined ? "latest" : prompt.bindingType;
33
+ if (typeof bindingType !== "string" || bindingType.trim() === "") {
34
+ throw new Error(`task("${name}") prompt.bindingType must be a non-empty string`);
35
+ }
36
+ const resolved = { resourceId: resourceId.trim(), bindingType: bindingType.trim() };
37
+ if (prompt.append !== undefined) {
38
+ if (typeof prompt.append !== "string" || prompt.append.trim() === "") {
39
+ throw new Error(`task("${name}") prompt.append must be a non-empty string when provided`);
40
+ }
41
+ resolved.append = prompt.append.trim();
42
+ }
43
+ return resolved;
44
+ }
45
+ /** Build the prompt-specific `<bpmn:extensionElements>` delta appended to the
46
+ * shared service-task emission: the `zeebe:linkedResources` prompt binding and
47
+ * (optionally) a `zeebe:ioMapping` `appendPrompt` input. The task shell
48
+ * (taskDefinition + envelope properties) is rendered by the built-in
49
+ * `EmitApi.addServiceTask`, so this variant only contributes its delta and the
50
+ * envelope serialization can never drift from the plain service task. */
51
+ function promptExtensions(prompt) {
52
+ const { resourceId, bindingType, append } = prompt;
53
+ const linked = ` <zeebe:linkedResources>\n` +
54
+ ` <zeebe:linkedResource resourceId="${escapeXml(resourceId)}" ` +
55
+ `bindingType="${escapeXml(bindingType ?? "latest")}" resourceType="GenericScript" linkName="prompt" />\n` +
56
+ ` </zeebe:linkedResources>\n`;
57
+ const io = append !== undefined
58
+ ? ` <zeebe:ioMapping>\n` +
59
+ ` <zeebe:input source="${escapeXml(append)}" target="appendPrompt" />\n` +
60
+ ` </zeebe:ioMapping>\n`
61
+ : "";
62
+ return linked + io;
63
+ }
7
64
  registerNodeKind("task", {
8
65
  build: (api) => (name, opts) => {
9
66
  api.claim(name);
10
67
  const override = opts?.jobType;
11
68
  if (override !== undefined)
12
69
  assertJobType("task jobType", override);
13
- api.out.push({ kind: "task", name, envelopes: api.contractEnvelopes(name), jobType: override });
70
+ const prompt = opts?.prompt !== undefined ? resolvePrompt(name, opts.prompt) : undefined;
71
+ api.out.push({
72
+ kind: "task",
73
+ name,
74
+ envelopes: api.contractEnvelopes(name),
75
+ jobType: override,
76
+ prompt,
77
+ });
14
78
  return api.self();
15
79
  },
16
80
  emit: (node, incoming, _loop, api) => {
17
- api.addServiceTask(node);
81
+ // No prompt → the standard service-task emission is preserved unchanged (no
82
+ // `linkedResources` are emitted).
83
+ if (node.prompt === undefined) {
84
+ api.addServiceTask(node);
85
+ api.connect(incoming, node.name);
86
+ return [api.newEdge(node.name)];
87
+ }
88
+ // Prompt binding present → the shared `addServiceTask` renders the task
89
+ // shell (taskDefinition + envelope properties, recording envelopes); we only
90
+ // supply the `zeebe:linkedResource` (and optional `appendPrompt` ioMapping)
91
+ // delta, so the envelope/taskDefinition serialization has a single source.
92
+ const prompt = node.prompt;
93
+ api.addServiceTask(node, { extraExt: promptExtensions(prompt) });
18
94
  api.connect(incoming, node.name);
19
95
  return [api.newEdge(node.name)];
20
96
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/workflow",
3
- "version": "0.9.0",
3
+ "version": "0.11.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",
@@ -622,8 +622,14 @@ export interface EmitApi {
622
622
  /** Place a fully custom BPMN element (a slice's own render closure). */
623
623
  addNode(node: RenderNode): void;
624
624
  /** Place a `<bpmn:serviceTask>` with the standard taskDefinition/envelope
625
- * extension elements (optionally with multi-instance characteristics). */
626
- addServiceTask(node: { name: string; envelopes?: NodeEnvelopes; jobType?: string }, mi?: string): void;
625
+ * extension elements. `opts.mi` adds multi-instance characteristics; `opts.extraExt`
626
+ * injects additional `<bpmn:extensionElements>` content (e.g. a
627
+ * `zeebe:linkedResources` prompt binding) after the taskDefinition/properties,
628
+ * so a variant task only supplies its delta rather than re-rendering the shell. */
629
+ addServiceTask(
630
+ node: { name: string; envelopes?: NodeEnvelopes; jobType?: string },
631
+ opts?: { mi?: string; extraExt?: string },
632
+ ): void;
627
633
  /** Place a `<bpmn:intermediateCatchEvent>` with a message event definition. */
628
634
  addCatchEvent(node: { name: string }): void;
629
635
  /** Place a `<bpmn:intermediateCatchEvent>` with a timer event definition. */
@@ -674,7 +680,7 @@ class Compiler {
674
680
  addNode: (node) => {
675
681
  this.nodes.push({ ...node, scope: node.scope ?? this.currentScope() });
676
682
  },
677
- addServiceTask: (node, mi) => this.addServiceTask(node, mi),
683
+ addServiceTask: (node, opts) => this.addServiceTask(node, opts),
678
684
  addCatchEvent: (node) => this.addCatchEvent(node),
679
685
  addTimerCatchEvent: (node) => this.addTimerCatchEvent(node),
680
686
  addGateway: (id, name) => this.addGateway(id, name),
@@ -727,7 +733,11 @@ class Compiler {
727
733
  this.envelopes.set(env.name, env.fields);
728
734
  }
729
735
 
730
- private addServiceTask(node: { name: string; envelopes?: NodeEnvelopes; jobType?: string }, mi = ""): void {
736
+ private addServiceTask(
737
+ node: { name: string; envelopes?: NodeEnvelopes; jobType?: string },
738
+ opts: { mi?: string; extraExt?: string } = {},
739
+ ): void {
740
+ const { mi = "", extraExt = "" } = opts;
731
741
  const type = node.jobType ?? jobType(this.flow.id, node.name);
732
742
  this.recordEnvelope(node.envelopes?.in);
733
743
  this.recordEnvelope(node.envelopes?.out);
@@ -738,6 +748,7 @@ class Compiler {
738
748
  ` <bpmn:extensionElements>\n` +
739
749
  ` <zeebe:taskDefinition type="${escapeXml(type)}" />\n` +
740
750
  (props.length ? ` <zeebe:properties>\n${props.join("\n")}\n </zeebe:properties>\n` : "") +
751
+ extraExt +
741
752
  ` </bpmn:extensionElements>`;
742
753
  const id = node.name;
743
754
  this.nodes.push({
@@ -922,6 +933,7 @@ class Compiler {
922
933
  return (
923
934
  `<?xml version="1.0" encoding="UTF-8"?>\n` +
924
935
  `<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" ` +
936
+ `xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" ` +
925
937
  `xmlns:zeebe="http://camunda.org/schema/zeebe/1.0" ` +
926
938
  `xmlns:nano="https://nanobpm.io/schema/shapes/1.0" ` +
927
939
  `id="Definitions_${escapeXml(this.flow.id)}" targetNamespace="http://bpmn.io/schema/bpmn">\n` +
package/src/index.ts CHANGED
@@ -43,6 +43,9 @@ 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";
46
49
  export { layoutBpmn, declarativeToLayoutedBpmn } from "./layout.js";
47
50
  export { envelope } from "./envelope.js";
48
51
  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
+ });
@@ -115,9 +115,14 @@ registerNodeKind("forEach", {
115
115
  const only = node.body.length === 1 ? node.body[0] : undefined;
116
116
  // A single service-task body carries the MI characteristics directly; a
117
117
  // multi-step body is wrapped in an embedded MI sub-process (its own token
118
- // scope, with its own start/end).
119
- if (only && (only.kind === "run" || only.kind === "task")) {
120
- api.addServiceTask(only, mi);
118
+ // scope, with its own start/end). A prompt-carrying `task` is deliberately
119
+ // EXCLUDED from this fast path: computing its `zeebe:linkedResources`/
120
+ // `appendPrompt` delta lives in the `task` kind's own emitter, so lifting it
121
+ // here would silently drop that binding. It falls through to the sub-process
122
+ // path, where the `task` emitter renders the agent service task (prompt
123
+ // intact) inside the MI scope.
124
+ if (only && (only.kind === "run" || (only.kind === "task" && only.prompt === undefined))) {
125
+ api.addServiceTask(only, { mi });
121
126
  api.connect(incoming, only.name);
122
127
  return [api.newEdge(only.name)];
123
128
  }
@@ -6,12 +6,14 @@
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";
13
14
  import "./loop.js";
14
15
  import "./parallel.js";
16
+ import "./race.js";
15
17
  import "./run.js";
16
18
  import "./signal.js";
17
19
  import "./switch.js";
@@ -0,0 +1,185 @@
1
+ // Flow-node kind: `race` — a first-of event race (a BPMN event-based gateway).
2
+ // The gateway forks to one intermediate catch event per arm — a MESSAGE catch for
3
+ // a `signal` arm (correlated on a process variable) or a TIMER catch for a `timer`
4
+ // arm — and the engine arms them all at once. The FIRST event to fire wins: its
5
+ // arm's `do` body runs and the losing catch events are cancelled; each arm's body
6
+ // then converges on whatever follows the race.
7
+ //
8
+ // Contributed entirely from this module through the wave-0 extension seam (epic
9
+ // #314, S1/#316): its `FlowNode` variant (declaration-merged into the augmentable
10
+ // `FlowNodeRegistry`), its `w.race(...)` builder method (merged into
11
+ // `FlowBuilder<C>`), and its `walk`/`emit` handlers — with NO edit to the shared
12
+ // `FlowNode` union, the `walkNodes`/`emitNode` dispatch, or the generated barrel.
13
+ //
14
+ // The catch arms are modelled as nested `signal`/`timer` `FlowNode`s so the whole
15
+ // machine reuses the built-ins: `walk` recurses into them (so the shared message
16
+ // emitter lifts each `signal` arm's `<bpmn:message>` + correlation subscription
17
+ // automatically, and `WorkflowClient.signal` can correlate to it), and `emit`
18
+ // dispatches each arm's catch through its own registered handler (so a race's
19
+ // catch is byte-identical to a standalone `w.signal`/`w.timer`). Only the
20
+ // event-based gateway itself is placed here.
21
+
22
+ import type { FlowNode } from "../types.js";
23
+ import type { Block, Edge, RenderNode } from "../declarative.js";
24
+ import { incomingOutgoing } from "../declarative.js";
25
+ import { assertIdent, assertTimerDate, assertTimerDuration, escapeXml } from "../xml.js";
26
+ import { registerNodeKind, requireNodeKind } from "./registry.js";
27
+
28
+ /** One arm of a {@link FlowBuilder.race}: exactly one waited-for event — a
29
+ * `signal` (a message catch correlated on a process variable) OR a `timer` (a
30
+ * delay `{ after }` or an instant `{ at }`) — plus the `do` body that runs when
31
+ * that event wins the race. */
32
+ export type RaceArm = ({ signal: { correlationKey: string } } | { timer: { after: string } | { at: string } }) & {
33
+ do: Block;
34
+ };
35
+
36
+ /** A compiled race arm: its catch event (a `signal` or `timer` `FlowNode`, reused
37
+ * from the built-ins) and the body that runs when it wins. */
38
+ interface CompiledArm {
39
+ event: FlowNode;
40
+ body: FlowNode[];
41
+ }
42
+
43
+ declare module "../types.js" {
44
+ interface FlowNodeRegistry {
45
+ race: { kind: "race"; arms: CompiledArm[] };
46
+ }
47
+ }
48
+
49
+ declare module "../declarative.js" {
50
+ interface FlowBuilder<C extends object> {
51
+ /**
52
+ * A first-of event race — a BPMN event-based gateway. Each named arm waits on
53
+ * exactly one event: a `signal` (a durable message catch correlated on a
54
+ * process variable) or a `timer` (`{ after }` a delay, or `{ at }` an
55
+ * instant). The engine arms every catch at once; the FIRST to fire wins, its
56
+ * arm's `do` body runs, and the losing catch events are cancelled. Every arm's
57
+ * body converges on whatever follows the race.
58
+ *
59
+ * ```ts
60
+ * w.race({
61
+ * "review-ready": { signal: { correlationKey: "prKey" }, do: (b) => b.run("re-review", reReview) },
62
+ * "review-timeout": { timer: { after: "=reviewWaitTimeout" }, do: (b) => b.run("escalate", escalate) },
63
+ * });
64
+ * ```
65
+ */
66
+ race(arms: Record<string, RaceArm>): FlowBuilder<C>;
67
+ }
68
+ }
69
+
70
+ function hasSignal(spec: RaceArm): spec is { signal: { correlationKey: string } } & { do: Block } {
71
+ return "signal" in spec && spec.signal !== null && typeof spec.signal === "object";
72
+ }
73
+
74
+ function hasTimer(spec: RaceArm): spec is { timer: { after: string } | { at: string } } & { do: Block } {
75
+ return "timer" in spec && spec.timer !== null && typeof spec.timer === "object";
76
+ }
77
+
78
+ registerNodeKind("race", {
79
+ build: (api) => (arms: Record<string, RaceArm>) => {
80
+ if (arms === null || typeof arms !== "object") {
81
+ throw new Error(`race() needs an arms object { "<name>": { signal | timer, do } }`);
82
+ }
83
+ const entries = Object.entries(arms);
84
+ if (entries.length < 2) {
85
+ throw new Error(
86
+ `race() needs at least two arms (an event-based gateway with fewer than two events is degenerate)`,
87
+ );
88
+ }
89
+ const compiled: CompiledArm[] = [];
90
+ for (const [name, spec] of entries) {
91
+ if (spec === null || typeof spec !== "object") {
92
+ throw new Error(`race() arm "${name}" must be an object { signal | timer, do }`);
93
+ }
94
+ const isSignal = hasSignal(spec);
95
+ const isTimer = hasTimer(spec);
96
+ if (isSignal === isTimer) {
97
+ throw new Error(
98
+ `race() arm "${name}" needs exactly one of { signal: { correlationKey } } or { timer: { after | at } }`,
99
+ );
100
+ }
101
+ if (typeof spec.do !== "function") {
102
+ throw new Error(`race() arm "${name}" needs a do block (b) => {…}`);
103
+ }
104
+ api.claim(name);
105
+ let event: FlowNode;
106
+ if (isSignal) {
107
+ const rawKey = spec.signal.correlationKey;
108
+ if (typeof rawKey !== "string" || rawKey.trim() === "") {
109
+ throw new Error(`race() arm "${name}" signal needs a non-empty { correlationKey }`);
110
+ }
111
+ // Trim before validating/storing, in parity with the timer arm ({ after }/
112
+ // { at } are trimmed above): a value like " prKey " is usable after
113
+ // trimming, so it must not fail the NCName check on stray whitespace. The
114
+ // assertIdent context names the arm so a failure says WHICH arm is invalid.
115
+ const correlationKey = rawKey.trim();
116
+ assertIdent(`race() arm "${name}" signal correlationKey`, correlationKey);
117
+ event = { kind: "signal", name, correlationKey, payload: api.contracts[name]?.in };
118
+ } else {
119
+ const timer = spec.timer;
120
+ // Enforce exactly-one on KEY PRESENCE, not string-ness: a runtime-invalid
121
+ // arm that supplies both keys where only one is a string (e.g.
122
+ // `{ after: 1, at: "…" }`) must be rejected here, not silently dispatched
123
+ // into `after.trim()` — which would throw an opaque TypeError. Once the
124
+ // branch is chosen, validate the value is a string before trimming.
125
+ const hasAfterKey = "after" in timer;
126
+ const hasAtKey = "at" in timer;
127
+ if (hasAfterKey === hasAtKey) {
128
+ throw new Error(`race() arm "${name}" timer needs exactly one of { after } (a delay) or { at } (an instant)`);
129
+ }
130
+ if ("after" in timer) {
131
+ if (typeof timer.after !== "string") {
132
+ throw new Error(`race() arm "${name}" timer { after } must be a string duration`);
133
+ }
134
+ const after = timer.after.trim();
135
+ assertTimerDuration(`race() arm "${name}" timer after`, after);
136
+ event = { kind: "timer", name, after };
137
+ } else {
138
+ if (typeof timer.at !== "string") {
139
+ throw new Error(`race() arm "${name}" timer { at } must be a string instant`);
140
+ }
141
+ const at = timer.at.trim();
142
+ assertTimerDate(`race() arm "${name}" timer at`, at);
143
+ event = { kind: "timer", name, at };
144
+ }
145
+ }
146
+ compiled.push({ event, body: api.child(spec.do, false) });
147
+ }
148
+ api.out.push({ kind: "race", arms: compiled });
149
+ return api.self();
150
+ },
151
+ walk: (node, recurse) => {
152
+ // Recurse into each arm's catch event AND its body: the catch `signal` nodes
153
+ // must be visited so the shared message emitter lifts their `<bpmn:message>`
154
+ // + correlation subscription, and body `task` nodes so their external job
155
+ // types are discovered — exactly as a standalone `signal`/`task` would be.
156
+ for (const arm of node.arms) recurse([arm.event, ...arm.body]);
157
+ },
158
+ emit: (node, incoming, loop, api) => {
159
+ const gwId = `Gw_${api.nextGw()}`;
160
+ api.addNode(eventBasedGateway(gwId, api.currentScope()));
161
+ api.connect(incoming, gwId);
162
+ const out: Edge[] = [];
163
+ for (const arm of node.arms) {
164
+ // Fork the gateway straight to the arm's catch event (BPMN requires an
165
+ // event-based gateway's targets to be intermediate catch events), reusing
166
+ // the arm kind's own registered emit so the catch is identical to a
167
+ // standalone `w.signal`/`w.timer`; then wire its `do` body onward.
168
+ const fork = api.newEdge(gwId);
169
+ const afterCatch = requireNodeKind(arm.event.kind).emit(arm.event, [fork], loop, api);
170
+ out.push(...api.emitList(arm.body, afterCatch, loop));
171
+ }
172
+ return out;
173
+ },
174
+ });
175
+
176
+ /** A `<bpmn:eventBasedGateway>` render node (no typed `EmitApi` primitive exists —
177
+ * built as a custom {@link RenderNode} from the exported string helpers). */
178
+ function eventBasedGateway(id: string, scope: string | undefined): RenderNode {
179
+ return {
180
+ id,
181
+ scope,
182
+ render: (inc: string[], outg: string[]) =>
183
+ ` <bpmn:eventBasedGateway id="${escapeXml(id)}">\n` + incomingOutgoing(inc, outg) + ` </bpmn:eventBasedGateway>`,
184
+ };
185
+ }
@@ -15,9 +15,15 @@ declare module "../types.js" {
15
15
  registerNodeKind("signal", {
16
16
  build: (api) => (name: string, opts: { correlationKey: string }) => {
17
17
  api.claim(name);
18
- if (!opts || !opts.correlationKey) throw new Error(`signal("${name}") needs { correlationKey }`);
19
- assertIdent("correlationKey", opts.correlationKey);
20
- api.out.push({ kind: "signal", name, correlationKey: opts.correlationKey, payload: api.contracts[name]?.in });
18
+ if (!opts || typeof opts.correlationKey !== "string" || opts.correlationKey.trim() === "") {
19
+ throw new Error(`signal("${name}") needs { correlationKey }`);
20
+ }
21
+ // Trim before validating/storing, in parity with the race() signal arm: a
22
+ // value like " prKey " is usable after trimming, so it must not fail the
23
+ // NCName check on stray whitespace. Name the node in the assertIdent context.
24
+ const correlationKey = opts.correlationKey.trim();
25
+ assertIdent(`signal("${name}") correlationKey`, correlationKey);
26
+ api.out.push({ kind: "signal", name, correlationKey, payload: api.contracts[name]?.in });
21
27
  return api.self();
22
28
  },
23
29
  emit: (node, incoming, _loop, api) => {