@nanobpm/workflow 0.12.0 → 0.13.1

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
@@ -236,6 +236,46 @@ Only `resourceId` is required; omit `append` and no `ioMapping` is emitted. A
236
236
  `w.task` **without** a `prompt` is unchanged — it emits no `linkedResources`.
237
237
  Data envelopes (via contracts) still lift alongside the prompt binding.
238
238
 
239
+ #### Service-task I/O mappings (`w.task`/`w.run` with `{ io }`)
240
+
241
+ Any service task — external (`w.task`) or locally-hosted (`w.run`) — can carry a
242
+ general `<zeebe:ioMapping>` of arbitrary **input** (applied on activation) and
243
+ **output** (applied on completion) variable mappings via an optional `io`. It
244
+ reuses the same `{ input?, output? }` shape as `w.human`'s `io` (a single
245
+ `HumanIoMapping` / `HumanIoEntry`, no parallel type):
246
+
247
+ ```ts
248
+ w.task("record-conformance-ack", {
249
+ jobType: "pr.conformance-ack",
250
+ io: {
251
+ input: [
252
+ { source: "=planKey", target: "planKey" },
253
+ { source: "=if (is defined(note)) then note else null", target: "note" },
254
+ ],
255
+ },
256
+ });
257
+ ```
258
+
259
+ derives:
260
+
261
+ ```xml
262
+ <bpmn:serviceTask id="record-conformance-ack" name="record-conformance-ack">
263
+ <bpmn:extensionElements>
264
+ <zeebe:taskDefinition type="pr.conformance-ack" />
265
+ <zeebe:ioMapping>
266
+ <zeebe:input source="=planKey" target="planKey" />
267
+ <zeebe:input source="=if (is defined(note)) then note else null" target="note" />
268
+ </zeebe:ioMapping>
269
+ </bpmn:extensionElements>
270
+ </bpmn:serviceTask>
271
+ ```
272
+
273
+ The `ioMapping` is emitted after `taskDefinition` (and after `linkedResources` /
274
+ envelope `properties` when present). When a task has **both** a `prompt.append`
275
+ and an explicit `io.input`, they merge into a **single** `<zeebe:ioMapping>` (the
276
+ `appendPrompt` input trails the explicit inputs) — never two. Omit `io` (or pass
277
+ an empty `{}`) and no `ioMapping` is emitted.
278
+
239
279
  ### Imperative (Temporal-style, engine-replayed) — experimental/internal
240
280
 
241
281
  Write the orchestration as a function. `ctx.run(name, fn)` is a durable step: its
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
  }
@@ -1,5 +1,6 @@
1
1
  import type { DeclarativeFlow, FlowContracts, FlowNode, JsonObject, NodeEnvelopes, StepHandler, TimerStart } from "./types.js";
2
2
  import type { Envelope } from "./envelope.js";
3
+ import type { HumanIoMapping } from "./io-mapping.js";
3
4
  import "./nodes/index.js";
4
5
  /** The TS payload type of a contract's input envelope (untyped fallback). */
