@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.
Files changed (35) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +9 -5
  3. package/app/deliveryGraphDispatch.test.ts +143 -0
  4. package/app/deliveryGraphDispatch.ts +168 -0
  5. package/app/deliveryGraphProposals.test.ts +267 -0
  6. package/app/deliveryGraphProposals.ts +269 -0
  7. package/app/deliveryGraphRun.test.ts +6 -52
  8. package/app/deliveryGraphRun.ts +21 -76
  9. package/app/deliveryGraphText.ts +3 -3
  10. package/app/deliveryRunner.ts +4 -3
  11. package/app/service.ts +15 -0
  12. package/db/migrations/075_delivery_graph_proposals.sql +48 -0
  13. package/docs/adr/0005-agent-authored-delivery-graphs.md +18 -0
  14. package/docs/adr/0006-delivery-units-one-representation.md +221 -0
  15. package/docs/agent-guide.md +50 -58
  16. package/e2e/delivery-graph-dispatch.e2e.ts +155 -0
  17. package/openapi.yaml +118 -161
  18. package/operations/compileDeliveryGraph.test.ts +100 -37
  19. package/operations/compileDeliveryGraph.ts +64 -18
  20. package/operations/dispatchDeliveryGraph.test.ts +171 -152
  21. package/operations/dispatchDeliveryGraph.ts +79 -99
  22. package/operations/getAgentInstructions.test.ts +10 -6
  23. package/operations/previewDeliveryGraph.test.ts +90 -51
  24. package/operations/previewDeliveryGraph.ts +45 -18
  25. package/package.json +1 -1
  26. package/pages/cockpit/mount.js +19 -12
  27. package/pages/delivery-graphs/mount.js +37 -137
  28. package/pages/delivery-graphs.page.json +50 -3
  29. package/scripts/check-migrations.test.ts +9 -0
  30. package/scripts/check-migrations.ts +11 -1
  31. package/test/cockpit-embed-endpoints.test.ts +59 -36
  32. package/test/delivery-graphs-embed.test.ts +36 -34
  33. package/e2e/delivery-graph-start.e2e.ts +0 -145
  34. package/operations/startDeliveryGraph.integration.test.ts +0 -316
  35. package/operations/startDeliveryGraph.ts +0 -222
@@ -1,222 +0,0 @@
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 = await 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
- });