@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.
@@ -1,136 +1,619 @@
1
- // Strategy A — the declarative surface: a step list compiled to a linear model.
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 flow = defineFlow("pr-review", (w) => {
4
- // w.run("fetchDiff", async (job) => ({ diff: await gh.diff(job.variables.prId) }));
5
- // w.run("autoReview", async (job) => ({ findings: await llm.review(job.variables.diff) }));
6
- // w.signal("humanApproval", { correlationKey: "prId" }); // durable human wait
7
- // w.run("merge", async (job) => ({ merged: await gh.merge(job.variables.prId) }));
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
- // The façade derives the BPMN model, the job types (`<id>:<step>`), the message
11
- // name + `zeebe:subscription correlationKey`, and (via Worker) a generic worker.
12
- // This surface supports human-in-the-loop signals today.
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 { DeclarativeFlow, DeclarativeStep, StepHandler } from "./types.js";
15
- import { assertIdent, escapeXml, jobType, messageName } from "./xml.js";
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
- export interface FlowBuilder {
18
- /** A durable activity served by a worker THIS program hosts (a BPMN service
19
- * task; the handler runs in the in-process `Worker`). */
20
- run(name: string, handler: StepHandler): FlowBuilder;
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 with the same derived job type `${flowId}:${name}`, but no
24
- * locally-hosted handler). The engine offers the job to whichever worker
25
- * subscribes to that type — in another process, service, or language. Use
26
- * `externalJobTypes(flow)` to list the contract those workers must poll.
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
- signal(name: string, opts: { correlationKey: string }): FlowBuilder;
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
- /** Define a declarative flow. `build(w)` declares an ordered list of steps. */
38
- export function defineFlow(id: string, build: (w: FlowBuilder) => void): DeclarativeFlow {
39
- assertIdent("workflow id", id);
40
- const steps: DeclarativeStep[] = [];
41
- const handlers: Record<string, StepHandler> = {};
42
- const seen = new Set<string>();
43
- const w: FlowBuilder = {
44
- run(name, handler) {
45
- assertIdent("step name", name);
46
- if (seen.has(name)) throw new Error(`duplicate step name "${name}" in flow "${id}"`);
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
- seen.add(name);
49
- steps.push({ kind: "run", name });
50
- handlers[name] = handler;
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
- assertIdent("step name", name);
55
- if (seen.has(name)) throw new Error(`duplicate step name "${name}" in flow "${id}"`);
56
- seen.add(name);
57
- steps.push({ kind: "task", name });
58
- return w;
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
- assertIdent("step name", name);
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
- seen.add(name);
66
- steps.push({ kind: "signal", name, correlationKey: opts.correlationKey });
67
- return w;
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
- build(w);
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
- /** The derived job types of a flow's external `task` steps — the contract that
76
- * workers outside this program must subscribe to (the engine offers those jobs
77
- * to whoever polls the type). Empty when the flow hosts all its own steps. */
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
- return flow.steps.filter((s) => s.kind === "task").map((s) => jobType(flow.id, s.name));
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
- /** Derive an executable BPMN model from a declarative flow. */
83
- export function declarativeToBpmn(flow: DeclarativeFlow): string {
84
- const nodes: string[] = [];
85
- const flows: string[] = [];
86
- const messages: string[] = [];
87
- const ids = ["Start", ...flow.steps.map((s) => s.name), "End"];
88
-
89
- nodes.push(` <bpmn:startEvent id="Start"><bpmn:outgoing>flow_0</bpmn:outgoing></bpmn:startEvent>`);
90
- flow.steps.forEach((step, i) => {
91
- const incoming = `flow_${i}`;
92
- const outgoing = `flow_${i + 1}`;
93
- if (step.kind === "run" || step.kind === "task") {
94
- // Both hosted (`run`) and external (`task`) steps are BPMN service tasks
95
- // with the same derived job type; they differ only in whether the local
96
- // Worker registers a handler for that type.
97
- nodes.push(
98
- ` <bpmn:serviceTask id="${escapeXml(step.name)}" name="${escapeXml(step.name)}">\n` +
99
- ` <bpmn:extensionElements><zeebe:taskDefinition type="${escapeXml(jobType(flow.id, step.name))}" /></bpmn:extensionElements>\n` +
100
- ` <bpmn:incoming>${incoming}</bpmn:incoming><bpmn:outgoing>${outgoing}</bpmn:outgoing>\n` +
101
- ` </bpmn:serviceTask>`,
102
- );
103
- } else {
104
- const msgId = `Msg_${escapeXml(step.name)}`;
105
- nodes.push(
106
- ` <bpmn:intermediateCatchEvent id="${escapeXml(step.name)}" name="${escapeXml(step.name)}">\n` +
107
- ` <bpmn:incoming>${incoming}</bpmn:incoming><bpmn:outgoing>${outgoing}</bpmn:outgoing>\n` +
108
- ` <bpmn:messageEventDefinition messageRef="${msgId}" />\n` +
109
- ` </bpmn:intermediateCatchEvent>`,
110
- );
111
- messages.push(
112
- ` <bpmn:message id="${msgId}" name="${escapeXml(messageName(flow.id, step.name))}">\n` +
113
- ` <bpmn:extensionElements><zeebe:subscription correlationKey="=${escapeXml(step.correlationKey)}" /></bpmn:extensionElements>\n` +
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
- `<?xml version="1.0" encoding="UTF-8"?>\n` +
125
- `<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:zeebe="http://camunda.org/schema/zeebe/1.0" id="Definitions_${escapeXml(flow.id)}" targetNamespace="http://bpmn.io/schema/bpmn">\n` +
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
+ }