@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,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
|
-
});
|