@nanobpm/workflow 0.9.0 → 0.10.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 +73 -2
- package/dist/declarative.d.ts +8 -2
- package/dist/declarative.js +4 -2
- package/dist/nodes/forEach.js +8 -3
- package/dist/nodes/index.d.ts +1 -0
- package/dist/nodes/index.js +1 -0
- package/dist/nodes/race.d.ts +54 -0
- package/dist/nodes/race.js +134 -0
- package/dist/nodes/signal.js +8 -3
- package/dist/nodes/task.d.ts +37 -1
- package/dist/nodes/task.js +79 -3
- package/package.json +1 -1
- package/src/declarative.ts +15 -4
- package/src/nodes/forEach.ts +8 -3
- package/src/nodes/index.ts +1 -0
- package/src/nodes/race.ts +185 -0
- package/src/nodes/signal.ts +9 -3
- package/src/nodes/task.ts +126 -5
package/README.md
CHANGED
|
@@ -53,6 +53,15 @@ human-in-the-loop path), composed with control-flow combinators:
|
|
|
53
53
|
`itemVar`). `{ sequential }` runs children one at a time; `{ outputCollection,
|
|
54
54
|
outputElement }` collects each child's result into a list; `{ completionCondition }`
|
|
55
55
|
completes the body early.
|
|
56
|
+
- `w.race({ armName: { signal | timer, do }, … })` — a first-of race over ≥2
|
|
57
|
+
named arms, each waiting on **exactly one** event: a `signal` (a correlated
|
|
58
|
+
message catch, `{ correlationKey }`) or a `timer` (`{ after }` for a duration or
|
|
59
|
+
`{ at }` for a date, ISO or a leading-`=` FEEL expression). Whichever arm's event
|
|
60
|
+
fires **first wins**: its `do` block runs and the losers are cancelled. Compiles
|
|
61
|
+
to an **event-based gateway** fanning out to one intermediate catch event per arm
|
|
62
|
+
(a message- or timer-event-definition), the arm bodies rejoining downstream by an
|
|
63
|
+
implicit XOR merge. This is the shape a convergence loop uses to bound a
|
|
64
|
+
human-in-the-loop wait with a timeout.
|
|
56
65
|
|
|
57
66
|
```ts
|
|
58
67
|
import { defineFlow, WorkflowClient, Worker } from "@nanobpm/workflow";
|
|
@@ -94,13 +103,35 @@ const convergence = defineFlow("convergence-loop", (w) => {
|
|
|
94
103
|
});
|
|
95
104
|
```
|
|
96
105
|
|
|
97
|
-
|
|
106
|
+
Bound a human-in-the-loop wait with a timeout — `w.race` resolves to whichever
|
|
107
|
+
arm fires first, cancelling the loser:
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
const reviewWait = defineFlow("review-wait", (w) => {
|
|
111
|
+
w.run("request-review", async (job) => ({ prKey: job.variables.prKey }));
|
|
112
|
+
w.race({
|
|
113
|
+
"review-arrived": {
|
|
114
|
+
signal: { correlationKey: "prKey" },
|
|
115
|
+
do: (b) => b.run("apply-review", applyReview),
|
|
116
|
+
},
|
|
117
|
+
"review-timed-out": {
|
|
118
|
+
timer: { after: "=reviewWaitTimeout" }, // FEEL duration, e.g. "PT48H"
|
|
119
|
+
do: (b) => b.run("escalate", escalate),
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
w.run("finalize", finalize);
|
|
123
|
+
});
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`switch` /
|
|
98
127
|
`branch` → an exclusive gateway (in-order conditions, first match wins, default =
|
|
99
128
|
unconditional flow); `loop` → a convergent gateway whose body falls through back to
|
|
100
129
|
the head; nodes with multiple incoming flows are an implicit XOR merge; `parallel`
|
|
101
130
|
→ a diverging/converging **parallel gateway** pair (AND fork/join); `forEach` → a
|
|
102
131
|
**parallel multi-instance** activity (a single-step body) or an embedded
|
|
103
|
-
multi-instance sub-process (a multi-step body)
|
|
132
|
+
multi-instance sub-process (a multi-step body); `race` → an **event-based
|
|
133
|
+
gateway** fanning out to one intermediate catch event per arm (message or timer),
|
|
134
|
+
first event wins and cancels the losers. See **ADR 0047**.
|
|
104
135
|
|
|
105
136
|
#### Typed data envelopes (eject to model-first with contracts intact)
|
|
106
137
|
|
|
@@ -128,6 +159,46 @@ const orders = defineFlow(
|
|
|
128
159
|
);
|
|
129
160
|
```
|
|
130
161
|
|
|
162
|
+
#### Agent tasks — bind an LLM prompt to a worker (`w.task(name, { prompt })`)
|
|
163
|
+
|
|
164
|
+
An **agent service task** is a `w.task` that additionally binds an LLM **prompt
|
|
165
|
+
resource** to the worker that services it. Pass a `prompt` alongside the
|
|
166
|
+
`jobType` capability token and the emitter adds a
|
|
167
|
+
`<zeebe:linkedResource … resourceType="GenericScript" linkName="prompt">` to the
|
|
168
|
+
task's `zeebe:taskDefinition` — the shape the nano-workforce agent tasks use to
|
|
169
|
+
attach a prompt script to the (e.g. `senior:retro`) agent pool that runs the job:
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
w.task("synthesize", {
|
|
173
|
+
jobType: "senior:retro", // the agent capability token
|
|
174
|
+
prompt: {
|
|
175
|
+
resourceId: "retro.md", // the GenericScript resource bound as the prompt
|
|
176
|
+
bindingType: "latest", // optional — how the version resolves (default "latest")
|
|
177
|
+
append: "=retroDigest", // optional — FEEL fed to a zeebe:ioMapping `appendPrompt` input
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
derives:
|
|
183
|
+
|
|
184
|
+
```xml
|
|
185
|
+
<bpmn:serviceTask id="synthesize" name="synthesize">
|
|
186
|
+
<bpmn:extensionElements>
|
|
187
|
+
<zeebe:taskDefinition type="senior:retro" />
|
|
188
|
+
<zeebe:linkedResources>
|
|
189
|
+
<zeebe:linkedResource resourceId="retro.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
|
|
190
|
+
</zeebe:linkedResources>
|
|
191
|
+
<zeebe:ioMapping>
|
|
192
|
+
<zeebe:input source="=retroDigest" target="appendPrompt" />
|
|
193
|
+
</zeebe:ioMapping>
|
|
194
|
+
</bpmn:extensionElements>
|
|
195
|
+
</bpmn:serviceTask>
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Only `resourceId` is required; omit `append` and no `ioMapping` is emitted. A
|
|
199
|
+
`w.task` **without** a `prompt` is unchanged — it emits no `linkedResources`.
|
|
200
|
+
Data envelopes (via contracts) still lift alongside the prompt binding.
|
|
201
|
+
|
|
131
202
|
### Imperative (Temporal-style, engine-replayed) — experimental/internal
|
|
132
203
|
|
|
133
204
|
Write the orchestration as a function. `ctx.run(name, fn)` is a durable step: its
|
package/dist/declarative.d.ts
CHANGED
|
@@ -288,12 +288,18 @@ export interface EmitApi {
|
|
|
288
288
|
/** Place a fully custom BPMN element (a slice's own render closure). */
|
|
289
289
|
addNode(node: RenderNode): void;
|
|
290
290
|
/** Place a `<bpmn:serviceTask>` with the standard taskDefinition/envelope
|
|
291
|
-
* extension elements
|
|
291
|
+
* extension elements. `opts.mi` adds multi-instance characteristics; `opts.extraExt`
|
|
292
|
+
* injects additional `<bpmn:extensionElements>` content (e.g. a
|
|
293
|
+
* `zeebe:linkedResources` prompt binding) after the taskDefinition/properties,
|
|
294
|
+
* so a variant task only supplies its delta rather than re-rendering the shell. */
|
|
292
295
|
addServiceTask(node: {
|
|
293
296
|
name: string;
|
|
294
297
|
envelopes?: NodeEnvelopes;
|
|
295
298
|
jobType?: string;
|
|
296
|
-
},
|
|
299
|
+
}, opts?: {
|
|
300
|
+
mi?: string;
|
|
301
|
+
extraExt?: string;
|
|
302
|
+
}): void;
|
|
297
303
|
/** Place a `<bpmn:intermediateCatchEvent>` with a message event definition. */
|
|
298
304
|
addCatchEvent(node: {
|
|
299
305
|
name: string;
|
package/dist/declarative.js
CHANGED
|
@@ -356,7 +356,7 @@ class Compiler {
|
|
|
356
356
|
addNode: (node) => {
|
|
357
357
|
this.nodes.push({ ...node, scope: node.scope ?? this.currentScope() });
|
|
358
358
|
},
|
|
359
|
-
addServiceTask: (node,
|
|
359
|
+
addServiceTask: (node, opts) => this.addServiceTask(node, opts),
|
|
360
360
|
addCatchEvent: (node) => this.addCatchEvent(node),
|
|
361
361
|
addTimerCatchEvent: (node) => this.addTimerCatchEvent(node),
|
|
362
362
|
addGateway: (id, name) => this.addGateway(id, name),
|
|
@@ -404,7 +404,8 @@ class Compiler {
|
|
|
404
404
|
}
|
|
405
405
|
this.envelopes.set(env.name, env.fields);
|
|
406
406
|
}
|
|
407
|
-
addServiceTask(node,
|
|
407
|
+
addServiceTask(node, opts = {}) {
|
|
408
|
+
const { mi = "", extraExt = "" } = opts;
|
|
408
409
|
const type = node.jobType ?? jobType(this.flow.id, node.name);
|
|
409
410
|
this.recordEnvelope(node.envelopes?.in);
|
|
410
411
|
this.recordEnvelope(node.envelopes?.out);
|
|
@@ -416,6 +417,7 @@ class Compiler {
|
|
|
416
417
|
const ext = ` <bpmn:extensionElements>\n` +
|
|
417
418
|
` <zeebe:taskDefinition type="${escapeXml(type)}" />\n` +
|
|
418
419
|
(props.length ? ` <zeebe:properties>\n${props.join("\n")}\n </zeebe:properties>\n` : "") +
|
|
420
|
+
extraExt +
|
|
419
421
|
` </bpmn:extensionElements>`;
|
|
420
422
|
const id = node.name;
|
|
421
423
|
this.nodes.push({
|
package/dist/nodes/forEach.js
CHANGED
|
@@ -82,9 +82,14 @@ registerNodeKind("forEach", {
|
|
|
82
82
|
const only = node.body.length === 1 ? node.body[0] : undefined;
|
|
83
83
|
// A single service-task body carries the MI characteristics directly; a
|
|
84
84
|
// multi-step body is wrapped in an embedded MI sub-process (its own token
|
|
85
|
-
// scope, with its own start/end).
|
|
86
|
-
|
|
87
|
-
|
|
85
|
+
// scope, with its own start/end). A prompt-carrying `task` is deliberately
|
|
86
|
+
// EXCLUDED from this fast path: computing its `zeebe:linkedResources`/
|
|
87
|
+
// `appendPrompt` delta lives in the `task` kind's own emitter, so lifting it
|
|
88
|
+
// here would silently drop that binding. It falls through to the sub-process
|
|
89
|
+
// path, where the `task` emitter renders the agent service task (prompt
|
|
90
|
+
// intact) inside the MI scope.
|
|
91
|
+
if (only && (only.kind === "run" || (only.kind === "task" && only.prompt === undefined))) {
|
|
92
|
+
api.addServiceTask(only, { mi });
|
|
88
93
|
api.connect(incoming, only.name);
|
|
89
94
|
return [api.newEdge(only.name)];
|
|
90
95
|
}
|
package/dist/nodes/index.d.ts
CHANGED
package/dist/nodes/index.js
CHANGED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { FlowNode } from "../types.js";
|
|
2
|
+
import type { Block } from "../declarative.js";
|
|
3
|
+
/** One arm of a {@link FlowBuilder.race}: exactly one waited-for event — a
|
|
4
|
+
* `signal` (a message catch correlated on a process variable) OR a `timer` (a
|
|
5
|
+
* delay `{ after }` or an instant `{ at }`) — plus the `do` body that runs when
|
|
6
|
+
* that event wins the race. */
|
|
7
|
+
export type RaceArm = ({
|
|
8
|
+
signal: {
|
|
9
|
+
correlationKey: string;
|
|
10
|
+
};
|
|
11
|
+
} | {
|
|
12
|
+
timer: {
|
|
13
|
+
after: string;
|
|
14
|
+
} | {
|
|
15
|
+
at: string;
|
|
16
|
+
};
|
|
17
|
+
}) & {
|
|
18
|
+
do: Block;
|
|
19
|
+
};
|
|
20
|
+
/** A compiled race arm: its catch event (a `signal` or `timer` `FlowNode`, reused
|
|
21
|
+
* from the built-ins) and the body that runs when it wins. */
|
|
22
|
+
interface CompiledArm {
|
|
23
|
+
event: FlowNode;
|
|
24
|
+
body: FlowNode[];
|
|
25
|
+
}
|
|
26
|
+
declare module "../types.js" {
|
|
27
|
+
interface FlowNodeRegistry {
|
|
28
|
+
race: {
|
|
29
|
+
kind: "race";
|
|
30
|
+
arms: CompiledArm[];
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
declare module "../declarative.js" {
|
|
35
|
+
interface FlowBuilder<C extends object> {
|
|
36
|
+
/**
|
|
37
|
+
* A first-of event race — a BPMN event-based gateway. Each named arm waits on
|
|
38
|
+
* exactly one event: a `signal` (a durable message catch correlated on a
|
|
39
|
+
* process variable) or a `timer` (`{ after }` a delay, or `{ at }` an
|
|
40
|
+
* instant). The engine arms every catch at once; the FIRST to fire wins, its
|
|
41
|
+
* arm's `do` body runs, and the losing catch events are cancelled. Every arm's
|
|
42
|
+
* body converges on whatever follows the race.
|
|
43
|
+
*
|
|
44
|
+
* ```ts
|
|
45
|
+
* w.race({
|
|
46
|
+
* "review-ready": { signal: { correlationKey: "prKey" }, do: (b) => b.run("re-review", reReview) },
|
|
47
|
+
* "review-timeout": { timer: { after: "=reviewWaitTimeout" }, do: (b) => b.run("escalate", escalate) },
|
|
48
|
+
* });
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
race(arms: Record<string, RaceArm>): FlowBuilder<C>;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export {};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Flow-node kind: `race` — a first-of event race (a BPMN event-based gateway).
|
|
2
|
+
// The gateway forks to one intermediate catch event per arm — a MESSAGE catch for
|
|
3
|
+
// a `signal` arm (correlated on a process variable) or a TIMER catch for a `timer`
|
|
4
|
+
// arm — and the engine arms them all at once. The FIRST event to fire wins: its
|
|
5
|
+
// arm's `do` body runs and the losing catch events are cancelled; each arm's body
|
|
6
|
+
// then converges on whatever follows the race.
|
|
7
|
+
//
|
|
8
|
+
// Contributed entirely from this module through the wave-0 extension seam (epic
|
|
9
|
+
// #314, S1/#316): its `FlowNode` variant (declaration-merged into the augmentable
|
|
10
|
+
// `FlowNodeRegistry`), its `w.race(...)` builder method (merged into
|
|
11
|
+
// `FlowBuilder<C>`), and its `walk`/`emit` handlers — with NO edit to the shared
|
|
12
|
+
// `FlowNode` union, the `walkNodes`/`emitNode` dispatch, or the generated barrel.
|
|
13
|
+
//
|
|
14
|
+
// The catch arms are modelled as nested `signal`/`timer` `FlowNode`s so the whole
|
|
15
|
+
// machine reuses the built-ins: `walk` recurses into them (so the shared message
|
|
16
|
+
// emitter lifts each `signal` arm's `<bpmn:message>` + correlation subscription
|
|
17
|
+
// automatically, and `WorkflowClient.signal` can correlate to it), and `emit`
|
|
18
|
+
// dispatches each arm's catch through its own registered handler (so a race's
|
|
19
|
+
// catch is byte-identical to a standalone `w.signal`/`w.timer`). Only the
|
|
20
|
+
// event-based gateway itself is placed here.
|
|
21
|
+
import { incomingOutgoing } from "../declarative.js";
|
|
22
|
+
import { assertIdent, assertTimerDate, assertTimerDuration, escapeXml } from "../xml.js";
|
|
23
|
+
import { registerNodeKind, requireNodeKind } from "./registry.js";
|
|
24
|
+
function hasSignal(spec) {
|
|
25
|
+
return "signal" in spec && spec.signal !== null && typeof spec.signal === "object";
|
|
26
|
+
}
|
|
27
|
+
function hasTimer(spec) {
|
|
28
|
+
return "timer" in spec && spec.timer !== null && typeof spec.timer === "object";
|
|
29
|
+
}
|
|
30
|
+
registerNodeKind("race", {
|
|
31
|
+
build: (api) => (arms) => {
|
|
32
|
+
if (arms === null || typeof arms !== "object") {
|
|
33
|
+
throw new Error(`race() needs an arms object { "<name>": { signal | timer, do } }`);
|
|
34
|
+
}
|
|
35
|
+
const entries = Object.entries(arms);
|
|
36
|
+
if (entries.length < 2) {
|
|
37
|
+
throw new Error(`race() needs at least two arms (an event-based gateway with fewer than two events is degenerate)`);
|
|
38
|
+
}
|
|
39
|
+
const compiled = [];
|
|
40
|
+
for (const [name, spec] of entries) {
|
|
41
|
+
if (spec === null || typeof spec !== "object") {
|
|
42
|
+
throw new Error(`race() arm "${name}" must be an object { signal | timer, do }`);
|
|
43
|
+
}
|
|
44
|
+
const isSignal = hasSignal(spec);
|
|
45
|
+
const isTimer = hasTimer(spec);
|
|
46
|
+
if (isSignal === isTimer) {
|
|
47
|
+
throw new Error(`race() arm "${name}" needs exactly one of { signal: { correlationKey } } or { timer: { after | at } }`);
|
|
48
|
+
}
|
|
49
|
+
if (typeof spec.do !== "function") {
|
|
50
|
+
throw new Error(`race() arm "${name}" needs a do block (b) => {…}`);
|
|
51
|
+
}
|
|
52
|
+
api.claim(name);
|
|
53
|
+
let event;
|
|
54
|
+
if (isSignal) {
|
|
55
|
+
const rawKey = spec.signal.correlationKey;
|
|
56
|
+
if (typeof rawKey !== "string" || rawKey.trim() === "") {
|
|
57
|
+
throw new Error(`race() arm "${name}" signal needs a non-empty { correlationKey }`);
|
|
58
|
+
}
|
|
59
|
+
// Trim before validating/storing, in parity with the timer arm ({ after }/
|
|
60
|
+
// { at } are trimmed above): a value like " prKey " is usable after
|
|
61
|
+
// trimming, so it must not fail the NCName check on stray whitespace. The
|
|
62
|
+
// assertIdent context names the arm so a failure says WHICH arm is invalid.
|
|
63
|
+
const correlationKey = rawKey.trim();
|
|
64
|
+
assertIdent(`race() arm "${name}" signal correlationKey`, correlationKey);
|
|
65
|
+
event = { kind: "signal", name, correlationKey, payload: api.contracts[name]?.in };
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
const timer = spec.timer;
|
|
69
|
+
// Enforce exactly-one on KEY PRESENCE, not string-ness: a runtime-invalid
|
|
70
|
+
// arm that supplies both keys where only one is a string (e.g.
|
|
71
|
+
// `{ after: 1, at: "…" }`) must be rejected here, not silently dispatched
|
|
72
|
+
// into `after.trim()` — which would throw an opaque TypeError. Once the
|
|
73
|
+
// branch is chosen, validate the value is a string before trimming.
|
|
74
|
+
const hasAfterKey = "after" in timer;
|
|
75
|
+
const hasAtKey = "at" in timer;
|
|
76
|
+
if (hasAfterKey === hasAtKey) {
|
|
77
|
+
throw new Error(`race() arm "${name}" timer needs exactly one of { after } (a delay) or { at } (an instant)`);
|
|
78
|
+
}
|
|
79
|
+
if ("after" in timer) {
|
|
80
|
+
if (typeof timer.after !== "string") {
|
|
81
|
+
throw new Error(`race() arm "${name}" timer { after } must be a string duration`);
|
|
82
|
+
}
|
|
83
|
+
const after = timer.after.trim();
|
|
84
|
+
assertTimerDuration(`race() arm "${name}" timer after`, after);
|
|
85
|
+
event = { kind: "timer", name, after };
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
if (typeof timer.at !== "string") {
|
|
89
|
+
throw new Error(`race() arm "${name}" timer { at } must be a string instant`);
|
|
90
|
+
}
|
|
91
|
+
const at = timer.at.trim();
|
|
92
|
+
assertTimerDate(`race() arm "${name}" timer at`, at);
|
|
93
|
+
event = { kind: "timer", name, at };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
compiled.push({ event, body: api.child(spec.do, false) });
|
|
97
|
+
}
|
|
98
|
+
api.out.push({ kind: "race", arms: compiled });
|
|
99
|
+
return api.self();
|
|
100
|
+
},
|
|
101
|
+
walk: (node, recurse) => {
|
|
102
|
+
// Recurse into each arm's catch event AND its body: the catch `signal` nodes
|
|
103
|
+
// must be visited so the shared message emitter lifts their `<bpmn:message>`
|
|
104
|
+
// + correlation subscription, and body `task` nodes so their external job
|
|
105
|
+
// types are discovered — exactly as a standalone `signal`/`task` would be.
|
|
106
|
+
for (const arm of node.arms)
|
|
107
|
+
recurse([arm.event, ...arm.body]);
|
|
108
|
+
},
|
|
109
|
+
emit: (node, incoming, loop, api) => {
|
|
110
|
+
const gwId = `Gw_${api.nextGw()}`;
|
|
111
|
+
api.addNode(eventBasedGateway(gwId, api.currentScope()));
|
|
112
|
+
api.connect(incoming, gwId);
|
|
113
|
+
const out = [];
|
|
114
|
+
for (const arm of node.arms) {
|
|
115
|
+
// Fork the gateway straight to the arm's catch event (BPMN requires an
|
|
116
|
+
// event-based gateway's targets to be intermediate catch events), reusing
|
|
117
|
+
// the arm kind's own registered emit so the catch is identical to a
|
|
118
|
+
// standalone `w.signal`/`w.timer`; then wire its `do` body onward.
|
|
119
|
+
const fork = api.newEdge(gwId);
|
|
120
|
+
const afterCatch = requireNodeKind(arm.event.kind).emit(arm.event, [fork], loop, api);
|
|
121
|
+
out.push(...api.emitList(arm.body, afterCatch, loop));
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
/** A `<bpmn:eventBasedGateway>` render node (no typed `EmitApi` primitive exists —
|
|
127
|
+
* built as a custom {@link RenderNode} from the exported string helpers). */
|
|
128
|
+
function eventBasedGateway(id, scope) {
|
|
129
|
+
return {
|
|
130
|
+
id,
|
|
131
|
+
scope,
|
|
132
|
+
render: (inc, outg) => ` <bpmn:eventBasedGateway id="${escapeXml(id)}">\n` + incomingOutgoing(inc, outg) + ` </bpmn:eventBasedGateway>`,
|
|
133
|
+
};
|
|
134
|
+
}
|
package/dist/nodes/signal.js
CHANGED
|
@@ -6,10 +6,15 @@ import { registerNodeKind } from "./registry.js";
|
|
|
6
6
|
registerNodeKind("signal", {
|
|
7
7
|
build: (api) => (name, opts) => {
|
|
8
8
|
api.claim(name);
|
|
9
|
-
if (!opts ||
|
|
9
|
+
if (!opts || typeof opts.correlationKey !== "string" || opts.correlationKey.trim() === "") {
|
|
10
10
|
throw new Error(`signal("${name}") needs { correlationKey }`);
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
}
|
|
12
|
+
// Trim before validating/storing, in parity with the race() signal arm: a
|
|
13
|
+
// value like " prKey " is usable after trimming, so it must not fail the
|
|
14
|
+
// NCName check on stray whitespace. Name the node in the assertIdent context.
|
|
15
|
+
const correlationKey = opts.correlationKey.trim();
|
|
16
|
+
assertIdent(`signal("${name}") correlationKey`, correlationKey);
|
|
17
|
+
api.out.push({ kind: "signal", name, correlationKey, payload: api.contracts[name]?.in });
|
|
13
18
|
return api.self();
|
|
14
19
|
},
|
|
15
20
|
emit: (node, incoming, _loop, api) => {
|
package/dist/nodes/task.d.ts
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
/** Binds an LLM prompt resource to an agent service task. Emits, alongside the
|
|
2
|
+
* task's `zeebe:taskDefinition` capability token, a
|
|
3
|
+
* `<zeebe:linkedResources><zeebe:linkedResource resourceId=… bindingType=…
|
|
4
|
+
* resourceType="GenericScript" linkName="prompt"/></zeebe:linkedResources>`
|
|
5
|
+
* extension — the shape the nano-workforce agent tasks use to attach a prompt
|
|
6
|
+
* script to the worker that services the job. */
|
|
7
|
+
export interface PromptBinding {
|
|
8
|
+
/** The `GenericScript` resource id bound as the prompt (the linkedResource
|
|
9
|
+
* `resourceId`, e.g. `"retro.md"`). Required and non-empty. */
|
|
10
|
+
resourceId: string;
|
|
11
|
+
/** The linkedResource `bindingType` — how the engine resolves the resource
|
|
12
|
+
* version at deploy time. Defaults to `"latest"`. */
|
|
13
|
+
bindingType?: string;
|
|
14
|
+
/** Optional prompt addendum: a FEEL expression (or literal) fed to the worker
|
|
15
|
+
* through a `<zeebe:ioMapping><zeebe:input source=… target="appendPrompt"/>`
|
|
16
|
+
* input, the convention the agent tasks use to append runtime context to the
|
|
17
|
+
* bound prompt. Omitted → no `ioMapping` is emitted. */
|
|
18
|
+
append?: string;
|
|
19
|
+
}
|
|
1
20
|
declare module "../types.js" {
|
|
2
21
|
interface FlowNodeRegistry {
|
|
3
22
|
task: {
|
|
@@ -5,7 +24,24 @@ declare module "../types.js" {
|
|
|
5
24
|
name: string;
|
|
6
25
|
envelopes?: NodeEnvelopes;
|
|
7
26
|
jobType?: string;
|
|
27
|
+
/** The agent prompt binding, when the task is an LLM-agent service task. */
|
|
28
|
+
prompt?: PromptBinding;
|
|
8
29
|
};
|
|
9
30
|
}
|
|
10
31
|
}
|
|
11
|
-
|
|
32
|
+
declare module "../declarative.js" {
|
|
33
|
+
interface FlowBuilder<C extends object> {
|
|
34
|
+
/**
|
|
35
|
+
* An AGENT external service task: as `task`, but additionally binds an LLM
|
|
36
|
+
* `prompt` resource to the worker via a `zeebe:linkedResource`
|
|
37
|
+
* (`resourceType="GenericScript" linkName="prompt"`). Pair it with a
|
|
38
|
+
* `{ jobType }` capability token (e.g. `senior:retro`) so an agent pool
|
|
39
|
+
* services it. `prompt.append` (optional) feeds a FEEL expression to the
|
|
40
|
+
* worker through a `zeebe:ioMapping` `appendPrompt` input.
|
|
41
|
+
*/
|
|
42
|
+
task<K extends string>(name: K, opts: {
|
|
43
|
+
jobType?: string;
|
|
44
|
+
prompt: PromptBinding;
|
|
45
|
+
}): FlowBuilder<C>;
|
|
46
|
+
}
|
|
47
|
+
}
|
package/dist/nodes/task.js
CHANGED
|
@@ -2,19 +2,95 @@
|
|
|
2
2
|
// OUTSIDE this program (no locally-hosted handler). Its job type defaults to the
|
|
3
3
|
// derived `${flowId}:${name}` unless overridden with `{ jobType }`. Contributed
|
|
4
4
|
// from this module through the extension seam (epic #314, S0/#315).
|
|
5
|
-
|
|
5
|
+
//
|
|
6
|
+
// This module also owns the AGENT PROMPT BINDING (epic #314, S4/#319): an
|
|
7
|
+
// optional `{ prompt }` option binds an LLM prompt resource to the worker via a
|
|
8
|
+
// `<zeebe:linkedResource … resourceType="GenericScript" linkName="prompt">`,
|
|
9
|
+
// mirroring the nano-workforce agent service tasks. The extension is contributed
|
|
10
|
+
// entirely from this module — the `task` node's own registered emit handler and
|
|
11
|
+
// its option type — WITHOUT re-opening the shared `FlowNode` union, the central
|
|
12
|
+
// `walkNodes`/`emitNode` dispatch, or the generated barrel.
|
|
13
|
+
import { assertJobType, escapeXml } from "../xml.js";
|
|
6
14
|
import { registerNodeKind } from "./registry.js";
|
|
15
|
+
/** Normalize + validate a prompt binding at authoring time, applying the
|
|
16
|
+
* `bindingType` default. Emitted string fields are trimmed before storing (in
|
|
17
|
+
* parity with how `timer` normalizes `{ after }`/`{ at }`), so a value like
|
|
18
|
+
* `resourceId: " retro.md "` passes validation AND is emitted without stray
|
|
19
|
+
* surrounding whitespace. Returns a fully-resolved binding stored on the node. */
|
|
20
|
+
function resolvePrompt(name, prompt) {
|
|
21
|
+
if (typeof prompt !== "object" || prompt === null) {
|
|
22
|
+
throw new Error(`task("${name}") prompt must be an object with a non-empty resourceId`);
|
|
23
|
+
}
|
|
24
|
+
const resourceId = prompt.resourceId;
|
|
25
|
+
if (typeof resourceId !== "string" || resourceId.trim() === "") {
|
|
26
|
+
throw new Error(`task("${name}") prompt.resourceId must be a non-empty string`);
|
|
27
|
+
}
|
|
28
|
+
// Only an ABSENT bindingType defaults to "latest"; `??` would also swallow an
|
|
29
|
+
// explicit `null`, masking an authoring mistake. Distinguish `undefined` (use
|
|
30
|
+
// the default) from any other non-string (reject), in parity with how `append`
|
|
31
|
+
// below treats `!== undefined` as "provided, so validate strictly".
|
|
32
|
+
const bindingType = prompt.bindingType === undefined ? "latest" : prompt.bindingType;
|
|
33
|
+
if (typeof bindingType !== "string" || bindingType.trim() === "") {
|
|
34
|
+
throw new Error(`task("${name}") prompt.bindingType must be a non-empty string`);
|
|
35
|
+
}
|
|
36
|
+
const resolved = { resourceId: resourceId.trim(), bindingType: bindingType.trim() };
|
|
37
|
+
if (prompt.append !== undefined) {
|
|
38
|
+
if (typeof prompt.append !== "string" || prompt.append.trim() === "") {
|
|
39
|
+
throw new Error(`task("${name}") prompt.append must be a non-empty string when provided`);
|
|
40
|
+
}
|
|
41
|
+
resolved.append = prompt.append.trim();
|
|
42
|
+
}
|
|
43
|
+
return resolved;
|
|
44
|
+
}
|
|
45
|
+
/** Build the prompt-specific `<bpmn:extensionElements>` delta appended to the
|
|
46
|
+
* shared service-task emission: the `zeebe:linkedResources` prompt binding and
|
|
47
|
+
* (optionally) a `zeebe:ioMapping` `appendPrompt` input. The task shell
|
|
48
|
+
* (taskDefinition + envelope properties) is rendered by the built-in
|
|
49
|
+
* `EmitApi.addServiceTask`, so this variant only contributes its delta and the
|
|
50
|
+
* envelope serialization can never drift from the plain service task. */
|
|
51
|
+
function promptExtensions(prompt) {
|
|
52
|
+
const { resourceId, bindingType, append } = prompt;
|
|
53
|
+
const linked = ` <zeebe:linkedResources>\n` +
|
|
54
|
+
` <zeebe:linkedResource resourceId="${escapeXml(resourceId)}" ` +
|
|
55
|
+
`bindingType="${escapeXml(bindingType ?? "latest")}" resourceType="GenericScript" linkName="prompt" />\n` +
|
|
56
|
+
` </zeebe:linkedResources>\n`;
|
|
57
|
+
const io = append !== undefined
|
|
58
|
+
? ` <zeebe:ioMapping>\n` +
|
|
59
|
+
` <zeebe:input source="${escapeXml(append)}" target="appendPrompt" />\n` +
|
|
60
|
+
` </zeebe:ioMapping>\n`
|
|
61
|
+
: "";
|
|
62
|
+
return linked + io;
|
|
63
|
+
}
|
|
7
64
|
registerNodeKind("task", {
|
|
8
65
|
build: (api) => (name, opts) => {
|
|
9
66
|
api.claim(name);
|
|
10
67
|
const override = opts?.jobType;
|
|
11
68
|
if (override !== undefined)
|
|
12
69
|
assertJobType("task jobType", override);
|
|
13
|
-
|
|
70
|
+
const prompt = opts?.prompt !== undefined ? resolvePrompt(name, opts.prompt) : undefined;
|
|
71
|
+
api.out.push({
|
|
72
|
+
kind: "task",
|
|
73
|
+
name,
|
|
74
|
+
envelopes: api.contractEnvelopes(name),
|
|
75
|
+
jobType: override,
|
|
76
|
+
prompt,
|
|
77
|
+
});
|
|
14
78
|
return api.self();
|
|
15
79
|
},
|
|
16
80
|
emit: (node, incoming, _loop, api) => {
|
|
17
|
-
|
|
81
|
+
// No prompt → the standard service-task emission is preserved unchanged (no
|
|
82
|
+
// `linkedResources` are emitted).
|
|
83
|
+
if (node.prompt === undefined) {
|
|
84
|
+
api.addServiceTask(node);
|
|
85
|
+
api.connect(incoming, node.name);
|
|
86
|
+
return [api.newEdge(node.name)];
|
|
87
|
+
}
|
|
88
|
+
// Prompt binding present → the shared `addServiceTask` renders the task
|
|
89
|
+
// shell (taskDefinition + envelope properties, recording envelopes); we only
|
|
90
|
+
// supply the `zeebe:linkedResource` (and optional `appendPrompt` ioMapping)
|
|
91
|
+
// delta, so the envelope/taskDefinition serialization has a single source.
|
|
92
|
+
const prompt = node.prompt;
|
|
93
|
+
api.addServiceTask(node, { extraExt: promptExtensions(prompt) });
|
|
18
94
|
api.connect(incoming, node.name);
|
|
19
95
|
return [api.newEdge(node.name)];
|
|
20
96
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/workflow",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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
|
@@ -622,8 +622,14 @@ export interface EmitApi {
|
|
|
622
622
|
/** Place a fully custom BPMN element (a slice's own render closure). */
|
|
623
623
|
addNode(node: RenderNode): void;
|
|
624
624
|
/** Place a `<bpmn:serviceTask>` with the standard taskDefinition/envelope
|
|
625
|
-
* extension elements
|
|
626
|
-
|
|
625
|
+
* extension elements. `opts.mi` adds multi-instance characteristics; `opts.extraExt`
|
|
626
|
+
* injects additional `<bpmn:extensionElements>` content (e.g. a
|
|
627
|
+
* `zeebe:linkedResources` prompt binding) after the taskDefinition/properties,
|
|
628
|
+
* so a variant task only supplies its delta rather than re-rendering the shell. */
|
|
629
|
+
addServiceTask(
|
|
630
|
+
node: { name: string; envelopes?: NodeEnvelopes; jobType?: string },
|
|
631
|
+
opts?: { mi?: string; extraExt?: string },
|
|
632
|
+
): void;
|
|
627
633
|
/** Place a `<bpmn:intermediateCatchEvent>` with a message event definition. */
|
|
628
634
|
addCatchEvent(node: { name: string }): void;
|
|
629
635
|
/** Place a `<bpmn:intermediateCatchEvent>` with a timer event definition. */
|
|
@@ -674,7 +680,7 @@ class Compiler {
|
|
|
674
680
|
addNode: (node) => {
|
|
675
681
|
this.nodes.push({ ...node, scope: node.scope ?? this.currentScope() });
|
|
676
682
|
},
|
|
677
|
-
addServiceTask: (node,
|
|
683
|
+
addServiceTask: (node, opts) => this.addServiceTask(node, opts),
|
|
678
684
|
addCatchEvent: (node) => this.addCatchEvent(node),
|
|
679
685
|
addTimerCatchEvent: (node) => this.addTimerCatchEvent(node),
|
|
680
686
|
addGateway: (id, name) => this.addGateway(id, name),
|
|
@@ -727,7 +733,11 @@ class Compiler {
|
|
|
727
733
|
this.envelopes.set(env.name, env.fields);
|
|
728
734
|
}
|
|
729
735
|
|
|
730
|
-
private addServiceTask(
|
|
736
|
+
private addServiceTask(
|
|
737
|
+
node: { name: string; envelopes?: NodeEnvelopes; jobType?: string },
|
|
738
|
+
opts: { mi?: string; extraExt?: string } = {},
|
|
739
|
+
): void {
|
|
740
|
+
const { mi = "", extraExt = "" } = opts;
|
|
731
741
|
const type = node.jobType ?? jobType(this.flow.id, node.name);
|
|
732
742
|
this.recordEnvelope(node.envelopes?.in);
|
|
733
743
|
this.recordEnvelope(node.envelopes?.out);
|
|
@@ -738,6 +748,7 @@ class Compiler {
|
|
|
738
748
|
` <bpmn:extensionElements>\n` +
|
|
739
749
|
` <zeebe:taskDefinition type="${escapeXml(type)}" />\n` +
|
|
740
750
|
(props.length ? ` <zeebe:properties>\n${props.join("\n")}\n </zeebe:properties>\n` : "") +
|
|
751
|
+
extraExt +
|
|
741
752
|
` </bpmn:extensionElements>`;
|
|
742
753
|
const id = node.name;
|
|
743
754
|
this.nodes.push({
|
package/src/nodes/forEach.ts
CHANGED
|
@@ -115,9 +115,14 @@ registerNodeKind("forEach", {
|
|
|
115
115
|
const only = node.body.length === 1 ? node.body[0] : undefined;
|
|
116
116
|
// A single service-task body carries the MI characteristics directly; a
|
|
117
117
|
// multi-step body is wrapped in an embedded MI sub-process (its own token
|
|
118
|
-
// scope, with its own start/end).
|
|
119
|
-
|
|
120
|
-
|
|
118
|
+
// scope, with its own start/end). A prompt-carrying `task` is deliberately
|
|
119
|
+
// EXCLUDED from this fast path: computing its `zeebe:linkedResources`/
|
|
120
|
+
// `appendPrompt` delta lives in the `task` kind's own emitter, so lifting it
|
|
121
|
+
// here would silently drop that binding. It falls through to the sub-process
|
|
122
|
+
// path, where the `task` emitter renders the agent service task (prompt
|
|
123
|
+
// intact) inside the MI scope.
|
|
124
|
+
if (only && (only.kind === "run" || (only.kind === "task" && only.prompt === undefined))) {
|
|
125
|
+
api.addServiceTask(only, { mi });
|
|
121
126
|
api.connect(incoming, only.name);
|
|
122
127
|
return [api.newEdge(only.name)];
|
|
123
128
|
}
|
package/src/nodes/index.ts
CHANGED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Flow-node kind: `race` — a first-of event race (a BPMN event-based gateway).
|
|
2
|
+
// The gateway forks to one intermediate catch event per arm — a MESSAGE catch for
|
|
3
|
+
// a `signal` arm (correlated on a process variable) or a TIMER catch for a `timer`
|
|
4
|
+
// arm — and the engine arms them all at once. The FIRST event to fire wins: its
|
|
5
|
+
// arm's `do` body runs and the losing catch events are cancelled; each arm's body
|
|
6
|
+
// then converges on whatever follows the race.
|
|
7
|
+
//
|
|
8
|
+
// Contributed entirely from this module through the wave-0 extension seam (epic
|
|
9
|
+
// #314, S1/#316): its `FlowNode` variant (declaration-merged into the augmentable
|
|
10
|
+
// `FlowNodeRegistry`), its `w.race(...)` builder method (merged into
|
|
11
|
+
// `FlowBuilder<C>`), and its `walk`/`emit` handlers — with NO edit to the shared
|
|
12
|
+
// `FlowNode` union, the `walkNodes`/`emitNode` dispatch, or the generated barrel.
|
|
13
|
+
//
|
|
14
|
+
// The catch arms are modelled as nested `signal`/`timer` `FlowNode`s so the whole
|
|
15
|
+
// machine reuses the built-ins: `walk` recurses into them (so the shared message
|
|
16
|
+
// emitter lifts each `signal` arm's `<bpmn:message>` + correlation subscription
|
|
17
|
+
// automatically, and `WorkflowClient.signal` can correlate to it), and `emit`
|
|
18
|
+
// dispatches each arm's catch through its own registered handler (so a race's
|
|
19
|
+
// catch is byte-identical to a standalone `w.signal`/`w.timer`). Only the
|
|
20
|
+
// event-based gateway itself is placed here.
|
|
21
|
+
|
|
22
|
+
import type { FlowNode } from "../types.js";
|
|
23
|
+
import type { Block, Edge, RenderNode } from "../declarative.js";
|
|
24
|
+
import { incomingOutgoing } from "../declarative.js";
|
|
25
|
+
import { assertIdent, assertTimerDate, assertTimerDuration, escapeXml } from "../xml.js";
|
|
26
|
+
import { registerNodeKind, requireNodeKind } from "./registry.js";
|
|
27
|
+
|
|
28
|
+
/** One arm of a {@link FlowBuilder.race}: exactly one waited-for event — a
|
|
29
|
+
* `signal` (a message catch correlated on a process variable) OR a `timer` (a
|
|
30
|
+
* delay `{ after }` or an instant `{ at }`) — plus the `do` body that runs when
|
|
31
|
+
* that event wins the race. */
|
|
32
|
+
export type RaceArm = ({ signal: { correlationKey: string } } | { timer: { after: string } | { at: string } }) & {
|
|
33
|
+
do: Block;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** A compiled race arm: its catch event (a `signal` or `timer` `FlowNode`, reused
|
|
37
|
+
* from the built-ins) and the body that runs when it wins. */
|
|
38
|
+
interface CompiledArm {
|
|
39
|
+
event: FlowNode;
|
|
40
|
+
body: FlowNode[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
declare module "../types.js" {
|
|
44
|
+
interface FlowNodeRegistry {
|
|
45
|
+
race: { kind: "race"; arms: CompiledArm[] };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
declare module "../declarative.js" {
|
|
50
|
+
interface FlowBuilder<C extends object> {
|
|
51
|
+
/**
|
|
52
|
+
* A first-of event race — a BPMN event-based gateway. Each named arm waits on
|
|
53
|
+
* exactly one event: a `signal` (a durable message catch correlated on a
|
|
54
|
+
* process variable) or a `timer` (`{ after }` a delay, or `{ at }` an
|
|
55
|
+
* instant). The engine arms every catch at once; the FIRST to fire wins, its
|
|
56
|
+
* arm's `do` body runs, and the losing catch events are cancelled. Every arm's
|
|
57
|
+
* body converges on whatever follows the race.
|
|
58
|
+
*
|
|
59
|
+
* ```ts
|
|
60
|
+
* w.race({
|
|
61
|
+
* "review-ready": { signal: { correlationKey: "prKey" }, do: (b) => b.run("re-review", reReview) },
|
|
62
|
+
* "review-timeout": { timer: { after: "=reviewWaitTimeout" }, do: (b) => b.run("escalate", escalate) },
|
|
63
|
+
* });
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
race(arms: Record<string, RaceArm>): FlowBuilder<C>;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function hasSignal(spec: RaceArm): spec is { signal: { correlationKey: string } } & { do: Block } {
|
|
71
|
+
return "signal" in spec && spec.signal !== null && typeof spec.signal === "object";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function hasTimer(spec: RaceArm): spec is { timer: { after: string } | { at: string } } & { do: Block } {
|
|
75
|
+
return "timer" in spec && spec.timer !== null && typeof spec.timer === "object";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
registerNodeKind("race", {
|
|
79
|
+
build: (api) => (arms: Record<string, RaceArm>) => {
|
|
80
|
+
if (arms === null || typeof arms !== "object") {
|
|
81
|
+
throw new Error(`race() needs an arms object { "<name>": { signal | timer, do } }`);
|
|
82
|
+
}
|
|
83
|
+
const entries = Object.entries(arms);
|
|
84
|
+
if (entries.length < 2) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`race() needs at least two arms (an event-based gateway with fewer than two events is degenerate)`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
const compiled: CompiledArm[] = [];
|
|
90
|
+
for (const [name, spec] of entries) {
|
|
91
|
+
if (spec === null || typeof spec !== "object") {
|
|
92
|
+
throw new Error(`race() arm "${name}" must be an object { signal | timer, do }`);
|
|
93
|
+
}
|
|
94
|
+
const isSignal = hasSignal(spec);
|
|
95
|
+
const isTimer = hasTimer(spec);
|
|
96
|
+
if (isSignal === isTimer) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`race() arm "${name}" needs exactly one of { signal: { correlationKey } } or { timer: { after | at } }`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
if (typeof spec.do !== "function") {
|
|
102
|
+
throw new Error(`race() arm "${name}" needs a do block (b) => {…}`);
|
|
103
|
+
}
|
|
104
|
+
api.claim(name);
|
|
105
|
+
let event: FlowNode;
|
|
106
|
+
if (isSignal) {
|
|
107
|
+
const rawKey = spec.signal.correlationKey;
|
|
108
|
+
if (typeof rawKey !== "string" || rawKey.trim() === "") {
|
|
109
|
+
throw new Error(`race() arm "${name}" signal needs a non-empty { correlationKey }`);
|
|
110
|
+
}
|
|
111
|
+
// Trim before validating/storing, in parity with the timer arm ({ after }/
|
|
112
|
+
// { at } are trimmed above): a value like " prKey " is usable after
|
|
113
|
+
// trimming, so it must not fail the NCName check on stray whitespace. The
|
|
114
|
+
// assertIdent context names the arm so a failure says WHICH arm is invalid.
|
|
115
|
+
const correlationKey = rawKey.trim();
|
|
116
|
+
assertIdent(`race() arm "${name}" signal correlationKey`, correlationKey);
|
|
117
|
+
event = { kind: "signal", name, correlationKey, payload: api.contracts[name]?.in };
|
|
118
|
+
} else {
|
|
119
|
+
const timer = spec.timer;
|
|
120
|
+
// Enforce exactly-one on KEY PRESENCE, not string-ness: a runtime-invalid
|
|
121
|
+
// arm that supplies both keys where only one is a string (e.g.
|
|
122
|
+
// `{ after: 1, at: "…" }`) must be rejected here, not silently dispatched
|
|
123
|
+
// into `after.trim()` — which would throw an opaque TypeError. Once the
|
|
124
|
+
// branch is chosen, validate the value is a string before trimming.
|
|
125
|
+
const hasAfterKey = "after" in timer;
|
|
126
|
+
const hasAtKey = "at" in timer;
|
|
127
|
+
if (hasAfterKey === hasAtKey) {
|
|
128
|
+
throw new Error(`race() arm "${name}" timer needs exactly one of { after } (a delay) or { at } (an instant)`);
|
|
129
|
+
}
|
|
130
|
+
if ("after" in timer) {
|
|
131
|
+
if (typeof timer.after !== "string") {
|
|
132
|
+
throw new Error(`race() arm "${name}" timer { after } must be a string duration`);
|
|
133
|
+
}
|
|
134
|
+
const after = timer.after.trim();
|
|
135
|
+
assertTimerDuration(`race() arm "${name}" timer after`, after);
|
|
136
|
+
event = { kind: "timer", name, after };
|
|
137
|
+
} else {
|
|
138
|
+
if (typeof timer.at !== "string") {
|
|
139
|
+
throw new Error(`race() arm "${name}" timer { at } must be a string instant`);
|
|
140
|
+
}
|
|
141
|
+
const at = timer.at.trim();
|
|
142
|
+
assertTimerDate(`race() arm "${name}" timer at`, at);
|
|
143
|
+
event = { kind: "timer", name, at };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
compiled.push({ event, body: api.child(spec.do, false) });
|
|
147
|
+
}
|
|
148
|
+
api.out.push({ kind: "race", arms: compiled });
|
|
149
|
+
return api.self();
|
|
150
|
+
},
|
|
151
|
+
walk: (node, recurse) => {
|
|
152
|
+
// Recurse into each arm's catch event AND its body: the catch `signal` nodes
|
|
153
|
+
// must be visited so the shared message emitter lifts their `<bpmn:message>`
|
|
154
|
+
// + correlation subscription, and body `task` nodes so their external job
|
|
155
|
+
// types are discovered — exactly as a standalone `signal`/`task` would be.
|
|
156
|
+
for (const arm of node.arms) recurse([arm.event, ...arm.body]);
|
|
157
|
+
},
|
|
158
|
+
emit: (node, incoming, loop, api) => {
|
|
159
|
+
const gwId = `Gw_${api.nextGw()}`;
|
|
160
|
+
api.addNode(eventBasedGateway(gwId, api.currentScope()));
|
|
161
|
+
api.connect(incoming, gwId);
|
|
162
|
+
const out: Edge[] = [];
|
|
163
|
+
for (const arm of node.arms) {
|
|
164
|
+
// Fork the gateway straight to the arm's catch event (BPMN requires an
|
|
165
|
+
// event-based gateway's targets to be intermediate catch events), reusing
|
|
166
|
+
// the arm kind's own registered emit so the catch is identical to a
|
|
167
|
+
// standalone `w.signal`/`w.timer`; then wire its `do` body onward.
|
|
168
|
+
const fork = api.newEdge(gwId);
|
|
169
|
+
const afterCatch = requireNodeKind(arm.event.kind).emit(arm.event, [fork], loop, api);
|
|
170
|
+
out.push(...api.emitList(arm.body, afterCatch, loop));
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
/** A `<bpmn:eventBasedGateway>` render node (no typed `EmitApi` primitive exists —
|
|
177
|
+
* built as a custom {@link RenderNode} from the exported string helpers). */
|
|
178
|
+
function eventBasedGateway(id: string, scope: string | undefined): RenderNode {
|
|
179
|
+
return {
|
|
180
|
+
id,
|
|
181
|
+
scope,
|
|
182
|
+
render: (inc: string[], outg: string[]) =>
|
|
183
|
+
` <bpmn:eventBasedGateway id="${escapeXml(id)}">\n` + incomingOutgoing(inc, outg) + ` </bpmn:eventBasedGateway>`,
|
|
184
|
+
};
|
|
185
|
+
}
|
package/src/nodes/signal.ts
CHANGED
|
@@ -15,9 +15,15 @@ declare module "../types.js" {
|
|
|
15
15
|
registerNodeKind("signal", {
|
|
16
16
|
build: (api) => (name: string, opts: { correlationKey: string }) => {
|
|
17
17
|
api.claim(name);
|
|
18
|
-
if (!opts ||
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
if (!opts || typeof opts.correlationKey !== "string" || opts.correlationKey.trim() === "") {
|
|
19
|
+
throw new Error(`signal("${name}") needs { correlationKey }`);
|
|
20
|
+
}
|
|
21
|
+
// Trim before validating/storing, in parity with the race() signal arm: a
|
|
22
|
+
// value like " prKey " is usable after trimming, so it must not fail the
|
|
23
|
+
// NCName check on stray whitespace. Name the node in the assertIdent context.
|
|
24
|
+
const correlationKey = opts.correlationKey.trim();
|
|
25
|
+
assertIdent(`signal("${name}") correlationKey`, correlationKey);
|
|
26
|
+
api.out.push({ kind: "signal", name, correlationKey, payload: api.contracts[name]?.in });
|
|
21
27
|
return api.self();
|
|
22
28
|
},
|
|
23
29
|
emit: (node, incoming, _loop, api) => {
|
package/src/nodes/task.ts
CHANGED
|
@@ -2,27 +2,148 @@
|
|
|
2
2
|
// OUTSIDE this program (no locally-hosted handler). Its job type defaults to the
|
|
3
3
|
// derived `${flowId}:${name}` unless overridden with `{ jobType }`. Contributed
|
|
4
4
|
// from this module through the extension seam (epic #314, S0/#315).
|
|
5
|
+
//
|
|
6
|
+
// This module also owns the AGENT PROMPT BINDING (epic #314, S4/#319): an
|
|
7
|
+
// optional `{ prompt }` option binds an LLM prompt resource to the worker via a
|
|
8
|
+
// `<zeebe:linkedResource … resourceType="GenericScript" linkName="prompt">`,
|
|
9
|
+
// mirroring the nano-workforce agent service tasks. The extension is contributed
|
|
10
|
+
// entirely from this module — the `task` node's own registered emit handler and
|
|
11
|
+
// its option type — WITHOUT re-opening the shared `FlowNode` union, the central
|
|
12
|
+
// `walkNodes`/`emitNode` dispatch, or the generated barrel.
|
|
5
13
|
|
|
6
14
|
import type { NodeEnvelopes } from "../types.js";
|
|
7
|
-
import { assertJobType } from "../xml.js";
|
|
15
|
+
import { assertJobType, escapeXml } from "../xml.js";
|
|
8
16
|
import { registerNodeKind } from "./registry.js";
|
|
9
17
|
|
|
18
|
+
/** Binds an LLM prompt resource to an agent service task. Emits, alongside the
|
|
19
|
+
* task's `zeebe:taskDefinition` capability token, a
|
|
20
|
+
* `<zeebe:linkedResources><zeebe:linkedResource resourceId=… bindingType=…
|
|
21
|
+
* resourceType="GenericScript" linkName="prompt"/></zeebe:linkedResources>`
|
|
22
|
+
* extension — the shape the nano-workforce agent tasks use to attach a prompt
|
|
23
|
+
* script to the worker that services the job. */
|
|
24
|
+
export interface PromptBinding {
|
|
25
|
+
/** The `GenericScript` resource id bound as the prompt (the linkedResource
|
|
26
|
+
* `resourceId`, e.g. `"retro.md"`). Required and non-empty. */
|
|
27
|
+
resourceId: string;
|
|
28
|
+
/** The linkedResource `bindingType` — how the engine resolves the resource
|
|
29
|
+
* version at deploy time. Defaults to `"latest"`. */
|
|
30
|
+
bindingType?: string;
|
|
31
|
+
/** Optional prompt addendum: a FEEL expression (or literal) fed to the worker
|
|
32
|
+
* through a `<zeebe:ioMapping><zeebe:input source=… target="appendPrompt"/>`
|
|
33
|
+
* input, the convention the agent tasks use to append runtime context to the
|
|
34
|
+
* bound prompt. Omitted → no `ioMapping` is emitted. */
|
|
35
|
+
append?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
10
38
|
declare module "../types.js" {
|
|
11
39
|
interface FlowNodeRegistry {
|
|
12
|
-
task: {
|
|
40
|
+
task: {
|
|
41
|
+
kind: "task";
|
|
42
|
+
name: string;
|
|
43
|
+
envelopes?: NodeEnvelopes;
|
|
44
|
+
jobType?: string;
|
|
45
|
+
/** The agent prompt binding, when the task is an LLM-agent service task. */
|
|
46
|
+
prompt?: PromptBinding;
|
|
47
|
+
};
|
|
13
48
|
}
|
|
14
49
|
}
|
|
15
50
|
|
|
51
|
+
declare module "../declarative.js" {
|
|
52
|
+
interface FlowBuilder<C extends object> {
|
|
53
|
+
/**
|
|
54
|
+
* An AGENT external service task: as `task`, but additionally binds an LLM
|
|
55
|
+
* `prompt` resource to the worker via a `zeebe:linkedResource`
|
|
56
|
+
* (`resourceType="GenericScript" linkName="prompt"`). Pair it with a
|
|
57
|
+
* `{ jobType }` capability token (e.g. `senior:retro`) so an agent pool
|
|
58
|
+
* services it. `prompt.append` (optional) feeds a FEEL expression to the
|
|
59
|
+
* worker through a `zeebe:ioMapping` `appendPrompt` input.
|
|
60
|
+
*/
|
|
61
|
+
task<K extends string>(name: K, opts: { jobType?: string; prompt: PromptBinding }): FlowBuilder<C>;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Normalize + validate a prompt binding at authoring time, applying the
|
|
66
|
+
* `bindingType` default. Emitted string fields are trimmed before storing (in
|
|
67
|
+
* parity with how `timer` normalizes `{ after }`/`{ at }`), so a value like
|
|
68
|
+
* `resourceId: " retro.md "` passes validation AND is emitted without stray
|
|
69
|
+
* surrounding whitespace. Returns a fully-resolved binding stored on the node. */
|
|
70
|
+
function resolvePrompt(name: string, prompt: PromptBinding): PromptBinding {
|
|
71
|
+
if (typeof prompt !== "object" || prompt === null) {
|
|
72
|
+
throw new Error(`task("${name}") prompt must be an object with a non-empty resourceId`);
|
|
73
|
+
}
|
|
74
|
+
const resourceId = prompt.resourceId;
|
|
75
|
+
if (typeof resourceId !== "string" || resourceId.trim() === "") {
|
|
76
|
+
throw new Error(`task("${name}") prompt.resourceId must be a non-empty string`);
|
|
77
|
+
}
|
|
78
|
+
// Only an ABSENT bindingType defaults to "latest"; `??` would also swallow an
|
|
79
|
+
// explicit `null`, masking an authoring mistake. Distinguish `undefined` (use
|
|
80
|
+
// the default) from any other non-string (reject), in parity with how `append`
|
|
81
|
+
// below treats `!== undefined` as "provided, so validate strictly".
|
|
82
|
+
const bindingType = prompt.bindingType === undefined ? "latest" : prompt.bindingType;
|
|
83
|
+
if (typeof bindingType !== "string" || bindingType.trim() === "") {
|
|
84
|
+
throw new Error(`task("${name}") prompt.bindingType must be a non-empty string`);
|
|
85
|
+
}
|
|
86
|
+
const resolved: PromptBinding = { resourceId: resourceId.trim(), bindingType: bindingType.trim() };
|
|
87
|
+
if (prompt.append !== undefined) {
|
|
88
|
+
if (typeof prompt.append !== "string" || prompt.append.trim() === "") {
|
|
89
|
+
throw new Error(`task("${name}") prompt.append must be a non-empty string when provided`);
|
|
90
|
+
}
|
|
91
|
+
resolved.append = prompt.append.trim();
|
|
92
|
+
}
|
|
93
|
+
return resolved;
|
|
94
|
+
}
|
|
95
|
+
|
|
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
|
|
99
|
+
* (taskDefinition + envelope properties) is rendered by the built-in
|
|
100
|
+
* `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;
|
|
104
|
+
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`
|
|
114
|
+
: "";
|
|
115
|
+
return linked + io;
|
|
116
|
+
}
|
|
117
|
+
|
|
16
118
|
registerNodeKind("task", {
|
|
17
|
-
build: (api) => (name: string, opts?: { jobType?: string }) => {
|
|
119
|
+
build: (api) => (name: string, opts?: { jobType?: string; prompt?: PromptBinding }) => {
|
|
18
120
|
api.claim(name);
|
|
19
121
|
const override = opts?.jobType;
|
|
20
122
|
if (override !== undefined) assertJobType("task jobType", override);
|
|
21
|
-
|
|
123
|
+
const prompt = opts?.prompt !== undefined ? resolvePrompt(name, opts.prompt) : undefined;
|
|
124
|
+
api.out.push({
|
|
125
|
+
kind: "task",
|
|
126
|
+
name,
|
|
127
|
+
envelopes: api.contractEnvelopes(name),
|
|
128
|
+
jobType: override,
|
|
129
|
+
prompt,
|
|
130
|
+
});
|
|
22
131
|
return api.self();
|
|
23
132
|
},
|
|
24
133
|
emit: (node, incoming, _loop, api) => {
|
|
25
|
-
|
|
134
|
+
// No prompt → the standard service-task emission is preserved unchanged (no
|
|
135
|
+
// `linkedResources` are emitted).
|
|
136
|
+
if (node.prompt === undefined) {
|
|
137
|
+
api.addServiceTask(node);
|
|
138
|
+
api.connect(incoming, node.name);
|
|
139
|
+
return [api.newEdge(node.name)];
|
|
140
|
+
}
|
|
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) });
|
|
26
147
|
api.connect(incoming, node.name);
|
|
27
148
|
return [api.newEdge(node.name)];
|
|
28
149
|
},
|