Skip to content

Commit b046ec2

Browse files
authored
feat(automation): BPMN ⇄ structured-construct model mapping (ADR-0031, task 5) (#1500)
Model-level BPMN ⇄ structured-construct mapping (ADR-0031 §Decision 5), completing the ADR-0031 roadmap. exportConstructsToBpmn expands parallel → gateways, try_catch → boundary event, loop → multi-instance; importBpmnToConstructs reconstructs exactly via the osConstruct marker and best-effort-folds foreign gateways. New automation/bpmn-mapping.ts + 9 tests. XML (de)serialization stays a plugin concern.
1 parent 4ee139d commit b046ec2

5 files changed

Lines changed: 730 additions & 0 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(automation): BPMN ⇄ structured-construct model mapping (ADR-0031, task 5)
6+
7+
Add the semantic bridge between the structured control-flow constructs (the
8+
native model) and the BPMN gateway/boundary/multi-instance vocabulary (kept for
9+
interop only), at the **flow-model level** — independent of any wire format
10+
(`automation/bpmn-mapping.ts`):
11+
12+
- `exportConstructsToBpmn(flow)` expands each construct into its BPMN
13+
interchange shape — `parallel` → `parallel_gateway` (AND-split) + branch
14+
regions + `join_gateway` (AND-join); `try_catch` → the protected activity +
15+
an error `boundary_event` + the handler region; `loop` → its body marked with
16+
multi-instance loop characteristics — so external BPM tools see a well-formed
17+
BPMN graph. Each expansion's anchor carries an `osConstruct` extension marker.
18+
- `importBpmnToConstructs(flow)` folds that BPMN shape back into the constructs:
19+
exact reconstruction from the `osConstruct` marker (so `construct → BPMN →
20+
construct` is identity), and a best-effort structural fold of foreign
21+
`parallel_gateway`/`join_gateway` pairs, with diagnostics for shapes it can't
22+
safely fold.
23+
24+
BPMN 2.0 **XML** (de)serialization layers on top of this mapping and remains a
25+
plugin concern (per `bpmn-interop.zod.ts`), out of scope here.

‎docs/adr/0031-advanced-flow-node-executors-and-dag.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,18 @@ nested containers (in `../objectui`) are deferred follow-ups.
177177
construct in spec + designer.
178178
5. **BPMN mapping** — `bpmn-interop` import/export ↔ structured constructs.
179179

180+
### Delivery status
181+
182+
Tasks 1–5 are shipped. Task 5 landed as a **model-level** mapping
183+
(`@objectstack/spec` `automation/bpmn-mapping.ts`): `exportConstructsToBpmn` /
184+
`importBpmnToConstructs` translate between the structured constructs and the
185+
BPMN gateway/boundary/multi-instance vocabulary on the flow model
186+
(nodes + edges + config), round-trippable via an `osConstruct` extension marker,
187+
with best-effort folding of foreign BPMN gateways. BPMN 2.0 **XML**
188+
(de)serialization layers on top and stays a **plugin** concern (per
189+
`bpmn-interop.zod.ts`, "Priority: Low"). Designer rendering of the constructs
190+
(in `../objectui`) is tracked separately.
191+
180192
## Non-goals / deferred
181193

182194
- Author-visible low-level BPMN `parallel_gateway` / `join_gateway` /
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import {
5+
exportConstructsToBpmn,
6+
importBpmnToConstructs,
7+
BPMN_PARALLEL_GATEWAY,
8+
BPMN_JOIN_GATEWAY,
9+
BPMN_BOUNDARY_EVENT,
10+
OS_CONSTRUCT_EXT,
11+
type MappableFlow,
12+
} from './bpmn-mapping';
13+
14+
const node = (id: string, type = 'assignment', config?: Record<string, unknown>) => ({ id, type, label: id, config });
15+
const edge = (id: string, source: string, target: string) => ({ id, source, target, type: 'default' as const });
16+
17+
/** Find the single node of a given type. */
18+
const findType = (flow: MappableFlow, type: string) => flow.nodes.filter(n => n.type === type);
19+
const findId = (flow: MappableFlow, id: string) => flow.nodes.find(n => n.id === id);
20+
const hasEdge = (flow: MappableFlow, source: string, target: string) =>
21+
flow.edges.some(e => e.source === source && e.target === target);
22+
23+
describe('exportConstructsToBpmn — parallel', () => {
24+
const flow: MappableFlow = {
25+
nodes: [
26+
node('start', 'start'),
27+
node('par', 'parallel', {
28+
branches: [
29+
{ name: 'A', nodes: [node('a')], edges: [] },
30+
{ name: 'B', nodes: [node('b')], edges: [] },
31+
],
32+
}),
33+
node('end', 'end'),
34+
],
35+
edges: [edge('e1', 'start', 'par'), edge('e2', 'par', 'end')],
36+
};
37+
38+
it('expands a parallel block into split/join gateways with branch nodes', () => {
39+
const { flow: out, mappedCount } = exportConstructsToBpmn(flow);
40+
expect(mappedCount).toBe(1);
41+
expect(findType(out, BPMN_PARALLEL_GATEWAY)).toHaveLength(1);
42+
expect(findType(out, BPMN_JOIN_GATEWAY)).toHaveLength(1);
43+
// the parallel construct node is gone; branch nodes are inlined
44+
expect(findId(out, 'par')).toBeUndefined();
45+
expect(findId(out, 'a')).toBeDefined();
46+
expect(findId(out, 'b')).toBeDefined();
47+
// wiring: start → split → {a,b} → join → end
48+
expect(hasEdge(out, 'start', 'par__split')).toBe(true);
49+
expect(hasEdge(out, 'par__split', 'a')).toBe(true);
50+
expect(hasEdge(out, 'par__split', 'b')).toBe(true);
51+
expect(hasEdge(out, 'a', 'par__join')).toBe(true);
52+
expect(hasEdge(out, 'b', 'par__join')).toBe(true);
53+
expect(hasEdge(out, 'par__join', 'end')).toBe(true);
54+
// the split carries the osConstruct extension marker
55+
const split = findId(out, 'par__split')!;
56+
expect((split.config as any)[OS_CONSTRUCT_EXT].type).toBe('parallel');
57+
});
58+
59+
it('round-trips exactly: parallel → BPMN → parallel', () => {
60+
const exported = exportConstructsToBpmn(flow).flow;
61+
const { flow: back, mappedCount } = importBpmnToConstructs(exported);
62+
expect(mappedCount).toBe(1);
63+
const par = findId(back, 'par');
64+
expect(par).toBeDefined();
65+
expect(par!.type).toBe('parallel');
66+
expect(par!.config).toEqual(flow.nodes[1].config); // branches preserved
67+
// external wiring restored, gateways gone
68+
expect(hasEdge(back, 'start', 'par')).toBe(true);
69+
expect(hasEdge(back, 'par', 'end')).toBe(true);
70+
expect(findType(back, BPMN_PARALLEL_GATEWAY)).toHaveLength(0);
71+
expect(findType(back, BPMN_JOIN_GATEWAY)).toHaveLength(0);
72+
});
73+
});
74+
75+
describe('exportConstructsToBpmn — try_catch', () => {
76+
const flow: MappableFlow = {
77+
nodes: [
78+
node('start', 'start'),
79+
node('tc', 'try_catch', {
80+
try: { nodes: [node('charge', 'http_request')], edges: [] },
81+
catch: { nodes: [node('flag', 'update_record')], edges: [] },
82+
errorVariable: '$error',
83+
retry: { maxRetries: 2, retryDelayMs: 100 },
84+
}),
85+
node('end', 'end'),
86+
],
87+
edges: [edge('e1', 'start', 'tc'), edge('e2', 'tc', 'end')],
88+
};
89+
90+
it('expands a try_catch into a protected activity + error boundary_event + handler', () => {
91+
const { flow: out } = exportConstructsToBpmn(flow);
92+
expect(findType(out, BPMN_BOUNDARY_EVENT)).toHaveLength(1);
93+
const boundary = findType(out, BPMN_BOUNDARY_EVENT)[0];
94+
expect((boundary as any).boundaryConfig.eventType).toBe('error');
95+
expect((boundary as any).boundaryConfig.attachedToNodeId).toBe('charge');
96+
expect(findId(out, 'charge')).toBeDefined(); // try activity inlined
97+
expect(findId(out, 'flag')).toBeDefined(); // catch handler inlined
98+
expect(findId(out, 'tc')).toBeUndefined();
99+
expect(hasEdge(out, 'start', 'charge')).toBe(true);
100+
});
101+
102+
it('round-trips exactly: try_catch → BPMN → try_catch (retry preserved)', () => {
103+
const exported = exportConstructsToBpmn(flow).flow;
104+
const { flow: back } = importBpmnToConstructs(exported);
105+
const tc = findId(back, 'tc');
106+
expect(tc?.type).toBe('try_catch');
107+
expect(tc!.config).toEqual(flow.nodes[1].config); // try/catch/retry/errorVariable preserved
108+
expect(findType(back, BPMN_BOUNDARY_EVENT)).toHaveLength(0);
109+
expect(hasEdge(back, 'start', 'tc')).toBe(true);
110+
expect(hasEdge(back, 'tc', 'end')).toBe(true);
111+
});
112+
});
113+
114+
describe('exportConstructsToBpmn — loop', () => {
115+
const flow: MappableFlow = {
116+
nodes: [
117+
node('start', 'start'),
118+
node('loop1', 'loop', {
119+
collection: '{items}',
120+
iteratorVariable: 'item',
121+
body: { nodes: [node('work', 'script')], edges: [] },
122+
}),
123+
node('end', 'end'),
124+
],
125+
edges: [edge('e1', 'start', 'loop1'), edge('e2', 'loop1', 'end')],
126+
};
127+
128+
it('expands a loop into a multi-instance activity (loopCharacteristics)', () => {
129+
const { flow: out } = exportConstructsToBpmn(flow);
130+
const work = findId(out, 'work')!;
131+
expect((work.config as any).bpmnLoopCharacteristics.collection).toBe('{items}');
132+
expect((work.config as any).bpmnLoopCharacteristics.elementVariable).toBe('item');
133+
expect(findId(out, 'loop1')).toBeUndefined();
134+
expect(hasEdge(out, 'start', 'work')).toBe(true);
135+
expect(hasEdge(out, 'work', 'end')).toBe(true);
136+
});
137+
138+
it('round-trips exactly: loop → BPMN → loop', () => {
139+
const exported = exportConstructsToBpmn(flow).flow;
140+
const { flow: back } = importBpmnToConstructs(exported);
141+
const loop = findId(back, 'loop1');
142+
expect(loop?.type).toBe('loop');
143+
expect(loop!.config).toEqual(flow.nodes[1].config);
144+
// the multi-instance marker is gone from the reconstructed body
145+
expect((findId(back, 'work') as any)).toBeUndefined(); // body folded back into the loop
146+
});
147+
});
148+
149+
describe('importBpmnToConstructs — foreign BPMN (no markers)', () => {
150+
it('folds a foreign parallel_gateway/join_gateway pair into a parallel block', () => {
151+
const foreign: MappableFlow = {
152+
nodes: [
153+
node('start', 'start'),
154+
node('split', BPMN_PARALLEL_GATEWAY),
155+
node('a', 'script'),
156+
node('b', 'script'),
157+
node('join', BPMN_JOIN_GATEWAY),
158+
node('end', 'end'),
159+
],
160+
edges: [
161+
edge('e1', 'start', 'split'),
162+
edge('e2', 'split', 'a'),
163+
edge('e3', 'split', 'b'),
164+
edge('e4', 'a', 'join'),
165+
edge('e5', 'b', 'join'),
166+
edge('e6', 'join', 'end'),
167+
],
168+
};
169+
const { flow: back, mappedCount } = importBpmnToConstructs(foreign);
170+
expect(mappedCount).toBe(1);
171+
const par = findType(back, 'parallel');
172+
expect(par).toHaveLength(1);
173+
expect((par[0].config as any).branches).toHaveLength(2);
174+
expect(findType(back, BPMN_PARALLEL_GATEWAY)).toHaveLength(0);
175+
expect(findType(back, BPMN_JOIN_GATEWAY)).toHaveLength(0);
176+
expect(hasEdge(back, 'start', par[0].id)).toBe(true);
177+
expect(hasEdge(back, par[0].id, 'end')).toBe(true);
178+
});
179+
180+
it('warns (does not fold) a foreign boundary_event with no marker', () => {
181+
const foreign: MappableFlow = {
182+
nodes: [node('host', 'http_request'), node('b', BPMN_BOUNDARY_EVENT)],
183+
edges: [],
184+
};
185+
const { diagnostics, unmappedCount } = importBpmnToConstructs(foreign);
186+
expect(unmappedCount).toBe(1);
187+
expect(diagnostics.some(d => d.severity === 'warning' && d.message.includes('boundary_event'))).toBe(true);
188+
});
189+
190+
it('warns when a parallel_gateway does not reconverge at a single join', () => {
191+
const foreign: MappableFlow = {
192+
nodes: [
193+
node('split', BPMN_PARALLEL_GATEWAY),
194+
node('a', 'script'),
195+
node('b', 'script'),
196+
],
197+
edges: [edge('e1', 'split', 'a'), edge('e2', 'split', 'b')], // no join
198+
};
199+
const { diagnostics } = importBpmnToConstructs(foreign);
200+
expect(diagnostics.some(d => d.severity === 'warning' && d.message.includes('could not be matched'))).toBe(true);
201+
});
202+
});

0 commit comments

Comments
 (0)