@nanobpm/nano-workforce 0.115.0 → 0.117.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.
@@ -0,0 +1,145 @@
1
+ // End-to-end proof of the S5 DISPATCH DOOR (ADR 0005 Decision 7) driven through its REAL ingress — the
2
+ // `startDeliveryGraph` OpenAPI operation (`POST /app/api/actions/start/delivery-graph`), the one
3
+ // contract all three ingress paths (agent POST, raw REST, UI JSON-paste) share. Hermetic: deterministic
4
+ // virtual clock, no network. It proves the acceptance the slice hinges on:
5
+ //
6
+ // • APPROVAL GATE: a side-effecting graph submitted WITHOUT approval is refused + PARKED (400,
7
+ // awaiting-approval, no engine instance, no agent job fired) — and is VISIBLE in the cockpit's
8
+ // `delivery_graph_runs` aggregate so an operator can see it waiting.
9
+ // • DISPATCH: re-submitting the same graph WITH its content-addressed approval token dispatches — the
10
+ // graph deploys + runs engine-natively (the agent side effect fires), and the run's derived phase
11
+ // shows WHERE it is parked ("Parked on human node: …") via the same `pollDeliveryGraphPhase`
12
+ // projection the cockpit reads.
13
+ // • IDEMPOTENCY: a duplicate submit short-circuits (`alreadyRunning`) — the agent side effect fires
14
+ // exactly ONCE, never twice.
15
+ // • COMPLETION: when the instance ends, the poller reconciles the run to `done` (instanceTracking's
16
+ // onTerminated reconciles only TERMINATED, so this poller owns COMPLETED→done).
17
+ import { mkdtempSync, rmSync } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { join, resolve } from "node:path";
20
+ import { after, describe, test } from "node:test";
21
+ import assert from "node:assert/strict";
22
+ import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
23
+ import { deliveryGraphRuns } from "../app/deliveryGraphRun.ts";
24
+ import { pollDeliveryGraphPhase } from "../app/service.ts";
25
+ import type { DeliveryGraph } from "../nano-generated/api-io.d.ts";
26
+
27
+ const APP_ROOT = resolve(import.meta.dirname, "..");
28
+ const GITHUB_ENV: Record<string, string> = { NANO_PR_GITHUB_TRANSPORT: "token", GITHUB_TOKEN: "" };
29
+
30
+ interface StartResult {
31
+ ok: boolean;
32
+ status: string;
33
+ runKey: string;
34
+ digest: string;
35
+ sideEffecting: boolean;
36
+ alreadyRunning?: boolean;
37
+ processInstanceKey?: string;
38
+ approvalToken?: string;
39
+ }
40
+
41
+ // A side-effecting graph: an `agent` side effect gated ahead of a `human` stop. Approval is required
42
+ // (the agent + the human-facing merge/publish class of graphs Decision 7 protects).
43
+ const GRAPH: DeliveryGraph = {
44
+ name: "release runbook e2e",
45
+ nodes: [
46
+ { id: "open", kind: "agent", agent: { jobType: "senior:demo", prompt: "open + prep" } },
47
+ { id: "publish", kind: "human", human: { prompt: "run the manual OTP publish" } },
48
+ ],
49
+ edges: [{ from: "open", to: "publish" }],
50
+ };
51
+
52
+ describe("startDeliveryGraph dispatch door — submit → approve → dispatch, idempotent (S5)", () => {
53
+ const dirs: string[] = [];
54
+ const apps: TestApp[] = [];
55
+ after(async () => {
56
+ for (const app of apps) await app.stop?.();
57
+ for (const d of dirs) rmSync(d, { recursive: true, force: true });
58
+ });
59
+ const boot = async (): Promise<TestApp> => {
60
+ const d = mkdtempSync(join(tmpdir(), "nwf-delivery-start-e2e-"));
61
+ dirs.push(d);
62
+ const app = await bootTestApp(APP_ROOT, { env: { ...GITHUB_ENV, NANO_APP_DB_URL: `file:${join(d, "app.db")}` } });
63
+ apps.push(app);
64
+ return app;
65
+ };
66
+
67
+ test("a side-effecting graph is parked at approval, then dispatches once approved; a duplicate never double-launches", async () => {
68
+ const app = await boot();
69
+ assert.ok(app.api, "app declares an `api` binding");
70
+ const api = app.api;
71
+
72
+ let agentFired = 0;
73
+ await app.engine.registerWorker("senior:demo", async () => {
74
+ agentFired++;
75
+ return {};
76
+ });
77
+
78
+ // ── Approval gate: submit WITHOUT approval → refused + parked, nothing launched ────────────────
79
+ const parked = await api.call<StartResult>("startDeliveryGraph", { body: { graph: GRAPH } });
80
+ assert.equal(parked.status, 400, "an unapproved side-effecting graph is refused");
81
+ assert.equal(parked.body.status, "awaiting-approval");
82
+ assert.equal(parked.body.sideEffecting, true);
83
+ assert.ok(parked.body.approvalToken, "the response hands back the approval token to re-submit with");
84
+ await app.settle();
85
+ assert.equal(agentFired, 0, "a parked graph never dispatched its side effect");
86
+
87
+ // The parked run is durable + visible in the cockpit aggregate.
88
+ const runKey = parked.body.runKey;
89
+ const parkedRow = await deliveryGraphRuns(app.db).get(runKey);
90
+ assert.ok(parkedRow, "a delivery_graph_runs row exists for the parked graph");
91
+ assert.equal(parkedRow?.status, "awaiting-approval");
92
+ assert.equal(parkedRow?.process_key, null, "no engine instance while parked");
93
+
94
+ // ── Dispatch: re-submit WITH the token → deploys + runs engine-natively ───────────────────────
95
+ const token = parked.body.approvalToken;
96
+ const dispatched = await api.call<StartResult>("startDeliveryGraph", { body: { graph: GRAPH, approvalToken: token } });
97
+ assert.equal(dispatched.status, 202, "an approved graph dispatches");
98
+ assert.equal(dispatched.body.status, "running");
99
+ assert.equal(dispatched.body.alreadyRunning, false);
100
+ assert.ok(dispatched.body.processInstanceKey, "the run carries the started engine instance key");
101
+ await app.settle();
102
+ assert.equal(agentFired, 1, "the agent side effect fired exactly once");
103
+
104
+ // The run transitioned parked → running IN PLACE (one row, not a duplicate), carrying its instance.
105
+ const runningRows = await deliveryGraphRuns(app.db).find({ status: "running" });
106
+ assert.equal(runningRows.length, 1, "exactly one running run");
107
+ assert.equal(runningRows[0]?.run_key, runKey, "the SAME run row was approved, not a new one");
108
+ assert.equal(runningRows[0]?.process_key, dispatched.body.processInstanceKey);
109
+
110
+ // ── Cockpit phase: the poller derives WHERE the run is parked (the human node) ─────────────────
111
+ await pollDeliveryGraphPhase(app.db, app.engine);
112
+ const phased = await deliveryGraphRuns(app.db).get(runKey);
113
+ assert.equal(phased?.status, "running");
114
+ assert.match(String(phased?.phase), /^Parked on human node:/, `phase shows the parked human node, got ${phased?.phase}`);
115
+
116
+ // ── Idempotency: a duplicate submit short-circuits — the side effect never fires twice ────────
117
+ const dup = await api.call<StartResult>("startDeliveryGraph", { body: { graph: GRAPH, approvalToken: token } });
118
+ assert.equal(dup.status, 202);
119
+ assert.equal(dup.body.alreadyRunning, true, "the re-submit short-circuited the already-running run");
120
+ await app.settle();
121
+ assert.equal(agentFired, 1, "the agent side effect STILL fired only once (no double-launch)");
122
+
123
+ // ── Completion: complete the human stop → the instance ends → the poller reconciles to done ───
124
+ const open = await app.engine.searchUserTasks({ state: "CREATED" });
125
+ const human = open.find((t) => t.elementId?.startsWith("delivery-human-task__") && !t.elementId?.endsWith("__esc"));
126
+ assert.ok(human, `a human user task is open, got ${JSON.stringify(open.map((t) => t.elementId))}`);
127
+ await app.engine.completeUserTask(human.userTaskKey, { humanOutcome: "completed" });
128
+ await app.settle();
129
+ await pollDeliveryGraphPhase(app.db, app.engine);
130
+ const done = await deliveryGraphRuns(app.db).get(runKey);
131
+ assert.equal(done?.status, "done", "the completed instance reconciled the run to done");
132
+ assert.equal(done?.phase, "Completed");
133
+ });
134
+
135
+ test("a non-side-effecting (human-only) graph dispatches with NO approval", async () => {
136
+ const app = await boot();
137
+ assert.ok(app.api);
138
+ const graph: DeliveryGraph = { name: "manual gate", nodes: [{ id: "ack", kind: "human", human: { prompt: "click done" } }] };
139
+ const res = await app.api.call<StartResult>("startDeliveryGraph", { body: { graph } });
140
+ assert.equal(res.status, 202, "a graph with no side effects needs no approval");
141
+ assert.equal(res.body.status, "running");
142
+ assert.equal(res.body.sideEffecting, false);
143
+ assert.ok(res.body.processInstanceKey);
144
+ });
145
+ });
package/nano.app.json CHANGED
@@ -81,6 +81,20 @@
81
81
  }
