@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.
package/README.md CHANGED
@@ -53,6 +53,22 @@ human-in-the-loop path), composed with control-flow combinators:
53
53
  `itemVar`). `{ sequential }` runs children one at a time; `{ outputCollection,
54
54
  outputElement }` collects each child's result into a list; `{ completionCondition }`
55
55
  completes the body early.
56
+ - `w.race({ armName: { signal | timer, do }, … })` — a first-of race over ≥2
57
+ named arms, each waiting on **exactly one** event: a `signal` (a correlated
58
+ message catch, `{ correlationKey }`) or a `timer` (`{ after }` for a duration or
59
+ `{ at }` for a date, ISO or a leading-`=` FEEL expression). Whichever arm's event
60
+ fires **first wins**: its `do` block runs and the losers are cancelled. Compiles
61
+ to an **event-based gateway** fanning out to one intermediate catch event per arm
62
+ (a message- or timer-event-definition), the arm bodies rejoining downstream by an
63
+ implicit XOR merge. This is the shape a convergence loop uses to bound a
64
+ human-in-the-loop wait with a timeout.
65
+ - `w.run(...).boundary({ timer, onTimeout, interrupting?, name? })` — attach an
66
+ SLA (an interrupting timer boundary event) to the PRECEDING activity: when
67
+ `timer` elapses the activity is cancelled and the token routes to the
68
+ `onTimeout` escalation body, which then converges with the activity's normal
69
+ continuation. `timer` is an ISO-8601 duration (`PT24H`) or a FEEL
70
+ `=`-expression (`=escalationSlaTimeout`). `interrupting` defaults to `true`
71
+ (pass `false` for a non-interrupting boundary that leaves the activity running).
56
72
 
57
73
  ```ts
58
74
  import { defineFlow, WorkflowClient, Worker } from "@nanobpm/workflow";
@@ -94,13 +110,35 @@ const convergence = defineFlow("convergence-loop", (w) => {
94
110
  });
95
111
  ```
96
112
 
97
- Each combinator compiles to a BPMN primitive the engine already runs: `switch` /
113
+ Bound a human-in-the-loop wait with a timeout — `w.race` resolves to whichever
114
+ arm fires first, cancelling the loser:
115
+
116
+ ```ts
117
+ const reviewWait = defineFlow("review-wait", (w) => {
118
+ w.run("request-review", async (job) => ({ prKey: job.variables.prKey }));
119
+ w.race({
120
+ "review-arrived": {
121
+ signal: { correlationKey: "prKey" },
122
+ do: (b) => b.run("apply-review", applyReview),
123
+ },
124
+ "review-timed-out": {
125
+ timer: { after: "=reviewWaitTimeout" }, // FEEL duration, e.g. "PT48H"
126
+ do: (b) => b.run("escalate", escalate),
127
+ },
128
+ });
129
+ w.run("finalize", finalize);
130
+ });
131
+ ```
132
+
133
+ `switch` /
98
134
  `branch` → an exclusive gateway (in-order conditions, first match wins, default =
99
135
  unconditional flow); `loop` → a convergent gateway whose body falls through back to
100
136
  the head; nodes with multiple incoming flows are an implicit XOR merge; `parallel`
101
137
  → a diverging/converging **parallel gateway** pair (AND fork/join); `forEach` → a
102
138
  **parallel multi-instance** activity (a single-step body) or an embedded
103
- multi-instance sub-process (a multi-step body). See **ADR 0047**.
139
+ multi-instance sub-process (a multi-step body); `race` → an **event-based
140
+ gateway** fanning out to one intermediate catch event per arm (message or timer),
141
+ first event wins and cancels the losers. See **ADR 0047**.
104
142
 
105
143
  #### Typed data envelopes (eject to model-first with contracts intact)
106
144
 
@@ -128,6 +166,46 @@ const orders = defineFlow(
128
166
  );
