@nanobpm/workflow 0.12.0 → 0.13.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 +40 -0
- package/dist/declarative.d.ts +5 -1
- package/dist/io-mapping.d.ts +32 -0
- package/dist/io-mapping.js +67 -0
- package/dist/nodes/human.d.ts +2 -15
- package/dist/nodes/human.js +7 -48
- package/dist/nodes/run.d.ts +2 -1
- package/dist/nodes/run.js +5 -3
- package/dist/nodes/task.d.ts +9 -1
- package/dist/nodes/task.js +41 -24
- package/package.json +1 -1
- package/src/declarative.ts +7 -2
- package/src/io-mapping.ts +85 -0
- package/src/nodes/human.ts +12 -62
- package/src/nodes/run.ts +7 -4
- package/src/nodes/task.ts +53 -28
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/declarative.d.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/dist/nodes/human.d.ts
CHANGED
|
@@ -1,17 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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 {};
|
package/dist/nodes/human.js
CHANGED
|
@@ -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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
|
|
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,
|
package/dist/nodes/run.d.ts
CHANGED
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
|
},
|
package/dist/nodes/task.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/nodes/task.js
CHANGED
|
@@ -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
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
82
|
-
// `linkedResources
|
|
83
|
-
|
|
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
|
-
//
|
|
89
|
-
// shell (taskDefinition + envelope
|
|
90
|
-
// supply the `
|
|
91
|
-
// delta, so the envelope/taskDefinition serialization has
|
|
92
|
-
|
|
93
|
-
api.addServiceTask(node, { extraExt:
|
|
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.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Code-first durable orchestration for nanobpmn (ADR 0044/0045). Author a durable workflow as a declarative flow — `defineFlow(id, w => { w.run(...); w.signal(...); w.task(...) })` — and the SDK derives the executable BPMN model, the job types, and the message/correlation wiring, then hosts a generic worker. `w.run` is a local step, `w.signal` is a durable human-in-the-loop wait, `w.task` is a step serviced by an external worker. `defineFlow` is the single code-first surface; the imperative `defineWorkflow` replay function is experimental/internal, not a recommended API. No diagram, no task-type wiring, no correlation plumbing authored by hand — and you can optionally auto-generate a diagram (DI) for the derived model with `declarativeToLayoutedBpmn` via the optional `bpmn-auto-layout` peer dependency. Talks to a running nanobpmn gateway through @nanobpm/nano-sdk (the shared engine-transport spine — ADR 0055), which negotiates the Falcon protocol on a Nano server and REST elsewhere.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
package/src/declarative.ts
CHANGED
|
@@ -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>(
|
|
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
|
+
}
|
package/src/nodes/human.ts
CHANGED
|
@@ -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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
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
|
-
|
|
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>(
|
|
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
|
|
97
|
-
*
|
|
98
|
-
*
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
135
|
-
// `linkedResources
|
|
136
|
-
|
|
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
|
-
//
|
|
142
|
-
// shell (taskDefinition + envelope
|
|
143
|
-
// supply the `
|
|
144
|
-
// delta, so the envelope/taskDefinition serialization has
|
|
145
|
-
|
|
146
|
-
api.addServiceTask(node, { extraExt:
|
|
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
|
},
|