@nanobpm/nano-workforce 0.123.1 → 0.124.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +9 -5
  3. package/app/deliveryGraphDeploy.test.ts +209 -0
  4. package/app/deliveryGraphDispatch.test.ts +143 -0
  5. package/app/deliveryGraphDispatch.ts +168 -0
  6. package/app/deliveryGraphProposals.test.ts +267 -0
  7. package/app/deliveryGraphProposals.ts +269 -0
  8. package/app/deliveryGraphRun.test.ts +6 -52
  9. package/app/deliveryGraphRun.ts +21 -76
  10. package/app/deliveryGraphText.ts +3 -3
  11. package/app/deliveryRunner.ts +4 -3
  12. package/app/featureReadModel.test.ts +80 -12
  13. package/app/github.test.ts +34 -0
  14. package/app/github.ts +12 -3
  15. package/app/maybeEnsureFreshHeadRun.test.ts +150 -0
  16. package/app/mergeEscalationQuestion.test.ts +33 -0
  17. package/app/mergeProtocol.test.ts +25 -0
  18. package/app/mergeProtocol.ts +10 -4
  19. package/app/pollUserTasks.test.ts +27 -0
  20. package/app/service.ts +92 -13
  21. package/app/stage.test.ts +21 -7
  22. package/app/stage.ts +18 -5
  23. package/db/migrations/075_delivery_graph_proposals.sql +48 -0
  24. package/db/migrations/075_feature_read_model_attention_from_user_tasks.sql +113 -0
  25. package/docs/adr/0005-agent-authored-delivery-graphs.md +18 -0
  26. package/docs/adr/0006-delivery-units-one-representation.md +221 -0
  27. package/docs/agent-guide.md +50 -58
  28. package/e2e/convergence-escalation.e2e.ts +10 -0
  29. package/e2e/delivery-graph-dispatch.e2e.ts +155 -0
  30. package/e2e/retire-escalation-subsystem.e2e.ts +13 -0
  31. package/openapi.yaml +118 -161
  32. package/operations/compileDeliveryGraph.test.ts +100 -37
  33. package/operations/compileDeliveryGraph.ts +64 -18
  34. package/operations/dispatchDeliveryGraph.test.ts +171 -152
  35. package/operations/dispatchDeliveryGraph.ts +79 -99
  36. package/operations/getAgentInstructions.test.ts +10 -6
  37. package/operations/previewDeliveryGraph.test.ts +90 -51
  38. package/operations/previewDeliveryGraph.ts +45 -18
  39. package/package.json +3 -3
  40. package/pages/cockpit/mount.js +19 -12
  41. package/pages/delivery-graphs/mount.js +37 -137
  42. package/pages/delivery-graphs.page.json +50 -3
  43. package/resources/processes/merge-loop.bpmn +1 -1
  44. package/scripts/check-migrations.test.ts +9 -0
  45. package/scripts/check-migrations.ts +11 -1
  46. package/test/cockpit-embed-endpoints.test.ts +59 -36
  47. package/test/delivery-graphs-embed.test.ts +36 -34
  48. package/e2e/delivery-graph-start.e2e.ts +0 -145
  49. package/operations/startDeliveryGraph.integration.test.ts +0 -316
  50. package/operations/startDeliveryGraph.ts +0 -222