82
82
  },
83
83
  "pollMs": 5000
84
+ },
85
+ {
86
+ "table": "delivery_graph_runs",
87
+ "keyField": "process_key",
88
+ "statusField": "status",
89
+ "activeStatuses": [
90
+ "running"
91
+ ],
92
+ "onTerminated": {
93
+ "set": {
94
+ "status": "failed"
95
+ }
96
+ },
97
+ "pollMs": 5000
84
98
  }
85
99
  ],
86
100
  "workers": [
@@ -214,7 +228,8 @@
214
228
  "senior:feature",
215
229
  "senior:trial-merge",
216
230
  "senior:retro",
217
- "senior:conformance"
231
+ "senior:conformance",
232
+ "senior:scope-classify"
218
233
  ],
219
234
  "surfaces": {
220
235
  "taskInbox": {
package/openapi.yaml CHANGED
@@ -1713,6 +1713,85 @@ components:
1713
1713
  items:
1714
1714
  $ref: "#/components/schemas/DeliveryCompileError"
1715
1715
  description: The path-qualified validation/compile failures (at least one).
1716
+ DeliveryGraphStart:
1717
+ description: >-
1718
+ The `startDeliveryGraph` request body (ADR 0005 slice S5) — the ONE gated dispatch door that
1719
+ turns an agent-authored `DeliveryGraph` into a RUNNING engine-native process. This is the OUTER
1720
+ action, distinct from S1's pure `compileDeliveryGraph`: there is deliberately no `dryRun` flag
1721
+ (Decision 5/7) — compile and start are separate operations. The door re-validates (S0), compiles
1722
+ (S1), and launches the S4 runner. Three ingress paths — an agent-ergonomic POST, a raw REST call,
1723
+ and a UI JSON-paste — all hit this ONE contract.
1724
+ type: object
1725
+ additionalProperties: false
1726
+ required:
1727
+ - graph
1728
+ properties:
1729
+ graph:
1730
+ $ref: "#/components/schemas/DeliveryGraph"
1731
+ approvalToken:
1732
+ type: string
1733
+ description: >-
1734
+ The approval OF the rendered preview (Decision 7). Because a delivery graph merges PRs and
1735
+ publishes packages, a graph with any SIDE-EFFECTING node (`agent`/`connector`) dispatches
1736
+ ONLY when the caller presents the graph's content-addressed approval token (returned as
1737
+ `approvalToken` on a prior unapproved submit's `awaiting-approval` response). A graph with
1738
+ no side effects (only `wait`/`human` nodes) needs none and dispatches straight away.
1739
+ idempotencyKey:
1740
+ type: string
1741
+ maxLength: 255
1742
+ description: >-
1743
+ OPTIONAL caller-supplied idempotency key. A re-POST with the SAME key (or, when omitted, the
1744
+ SAME graph — the default key is the graph's content digest) does NOT double-launch: an
1745
+ in-flight run short-circuits with `alreadyRunning: true`. Blank/whitespace is treated as
1746
+ absent.
1747
+ StartDeliveryGraphResult:
1748
+ description: >-
1749
+ The `startDeliveryGraph` outcome. `status` is the run's lifecycle position: `running` (the graph
1750
+ dispatched — or was already running, see `alreadyRunning`) or `awaiting-approval` (a
1751
+ side-effecting graph was submitted without a valid `approvalToken` — it is PARKED, visible in the
1752
+ cockpit, and refused pending approval; re-POST with the returned `approvalToken` to dispatch).
1753
+ type: object
1754
+ additionalProperties: false
1755
+ required:
1756
+ - ok
1757
+ - status
1758
+ - runKey
1759
+ - digest
1760
+ - sideEffecting
1761
+ properties:
1762
+ ok:
1763
+ type: boolean
1764
+ description: True when the graph dispatched (or was already running); false when parked at approval.
1765
+ status:
1766
+ type: string
1767
+ enum: [running, awaiting-approval]
1768
+ description: The run's lifecycle position after this call.
1769
+ runKey:
1770
+ type: string
1771
+ description: The idempotency key the run is stored under (the caller key, else the content digest).
1772
+ digest:
1773
+ type: string
1774
+ description: The graph's content digest — the content-address of the compiled definition.
1775
+ sideEffecting:
1776
+ type: boolean
1777
+ description: Whether the graph has any side-effecting (`agent`/`connector`) node — i.e. whether approval is required.
1778
+ alreadyRunning:
1779
+ type: boolean
1780
+ description: True when a re-submit short-circuited onto an already-running run (no second launch).
1781
+ processInstanceKey:
1782
+ type: string
1783
+ description: The started engine instance key. Present when `status` is `running`; absent while parked at approval.
1784
+ processDefinitionId:
1785
+ type: string
1786
+ description: The content-addressed deployed process id (`delivery-graph-<digest>`). Present when dispatched.
1787
+ approvalToken:
1788
+ type: string
1789
+ description: >-
1790
+ The token to present as `approvalToken` on a re-POST to dispatch this exact graph. Present
1791
+ when `status` is `awaiting-approval` (equals `digest`).
1792
+ message:
1793
+ type: string
1794
+ description: A human-readable summary of the outcome (e.g. the approval-required reason).
1716
1795
  FeatureStart:
1717
1796
  description: The start-feature request body — a SINGLE-issue feature run. Names the target issue
1718
1797
  by EXACTLY ONE of `issue` (an `owner/repo#123` reference) or `url` (a bare issue URL), plus a
@@ -2495,6 +2574,47 @@ paths:
2495
2574
  application/json:
2496
2575
  schema:
2497
2576
  $ref: "#/components/schemas/CompileDeliveryGraphErrors"
2577
+ /actions/start/delivery-graph:
2578
+ post:
2579
+ operationId: startDeliveryGraph
2580
+ summary: Validate + compile + DISPATCH a delivery graph as a running engine-native process (gated, idempotent). (ADR 0005 slice S5)
2581
+ description: >-
2582
+ The delivery-graph DISPATCH door (ADR 0005 Decision 7) — the single gated OUTER action that
2583
+ turns an agent-authored `DeliveryGraph` into a RUNNING engine-native process. Distinct from S1's
2584
+ pure `compileDeliveryGraph`: compile and start are SEPARATE operations (Decision 5/7), so there
2585
+ is deliberately no `dryRun` flag. The door re-validates the graph (`validateDeliveryGraph`, S0),
2586
+ compiles it (`compileDeliveryGraph`, S1), and LAUNCHES the S4 runner (`runDeliveryGraph`).
2587
+ Because these graphs merge PRs and publish packages, dispatch is GATED: a graph with any
2588
+ side-effecting (`agent`/`connector`) node must present the content-addressed `approvalToken` of
2589
+ its rendered preview, else it is PARKED at approval (a 400 carrying the token, plus a visible
2590
+ `awaiting-approval` run in the cockpit) rather than dispatched. Idempotent: a re-POST of the same
2591
+ graph (or the same `idempotencyKey`) short-circuits an already-running run instead of
2592
+ double-launching. Three ingress paths — an agent-ergonomic POST, a raw REST call, and a UI
2593
+ JSON-paste — all hit this ONE contract.
2594
+ requestBody:
2595
+ required: true
2596
+ content:
2597
+ application/json:
2598
+ schema:
2599
+ $ref: "#/components/schemas/DeliveryGraphStart"
2600
+ responses:
2601
+ "202":
2602
+ description: The graph dispatched (or a re-submit short-circuited an already-running run).
2603
+ content:
2604
+ application/json:
2605
+ schema:
2606
+ $ref: "#/components/schemas/StartDeliveryGraphResult"
2607
+ "400":
2608
+ description: >-
2609
+ The graph failed validation/compilation (path-qualified errors), OR a side-effecting graph
2610
+ was submitted without a valid approval token — it is refused and PARKED at approval (the
2611
+ body carries the `approvalToken` to re-submit with).
2612
+ content:
2613
+ application/json:
2614
+ schema:
2615
+ oneOf:
2616
+ - $ref: "#/components/schemas/CompileDeliveryGraphErrors"
2617
+ - $ref: "#/components/schemas/StartDeliveryGraphResult"
2498
2618
  /actions/start/feature:
2499
2619
  post:
2500
2620
  operationId: startFeature
@@ -0,0 +1,316 @@
1
+ // Integration coverage for the S5 DISPATCH door (ADR 0005 Decision 7) driven through the operation
2
+ // EDGE — `startDeliveryGraph` composing S0 validate → S1 compile → approval gate → S4 launch. The unit
3
+ // tests in app/deliveryGraphRun.test.ts prove the pure decision helpers in isolation; this file proves
4
+ // the COMPOSED behaviour at the door: each path maps to the correct HTTP status and the correct
5
+ // durable-run / launch effect. It runs the real delegate against an in-memory app/data/engine — no
6
+ // network, deterministic on a single run.
7
+ import { test } from "node:test";
8
+ import { assertEquals } from "#test-assert";
9
+ import type { AppApi } from "@nanobpm/urban";
10
+ import { noopLog } from "../test/log.ts";
11
+ import startDeliveryGraph from "./startDeliveryGraph.ts";
12
+
13
+ // ── in-memory app (data + engine) ────────────────────────────────────────────
14
+ // A generic table over an array (the DataLayer surface the run aggregate uses: get/find/insert/update)
15
+ // plus a fake engine recording each deploy + start so an accept path can assert exactly-once launch.
16
+ function makeApp(opts: { failCreate?: boolean } = {}) {
17
+ const tables = new Map<string, Record<string, unknown>[]>();
18
+ const started: { processDefinitionId: string; variables?: Record<string, unknown> }[] = [];
19
+ const deployed: unknown[][] = [];
20
+ const table = (name: string, key: string) => {
21
+ const rows = tables.get(name) ?? (() => {
22
+ const fresh: Record<string, unknown>[] = [];
23
+ tables.set(name, fresh);
24
+ return fresh;
25
+ })();
26
+ return {
27
+ get: (k: unknown) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
28
+ find: (q: Record<string, unknown>) =>
29
+ Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
30
+ insert: (r: Record<string, unknown>) => {
31
+ // Faithful to the durable table's PRIMARY KEY: a duplicate-key insert is rejected with the
32
+ // SQLite fence message `isUniqueConstraintFence` classifies, so the door's claim-before-launch
33
+ // fence is exercised the same way it is against the real store.
34
+ if (rows.some((existing) => existing[key] === r[key])) {
35
+ return Promise.reject(new Error(`UNIQUE constraint failed: ${name}.${key}`));
36
+ }
37
+ rows.push(r);
38
+ return Promise.resolve(r);
39
+ },
40
+ update: (k: unknown, patch: Record<string, unknown>) => {
41
+ const row = rows.find((r) => r[key] === k);
42
+ if (row) Object.assign(row, patch);
43
+ return Promise.resolve(row);
44
+ },
45
+ delete: (k: unknown) => {
46
+ const i = rows.findIndex((r) => r[key] === k);
47
+ if (i >= 0) rows.splice(i, 1);
48
+ return Promise.resolve();
49
+ },
50
+ };
51
+ };
52
+ const app = {
53
+ data: {
54
+ table,
55
+ // Faithful model of the guarded raw UPDATEs the door issues via the DataSource gateway, BOTH of
56
+ // which fence on `WHERE "run_key" = ? AND "status" <> 'running'`: the launch-claim compare-and-swap
57
+ // (`SET status=?,updated_at=?`) and the approval-park write (`SET process_key=?,…,updated_at=?`).
58
+ // Columns are parsed from the SQL so either statement is applied faithfully. Deferred to a microtask
59
+ // to model the real async DataSource — the guard-and-write is NOT visible synchronously at call
60
+ // time, so a concurrently-scheduled delegate can still read the row pre-flip and reach its OWN
61
+ // claim/park (the exact interleave that made the unfenced writes double-launch / clobber a claim).
62
+ // The single `status <> 'running'` guard then lets only the first writer win: a launched `running`
63
+ // row is never flipped back, and a losing claim matches zero rows (`changed: 0`).
64
+ open: () => ({
65
+ exec: (sql: string, params: unknown[]) =>
66
+ Promise.resolve().then(() => {
67
+ // `"col" = ?` matches every SET assignment plus the WHERE `"run_key" = ?` (the `<> 'running'`
68
+ // guard uses `<>`, not `=`, so it is excluded); the last param is therefore the run_key.
69
+ const cols = [...sql.matchAll(/"(\w+)"\s*=\s*\?/g)].map((m) => m[1]);
70
+ const runKey = params[params.length - 1];
71
+ const rows = tables.get("delivery_graph_runs") ?? [];
72
+ const row = rows.find((r) => r["run_key"] === runKey);
73
+ if (row && row["status"] !== "running") {
74
+ for (let i = 0; i < cols.length - 1; i++) row[cols[i]] = params[i];
75
+ return { changed: 1 };
76
+ }
77
+ return { changed: 0 };
78
+ }),
79
+ }),
80
+ },
81
+ engine: {
82
+ deployResources: (res: unknown[]) => {
83
+ deployed.push(res);
84
+ return Promise.resolve([]);
85
+ },
86
+ createInstance: (req: { processDefinitionId: string; variables?: Record<string, unknown> }) => {
87
+ started.push(req);
88
+ if (opts.failCreate) return Promise.reject(new Error("engine unavailable"));
89
+ return Promise.resolve({ processInstanceKey: "PI-1" });
90
+ },
91
+ },
92
+ log: noopLog(),
93
+ } as unknown as AppApi;
94
+ return { app, started, deployed, runs: () => tables.get("delivery_graph_runs") ?? [] };
95
+ }
96
+
97
+ function input(body: unknown) {
98
+ return {
99
+ req: { method: "POST", path: "/", query: new URLSearchParams(), headers: new Headers(), text: async () => "" } as never,
100
+ params: {},
101
+ query: {},
102
+ body,
103
+ };
104
+ }
105
+
106
+ // A SIDE-EFFECTING graph (an `agent` node → approval required) and a NON-side-effecting one
107
+ // (`human`-only → dispatches without approval).
108
+ const SIDE_EFFECTING = {
109
+ name: "release runbook",
110
+ nodes: [
111
+ { id: "open-b", kind: "agent", agent: { jobType: "senior:feature", prompt: "un-draft + merge #B" } },
112
+ { id: "publish", kind: "human", human: { prompt: "run the manual OTP publish" } },
113
+ ],
114
+ edges: [{ from: "open-b", to: "publish" }],
115
+ };
116
+ const HUMAN_ONLY = {
117
+ name: "manual gate",
118
+ nodes: [{ id: "ack", kind: "human", human: { prompt: "click done when the release is out" } }],
119
+ };
120
+
121
+ test("missing graph → 400, nothing launched", async () => {
122
+ const { app, started } = makeApp();
123
+ const res = (await startDeliveryGraph(input({}), app)) as { status: number; body: { ok: boolean } };
124
+ assertEquals(res.status, 400);
125
+ assertEquals(res.body.ok, false);
126
+ assertEquals(started.length, 0);
127
+ });
128
+
129
+ test("a malformed graph fails S0 validation → 400, nothing compiled or launched", async () => {
130
+ const { app, started } = makeApp();
131
+ // Duplicate node ids — a semantic error `validateDeliveryGraph` catches (shape alone is fine).
132
+ const dup = { nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" } }, { id: "a", kind: "agent", agent: { jobType: "j" } }] };
133
+ const res = (await startDeliveryGraph(input({ graph: dup }), app)) as { status: number; body: { ok: boolean; errors?: unknown[] } };
134
+ assertEquals(res.status, 400);
135
+ assertEquals(res.body.ok, false);
136
+ assertEquals(Array.isArray(res.body.errors), true);
137
+ assertEquals(started.length, 0);
138
+ });
139
+
140
+ test("a side-effecting graph WITHOUT approval is refused + PARKED at approval (400, awaiting-approval row, no launch)", async () => {
141
+ const { app, started, runs } = makeApp();
142
+ const res = (await startDeliveryGraph(input({ graph: SIDE_EFFECTING }), app)) as {
143
+ status: number;
144
+ body: { ok: boolean; status: string; approvalToken: string; sideEffecting: boolean };
145
+ };
146
+ assertEquals(res.status, 400);
147
+ assertEquals(res.body.ok, false);
148
+ assertEquals(res.body.status, "awaiting-approval");
149
+ assertEquals(res.body.sideEffecting, true);
150
+ assertEquals(typeof res.body.approvalToken, "string");
151
+ assertEquals(started.length, 0); // parked, never launched
152
+ // The parked run is durable + visible (cockpit reads this table).
153
+ assertEquals(runs().length, 1);
154
+ assertEquals(runs()[0]?.["status"], "awaiting-approval");
155
+ });
156
+
157
+ test("re-submitting the SAME side-effecting graph WITH its approval token dispatches (202, running, launched once)", async () => {
158
+ const { app, started, runs } = makeApp();
159
+ // First submit parks + hands back the token.
160
+ const parked = (await startDeliveryGraph(input({ graph: SIDE_EFFECTING }), app)) as { body: { approvalToken: string } };
161
+ const token = parked.body.approvalToken;
162
+ // Second submit approves → dispatch. The SAME run row transitions parked → running (not a new row).
163
+ const res = (await startDeliveryGraph(input({ graph: SIDE_EFFECTING, approvalToken: token }), app)) as {
164
+ status: number;
165
+ body: { ok: boolean; status: string; processInstanceKey?: string };
166
+ };
167
+ assertEquals(res.status, 202);
168
+ assertEquals(res.body.ok, true);
169
+ assertEquals(res.body.status, "running");
170
+ assertEquals(res.body.processInstanceKey, "PI-1");
171
+ assertEquals(started.length, 1);
172
+ assertEquals(runs().length, 1); // still ONE row — approval updated it, did not duplicate
173
+ assertEquals(runs()[0]?.["status"], "running");
174
+ });
175
+
176
+ test("a non-side-effecting (human-only) graph dispatches WITHOUT approval (202, running)", async () => {
177
+ const { app, started } = makeApp();
178
+ const res = (await startDeliveryGraph(input({ graph: HUMAN_ONLY }), app)) as {
179
+ status: number;
180
+ body: { ok: boolean; status: string; sideEffecting: boolean };
181
+ };
182
+ assertEquals(res.status, 202);
183
+ assertEquals(res.body.ok, true);
184
+ assertEquals(res.body.status, "running");
185
+ assertEquals(res.body.sideEffecting, false);
186
+ assertEquals(started.length, 1);
187
+ });
188
+
189
+ test("a duplicate submit of an already-running graph short-circuits — no second launch", async () => {
190
+ const { app, started } = makeApp();
191
+ await startDeliveryGraph(input({ graph: HUMAN_ONLY }), app); // launch #1
192
+ const res = (await startDeliveryGraph(input({ graph: HUMAN_ONLY }), app)) as {
193
+ status: number;
194
+ body: { alreadyRunning: boolean; status: string };
195
+ };
196
+ assertEquals(res.status, 202);
197
+ assertEquals(res.body.alreadyRunning, true);
198
+ assertEquals(res.body.status, "running");
199
+ assertEquals(started.length, 1); // still ONE launch — the re-POST did not double-launch
200
+ });
201
+
202
+ test("a caller idempotencyKey scopes the run — the same key short-circuits, a different key launches again", async () => {
203
+ const { app, started } = makeApp();
204
+ await startDeliveryGraph(input({ graph: HUMAN_ONLY, idempotencyKey: "run-1" }), app);
205
+ const same = (await startDeliveryGraph(input({ graph: HUMAN_ONLY, idempotencyKey: "run-1" }), app)) as { body: { alreadyRunning: boolean } };
206
+ assertEquals(same.body.alreadyRunning, true);
207
+ assertEquals(started.length, 1);
208
+ const other = (await startDeliveryGraph(input({ graph: HUMAN_ONLY, idempotencyKey: "run-2" }), app)) as { body: { status: string; runKey: string } };
209
+ assertEquals(other.body.status, "running");
210
+ assertEquals(other.body.runKey, "run-2");
211
+ assertEquals(started.length, 2); // a distinct key is a distinct run
212
+ });
213
+
214
+ test("two SIMULTANEOUS submits of the same graph launch it exactly ONCE — the loser hits the run_key fence and short-circuits, no double side effect", async () => {
215
+ const { app, started, runs } = makeApp();
216
+ // Fire both before awaiting either: both read `existing === null`, then race to claim the run_key.
217
+ // The claim-before-launch fence means the loser's insert collides on the PK and it NEVER launches.
218
+ const [a, b] = (await Promise.all([
219
+ startDeliveryGraph(input({ graph: HUMAN_ONLY }), app),
220
+ startDeliveryGraph(input({ graph: HUMAN_ONLY }), app),
221
+ ])) as { status: number; body: { ok: boolean; status: string; alreadyRunning: boolean } }[];
222
+ assertEquals(a.status, 202);
223
+ assertEquals(b.status, 202);
224
+ assertEquals(a.body.ok, true);
225
+ assertEquals(b.body.ok, true);
226
+ // Exactly ONE launch and ONE durable row — no double-dispatch of side effects, no duplicate row.
227
+ assertEquals(started.length, 1);
228
+ assertEquals(runs().length, 1);
229
+ assertEquals(runs()[0]?.["status"], "running");
230
+ // Exactly one racer is the short-circuited loser (alreadyRunning); the other is the fresh winner.
231
+ assertEquals([a, b].filter((r) => r.body.alreadyRunning === true).length, 1);
232
+ });
233
+
234
+ test("two SIMULTANEOUS APPROVED re-submits of an already-PARKED graph launch it exactly ONCE — the parked→running claim is a compare-and-swap, not an unfenced update", async () => {
235
+ const { app, started, runs } = makeApp();
236
+ // Park the side-effecting graph first (unapproved), then grab its approval token.
237
+ const parked = (await startDeliveryGraph(input({ graph: SIDE_EFFECTING }), app)) as { body: { approvalToken: string } };
238
+ const token = parked.body.approvalToken;
239
+ assertEquals(runs()[0]?.["status"], "awaiting-approval");
240
+ // Fire two APPROVED submits before awaiting either: both read `existing` as the SAME parked row.
241
+ // Without a fence on the parked→running transition both would `update` then both launch. The
242
+ // compare-and-swap (`WHERE status <> 'running'`) lets exactly one flip the row and launch.
243
+ const [a, b] = (await Promise.all([
244
+ startDeliveryGraph(input({ graph: SIDE_EFFECTING, approvalToken: token }), app),
245
+ startDeliveryGraph(input({ graph: SIDE_EFFECTING, approvalToken: token }), app),
246
+ ])) as { status: number; body: { ok: boolean; status: string; alreadyRunning: boolean } }[];
247
+ assertEquals(a.status, 202);
248
+ assertEquals(b.status, 202);
249
+ // Exactly ONE launch of the side-effecting graph and still ONE durable row (no double-dispatch).
250
+ assertEquals(started.length, 1);
251
+ assertEquals(runs().length, 1);
252
+ assertEquals(runs()[0]?.["status"], "running");
253
+ // Exactly one racer is the short-circuited loser (alreadyRunning); the other is the fresh winner.
254
+ assertEquals([a, b].filter((r) => r.body.alreadyRunning === true).length, 1);
255
+ });
256
+
257
+ test("an APPROVED launch racing a concurrent UNAPPROVED re-submit of an already-PARKED graph is NOT clobbered — the park write is fenced `WHERE status <> 'running'`, so the launched claim (and its process_key) survives", async () => {
258
+ const { app, started, runs } = makeApp();
259
+ // Park the side-effecting graph first (unapproved) + grab its token — both racers read THIS row.
260
+ const parked = (await startDeliveryGraph(input({ graph: SIDE_EFFECTING }), app)) as { body: { approvalToken: string } };
261
+ const token = parked.body.approvalToken;
262
+ assertEquals(runs()[0]?.["status"], "awaiting-approval");
263
+ // Fire an APPROVED submit (which claims → running → launches) SIMULTANEOUSLY with another UNAPPROVED
264
+ // submit (which re-parks). Both read `existing` as the parked row. A blind park `update` would flip
265
+ // the launched `running` claim back to `awaiting-approval` and null its process_key — breaking the
266
+ // at-most-once fence. The guarded park write refuses to touch a `running` row instead.
267
+ const [approved, unapproved] = (await Promise.all([
268
+ startDeliveryGraph(input({ graph: SIDE_EFFECTING, approvalToken: token }), app),
269
+ startDeliveryGraph(input({ graph: SIDE_EFFECTING }), app),
270
+ ])) as { status: number; body: { status: string } }[];
271
+ assertEquals(approved.status, 202);
272
+ assertEquals(approved.body.status, "running"); // the approved submit dispatched
273
+ assertEquals(unapproved.status, 400); // the unapproved submit is refused (needs approval)
274
+ // Exactly ONE launch and ONE durable row, still `running` with its instance key — NOT clobbered.
275
+ assertEquals(started.length, 1);
276
+ assertEquals(runs().length, 1);
277
+ assertEquals(runs()[0]?.["status"], "running");
278
+ assertEquals(runs()[0]?.["process_key"], "PI-1");
279
+ });
280
+
281
+ test("a launch failure rolls the claimed run to `failed` — no stranded null-process_key `running` row", async () => {
282
+ const { app, started, runs } = makeApp({ failCreate: true });
283
+ let threw = false;
284
+ try {
285
+ await startDeliveryGraph(input({ graph: HUMAN_ONLY }), app);
286
+ } catch {
287
+ threw = true; // a thrown engine error propagates (framework maps it to a 500) — but only after rollback
288
+ }
289
+ assertEquals(threw, true);
290
+ assertEquals(started.length, 1); // the launch was attempted once
291
+ // The claim was written, then rolled back to a TERMINAL `failed` — the reconciler/poller skip null-
292
+ // key rows, so leaving it `running` would strand it forever; `failed` lets it drop out cleanly.
293
+ assertEquals(runs().length, 1);
294
+ assertEquals(runs()[0]?.["status"], "failed");
295
+ assertEquals(runs()[0]?.["process_key"], null);
296
+ });
297
+
298
+ test("a reused idempotencyKey short-circuits with the RUNNING run's persisted digest/sideEffecting — not the new submission's", async () => {
299
+ const { app, started } = makeApp();
300
+ // Launch a human-only (non-side-effecting) run under an explicit key.
301
+ const first = (await startDeliveryGraph(input({ graph: HUMAN_ONLY, idempotencyKey: "shared" }), app)) as {
302
+ body: { digest: string; sideEffecting: boolean };
303
+ };
304
+ assertEquals(first.body.sideEffecting, false);
305
+ // Re-POST the SAME key with a DIFFERENT (side-effecting) graph. The response must describe the run
306
+ // that is actually running — the human-only one — not this mismatched submission.
307
+ const second = (await startDeliveryGraph(input({ graph: SIDE_EFFECTING, idempotencyKey: "shared" }), app)) as {
308
+ status: number;
309
+ body: { alreadyRunning: boolean; digest: string; sideEffecting: boolean };
310
+ };
311
+ assertEquals(second.status, 202);
312
+ assertEquals(second.body.alreadyRunning, true);
313
+ assertEquals(second.body.sideEffecting, false); // the RUNNING run's value, not the side-effecting resubmit's
314
+ assertEquals(second.body.digest, first.body.digest);
315
+ assertEquals(started.length, 1); // still one launch
316
+ });