@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.
- package/.github/workflows/renovate.yml +72 -0
- package/CHANGELOG.md +14 -0
- package/README.md +17 -0
- package/app/convergeGate.test.ts +94 -84
- package/app/deliveryGraphRun.test.ts +249 -0
- package/app/deliveryGraphRun.ts +323 -0
- package/app/deliveryRunner.ts +9 -1
- package/app/instance-tracking.test.ts +32 -0
- package/app/persist-escalation.test.ts +0 -33
- package/app/service.ts +39 -0
- package/db/migrations/057_drop_escalation_head_override.sql +16 -0
- package/db/migrations/058_delivery_graph_runs.sql +58 -0
- package/e2e/convergence-escalation.e2e.ts +6 -0
- package/e2e/delivery-graph-start.e2e.ts +145 -0
- package/nano.app.json +16 -1
- package/openapi.yaml +120 -0
- package/operations/startDeliveryGraph.integration.test.ts +316 -0
- package/operations/startDeliveryGraph.ts +222 -0
- package/package.json +1 -1
- package/pages/overview.page.json +34 -0
- package/resources/processes/convergence-loop.bpmn +177 -88
- package/resources/prompts/feature.md +15 -13
- package/resources/prompts/scope-classify.md +142 -0
- package/test/derivation-parity/derivation-parity.test.ts +2 -2
- package/test/derivation-parity/flows.ts +1 -1
- package/workers/converge-gate/worker.ts +14 -157
- package/workers/persist-escalation/worker.ts +1 -8
- package/app/scopeGuard.test.ts +0 -185
- package/app/scopeGuard.ts +0 -165
- package/workers/converge-gate/worker.test.ts +0 -116
|
@@ -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
|
+
});
|