package/CHANGELOG.md CHANGED
@@ -1,3 +1,25 @@
1
+ # [0.124.0](https://github.com/nanobpm/nano-workforce/compare/v0.123.2...v0.124.0) (2026-08-23)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **cockpit:** anchor supply/transcript endpoints to import.meta.url so the shell finds the API ([#468](https://github.com/nanobpm/nano-workforce/issues/468)) ([4ff4b99](https://github.com/nanobpm/nano-workforce/commit/4ff4b99767b915902be19cdecd5973c1ce6efc4c)), closes [279/#280](https://github.com/nanobpm/nano-workforce/issues/280) [#467](https://github.com/nanobpm/nano-workforce/issues/467)
7
+ * **migrations:** grandfather prefix 075 collision to unblock main + releases ([#471](https://github.com/nanobpm/nano-workforce/issues/471)) ([508194e](https://github.com/nanobpm/nano-workforce/commit/508194e7be0f58ba3f12d2b67d4d6212d243c609)), closes [#458](https://github.com/nanobpm/nano-workforce/issues/458) [460/#463](https://github.com/nanobpm/nano-workforce/issues/463) [#357](https://github.com/nanobpm/nano-workforce/issues/357) [#470](https://github.com/nanobpm/nano-workforce/issues/470)
8
+
9
+
10
+ ### Features
11
+
12
+ * **delivery-graph:** make dispatch operator-only ([#460](https://github.com/nanobpm/nano-workforce/issues/460)) ([#463](https://github.com/nanobpm/nano-workforce/issues/463)) ([cd6d2b8](https://github.com/nanobpm/nano-workforce/commit/cd6d2b819b32c5c7daf047dbb6e077a26d4b09e6))
13
+
14
+ ## [0.123.2](https://github.com/nanobpm/nano-workforce/compare/v0.123.1...v0.123.2) (2026-08-22)
15
+
16
+
17
+ ### Bug Fixes
18
+
19
+ * **merge:** make draft PRs a first-class not-landable verdict ([#454](https://github.com/nanobpm/nano-workforce/issues/454)) ([#455](https://github.com/nanobpm/nano-workforce/issues/455)) ([d684086](https://github.com/nanobpm/nano-workforce/commit/d684086748c840fe6a77fd7d86c5cdecbf8bfe2a))
20
+ * **read-model:** derive feature attention badge from open user tasks, not sticky status ([#458](https://github.com/nanobpm/nano-workforce/issues/458)) ([496971d](https://github.com/nanobpm/nano-workforce/commit/496971dba95f19857d659e5a9d7fa56085d932fa)), closes [#439](https://github.com/nanobpm/nano-workforce/issues/439) [#448](https://github.com/nanobpm/nano-workforce/issues/448) [#422](https://github.com/nanobpm/nano-workforce/issues/422) [#422](https://github.com/nanobpm/nano-workforce/issues/422)
21
+ * **tasks:** scan running delivery-graph runs in the typed-seam fallback so human gates surface without raw-REST ([#457](https://github.com/nanobpm/nano-workforce/issues/457)) ([9e82d37](https://github.com/nanobpm/nano-workforce/commit/9e82d37a101d532de447c6ca0bf64bd3d9a3bee7)), closes [#443](https://github.com/nanobpm/nano-workforce/issues/443) [#442](https://github.com/nanobpm/nano-workforce/issues/442)
22
+
1
23
  ## [0.123.1](https://github.com/nanobpm/nano-workforce/compare/v0.123.0...v0.123.1) (2026-08-22)
2
24
 
3
25
 
package/README.md CHANGED
@@ -254,11 +254,15 @@ run an **arbitrary, heterogeneous, cross-repo, partly-human delivery graph** —
254
254
  the just-published version*. You author the graph as **JSON over a closed node vocabulary**
255
255
  (`agent` | `wait` | `human` | `connector`) — never BPMN or code — whose edges are
256
256
  **discovered facts** (`from: "<node>.<fact>"`). A deterministic compiler turns it into an
257
- engine-native process; a human approves the rendered preview before any side effect runs.
258
-
259
- Two doors: a **pure** `POST /app/api/actions/compile-delivery-graph` (validate + preview,
260
- side-effect-freehammer it while drafting) and a **gated, idempotent**
261
- `POST /app/api/actions/start/delivery-graph` (approve dispatch).
257
+ engine-native process; a human previews the staged proposal and dispatches it from the
258
+ cockpit before any side effect runs.
259
+
260
+ Two doors and the second is **operator-only** (issue #460): a
261
+ `POST /app/api/actions/compile-delivery-graph` on the agent surface that validates + previews
262
+ the graph and **stages** it as a proposal (the agent surface ends there — no run key or token
263
+ comes back), and a `POST /app/api/actions/delivery-graph/dispatch` the **cockpit** uses to
264
+ dispatch a staged proposal by its content `digest` (idempotent, at-most-once). There is no
265
+ agent `start` door to replay — the human clicking Dispatch in the cockpit is the approval.
262
266
 
263
267
  The agent guide served at `GET /app/api/agent` documents the full vocabulary, both
264
268
  operation contracts, and a complete worked example — see §9 there, or point your coding
@@ -0,0 +1,209 @@
1
+ // End-to-end coverage that the delivery-graph compiler (ADR 0005) emits BPMN that is BOTH
2
+ // EXECUTABLE and RENDERABLE — the two disjoint validity axes of the one BPMN in this system that is
3
+ // generated at RUNTIME by raw string concatenation rather than authored (issue #451).
4
+ //
5
+ // The pure compiler tests (`deliveryGraphCompiler.test.ts`) assert only on the XML STRING SHAPE
6
+ // (`includes(...)`, regex counts). A string-shape assert proves the text LOOKS right; it does NOT
7
+ // prove it DEPLOYS — a mis-wired boundary event, a flow to a dropped element, a bad `ioMapping`, or a
8
+ // `jobType` typo yields BPMN that passes every `includes()` and still fails `engine.deploy(xml)` with
9
+ // a misleading "unknown target element" at the flow (the AGENTS.md "it parsed but didn't execute"
10
+ // drift class). So here we DEPLOY the compiled graph through the real in-process WASM engine
11
+ // (`@nanobpm/urban-testkit`) via the SAME S4 path the runner uses (`runDeliveryGraph`) and ADVANCE a
12
+ // live instance to a terminal state — the exact deploy → instance → user-tasks → complete → terminal
13
+ // path that otherwise only gets hand-verified against a live node.
14
+ //
15
+ // Renderability and executability are DISJOINT (a graph can lay out perfectly and still fail deploy,
16
+ // and vice-versa), so `di coverage` guards the visual axis independently: every emitted flow node
17
+ // carries a `bpmndi:BPMNShape` and every sequence flow a `bpmndi:BPMNEdge`, so a future node kind
18
+ // cannot silently ship without a diagram (AGENTS.md: "BPMN Models need DI for rendering").
19
+ import { test } from "node:test";
20
+ import { createWasmEngineClient } from "@nanobpm/urban-testkit";
21
+ import { assert, assertEquals } from "#test-assert";
22
+ import { DELIVERY_CONNECTOR_TASK_TYPE } from "./deliveryConnector.ts";
23
+ import { compileDeliveryGraph } from "./deliveryGraphCompiler.ts";
24
+ import { runDeliveryGraph } from "./deliveryRunner.ts";
25
+ import type { DeliveryGraph } from "../nano-generated/api-io.d.ts";
26
+
27
+ /** A graph exercising the full node-kind matrix: `agent` (a named `senior:*` job), `wait` (the
28
+ * `pr.readiness-probe` poll gate), `human` (a user task), and `connector` (the delivery-connector
29
+ * delegate). This is the ADR's motivating release runbook. */
30
+ const MATRIX_GRAPH: DeliveryGraph = {
31
+ name: "release runbook",
32
+ nodes: [
33
+ { id: "impl", kind: "agent", agent: { jobType: "senior:feature", prompt: "un-draft + merge #B" } },
34
+ { id: "watch", kind: "wait", wait: { kind: "pr", target: "owner/repo#42", match: { prState: "merged" } }, emits: [{ name: "mergedSha", type: "string" }] },
35
+ { id: "publish", kind: "human", human: { prompt: "run the manual OTP publish" }, emits: [{ name: "resolvedArtifact", type: "artifact" }] },
36
+ { id: "consume", kind: "connector", connector: { target: "npm:install", dedupeKey: "consume-1" } },
37
+ ],
38
+ edges: [
39
+ { from: "impl", to: "watch" },
40
+ { from: "watch.mergedSha", to: "publish" },
41
+ { from: "publish.resolvedArtifact", to: "consume" },
42
+ ],
43
+ };
44
+
45
+ /** The generic completion payload for a delivery user task. Satisfies BOTH the `human` node's output
46
+ * ioMapping (`value` → `humanEmitValue`, `resolvedArtifact` → `humanEmitArtifact`, `note` →
47
+ * `humanNote`) and the escalation task's generic form (`value` required, `note`). */
48
+ const HUMAN_PAYLOAD = { value: "done", note: "ok", resolvedArtifact: "@nanobpm/demo@1.0.0" };
49
+
50
+ /** Upper bound on drive rounds — a terminal graph settles in a handful; the cap turns a wiring bug
51
+ * (a node that never advances) into a loud failure instead of a hang. */
52
+ const MAX_ROUNDS = 16;
53
+
54
+ test("deploy+advance: a well-formed graph deploys through the real engine and every node kind advances to a COMPLETED instance", async () => {
55
+ const engine = await createWasmEngineClient();
56
+ try {
57
+ // Serve every service node's job so each node completes NORMALLY (no boundary timeout fires): the
58
+ // agent job, the readiness probe (return `ready: true` so the poll loop exits on its first pass),
59
+ // and the connector delegate.
60
+ await engine.registerWorker("senior:feature", async () => ({}));
61
+ await engine.registerWorker("pr.readiness-probe", async () => ({ ready: true, mergedSha: "deadbeefcafe" }));
62
+ await engine.registerWorker(DELIVERY_CONNECTOR_TASK_TYPE, async () => ({}));
63
+
64
+ const run = await runDeliveryGraph(engine, MATRIX_GRAPH);
65
+ assert(run.ok, `runDeliveryGraph failed: ${JSON.stringify(run)}`);
66
+ const key = run.handle.processInstanceKey;
67
+
68
+ // Drive to terminal: serve jobs (drain), then complete any parked human user task, repeat. No
69
+ // virtual-clock advance — the happy path stalls ONLY on the human node, never on a timer.
70
+ const humanTasks: string[] = [];
71
+ let state = "?";
72
+ for (let round = 0; round < MAX_ROUNDS; round++) {
73
+ await engine.drain();
74
+ const [pi] = await engine.searchProcessInstances({ processInstanceKeys: [key] });
75
+ assert(pi, `no process instance snapshot for ${key} — searchProcessInstances returned empty`);
76
+ state = pi.state ?? "?";
77
+ if (state === "COMPLETED" || state === "TERMINATED") break;
78
+ const open = await engine.searchUserTasks({ processInstanceKey: key, state: "CREATED" });
79
+ assert(open.length > 0, `instance is ${state} with no open user task — a service node never advanced`);
80
+ for (const t of open) {
81
+ humanTasks.push(t.elementId ?? "?");
82
+ await engine.completeUserTask(t.userTaskKey, HUMAN_PAYLOAD);
83
+ }
84
+ }
85
+
86
+ assertEquals(state, "COMPLETED", "the deployed delivery graph must run to a COMPLETED instance");
87
+ // The ONE stop on the happy path is the `publish` human node; its compiled user-task element id is
88
+ // `delivery-human-task__<element>`. Assert we actually surfaced (and completed) it — proof the
89
+ // human node's user task deployed and is completable, not just that the instance ended.
90
+ assertEquals(humanTasks.length, 1, `expected exactly one human user task, saw ${JSON.stringify(humanTasks)}`);
91
+ assert(
92
+ humanTasks[0].startsWith("delivery-human-task__") && !humanTasks[0].endsWith("__esc"),
93
+ `expected a human node task, saw ${humanTasks[0]}`,
94
+ );
95
+ } finally {
96
+ await engine.close();
97
+ }
98
+ });
99
+
100
+ test("deploy+advance: a stalled service node escalates on its node-timeout boundary onto a human-completable task that advances the instance to COMPLETED", async () => {
101
+ const engine = await createWasmEngineClient();
102
+ try {
103
+ // A minimal agent → human graph. We deliberately register NO `senior:feature` worker, so the agent
104
+ // node stalls and MUST escalate on its `=nodeTimeout` boundary timer — the exact path a stuck node
105
+ // takes on a live fleet (and the one hand-verified against merlin).
106
+ const graph: DeliveryGraph = {
107
+ name: "escalation graph",
108
+ nodes: [
109
+ { id: "impl", kind: "agent", agent: { jobType: "senior:feature", prompt: "do it" } },
110
+ { id: "signoff", kind: "human", human: { prompt: "sign off" } },
111
+ ],
112
+ edges: [{ from: "impl", to: "signoff" }],
113
+ };
114
+ // Short node timeout so the boundary fires within one virtual-clock advance; a long SLA so the
115
+ // human node's own escalation boundary never fires during the drive.
116
+ const run = await runDeliveryGraph(engine, graph, { nodeTimeout: "PT1M", escalationSlaTimeout: "PT1H" });
117
+ assert(run.ok, `runDeliveryGraph failed: ${JSON.stringify(run)}`);
118
+ const key = run.handle.processInstanceKey;
119
+
120
+ // The stalled agent has NOT escalated yet: no user task before the timeout.
121
+ await engine.drain();
122
+ let open = await engine.searchUserTasks({ processInstanceKey: key, state: "CREATED" });
123
+ assertEquals(open.length, 0, "the stalled agent must not surface a task before its node timeout");
124
+
125
+ // Fire the PT1M node-timeout boundary → the agent node escalates onto its `__esc` user task.
126
+ await engine.advanceTime(60_000);
127
+ open = await engine.searchUserTasks({ processInstanceKey: key, state: "CREATED" });
128
+ assertEquals(open.length, 1, "the node timeout must surface exactly one escalation user task");
129
+ assert(open[0].elementId?.endsWith("__esc"), `expected an __esc escalation task, saw ${open[0].elementId}`);
130
+
131
+ // Complete the escalation task → the agent node ends → the flow reaches the human node → complete
132
+ // that → terminal.
133
+ const completed: string[] = [];
134
+ let state = "?";
135
+ for (let round = 0; round < MAX_ROUNDS; round++) {
136
+ await engine.drain();
137
+ const [pi] = await engine.searchProcessInstances({ processInstanceKeys: [key] });
138
+ assert(pi, `no process instance snapshot for ${key} — searchProcessInstances returned empty`);
139
+ state = pi.state ?? "?";
140
+ if (state === "COMPLETED" || state === "TERMINATED") break;
141
+ const tasks = await engine.searchUserTasks({ processInstanceKey: key, state: "CREATED" });
142
+ assert(tasks.length > 0, `instance is ${state} with no open task after escalation — a node never advanced`);
143
+ for (const t of tasks) {
144
+ completed.push(t.elementId ?? "?");
145
+ await engine.completeUserTask(t.userTaskKey, HUMAN_PAYLOAD);
146
+ }
147
+ }
148
+
149
+ assertEquals(state, "COMPLETED", "completing the escalation + human task must run the graph to COMPLETED");
150
+ assert(
151
+ completed.some((id) => id.endsWith("__esc")),
152
+ `the escalation task must have been driven, saw ${JSON.stringify(completed)}`,
153
+ );
154
+ assert(
155
+ completed.some((id) => id.startsWith("delivery-human-task__") && !id.endsWith("__esc")),
156
+ `the downstream human task must have been driven, saw ${JSON.stringify(completed)}`,
157
+ );
158
+ } finally {
159
+ await engine.close();
160
+ }
161
+ });
162
+
163
+ /** Every BPMN flow-node tag that must carry a `bpmndi:BPMNShape` to be rendered by human tooling. */
164
+ const FLOW_NODE_TAGS = [
165
+ "startEvent",
166
+ "endEvent",
167
+ "task",
168
+ "serviceTask",
169
+ "userTask",
170
+ "subProcess",
171
+ "exclusiveGateway",
172
+ "parallelGateway",
173
+ "inclusiveGateway",
174
+ "boundaryEvent",
175
+ "intermediateCatchEvent",
176
+ "intermediateThrowEvent",
177
+ "callActivity",
178
+ ].join("|");
179
+
180
+ /** Extract every `id="…"` for the given opening-tag alternation from the BPMN source. */
181
+ function idsForTags(bpmn: string, tagAlternation: string): string[] {
182
+ const re = new RegExp(`<bpmn:(?:${tagAlternation})\\b[^>]*\\bid="([^"]+)"`, "g");
183
+ return [...bpmn.matchAll(re)].map((m) => m[1]);
184
+ }
185
+
186
+ test("di coverage: every compiled flow node carries a BPMNShape and every sequence flow a BPMNEdge", async () => {
187
+ const r = await compileDeliveryGraph(MATRIX_GRAPH);
188
+ assert(r.ok, `expected ok:true, got ${JSON.stringify(r)}`);
189
+ const bpmn = r.bpmn;
190
+
191
+ const flowNodeIds = idsForTags(bpmn, FLOW_NODE_TAGS);
192
+ assert(flowNodeIds.length > 0, "expected the compiled graph to contain flow nodes");
193
+ const shapeless = flowNodeIds.filter(
194
+ (id) => !new RegExp(`<bpmndi:BPMNShape[^>]*bpmnElement="${escapeRe(id)}"`).test(bpmn),
195
+ );
196
+ assertEquals(shapeless, [], `every flow node must have a BPMNShape; missing: ${JSON.stringify(shapeless)}`);
197
+
198
+ const sequenceFlowIds = idsForTags(bpmn, "sequenceFlow");
199
+ assert(sequenceFlowIds.length > 0, "expected the compiled graph to contain sequence flows");
200
+ const edgeless = sequenceFlowIds.filter(
201
+ (id) => !new RegExp(`<bpmndi:BPMNEdge[^>]*bpmnElement="${escapeRe(id)}"`).test(bpmn),
202
+ );
203
+ assertEquals(edgeless, [], `every sequence flow must have a BPMNEdge; missing: ${JSON.stringify(edgeless)}`);
204
+ });
205
+
206
+ /** Escape a BPMN element id for embedding in a RegExp (ids can contain `.` from fact-qualified names). */
207
+ function escapeRe(s: string): string {
208
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
209
+ }
@@ -0,0 +1,143 @@
1
+ // Unit coverage for `dispatchDeliveryGraphRun` (app/deliveryGraphDispatch.ts) — the retained
2
+ // delivery-graph DISPATCH core extracted out of the removed agent `start` door (ADR 0005 Decision 7,
3
+ // issue #460). It has NO approval gate: the authorization lives in the fact that only the cockpit
4
+ // dispatch seam reaches this code (never the agent surface). What it DOES keep is the durable
5
+ // at-most-once launch fence + idempotency short-circuit, so a double-dispatch never double-launches a
6
+ // graph's side effects. These tests drive it against an in-memory app/data/engine faithful to the run
7
+ // aggregate's PRIMARY KEY fence and the guarded raw UPDATE the claim issues.
8
+ import { test } from "node:test";
9
+ import { assert, assertEquals } from "#test-assert";
10
+ import type { AppApi } from "@nanobpm/urban";
11
+ import { dispatchDeliveryGraphRun } from "./deliveryGraphDispatch.ts";
12
+ import { noopLog } from "../test/log.ts";
13
+
14
+ function makeApp() {
15
+ const tables = new Map<string, Record<string, unknown>[]>();
16
+ const started: { processDefinitionId: string }[] = [];
17
+ const table = (name: string, key: string) => {
18
+ const rows =
19
+ tables.get(name) ??
20
+ (() => {
21
+ const fresh: Record<string, unknown>[] = [];
22
+ tables.set(name, fresh);
23
+ return fresh;
24
+ })();
25
+ return {
26
+ get: (k: unknown) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
27
+ find: (q: Record<string, unknown>) =>
28
+ Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
29
+ all: () => Promise.resolve([...rows]),
30
+ insert: (r: Record<string, unknown>) => {
31
+ if (rows.some((existing) => existing[key] === r[key])) {
32
+ return Promise.reject(new Error(`UNIQUE constraint failed: ${name}.${key}`));
33
+ }
34
+ rows.push(r);
35
+ return Promise.resolve(r);
36
+ },
37
+ update: (k: unknown, patch: Record<string, unknown>) => {
38
+ const row = rows.find((r) => r[key] === k);
39
+ if (row) Object.assign(row, patch);
40
+ return Promise.resolve(row);
41
+ },
42
+ delete: (k: unknown) => {
43
+ const i = rows.findIndex((r) => r[key] === k);
44
+ if (i >= 0) rows.splice(i, 1);
45
+ return Promise.resolve();
46
+ },
47
+ };
48
+ };
49
+ const app = {
50
+ data: {
51
+ table,
52
+ open: () => ({
53
+ exec: (sql: string, params: unknown[]) =>
54
+ Promise.resolve().then(() => {
55
+ const cols = [...sql.matchAll(/"(\w+)"\s*=\s*\?/g)].map((m) => m[1]);
56
+ const runKey = params[params.length - 1];
57
+ const rows = tables.get("delivery_graph_runs") ?? [];
58
+ const row = rows.find((r) => r["run_key"] === runKey);
59
+ if (row && row["status"] !== "running") {
60
+ for (let i = 0; i < cols.length - 1; i++) row[cols[i]] = params[i];
61
+ return { changed: 1 };
62
+ }
63
+ return { changed: 0 };
64
+ }),
65
+ }),
66
+ },
67
+ engine: {
68
+ deployResources: () => Promise.resolve([]),
69
+ createInstance: (req: { processDefinitionId: string }) => {
70
+ started.push(req);
71
+ return Promise.resolve({ processInstanceKey: "PI-1", processDefinitionId: req.processDefinitionId });
72
+ },
73
+ },
74
+ log: noopLog(),
75
+ } as unknown as AppApi;
76
+ return { app, started, runs: () => tables.get("delivery_graph_runs") ?? [] };
77
+ }
78
+
79
+ const HUMAN_ONLY = {
80
+ name: "manual gate",
81
+ nodes: [{ id: "ack", kind: "human", human: { prompt: "click done" } }],
82
+ };
83
+ const SIDE_EFFECTING = {
84
+ name: "release runbook",
85
+ nodes: [
86
+ { id: "open-b", kind: "agent", agent: { jobType: "senior:demo", prompt: "merge #B" } },
87
+ { id: "publish", kind: "human", human: { prompt: "run the manual OTP publish" } },
88
+ ],
89
+ edges: [{ from: "open-b", to: "publish" }],
90
+ };
91
+
92
+ test("dispatchDeliveryGraphRun: a human-only graph launches straight away (running), one engine instance", async () => {
93
+ const { app, started, runs } = makeApp();
94
+ const res = await dispatchDeliveryGraphRun(app, HUMAN_ONLY);
95
+ assertEquals(res.ok, true);
96
+ if (!res.ok) return;
97
+ assertEquals(res.status, "running");
98
+ assertEquals(res.alreadyRunning, false);
99
+ assertEquals(res.sideEffecting, false);
100
+ assertEquals(started.length, 1);
101
+ assertEquals(runs()[0].status, "running");
102
+ });
103
+
104
+ test("dispatchDeliveryGraphRun: a side-effecting graph dispatches with NO approval token — the operator seam IS the approval", async () => {
105
+ const { app, started, runs } = makeApp();
106
+ const res = await dispatchDeliveryGraphRun(app, SIDE_EFFECTING);
107
+ assertEquals(res.ok, true);
108
+ if (!res.ok) return;
109
+ assertEquals(res.status, "running");
110
+ assertEquals(res.sideEffecting, true);
111
+ assertEquals(started.length, 1);
112
+ assertEquals(runs()[0].status, "running");
113
+ });
114
+
115
+ test("dispatchDeliveryGraphRun: a re-dispatch of a still-running run short-circuits (alreadyRunning) — the side effect launches at most once", async () => {
116
+ const { app, started } = makeApp();
117
+ const first = await dispatchDeliveryGraphRun(app, SIDE_EFFECTING);
118
+ assert(first.ok);
119
+ const second = await dispatchDeliveryGraphRun(app, SIDE_EFFECTING);
120
+ assertEquals(second.ok, true);
121
+ if (!second.ok) return;
122
+ assertEquals(second.alreadyRunning, true);
123
+ assertEquals(started.length, 1); // never a second launch
124
+ });
125
+
126
+ test("dispatchDeliveryGraphRun: an explicit idempotency key forces a distinct run row", async () => {
127
+ const { app, started } = makeApp();
128
+ await dispatchDeliveryGraphRun(app, HUMAN_ONLY);
129
+ await dispatchDeliveryGraphRun(app, HUMAN_ONLY, { runKey: "second-run" });
130
+ assertEquals(started.length, 2);
131
+ });
132
+
133
+ test("dispatchDeliveryGraphRun: a malformed graph → ok:false with path-qualified errors, nothing launched", async () => {
134
+ const { app, started } = makeApp();
135
+ const res = await dispatchDeliveryGraphRun(app, { name: "empty", nodes: [] });
136
+ assertEquals(res.ok, false);
137
+ if (res.ok) return;
138
+ assert(Array.isArray(res.errors) && res.errors.length > 0);
139
+ for (const e of res.errors) {
140
+ assert(typeof e.path === "string" && typeof e.message === "string");
141
+ }
142
+ assertEquals(started.length, 0);
143
+ });
@@ -0,0 +1,168 @@
1
+ // app/deliveryGraphDispatch.ts — the delivery-graph DISPATCH core (ADR 0005 Decision 7, issue #460).
2
+ // This is the retained run-launch logic (compile → content-address → idempotent at-most-once claim →
3
+ // deploy+start the S4 runner → stamp the instance) extracted out of the removed agent `start` door so
4
+ // it is invoked ONLY from the cockpit dispatch action — never an agent-callable operation.
5
+ //
6
+ // The old `start` door gated dispatch on a REPLAYABLE `approvalToken` (the content digest handed back
7
+ // to the same caller), which let any holder of the API credential self-approve. Under issue #460 the
8
+ // human clicking Dispatch in the cockpit IS the approval — so there is NO approval gate here; the
9
+ // authorization lives in the fact that only the cockpit dispatch seam reaches this code, not the agent
10
+ // surface. What IS retained is the durable at-most-once launch fence (`claimRunForLaunch`) and the
11
+ // idempotency short-circuit, so a double-click or a re-dispatch never double-launches a graph's side
12
+ // effects.
13
+
14
+ import type { AppApi } from "@nanobpm/urban";
15
+ import type { DeliveryGraph } from "../nano-generated/api-io.d.ts";
16
+ import { validateDeliveryGraph } from "./deliveryGraph.ts";
17
+ import { compileDeliveryGraph } from "./deliveryGraphCompiler.ts";
18
+ import {
19
+ buildDeliveryGraphRunRow,
20
+ buildHumanLabels,
21
+ claimRunForLaunch,
22
+ computeRunKey,
23
+ DELIVERY_PHASE,
24
+ deliveryGraphRuns,
25
+ } from "./deliveryGraphRun.ts";
26
+ import { deliveryGraphDigest, runDeliveryGraph } from "./deliveryRunner.ts";
27
+
28
+ /** The outcome of a dispatch attempt — mirrors the retained run lifecycle. `ok:false` carries the
29
+ * path-qualified compile/validation errors (a staged graph should always recompile, but a corrupt
30
+ * stored graph is refused cleanly rather than throwing). */
31
+ export type DispatchDeliveryGraphResult =
32
+ | {
33
+ ok: true;
34
+ status: "running";
35
+ runKey: string;
36
+ digest: string;
37
+ sideEffecting: boolean;
38
+ alreadyRunning: boolean;
39
+ processInstanceKey?: string;
40
+ processDefinitionId?: string;
41
+ }
42
+ | { ok: false; errors: { path: string; message: string }[] };
43
+
44
+ /** Dispatch a delivery graph as a running engine-native process — the operator action. Re-validates
45
+ * and re-compiles the (already-staged) graph to derive its content digest + run-row shape, then
46
+ * launches it through the durable at-most-once fence. Idempotent: a re-dispatch of an already-running
47
+ * run short-circuits with `alreadyRunning` instead of double-launching. */
48
+ export async function dispatchDeliveryGraphRun(
49
+ app: Pick<AppApi, "data" | "engine" | "log">,
50
+ graph: unknown,
51
+ options: { runKey?: string | null; title?: string | null } = {},
52
+ ): Promise<DispatchDeliveryGraphResult> {
53
+ const validationErrors = validateDeliveryGraph(graph);
54
+ if (validationErrors.length > 0) {
55
+ return { ok: false, errors: validationErrors };
56
+ }
57
+ // The graph passed the semantic validator above, so it is safe to narrow to the typed contract.
58
+ // biome-ignore lint/plugin: validated external body narrowed to its contract after validateDeliveryGraph
59
+ const typedGraph = graph as DeliveryGraph;
60
+ const compiled = await compileDeliveryGraph(typedGraph);
61
+ if (!compiled.ok) {
62
+ return { ok: false, errors: compiled.errors };
63
+ }
64
+
65
+ const digest = deliveryGraphDigest(compiled.bpmn);
66
+ const runKey = computeRunKey(options.runKey, digest);
67
+ const sideEffecting = compiled.sideEffects.length > 0;
68
+ const explicitTitle = typeof options.title === "string" && options.title.trim() !== "" ? options.title.trim() : "";
69
+ const graphName = typeof typedGraph.name === "string" && typedGraph.name.trim() !== "" ? typedGraph.name.trim() : "";
70
+ const title = explicitTitle || graphName || runKey;
71
+ const runs = deliveryGraphRuns(app.data);
72
+
73
+ // Idempotency short-circuit — a re-dispatch onto a still-running run does NOT double-launch.
74
+ const existing = await runs.get(runKey);
75
+ if (existing && existing.status === "running") {
76
+ app.log.info("dispatch-delivery-graph short-circuit: already running", { runKey });
77
+ return {
78
+ ok: true,
79
+ status: "running",
80
+ runKey,
81
+ digest: existing.digest,
82
+ sideEffecting: existing.side_effecting === 1,
83
+ alreadyRunning: true,
84
+ processInstanceKey: existing.process_key ?? undefined,
85
+ processDefinitionId: existing.process_definition_id ?? undefined,
86
+ };
87
+ }
88
+
89
+ const rowBase = {
90
+ runKey,
91
+ digest,
92
+ sideEffecting,
93
+ nodeCount: compiled.resolved.nodes.length,
94
+ humanNodeCount: compiled.humanNodes.length,
95
+ sideEffectCount: compiled.sideEffects.length,
96
+ title,
97
+ humanLabels: buildHumanLabels(compiled),
98
+ createdAt: existing?.created_at,
99
+ };
100
+
101
+ // Claim the run durably BEFORE the side effect (the at-most-once dispatch fence). A losing racer
102
+ // never reaches `runDeliveryGraph` — it re-reads the winner's row and short-circuits.
103
+ const claim = buildDeliveryGraphRunRow({ ...rowBase, status: "running", phase: DELIVERY_PHASE.RUNNING, processKey: null });
104
+ const wonClaim = await claimRunForLaunch(app.data, Boolean(existing), claim);
105
+ if (!wonClaim) {
106
+ const won = await runs.get(runKey);
107
+ app.log.info("dispatch-delivery-graph short-circuit: launch claim raced a concurrent dispatch", { runKey });
108
+ return {
109
+ ok: true,
110
+ status: "running",
111
+ runKey,
112
+ digest: won?.digest ?? digest,
113
+ sideEffecting: won ? won.side_effecting === 1 : sideEffecting,
114
+ alreadyRunning: true,
115
+ processInstanceKey: won?.process_key ?? undefined,
116
+ processDefinitionId: won?.process_definition_id ?? undefined,
117
+ };
118
+ }
119
+ if (existing) {
120
+ const { run_key, created_at, ...patch } = claim;
121
+ await runs.update(runKey, patch);
122
+ }
123
+
124
+ // Launch — deploy + start the compiled definition. On ANY launch failure flip the claimed row to
125
+ // `failed` so no null-process_key `running` row is ever stranded.
126
+ const markClaimFailed = async () => {
127
+ const failed = buildDeliveryGraphRunRow({ ...rowBase, status: "failed", phase: DELIVERY_PHASE.FAILED, processKey: null });
128
+ const { run_key, created_at, ...patch } = failed;
129
+ await runs.update(runKey, patch);
130
+ };
131
+ let launched: Awaited<ReturnType<typeof runDeliveryGraph>>;
132
+ try {
133
+ launched = await runDeliveryGraph(app.engine, typedGraph, { runKey });
134
+ } catch (err) {
135
+ await markClaimFailed();
136
+ app.log.error("dispatch-delivery-graph launch threw", { runKey });
137
+ throw err;
138
+ }
139
+ if (!launched.ok) {
140
+ await markClaimFailed();
141
+ app.log.error("dispatch-delivery-graph launch failed", { runKey, count: launched.errors.length });
142
+ return { ok: false, errors: launched.errors };
143
+ }
144
+
145
+ // Stamp the started instance key onto the claimed row.
146
+ {
147
+ const running = buildDeliveryGraphRunRow({
148
+ ...rowBase,
149
+ status: "running",
150
+ phase: DELIVERY_PHASE.RUNNING,
151
+ processKey: launched.handle.processInstanceKey,
152
+ processDefinitionId: launched.handle.processDefinitionId,
153
+ });
154
+ const { run_key, created_at, ...patch } = running;
155
+ await runs.update(runKey, patch);
156
+ }
157
+ app.log.info("delivery graph dispatched", { runKey, processInstanceKey: launched.handle.processInstanceKey });
158
+ return {
159
+ ok: true,
160
+ status: "running",
161
+ runKey,
162
+ digest,
163
+ sideEffecting,
164
+ alreadyRunning: false,
165
+ processInstanceKey: launched.handle.processInstanceKey,
166
+ processDefinitionId: launched.handle.processDefinitionId,
167
+ };
168
+ }