@nanobpm/nano-workforce 0.120.0 → 0.120.2
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/CHANGELOG.md +15 -0
- package/app/capabilityNeed.test.ts +4 -2
- package/app/capabilityNeed.ts +3 -1
- package/app/deliveryGraphCompiler.test.ts +72 -44
- package/app/deliveryGraphCompiler.ts +121 -19
- package/app/deliveryGraphRun.test.ts +2 -2
- package/app/deliveryRunner.test.ts +29 -17
- package/app/deliveryRunner.ts +31 -10
- package/app/feature.test.ts +3 -1
- package/app/feature.ts +9 -0
- package/app/featureReadiness.test.ts +7 -4
- package/app/featureReadiness.ts +8 -3
- package/app/plan.test.ts +1 -1
- package/app/plan.ts +9 -0
- package/app/planFanoutPreflight.test.ts +12 -12
- package/app/planLowering.test.ts +2 -0
- package/app/planLowering.ts +7 -2
- package/app/pollUserTasks.test.ts +49 -1
- package/app/readiness.test.ts +9 -0
- package/app/readiness.ts +25 -0
- package/app/service.ts +24 -6
- package/biome.json +24 -1
- package/e2e/delivery-graph.e2e.ts +2 -1
- package/e2e/feature-preflight.e2e.ts +2 -0
- package/e2e/inter-epic-dependency.e2e.ts +7 -1
- package/e2e/plan-fanout-preflight.e2e.ts +2 -0
- package/e2e/readiness-gate.e2e.ts +27 -11
- package/operations/compileDeliveryGraph.test.ts +3 -0
- package/operations/compileDeliveryGraph.ts +1 -1
- package/operations/dispatchDeliveryGraph.ts +1 -1
- package/operations/previewDeliveryGraph.ts +1 -1
- package/operations/startDeliveryGraph.ts +1 -1
- package/package.json +3 -2
- package/resources/processes/feature.bpmn +168 -52
- package/resources/processes/plan-fanout.bpmn +168 -52
- package/resources/processes/readiness-gate.bpmn +194 -84
- package/workers/readiness-probe/worker.test.ts +82 -238
- package/workers/readiness-probe/worker.ts +46 -128
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
## [0.120.2](https://github.com/nanobpm/nano-workforce/compare/v0.120.1...v0.120.2) (2026-08-22)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* **delivery-graph:** attach DI to compiled BPMN via layoutBpmn ([#440](https://github.com/nanobpm/nano-workforce/issues/440)) ([#444](https://github.com/nanobpm/nano-workforce/issues/444)) ([ed87208](https://github.com/nanobpm/nano-workforce/commit/ed87208e3c4d941a88a36ec5cad6c2661e15d587)), closes [#34](https://github.com/nanobpm/nano-workforce/issues/34)
|
|
7
|
+
* **tasks:** surface delivery-graph human gates in the Tasks inbox ([#442](https://github.com/nanobpm/nano-workforce/issues/442)) ([#443](https://github.com/nanobpm/nano-workforce/issues/443)) ([a550e77](https://github.com/nanobpm/nano-workforce/commit/a550e7730fe496f34e1a1017fb84d448a2840f15))
|
|
8
|
+
|
|
9
|
+
## [0.120.1](https://github.com/nanobpm/nano-workforce/compare/v0.120.0...v0.120.1) (2026-08-22)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
### Bug Fixes
|
|
13
|
+
|
|
14
|
+
* **readiness:** make pr.readiness-probe single-shot; engine owns retry cadence ([#428](https://github.com/nanobpm/nano-workforce/issues/428)) ([#429](https://github.com/nanobpm/nano-workforce/issues/429)) ([07db031](https://github.com/nanobpm/nano-workforce/commit/07db031f0d5e3f63ff47ffa51583bdef3012a2ab))
|
|
15
|
+
|
|
1
16
|
# [0.120.0](https://github.com/nanobpm/nano-workforce/compare/v0.119.0...v0.120.0) (2026-08-22)
|
|
2
17
|
|
|
3
18
|
|
|
@@ -106,9 +106,11 @@ test("capabilityNeedToProbeInput: maps a need to the readiness-gate capability p
|
|
|
106
106
|
planKey: "owner/repo#289",
|
|
107
107
|
taskId: "issue-289",
|
|
108
108
|
probeTimeout: "PT12H",
|
|
109
|
+
probePollEvery: "PT15S",
|
|
109
110
|
});
|
|
110
111
|
assertEquals(input.gateKey, "owner/repo#289:issue-289:nanobpm/nano-ide#274:@nanobpm/urban");
|
|
111
112
|
assertEquals(input.probeTimeout, "PT12H");
|
|
113
|
+
assertEquals(input.probePollEvery, "PT15S");
|
|
112
114
|
assertEquals(input.onTimeout, "escalate");
|
|
113
115
|
assertEquals(input.probe.kind, "capability");
|
|
114
116
|
assertEquals(input.probe.target, "github-releases:nanobpm/nano-ide");
|
|
@@ -121,7 +123,7 @@ test("capabilityNeedToProbeInput: maps a need to the readiness-gate capability p
|
|
|
121
123
|
test("capabilityNeedToProbeInput: omits verifyCommand when the need has none", () => {
|
|
122
124
|
const input = capabilityNeedToProbeInput(
|
|
123
125
|
{ capabilityRef: "nanobpm/nano-ide#274", package: "@nanobpm/urban" },
|
|
124
|
-
{ planKey: "o/r#1", taskId: "t1", probeTimeout: "PT1H" },
|
|
126
|
+
{ planKey: "o/r#1", taskId: "t1", probeTimeout: "PT1H", probePollEvery: "PT15S" },
|
|
125
127
|
);
|
|
126
128
|
assertEquals(input.probe.match?.verifyCommand, undefined);
|
|
127
129
|
});
|
|
@@ -131,7 +133,7 @@ test("capabilityNeedToProbeInput: throws when the handle names no releases sourc
|
|
|
131
133
|
() =>
|
|
132
134
|
capabilityNeedToProbeInput(
|
|
133
135
|
{ capabilityRef: "#274", package: "@nanobpm/urban" },
|
|
134
|
-
{ planKey: "o/r#1", taskId: "t1", probeTimeout: "PT1H" },
|
|
136
|
+
{ planKey: "o/r#1", taskId: "t1", probeTimeout: "PT1H", probePollEvery: "PT15S" },
|
|
135
137
|
),
|
|
136
138
|
UnresolvableCapabilityRefError,
|
|
137
139
|
"names no owner/repo releases source",
|
package/app/capabilityNeed.ts
CHANGED
|
@@ -36,6 +36,7 @@ export interface CapabilityNeed {
|
|
|
36
36
|
export interface ReadinessProbeInput {
|
|
37
37
|
readonly gateKey: string;
|
|
38
38
|
readonly probeTimeout: string;
|
|
39
|
+
readonly probePollEvery: string;
|
|
39
40
|
readonly onTimeout?: OnTimeout;
|
|
40
41
|
readonly probe: ReadinessProbe;
|
|
41
42
|
}
|
|
@@ -152,7 +153,7 @@ export function capabilityTaskBarrierKey(planKey: string, taskId: string): strin
|
|
|
152
153
|
* the handle names no releases source. */
|
|
153
154
|
export function capabilityNeedToProbeInput(
|
|
154
155
|
need: CapabilityNeed,
|
|
155
|
-
opts: { planKey: string; taskId: string; probeTimeout: string },
|
|
156
|
+
opts: { planKey: string; taskId: string; probeTimeout: string; probePollEvery: string },
|
|
156
157
|
): ReadinessProbeInput {
|
|
157
158
|
const repo = capabilityReleasesRepo(need.capabilityRef);
|
|
158
159
|
if (!repo) throw new UnresolvableCapabilityRefError(need.capabilityRef);
|
|
@@ -170,6 +171,7 @@ export function capabilityNeedToProbeInput(
|
|
|
170
171
|
return {
|
|
171
172
|
gateKey: capabilityGateKey(opts.planKey, opts.taskId, need.capabilityRef, need.package),
|
|
172
173
|
probeTimeout: opts.probeTimeout,
|
|
174
|
+
probePollEvery: opts.probePollEvery,
|
|
173
175
|
onTimeout: "escalate",
|
|
174
176
|
probe,
|
|
175
177
|
};
|
|
@@ -16,15 +16,15 @@ import { assert, assertEquals } from "#test-assert";
|
|
|
16
16
|
import { compileDeliveryGraph } from "./deliveryGraphCompiler.ts";
|
|
17
17
|
|
|
18
18
|
/** Compile and assert success, returning the narrowed ok-result. */
|
|
19
|
-
function compileOk(graph: unknown) {
|
|
20
|
-
const r = compileDeliveryGraph(graph);
|
|
19
|
+
async function compileOk(graph: unknown) {
|
|
20
|
+
const r = await compileDeliveryGraph(graph);
|
|
21
21
|
assert(r.ok, `expected ok:true, got ${JSON.stringify(r)}`);
|
|
22
22
|
return r;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
/** Compile and assert failure, returning the errors. */
|
|
26
|
-
function compileFail(graph: unknown) {
|
|
27
|
-
const r = compileDeliveryGraph(graph);
|
|
26
|
+
async function compileFail(graph: unknown) {
|
|
27
|
+
const r = await compileDeliveryGraph(graph);
|
|
28
28
|
assert(!r.ok, `expected ok:false, got ${JSON.stringify(r)}`);
|
|
29
29
|
return r.errors;
|
|
30
30
|
}
|
|
@@ -57,8 +57,8 @@ const RELEASE_RUNBOOK = {
|
|
|
57
57
|
],
|
|
58
58
|
};
|
|
59
59
|
|
|
60
|
-
test("happy path: a well-formed graph compiles to a full preview with no side effects", () => {
|
|
61
|
-
const r = compileOk(RELEASE_RUNBOOK);
|
|
60
|
+
test("happy path: a well-formed graph compiles to a full preview with no side effects", async () => {
|
|
61
|
+
const r = await compileOk(RELEASE_RUNBOOK);
|
|
62
62
|
assertEquals(r.ok, true);
|
|
63
63
|
assert(r.bpmn.includes("<bpmn:process id=\"delivery-graph\""), "bpmn carries the compiled process");
|
|
64
64
|
assert(r.diagram.startsWith("flowchart TD"), "diagram is a mermaid flowchart");
|
|
@@ -70,25 +70,25 @@ test("happy path: a well-formed graph compiles to a full preview with no side ef
|
|
|
70
70
|
assertEquals(r.sideEffects.length, 2);
|
|
71
71
|
});
|
|
72
72
|
|
|
73
|
-
test("determinism: the same JSON always yields byte-identical bpmn/diagram/resolved", () => {
|
|
74
|
-
const a = compileOk(RELEASE_RUNBOOK);
|
|
75
|
-
const b = compileOk(RELEASE_RUNBOOK);
|
|
73
|
+
test("determinism: the same JSON always yields byte-identical bpmn/diagram/resolved", async () => {
|
|
74
|
+
const a = await compileOk(RELEASE_RUNBOOK);
|
|
75
|
+
const b = await compileOk(RELEASE_RUNBOOK);
|
|
76
76
|
assertEquals(a.bpmn, b.bpmn);
|
|
77
77
|
assertEquals(a.diagram, b.diagram);
|
|
78
78
|
assertEquals(JSON.stringify(a.resolved), JSON.stringify(b.resolved));
|
|
79
79
|
// Node ORDER in the input must not change the artifact (nodes are sorted by id).
|
|
80
80
|
const shuffled = { ...RELEASE_RUNBOOK, nodes: [...RELEASE_RUNBOOK.nodes].reverse() };
|
|
81
|
-
const c = compileOk(shuffled);
|
|
81
|
+
const c = await compileOk(shuffled);
|
|
82
82
|
assertEquals(c.bpmn, a.bpmn);
|
|
83
83
|
assertEquals(c.diagram, a.diagram);
|
|
84
84
|
});
|
|
85
85
|
|
|
86
|
-
test("trust bound: every node inlines an embedded subProcess delegating to an allowlisted body — no other activity type", () => {
|
|
87
|
-
const r = compileOk(RELEASE_RUNBOOK);
|
|
88
|
-
// Each of the 4 nodes compiles to an EMBEDDED subProcess (call activities are a no-op on the pinned
|
|
86
|
+
test("trust bound: every node inlines an embedded subProcess delegating to an allowlisted body — no other activity type", async () => {
|
|
87
|
+
const r = await compileOk(RELEASE_RUNBOOK);
|
|
88
|
+
// Each of the 4 nodes compiles to an EMBEDDED subProcess; wait adds one nested retry-loop subProcess (call activities are a no-op on the pinned
|
|
89
89
|
// WASM engine, so delegation is an inlined subProcess sharing the parent scope — never a callActivity).
|
|
90
90
|
assertEquals((r.bpmn.match(/<bpmn:callActivity/g) ?? []).length, 0);
|
|
91
|
-
assertEquals((r.bpmn.match(/<bpmn:subProcess /g) ?? []).length,
|
|
91
|
+
assertEquals((r.bpmn.match(/<bpmn:subProcess /g) ?? []).length, 5);
|
|
92
92
|
assert(!r.bpmn.includes("<bpmn:scriptTask"), "no script task is ever emitted");
|
|
93
93
|
// Each node's inner body delegates to an allowlisted engine-native body: a `serviceTask` typed to a
|
|
94
94
|
// worker (agent → its `senior:*` job; wait → `pr.readiness-probe`; connector → `pr.delivery-connector`)
|
|
@@ -103,8 +103,8 @@ test("trust bound: every node inlines an embedded subProcess delegating to an al
|
|
|
103
103
|
assert(/<bpmn:userTask id="delivery-human-task__n\d+__esc"/.test(r.bpmn), "a bounded node inlines an escalation user task");
|
|
104
104
|
});
|
|
105
105
|
|
|
106
|
-
test("late-binding: a fact-qualified edge threads a boundFacts input into the consumer subProcess", () => {
|
|
107
|
-
const r = compileOk(RELEASE_RUNBOOK);
|
|
106
|
+
test("late-binding: a fact-qualified edge threads a boundFacts input into the consumer subProcess", async () => {
|
|
107
|
+
const r = await compileOk(RELEASE_RUNBOOK);
|
|
108
108
|
// `publish.resolvedArtifact -> consume`: the connector subProcess receives the human's emitted fact as
|
|
109
109
|
// a boundFacts list entry, read from the flat `<producerElement>_<fact>` variable the producer publishes.
|
|
110
110
|
// FEEL string literals must use single-quote XML-attribute delimiters (the engine deploy path drops
|
|
@@ -114,8 +114,8 @@ test("late-binding: a fact-qualified edge threads a boundFacts input into the co
|
|
|
114
114
|
assert(boundInput, `boundFacts is a single-quoted FEEL list literal, got: ${r.bpmn.match(/source='[^']*' target="boundFacts"/)?.[0] ?? r.bpmn.match(/source="[^"]*" target="boundFacts"/)?.[0]}`);
|
|
115
115
|
});
|
|
116
116
|
|
|
117
|
-
test("rejects unknown kind (by construction) with a path-qualified error, nothing compiled", () => {
|
|
118
|
-
const errors = compileFail({
|
|
117
|
+
test("rejects unknown kind (by construction) with a path-qualified error, nothing compiled", async () => {
|
|
118
|
+
const errors = await compileFail({
|
|
119
119
|
nodes: [{ id: "x", kind: "deploy", deploy: { target: "prod" } }],
|
|
120
120
|
});
|
|
121
121
|
const e = errors.find((err) => err.path === "nodes[0].kind");
|
|
@@ -123,8 +123,8 @@ test("rejects unknown kind (by construction) with a path-qualified error, nothin
|
|
|
123
123
|
assert(e.message.length > 0);
|
|
124
124
|
});
|
|
125
125
|
|
|
126
|
-
test("rejects a dependency cycle with a path-qualified error", () => {
|
|
127
|
-
const errors = compileFail({
|
|
126
|
+
test("rejects a dependency cycle with a path-qualified error", async () => {
|
|
127
|
+
const errors = await compileFail({
|
|
128
128
|
nodes: [
|
|
129
129
|
{ id: "a", kind: "agent", agent: { jobType: "j" } },
|
|
130
130
|
{ id: "b", kind: "agent", agent: { jobType: "j" } },
|
|
@@ -137,14 +137,14 @@ test("rejects a dependency cycle with a path-qualified error", () => {
|
|
|
137
137
|
assert(errors.some((e) => /cycle/i.test(e.message)), `expected a cycle error, got ${JSON.stringify(errors)}`);
|
|
138
138
|
});
|
|
139
139
|
|
|
140
|
-
test("rejects a dangling edge and a bad fact reference, each path-qualified", () => {
|
|
141
|
-
const dangling = compileFail({
|
|
140
|
+
test("rejects a dangling edge and a bad fact reference, each path-qualified", async () => {
|
|
141
|
+
const dangling = await compileFail({
|
|
142
142
|
nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" } }],
|
|
143
143
|
edges: [{ from: "a", to: "ghost" }],
|
|
144
144
|
});
|
|
145
145
|
assert(dangling.some((e) => e.path === "edges[0].to"));
|
|
146
146
|
|
|
147
|
-
const badFrom = compileFail({
|
|
147
|
+
const badFrom = await compileFail({
|
|
148
148
|
nodes: [
|
|
149
149
|
{ id: "a", kind: "wait", wait: { kind: "http", target: "u" }, emits: [{ name: "x", type: "string" }] },
|
|
150
150
|
{ id: "b", kind: "agent", agent: { jobType: "j" } },
|
|
@@ -154,8 +154,8 @@ test("rejects a dangling edge and a bad fact reference, each path-qualified", ()
|
|
|
154
154
|
assert(badFrom.some((e) => e.path === "edges[0].from"));
|
|
155
155
|
});
|
|
156
156
|
|
|
157
|
-
test("fan-in: a node with two producers gets a parallel JOIN gateway", () => {
|
|
158
|
-
const r = compileOk({
|
|
157
|
+
test("fan-in: a node with two producers gets a parallel JOIN gateway", async () => {
|
|
158
|
+
const r = await compileOk({
|
|
159
159
|
nodes: [
|
|
160
160
|
{ id: "a", kind: "agent", agent: { jobType: "j" } },
|
|
161
161
|
{ id: "b", kind: "agent", agent: { jobType: "j" } },
|
|
@@ -171,8 +171,8 @@ test("fan-in: a node with two producers gets a parallel JOIN gateway", () => {
|
|
|
171
171
|
assertEquals(cNode?.dependsOn, ["a", "b"]);
|
|
172
172
|
});
|
|
173
173
|
|
|
174
|
-
test("fan-out: a node with two consumers gets a parallel FORK gateway", () => {
|
|
175
|
-
const r = compileOk({
|
|
174
|
+
test("fan-out: a node with two consumers gets a parallel FORK gateway", async () => {
|
|
175
|
+
const r = await compileOk({
|
|
176
176
|
nodes: [
|
|
177
177
|
{ id: "a", kind: "agent", agent: { jobType: "j" } },
|
|
178
178
|
{ id: "b", kind: "agent", agent: { jobType: "j" } },
|
|
@@ -186,8 +186,8 @@ test("fan-out: a node with two consumers gets a parallel FORK gateway", () => {
|
|
|
186
186
|
assert(r.bpmn.includes('name="fan out of a"'), "a fork gateway for node a is emitted");
|
|
187
187
|
});
|
|
188
188
|
|
|
189
|
-
test("multiple roots fork from Start and multiple leaves join into End", () => {
|
|
190
|
-
const r = compileOk({
|
|
189
|
+
test("multiple roots fork from Start and multiple leaves join into End", async () => {
|
|
190
|
+
const r = await compileOk({
|
|
191
191
|
nodes: [
|
|
192
192
|
{ id: "r1", kind: "agent", agent: { jobType: "j" } },
|
|
193
193
|
{ id: "r2", kind: "agent", agent: { jobType: "j" } },
|
|
@@ -198,8 +198,8 @@ test("multiple roots fork from Start and multiple leaves join into End", () => {
|
|
|
198
198
|
assert(r.bpmn.includes('id="gwj_end"'), "an end join gateway for multiple leaves");
|
|
199
199
|
});
|
|
200
200
|
|
|
201
|
-
test("humanNodes: extracts prompt/formKey/emits; a click-done node emits nothing", () => {
|
|
202
|
-
const r = compileOk({
|
|
201
|
+
test("humanNodes: extracts prompt/formKey/emits; a click-done node emits nothing", async () => {
|
|
202
|
+
const r = await compileOk({
|
|
203
203
|
nodes: [
|
|
204
204
|
{
|
|
205
205
|
id: "publish",
|
|
@@ -220,8 +220,8 @@ test("humanNodes: extracts prompt/formKey/emits; a click-done node emits nothing
|
|
|
220
220
|
assertEquals(ack?.prompt, undefined);
|
|
221
221
|
});
|
|
222
222
|
|
|
223
|
-
test("sideEffects: agent + connector only; connector carries its dedupeKey", () => {
|
|
224
|
-
const r = compileOk(RELEASE_RUNBOOK);
|
|
223
|
+
test("sideEffects: agent + connector only; connector carries its dedupeKey", async () => {
|
|
224
|
+
const r = await compileOk(RELEASE_RUNBOOK);
|
|
225
225
|
const agent = r.sideEffects.find((s) => s.nodeId === "open-b");
|
|
226
226
|
assertEquals(agent?.kind, "agent");
|
|
227
227
|
assert(agent?.description.includes("senior:feature"));
|
|
@@ -233,8 +233,8 @@ test("sideEffects: agent + connector only; connector carries its dedupeKey", ()
|
|
|
233
233
|
assert(!r.sideEffects.some((s) => s.nodeId === "publish"));
|
|
234
234
|
});
|
|
235
235
|
|
|
236
|
-
test("resolved edges carry the resolved fromNode and the referenced fact", () => {
|
|
237
|
-
const r = compileOk(RELEASE_RUNBOOK);
|
|
236
|
+
test("resolved edges carry the resolved fromNode and the referenced fact", async () => {
|
|
237
|
+
const r = await compileOk(RELEASE_RUNBOOK);
|
|
238
238
|
const factEdge = r.resolved.edges.find((e) => e.from === "watch-b.mergedSha");
|
|
239
239
|
assertEquals(factEdge?.fromNode, "watch-b");
|
|
240
240
|
assertEquals(factEdge?.fromFact, "mergedSha");
|
|
@@ -243,8 +243,8 @@ test("resolved edges carry the resolved fromNode and the referenced fact", () =>
|
|
|
243
243
|
assertEquals(plainEdge?.fromFact, undefined);
|
|
244
244
|
});
|
|
245
245
|
|
|
246
|
-
test("BPMN is structurally coherent: one process start, one process end, every flow endpoint declared", () => {
|
|
247
|
-
const r = compileOk(RELEASE_RUNBOOK);
|
|
246
|
+
test("BPMN is structurally coherent: one process start, one process end, every flow endpoint declared", async () => {
|
|
247
|
+
const r = await compileOk(RELEASE_RUNBOOK);
|
|
248
248
|
// The TOP-LEVEL process has exactly one Start and one End (each inlined subProcess has its OWN
|
|
249
249
|
// start/end events, so a raw `<bpmn:startEvent>` count is not the process boundary — the fixed ids are).
|
|
250
250
|
assertEquals((r.bpmn.match(/ id="Start"/g) ?? []).length, 1);
|
|
@@ -257,12 +257,12 @@ test("BPMN is structurally coherent: one process start, one process end, every f
|
|
|
257
257
|
}
|
|
258
258
|
});
|
|
259
259
|
|
|
260
|
-
test("duplicate fact-qualified edges between the same node pair collapse to ONE sequence flow", () => {
|
|
260
|
+
test("duplicate fact-qualified edges between the same node pair collapse to ONE sequence flow", async () => {
|
|
261
261
|
// `src` emits two facts, both feeding `b` (`src.x -> b` and `src.y -> b`). Adjacency is de-duped by
|
|
262
262
|
// node id, so no fork/join gateway is inserted — the producer wires straight to the consumer. The
|
|
263
263
|
// compiler must therefore collapse the two edges into a SINGLE sequenceFlow so `b` is not scheduled
|
|
264
264
|
// twice (multiple outgoing flows without a diverging gateway is invalid/double-executing BPMN).
|
|
265
|
-
const r = compileOk({
|
|
265
|
+
const r = await compileOk({
|
|
266
266
|
nodes: [
|
|
267
267
|
{
|
|
268
268
|
id: "src",
|
|
@@ -289,9 +289,37 @@ test("duplicate fact-qualified edges between the same node pair collapse to ONE
|
|
|
289
289
|
assert(!r.bpmn.includes("<bpmn:parallelGateway"), "no gateway for a single de-duplicated producer/consumer pair");
|
|
290
290
|
});
|
|
291
291
|
|
|
292
|
-
test("a non-object / empty body is a clean ok:false, never a throw", () => {
|
|
293
|
-
assert(!compileDeliveryGraph(undefined).ok);
|
|
294
|
-
assert(!compileDeliveryGraph(null).ok);
|
|
295
|
-
assert(!compileDeliveryGraph({}).ok);
|
|
296
|
-
assert(!compileDeliveryGraph({ nodes: [] }).ok);
|
|
292
|
+
test("a non-object / empty body is a clean ok:false, never a throw", async () => {
|
|
293
|
+
assert(!(await compileDeliveryGraph(undefined)).ok);
|
|
294
|
+
assert(!(await compileDeliveryGraph(null)).ok);
|
|
295
|
+
assert(!(await compileDeliveryGraph({})).ok);
|
|
296
|
+
assert(!(await compileDeliveryGraph({ nodes: [] })).ok);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("DI (#440): the compiled bpmn carries an auto-laid-out bpmndi:BPMNDiagram — a shape per element, an edge per flow", async () => {
|
|
300
|
+
// The delivery-graph compiler is the ONE BPMN generated at runtime; every AUTHORED process gets DI
|
|
301
|
+
// from `npm run layout` (`layoutBpmn`), and before #440 this generated one skipped that pass and
|
|
302
|
+
// shipped DI-less — a compiled/running graph rendered positionless in the process explorer. The
|
|
303
|
+
// compiler now runs the SAME `layoutBpmn` autolayout, so the preview `bpmn` (what actually deploys)
|
|
304
|
+
// carries diagram interchange. This RED/GREEN guard fails on the old DI-less output.
|
|
305
|
+
const r = await compileOk(RELEASE_RUNBOOK);
|
|
306
|
+
assert(r.bpmn.includes("<bpmndi:BPMNDiagram"), "compiled bpmn carries a bpmndi:BPMNDiagram");
|
|
307
|
+
assert(r.bpmn.includes("<bpmndi:BPMNPlane"), "the diagram has a plane");
|
|
308
|
+
// The top-level plane references the compiled process, so the process explorer can render it.
|
|
309
|
+
assert(r.bpmn.includes('bpmnElement="delivery-graph"'), "the top-level plane binds to the process id");
|
|
310
|
+
// A shape per BPMN element and an edge per sequence flow — the same "N shapes + M edges" accounting
|
|
311
|
+
// `scripts/layout-bpmn.ts` reports. Every declared sequenceFlow gets a BPMNEdge.
|
|
312
|
+
const shapes = (r.bpmn.match(/<bpmndi:BPMNShape\b/g) ?? []).length;
|
|
313
|
+
const edges = (r.bpmn.match(/<bpmndi:BPMNEdge\b/g) ?? []).length;
|
|
314
|
+
const flows = (r.bpmn.match(/<bpmn:sequenceFlow\b/g) ?? []).length;
|
|
315
|
+
assert(shapes > 0, "at least one BPMNShape is drawn");
|
|
316
|
+
assertEquals(edges, flows, "every sequence flow gets exactly one BPMNEdge");
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("DI (#440) is deterministic: identical JSON yields byte-identical laid-out bpmn", async () => {
|
|
320
|
+
// `layoutBpmn` (bpmn-auto-layout) is deterministic given identical semantic input, so adding the
|
|
321
|
+
// diagram must not break the compiler's "same JSON → byte-identical XML" trust property.
|
|
322
|
+
const a = await compileOk(RELEASE_RUNBOOK);
|
|
323
|
+
const b = await compileOk(RELEASE_RUNBOOK);
|
|
324
|
+
assertEquals(a.bpmn, b.bpmn);
|
|
297
325
|
});
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
// parallel gateways for genuine fan-out (>1 downstream) and fan-in (>1 upstream). This slice targets
|
|
24
24
|
// the WIRING/SHAPE — the concrete node bodies land in S4.
|
|
25
25
|
|
|
26
|
+
import { layoutBpmn } from "@nanobpm/urban";
|
|
26
27
|
import type {
|
|
27
28
|
CompileDeliveryGraphErrors,
|
|
28
29
|
CompileDeliveryGraphResult,
|
|
@@ -212,8 +213,17 @@ function mustGet<K, V>(map: ReadonlyMap<K, V>, key: K): V {
|
|
|
212
213
|
* `{ ok:false, errors }` (each error path-qualified) for a malformed one. NEVER deploys, dispatches,
|
|
213
214
|
* or mutates anything — safe to call repeatedly. Deterministic: identical input JSON yields
|
|
214
215
|
* byte-identical output.
|
|
216
|
+
*
|
|
217
|
+
* ASYNC because the final step attaches DIAGRAM INTERCHANGE (`bpmndi:BPMNDiagram`) via the toolkit
|
|
218
|
+
* autolayout (`layoutBpmn` — `bpmn-auto-layout`), the SAME pass every AUTHORED process gets from
|
|
219
|
+
* `npm run layout` (`scripts/layout-bpmn.ts`). This is the one BPMN in the system generated at
|
|
220
|
+
* runtime, so without this it was the only one shipping DI-less — unrenderable in the process
|
|
221
|
+
* explorer (#440). `layoutBpmn` is itself deterministic given identical semantic input, so
|
|
222
|
+
* "same JSON → byte-identical XML" still holds with the diagram included.
|
|
215
223
|
*/
|
|
216
|
-
export function compileDeliveryGraph(
|
|
224
|
+
export async function compileDeliveryGraph(
|
|
225
|
+
graph: unknown,
|
|
226
|
+
): Promise<CompileDeliveryGraphResult | CompileDeliveryGraphErrors> {
|
|
217
227
|
const validationErrors: DeliveryGraphError[] = validateDeliveryGraph(graph);
|
|
218
228
|
if (validationErrors.length > 0) {
|
|
219
229
|
// Forward every semantic failure verbatim as a wire `{ path, message }` (the stable `code` stays
|
|
@@ -354,7 +364,8 @@ export function compileDeliveryGraph(graph: unknown): CompileDeliveryGraphResult
|
|
|
354
364
|
list.sort((a, b) => byCodeUnit(a.producerElement, b.producerElement) || byCodeUnit(a.fact, b.fact));
|
|
355
365
|
}
|
|
356
366
|
|
|
357
|
-
const
|
|
367
|
+
const semanticBpmn = renderBpmn(typed, wirings, numberedFlows, startForkGateway, endJoinGateway, boundInputsByElement);
|
|
368
|
+
const bpmn = await layoutDeliveryDiagram(semanticBpmn);
|
|
358
369
|
const diagram = renderMermaid(typed, wirings, resolvedEdges, elementById);
|
|
359
370
|
const resolved = buildResolved(typed, wirings, resolvedEdges, producersById);
|
|
360
371
|
const humanNodes = buildHumanNodes(nodes);
|
|
@@ -363,6 +374,48 @@ export function compileDeliveryGraph(graph: unknown): CompileDeliveryGraphResult
|
|
|
363
374
|
return { ok: true, diagram, bpmn, resolved, humanNodes, sideEffects };
|
|
364
375
|
}
|
|
365
376
|
|
|
377
|
+
/** Attach diagram interchange (`bpmndi:BPMNDiagram`) to the semantic-only compiled BPMN via the
|
|
378
|
+
* toolkit autolayout — the SAME `layoutBpmn` (`bpmn-auto-layout`) pass `npm run layout` runs over
|
|
379
|
+
* every authored process (`scripts/layout-bpmn.ts`), so there is ONE layout source, not two. Without
|
|
380
|
+
* it, a compiled/running delivery graph rendered positionless in the process explorer (#440).
|
|
381
|
+
*
|
|
382
|
+
* We do NOT return `layoutBpmn`'s serialized output directly: its moddle round-trip re-serializes the
|
|
383
|
+
* semantic model, and in doing so normalizes attribute quoting — a single-quote-delimited attribute
|
|
384
|
+
* with literal double-quotes inside becomes a double-quoted attribute with `"` entities. The
|
|
385
|
+
* compiler deliberately emits FEEL string literals (`boundFacts`) with SINGLE-quote delimiters because
|
|
386
|
+
* the WASM engine deploy path does NOT decode those entities before FEEL parsing (see `attr`), so a
|
|
387
|
+
* round-trip would silently blank every late-bound fact. Instead we keep the compiler's carefully
|
|
388
|
+
* encoded semantic XML BYTE-FOR-BYTE and graft only the computed `<bpmndi:BPMNDiagram>` block(s) onto
|
|
389
|
+
* it — the diagram references element ids `layoutBpmn` leaves untouched, so the graft is sound.
|
|
390
|
+
*
|
|
391
|
+
* `bpmn-auto-layout` is a real runtime dependency of `@nanobpm/urban` (which re-exports `layoutBpmn`),
|
|
392
|
+
* but the toolkit no-ops layout (semantic model unchanged, no DI) when it is somehow absent. That
|
|
393
|
+
* silent no-op is exactly the DI-less bug this fixes, so we FAIL LOUD if the pass produced no diagram.
|
|
394
|
+
* Deterministic given identical input, preserving the compiler's "same JSON → byte-identical XML". */
|
|
395
|
+
async function layoutDeliveryDiagram(semanticBpmn: string): Promise<string> {
|
|
396
|
+
const laidOut = await layoutBpmn(semanticBpmn);
|
|
397
|
+
const start = laidOut.indexOf("<bpmndi:BPMNDiagram");
|
|
398
|
+
const endTag = "</bpmndi:BPMNDiagram>";
|
|
399
|
+
const end = laidOut.lastIndexOf(endTag);
|
|
400
|
+
if (start === -1 || end === -1) {
|
|
401
|
+
throw new Error(
|
|
402
|
+
"compileDeliveryGraph: layoutBpmn produced no bpmndi:BPMNDiagram, so the compiled graph would " +
|
|
403
|
+
"deploy DI-less and render positionless in the process explorer (#440). This usually means the " +
|
|
404
|
+
"`bpmn-auto-layout` toolkit peer is missing (the toolkit then silently no-ops layout), but it " +
|
|
405
|
+
"can also indicate a change in `layoutBpmn` output (different namespace prefix/serialization) or " +
|
|
406
|
+
"an internal layout failure returning semantic-only XML. Ensure `bpmn-auto-layout` is installed " +
|
|
407
|
+
"as a runtime dependency and that `layoutBpmn` still emits a `<bpmndi:BPMNDiagram>` block.",
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
const diagram = laidOut.slice(start, end + endTag.length);
|
|
411
|
+
const closing = "</bpmn:definitions>";
|
|
412
|
+
const insertAt = semanticBpmn.lastIndexOf(closing);
|
|
413
|
+
if (insertAt === -1) {
|
|
414
|
+
throw new Error("compileDeliveryGraph: compiled BPMN has no </bpmn:definitions> to graft DI into");
|
|
415
|
+
}
|
|
416
|
+
return `${semanticBpmn.slice(0, insertAt)} ${diagram}\n${semanticBpmn.slice(insertAt)}`;
|
|
417
|
+
}
|
|
418
|
+
|
|
366
419
|
/** Push `value` into `list` (may be undefined for a dangling target, already reported by the
|
|
367
420
|
* validator) only when not already present — keeps adjacency de-duplicated. */
|
|
368
421
|
function pushUnique(list: string[] | undefined, value: string): void {
|
|
@@ -485,6 +538,9 @@ function renderBpmn(
|
|
|
485
538
|
'<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" ' +
|
|
486
539
|
'xmlns:zeebe="http://camunda.org/schema/zeebe/1.0" ' +
|
|
487
540
|
'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" ' +
|
|
541
|
+
'xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" ' +
|
|
542
|
+
'xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" ' +
|
|
543
|
+
'xmlns:di="http://www.omg.org/spec/DD/20100524/DI" ' +
|
|
488
544
|
'id="Definitions_delivery_graph" targetNamespace="http://nanobpm.io/nano-workforce">',
|
|
489
545
|
);
|
|
490
546
|
const processName = graph.name ?? "Delivery graph";
|
|
@@ -604,6 +660,7 @@ function ioMappingLines(w: NodeWiring, boundInputs: readonly BoundInput[]): stri
|
|
|
604
660
|
inputs.push({ source: cfg("gateKey"), target: "gateKey" });
|
|
605
661
|
inputs.push({ source: cfg("probe"), target: "probe" });
|
|
606
662
|
inputs.push({ source: cfg("probeTimeout"), target: "probeTimeout" });
|
|
663
|
+
inputs.push({ source: cfg("probePollEvery"), target: "probePollEvery" });
|
|
607
664
|
break;
|
|
608
665
|
case "human":
|
|
609
666
|
inputs.push({ source: cfg("escalationSlaTimeout"), target: "escalationSlaTimeout" });
|
|
@@ -712,33 +769,78 @@ function waitBodyLines(el: string, nodeId: string): string[] {
|
|
|
712
769
|
const esc = escalationTaskElement(el);
|
|
713
770
|
return [
|
|
714
771
|
` <bpmn:startEvent id="${el}_start"><bpmn:outgoing>${el}_i0</bpmn:outgoing></bpmn:startEvent>`,
|
|
715
|
-
` <bpmn:
|
|
772
|
+
` <bpmn:subProcess id="${el}_probeLoop" name="Probe readiness loop: ${escapeXml(nodeId)}">`,
|
|
773
|
+
" <bpmn:extensionElements>",
|
|
774
|
+
" <zeebe:ioMapping>",
|
|
775
|
+
' <zeebe:output source="=ready" target="ready" />',
|
|
776
|
+
' <zeebe:output source="=if (is defined(detail)) then detail else null" target="detail" />',
|
|
777
|
+
' <zeebe:output source="=if (is defined(resolvedArtifact)) then resolvedArtifact else null" target="resolvedArtifact" />',
|
|
778
|
+
' <zeebe:output source="=if (is defined(mergedSha)) then mergedSha else null" target="mergedSha" />',
|
|
779
|
+
" </zeebe:ioMapping>",
|
|
780
|
+
" </bpmn:extensionElements>",
|
|
781
|
+
` <bpmn:incoming>${el}_i0</bpmn:incoming>`,
|
|
782
|
+
` <bpmn:outgoing>${el}_i1</bpmn:outgoing>`,
|
|
783
|
+
` <bpmn:startEvent id="${el}_loopStart"><bpmn:outgoing>${el}_li0</bpmn:outgoing></bpmn:startEvent>`,
|
|
784
|
+
` <bpmn:serviceTask id="${el}_task" name="Probe readiness: ${escapeXml(nodeId)}">`,
|
|
785
|
+
" <bpmn:extensionElements>",
|
|
786
|
+
` <zeebe:taskDefinition type="${DELEGATE_TASK_TYPE.wait}" />`,
|
|
787
|
+
" <zeebe:properties>",
|
|
788
|
+
' <zeebe:property name="io.nanobpm.dataEnvelope.in" value="ReadinessProbeIn" />',
|
|
789
|
+
' <zeebe:property name="io.nanobpm.dataEnvelope.out" value="ReadinessProbeOut" />',
|
|
790
|
+
" </zeebe:properties>",
|
|
791
|
+
" </bpmn:extensionElements>",
|
|
792
|
+
` <bpmn:incoming>${el}_li0</bpmn:incoming>`,
|
|
793
|
+
` <bpmn:incoming>${el}_li4</bpmn:incoming>`,
|
|
794
|
+
` <bpmn:outgoing>${el}_li1</bpmn:outgoing>`,
|
|
795
|
+
" </bpmn:serviceTask>",
|
|
796
|
+
` <bpmn:exclusiveGateway id="${el}_gw" name="ready?" default="${el}_li3">`,
|
|
797
|
+
` <bpmn:incoming>${el}_li1</bpmn:incoming>`,
|
|
798
|
+
` <bpmn:outgoing>${el}_li2</bpmn:outgoing>`,
|
|
799
|
+
` <bpmn:outgoing>${el}_li3</bpmn:outgoing>`,
|
|
800
|
+
" </bpmn:exclusiveGateway>",
|
|
801
|
+
` <bpmn:intermediateCatchEvent id="${el}_waitPoll" name="Wait poll interval">`,
|
|
802
|
+
` <bpmn:incoming>${el}_li3</bpmn:incoming>`,
|
|
803
|
+
` <bpmn:outgoing>${el}_li4</bpmn:outgoing>`,
|
|
804
|
+
` <bpmn:timerEventDefinition id="${el}_pollTed"><bpmn:timeDuration xsi:type="bpmn:tFormalExpression">=probePollEvery</bpmn:timeDuration></bpmn:timerEventDefinition>`,
|
|
805
|
+
" </bpmn:intermediateCatchEvent>",
|
|
806
|
+
` <bpmn:endEvent id="${el}_loopEnd"><bpmn:incoming>${el}_li2</bpmn:incoming></bpmn:endEvent>`,
|
|
807
|
+
flow(`${el}_li0`, `${el}_loopStart`, `${el}_task`),
|
|
808
|
+
flow(`${el}_li1`, `${el}_task`, `${el}_gw`),
|
|
809
|
+
` <bpmn:sequenceFlow id="${el}_li2" name="ready" sourceRef="${el}_gw" targetRef="${el}_loopEnd"><bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=ready = true</bpmn:conditionExpression></bpmn:sequenceFlow>`,
|
|
810
|
+
` <bpmn:sequenceFlow id="${el}_li3" name="not ready" sourceRef="${el}_gw" targetRef="${el}_waitPoll" />`,
|
|
811
|
+
flow(`${el}_li4`, `${el}_waitPoll`, `${el}_task`),
|
|
812
|
+
" </bpmn:subProcess>",
|
|
813
|
+
` <bpmn:boundaryEvent id="${el}_be" name="Gate timed out" attachedToRef="${el}_probeLoop">`,
|
|
814
|
+
` <bpmn:outgoing>${el}_i2</bpmn:outgoing>`,
|
|
815
|
+
` <bpmn:timerEventDefinition id="${el}_ted"><bpmn:timeDuration xsi:type="bpmn:tFormalExpression">=probeTimeout</bpmn:timeDuration></bpmn:timerEventDefinition>`,
|
|
816
|
+
" </bpmn:boundaryEvent>",
|
|
817
|
+
` <bpmn:serviceTask id="${el}_lastAttempt" name="Probe readiness at boundary: ${escapeXml(nodeId)}">`,
|
|
716
818
|
" <bpmn:extensionElements>",
|
|
717
819
|
` <zeebe:taskDefinition type="${DELEGATE_TASK_TYPE.wait}" />`,
|
|
718
820
|
" <zeebe:properties>",
|
|
719
821
|
' <zeebe:property name="io.nanobpm.dataEnvelope.in" value="ReadinessProbeIn" />',
|
|
720
822
|
' <zeebe:property name="io.nanobpm.dataEnvelope.out" value="ReadinessProbeOut" />',
|
|
721
823
|
" </zeebe:properties>",
|
|
824
|
+
" <zeebe:ioMapping>",
|
|
825
|
+
' <zeebe:input source="=true" target="lastAttempt" />',
|
|
826
|
+
" </zeebe:ioMapping>",
|
|
722
827
|
" </bpmn:extensionElements>",
|
|
723
|
-
` <bpmn:incoming>${el}
|
|
724
|
-
` <bpmn:outgoing>${el}
|
|
828
|
+
` <bpmn:incoming>${el}_i2</bpmn:incoming>`,
|
|
829
|
+
` <bpmn:outgoing>${el}_i6</bpmn:outgoing>`,
|
|
725
830
|
" </bpmn:serviceTask>",
|
|
726
|
-
` <bpmn:
|
|
727
|
-
` <bpmn:
|
|
728
|
-
` <bpmn:
|
|
729
|
-
" </bpmn:boundaryEvent>",
|
|
730
|
-
` <bpmn:exclusiveGateway id="${el}_gw" name="ready?" default="${el}_i4">`,
|
|
731
|
-
` <bpmn:incoming>${el}_i1</bpmn:incoming>`,
|
|
732
|
-
` <bpmn:outgoing>${el}_i3</bpmn:outgoing>`,
|
|
831
|
+
` <bpmn:exclusiveGateway id="${el}_lastGw" name="ready after boundary?" default="${el}_i4">`,
|
|
832
|
+
` <bpmn:incoming>${el}_i6</bpmn:incoming>`,
|
|
833
|
+
` <bpmn:outgoing>${el}_i7</bpmn:outgoing>`,
|
|
733
834
|
` <bpmn:outgoing>${el}_i4</bpmn:outgoing>`,
|
|
734
835
|
" </bpmn:exclusiveGateway>",
|
|
735
|
-
...escalationTaskLines(esc, nodeId, [`${el}
|
|
736
|
-
` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}
|
|
737
|
-
flow(`${el}_i0`, `${el}_start`, `${el}
|
|
738
|
-
flow(`${el}_i1`, `${el}
|
|
739
|
-
flow(`${el}_i2`, `${el}_be`,
|
|
740
|
-
|
|
741
|
-
` <bpmn:sequenceFlow id="${el}
|
|
836
|
+
...escalationTaskLines(esc, nodeId, [`${el}_i4`], `${el}_i5`),
|
|
837
|
+
` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_i1</bpmn:incoming><bpmn:incoming>${el}_i5</bpmn:incoming><bpmn:incoming>${el}_i7</bpmn:incoming></bpmn:endEvent>`,
|
|
838
|
+
flow(`${el}_i0`, `${el}_start`, `${el}_probeLoop`),
|
|
839
|
+
flow(`${el}_i1`, `${el}_probeLoop`, `${el}_end`),
|
|
840
|
+
flow(`${el}_i2`, `${el}_be`, `${el}_lastAttempt`),
|
|
841
|
+
flow(`${el}_i6`, `${el}_lastAttempt`, `${el}_lastGw`),
|
|
842
|
+
` <bpmn:sequenceFlow id="${el}_i7" name="ready" sourceRef="${el}_lastGw" targetRef="${el}_end"><bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=ready = true</bpmn:conditionExpression></bpmn:sequenceFlow>`,
|
|
843
|
+
` <bpmn:sequenceFlow id="${el}_i4" name="not ready" sourceRef="${el}_lastGw" targetRef="${esc}" />`,
|
|
742
844
|
flow(`${el}_i5`, esc, `${el}_end`),
|
|
743
845
|
];
|
|
744
846
|
}
|
|
@@ -179,7 +179,7 @@ test("isDeliveryGraphApproved: a side-effecting graph dispatches ONLY with the m
|
|
|
179
179
|
});
|
|
180
180
|
|
|
181
181
|
// ── buildHumanLabels / parseHumanLabels ───────────────────────────────────────
|
|
182
|
-
test("buildHumanLabels: maps each human node's compiled user-task element id → its instruction label", () => {
|
|
182
|
+
test("buildHumanLabels: maps each human node's compiled user-task element id → its instruction label", async () => {
|
|
183
183
|
const graph = {
|
|
184
184
|
nodes: [
|
|
185
185
|
{ id: "open-b", kind: "agent", agent: { jobType: "j" } },
|
|
@@ -188,7 +188,7 @@ test("buildHumanLabels: maps each human node's compiled user-task element id →
|
|
|
188
188
|
],
|
|
189
189
|
edges: [{ from: "open-b", to: "publish" }, { from: "publish", to: "ack" }],
|
|
190
190
|
};
|
|
191
|
-
const compiled = compileDeliveryGraph(graph);
|
|
191
|
+
const compiled = await compileDeliveryGraph(graph);
|
|
192
192
|
assertEquals(compiled.ok, true);
|
|
193
193
|
if (!compiled.ok) return;
|
|
194
194
|
const labels = buildHumanLabels(compiled);
|