@nanobpm/workflow 0.2.0 → 0.4.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 +98 -28
- package/dist/client.js +8 -2
- package/dist/declarative.d.ts +94 -17
- package/dist/declarative.js +437 -85
- package/dist/envelope.d.ts +57 -0
- package/dist/envelope.js +62 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.js +3 -1
- package/dist/layout.d.ts +22 -0
- package/dist/layout.js +62 -0
- package/dist/types.d.ts +58 -5
- package/dist/worker.js +8 -5
- package/dist/xml.d.ts +1 -0
- package/dist/xml.js +43 -2
- package/package.json +11 -2
- package/src/bpmn-auto-layout.d.ts +10 -0
- package/src/client.ts +7 -2
- package/src/declarative.ts +585 -102
- package/src/envelope.ts +130 -0
- package/src/index.ts +17 -1
- package/src/layout.ts +68 -0
- package/src/types.ts +56 -9
- package/src/worker.ts +8 -5
- package/src/xml.ts +44 -2
package/src/declarative.ts
CHANGED
|
@@ -1,136 +1,619 @@
|
|
|
1
|
-
// Strategy A — the declarative surface: a
|
|
1
|
+
// Strategy A — the declarative surface: a TREE of nodes compiled to a real BPMN
|
|
2
|
+
// model. Leaf activities (`run`/`task`/`signal`) plus structural combinators
|
|
3
|
+
// (`switch`/`branch`/`loop`) that compile to exclusive gateways + back-edges the
|
|
4
|
+
// engine already runs (engine-core supports XOR gateways with in-order condition
|
|
5
|
+
// evaluation and a default flow, and tasks with multiple incoming flows act as
|
|
6
|
+
// an implicit XOR merge).
|
|
2
7
|
//
|
|
3
|
-
// const
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
8
|
+
// const convergence = defineFlow(
|
|
9
|
+
// "convergence-loop",
|
|
10
|
+
// { // contracts keyed by step name
|
|
11
|
+
// "review-round": { in: PrReviewRoundIn, out: PrReviewRoundOut },
|
|
12
|
+
// "persist-round": { out: RoundState },
|
|
13
|
+
// "wait-review": { in: ReviewReady },
|
|
14
|
+
// "wait-answer": { in: EscalationAnswered },
|
|
15
|
+
// },
|
|
16
|
+
// (w) => {
|
|
17
|
+
// w.loop((b) => {
|
|
18
|
+
// b.run("review-round", reviewRound); // job.variables typed from the contract
|
|
19
|
+
// b.switch("status", {
|
|
20
|
+
// converged: (c) => { c.run("persist-converged", finalize); c.break(); },
|
|
21
|
+
// addressed: (c) => c.branch("round >= maxRounds", {
|
|
22
|
+
// then: (g) => { g.run("persist-escalation-maxrounds", persistEsc);
|
|
23
|
+
// g.signal("wait-answer", { correlationKey: "prKey" }); },
|
|
24
|
+
// else: (g) => { g.run("persist-round", persistRound); // returns { round: round + 1 }
|
|
25
|
+
// g.signal("wait-review", { correlationKey: "prKey" }); },
|
|
26
|
+
// }),
|
|
27
|
+
// default: (c) => { c.run("persist-escalation", persistEsc);
|
|
28
|
+
// c.signal("wait-answer", { correlationKey: "prKey" }); },
|
|
29
|
+
// });
|
|
30
|
+
// });
|
|
31
|
+
// },
|
|
32
|
+
// );
|
|
9
33
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
34
|
+
// Typed data envelopes are LIFTED into the model (nano:shape + dataEnvelope
|
|
35
|
+
// zeebe:property), so the generated .bpmn is ejectable to model-first with its
|
|
36
|
+
// typed contracts intact.
|
|
13
37
|
|
|
14
|
-
import type {
|
|
15
|
-
|
|
38
|
+
import type {
|
|
39
|
+
DeclarativeFlow,
|
|
40
|
+
FlowContracts,
|
|
41
|
+
FlowNode,
|
|
42
|
+
JsonObject,
|
|
43
|
+
NodeEnvelopes,
|
|
44
|
+
StepContract,
|
|
45
|
+
StepHandler,
|
|
46
|
+
SwitchCase,
|
|
47
|
+
} from "./types.js";
|
|
48
|
+
import type { Envelope, EnvelopeField } from "./envelope.js";
|
|
49
|
+
import { assertIdent, assertJobType, escapeXml, jobType, messageName } from "./xml.js";
|
|
16
50
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
51
|
+
// --- Authoring surface -------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
/** The TS payload type of a contract's input envelope (untyped fallback). */
|
|
54
|
+
type InPayload<Ct> = Ct extends { in: Envelope } ? Ct["in"]["type"] : JsonObject;
|
|
55
|
+
/** The TS payload type of a contract's output envelope (untyped fallback). */
|
|
56
|
+
type OutPayload<Ct> = Ct extends { out: Envelope } ? Ct["out"]["type"] : JsonObject;
|
|
57
|
+
/** The input payload type of step `K` under contracts `C`. */
|
|
58
|
+
type VarsOf<C, K extends string> = K extends keyof C ? InPayload<C[K]> : JsonObject;
|
|
59
|
+
/** The output payload type of step `K` under contracts `C`. */
|
|
60
|
+
type ResultOf<C, K extends string> = K extends keyof C ? OutPayload<C[K]> : JsonObject;
|
|
61
|
+
|
|
62
|
+
/** A typed handler for a `run` step: its job variables and result are resolved
|
|
63
|
+
* from the flow contracts by the step name. */
|
|
64
|
+
type TypedHandler<V, R> = (job: {
|
|
65
|
+
jobKey: string;
|
|
66
|
+
processInstanceKey: string;
|
|
67
|
+
elementId: string;
|
|
68
|
+
type: string;
|
|
69
|
+
variables: V;
|
|
70
|
+
}) => Promise<R | void> | R | void;
|
|
71
|
+
|
|
72
|
+
/** A block: the callback that populates a nested body (a case, arm, or loop). */
|
|
73
|
+
type Block<C extends FlowContracts> = (b: FlowBuilder<C>) => void;
|
|
74
|
+
|
|
75
|
+
export interface FlowBuilder<C extends FlowContracts = Record<string, never>> {
|
|
76
|
+
/**
|
|
77
|
+
* A durable activity served by a worker THIS program hosts (a BPMN service
|
|
78
|
+
* task; the handler runs in the in-process `Worker`). If `name` is a key in
|
|
79
|
+
* the flow's contracts, the handler's job variables and return value are typed
|
|
80
|
+
* from that contract's `in`/`out` envelopes; otherwise they are `JsonObject`.
|
|
81
|
+
*/
|
|
82
|
+
run<K extends string>(name: K, handler: TypedHandler<VarsOf<C, K>, ResultOf<C, K>>): FlowBuilder<C>;
|
|
21
83
|
/**
|
|
22
84
|
* A durable activity served by a worker OUTSIDE this program (a BPMN service
|
|
23
|
-
* task
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* `
|
|
85
|
+
* task, but no locally-hosted handler). Its job type defaults to the derived
|
|
86
|
+
* `${flowId}:${name}`; pass `{ jobType }` to override it with an explicit
|
|
87
|
+
* worker token (e.g. a `rank:capability` token like `senior:pr-review` that a
|
|
88
|
+
* `c8ctl nano work` matrix subscribes to) so an existing pool of agents can
|
|
89
|
+
* service it without renaming the flow. The step name stays the BPMN element
|
|
90
|
+
* id; only the emitted `zeebe:taskDefinition` type changes. Use
|
|
91
|
+
* `externalJobTypes(flow)` to list the (possibly overridden) types those
|
|
92
|
+
* workers must poll. Its contract envelopes (if any) type the model, not a
|
|
93
|
+
* local handler.
|
|
27
94
|
*/
|
|
28
|
-
task(name: string): FlowBuilder
|
|
95
|
+
task<K extends string>(name: K, opts?: { jobType?: string }): FlowBuilder<C>;
|
|
29
96
|
/**
|
|
30
97
|
* A durable wait for an external/human event, correlated on a process
|
|
31
98
|
* variable (a BPMN message intermediate catch event). Resume it with
|
|
32
|
-
* `WorkflowClient.signal(flow, name, correlationKeyValue, vars)`.
|
|
99
|
+
* `WorkflowClient.signal(flow, name, correlationKeyValue, vars)`. The message
|
|
100
|
+
* payload envelope, if any, comes from the contract's `in`.
|
|
101
|
+
*/
|
|
102
|
+
signal<K extends string>(name: K, opts: { correlationKey: string }): FlowBuilder<C>;
|
|
103
|
+
/**
|
|
104
|
+
* A multi-way exclusive choice (a BPMN exclusive gateway). `subject` is a FEEL
|
|
105
|
+
* expression (usually a variable name); each case routes when `subject` equals
|
|
106
|
+
* the case value. An optional `default` case is the unconditional fallback.
|
|
107
|
+
*/
|
|
108
|
+
switch(subject: string, cases: Record<string, Block<C>> & { default?: Block<C> }): FlowBuilder<C>;
|
|
109
|
+
/**
|
|
110
|
+
* A two-way exclusive choice on a FEEL boolean `condition` (a BPMN exclusive
|
|
111
|
+
* gateway). The `then` branch is guarded by the condition; the `else` branch
|
|
112
|
+
* (the gateway default) runs otherwise. Omitting `else` skips to whatever
|
|
113
|
+
* follows the branch when the condition is false.
|
|
114
|
+
*/
|
|
115
|
+
branch(condition: string, arms: { then: Block<C>; else?: Block<C> }): FlowBuilder<C>;
|
|
116
|
+
/**
|
|
117
|
+
* A durable loop (a back-edge to the loop head). The body runs, then control
|
|
118
|
+
* returns to the top of the loop unless a branch calls `break()`. Nodes after
|
|
119
|
+
* the loop run once `break()` is reached.
|
|
33
120
|
*/
|
|
34
|
-
|
|
121
|
+
loop(body: Block<C>): FlowBuilder<C>;
|
|
122
|
+
/** Exit the enclosing loop (routes to whatever follows it). Only valid inside
|
|
123
|
+
* a `loop`. */
|
|
124
|
+
break(): FlowBuilder<C>;
|
|
125
|
+
/** Jump straight back to the top of the enclosing loop, skipping the rest of
|
|
126
|
+
* the body. Only valid inside a `loop`. */
|
|
127
|
+
continue(): FlowBuilder<C>;
|
|
35
128
|
}
|
|
36
129
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
130
|
+
interface BuilderCtx {
|
|
131
|
+
contracts: FlowContracts;
|
|
132
|
+
handlers: Record<string, StepHandler>;
|
|
133
|
+
seen: Set<string>;
|
|
134
|
+
loopDepth: number;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Ids the emitter generates for structural nodes / flows / messages. A step
|
|
138
|
+
* name that collides with one of these would produce a duplicate BPMN id and an
|
|
139
|
+
* invalid model, so reject them at authoring time. */
|
|
140
|
+
const RESERVED_PREFIXES = /^(Gw_|Loop_|Msg_|f_)/;
|
|
141
|
+
|
|
142
|
+
function claimName(ctx: BuilderCtx, id: string, name: string): void {
|
|
143
|
+
assertIdent("step name", name);
|
|
144
|
+
if (name === "Start" || name === "End" || name === id) {
|
|
145
|
+
throw new Error(`step name "${name}" is reserved (collides with a generated BPMN id) in flow "${id}"`);
|
|
146
|
+
}
|
|
147
|
+
if (RESERVED_PREFIXES.test(name)) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
`step name "${name}" uses a reserved prefix (Gw_/Loop_/Msg_/f_ are generated ids) in flow "${id}"`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
if (ctx.seen.has(name)) throw new Error(`duplicate step name "${name}" in flow "${id}"`);
|
|
153
|
+
ctx.seen.add(name);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Resolve a step's declared envelopes from the flow contracts. */
|
|
157
|
+
function contractEnvelopes(ctx: BuilderCtx, name: string): { in?: Envelope; out?: Envelope } | undefined {
|
|
158
|
+
const c: StepContract | undefined = ctx.contracts[name];
|
|
159
|
+
if (!c || (!c.in && !c.out)) return undefined;
|
|
160
|
+
return { in: c.in, out: c.out };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Build a FlowBuilder that appends its nodes to `out`, sharing the flow-wide
|
|
164
|
+
* `ctx` (contracts, handler registry, name set, loop nesting). Structural
|
|
165
|
+
* combinators recurse with a fresh `out` array for each nested body. */
|
|
166
|
+
function makeBuilder<C extends FlowContracts>(id: string, out: FlowNode[], ctx: BuilderCtx): FlowBuilder<C> {
|
|
167
|
+
const child = (fn: Block<C>, inLoop: boolean): FlowNode[] => {
|
|
168
|
+
const body: FlowNode[] = [];
|
|
169
|
+
const depth = inLoop ? ctx.loopDepth + 1 : ctx.loopDepth;
|
|
170
|
+
fn(makeBuilder<C>(id, body, { ...ctx, loopDepth: depth }));
|
|
171
|
+
return body;
|
|
172
|
+
};
|
|
173
|
+
const b = {
|
|
174
|
+
run(name: string, handler: StepHandler): FlowBuilder<C> {
|
|
175
|
+
claimName(ctx, id, name);
|
|
47
176
|
if (typeof handler !== "function") throw new Error(`run("${name}") needs a handler function`);
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
return w;
|
|
177
|
+
ctx.handlers[name] = handler;
|
|
178
|
+
out.push({ kind: "run", name, envelopes: contractEnvelopes(ctx, name) });
|
|
179
|
+
return b as unknown as FlowBuilder<C>;
|
|
52
180
|
},
|
|
53
|
-
task(name) {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
return
|
|
181
|
+
task(name: string, opts?: { jobType?: string }): FlowBuilder<C> {
|
|
182
|
+
claimName(ctx, id, name);
|
|
183
|
+
const override = opts?.jobType;
|
|
184
|
+
if (override !== undefined) assertJobType("task jobType", override);
|
|
185
|
+
out.push({ kind: "task", name, envelopes: contractEnvelopes(ctx, name), jobType: override });
|
|
186
|
+
return b as unknown as FlowBuilder<C>;
|
|
59
187
|
},
|
|
60
|
-
signal(name, opts) {
|
|
61
|
-
|
|
62
|
-
if (seen.has(name)) throw new Error(`duplicate step name "${name}" in flow "${id}"`);
|
|
188
|
+
signal(name: string, opts: { correlationKey: string }): FlowBuilder<C> {
|
|
189
|
+
claimName(ctx, id, name);
|
|
63
190
|
if (!opts || !opts.correlationKey) throw new Error(`signal("${name}") needs { correlationKey }`);
|
|
64
191
|
assertIdent("correlationKey", opts.correlationKey);
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
192
|
+
out.push({ kind: "signal", name, correlationKey: opts.correlationKey, payload: ctx.contracts[name]?.in });
|
|
193
|
+
return b as unknown as FlowBuilder<C>;
|
|
194
|
+
},
|
|
195
|
+
switch(subject: string, cases: Record<string, Block<C>> & { default?: Block<C> }): FlowBuilder<C> {
|
|
196
|
+
if (typeof subject !== "string" || subject.trim() === "") {
|
|
197
|
+
throw new Error(`switch() needs a non-empty subject expression`);
|
|
198
|
+
}
|
|
199
|
+
const entries = Object.entries(cases).filter(([k]) => k !== "default") as [string, Block<C>][];
|
|
200
|
+
if (entries.length === 0) throw new Error(`switch("${subject}") needs at least one case`);
|
|
201
|
+
const caseNodes: SwitchCase[] = entries.map(([value, fn]) => ({ value, body: child(fn, false) }));
|
|
202
|
+
const def = cases.default ? child(cases.default, false) : undefined;
|
|
203
|
+
out.push({ kind: "switch", subject, cases: caseNodes, default: def });
|
|
204
|
+
return b as unknown as FlowBuilder<C>;
|
|
205
|
+
},
|
|
206
|
+
branch(condition: string, arms: { then: Block<C>; else?: Block<C> }): FlowBuilder<C> {
|
|
207
|
+
if (typeof condition !== "string" || condition.trim() === "") {
|
|
208
|
+
throw new Error(`branch() needs a non-empty FEEL condition`);
|
|
209
|
+
}
|
|
210
|
+
if (!arms || typeof arms.then !== "function") throw new Error(`branch("${condition}") needs a then arm`);
|
|
211
|
+
out.push({
|
|
212
|
+
kind: "branch",
|
|
213
|
+
condition,
|
|
214
|
+
then: child(arms.then, false),
|
|
215
|
+
else: arms.else ? child(arms.else, false) : undefined,
|
|
216
|
+
});
|
|
217
|
+
return b as unknown as FlowBuilder<C>;
|
|
218
|
+
},
|
|
219
|
+
loop(body: Block<C>): FlowBuilder<C> {
|
|
220
|
+
if (typeof body !== "function") throw new Error(`loop() needs a body function`);
|
|
221
|
+
out.push({ kind: "loop", body: child(body, true) });
|
|
222
|
+
return b as unknown as FlowBuilder<C>;
|
|
223
|
+
},
|
|
224
|
+
break(): FlowBuilder<C> {
|
|
225
|
+
if (ctx.loopDepth === 0) throw new Error(`break() is only valid inside a loop`);
|
|
226
|
+
out.push({ kind: "break" });
|
|
227
|
+
return b as unknown as FlowBuilder<C>;
|
|
228
|
+
},
|
|
229
|
+
continue(): FlowBuilder<C> {
|
|
230
|
+
if (ctx.loopDepth === 0) throw new Error(`continue() is only valid inside a loop`);
|
|
231
|
+
out.push({ kind: "continue" });
|
|
232
|
+
return b as unknown as FlowBuilder<C>;
|
|
68
233
|
},
|
|
69
234
|
};
|
|
70
|
-
|
|
235
|
+
return b as unknown as FlowBuilder<C>;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Define a declarative flow. Pass a typed `contracts` map (keyed by step name)
|
|
240
|
+
* to type each step's I/O and lift its data envelopes into the model; or omit it
|
|
241
|
+
* for an untyped flow. `build(w)` declares a tree of nodes.
|
|
242
|
+
*/
|
|
243
|
+
export function defineFlow<C extends FlowContracts>(
|
|
244
|
+
id: string,
|
|
245
|
+
contracts: C,
|
|
246
|
+
build: (w: FlowBuilder<C>) => void,
|
|
247
|
+
): DeclarativeFlow;
|
|
248
|
+
export function defineFlow(id: string, build: (w: FlowBuilder) => void): DeclarativeFlow;
|
|
249
|
+
export function defineFlow(
|
|
250
|
+
id: string,
|
|
251
|
+
second: FlowContracts | ((w: FlowBuilder) => void),
|
|
252
|
+
third?: (w: FlowBuilder<FlowContracts>) => void,
|
|
253
|
+
): DeclarativeFlow {
|
|
254
|
+
assertIdent("workflow id", id);
|
|
255
|
+
if (typeof second !== "function" && (second === null || typeof second !== "object")) {
|
|
256
|
+
throw new Error(`defineFlow("${id}"): the contracts argument must be an object`);
|
|
257
|
+
}
|
|
258
|
+
const contracts: FlowContracts = typeof second === "function" ? {} : second;
|
|
259
|
+
const build = (typeof second === "function" ? second : third) as (w: FlowBuilder<FlowContracts>) => void;
|
|
260
|
+
if (typeof build !== "function") {
|
|
261
|
+
throw new Error(`defineFlow("${id}"): a build callback (w) => {…} is required`);
|
|
262
|
+
}
|
|
263
|
+
const steps: FlowNode[] = [];
|
|
264
|
+
const handlers: Record<string, StepHandler> = {};
|
|
265
|
+
const ctx: BuilderCtx = { contracts, handlers, seen: new Set(), loopDepth: 0 };
|
|
266
|
+
build(makeBuilder<FlowContracts>(id, steps, ctx));
|
|
71
267
|
if (steps.length === 0) throw new Error(`flow "${id}" declared no steps`);
|
|
72
268
|
return { kind: "declarative", id, steps, handlers };
|
|
73
269
|
}
|
|
74
270
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
271
|
+
// --- Tree walkers ------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
/** Depth-first visit of every node in a flow tree (structural combinators
|
|
274
|
+
* recurse into their bodies). */
|
|
275
|
+
export function walkNodes(nodes: FlowNode[], visit: (n: FlowNode) => void): void {
|
|
276
|
+
for (const n of nodes) {
|
|
277
|
+
visit(n);
|
|
278
|
+
switch (n.kind) {
|
|
279
|
+
case "switch":
|
|
280
|
+
for (const c of n.cases) walkNodes(c.body, visit);
|
|
281
|
+
if (n.default) walkNodes(n.default, visit);
|
|
282
|
+
break;
|
|
283
|
+
case "branch":
|
|
284
|
+
walkNodes(n.then, visit);
|
|
285
|
+
if (n.else) walkNodes(n.else, visit);
|
|
286
|
+
break;
|
|
287
|
+
case "loop":
|
|
288
|
+
walkNodes(n.body, visit);
|
|
289
|
+
break;
|
|
290
|
+
default:
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** The job types of a flow's external `task` steps (anywhere in the tree) — the
|
|
297
|
+
* contract workers outside this program must subscribe to. Each is the derived
|
|
298
|
+
* `<flowId>:<stepName>` unless the step overrode it via `w.task(name,
|
|
299
|
+
* { jobType })`. Deduplicated (preserving first-seen order) since several steps
|
|
300
|
+
* may intentionally share one override token. */
|
|
78
301
|
export function externalJobTypes(flow: DeclarativeFlow): string[] {
|
|
79
|
-
|
|
302
|
+
const seen = new Set<string>();
|
|
303
|
+
const types: string[] = [];
|
|
304
|
+
walkNodes(flow.steps, (n) => {
|
|
305
|
+
if (n.kind !== "task") return;
|
|
306
|
+
const type = n.jobType ?? jobType(flow.id, n.name);
|
|
307
|
+
if (seen.has(type)) return;
|
|
308
|
+
seen.add(type);
|
|
309
|
+
types.push(type);
|
|
310
|
+
});
|
|
311
|
+
return types;
|
|
80
312
|
}
|
|
81
313
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
314
|
+
// --- Model emitter (two-phase graph compiler) --------------------------------
|
|
315
|
+
|
|
316
|
+
interface RenderNode {
|
|
317
|
+
id: string;
|
|
318
|
+
render(incoming: string[], outgoing: string[]): string;
|
|
319
|
+
/** For exclusive gateways: the flow id of the unconditional default edge. */
|
|
320
|
+
defaultFlow?: string;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
interface Edge {
|
|
324
|
+
id: string;
|
|
325
|
+
from: string;
|
|
326
|
+
to?: string;
|
|
327
|
+
condition?: string;
|
|
328
|
+
name?: string;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
interface LoopCtx {
|
|
332
|
+
headId: string;
|
|
333
|
+
breaks: Edge[];
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
class Compiler {
|
|
337
|
+
private readonly nodes: RenderNode[] = [];
|
|
338
|
+
private readonly edges: Edge[] = [];
|
|
339
|
+
/** envelope name → its fields, deduped for lifting to a single nano:shape. */
|
|
340
|
+
private readonly envelopes = new Map<string, EnvelopeField[]>();
|
|
341
|
+
private seq = 0;
|
|
342
|
+
private gw = 0;
|
|
343
|
+
|
|
344
|
+
constructor(private readonly flow: DeclarativeFlow) {}
|
|
345
|
+
|
|
346
|
+
private newEdge(from: string, opts: { condition?: string; name?: string } = {}): Edge {
|
|
347
|
+
const e: Edge = { id: `f_${this.seq++}`, from, condition: opts.condition, name: opts.name };
|
|
348
|
+
this.edges.push(e);
|
|
349
|
+
return e;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private connect(incoming: Edge[], toId: string): void {
|
|
353
|
+
for (const e of incoming) e.to = toId;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
private recordEnvelope(env?: Envelope): void {
|
|
357
|
+
if (!env) return;
|
|
358
|
+
const prev = this.envelopes.get(env.name);
|
|
359
|
+
if (prev) {
|
|
360
|
+
if (JSON.stringify(prev) !== JSON.stringify(env.fields)) {
|
|
361
|
+
throw new Error(
|
|
362
|
+
`envelope "${env.name}" is declared with two different field sets in flow "${this.flow.id}"`,
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
this.envelopes.set(env.name, env.fields);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
private addServiceTask(node: { name: string; envelopes?: NodeEnvelopes; jobType?: string }): void {
|
|
371
|
+
const type = node.jobType ?? jobType(this.flow.id, node.name);
|
|
372
|
+
this.recordEnvelope(node.envelopes?.in);
|
|
373
|
+
this.recordEnvelope(node.envelopes?.out);
|
|
374
|
+
const props: string[] = [];
|
|
375
|
+
if (node.envelopes?.in) props.push(envelopeProp("in", node.envelopes.in.name));
|
|
376
|
+
if (node.envelopes?.out) props.push(envelopeProp("out", node.envelopes.out.name));
|
|
377
|
+
const ext =
|
|
378
|
+
` <bpmn:extensionElements>\n` +
|
|
379
|
+
` <zeebe:taskDefinition type="${escapeXml(type)}" />\n` +
|
|
380
|
+
(props.length ? ` <zeebe:properties>\n${props.join("\n")}\n </zeebe:properties>\n` : "") +
|
|
381
|
+
` </bpmn:extensionElements>`;
|
|
382
|
+
const id = node.name;
|
|
383
|
+
this.nodes.push({
|
|
384
|
+
id,
|
|
385
|
+
render: (inc, outg) =>
|
|
386
|
+
` <bpmn:serviceTask id="${escapeXml(id)}" name="${escapeXml(id)}">\n` +
|
|
387
|
+
ext +
|
|
388
|
+
"\n" +
|
|
389
|
+
incomingOutgoing(inc, outg) +
|
|
390
|
+
` </bpmn:serviceTask>`,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private addCatchEvent(node: { name: string }): void {
|
|
395
|
+
const id = node.name;
|
|
396
|
+
const msgId = `Msg_${id}`;
|
|
397
|
+
this.nodes.push({
|
|
398
|
+
id,
|
|
399
|
+
render: (inc, outg) =>
|
|
400
|
+
` <bpmn:intermediateCatchEvent id="${escapeXml(id)}" name="${escapeXml(id)}">\n` +
|
|
401
|
+
incomingOutgoing(inc, outg) +
|
|
402
|
+
` <bpmn:messageEventDefinition messageRef="${msgId}" />\n` +
|
|
403
|
+
` </bpmn:intermediateCatchEvent>`,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
private addGateway(id: string, name?: string): RenderNode {
|
|
408
|
+
const gwNode: RenderNode = {
|
|
409
|
+
id,
|
|
410
|
+
render: (inc, outg) => {
|
|
411
|
+
const def = gwNode.defaultFlow ? ` default="${gwNode.defaultFlow}"` : "";
|
|
412
|
+
const nm = name ? ` name="${escapeXml(name)}"` : "";
|
|
413
|
+
return (
|
|
414
|
+
` <bpmn:exclusiveGateway id="${id}"${nm}${def}>\n` +
|
|
415
|
+
incomingOutgoing(inc, outg) +
|
|
416
|
+
` </bpmn:exclusiveGateway>`
|
|
417
|
+
);
|
|
418
|
+
},
|
|
419
|
+
};
|
|
420
|
+
this.nodes.push(gwNode);
|
|
421
|
+
return gwNode;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
private emitList(list: FlowNode[], incoming: Edge[], loop: LoopCtx | null): Edge[] {
|
|
425
|
+
let cur = incoming;
|
|
426
|
+
for (const node of list) cur = this.emitNode(node, cur, loop);
|
|
427
|
+
return cur;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
private emitNode(node: FlowNode, incoming: Edge[], loop: LoopCtx | null): Edge[] {
|
|
431
|
+
switch (node.kind) {
|
|
432
|
+
case "run":
|
|
433
|
+
case "task": {
|
|
434
|
+
this.addServiceTask(node);
|
|
435
|
+
this.connect(incoming, node.name);
|
|
436
|
+
return [this.newEdge(node.name)];
|
|
437
|
+
}
|
|
438
|
+
case "signal": {
|
|
439
|
+
this.addCatchEvent(node);
|
|
440
|
+
this.connect(incoming, node.name);
|
|
441
|
+
this.recordEnvelope(node.payload);
|
|
442
|
+
return [this.newEdge(node.name)];
|
|
443
|
+
}
|
|
444
|
+
case "switch": {
|
|
445
|
+
const id = `Gw_${this.gw++}`;
|
|
446
|
+
const gw = this.addGateway(id, node.subject);
|
|
447
|
+
this.connect(incoming, id);
|
|
448
|
+
const out: Edge[] = [];
|
|
449
|
+
for (const c of node.cases) {
|
|
450
|
+
const e = this.newEdge(id, { condition: feelEquals(node.subject, c.value), name: c.value });
|
|
451
|
+
out.push(...this.emitList(c.body, [e], loop));
|
|
452
|
+
}
|
|
453
|
+
// The default (or a synthesised fall-through) is the unconditional edge.
|
|
454
|
+
const de = this.newEdge(id, { name: "default" });
|
|
455
|
+
gw.defaultFlow = de.id;
|
|
456
|
+
out.push(...this.emitList(node.default ?? [], [de], loop));
|
|
457
|
+
return out;
|
|
458
|
+
}
|
|
459
|
+
case "branch": {
|
|
460
|
+
const id = `Gw_${this.gw++}`;
|
|
461
|
+
const gw = this.addGateway(id);
|
|
462
|
+
this.connect(incoming, id);
|
|
463
|
+
const te = this.newEdge(id, { condition: feel(node.condition), name: "then" });
|
|
464
|
+
const out = this.emitList(node.then, [te], loop);
|
|
465
|
+
const ee = this.newEdge(id, { name: "else" });
|
|
466
|
+
gw.defaultFlow = ee.id;
|
|
467
|
+
out.push(...this.emitList(node.else ?? [], [ee], loop));
|
|
468
|
+
return out;
|
|
469
|
+
}
|
|
470
|
+
case "loop": {
|
|
471
|
+
const headId = `Loop_${this.gw++}`;
|
|
472
|
+
this.addGateway(headId);
|
|
473
|
+
this.connect(incoming, headId);
|
|
474
|
+
const ctx: LoopCtx = { headId, breaks: [] };
|
|
475
|
+
const headOut = this.newEdge(headId);
|
|
476
|
+
const bodyOut = this.emitList(node.body, [headOut], ctx);
|
|
477
|
+
// Normal fall-through of the body loops back to the head (continue).
|
|
478
|
+
this.connect(bodyOut, headId);
|
|
479
|
+
// The loop exits only via break edges.
|
|
480
|
+
return ctx.breaks;
|
|
481
|
+
}
|
|
482
|
+
case "break": {
|
|
483
|
+
if (!loop) throw new Error(`break outside a loop in flow "${this.flow.id}"`);
|
|
484
|
+
// The incoming edges (from the previous node) become the loop's exit
|
|
485
|
+
// danglers; this path does not fall through.
|
|
486
|
+
loop.breaks.push(...incoming);
|
|
487
|
+
return [];
|
|
488
|
+
}
|
|
489
|
+
case "continue": {
|
|
490
|
+
if (!loop) throw new Error(`continue outside a loop in flow "${this.flow.id}"`);
|
|
491
|
+
this.connect(incoming, loop.headId);
|
|
492
|
+
return [];
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
compile(): string {
|
|
498
|
+
// Start → top-level sequence → End.
|
|
499
|
+
this.nodes.push({
|
|
500
|
+
id: "Start",
|
|
501
|
+
render: (_inc, outg) => ` <bpmn:startEvent id="Start">${outgoingOnly(outg)}</bpmn:startEvent>`,
|
|
502
|
+
});
|
|
503
|
+
const s0 = this.newEdge("Start");
|
|
504
|
+
const finalDanglers = this.emitList(this.flow.steps, [s0], null);
|
|
505
|
+
this.nodes.push({
|
|
506
|
+
id: "End",
|
|
507
|
+
render: (inc) => ` <bpmn:endEvent id="End">${incomingOnly(inc)}</bpmn:endEvent>`,
|
|
508
|
+
});
|
|
509
|
+
this.connect(finalDanglers, "End");
|
|
510
|
+
|
|
511
|
+
// A sequence flow needs both ends; drop any edge that never got a target.
|
|
512
|
+
const live = this.edges.filter((e) => e.to !== undefined);
|
|
513
|
+
const incomingOf = (id: string) => live.filter((e) => e.to === id).map((e) => e.id);
|
|
514
|
+
const outgoingOf = (id: string) => live.filter((e) => e.from === id).map((e) => e.id);
|
|
515
|
+
|
|
516
|
+
const nodeXml = this.nodes.map((n) => n.render(incomingOf(n.id), outgoingOf(n.id))).join("\n");
|
|
517
|
+
const flowXml = live.map((e) => sequenceFlow(e)).join("\n");
|
|
518
|
+
const messageXml = this.emitMessages();
|
|
519
|
+
const shapeXml = this.emitShapes();
|
|
520
|
+
|
|
521
|
+
return (
|
|
522
|
+
`<?xml version="1.0" encoding="UTF-8"?>\n` +
|
|
523
|
+
`<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" ` +
|
|
524
|
+
`xmlns:zeebe="http://camunda.org/schema/zeebe/1.0" ` +
|
|
525
|
+
`xmlns:nano="https://nanobpm.io/schema/shapes/1.0" ` +
|
|
526
|
+
`id="Definitions_${escapeXml(this.flow.id)}" targetNamespace="http://bpmn.io/schema/bpmn">\n` +
|
|
527
|
+
` <bpmn:process id="${escapeXml(this.flow.id)}" name="${escapeXml(this.flow.id)}" isExecutable="true">\n` +
|
|
528
|
+
(shapeXml ? shapeXml + "\n" : "") +
|
|
529
|
+
nodeXml +
|
|
530
|
+
"\n" +
|
|
531
|
+
flowXml +
|
|
532
|
+
"\n" +
|
|
533
|
+
` </bpmn:process>\n` +
|
|
534
|
+
messageXml +
|
|
535
|
+
(messageXml ? "\n" : "") +
|
|
536
|
+
`</bpmn:definitions>\n`
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** Emit a `<bpmn:message>` per signal step, with its subscription and (when
|
|
541
|
+
* typed) its payload data envelope. */
|
|
542
|
+
private emitMessages(): string {
|
|
543
|
+
const msgs: string[] = [];
|
|
544
|
+
walkNodes(this.flow.steps, (n) => {
|
|
545
|
+
if (n.kind !== "signal") return;
|
|
546
|
+
const msgId = `Msg_${n.name}`;
|
|
547
|
+
const payloadProp = n.payload
|
|
548
|
+
? `\n <zeebe:properties>\n${envelopeProp("in", n.payload.name)}\n </zeebe:properties>`
|
|
549
|
+
: "";
|
|
550
|
+
msgs.push(
|
|
551
|
+
` <bpmn:message id="${msgId}" name="${escapeXml(messageName(this.flow.id, n.name))}">\n` +
|
|
552
|
+
` <bpmn:extensionElements>\n` +
|
|
553
|
+
` <zeebe:subscription correlationKey="=${escapeXml(n.correlationKey)}" />` +
|
|
554
|
+
payloadProp +
|
|
555
|
+
`\n </bpmn:extensionElements>\n` +
|
|
114
556
|
` </bpmn:message>`,
|
|
115
557
|
);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
nodes.push(` <bpmn:endEvent id="End"><bpmn:incoming>flow_${flow.steps.length}</bpmn:incoming></bpmn:endEvent>`);
|
|
119
|
-
for (let i = 0; i < ids.length - 1; i++) {
|
|
120
|
-
flows.push(` <bpmn:sequenceFlow id="flow_${i}" sourceRef="${ids[i]}" targetRef="${ids[i + 1]}" />`);
|
|
558
|
+
});
|
|
559
|
+
return msgs.join("\n");
|
|
121
560
|
}
|
|
122
561
|
|
|
562
|
+
/** Lift the referenced data envelopes into a `<nano:shapes>` container on the
|
|
563
|
+
* process extension elements, so the model carries the typed contracts and is
|
|
564
|
+
* ejectable to model-first. */
|
|
565
|
+
private emitShapes(): string {
|
|
566
|
+
if (this.envelopes.size === 0) return "";
|
|
567
|
+
const shapes = [...this.envelopes.entries()].map(([name, fields]) => {
|
|
568
|
+
const exts = fields.map((f) => {
|
|
569
|
+
const opt = f.optional ? ` optional="true"` : "";
|
|
570
|
+
const list = f.list ? ` list="true"` : "";
|
|
571
|
+
return ` <nano:extend name="${escapeXml(f.name)}" type="${escapeXml(f.type)}"${opt}${list} />`;
|
|
572
|
+
});
|
|
573
|
+
return ` <nano:shape id="${escapeXml(name)}">\n${exts.join("\n")}\n </nano:shape>`;
|
|
574
|
+
});
|
|
575
|
+
return (
|
|
576
|
+
` <bpmn:extensionElements>\n` +
|
|
577
|
+
` <nano:shapes>\n${shapes.join("\n")}\n </nano:shapes>\n` +
|
|
578
|
+
` </bpmn:extensionElements>`
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** Derive an executable BPMN model from a declarative flow. */
|
|
584
|
+
export function declarativeToBpmn(flow: DeclarativeFlow): string {
|
|
585
|
+
return new Compiler(flow).compile();
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// --- small XML / FEEL helpers ------------------------------------------------
|
|
589
|
+
|
|
590
|
+
const envelopeProp = (dir: "in" | "out", value: string): string =>
|
|
591
|
+
` <zeebe:property name="io.nanobpm.dataEnvelope.${dir}" value="${escapeXml(value)}" />`;
|
|
592
|
+
|
|
593
|
+
/** Wrap a raw FEEL expression as a Zeebe condition body (leading `=`). */
|
|
594
|
+
const feel = (expr: string): string => `=${expr}`;
|
|
595
|
+
|
|
596
|
+
/** A FEEL equality test `subject = "value"`, with the value as a FEEL string. */
|
|
597
|
+
const feelEquals = (subject: string, value: string): string =>
|
|
598
|
+
`=${subject} = "${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
599
|
+
|
|
600
|
+
function incomingOutgoing(inc: string[], outg: string[]): string {
|
|
123
601
|
return (
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
` <bpmn:process id="${escapeXml(flow.id)}" name="${escapeXml(flow.id)}" isExecutable="true">\n` +
|
|
127
|
-
nodes.join("\n") +
|
|
128
|
-
"\n" +
|
|
129
|
-
flows.join("\n") +
|
|
130
|
-
"\n" +
|
|
131
|
-
` </bpmn:process>\n` +
|
|
132
|
-
messages.join("\n") +
|
|
133
|
-
(messages.length ? "\n" : "") +
|
|
134
|
-
`</bpmn:definitions>\n`
|
|
602
|
+
inc.map((f) => ` <bpmn:incoming>${f}</bpmn:incoming>\n`).join("") +
|
|
603
|
+
outg.map((f) => ` <bpmn:outgoing>${f}</bpmn:outgoing>\n`).join("")
|
|
135
604
|
);
|
|
136
605
|
}
|
|
606
|
+
const incomingOnly = (inc: string[]): string => inc.map((f) => `<bpmn:incoming>${f}</bpmn:incoming>`).join("");
|
|
607
|
+
const outgoingOnly = (outg: string[]): string => outg.map((f) => `<bpmn:outgoing>${f}</bpmn:outgoing>`).join("");
|
|
608
|
+
|
|
609
|
+
function sequenceFlow(e: Edge): string {
|
|
610
|
+
const nm = e.name ? ` name="${escapeXml(e.name)}"` : "";
|
|
611
|
+
if (e.condition) {
|
|
612
|
+
return (
|
|
613
|
+
` <bpmn:sequenceFlow id="${e.id}" sourceRef="${e.from}" targetRef="${e.to}"${nm}>\n` +
|
|
614
|
+
` <bpmn:conditionExpression>${escapeXml(e.condition)}</bpmn:conditionExpression>\n` +
|
|
615
|
+
` </bpmn:sequenceFlow>`
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
return ` <bpmn:sequenceFlow id="${e.id}" sourceRef="${e.from}" targetRef="${e.to}"${nm} />`;
|
|
619
|
+
}
|