@nanobpm/nano-workforce 0.120.1 → 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 +8 -0
- package/app/deliveryGraphCompiler.test.ts +70 -42
- package/app/deliveryGraphCompiler.ts +58 -2
- package/app/deliveryGraphRun.test.ts +2 -2
- package/app/deliveryRunner.test.ts +28 -17
- package/app/deliveryRunner.ts +15 -6
- package/app/pollUserTasks.test.ts +49 -1
- package/app/service.ts +16 -5
- 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/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
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
|
+
|
|
1
9
|
## [0.120.1](https://github.com/nanobpm/nano-workforce/compare/v0.120.0...v0.120.1) (2026-08-22)
|
|
2
10
|
|
|
3
11
|
|
|
@@ -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,21 +70,21 @@ 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);
|
|
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
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);
|
|
@@ -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";
|
|
@@ -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);
|
|
@@ -29,31 +29,42 @@ const GRAPH: DeliveryGraph = {
|
|
|
29
29
|
],
|
|
30
30
|
};
|
|
31
31
|
|
|
32
|
-
function prepareOk(graph: DeliveryGraph, options = {}) {
|
|
33
|
-
const r = prepareDeliveryGraph(graph, options);
|
|
32
|
+
async function prepareOk(graph: DeliveryGraph, options = {}) {
|
|
33
|
+
const r = await prepareDeliveryGraph(graph, options);
|
|
34
34
|
assert(r.ok, `expected ok:true, got ${JSON.stringify(r)}`);
|
|
35
35
|
return r.prepared;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
test("content-addressed id: deterministic for the same graph, content-sensitive across graphs", () => {
|
|
39
|
-
const a = prepareOk(GRAPH);
|
|
40
|
-
const b = prepareOk(GRAPH);
|
|
38
|
+
test("content-addressed id: deterministic for the same graph, content-sensitive across graphs", async () => {
|
|
39
|
+
const a = await prepareOk(GRAPH);
|
|
40
|
+
const b = await prepareOk(GRAPH);
|
|
41
41
|
assert(/^delivery-graph-[0-9a-f]{12}$/.test(a.processDefinitionId), `id is content-addressed, got ${a.processDefinitionId}`);
|
|
42
42
|
assertEquals(a.processDefinitionId, b.processDefinitionId);
|
|
43
43
|
|
|
44
44
|
// A structurally different graph gets a DIFFERENT id (no collision / no accidental redeploy-as-same).
|
|
45
|
-
const other = prepareOk({ ...GRAPH, nodes: [...GRAPH.nodes, { id: "extra", kind: "agent", agent: { jobType: "senior:feature" } }], edges: [...GRAPH.edges, { from: "consume", to: "extra" }] });
|
|
45
|
+
const other = await prepareOk({ ...GRAPH, nodes: [...GRAPH.nodes, { id: "extra", kind: "agent", agent: { jobType: "senior:feature" } }], edges: [...GRAPH.edges, { from: "consume", to: "extra" }] });
|
|
46
46
|
assert(other.processDefinitionId !== a.processDefinitionId, "a different graph yields a different id");
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
-
test("the deployable BPMN rewrites the base process id to the content-addressed deploy id", () => {
|
|
50
|
-
const p = prepareOk(GRAPH);
|
|
49
|
+
test("the deployable BPMN rewrites the base process id to the content-addressed deploy id", async () => {
|
|
50
|
+
const p = await prepareOk(GRAPH);
|
|
51
51
|
assert(p.bpmn.includes(`<bpmn:process id="${p.processDefinitionId}"`), "process id is the content-addressed id");
|
|
52
52
|
assert(!p.bpmn.includes('<bpmn:process id="delivery-graph"'), "the base id no longer appears as the process id");
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
-
test("
|
|
56
|
-
|
|
55
|
+
test("DI (#440): the deployable definition carries diagram interchange bound to the rewritten process id", async () => {
|
|
56
|
+
// The DEPLOYED definition (not just the compile preview) must render in the process explorer, so it
|
|
57
|
+
// carries the auto-laid-out `bpmndi:BPMNDiagram`. The top-level plane's `bpmnElement` reference is
|
|
58
|
+
// rewritten in lock-step with the process id, otherwise the deployed diagram would dangle and render
|
|
59
|
+
// positionless — the exact bug #440 fixes.
|
|
60
|
+
const p = await prepareOk(GRAPH);
|
|
61
|
+
assert(p.bpmn.includes("<bpmndi:BPMNDiagram"), "deployable bpmn carries a diagram");
|
|
62
|
+
assert(p.bpmn.includes(`bpmnElement="${p.processDefinitionId}"`), "the plane binds to the content-addressed id");
|
|
63
|
+
assert(!p.bpmn.includes('bpmnElement="delivery-graph"'), "no dangling reference to the base process id remains");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("nodeInputs seeds the exact per-kind fields each node's subProcess ioMapping reads", async () => {
|
|
67
|
+
const p = await prepareOk(GRAPH, { nodeTimeout: "PT10M", probeTimeout: "PT20M", escalationSlaTimeout: "PT2H", escalationAssignee: "alice", runKey: "run-7" });
|
|
57
68
|
// Element ids are positional by sorted node id: consume, open-b, publish, watch-b → n0..n3.
|
|
58
69
|
const inputs = p.nodeInputs;
|
|
59
70
|
const byField = (pred: (v: Record<string, unknown>) => boolean) => Object.values(inputs).find((v) => pred(v as Record<string, unknown>)) as Record<string, unknown> | undefined;
|
|
@@ -74,12 +85,12 @@ test("nodeInputs seeds the exact per-kind fields each node's subProcess ioMappin
|
|
|
74
85
|
assertEquals(connector, { target: "npm:install", dedupeKey: "consume-1", payload: null, timeout: "PT10M" });
|
|
75
86
|
});
|
|
76
87
|
|
|
77
|
-
test("wait gateKeys default to a fresh per-run token so concurrent runs of one graph never cross-correlate", () => {
|
|
78
|
-
const gateKeyOf = (p: ReturnType<typeof prepareOk
|
|
88
|
+
test("wait gateKeys default to a fresh per-run token so concurrent runs of one graph never cross-correlate", async () => {
|
|
89
|
+
const gateKeyOf = (p: Awaited<ReturnType<typeof prepareOk>>) =>
|
|
79
90
|
(Object.values(p.nodeInputs).find((v) => "gateKey" in v) as { gateKey?: string } | undefined)?.gateKey;
|
|
80
91
|
|
|
81
|
-
const a = prepareOk(GRAPH);
|
|
82
|
-
const b = prepareOk(GRAPH);
|
|
92
|
+
const a = await prepareOk(GRAPH);
|
|
93
|
+
const b = await prepareOk(GRAPH);
|
|
83
94
|
assert(gateKeyOf(a) && gateKeyOf(b), "each run seeds a wait gateKey");
|
|
84
95
|
assert(gateKeyOf(a) !== gateKeyOf(b), "two runs of the same graph get DISTINCT default gate scopes");
|
|
85
96
|
// The gate key must NOT be derived from the (shared) content digest — that is the bug this guards.
|
|
@@ -89,12 +100,12 @@ test("wait gateKeys default to a fresh per-run token so concurrent runs of one g
|
|
|
89
100
|
assertEquals(a.bpmn, b.bpmn);
|
|
90
101
|
|
|
91
102
|
// An explicit runKey is honoured verbatim (reproducible seed).
|
|
92
|
-
const seeded = prepareOk(GRAPH, { runKey: "run-7" });
|
|
103
|
+
const seeded = await prepareOk(GRAPH, { runKey: "run-7" });
|
|
93
104
|
assertEquals(gateKeyOf(seeded), "run-7:n3");
|
|
94
105
|
});
|
|
95
106
|
|
|
96
|
-
test("a malformed graph returns the S1 compile errors and prepares nothing", () => {
|
|
97
|
-
const r = prepareDeliveryGraph({ nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" } }], edges: [{ from: "a", to: "ghost" }] } as unknown as DeliveryGraph);
|
|
107
|
+
test("a malformed graph returns the S1 compile errors and prepares nothing", async () => {
|
|
108
|
+
const r = await prepareDeliveryGraph({ nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" } }], edges: [{ from: "a", to: "ghost" }] } as unknown as DeliveryGraph);
|
|
98
109
|
assert(!r.ok, "a dangling edge fails to prepare");
|
|
99
110
|
assert(r.errors.some((e) => e.path === "edges[0].to"), `expected a dangling-edge error, got ${JSON.stringify(r.errors)}`);
|
|
100
111
|
});
|
package/app/deliveryRunner.ts
CHANGED
|
@@ -97,8 +97,11 @@ export type RunDeliveryResult =
|
|
|
97
97
|
* so each call's `wait` `gateKey`s differ (two concurrent runs of the same graph never cross-correlate);
|
|
98
98
|
* pass an explicit `runKey` for a reproducible seed. Returns the S1 compile errors verbatim for a
|
|
99
99
|
* malformed graph. */
|
|
100
|
-
export function prepareDeliveryGraph(
|
|
101
|
-
|
|
100
|
+
export async function prepareDeliveryGraph(
|
|
101
|
+
graph: DeliveryGraph,
|
|
102
|
+
options: DeliveryRunOptions = {},
|
|
103
|
+
): Promise<PrepareDeliveryResult> {
|
|
104
|
+
const compiled = await compileDeliveryGraph(graph);
|
|
102
105
|
if (!compiled.ok) return { ok: false, errors: compiled.errors };
|
|
103
106
|
|
|
104
107
|
const digest = deliveryGraphDigest(compiled.bpmn);
|
|
@@ -132,7 +135,7 @@ export async function runDeliveryGraph(
|
|
|
132
135
|
graph: DeliveryGraph,
|
|
133
136
|
options: DeliveryRunOptions = {},
|
|
134
137
|
): Promise<RunDeliveryResult> {
|
|
135
|
-
const prep = prepareDeliveryGraph(graph, options);
|
|
138
|
+
const prep = await prepareDeliveryGraph(graph, options);
|
|
136
139
|
if (!prep.ok) return prep;
|
|
137
140
|
const { processDefinitionId, bpmn, nodeInputs } = prep.prepared;
|
|
138
141
|
|
|
@@ -150,10 +153,16 @@ export async function runDeliveryGraph(
|
|
|
150
153
|
}
|
|
151
154
|
|
|
152
155
|
/** Rewrite the compiled BPMN's base `bpmn:process` id to the content-addressed deploy id. The base id
|
|
153
|
-
* appears exactly once
|
|
154
|
-
* `End`, never the process id)
|
|
156
|
+
* appears exactly once as the process element's `id` attribute (element ids are `n<i>`/`gw*`/`Start`/
|
|
157
|
+
* `End`, never the process id), and once more as the top-level `bpmndi:BPMNPlane`'s `bpmnElement`
|
|
158
|
+
* reference back to that process (the diagram interchange the compiler now attaches, #440). Both must
|
|
159
|
+
* move together, otherwise the deployed definition carries a DANGLING plane reference and renders
|
|
160
|
+
* positionless — the very bug DI was added to fix. Nested sub-process planes reference `n<i>` element
|
|
161
|
+
* ids, which are untouched. */
|
|
155
162
|
function rewriteProcessId(bpmn: string, processDefinitionId: string): string {
|
|
156
|
-
return bpmn
|
|
163
|
+
return bpmn
|
|
164
|
+
.replace(`id="${DELIVERY_GRAPH_PROCESS_ID}"`, `id="${processDefinitionId}"`)
|
|
165
|
+
.replace(`bpmnElement="${DELIVERY_GRAPH_PROCESS_ID}"`, `bpmnElement="${processDefinitionId}"`);
|
|
157
166
|
}
|
|
158
167
|
|
|
159
168
|
/** Build the `nodeInputs.<element>` seed for one node, per its kind — the exact fields the compiled
|
|
@@ -535,4 +535,52 @@ test("pollUserTasks (engine-first): pages through a large open set (no first-pag
|
|
|
535
535
|
}
|
|
536
536
|
|
|
537
537
|
assertEquals((stores.user_tasks ?? []).length, 150);
|
|
538
|
-
});
|
|
538
|
+
});
|
|
539
|
+
test("pollUserTasks (engine-first): surfaces an inlined delivery-graph human task, enriched + bucketed as `delivery` (issue #442)", async () => {
|
|
540
|
+
// A delivery-graph `human` node is compiled (S4) as an INLINED user task with a per-node id
|
|
541
|
+
// `delivery-human-task__<node>` — the bare `delivery-human-task` never appears at runtime. The poller's
|
|
542
|
+
// leak guards must recognise it through the single-source-of-truth predicate (`userTaskKindLabel` /
|
|
543
|
+
// `isDeliveryHumanElement`), NOT exact `USER_TASK_KIND_LABELS` membership — else every delivery-graph
|
|
544
|
+
// human gate is silently dropped from the Tasks inbox and no operator can tick it off (merlin task 35002).
|
|
545
|
+
const { data, stores } = memData({
|
|
546
|
+
delivery_graph_runs: [
|
|
547
|
+
{ run_key: "delivery-graph-403eb22e", process_key: "dg-1", status: "running", title: "release runbook" },
|
|
548
|
+
],
|
|
549
|
+
});
|
|
550
|
+
const restore = stubUserTaskSearch([
|
|
551
|
+
{ userTaskKey: "35002", elementId: "delivery-human-task__n1", processInstanceKey: "dg-1", state: "CREATED" },
|
|
552
|
+
]);
|
|
553
|
+
try {
|
|
554
|
+
await pollUserTasks(data, fakeEngine({}), REST);
|
|
555
|
+
} finally {
|
|
556
|
+
restore();
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
|
|
560
|
+
assertEquals(Object.keys(byKey), ["35002"]);
|
|
561
|
+
assertEquals(byKey["35002"].element_id, "delivery-human-task__n1");
|
|
562
|
+
assertEquals(byKey["35002"].kind_label, "Delivery: human step");
|
|
563
|
+
assertEquals(byKey["35002"].subject_type, "delivery");
|
|
564
|
+
assertEquals(byKey["35002"].subject_key, "delivery-graph-403eb22e");
|
|
565
|
+
assertEquals(byKey["35002"].subject_title, "release runbook");
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
test("pollUserTasks (engine-first): a delivery-human task on an UNTRACKED run still surfaces (bucketed `delivery`, instance fallback) (issue #442)", async () => {
|
|
569
|
+
// Even with no `delivery_graph_runs` row referencing the instance, the kind implies its aggregate, so
|
|
570
|
+
// the row renders and stays answerable — mirroring the orphaned-escalation guarantee (#358).
|
|
571
|
+
const { data, stores } = memData({});
|
|
572
|
+
const restore = stubUserTaskSearch([
|
|
573
|
+
{ userTaskKey: "35002", elementId: "delivery-human-task__n1", processInstanceKey: "dg-9", state: "CREATED" },
|
|
574
|
+
]);
|
|
575
|
+
try {
|
|
576
|
+
await pollUserTasks(data, fakeEngine({}), REST);
|
|
577
|
+
} finally {
|
|
578
|
+
restore();
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
|
|
582
|
+
assertEquals(Object.keys(byKey), ["35002"]);
|
|
583
|
+
assertEquals(byKey["35002"].kind_label, "Delivery: human step");
|
|
584
|
+
assertEquals(byKey["35002"].subject_type, "delivery");
|
|
585
|
+
assertEquals(byKey["35002"].subject_key, "dg-9"); // instance fallback — non-blank so it renders
|
|
586
|
+
});
|
package/app/service.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
import { isUniqueConstraintFence } from "./dbFence.ts";
|
|
30
30
|
import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
|
|
31
31
|
import { deliveryGraphRuns, deriveDeliveryPhase, parseHumanLabels } from "./deliveryGraphRun.ts";
|
|
32
|
+
import { isDeliveryHumanElement } from "./deliveryHuman.ts";
|
|
32
33
|
import { fleetSupportsDurableResume } from "./durableResume.ts";
|
|
33
34
|
import { backfillFeatureStages, deriveFeatureDelivery, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRunStatus, featureEscalations, featureRuns } from "./feature.ts";
|
|
34
35
|
import {
|
|
@@ -87,9 +88,9 @@ import {
|
|
|
87
88
|
prEscalations,
|
|
88
89
|
reconcileUserTasks,
|
|
89
90
|
TRIAL_MERGE_ELEMENT,
|
|
90
|
-
USER_TASK_KIND_LABELS,
|
|
91
91
|
type UserTaskContext,
|
|
92
92
|
type UserTaskRow,
|
|
93
|
+
userTaskKindLabel,
|
|
93
94
|
userTasks,
|
|
94
95
|
} from "./userTasks.ts";
|
|
95
96
|
import { deriveWaitGate } from "./waitGate.ts";
|
|
@@ -2166,7 +2167,7 @@ async function sweepOpenEscalationTasks(base: string, headers: Record<string, st
|
|
|
2166
2167
|
// answerable, so a lagging COMPLETED/CANCELED read must never surface a dead affordance (#294).
|
|
2167
2168
|
if (typeof it.state === "string" && it.state.toUpperCase() !== "CREATED") continue;
|
|
2168
2169
|
const elementId = typeof it.elementId === "string" ? it.elementId : undefined;
|
|
2169
|
-
if (!elementId ||
|
|
2170
|
+
if (!elementId || userTaskKindLabel(elementId) === undefined) continue;
|
|
2170
2171
|
const userTaskKey = it.userTaskKey == null ? "" : String(it.userTaskKey);
|
|
2171
2172
|
if (!userTaskKey || seen.has(userTaskKey)) continue;
|
|
2172
2173
|
seen.add(userTaskKey);
|
|
@@ -2254,7 +2255,7 @@ export async function pollUserTasks(
|
|
|
2254
2255
|
// summary for the `conformance-escalation` ack (its instance is tracked on `plan_conformance`, not a
|
|
2255
2256
|
// delivery aggregate).
|
|
2256
2257
|
interface Subject {
|
|
2257
|
-
type: "feature" | "plan" | "pr";
|
|
2258
|
+
type: "feature" | "plan" | "pr" | "delivery";
|
|
2258
2259
|
key: string;
|
|
2259
2260
|
title?: string | null;
|
|
2260
2261
|
url?: string | null;
|
|
@@ -2278,6 +2279,13 @@ export async function pollUserTasks(
|
|
|
2278
2279
|
const plan = await plans(data).get(review.plan_key);
|
|
2279
2280
|
subjectByInstance.set(review.process_key, { type: "plan", key: review.plan_key, title: plan?.title ?? null, url: plan?.issue_url ?? null, conformanceSummary: review.summary });
|
|
2280
2281
|
}
|
|
2282
|
+
// A delivery-graph `human` node parks on its run's engine instance; enrich from the run row so the
|
|
2283
|
+
// Tasks inbox shows the graph's title (its `run_key` as the stable subject key), mirroring the
|
|
2284
|
+
// feature/plan/pr enrichment. The row's inlined `delivery-human-task__<node>` id is recognised by
|
|
2285
|
+
// the shared `userTaskKindLabel` predicate, and buckets as `delivery` (below).
|
|
2286
|
+
for (const run of await deliveryGraphRuns(data).all()) {
|
|
2287
|
+
if (run.process_key) subjectByInstance.set(run.process_key, { type: "delivery", key: run.run_key, title: run.title, url: null });
|
|
2288
|
+
}
|
|
2281
2289
|
|
|
2282
2290
|
// Per-element subject type for an ORPHANED task (no subject row) — the kind implies its aggregate even
|
|
2283
2291
|
// when tracking is lost, so the fallback row still buckets correctly on the page.
|
|
@@ -2296,9 +2304,12 @@ export async function pollUserTasks(
|
|
|
2296
2304
|
// when the instance is tracked or a per-kind fallback when it is orphaned. Returns `null` for a
|
|
2297
2305
|
// non-escalation element (the leak guard) so an arbitrary internal user task can never reach the inbox.
|
|
2298
2306
|
const contextFor = async (elementId: string, userTaskKey: string, processInstanceKey: string): Promise<UserTaskContext | null> => {
|
|
2299
|
-
if (
|
|
2307
|
+
if (userTaskKindLabel(elementId) === undefined) return null;
|
|
2300
2308
|
const subj = subjectByInstance.get(processInstanceKey);
|
|
2301
|
-
|
|
2309
|
+
// Orphaned-task fallback: the kind implies its aggregate even when no subject row references the
|
|
2310
|
+
// instance. A delivery-human node's id is inlined (`delivery-human-task__<node>`), so its bucket is
|
|
2311
|
+
// derived from the predicate rather than the static per-element table.
|
|
2312
|
+
const subjectType = subj?.type ?? DEFAULT_SUBJECT_TYPE[elementId] ?? (isDeliveryHumanElement(elementId) ? "delivery" : "plan");
|
|
2302
2313
|
const subjectKey = subj?.key ?? processInstanceKey;
|
|
2303
2314
|
let question: string | null = null;
|
|
2304
2315
|
switch (elementId) {
|
|
@@ -29,6 +29,9 @@ test("compile-delivery-graph: a well-formed graph → 200 with the pure preview"
|
|
|
29
29
|
assertEquals(res.status, 200);
|
|
30
30
|
assertEquals(res.body.ok, true);
|
|
31
31
|
assert(typeof res.body.bpmn === "string" && res.body.bpmn.length > 0);
|
|
32
|
+
// The compile preview must show what actually deploys — including the auto-laid-out diagram
|
|
33
|
+
// interchange (#440), so the process explorer can render the previewed graph.
|
|
34
|
+
assert(res.body.bpmn.includes("<bpmndi:BPMNDiagram"), "the previewed bpmn carries diagram interchange");
|
|
32
35
|
assert(typeof res.body.diagram === "string" && res.body.diagram.length > 0);
|
|
33
36
|
assertEquals(res.body.resolved.nodes.length, 2);
|
|
34
37
|
assertEquals(res.body.humanNodes.length, 1);
|
|
@@ -20,7 +20,7 @@ export default defineOperation("compileDeliveryGraph", async ({ body }, app) =>
|
|
|
20
20
|
// the SEMANTIC checks (acyclicity, edge integrity, fact resolution) the schema cannot express. A
|
|
21
21
|
// directly-invoked delegate could still pass `undefined` — the compiler reads its input as
|
|
22
22
|
// `unknown` and maps that to a clean `ok:false`, never a 500.
|
|
23
|
-
const result = compileDeliveryGraph(body);
|
|
23
|
+
const result = await compileDeliveryGraph(body);
|
|
24
24
|
if (!result.ok) {
|
|
25
25
|
app.log.warn("compile-delivery-graph rejected", { errors: result.errors.length });
|
|
26
26
|
return { status: 400, body: result };
|
|
@@ -37,7 +37,7 @@ export default defineOperation("dispatchDeliveryGraph", async (input, app) => {
|
|
|
37
37
|
// dispatches. A compile failure here surfaces as a clean 400 rather than reaching the start door.
|
|
38
38
|
let approvalToken: string | undefined;
|
|
39
39
|
if (approve) {
|
|
40
|
-
const compiled = compileDeliveryGraph(parsed.graph);
|
|
40
|
+
const compiled = await compileDeliveryGraph(parsed.graph);
|
|
41
41
|
if (!compiled.ok) {
|
|
42
42
|
app.log.warn("dispatch-delivery-graph rejected: compile", { errors: compiled.errors.length });
|
|
43
43
|
return {
|
|
@@ -22,7 +22,7 @@ export default defineOperation("previewDeliveryGraph", async ({ body }, app) =>
|
|
|
22
22
|
app.log.warn("preview-delivery-graph rejected: parse", { message: parsed.error });
|
|
23
23
|
return { status: 400, body: { ok: false, error: parsed.error } };
|
|
24
24
|
}
|
|
25
|
-
const compiled = compileDeliveryGraph(parsed.graph);
|
|
25
|
+
const compiled = await compileDeliveryGraph(parsed.graph);
|
|
26
26
|
if (!compiled.ok) {
|
|
27
27
|
app.log.warn("preview-delivery-graph rejected: compile", { errors: compiled.errors.length });
|
|
28
28
|
return {
|
|
@@ -56,7 +56,7 @@ export default defineOperation("startDeliveryGraph", async ({ body }, app) => {
|
|
|
56
56
|
|
|
57
57
|
// 2) Compile via S1. This yields the deterministic BPMN (→ the content digest / approval token) plus
|
|
58
58
|
// the graph's shape: its side effects (whether approval is required), human stops, and node count.
|
|
59
|
-
const compiled = compileDeliveryGraph(graph);
|
|
59
|
+
const compiled = await compileDeliveryGraph(graph);
|
|
60
60
|
if (!compiled.ok) {
|
|
61
61
|
app.log.warn("start-delivery-graph rejected: compile", { count: compiled.errors.length });
|
|
62
62
|
return { status: 400, body: { ok: false, errors: compiled.errors } };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.120.
|
|
3
|
+
"version": "0.120.2",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
|
@@ -59,7 +59,8 @@
|
|
|
59
59
|
},
|
|
60
60
|
"dependencies": {
|
|
61
61
|
"@nanobpm/agentic": "^0.1.0",
|
|
62
|
-
"@nanobpm/urban": "^0.75.0"
|
|
62
|
+
"@nanobpm/urban": "^0.75.0",
|
|
63
|
+
"bpmn-auto-layout": "^2.0.0-alpha.2"
|
|
63
64
|
},
|
|
64
65
|
"devDependencies": {
|
|
65
66
|
"@biomejs/biome": "^2.4.11",
|