@nanobpm/nano-workforce 0.116.0 → 0.118.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 +38 -0
- package/app/deliveryGraphRun.test.ts +249 -0
- package/app/deliveryGraphRun.ts +323 -0
- package/app/deliveryGraphText.ts +41 -0
- package/app/deliveryRunner.ts +9 -1
- package/app/instance-tracking.test.ts +32 -0
- package/app/service.ts +39 -0
- package/db/migrations/058_delivery_graph_runs.sql +58 -0
- package/docs/agent-guide.md +173 -0
- package/e2e/delivery-graph-start.e2e.ts +145 -0
- package/nano.app.json +14 -0
- package/openapi.yaml +286 -0
- package/operations/dispatchDeliveryGraph.test.ts +166 -0
- package/operations/dispatchDeliveryGraph.ts +113 -0
- package/operations/getAgentInstructions.test.ts +22 -0
- package/operations/previewDeliveryGraph.test.ts +74 -0
- package/operations/previewDeliveryGraph.ts +59 -0
- package/operations/startDeliveryGraph.integration.test.ts +316 -0
- package/operations/startDeliveryGraph.ts +222 -0
- package/package.json +1 -1
- package/pages/_nav.json +1 -0
- package/pages/board.page.json +4 -0
- package/pages/cockpit.page.json +4 -0
- package/pages/delivery-graph-detail.page.json +114 -0
- package/pages/delivery-graphs.page.json +153 -0
- package/pages/epic-detail.page.json +4 -0
- package/pages/epic.page.json +4 -0
- package/pages/feature.page.json +4 -0
- package/pages/home.page.json +4 -0
- package/pages/lineage.page.json +4 -0
- package/pages/overview.page.json +39 -1
- package/pages/tasks.page.json +4 -0
- package/pages/velocity.page.json +4 -0
- package/scripts/pages-contract.test.ts +67 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// POST /app/api/actions/start/delivery-graph → operationId `startDeliveryGraph` (ADR 0005 slice S5,
|
|
2
|
+
// Decision 7). The ONE gated dispatch door for a delivery graph: it turns an agent-authored
|
|
3
|
+
// `DeliveryGraph` into a RUNNING engine-native process. Three ingress paths — an agent-ergonomic POST,
|
|
4
|
+
// a raw REST call, and a UI JSON-paste — all hit this ONE contract.
|
|
5
|
+
//
|
|
6
|
+
// This is the OUTER action, deliberately distinct from S1's pure `compileDeliveryGraph`: compile and
|
|
7
|
+
// start are SEPARATE operations (Decision 5/7), so there is NO `dryRun` flag here. The door composes
|
|
8
|
+
// the already-merged slices — it re-validates via S0 (`validateDeliveryGraph`), compiles via S1
|
|
9
|
+
// (`compileDeliveryGraph`), and launches via S4 (`runDeliveryGraph`) — and adds the two properties a
|
|
10
|
+
// DISPATCH (unlike a pure compile) must have:
|
|
11
|
+
//
|
|
12
|
+
// • APPROVAL (Decision 7). Because these graphs merge PRs and publish packages, a graph with any
|
|
13
|
+
// side-effecting node (`agent`/`connector`) dispatches ONLY when the caller presents the graph's
|
|
14
|
+
// content-addressed `approvalToken` (== the compiled `digest`) — an approval OF the rendered
|
|
15
|
+
// preview. A side-effecting graph submitted without it is REFUSED (400) and PARKED as an
|
|
16
|
+
// `awaiting-approval` run (visible in the cockpit), the response carrying the token to re-submit
|
|
17
|
+
// with. A graph with no side effects (only `wait`/`human`) needs no approval and dispatches.
|
|
18
|
+
// • IDEMPOTENCY. A run is keyed by `runKey` (a caller `idempotencyKey`, else the content `digest`);
|
|
19
|
+
// a re-POST of the same graph short-circuits an already-running run instead of double-launching —
|
|
20
|
+
// mirroring `startPlan`'s `alreadyRunning`.
|
|
21
|
+
|
|
22
|
+
import { validateDeliveryGraph } from "../app/deliveryGraph.ts";
|
|
23
|
+
import { compileDeliveryGraph } from "../app/deliveryGraphCompiler.ts";
|
|
24
|
+
import {
|
|
25
|
+
buildDeliveryGraphRunRow,
|
|
26
|
+
buildHumanLabels,
|
|
27
|
+
claimRunForLaunch,
|
|
28
|
+
computeRunKey,
|
|
29
|
+
DELIVERY_PHASE,
|
|
30
|
+
deliveryGraphRuns,
|
|
31
|
+
isDeliveryGraphApproved,
|
|
32
|
+
parkRunFencedAgainstLaunch,
|
|
33
|
+
} from "../app/deliveryGraphRun.ts";
|
|
34
|
+
import { deliveryGraphDigest, runDeliveryGraph } from "../app/deliveryRunner.ts";
|
|
35
|
+
import { defineOperation } from "../nano-generated/operations.ts";
|
|
36
|
+
|
|
37
|
+
export default defineOperation("startDeliveryGraph", async ({ body }, app) => {
|
|
38
|
+
// The runtime validates a well-formed body against openapi.yaml, but a directly-invoked delegate (or
|
|
39
|
+
// a missing body) leaves `body` undefined — guard so that becomes a 400, not a 500.
|
|
40
|
+
if (!body || typeof body !== "object" || !("graph" in body) || body.graph === null || typeof body.graph !== "object") {
|
|
41
|
+
app.log.warn("start-delivery-graph rejected: missing graph");
|
|
42
|
+
return { status: 400, body: { ok: false, errors: [{ path: "graph", message: "request body must carry a `graph`" }] } };
|
|
43
|
+
}
|
|
44
|
+
const graph = body.graph;
|
|
45
|
+
const approvalToken = "approvalToken" in body && typeof body.approvalToken === "string" ? body.approvalToken : null;
|
|
46
|
+
const idempotencyKey = "idempotencyKey" in body && typeof body.idempotencyKey === "string" ? body.idempotencyKey : null;
|
|
47
|
+
|
|
48
|
+
// 1) Re-validate via S0 (`validateDeliveryGraph`) for a clean 400 BEFORE compiling — the door
|
|
49
|
+
// re-checks even though the compiler validates internally, so a malformed graph is refused at the
|
|
50
|
+
// edge with path-qualified errors and nothing is compiled or launched.
|
|
51
|
+
const validationErrors = validateDeliveryGraph(graph);
|
|
52
|
+
if (validationErrors.length > 0) {
|
|
53
|
+
app.log.warn("start-delivery-graph rejected: validation", { count: validationErrors.length });
|
|
54
|
+
return { status: 400, body: { ok: false, errors: validationErrors } };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 2) Compile via S1. This yields the deterministic BPMN (→ the content digest / approval token) plus
|
|
58
|
+
// the graph's shape: its side effects (whether approval is required), human stops, and node count.
|
|
59
|
+
const compiled = compileDeliveryGraph(graph);
|
|
60
|
+
if (!compiled.ok) {
|
|
61
|
+
app.log.warn("start-delivery-graph rejected: compile", { count: compiled.errors.length });
|
|
62
|
+
return { status: 400, body: { ok: false, errors: compiled.errors } };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const digest = deliveryGraphDigest(compiled.bpmn);
|
|
66
|
+
const runKey = computeRunKey(idempotencyKey, digest);
|
|
67
|
+
const sideEffecting = compiled.sideEffects.length > 0;
|
|
68
|
+
const title = typeof graph.name === "string" && graph.name.trim() !== "" ? graph.name.trim() : runKey;
|
|
69
|
+
const runs = deliveryGraphRuns(app.data);
|
|
70
|
+
|
|
71
|
+
// 3) Idempotency short-circuit — a re-POST onto a run that is still in flight does NOT double-launch.
|
|
72
|
+
// Only a `running` run short-circuits (`running` is neither terminal nor a parked-gate status): it
|
|
73
|
+
// returns `alreadyRunning`. A terminal (`done`/`failed`/`abandoned`) run may re-run, and an
|
|
74
|
+
// `awaiting-approval` run falls through to the approval gate below (this POST may now carry the
|
|
75
|
+
// token). Mirrors `startPlan`.
|
|
76
|
+
const existing = await runs.get(runKey);
|
|
77
|
+
if (existing && existing.status === "running") {
|
|
78
|
+
app.log.info("start-delivery-graph short-circuit: already running", { runKey });
|
|
79
|
+
// Report the ACTUALLY-running run's persisted metadata, not this request's. If a caller reused the
|
|
80
|
+
// same `idempotencyKey` for a different graph, `digest`/`sideEffecting` derived from THIS submission
|
|
81
|
+
// would mislabel the run that is really in flight — echo the winner row instead.
|
|
82
|
+
return {
|
|
83
|
+
status: 202,
|
|
84
|
+
body: {
|
|
85
|
+
ok: true,
|
|
86
|
+
status: "running",
|
|
87
|
+
runKey,
|
|
88
|
+
digest: existing.digest,
|
|
89
|
+
sideEffecting: existing.side_effecting === 1,
|
|
90
|
+
alreadyRunning: true,
|
|
91
|
+
processInstanceKey: existing.process_key ?? undefined,
|
|
92
|
+
processDefinitionId: existing.process_definition_id ?? undefined,
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const rowBase = {
|
|
98
|
+
runKey,
|
|
99
|
+
digest,
|
|
100
|
+
sideEffecting,
|
|
101
|
+
nodeCount: compiled.resolved.nodes.length,
|
|
102
|
+
humanNodeCount: compiled.humanNodes.length,
|
|
103
|
+
sideEffectCount: compiled.sideEffects.length,
|
|
104
|
+
title,
|
|
105
|
+
humanLabels: buildHumanLabels(compiled),
|
|
106
|
+
createdAt: existing?.created_at,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// 4) Approval gate (Decision 7) — a side-effecting graph without a valid approval token is REFUSED
|
|
110
|
+
// (400) and PARKED as an `awaiting-approval` run so it is visible in the cockpit; the response
|
|
111
|
+
// carries the token to re-submit with. A non-side-effecting graph passes straight through.
|
|
112
|
+
if (!isDeliveryGraphApproved(sideEffecting, approvalToken, digest)) {
|
|
113
|
+
// Park through the launch fence — never overwrite a concurrently-launched `running` claim (a
|
|
114
|
+
// racing approved submit) back to `awaiting-approval`, which would break at-most-once dispatch.
|
|
115
|
+
await parkRunFencedAgainstLaunch(
|
|
116
|
+
app.data,
|
|
117
|
+
Boolean(existing),
|
|
118
|
+
buildDeliveryGraphRunRow({ ...rowBase, status: "awaiting-approval", phase: DELIVERY_PHASE.AWAITING_APPROVAL, processKey: null }),
|
|
119
|
+
);
|
|
120
|
+
app.log.info("start-delivery-graph parked: awaiting approval", { runKey, sideEffects: compiled.sideEffects.length });
|
|
121
|
+
return {
|
|
122
|
+
status: 400,
|
|
123
|
+
body: {
|
|
124
|
+
ok: false,
|
|
125
|
+
status: "awaiting-approval",
|
|
126
|
+
runKey,
|
|
127
|
+
digest,
|
|
128
|
+
sideEffecting,
|
|
129
|
+
approvalToken: digest,
|
|
130
|
+
message: `graph has ${compiled.sideEffects.length} side-effecting node(s); re-submit with approvalToken to dispatch`,
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 5) Claim the run durably BEFORE the side effect — mirrors `startPlan`, which writes the `plans`
|
|
136
|
+
// row before `engine.createInstance`. `claimRunForLaunch` makes the launch AT-MOST-ONCE under
|
|
137
|
+
// concurrent submits from EITHER starting state: a first launch is fenced by the `run_key` PK
|
|
138
|
+
// (a racing insert loses the unique constraint), and a relaunch off a persisted row (an approved
|
|
139
|
+
// parked row, or a re-run terminal row) is fenced by an atomic compare-and-swap that flips the
|
|
140
|
+
// row to `running` only if it is not already `running`. The loser never reaches `runDeliveryGraph`
|
|
141
|
+
// — it re-reads the winner's row and short-circuits as `alreadyRunning` instead of double-launching
|
|
142
|
+
// a graph's side effects. The claimed row carries no `process_key` yet — like a freshly-inserted
|
|
143
|
+
// `planning` plan it is a transient active row that `pollDeliveryGraphPhase` and the instanceTracking
|
|
144
|
+
// reconciler skip until the instance key lands (both ignore null-key rows).
|
|
145
|
+
const claim = buildDeliveryGraphRunRow({ ...rowBase, status: "running", phase: DELIVERY_PHASE.RUNNING, processKey: null });
|
|
146
|
+
const wonClaim = await claimRunForLaunch(app.data, Boolean(existing), claim);
|
|
147
|
+
if (!wonClaim) {
|
|
148
|
+
const won = await runs.get(runKey);
|
|
149
|
+
app.log.info("start-delivery-graph short-circuit: launch claim raced a concurrent submit", { runKey });
|
|
150
|
+
// Echo the winner row's persisted metadata (falling back to this request's only if the row
|
|
151
|
+
// somehow can't be re-read) so a reused idempotencyKey never reports the wrong run's digest.
|
|
152
|
+
return {
|
|
153
|
+
status: 202,
|
|
154
|
+
body: {
|
|
155
|
+
ok: true,
|
|
156
|
+
status: "running",
|
|
157
|
+
runKey,
|
|
158
|
+
digest: won?.digest ?? digest,
|
|
159
|
+
sideEffecting: won ? won.side_effecting === 1 : sideEffecting,
|
|
160
|
+
alreadyRunning: true,
|
|
161
|
+
processInstanceKey: won?.process_key ?? undefined,
|
|
162
|
+
processDefinitionId: won?.process_definition_id ?? undefined,
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
// Won the claim. For a relaunch off a persisted row the guarded CAS flipped only `status`; write the
|
|
167
|
+
// run's full metadata now — we are the sole caller past the fence, so this update cannot race.
|
|
168
|
+
if (existing) {
|
|
169
|
+
const { run_key, created_at, ...patch } = claim;
|
|
170
|
+
await runs.update(runKey, patch);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 6) Launch — deploy + start the compiled definition via the S4 runner. `runKey` scopes the run's
|
|
174
|
+
// wait-gate keys so two runs of the same graph never cross-correlate. On ANY launch failure (a
|
|
175
|
+
// thrown engine error OR the runner's `ok:false`) flip the claimed row to `failed` so no null-
|
|
176
|
+
// process_key `running` row is ever stranded — the reconciler and poller both skip null-key rows,
|
|
177
|
+
// so a stranded claim would otherwise never terminate — then surface the error.
|
|
178
|
+
const markClaimFailed = async () => {
|
|
179
|
+
const failed = buildDeliveryGraphRunRow({ ...rowBase, status: "failed", phase: DELIVERY_PHASE.FAILED, processKey: null });
|
|
180
|
+
const { run_key, created_at, ...patch } = failed;
|
|
181
|
+
await runs.update(runKey, patch);
|
|
182
|
+
};
|
|
183
|
+
let launched: Awaited<ReturnType<typeof runDeliveryGraph>>;
|
|
184
|
+
try {
|
|
185
|
+
launched = await runDeliveryGraph(app.engine, graph, { runKey });
|
|
186
|
+
} catch (err) {
|
|
187
|
+
await markClaimFailed();
|
|
188
|
+
app.log.error("start-delivery-graph launch threw", { runKey });
|
|
189
|
+
throw err;
|
|
190
|
+
}
|
|
191
|
+
if (!launched.ok) {
|
|
192
|
+
await markClaimFailed();
|
|
193
|
+
app.log.error("start-delivery-graph launch failed", { runKey, count: launched.errors.length });
|
|
194
|
+
return { status: 400, body: { ok: false, errors: launched.errors } };
|
|
195
|
+
}
|
|
196
|
+
// 7) Stamp the started instance key onto the claimed row.
|
|
197
|
+
{
|
|
198
|
+
const running = buildDeliveryGraphRunRow({
|
|
199
|
+
...rowBase,
|
|
200
|
+
status: "running",
|
|
201
|
+
phase: DELIVERY_PHASE.RUNNING,
|
|
202
|
+
processKey: launched.handle.processInstanceKey,
|
|
203
|
+
processDefinitionId: launched.handle.processDefinitionId,
|
|
204
|
+
});
|
|
205
|
+
const { run_key, created_at, ...patch } = running;
|
|
206
|
+
await runs.update(runKey, patch);
|
|
207
|
+
}
|
|
208
|
+
app.log.info("delivery graph dispatched", { runKey, processInstanceKey: launched.handle.processInstanceKey });
|
|
209
|
+
return {
|
|
210
|
+
status: 202,
|
|
211
|
+
body: {
|
|
212
|
+
ok: true,
|
|
213
|
+
status: "running",
|
|
214
|
+
runKey,
|
|
215
|
+
digest,
|
|
216
|
+
sideEffecting,
|
|
217
|
+
alreadyRunning: false,
|
|
218
|
+
processInstanceKey: launched.handle.processInstanceKey,
|
|
219
|
+
processDefinitionId: launched.handle.processDefinitionId,
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.118.0",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
package/pages/_nav.json
CHANGED
package/pages/board.page.json
CHANGED