129
167
  ```
130
168
 
169
+ #### Agent tasks — bind an LLM prompt to a worker (`w.task(name, { prompt })`)
170
+
171
+ An **agent service task** is a `w.task` that additionally binds an LLM **prompt
172
+ resource** to the worker that services it. Pass a `prompt` alongside the
173
+ `jobType` capability token and the emitter adds a
174
+ `<zeebe:linkedResource … resourceType="GenericScript" linkName="prompt">` to the
175
+ task's `zeebe:taskDefinition` — the shape the nano-workforce agent tasks use to
176
+ attach a prompt script to the (e.g. `senior:retro`) agent pool that runs the job:
177
+
178
+ ```ts
179
+ w.task("synthesize", {
180
+ jobType: "senior:retro", // the agent capability token
181
+ prompt: {
182
+ resourceId: "retro.md", // the GenericScript resource bound as the prompt
183
+ bindingType: "latest", // optional — how the version resolves (default "latest")
184
+ append: "=retroDigest", // optional — FEEL fed to a zeebe:ioMapping `appendPrompt` input
185
+ },
186
+ });
187
+ ```
188
+
189
+ derives:
190
+
191
+ ```xml
192
+ <bpmn:serviceTask id="synthesize" name="synthesize">
193
+ <bpmn:extensionElements>
194
+ <zeebe:taskDefinition type="senior:retro" />
195
+ <zeebe:linkedResources>
196
+ <zeebe:linkedResource resourceId="retro.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
197
+ </zeebe:linkedResources>
198
+ <zeebe:ioMapping>
199
+ <zeebe:input source="=retroDigest" target="appendPrompt" />
200
+ </zeebe:ioMapping>
201
+ </bpmn:extensionElements>
202
+ </bpmn:serviceTask>
203
+ ```
204
+
205
+ Only `resourceId` is required; omit `append` and no `ioMapping` is emitted. A
206
+ `w.task` **without** a `prompt` is unchanged — it emits no `linkedResources`.
207
+ Data envelopes (via contracts) still lift alongside the prompt binding.
208
+
131
209
  ### Imperative (Temporal-style, engine-replayed) — experimental/internal
132
210
 
133
211
  Write the orchestration as a function. `ctx.run(name, fn)` is a durable step: its
@@ -288,12 +288,18 @@ export interface EmitApi {
288
288
  /** Place a fully custom BPMN element (a slice's own render closure). */
289
289
  addNode(node: RenderNode): void;
290
290
  /** Place a `<bpmn:serviceTask>` with the standard taskDefinition/envelope
291
- * extension elements (optionally with multi-instance characteristics). */
291
+ * extension elements. `opts.mi` adds multi-instance characteristics; `opts.extraExt`
292
+ * injects additional `<bpmn:extensionElements>` content (e.g. a
293
+ * `zeebe:linkedResources` prompt binding) after the taskDefinition/properties,
294
+ * so a variant task only supplies its delta rather than re-rendering the shell. */
292
295
  addServiceTask(node: {
293
296
  name: string;
294
297
  envelopes?: NodeEnvelopes;
295
298
  jobType?: string;
296
- }, mi?: string): void;
299
+ }, opts?: {
300
+ mi?: string;
301
+ extraExt?: string;
302
+ }): void;
297
303
  /** Place a `<bpmn:intermediateCatchEvent>` with a message event definition. */
298
304
  addCatchEvent(node: {
299
305
  name: string;
@@ -356,7 +356,7 @@ class Compiler {
356
356
  addNode: (node) => {
357
357
  this.nodes.push({ ...node, scope: node.scope ?? this.currentScope() });
358
358
  },
359
- addServiceTask: (node, mi) => this.addServiceTask(node, mi),
359
+ addServiceTask: (node, opts) => this.addServiceTask(node, opts),
360
360
  addCatchEvent: (node) => this.addCatchEvent(node),
361
361
  addTimerCatchEvent: (node) => this.addTimerCatchEvent(node),
362
362
  addGateway: (id, name) => this.addGateway(id, name),
@@ -404,7 +404,8 @@ class Compiler {
404
404
  }
405
405
  this.envelopes.set(env.name, env.fields);
406
406
  }
407
- addServiceTask(node, mi = "") {
407
+ addServiceTask(node, opts = {}) {
408
+ const { mi = "", extraExt = "" } = opts;
408
409
  const type = node.jobType ?? jobType(this.flow.id, node.name);
409
410
  this.recordEnvelope(node.envelopes?.in);
410
411
  this.recordEnvelope(node.envelopes?.out);
@@ -416,6 +417,7 @@ class Compiler {
416
417
  const ext = ` <bpmn:extensionElements>\n` +
417
418
  ` <zeebe:taskDefinition type="${escapeXml(type)}" />\n` +
418
419
  (props.length ? ` <zeebe:properties>\n${props.join("\n")}\n </zeebe:properties>\n` : "") +
420
+ extraExt +
419
421
  ` </bpmn:extensionElements>`;
420
422
  const id = node.name;
421
423
  this.nodes.push({
@@ -575,6 +577,7 @@ class Compiler {
575
577
  const shapeXml = this.emitShapes();
576
578
  return (`<?xml version="1.0" encoding="UTF-8"?>\n` +
577
579
  `<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" ` +
