@nanobpm/nano-workforce 0.123.2 → 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.
- package/CHANGELOG.md +13 -0
- package/README.md +9 -5
- package/app/deliveryGraphDispatch.test.ts +143 -0
- package/app/deliveryGraphDispatch.ts +168 -0
- package/app/deliveryGraphProposals.test.ts +267 -0
- package/app/deliveryGraphProposals.ts +269 -0
- package/app/deliveryGraphRun.test.ts +6 -52
- package/app/deliveryGraphRun.ts +21 -76
- package/app/deliveryGraphText.ts +3 -3
- package/app/deliveryRunner.ts +4 -3
- package/app/service.ts +15 -0
- package/db/migrations/075_delivery_graph_proposals.sql +48 -0
- package/docs/adr/0005-agent-authored-delivery-graphs.md +18 -0
- package/docs/adr/0006-delivery-units-one-representation.md +221 -0
- package/docs/agent-guide.md +50 -58
- package/e2e/delivery-graph-dispatch.e2e.ts +155 -0
- package/openapi.yaml +118 -161
- package/operations/compileDeliveryGraph.test.ts +100 -37
- package/operations/compileDeliveryGraph.ts +64 -18
- package/operations/dispatchDeliveryGraph.test.ts +171 -152
- package/operations/dispatchDeliveryGraph.ts +79 -99
- package/operations/getAgentInstructions.test.ts +10 -6
- package/operations/previewDeliveryGraph.test.ts +90 -51
- package/operations/previewDeliveryGraph.ts +45 -18
- package/package.json +1 -1
- package/pages/cockpit/mount.js +19 -12
- package/pages/delivery-graphs/mount.js +37 -137
- package/pages/delivery-graphs.page.json +50 -3
- package/scripts/check-migrations.test.ts +9 -0
- package/scripts/check-migrations.ts +11 -1
- package/test/cockpit-embed-endpoints.test.ts +59 -36
- package/test/delivery-graphs-embed.test.ts +36 -34
- package/e2e/delivery-graph-start.e2e.ts +0 -145
- package/operations/startDeliveryGraph.integration.test.ts +0 -316
- package/operations/startDeliveryGraph.ts +0 -222
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
// Contract guard for the Delivery Graphs compose → preview →
|
|
1
|
+
// Contract guard for the Delivery Graphs compose → preview → STAGE App View (issues #441 + #460).
|
|
2
2
|
//
|
|
3
3
|
// The rich compile preview (mermaid diagram + humanNodes[] stop-points + sideEffects[] + inline
|
|
4
|
-
// path-qualified errors)
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
4
|
+
// path-qualified errors) is surfaced by an `appView` embed (pages/delivery-graphs/) over the EXISTING
|
|
5
|
+
// previewDeliveryGraph door — a bare `actionForm` discards its response and so can render none of that.
|
|
6
|
+
// Dispatch is deliberately NOT in this view (issue #460): it is an OPERATOR row-action on the Staged
|
|
7
|
+
// proposals grid on the same page. This test pins the wiring so it can't silently regress: the
|
|
8
|
+
// sidecars exist, mount.js hits the preview door with a base-relative default (the #279 App-View
|
|
9
|
+
// resolution class — a leading-slash path 404s through the console iframe), it renders each preview
|
|
10
|
+
// facet, and it exposes NO dispatch/approval affordance (the self-approval hole #460 closes).
|
|
10
11
|
import { test } from "node:test";
|
|
11
12
|
import { assert } from "#test-assert";
|
|
12
13
|
import { readFileSync } from "node:fs";
|
|
@@ -16,6 +17,7 @@ const DIR = `${ROOT}pages/delivery-graphs`;
|
|
|
16
17
|
const MOUNT_JS = readFileSync(`${DIR}/mount.js`, "utf8");
|
|
17
18
|
const EMBED_HTML = readFileSync(`${DIR}/embed.html`, "utf8");
|
|
18
19
|
const STANDALONE_HTML = readFileSync(`${DIR}/standalone.html`, "utf8");
|
|
20
|
+
const PAGE_JSON = readFileSync(`${ROOT}pages/delivery-graphs.page.json`, "utf8");
|
|
19
21
|
|
|
20
22
|
// Pull the string default out of `const <name> = config.<name> ?? "<default>";` (or a module const).
|
|
21
23
|
function defaultUrl(name: string): string {
|
|
@@ -34,27 +36,22 @@ test("#441: the delivery-graphs App View mounts the same module standalone and e
|
|
|
34
36
|
}
|
|
35
37
|
});
|
|
36
38
|
|
|
37
|
-
test("#441: mount.js wires
|
|
39
|
+
test("#441: mount.js wires the preview+stage door", () => {
|
|
38
40
|
const previewUrl = defaultUrl("previewUrl");
|
|
39
|
-
const dispatchUrl = defaultUrl("dispatchUrl");
|
|
40
41
|
assert(previewUrl.endsWith("actions/delivery-graph/preview"), `previewUrl default "${previewUrl}" must hit the previewDeliveryGraph door`);
|
|
41
|
-
assert(dispatchUrl.endsWith("actions/delivery-graph/dispatch"), `dispatchUrl default "${dispatchUrl}" must hit the dispatchDeliveryGraph door`);
|
|
42
42
|
});
|
|
43
43
|
|
|
44
44
|
// The #279 App-View resolution class: a default endpoint must be BASE-RELATIVE (no leading slash) so
|
|
45
45
|
// it resolves under the console app-view base, not the console origin root (which 404s the door).
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
});
|
|
51
|
-
}
|
|
46
|
+
test("#441/#279: default previewUrl is base-relative (no leading slash)", () => {
|
|
47
|
+
const def = defaultUrl("previewUrl");
|
|
48
|
+
assert(!def.startsWith("/"), `default previewUrl "${def}" must not start with "/" — a leading-slash path resolves against the console iframe ORIGIN, not the app-view base, so the door 404s`);
|
|
49
|
+
});
|
|
52
50
|
|
|
53
51
|
test("#441: the preview render consumes every compile facet the door returns", () => {
|
|
54
|
-
// The whole point of
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
// fails this guard.
|
|
52
|
+
// The whole point of #441: the preview data (diagram / humanNodes / sideEffects / errors) is rich
|
|
53
|
+
// but was consumed by nothing. Assert the renderer touches each facet at a CONCRETE call site (not a
|
|
54
|
+
// bare word, which a comment/string could satisfy) so a renderer that stops reading a field fails.
|
|
58
55
|
const facetUse: Record<string, RegExp> = {
|
|
59
56
|
diagram: /esc\(result\.diagram\)/,
|
|
60
57
|
humanNodes: /renderHumanNodes\(result\.humanNodes\)/,
|
|
@@ -66,20 +63,25 @@ test("#441: the preview render consumes every compile facet the door returns", (
|
|
|
66
63
|
}
|
|
67
64
|
});
|
|
68
65
|
|
|
69
|
-
test("#
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
assert(
|
|
66
|
+
test("#460: the compose view exposes NO dispatch or approval affordance — it only previews + stages", () => {
|
|
67
|
+
// Issue #460 removes the agent-reachable dispatch door. The compose view must not smuggle it back:
|
|
68
|
+
// no dispatch door wiring, no approval two-step, no replayable approvalToken. Dispatch is the
|
|
69
|
+
// operator's Staged-proposals row-action instead.
|
|
70
|
+
assert(!/dispatchUrl/.test(MOUNT_JS), "mount.js must NOT wire a dispatch door (dispatch is an operator row-action, issue #460)");
|
|
71
|
+
assert(!/delivery-graph\/dispatch/.test(MOUNT_JS), "mount.js must NOT post to the dispatch door");
|
|
72
|
+
assert(!/awaiting-approval/.test(MOUNT_JS), "mount.js must NOT implement the removed awaiting-approval two-step");
|
|
73
|
+
assert(!/approvalToken/.test(MOUNT_JS), "mount.js must NOT carry the removed replayable approvalToken");
|
|
74
74
|
});
|
|
75
75
|
|
|
76
|
-
test("#
|
|
77
|
-
// The
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
assert(
|
|
83
|
-
|
|
84
|
-
assert(
|
|
76
|
+
test("#460: dispatch is the operator's Staged-proposals row-action on the page", () => {
|
|
77
|
+
// The page (not mount.js) offers dispatch: a Staged proposals grid over delivery_graph_proposals
|
|
78
|
+
// with a Dispatch row-action that posts the proposal's digest to the operator dispatch door.
|
|
79
|
+
const page = JSON.parse(PAGE_JSON) as { nodes: Array<Record<string, any>> };
|
|
80
|
+
const staged = page.nodes.find((n) => n.id === "delivery-graphs-staged");
|
|
81
|
+
assert(staged, "the page must carry a Staged proposals grid");
|
|
82
|
+
assert(staged?.props?.data?.table === "delivery_graph_proposals", "the staged grid binds to delivery_graph_proposals");
|
|
83
|
+
const dispatch = (staged?.props?.rowActions ?? []).find((a: any) => a.label === "Dispatch");
|
|
84
|
+
assert(dispatch, "the staged grid must expose a Dispatch row-action");
|
|
85
|
+
assert(dispatch.action.path.endsWith("actions/delivery-graph/dispatch"), "Dispatch posts to the operator dispatch door");
|
|
86
|
+
assert(dispatch.action.body.digest === "{{row.digest}}", "Dispatch posts the proposal's digest");
|
|
85
87
|
});
|
|
@@ -1,145 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,316 +0,0 @@
|
|
|
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
|
-
});
|