5
6
  export type InPayload<Ct> = Ct extends {
@@ -42,7 +43,9 @@ export interface FlowBuilder<C extends object = object> {
42
43
  * the flow's contracts, the handler's job variables and return value are typed
43
44
  * from that contract's `in`/`out` envelopes; otherwise they are `JsonObject`.
44
45
  */
45
- run<K extends string>(name: K, handler: TypedHandler<VarsOf<C, K>, ResultOf<C, K>>): FlowBuilder<C>;
46
+ run<K extends string>(name: K, handler: TypedHandler<VarsOf<C, K>, ResultOf<C, K>>, opts?: {
47
+ io?: HumanIoMapping;
48
+ }): FlowBuilder<C>;
46
49
  /**
47
50
  * A durable activity served by a worker OUTSIDE this program (a BPMN service
48
51
  * task, but no locally-hosted handler). Its job type defaults to the derived
@@ -57,6 +60,7 @@ export interface FlowBuilder<C extends object = object> {
57
60
  */
58
61
  task<K extends string>(name: K, opts?: {
59
62
  jobType?: string;
63
+ io?: HumanIoMapping;
60
64
  }): FlowBuilder<C>;
61
65
  /**
62
66
  * A durable wait for an external/human event, correlated on a process
@@ -0,0 +1,32 @@
1
+ /** A single Zeebe I/O mapping entry — a FEEL `source` copied to/from a process
2
+ * variable `target`. */
3
+ export interface HumanIoEntry {
4
+ /** FEEL expression evaluated for the mapping (e.g. `=orderId`). */
5
+ source: string;
6
+ /** The process variable the value is written to. */
7
+ target: string;
8
+ }
9
+ /** A `<zeebe:ioMapping>`: input mappings applied on activation and output
10
+ * mappings applied on completion. */
11
+ export interface HumanIoMapping {
12
+ input?: HumanIoEntry[];
13
+ output?: HumanIoEntry[];
14
+ }
15
+ /** A present, non-empty, non-whitespace-only string — the shape every id /
16
+ * assignment / mapping-expression field must have. A blank value would emit a
17
+ * meaningless BPMN attribute (an empty ioMapping source/target, formId,
18
+ * assignee, …) that fails or misbehaves at deploy/run time, so we fail fast at
19
+ * build. Non-blank values are kept verbatim — FEEL expressions are never
20
+ * trimmed or normalized. */
21
+ export declare function isNonBlankString(v: unknown): v is string;
22
+ /** Validate an optional io mapping at authoring time. `label` is the node's
23
+ * self-description (e.g. `human("decide")` or `task("record")`) so the thrown
24
+ * message names the offending step. An `undefined` mapping is allowed (no
25
+ * ioMapping is emitted); a non-object / array value, or a malformed entry, fails
26
+ * fast. */
27
+ export declare function assertIoMapping(label: string, io: HumanIoMapping | undefined): void;
28
+ /** Emit a single `<zeebe:ioMapping>` (inputs then outputs) for the mapping, or
29
+ * the empty string when it has no entries. The 8-space element / 10-space entry
30
+ * indentation matches both the user-task and service-task emission, so a caller
31
+ * can splice the result straight into either `<bpmn:extensionElements>`. */
32
+ export declare function renderIoMapping(io: HumanIoMapping | undefined): string;
@@ -0,0 +1,67 @@
1
+ // The single source of truth for a Zeebe `<zeebe:ioMapping>` — its shape, its
2
+ // authoring-time validation, and its BPMN emission — shared by EVERY node kind
3
+ // that carries variable mappings (`human` user tasks, `task`/`run` service
4
+ // tasks). Derivation over duplication (AGENTS.md): one interface, one validator,
5
+ // one renderer, so the ioMapping surface can never drift between node kinds.
6
+ import { escapeXml } from "./xml.js";
7
+ /** A present, non-empty, non-whitespace-only string — the shape every id /
8
+ * assignment / mapping-expression field must have. A blank value would emit a
9
+ * meaningless BPMN attribute (an empty ioMapping source/target, formId,
10
+ * assignee, …) that fails or misbehaves at deploy/run time, so we fail fast at
11
+ * build. Non-blank values are kept verbatim — FEEL expressions are never
12
+ * trimmed or normalized. */
13
+ export function isNonBlankString(v) {
14
+ return typeof v === "string" && v.trim() !== "";
15
+ }
16
+ function isEntry(e) {
17
+ if (e === null || typeof e !== "object")
18
+ return false;
19
+ if (!("source" in e) || !("target" in e))
20
+ return false;
21
+ const { source, target } = e;
22
+ return isNonBlankString(source) && isNonBlankString(target);
23
+ }
24
+ function validateEntries(label, dir, entries) {
25
+ if (entries === undefined)
26
+ return;
27
+ if (!Array.isArray(entries))
28
+ throw new Error(`${label} io.${dir} must be an array of { source, target }`);
29
+ for (const e of entries) {
30
+ if (!isEntry(e)) {
31
+ throw new Error(`${label} io.${dir} entries must be { source: non-empty string, target: non-empty string }`);
32
+ }
33
+ }
34
+ }
35
+ /** Validate an optional io mapping at authoring time. `label` is the node's
36
+ * self-description (e.g. `human("decide")` or `task("record")`) so the thrown
37
+ * message names the offending step. An `undefined` mapping is allowed (no
38
+ * ioMapping is emitted); a non-object / array value, or a malformed entry, fails
39
+ * fast. */
40
+ export function assertIoMapping(label, io) {
41
+ if (io === undefined)
42
+ return;
43
+ if (io === null || typeof io !== "object" || Array.isArray(io)) {
44
+ throw new Error(`${label} { io } must be an object { input?, output? }`);
45
+ }
46
+ validateEntries(label, "input", io.input);
47
+ validateEntries(label, "output", io.output);
48
+ }
49
+ function renderMappings(entries, tag) {
50
+ if (!entries || entries.length === 0)
51
+ return "";
52
+ return entries
53
+ .map((e) => ` <zeebe:${tag} source="${escapeXml(e.source)}" target="${escapeXml(e.target)}" />\n`)
54
+ .join("");
55
+ }
56
+ /** Emit a single `<zeebe:ioMapping>` (inputs then outputs) for the mapping, or
57
+ * the empty string when it has no entries. The 8-space element / 10-space entry
58
+ * indentation matches both the user-task and service-task emission, so a caller
59
+ * can splice the result straight into either `<bpmn:extensionElements>`. */
60
+ export function renderIoMapping(io) {
61
+ if (!io || ((io.input?.length ?? 0) === 0 && (io.output?.length ?? 0) === 0))
62
+ return "";
63
+ return (` <zeebe:ioMapping>\n` +
64
+ renderMappings(io.input, "input") +
65
+ renderMappings(io.output, "output") +
66
+ ` </zeebe:ioMapping>\n`);
67
+ }
@@ -1,17 +1,5 @@
1
- /** A single Zeebe I/O mapping entry — a FEEL `source` copied to/from a process
2
- * variable `target`. */
3
- export interface HumanIoEntry {
4
- /** FEEL expression evaluated for the mapping (e.g. `=orderId`). */
5
- source: string;
6
- /** The process variable the value is written to. */
7
- target: string;
8
- }
9
- /** The `<zeebe:ioMapping>` for a user task: input mappings applied on activation
10
- * and output mappings applied on completion. */
11
- export interface HumanIoMapping {
12
- input?: HumanIoEntry[];
13
- output?: HumanIoEntry[];
14
- }
1
+ import type { HumanIoMapping } from "../io-mapping.js";
2
+ export type { HumanIoEntry, HumanIoMapping } from "../io-mapping.js";
15
3
  /** Options for {@link FlowBuilder.human}. `form` is the `zeebe:formDefinition`
16
4
  * form id; `assignee` / `candidateGroups` populate a `zeebe:assignmentDefinition`
17
5
  * (supply either or both); `io` populates a `zeebe:ioMapping`. */
@@ -54,4 +42,3 @@ declare module "../declarative.js" {
54
42
  human<K extends string>(name: K, opts: HumanOptions): FlowBuilder<C>;
55
43
  }
56
44
  }
57
- export {};
@@ -6,34 +6,13 @@
6
6
  // extension seam (epic #314, S3/#318) — no central union or dispatch is edited.
7
7
  import { escapeXml } from "../xml.js";
8
8
  import { incomingOutgoing } from "../declarative.js";
9
+ import { assertIoMapping, isNonBlankString, renderIoMapping } from "../io-mapping.js";
9
10
  import { registerNodeKind } from "./registry.js";
10
11
  /** A present, non-empty, non-whitespace-only string — the shape every human()
11
- * id / assignment / mapping-expression field must have. A blank value would
12
- * emit a meaningless BPMN attribute (an empty formId, assignee, candidateGroups,
13
- * or ioMapping source/target) that fails or misbehaves at deploy/run time, so we
14
- * fail fast at build. Non-blank values are kept verbatim — FEEL expressions are
15
- * never trimmed or normalized. */
16
- function isNonBlankString(v) {
17
- return typeof v === "string" && v.trim() !== "";
18
- }
19
- function isEntry(e) {
20
- if (e === null || typeof e !== "object")
21
- return false;
22
- if (!("source" in e) || !("target" in e))
23
- return false;
24
- const { source, target } = e;
25
- return isNonBlankString(source) && isNonBlankString(target);
26
- }
27
- function validateEntries(name, dir, entries) {
28
- if (entries === undefined)
29
- return;
30
- if (!Array.isArray(entries))
31
- throw new Error(`human("${name}") io.${dir} must be an array of { source, target }`);
32
- for (const e of entries) {
33
- if (!isEntry(e))
34
- throw new Error(`human("${name}") io.${dir} entries must be { source: non-empty string, target: non-empty string }`);
35
- }
36
- }
12
+ * id / assignment field must have. A blank value would emit a meaningless BPMN
13
+ * attribute (an empty formId, assignee, or candidateGroups) that fails or
14
+ * misbehaves at deploy/run time, so we fail fast at build. Shared with the
15
+ * ioMapping validator via `io-mapping`. */
37
16
  function renderAssignment(node) {
38
17
  if (node.assignee === undefined && node.candidateGroups === undefined)
39
18
  return "";
@@ -43,29 +22,13 @@ function renderAssignment(node) {
43
22
  ].join("");
44
23
  return ` <zeebe:assignmentDefinition${attrs} />\n`;
45
24
  }
46
- function renderMappings(entries, tag) {
47
- if (!entries || entries.length === 0)
48
- return "";
49
- return entries
50
- .map((e) => ` <zeebe:${tag} source="${escapeXml(e.source)}" target="${escapeXml(e.target)}" />\n`)
51
- .join("");
52
- }
53
- function renderIoMapping(node) {
54
- const io = node.io;
55
- if (!io || ((io.input?.length ?? 0) === 0 && (io.output?.length ?? 0) === 0))
56
- return "";
57
- return (` <zeebe:ioMapping>\n` +
58
- renderMappings(io.input, "input") +
59
- renderMappings(io.output, "output") +
60
- ` </zeebe:ioMapping>\n`);
61
- }
62
25
  function renderUserTask(node, inc, outg) {
63
26
  const id = node.name;
64
27
  const ext = ` <bpmn:extensionElements>\n` +
65
28
  ` <zeebe:formDefinition formId="${escapeXml(node.form)}" />\n` +
66
29
  ` <zeebe:userTask />\n` +
67
30
  renderAssignment(node) +
68
- renderIoMapping(node) +
31
+ renderIoMapping(node.io) +
69
32
  ` </bpmn:extensionElements>`;
70
33
  return (` <bpmn:userTask id="${escapeXml(id)}" name="${escapeXml(id)}">\n` +
71
34
  ext +
@@ -88,11 +51,7 @@ registerNodeKind("human", {
88
51
  if (opts.candidateGroups !== undefined && !isNonBlankString(opts.candidateGroups)) {
89
52
  throw new Error(`human("${name}") { candidateGroups } must be a non-empty string`);
90
53
  }
91
- if (opts.io !== undefined && (opts.io === null || typeof opts.io !== "object" || Array.isArray(opts.io))) {
92
- throw new Error(`human("${name}") { io } must be an object { input?, output? }`);
93
- }
94
- validateEntries(name, "input", opts.io?.input);
95
- validateEntries(name, "output", opts.io?.output);
54
+ assertIoMapping(`human("${name}")`, opts.io);
96
55
  api.out.push({
97
56
  kind: "human",
98
57
  name,
@@ -1,10 +1,11 @@
1
+ import type { HumanIoMapping } from "../io-mapping.js";
1
2
  declare module "../types.js" {
2
3
  interface FlowNodeRegistry {
3
4
  run: {
4
5
  kind: "run";
5
6
  name: string;
6
7
  envelopes?: NodeEnvelopes;
8
+ io?: HumanIoMapping;
7
9
  };
8
10
  }
9
11
  }
10
- export {};
package/dist/nodes/run.js CHANGED
@@ -3,18 +3,20 @@
3
3
  // entirely from this module through the extension seam (epic #314, S0/#315):
4
4
  // its `FlowNode` variant, its `w.run(...)` builder method, and its walk/emit
5
5
  // handlers — with NO edit to the shared union or a central dispatch switch.
6
+ import { assertIoMapping, renderIoMapping } from "../io-mapping.js";
6
7
  import { registerNodeKind } from "./registry.js";
7
8
  registerNodeKind("run", {
8
- build: (api) => (name, handler) => {
9
+ build: (api) => (name, handler, opts) => {
9
10
  api.claim(name);
10
11
  if (typeof handler !== "function")
11
12
  throw new Error(`run("${name}") needs a handler function`);
13
+ assertIoMapping(`run("${name}")`, opts?.io);
12
14
  api.handlers[name] = handler;
13
- api.out.push({ kind: "run", name, envelopes: api.contractEnvelopes(name) });
15
+ api.out.push({ kind: "run", name, envelopes: api.contractEnvelopes(name), io: opts?.io });
14
16
  return api.self();
15
17
  },
16
18
  emit: (node, incoming, _loop, api) => {
17
- api.addServiceTask(node);
19
+ api.addServiceTask(node, node.io !== undefined ? { extraExt: renderIoMapping(node.io) } : {});
18
20
  api.connect(incoming, node.name);
19
21
  return [api.newEdge(node.name)];
20
22
  },
@@ -1,3 +1,4 @@
1
+ import type { HumanIoMapping } from "../io-mapping.js";
1
2
  /** Binds an LLM prompt resource to an agent service task. Emits, alongside the
2
3
  * task's `zeebe:taskDefinition` capability token, a
3
4
  * `<zeebe:linkedResources><zeebe:linkedResource resourceId=… bindingType=…
@@ -26,6 +27,11 @@ declare module "../types.js" {
26
27
  jobType?: string;
27
28
  /** The agent prompt binding, when the task is an LLM-agent service task. */
28
29
  prompt?: PromptBinding;
30
+ /** An explicit `<zeebe:ioMapping>` on the service task — arbitrary input
31
+ * (and output) variable mappings, using the shared ioMapping shape. When a
32
+ * `prompt.append` is ALSO present it is folded into this single mapping as
33
+ * an extra `appendPrompt` input (no duplicate `ioMapping` element). */
34
+ io?: HumanIoMapping;
29
35
  };
30
36
  }
31
37
  }
@@ -37,11 +43,13 @@ declare module "../declarative.js" {
37
43
  * (`resourceType="GenericScript" linkName="prompt"`). Pair it with a
38
44
  * `{ jobType }` capability token (e.g. `senior:retro`) so an agent pool
39
45
  * services it. `prompt.append` (optional) feeds a FEEL expression to the
40
- * worker through a `zeebe:ioMapping` `appendPrompt` input.
46
+ * worker through a `zeebe:ioMapping` `appendPrompt` input. `io` (optional)
47
+ * adds arbitrary input/output variable mappings to the SAME `zeebe:ioMapping`.
41
48
  */
42
49
  task<K extends string>(name: K, opts: {
43
50
  jobType?: string;
44
51
  prompt: PromptBinding;
52
+ io?: HumanIoMapping;
45
53
  }): FlowBuilder<C>;
46
54
  }
47
55
  }
@@ -11,6 +11,7 @@
11
11
  // its option type — WITHOUT re-opening the shared `FlowNode` union, the central
12
12
  // `walkNodes`/`emitNode` dispatch, or the generated barrel.
13
13
  import { assertJobType, escapeXml } from "../xml.js";
14
+ import { assertIoMapping, renderIoMapping } from "../io-mapping.js";
14
15
  import { registerNodeKind } from "./registry.js";
15
16
  /** Normalize + validate a prompt binding at authoring time, applying the
16
17
  * `bindingType` default. Emitted string fields are trimmed before storing (in
@@ -42,24 +43,37 @@ function resolvePrompt(name, prompt) {
42
43
  }
43
44
  return resolved;
44
45
  }
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
46
+ /** Build the `<bpmn:extensionElements>` delta appended to the shared
47
+ * service-task emission: the optional `zeebe:linkedResources` prompt binding
48
+ * followed by a single, correctly-ordered `zeebe:ioMapping`. The task shell
48
49
  * (taskDefinition + envelope properties) is rendered by the built-in
49
50
  * `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`
51
+ * envelope serialization can never drift from the plain service task.
52
+ *
53
+ * The `ioMapping` is the SINGLE source for all variable mappings on the task:
54
+ * the explicit `io.input`/`io.output` plus — when `prompt.append` is present —
55
+ * the `appendPrompt` input, folded in as one more input entry so a task with
56
+ * both a `prompt.append` and an explicit `io.input` emits exactly one
57
+ * `<zeebe:ioMapping>` (never two). Element order matches the golden:
58
+ * `linkedResources` (when any) then `ioMapping` (when any), the latter after
59
+ * `taskDefinition`/`properties`. */
60
+ function taskExtensions(prompt, io) {
61
+ const linked = prompt !== undefined
62
+ ? ` <zeebe:linkedResources>\n` +
63
+ ` <zeebe:linkedResource resourceId="${escapeXml(prompt.resourceId)}" ` +
64
+ `bindingType="${escapeXml(prompt.bindingType ?? "latest")}" resourceType="GenericScript" linkName="prompt" />\n` +
65
+ ` </zeebe:linkedResources>\n`
61
66
  : "";
62
- return linked + io;
67
+ // Merge the explicit io mapping and the prompt's `appendPrompt` input into one
68
+ // mapping. The append input trails the explicit inputs — it appends runtime
69
+ // context to the bound prompt, so it reads naturally after the task's own
70
+ // inputs — and outputs follow, matching `renderIoMapping`'s input-then-output
71
+ // ordering.
72
+ const inputs = [...(io?.input ?? [])];
73
+ if (prompt?.append !== undefined)
74
+ inputs.push({ source: prompt.append, target: "appendPrompt" });
75
+ const merged = { input: inputs, output: io?.output ?? [] };
76
+ return linked + renderIoMapping(merged);
63
77
  }
64
78
  registerNodeKind("task", {
65
79
  build: (api) => (name, opts) => {
@@ -68,29 +82,32 @@ registerNodeKind("task", {
68
82
  if (override !== undefined)
69
83
  assertJobType("task jobType", override);
70
84
  const prompt = opts?.prompt !== undefined ? resolvePrompt(name, opts.prompt) : undefined;
85
+ assertIoMapping(`task("${name}")`, opts?.io);
71
86
  api.out.push({
72
87
  kind: "task",
73
88
  name,
74
89
  envelopes: api.contractEnvelopes(name),
75
90
  jobType: override,
76
91
  prompt,
92
+ io: opts?.io,
77
93
  });
78
94
  return api.self();
79
95
  },
80
96
  emit: (node, incoming, _loop, api) => {
81
- // No prompt → the standard service-task emission is preserved unchanged (no
82
- // `linkedResources` are emitted).
83
- if (node.prompt === undefined) {
97
+ // Neither a prompt binding nor an explicit io mapping → the standard
98
+ // service-task emission is preserved unchanged (no `linkedResources`, no
99
+ // `ioMapping`).
100
+ if (node.prompt === undefined && node.io === undefined) {
84
101
  api.addServiceTask(node);
85
102
  api.connect(incoming, node.name);
86
103
  return [api.newEdge(node.name)];
87
104
  }
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) });
105
+ // A prompt binding and/or an explicit io mapping present → the shared
106
+ // `addServiceTask` renders the task shell (taskDefinition + envelope
107
+ // properties, recording envelopes); we only supply the `linkedResource`
108
+ // and/or `ioMapping` delta, so the envelope/taskDefinition serialization has
109
+ // a single source.
110
+ api.addServiceTask(node, { extraExt: taskExtensions(node.prompt, node.io) });
94
111
  api.connect(incoming, node.name);
95
112
  return [api.newEdge(node.name)];
96
113
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/workflow",
3
- "version": "0.12.0",
3
+ "version": "0.13.1",
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
  }
@@ -48,6 +48,7 @@ import type {
48
48
  TimerStart,
49
49
  } from "./types.js";
50
50
  import type { Envelope, EnvelopeField } from "./envelope.js";
51
+ import type { HumanIoMapping } from "./io-mapping.js";
51
52
  import { assertIdent, assertTimerCycle, assertTimerDate, assertTimerDuration, escapeXml, jobType, messageName } from "./xml.js";
52
53
  import { eachNodeKind, requireNodeKind } from "./nodes/registry.js";
53
54
  // Import the generated barrel purely for its registration side effects: every
@@ -98,7 +99,11 @@ export interface FlowBuilder<C extends object = object> {
98
99
  * the flow's contracts, the handler's job variables and return value are typed
99
100
  * from that contract's `in`/`out` envelopes; otherwise they are `JsonObject`.
100
101
  */
101
- run<K extends string>(name: K, handler: TypedHandler<VarsOf<C, K>, ResultOf<C, K>>): FlowBuilder<C>;
102
+ run<K extends string>(
103
+ name: K,
104
+ handler: TypedHandler<VarsOf<C, K>, ResultOf<C, K>>,
105
+ opts?: { io?: HumanIoMapping },
106
+ ): FlowBuilder<C>;
102
107
  /**
103
108
  * A durable activity served by a worker OUTSIDE this program (a BPMN service
104
109
  * task, but no locally-hosted handler). Its job type defaults to the derived
@@ -111,7 +116,7 @@ export interface FlowBuilder<C extends object = object> {
111
116
  * workers must poll. Its contract envelopes (if any) type the model, not a
112
117
  * local handler.
113
118
  */
114
- task<K extends string>(name: K, opts?: { jobType?: string }): FlowBuilder<C>;
119
+ task<K extends string>(name: K, opts?: { jobType?: string; io?: HumanIoMapping }): FlowBuilder<C>;
115
120
  /**
116
121
  * A durable wait for an external/human event, correlated on a process
117
122
  * variable (a BPMN message intermediate catch event). Resume it with
@@ -0,0 +1,85 @@
1
+ // The single source of truth for a Zeebe `<zeebe:ioMapping>` — its shape, its
2
+ // authoring-time validation, and its BPMN emission — shared by EVERY node kind
3
+ // that carries variable mappings (`human` user tasks, `task`/`run` service
4
+ // tasks). Derivation over duplication (AGENTS.md): one interface, one validator,
5
+ // one renderer, so the ioMapping surface can never drift between node kinds.
6
+
7
+ import { escapeXml } from "./xml.js";
8
+
9
+ /** A single Zeebe I/O mapping entry — a FEEL `source` copied to/from a process
10
+ * variable `target`. */
11
+ export interface HumanIoEntry {
12
+ /** FEEL expression evaluated for the mapping (e.g. `=orderId`). */
13
+ source: string;
14
+ /** The process variable the value is written to. */
15
+ target: string;
16
+ }
17
+
18
+ /** A `<zeebe:ioMapping>`: input mappings applied on activation and output
19
+ * mappings applied on completion. */
20
+ export interface HumanIoMapping {
21
+ input?: HumanIoEntry[];
22
+ output?: HumanIoEntry[];
23
+ }
24
+
25
+ /** A present, non-empty, non-whitespace-only string — the shape every id /
26
+ * assignment / mapping-expression field must have. A blank value would emit a
27
+ * meaningless BPMN attribute (an empty ioMapping source/target, formId,
28
+ * assignee, …) that fails or misbehaves at deploy/run time, so we fail fast at
29
+ * build. Non-blank values are kept verbatim — FEEL expressions are never
30
+ * trimmed or normalized. */
31
+ export function isNonBlankString(v: unknown): v is string {
32
+ return typeof v === "string" && v.trim() !== "";
33
+ }
34
+
35
+ function isEntry(e: unknown): e is HumanIoEntry {
36
+ if (e === null || typeof e !== "object") return false;
37
+ if (!("source" in e) || !("target" in e)) return false;
38
+ const { source, target } = e;
39
+ return isNonBlankString(source) && isNonBlankString(target);
40
+ }
41
+
42
+ function validateEntries(label: string, dir: "input" | "output", entries: HumanIoEntry[] | undefined): void {
43
+ if (entries === undefined) return;
44
+ if (!Array.isArray(entries)) throw new Error(`${label} io.${dir} must be an array of { source, target }`);
45
+ for (const e of entries) {
46
+ if (!isEntry(e)) {
47
+ throw new Error(`${label} io.${dir} entries must be { source: non-empty string, target: non-empty string }`);
48
+ }
49
+ }
50
+ }
51
+
52
+ /** Validate an optional io mapping at authoring time. `label` is the node's
53
+ * self-description (e.g. `human("decide")` or `task("record")`) so the thrown
54
+ * message names the offending step. An `undefined` mapping is allowed (no
55
+ * ioMapping is emitted); a non-object / array value, or a malformed entry, fails
56
+ * fast. */
57
+ export function assertIoMapping(label: string, io: HumanIoMapping | undefined): void {
58
+ if (io === undefined) return;
59
+ if (io === null || typeof io !== "object" || Array.isArray(io)) {
60
+ throw new Error(`${label} { io } must be an object { input?, output? }`);
61
+ }
62
+ validateEntries(label, "input", io.input);
63
+ validateEntries(label, "output", io.output);
64
+ }
65
+
66
+ function renderMappings(entries: HumanIoEntry[] | undefined, tag: "input" | "output"): string {
67
+ if (!entries || entries.length === 0) return "";
68
+ return entries
69
+ .map((e) => ` <zeebe:${tag} source="${escapeXml(e.source)}" target="${escapeXml(e.target)}" />\n`)
70
+ .join("");
71
+ }
72
+
73
+ /** Emit a single `<zeebe:ioMapping>` (inputs then outputs) for the mapping, or
74
+ * the empty string when it has no entries. The 8-space element / 10-space entry
75
+ * indentation matches both the user-task and service-task emission, so a caller
76
+ * can splice the result straight into either `<bpmn:extensionElements>`. */
77
+ export function renderIoMapping(io: HumanIoMapping | undefined): string {
78
+ if (!io || ((io.input?.length ?? 0) === 0 && (io.output?.length ?? 0) === 0)) return "";
79
+ return (
80
+ ` <zeebe:ioMapping>\n` +
81
+ renderMappings(io.input, "input") +
82
+ renderMappings(io.output, "output") +
83
+ ` </zeebe:ioMapping>\n`
84
+ );
85
+ }
@@ -7,23 +7,14 @@
7
7
 
8
8
  import { escapeXml } from "../xml.js";
9
9
  import { incomingOutgoing } from "../declarative.js";
10
+ import { assertIoMapping, isNonBlankString, renderIoMapping } from "../io-mapping.js";
11
+ import type { HumanIoMapping } from "../io-mapping.js";
10
12
  import { registerNodeKind } from "./registry.js";
11
13
 
12
- /** A single Zeebe I/O mapping entry — a FEEL `source` copied to/from a process
13
- * variable `target`. */
14
- export interface HumanIoEntry {
15
- /** FEEL expression evaluated for the mapping (e.g. `=orderId`). */
16
- source: string;
17
- /** The process variable the value is written to. */
18
- target: string;
19
- }
20
-
21
- /** The `<zeebe:ioMapping>` for a user task: input mappings applied on activation
22
- * and output mappings applied on completion. */
23
- export interface HumanIoMapping {
24
- input?: HumanIoEntry[];
25
- output?: HumanIoEntry[];
26
- }
14
+ // The ioMapping shape lives in the shared `io-mapping` module (single source of
15
+ // truth across node kinds); re-export it so existing `@nanobpm/workflow`
16
+ // consumers keep importing `HumanIoMapping` / `HumanIoEntry` unchanged.
17
+ export type { HumanIoEntry, HumanIoMapping } from "../io-mapping.js";
27
18
 
28
19
  /** Options for {@link FlowBuilder.human}. `form` is the `zeebe:formDefinition`
29
20
  * form id; `assignee` / `candidateGroups` populate a `zeebe:assignmentDefinition`
@@ -72,29 +63,10 @@ declare module "../declarative.js" {
72
63
  }
73
64
 
74
65
  /** A present, non-empty, non-whitespace-only string — the shape every human()
75
- * id / assignment / mapping-expression field must have. A blank value would
76
- * emit a meaningless BPMN attribute (an empty formId, assignee, candidateGroups,
77
- * or ioMapping source/target) that fails or misbehaves at deploy/run time, so we
78
- * fail fast at build. Non-blank values are kept verbatim — FEEL expressions are
79
- * never trimmed or normalized. */
80
- function isNonBlankString(v: unknown): v is string {
81
- return typeof v === "string" && v.trim() !== "";
82
- }
83
-
84
- function isEntry(e: unknown): e is HumanIoEntry {
85
- if (e === null || typeof e !== "object") return false;
86
- if (!("source" in e) || !("target" in e)) return false;
87
- const { source, target } = e;
88
- return isNonBlankString(source) && isNonBlankString(target);
89
- }
90
-
91
- function validateEntries(name: string, dir: "input" | "output", entries: HumanIoEntry[] | undefined): void {
92
- if (entries === undefined) return;
93
- if (!Array.isArray(entries)) throw new Error(`human("${name}") io.${dir} must be an array of { source, target }`);
94
- for (const e of entries) {
95
- if (!isEntry(e)) throw new Error(`human("${name}") io.${dir} entries must be { source: non-empty string, target: non-empty string }`);
96
- }
97
- }
66
+ * id / assignment field must have. A blank value would emit a meaningless BPMN
67
+ * attribute (an empty formId, assignee, or candidateGroups) that fails or
68
+ * misbehaves at deploy/run time, so we fail fast at build. Shared with the
69
+ * ioMapping validator via `io-mapping`. */
98
70
 
99
71
  function renderAssignment(node: HumanNode): string {
100
72
  if (node.assignee === undefined && node.candidateGroups === undefined) return "";
@@ -105,24 +77,6 @@ function renderAssignment(node: HumanNode): string {
105
77
  return ` <zeebe:assignmentDefinition${attrs} />\n`;
106
78
  }
107
79
 
108
- function renderMappings(entries: HumanIoEntry[] | undefined, tag: "input" | "output"): string {
109
- if (!entries || entries.length === 0) return "";
110
- return entries
111
- .map((e) => ` <zeebe:${tag} source="${escapeXml(e.source)}" target="${escapeXml(e.target)}" />\n`)
112
- .join("");
113
- }
114
-
115
- function renderIoMapping(node: HumanNode): string {
116
- const io = node.io;
117
- if (!io || ((io.input?.length ?? 0) === 0 && (io.output?.length ?? 0) === 0)) return "";
118
- return (
119
- ` <zeebe:ioMapping>\n` +
120
- renderMappings(io.input, "input") +
121
- renderMappings(io.output, "output") +
122
- ` </zeebe:ioMapping>\n`
123
- );
124
- }
125
-
126
80
  function renderUserTask(node: HumanNode, inc: string[], outg: string[]): string {
127
81
  const id = node.name;
128
82
  const ext =
@@ -130,7 +84,7 @@ function renderUserTask(node: HumanNode, inc: string[], outg: string[]): string
130
84
  ` <zeebe:formDefinition formId="${escapeXml(node.form)}" />\n` +
131
85
  ` <zeebe:userTask />\n` +
132
86
  renderAssignment(node) +
133
- renderIoMapping(node) +
87
+ renderIoMapping(node.io) +
134
88
  ` </bpmn:extensionElements>`;
135
89
  return (
136
90
  ` <bpmn:userTask id="${escapeXml(id)}" name="${escapeXml(id)}">\n` +
@@ -156,11 +110,7 @@ registerNodeKind("human", {
156
110
  if (opts.candidateGroups !== undefined && !isNonBlankString(opts.candidateGroups)) {
157
111
  throw new Error(`human("${name}") { candidateGroups } must be a non-empty string`);
158
112
  }
159
- if (opts.io !== undefined && (opts.io === null || typeof opts.io !== "object" || Array.isArray(opts.io))) {
160
- throw new Error(`human("${name}") { io } must be an object { input?, output? }`);
161
- }
162
- validateEntries(name, "input", opts.io?.input);
163
- validateEntries(name, "output", opts.io?.output);
113
+ assertIoMapping(`human("${name}")`, opts.io);
164
114
  api.out.push({
165
115
  kind: "human",
166
116
  name,
package/src/nodes/run.ts CHANGED
@@ -5,24 +5,27 @@
5
5
  // handlers — with NO edit to the shared union or a central dispatch switch.
6
6
 
7
7
  import type { NodeEnvelopes, StepHandler } from "../types.js";
8
+ import { assertIoMapping, renderIoMapping } from "../io-mapping.js";
9
+ import type { HumanIoMapping } from "../io-mapping.js";
8
10
  import { registerNodeKind } from "./registry.js";
9
11
 
10
12
  declare module "../types.js" {
11
13
  interface FlowNodeRegistry {
12
- run: { kind: "run"; name: string; envelopes?: NodeEnvelopes };
14
+ run: { kind: "run"; name: string; envelopes?: NodeEnvelopes; io?: HumanIoMapping };
13
15
  }
14
16
  }
15
17
 
16
18
  registerNodeKind("run", {
17
- build: (api) => (name: string, handler: StepHandler) => {
19
+ build: (api) => (name: string, handler: StepHandler, opts?: { io?: HumanIoMapping }) => {
18
20
  api.claim(name);
19
21
  if (typeof handler !== "function") throw new Error(`run("${name}") needs a handler function`);
22
+ assertIoMapping(`run("${name}")`, opts?.io);
20
23
  api.handlers[name] = handler;
21
- api.out.push({ kind: "run", name, envelopes: api.contractEnvelopes(name) });
24
+ api.out.push({ kind: "run", name, envelopes: api.contractEnvelopes(name), io: opts?.io });
22
25
  return api.self();
23
26
  },
24
27
  emit: (node, incoming, _loop, api) => {
25
- api.addServiceTask(node);
28
+ api.addServiceTask(node, node.io !== undefined ? { extraExt: renderIoMapping(node.io) } : {});
26
29
  api.connect(incoming, node.name);
27
30
  return [api.newEdge(node.name)];
28
31
  },
package/src/nodes/task.ts CHANGED
@@ -13,6 +13,8 @@
13
13
 
14
14
  import type { NodeEnvelopes } from "../types.js";
15
15
  import { assertJobType, escapeXml } from "../xml.js";
16
+ import { assertIoMapping, renderIoMapping } from "../io-mapping.js";
17
+ import type { HumanIoEntry, HumanIoMapping } from "../io-mapping.js";
16
18
  import { registerNodeKind } from "./registry.js";
17
19
 
18
20
  /** Binds an LLM prompt resource to an agent service task. Emits, alongside the
@@ -44,6 +46,11 @@ declare module "../types.js" {
44
46
  jobType?: string;
45
47
  /** The agent prompt binding, when the task is an LLM-agent service task. */
46
48
  prompt?: PromptBinding;
49
+ /** An explicit `<zeebe:ioMapping>` on the service task — arbitrary input
50
+ * (and output) variable mappings, using the shared ioMapping shape. When a
51
+ * `prompt.append` is ALSO present it is folded into this single mapping as
52
+ * an extra `appendPrompt` input (no duplicate `ioMapping` element). */
53
+ io?: HumanIoMapping;
47
54
  };
48
55
  }
49
56
  }
@@ -56,9 +63,13 @@ declare module "../declarative.js" {
56
63
  * (`resourceType="GenericScript" linkName="prompt"`). Pair it with a
57
64
  * `{ jobType }` capability token (e.g. `senior:retro`) so an agent pool
58
65
  * services it. `prompt.append` (optional) feeds a FEEL expression to the
59
- * worker through a `zeebe:ioMapping` `appendPrompt` input.
66
+ * worker through a `zeebe:ioMapping` `appendPrompt` input. `io` (optional)
67
+ * adds arbitrary input/output variable mappings to the SAME `zeebe:ioMapping`.
60
68
  */
61
- task<K extends string>(name: K, opts: { jobType?: string; prompt: PromptBinding }): FlowBuilder<C>;
69
+ task<K extends string>(
70
+ name: K,
71
+ opts: { jobType?: string; prompt: PromptBinding; io?: HumanIoMapping },
72
+ ): FlowBuilder<C>;
62
73
  }
63
74
  }
64
75
 
@@ -93,57 +104,71 @@ function resolvePrompt(name: string, prompt: PromptBinding): PromptBinding {
93
104
  return resolved;
94
105
  }
95
106
 
96
- /** Build the prompt-specific `<bpmn:extensionElements>` delta appended to the
97
- * shared service-task emission: the `zeebe:linkedResources` prompt binding and
98
- * (optionally) a `zeebe:ioMapping` `appendPrompt` input. The task shell
107
+ /** Build the `<bpmn:extensionElements>` delta appended to the shared
108
+ * service-task emission: the optional `zeebe:linkedResources` prompt binding
109
+ * followed by a single, correctly-ordered `zeebe:ioMapping`. The task shell
99
110
  * (taskDefinition + envelope properties) is rendered by the built-in
100
111
  * `EmitApi.addServiceTask`, so this variant only contributes its delta and the
101
- * envelope serialization can never drift from the plain service task. */
102
- function promptExtensions(prompt: PromptBinding): string {
103
- const { resourceId, bindingType, append } = prompt;
112
+ * envelope serialization can never drift from the plain service task.
113
+ *
114
+ * The `ioMapping` is the SINGLE source for all variable mappings on the task:
115
+ * the explicit `io.input`/`io.output` plus — when `prompt.append` is present —
116
+ * the `appendPrompt` input, folded in as one more input entry so a task with
117
+ * both a `prompt.append` and an explicit `io.input` emits exactly one
118
+ * `<zeebe:ioMapping>` (never two). Element order matches the golden:
119
+ * `linkedResources` (when any) then `ioMapping` (when any), the latter after
120
+ * `taskDefinition`/`properties`. */
121
+ function taskExtensions(prompt: PromptBinding | undefined, io: HumanIoMapping | undefined): string {
104
122
  const linked =
105
- ` <zeebe:linkedResources>\n` +
106
- ` <zeebe:linkedResource resourceId="${escapeXml(resourceId)}" ` +
107
- `bindingType="${escapeXml(bindingType ?? "latest")}" resourceType="GenericScript" linkName="prompt" />\n` +
108
- ` </zeebe:linkedResources>\n`;
109
- const io =
110
- append !== undefined
111
- ? ` <zeebe:ioMapping>\n` +
112
- ` <zeebe:input source="${escapeXml(append)}" target="appendPrompt" />\n` +
113
- ` </zeebe:ioMapping>\n`
123
+ prompt !== undefined
124
+ ? ` <zeebe:linkedResources>\n` +
125
+ ` <zeebe:linkedResource resourceId="${escapeXml(prompt.resourceId)}" ` +
126
+ `bindingType="${escapeXml(prompt.bindingType ?? "latest")}" resourceType="GenericScript" linkName="prompt" />\n` +
127
+ ` </zeebe:linkedResources>\n`
114
128
  : "";
115
- return linked + io;
129
+ // Merge the explicit io mapping and the prompt's `appendPrompt` input into one
130
+ // mapping. The append input trails the explicit inputs — it appends runtime
131
+ // context to the bound prompt, so it reads naturally after the task's own
132
+ // inputs — and outputs follow, matching `renderIoMapping`'s input-then-output
133
+ // ordering.
134
+ const inputs: HumanIoEntry[] = [...(io?.input ?? [])];
135
+ if (prompt?.append !== undefined) inputs.push({ source: prompt.append, target: "appendPrompt" });
136
+ const merged: HumanIoMapping = { input: inputs, output: io?.output ?? [] };
137
+ return linked + renderIoMapping(merged);
116
138
  }
117
139
 
118
140
  registerNodeKind("task", {
119
- build: (api) => (name: string, opts?: { jobType?: string; prompt?: PromptBinding }) => {
141
+ build: (api) => (name: string, opts?: { jobType?: string; prompt?: PromptBinding; io?: HumanIoMapping }) => {
120
142
  api.claim(name);
121
143
  const override = opts?.jobType;
122
144
  if (override !== undefined) assertJobType("task jobType", override);
123
145
  const prompt = opts?.prompt !== undefined ? resolvePrompt(name, opts.prompt) : undefined;
146
+ assertIoMapping(`task("${name}")`, opts?.io);
124
147
  api.out.push({
125
148
  kind: "task",
126
149
  name,
127
150
  envelopes: api.contractEnvelopes(name),
128
151
  jobType: override,
129
152
  prompt,
153
+ io: opts?.io,
130
154
  });
131
155
  return api.self();
132
156
  },
133
157
  emit: (node, incoming, _loop, api) => {
134
- // No prompt → the standard service-task emission is preserved unchanged (no
135
- // `linkedResources` are emitted).
136
- if (node.prompt === undefined) {
158
+ // Neither a prompt binding nor an explicit io mapping → the standard
159
+ // service-task emission is preserved unchanged (no `linkedResources`, no
160
+ // `ioMapping`).
161
+ if (node.prompt === undefined && node.io === undefined) {
137
162
  api.addServiceTask(node);
138
163
  api.connect(incoming, node.name);
139
164
  return [api.newEdge(node.name)];
140
165
  }
141
- // Prompt binding present → the shared `addServiceTask` renders the task
142
- // shell (taskDefinition + envelope properties, recording envelopes); we only
143
- // supply the `zeebe:linkedResource` (and optional `appendPrompt` ioMapping)
144
- // delta, so the envelope/taskDefinition serialization has a single source.
145
- const prompt = node.prompt;
146
- api.addServiceTask(node, { extraExt: promptExtensions(prompt) });
166
+ // A prompt binding and/or an explicit io mapping present → the shared
167
+ // `addServiceTask` renders the task shell (taskDefinition + envelope
168
+ // properties, recording envelopes); we only supply the `linkedResource`
169
+ // and/or `ioMapping` delta, so the envelope/taskDefinition serialization has
170
+ // a single source.
171
+ api.addServiceTask(node, { extraExt: taskExtensions(node.prompt, node.io) });
147
172
  api.connect(incoming, node.name);
148
173
  return [api.newEdge(node.name)];
149
174
  },