580
+ `xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" ` +
578
581
  `xmlns:zeebe="http://camunda.org/schema/zeebe/1.0" ` +
579
582
  `xmlns:nano="https://nanobpm.io/schema/shapes/1.0" ` +
580
583
  `id="Definitions_${escapeXml(this.flow.id)}" targetNamespace="http://bpmn.io/schema/bpmn">\n` +
package/dist/index.d.ts CHANGED
@@ -2,6 +2,7 @@ export { defineWorkflow, imperativeToBpmn, replayOnce } from "./imperative.js";
2
2
  export type { Journal, ReplayStep } from "./imperative.js";
3
3
  export { defineFlow, declarativeToBpmn, externalJobTypes, walkNodes } from "./declarative.js";
4
4
  export type { FlowBuilder } from "./declarative.js";
5
+ export type { BoundaryOptions } from "./nodes/boundary.js";
5
6
  export { layoutBpmn, declarativeToLayoutedBpmn } from "./layout.js";
6
7
  export { envelope } from "./envelope.js";
7
8
  export type { Envelope, EnvelopeType, EnvelopeField, FieldSpec, ScalarType, ScalarTs, FieldTs, } from "./envelope.js";
@@ -0,0 +1,46 @@
1
+ import type { Block } from "../declarative.js";
2
+ /** Options for `.boundary(...)`: an attached timer SLA on the preceding activity.
3
+ * - `timer` — the timeout: an ISO-8601 duration (`PT24H`, `P1DT6H`) or a FEEL
4
+ * `=`-expression (`=escalationSlaTimeout`); the duration need not be a literal.
5
+ * - `onTimeout` — the escalation body run when the timer elapses (its danglers
6
+ * converge with the activity's normal continuation).
7
+ * - `interrupting` — whether firing cancels the host activity. Defaults to
8
+ * `true` (an interrupting boundary; `cancelActivity` omitted, the BPMN default).
9
+ * Pass `false` for a non-interrupting boundary (`cancelActivity="false"`).
10
+ * - `name` — an optional label for the boundary event. */
11
+ export interface BoundaryOptions<C extends object = object> {
12
+ timer: string;
13
+ onTimeout: Block<C>;
14
+ interrupting?: boolean;
15
+ name?: string;
16
+ }
17
+ declare module "../types.js" {
18
+ interface FlowNodeRegistry {
19
+ boundary: {
20
+ kind: "boundary";
21
+ /** The derived element id of the activity this boundary attaches to. */
22
+ attachedTo: string;
23
+ /** The timeout — an ISO-8601 duration or a FEEL `=`-expression. */
24
+ timer: string;
25
+ /** Whether firing cancels the host (interrupting). */
26
+ interrupting: boolean;
27
+ /** The escalation body wired from the boundary event's outgoing flow. */
28
+ onTimeout: FlowNode[];
29
+ /** Optional boundary-event label. */
30
+ label?: string;
31
+ };
32
+ }
33
+ }
34
+ declare module "../declarative.js" {
35
+ interface FlowBuilder<C extends object> {
36
+ /**
37
+ * Attach an interrupting timer boundary event (an SLA) to the PRECEDING
38
+ * activity (a `run`/`task`). When `timer` elapses, the activity is
39
+ * cancelled (for an interrupting boundary) and the token routes to the
40
+ * `onTimeout` escalation body; that body then converges with the activity's
41
+ * normal continuation. `timer` is an ISO-8601 duration or a FEEL
42
+ * `=`-expression. Chains as `w.run(...).boundary({ timer, onTimeout })`.
43
+ */
44
+ boundary(opts: BoundaryOptions<C>): FlowBuilder<C>;
45
+ }
46
+ }
@@ -0,0 +1,92 @@
1
+ // Flow-node kind: `boundary` — an activity-level attached interrupting timer
2
+ // boundary event (an SLA). Chained onto the activity it guards, it attaches a
3
+ // `<bpmn:boundaryEvent … attachedToRef=… ><bpmn:timerEventDefinition>` to that
4
+ // activity: when the timer elapses the activity is cancelled (interrupting) and
5
+ // the token routes to the `onTimeout` escalation body, which then converges with
6
+ // the activity's normal continuation. Contributed entirely from this module
7
+ // through the extension seam (epic #314, S2/#317) — NO edit to the shared
8
+ // `FlowNode` union, the `walkNodes`/`emitNode` dispatch, or the generated barrel.
9
+ import { outgoingOnly } from "../declarative.js";
10
+ import { assertTimerDuration, escapeXml } from "../xml.js";
11
+ import { registerNodeKind } from "./registry.js";
12
+ /** The flow-node kinds a boundary event may attach to. BPMN restricts boundary
13
+ * events to ACTIVITIES, so this is the set of activity kinds (the ones that
14
+ * render as a `<bpmn:serviceTask>`) — NOT merely "any node with a `name`", which
15
+ * would also admit catch events like `signal`/`timer` and produce invalid BPMN. */
16
+ const BOUNDARY_HOST_KINDS = new Set(["run", "task"]);
17
+ /** True for a FEEL expression body (a leading `=`), which renders with an
18
+ * `xsi:type="bpmn:tFormalExpression"` marker on the `timeDuration`. */
19
+ function isFeel(value) {
20
+ return value.startsWith("=");
21
+ }
22
+ function renderBoundary(id, node, outg) {
23
+ const nameAttr = node.label ? ` name="${escapeXml(node.label)}"` : "";
24
+ // An interrupting boundary omits `cancelActivity` (its BPMN default is true),
25
+ // matching the hand-authored nwf goldens; a non-interrupting one is explicit.
26
+ const cancelAttr = node.interrupting ? "" : ` cancelActivity="false"`;
27
+ const durType = isFeel(node.timer) ? ` xsi:type="bpmn:tFormalExpression"` : "";
28
+ return (` <bpmn:boundaryEvent id="${id}"${nameAttr} attachedToRef="${escapeXml(node.attachedTo)}"${cancelAttr}>\n` +
29
+ ` ${outgoingOnly(outg)}\n` +
30
+ ` <bpmn:timerEventDefinition>\n` +
31
+ ` <bpmn:timeDuration${durType}>${escapeXml(node.timer)}</bpmn:timeDuration>\n` +
32
+ ` </bpmn:timerEventDefinition>\n` +
33
+ ` </bpmn:boundaryEvent>`);
34
+ }
35
+ registerNodeKind("boundary", {
36
+ build: (api) => (opts) => {
37
+ if (!opts || typeof opts !== "object") {
38
+ throw new Error(`boundary(...) needs { timer, onTimeout }`);
39
+ }
40
+ if (typeof opts.timer !== "string" || opts.timer.trim() === "") {
41
+ throw new Error(`boundary(...) needs a { timer } duration (an ISO-8601 duration or a FEEL =expression)`);
42
+ }
43
+ const duration = opts.timer.trim();
44
+ assertTimerDuration("boundary(...) timer", duration);
45
+ if (typeof opts.onTimeout !== "function") {
46
+ throw new Error(`boundary(...) needs an { onTimeout } escalation body (b) => {…}`);
47
+ }
48
+ if (opts.interrupting !== undefined && typeof opts.interrupting !== "boolean") {
49
+ throw new Error(`boundary(...) { interrupting } must be a boolean`);
50
+ }
51
+ if (opts.name !== undefined && typeof opts.name !== "string") {
52
+ throw new Error(`boundary(...) { name } must be a string`);
53
+ }
54
+ const host = api.out[api.out.length - 1];
55
+ if (!host) {
56
+ throw new Error(`boundary(...) must follow the activity it attaches to (e.g. w.run(...).boundary(...))`);
57
+ }
58
+ if (!BOUNDARY_HOST_KINDS.has(host.kind) || !("name" in host) || typeof host.name !== "string") {
59
+ throw new Error(`boundary(...) can only attach to a named activity (run/task), not a "${host.kind}"`);
60
+ }
61
+ const onTimeout = api.child(opts.onTimeout, false);
62
+ if (onTimeout.length === 0) {
63
+ throw new Error(`boundary(...) needs a non-empty { onTimeout } escalation body (its outgoing flow has nowhere to go)`);
64
+ }
65
+ api.out.push({
66
+ kind: "boundary",
67
+ attachedTo: host.name,
68
+ timer: duration,
69
+ interrupting: opts.interrupting ?? true,
70
+ onTimeout,
71
+ label: opts.name,
72
+ });
73
+ return api.self();
74
+ },
75
+ walk: (node, recurse) => {
76
+ recurse(node.onTimeout);
77
+ },
78
+ emit: (node, incoming, loop, api) => {
79
+ // A boundary event has NO incoming sequence flow — it attaches to its host
80
+ // via `attachedToRef`. Place it, wire its outgoing to the escalation body,
81
+ // and return the host's normal continuation (`incoming`, passed through)
82
+ // PLUS the escalation danglers, so both converge on whatever follows.
83
+ const beId = `Be_${api.nextGw()}`;
84
+ api.addNode({
85
+ id: beId,
86
+ render: (_inc, outg) => renderBoundary(beId, node, outg),
87
+ });
88
+ const escalation = api.newEdge(beId);
89
+ const escOut = api.emitList(node.onTimeout, [escalation], loop);
90
+ return [...incoming, ...escOut];
91
+ },
92
+ });
@@ -82,9 +82,14 @@ registerNodeKind("forEach", {
82
82
  const only = node.body.length === 1 ? node.body[0] : undefined;
83
83
  // A single service-task body carries the MI characteristics directly; a
84
84
  // multi-step body is wrapped in an embedded MI sub-process (its own token
85
- // scope, with its own start/end).
86
- if (only && (only.kind === "run" || only.kind === "task")) {
87
- api.addServiceTask(only, mi);
85
+ // scope, with its own start/end). A prompt-carrying `task` is deliberately
86
+ // EXCLUDED from this fast path: computing its `zeebe:linkedResources`/
87
+ // `appendPrompt` delta lives in the `task` kind's own emitter, so lifting it
88
+ // here would silently drop that binding. It falls through to the sub-process
89
+ // path, where the `task` emitter renders the agent service task (prompt
90
+ // intact) inside the MI scope.
91
+ if (only && (only.kind === "run" || (only.kind === "task" && only.prompt === undefined))) {
92
+ api.addServiceTask(only, { mi });
88
93
  api.connect(incoming, only.name);
89
94
  return [api.newEdge(only.name)];
90
95
  }
@@ -1,9 +1,11 @@
1
+ import "./boundary.js";
1
2
  import "./branch.js";
2
3
  import "./break.js";
3
4
  import "./continue.js";
4
5
  import "./forEach.js";
5
6
  import "./loop.js";
6
7
  import "./parallel.js";
8
+ import "./race.js";
7
9
  import "./run.js";
8
10
  import "./signal.js";
9
11
  import "./switch.js";
@@ -5,12 +5,14 @@
5
5
  // table `makeBuilder`/`walkNodes`/the emitter route through. A new kind is
6
6
  // picked up automatically by re-running the generator (a prebuild step) — no
7
7
  // hand-edit here.
8
+ import "./boundary.js";
8
9
  import "./branch.js";
9
10
  import "./break.js";
10
11
  import "./continue.js";
11
12
  import "./forEach.js";
12
13
  import "./loop.js";
13
14
  import "./parallel.js";
15
+ import "./race.js";
14
16
  import "./run.js";
15
17
  import "./signal.js";
16
18
  import "./switch.js";
@@ -0,0 +1,54 @@
1
+ import type { FlowNode } from "../types.js";
2
+ import type { Block } from "../declarative.js";
3
+ /** One arm of a {@link FlowBuilder.race}: exactly one waited-for event — a
4
+ * `signal` (a message catch correlated on a process variable) OR a `timer` (a
5
+ * delay `{ after }` or an instant `{ at }`) — plus the `do` body that runs when
6
+ * that event wins the race. */
7
+ export type RaceArm = ({
8
+ signal: {
9
+ correlationKey: string;
10
+ };
11
+ } | {
12
+ timer: {
13
+ after: string;
14
+ } | {
15
+ at: string;
16
+ };
17
+ }) & {
18
+ do: Block;
19
+ };
20
+ /** A compiled race arm: its catch event (a `signal` or `timer` `FlowNode`, reused
21
+ * from the built-ins) and the body that runs when it wins. */
22
+ interface CompiledArm {
23
+ event: FlowNode;
24
+ body: FlowNode[];
25
+ }
26
+ declare module "../types.js" {
27
+ interface FlowNodeRegistry {
28
+ race: {
29
+ kind: "race";
30
+ arms: CompiledArm[];
31
+ };
32
+ }
33
+ }
34
+ declare module "../declarative.js" {
35
+ interface FlowBuilder<C extends object> {
36
+ /**
37
+ * A first-of event race — a BPMN event-based gateway. Each named arm waits on
38
+ * exactly one event: a `signal` (a durable message catch correlated on a
39
+ * process variable) or a `timer` (`{ after }` a delay, or `{ at }` an
40
+ * instant). The engine arms every catch at once; the FIRST to fire wins, its
41
+ * arm's `do` body runs, and the losing catch events are cancelled. Every arm's
42
+ * body converges on whatever follows the race.
43
+ *
44
+ * ```ts
45
+ * w.race({
46
+ * "review-ready": { signal: { correlationKey: "prKey" }, do: (b) => b.run("re-review", reReview) },
47
+ * "review-timeout": { timer: { after: "=reviewWaitTimeout" }, do: (b) => b.run("escalate", escalate) },
48
+ * });
49
+ * ```
50
+ */
51
+ race(arms: Record<string, RaceArm>): FlowBuilder<C>;
52
+ }
53
+ }
54
+ export {};
@@ -0,0 +1,134 @@
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
+ import { incomingOutgoing } from "../declarative.js";
22
+ import { assertIdent, assertTimerDate, assertTimerDuration, escapeXml } from "../xml.js";
23
+ import { registerNodeKind, requireNodeKind } from "./registry.js";
24
+ function hasSignal(spec) {
25
+ return "signal" in spec && spec.signal !== null && typeof spec.signal === "object";
26
+ }
27
+ function hasTimer(spec) {
28
+ return "timer" in spec && spec.timer !== null && typeof spec.timer === "object";
29
+ }
30
+ registerNodeKind("race", {
31
+ build: (api) => (arms) => {
32
+ if (arms === null || typeof arms !== "object") {
33
+ throw new Error(`race() needs an arms object { "<name>": { signal | timer, do } }`);
34
+ }
35
+ const entries = Object.entries(arms);
36
+ if (entries.length < 2) {
37
+ throw new Error(`race() needs at least two arms (an event-based gateway with fewer than two events is degenerate)`);
38
+ }
39
+ const compiled = [];
40
+ for (const [name, spec] of entries) {
41
+ if (spec === null || typeof spec !== "object") {
42
+ throw new Error(`race() arm "${name}" must be an object { signal | timer, do }`);
43
+ }
44
+ const isSignal = hasSignal(spec);
45
+ const isTimer = hasTimer(spec);
46
+ if (isSignal === isTimer) {
47
+ throw new Error(`race() arm "${name}" needs exactly one of { signal: { correlationKey } } or { timer: { after | at } }`);
48
+ }
49
+ if (typeof spec.do !== "function") {
50
+ throw new Error(`race() arm "${name}" needs a do block (b) => {…}`);
51
+ }
52
+ api.claim(name);
53
+ let event;
54
+ if (isSignal) {
55
+ const rawKey = spec.signal.correlationKey;
56
+ if (typeof rawKey !== "string" || rawKey.trim() === "") {
57
+ throw new Error(`race() arm "${name}" signal needs a non-empty { correlationKey }`);
58
+ }
59
+ // Trim before validating/storing, in parity with the timer arm ({ after }/
60
+ // { at } are trimmed above): a value like " prKey " is usable after
61
+ // trimming, so it must not fail the NCName check on stray whitespace. The
62
+ // assertIdent context names the arm so a failure says WHICH arm is invalid.
63
+ const correlationKey = rawKey.trim();
64
+ assertIdent(`race() arm "${name}" signal correlationKey`, correlationKey);
65
+ event = { kind: "signal", name, correlationKey, payload: api.contracts[name]?.in };
66
+ }
67
+ else {
68
+ const timer = spec.timer;
69
+ // Enforce exactly-one on KEY PRESENCE, not string-ness: a runtime-invalid
70
+ // arm that supplies both keys where only one is a string (e.g.
71
+ // `{ after: 1, at: "…" }`) must be rejected here, not silently dispatched
72
+ // into `after.trim()` — which would throw an opaque TypeError. Once the
73
+ // branch is chosen, validate the value is a string before trimming.
74
+ const hasAfterKey = "after" in timer;
75
+ const hasAtKey = "at" in timer;
76
+ if (hasAfterKey === hasAtKey) {
77
+ throw new Error(`race() arm "${name}" timer needs exactly one of { after } (a delay) or { at } (an instant)`);
78
+ }
79
+ if ("after" in timer) {
80
+ if (typeof timer.after !== "string") {
81
+ throw new Error(`race() arm "${name}" timer { after } must be a string duration`);
82
+ }
83
+ const after = timer.after.trim();
84
+ assertTimerDuration(`race() arm "${name}" timer after`, after);
85
+ event = { kind: "timer", name, after };
86
+ }
87
+ else {
88
+ if (typeof timer.at !== "string") {
89
+ throw new Error(`race() arm "${name}" timer { at } must be a string instant`);
90
+ }
91
+ const at = timer.at.trim();
92
+ assertTimerDate(`race() arm "${name}" timer at`, at);
93
+ event = { kind: "timer", name, at };
94
+ }
95
+ }
96
+ compiled.push({ event, body: api.child(spec.do, false) });
97
+ }
98
+ api.out.push({ kind: "race", arms: compiled });
99
+ return api.self();
100
+ },
101
+ walk: (node, recurse) => {
102
+ // Recurse into each arm's catch event AND its body: the catch `signal` nodes
103
+ // must be visited so the shared message emitter lifts their `<bpmn:message>`
104
+ // + correlation subscription, and body `task` nodes so their external job
105
+ // types are discovered — exactly as a standalone `signal`/`task` would be.
106
+ for (const arm of node.arms)
107
+ recurse([arm.event, ...arm.body]);
108
+ },
109
+ emit: (node, incoming, loop, api) => {
110
+ const gwId = `Gw_${api.nextGw()}`;
111
+ api.addNode(eventBasedGateway(gwId, api.currentScope()));
112
+ api.connect(incoming, gwId);
113
+ const out = [];
114
+ for (const arm of node.arms) {
115
+ // Fork the gateway straight to the arm's catch event (BPMN requires an
116
+ // event-based gateway's targets to be intermediate catch events), reusing
117
+ // the arm kind's own registered emit so the catch is identical to a
118
+ // standalone `w.signal`/`w.timer`; then wire its `do` body onward.
119
+ const fork = api.newEdge(gwId);
120
+ const afterCatch = requireNodeKind(arm.event.kind).emit(arm.event, [fork], loop, api);
121
+ out.push(...api.emitList(arm.body, afterCatch, loop));
122
+ }
123
+ return out;
124
+ },
125
+ });
126
+ /** A `<bpmn:eventBasedGateway>` render node (no typed `EmitApi` primitive exists —
127
+ * built as a custom {@link RenderNode} from the exported string helpers). */
128
+ function eventBasedGateway(id, scope) {
129
+ return {
130
+ id,
131
+ scope,
132
+ render: (inc, outg) => ` <bpmn:eventBasedGateway id="${escapeXml(id)}">\n` + incomingOutgoing(inc, outg) + ` </bpmn:eventBasedGateway>`,
133
+ };
134
+ }
@@ -6,10 +6,15 @@ import { registerNodeKind } from "./registry.js";
6
6
  registerNodeKind("signal", {
7
7
  build: (api) => (name, opts) => {
8
8
  api.claim(name);
9
- if (!opts || !opts.correlationKey)
9
+ if (!opts || typeof opts.correlationKey !== "string" || opts.correlationKey.trim() === "") {
10
10
  throw new Error(`signal("${name}") needs { correlationKey }`);
11
- assertIdent("correlationKey", opts.correlationKey);
12
- api.out.push({ kind: "signal", name, correlationKey: opts.correlationKey, payload: api.contracts[name]?.in });
11
+ }
12
+ // Trim before validating/storing, in parity with the race() signal arm: a
13
+ // value like " prKey " is usable after trimming, so it must not fail the
14
+ // NCName check on stray whitespace. Name the node in the assertIdent context.
15
+ const correlationKey = opts.correlationKey.trim();
16
+ assertIdent(`signal("${name}") correlationKey`, correlationKey);
17
+ api.out.push({ kind: "signal", name, correlationKey, payload: api.contracts[name]?.in });
13
18
  return api.self();
14
19
  },
15
20
  emit: (node, incoming, _loop, api) => {
@@ -1,3 +1,22 @@
1
+ /** Binds an LLM prompt resource to an agent service task. Emits, alongside the
2
+ * task's `zeebe:taskDefinition` capability token, a
3
+ * `<zeebe:linkedResources><zeebe:linkedResource resourceId=… bindingType=…
4
+ * resourceType="GenericScript" linkName="prompt"/></zeebe:linkedResources>`
5
+ * extension — the shape the nano-workforce agent tasks use to attach a prompt
6
+ * script to the worker that services the job. */
7
+ export interface PromptBinding {
8
+ /** The `GenericScript` resource id bound as the prompt (the linkedResource
9
+ * `resourceId`, e.g. `"retro.md"`). Required and non-empty. */
10
+ resourceId: string;
11
+ /** The linkedResource `bindingType` — how the engine resolves the resource
12
+ * version at deploy time. Defaults to `"latest"`. */
13
+ bindingType?: string;
14
+ /** Optional prompt addendum: a FEEL expression (or literal) fed to the worker
15
+ * through a `<zeebe:ioMapping><zeebe:input source=… target="appendPrompt"/>`
16
+ * input, the convention the agent tasks use to append runtime context to the
17
+ * bound prompt. Omitted → no `ioMapping` is emitted. */
18
+ append?: string;
19
+ }
1
20
  declare module "../types.js" {
2
21
  interface FlowNodeRegistry {
3
22
  task: {
@@ -5,7 +24,24 @@ declare module "../types.js" {
5
24
  name: string;
6
25
  envelopes?: NodeEnvelopes;
7
26
  jobType?: string;
27
+ /** The agent prompt binding, when the task is an LLM-agent service task. */
28
+ prompt?: PromptBinding;
8
29
  };
9
30
  }
10
31
  }
11
- export {};
32
+ declare module "../declarative.js" {
33
+ interface FlowBuilder<C extends object> {
34
+ /**
35
+ * An AGENT external service task: as `task`, but additionally binds an LLM
36
+ * `prompt` resource to the worker via a `zeebe:linkedResource`
37
+ * (`resourceType="GenericScript" linkName="prompt"`). Pair it with a
38
+ * `{ jobType }` capability token (e.g. `senior:retro`) so an agent pool
39
+ * services it. `prompt.append` (optional) feeds a FEEL expression to the
40
+ * worker through a `zeebe:ioMapping` `appendPrompt` input.
41
+ */
42
+ task<K extends string>(name: K, opts: {
43
+ jobType?: string;
44
+ prompt: PromptBinding;
45
+ }): FlowBuilder<C>;
46
+ }
47
+ }