@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
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// app/deliveryGraphProposals.ts — the `staged` delivery-graph proposal AGGREGATE (ADR 0005 Decision 7,
|
|
2
|
+
// issue #460). The seam that makes dispatch OPERATOR-ONLY: the agent-facing compile door persists a
|
|
3
|
+
// valid compiled graph HERE as a `staged` proposal and hands the agent only a preview + a navigational
|
|
4
|
+
// `reviewUrl`. There is no run key, token, or dispatch handle in that response — nothing the agent can
|
|
5
|
+
// replay to start a run. A human previews the staged proposal in the cockpit and dispatches it.
|
|
6
|
+
//
|
|
7
|
+
// This dissolves the self-approval hole the old `approvalToken` left open: the token was a REPLAYABLE
|
|
8
|
+
// content digest returned to the same caller, so any holder of the API credential self-approved. By
|
|
9
|
+
// removing the dispatch affordance from the agent surface entirely (capability by absence, not by an
|
|
10
|
+
// auth check), there is nothing to replay — the boundary becomes self-documenting.
|
|
11
|
+
//
|
|
12
|
+
// The pure helpers (`proposalLogicalKey`, `proposalExpiry`, `isProposalExpired`, `buildProposalPreview`,
|
|
13
|
+
// `buildProposalRow`) are DB-free so they unit-test in isolation; `stageProposal` / `getStagedProposal`
|
|
14
|
+
// supply the I/O and the supersede-by-logical-key + TTL semantics.
|
|
15
|
+
|
|
16
|
+
import type { DataLayer } from "@nanobpm/urban";
|
|
17
|
+
import type {
|
|
18
|
+
CompileDeliveryGraphResult,
|
|
19
|
+
DeliveryHumanStop,
|
|
20
|
+
DeliverySideEffect,
|
|
21
|
+
} from "../nano-generated/api-io.d.ts";
|
|
22
|
+
import { publicBaseUrl } from "./blackboard.ts";
|
|
23
|
+
|
|
24
|
+
const now = () => new Date().toISOString();
|
|
25
|
+
|
|
26
|
+
/** The staged-proposal TTL (24h). A staged proposal an operator never dispatches ages out of the
|
|
27
|
+
* cockpit list at `created_at + TTL`, so the surface only ever shows live, dispatchable proposals. */
|
|
28
|
+
export const DELIVERY_PROPOSAL_TTL_MS = 24 * 60 * 60 * 1000;
|
|
29
|
+
|
|
30
|
+
/** The NAVIGATIONAL cockpit deep-link to a staged proposal — a pointer the agent can hand the human,
|
|
31
|
+
* NOT a dispatch handle. Points at the Delivery Graphs cockpit page (where the staged-proposals grid
|
|
32
|
+
* lives), fragment-scoped to the proposal's digest so the operator can find it. */
|
|
33
|
+
export function proposalReviewUrl(digest: string, base: string = publicBaseUrl()): string {
|
|
34
|
+
return `${base}/app/pages/delivery-graphs#proposal-${encodeURIComponent(digest)}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The proposal lifecycle. `staged` = awaiting operator review/dispatch; `superseded` = replaced by a
|
|
38
|
+
* newer digest for the same logical graph; `dispatched` = the operator launched it; `expired` = it aged
|
|
39
|
+
* out of its TTL before an operator dispatched it. `superseded`/`dispatched`/`expired` all drop out of
|
|
40
|
+
* the cockpit's staged list (which filters to `status = 'staged'`). */
|
|
41
|
+
export const DELIVERY_PROPOSAL_STATUSES = ["staged", "superseded", "dispatched", "expired"] as const;
|
|
42
|
+
export type DeliveryProposalStatus = typeof DELIVERY_PROPOSAL_STATUSES[number];
|
|
43
|
+
|
|
44
|
+
/** One staged delivery-graph proposal — the durable row keyed by content `digest`. `side_effecting`
|
|
45
|
+
* is a SQLite boolean (0/1); `graph`/`preview` are JSON text columns. */
|
|
46
|
+
export interface DeliveryGraphProposal {
|
|
47
|
+
digest: string;
|
|
48
|
+
logical_key: string;
|
|
49
|
+
title: string | null;
|
|
50
|
+
/** The original `DeliveryGraph` JSON — retained so the cockpit dispatch action can run the previewed
|
|
51
|
+
* digest without the agent re-submitting anything. */
|
|
52
|
+
graph: string;
|
|
53
|
+
/** The rendered preview JSON (`{ diagram, sideEffects, humanNodes }`), stamped at stage time so the
|
|
54
|
+
* cockpit list renders without recompiling. */
|
|
55
|
+
preview: string;
|
|
56
|
+
node_count: number;
|
|
57
|
+
human_node_count: number;
|
|
58
|
+
side_effect_count: number;
|
|
59
|
+
side_effecting: number;
|
|
60
|
+
status: DeliveryProposalStatus;
|
|
61
|
+
created_at: string;
|
|
62
|
+
updated_at: string;
|
|
63
|
+
expires_at: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The rendered preview a staged proposal carries — the operator-facing view of WHAT the graph does
|
|
67
|
+
* (its diagram, the side effects a dispatch authorises, and where it parks on a person). It carries no
|
|
68
|
+
* dispatch handle by construction. */
|
|
69
|
+
export interface DeliveryProposalPreview {
|
|
70
|
+
diagram: string;
|
|
71
|
+
sideEffects: DeliverySideEffect[];
|
|
72
|
+
humanNodes: DeliveryHumanStop[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The `delivery_graph_proposals` aggregate accessor — the durable staged-proposal store keyed by
|
|
76
|
+
* content `digest`. */
|
|
77
|
+
export const deliveryGraphProposals = (data: DataLayer) =>
|
|
78
|
+
data.table<DeliveryGraphProposal>("delivery_graph_proposals", "digest");
|
|
79
|
+
|
|
80
|
+
/** The LOGICAL graph identity used to supersede: the graph's `name` (trimmed, when non-blank), else
|
|
81
|
+
* the content `digest`. A re-compile of a CHANGED graph (new digest) with the SAME name replaces the
|
|
82
|
+
* prior staged proposal; an unnamed graph is its own logical key (it supersedes only an identical
|
|
83
|
+
* recompile of itself). */
|
|
84
|
+
export function proposalLogicalKey(name: string | undefined | null, digest: string): string {
|
|
85
|
+
const trimmed = typeof name === "string" ? name.trim() : "";
|
|
86
|
+
return trimmed || digest;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The TTL horizon for a proposal staged at `createdAt` — `createdAt + DELIVERY_PROPOSAL_TTL_MS`. */
|
|
90
|
+
export function proposalExpiry(createdAtIso: string): string {
|
|
91
|
+
const created = Date.parse(createdAtIso);
|
|
92
|
+
const base = Number.isNaN(created) ? Date.now() : created;
|
|
93
|
+
return new Date(base + DELIVERY_PROPOSAL_TTL_MS).toISOString();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Whether a staged proposal has aged out (its `expires_at` is at or before `at`). A corrupt/blank
|
|
97
|
+
* `expires_at` is treated as expired so a bad row can never linger undismissable in the cockpit. */
|
|
98
|
+
export function isProposalExpired(expiresAtIso: string | null | undefined, at: Date = new Date()): boolean {
|
|
99
|
+
if (typeof expiresAtIso !== "string" || expiresAtIso.trim() === "") return true;
|
|
100
|
+
const expires = Date.parse(expiresAtIso);
|
|
101
|
+
if (Number.isNaN(expires)) return true;
|
|
102
|
+
return expires <= at.getTime();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Extract the operator-facing preview from a successful compile — the diagram, the side effects a
|
|
106
|
+
* dispatch authorises, and the human stop-points. No BPMN, no digest handle beyond the content
|
|
107
|
+
* address; a preview, not a dispatch affordance. */
|
|
108
|
+
export function buildProposalPreview(compiled: CompileDeliveryGraphResult): DeliveryProposalPreview {
|
|
109
|
+
return {
|
|
110
|
+
diagram: compiled.diagram,
|
|
111
|
+
sideEffects: compiled.sideEffects,
|
|
112
|
+
humanNodes: compiled.humanNodes,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Build the durable proposal row for a compiled graph at stage time. `graph` is the original
|
|
117
|
+
* `DeliveryGraph` JSON (serialised) the cockpit dispatch action re-runs; `createdAt` is preserved
|
|
118
|
+
* across an idempotent re-stage of a still-live proposal so the TTL stays anchored to the first stage
|
|
119
|
+
* (omit it — defaulting to now — to re-anchor the TTL when re-staging an already-expired digest). */
|
|
120
|
+
export function buildProposalRow(input: {
|
|
121
|
+
digest: string;
|
|
122
|
+
logicalKey: string;
|
|
123
|
+
title: string | null;
|
|
124
|
+
graphJson: string;
|
|
125
|
+
preview: DeliveryProposalPreview;
|
|
126
|
+
nodeCount: number;
|
|
127
|
+
humanNodeCount: number;
|
|
128
|
+
sideEffectCount: number;
|
|
129
|
+
sideEffecting: boolean;
|
|
130
|
+
createdAt?: string;
|
|
131
|
+
}): DeliveryGraphProposal {
|
|
132
|
+
const at = now();
|
|
133
|
+
const createdAt = input.createdAt ?? at;
|
|
134
|
+
return {
|
|
135
|
+
digest: input.digest,
|
|
136
|
+
logical_key: input.logicalKey,
|
|
137
|
+
title: input.title,
|
|
138
|
+
graph: input.graphJson,
|
|
139
|
+
preview: JSON.stringify(input.preview),
|
|
140
|
+
node_count: input.nodeCount,
|
|
141
|
+
human_node_count: input.humanNodeCount,
|
|
142
|
+
side_effect_count: input.sideEffectCount,
|
|
143
|
+
side_effecting: input.sideEffecting ? 1 : 0,
|
|
144
|
+
status: "staged",
|
|
145
|
+
created_at: createdAt,
|
|
146
|
+
updated_at: at,
|
|
147
|
+
expires_at: proposalExpiry(createdAt),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Persist a compiled graph as a `staged` proposal and SUPERSEDE any prior staged proposal for the
|
|
152
|
+
* same logical graph. Idempotent on `digest` (a re-stage of an identical, still-live digest refreshes
|
|
153
|
+
* `updated_at` but preserves `created_at`, so the TTL stays anchored to the first stage; a re-stage of
|
|
154
|
+
* a digest that has already aged out re-anchors the TTL to now so it is dispatchable again). After the
|
|
155
|
+
* upsert, every
|
|
156
|
+
* OTHER `staged` proposal sharing this `logical_key` is flipped to `superseded` — so the cockpit shows
|
|
157
|
+
* exactly one live proposal per logical graph (the latest digest the operator would dispatch). The
|
|
158
|
+
* supersede RECONCILES to the globally-newest staged row (`updated_at`, `digest`-tie-broken) rather than
|
|
159
|
+
* flipping only rows older than the just-written one, so concurrent stages of two different digests
|
|
160
|
+
* converge to EXACTLY ONE live proposal — never zero, and never two — regardless of arrival order. */
|
|
161
|
+
export async function stageProposal(data: DataLayer, row: DeliveryGraphProposal): Promise<DeliveryGraphProposal> {
|
|
162
|
+
const table = deliveryGraphProposals(data);
|
|
163
|
+
const existing = await table.get(row.digest);
|
|
164
|
+
const toWrite = existing
|
|
165
|
+
? buildProposalRow({
|
|
166
|
+
digest: row.digest,
|
|
167
|
+
logicalKey: row.logical_key,
|
|
168
|
+
title: row.title,
|
|
169
|
+
graphJson: row.graph,
|
|
170
|
+
preview: JSON.parse(row.preview),
|
|
171
|
+
nodeCount: row.node_count,
|
|
172
|
+
humanNodeCount: row.human_node_count,
|
|
173
|
+
sideEffectCount: row.side_effect_count,
|
|
174
|
+
sideEffecting: row.side_effecting === 1,
|
|
175
|
+
// Re-stage: if the existing row is STILL LIVE, preserve its original stage time so the TTL
|
|
176
|
+
// stays anchored to the first stage. But if it has already aged out of its TTL (or was
|
|
177
|
+
// dispatched/superseded long ago), reusing the stale `created_at` would yield a past
|
|
178
|
+
// `expires_at`, leaving the "re-staged" row immediately non-dispatchable (`getStagedProposal`
|
|
179
|
+
// rejects it as expired) while the preview claims it is staged. In that case re-anchor the TTL
|
|
180
|
+
// to now (omit `createdAt`) so a re-proposed digest is genuinely live again.
|
|
181
|
+
createdAt: isProposalExpired(existing.expires_at) ? undefined : existing.created_at,
|
|
182
|
+
})
|
|
183
|
+
: row;
|
|
184
|
+
if (existing) {
|
|
185
|
+
const { digest, ...patch } = toWrite;
|
|
186
|
+
await table.update(row.digest, patch);
|
|
187
|
+
} else {
|
|
188
|
+
await table.insert(toWrite);
|
|
189
|
+
}
|
|
190
|
+
// Reconcile to EXACTLY ONE live proposal per logical graph: supersede every `staged` row for this
|
|
191
|
+
// `logical_key` that has a strictly-NEWER staged sibling (by `updated_at`, with a deterministic
|
|
192
|
+
// `digest` tie-breaker), leaving only the globally-newest live. This is ORDER-INDEPENDENT — it never
|
|
193
|
+
// references the just-written digest, so it converges to a single live row regardless of the
|
|
194
|
+
// interleaving of concurrent stages. A supersede pass keyed to "only flip rows older than the one *I*
|
|
195
|
+
// just wrote" leaves TWO live proposals when an OLDER stage's pass runs AFTER a newer stage already
|
|
196
|
+
// committed (the older pass won't flip the newer row, and the newer pass ran before the older row
|
|
197
|
+
// existed) — and an unordered supersede-all leaves ZERO. Anchoring on the newest staged sibling avoids
|
|
198
|
+
// both: the newest row is never superseded (no newer sibling), so it stays staged throughout the
|
|
199
|
+
// statement and every older row's `EXISTS` is satisfied by it. Idempotent: a no-op once one row remains.
|
|
200
|
+
await data
|
|
201
|
+
.open()
|
|
202
|
+
.exec(
|
|
203
|
+
`UPDATE "delivery_graph_proposals" SET "status" = 'superseded', "updated_at" = ? WHERE "logical_key" = ? AND "status" = 'staged' AND EXISTS (SELECT 1 FROM "delivery_graph_proposals" AS "newer" WHERE "newer"."logical_key" = "delivery_graph_proposals"."logical_key" AND "newer"."status" = 'staged' AND ("newer"."updated_at" > "delivery_graph_proposals"."updated_at" OR ("newer"."updated_at" = "delivery_graph_proposals"."updated_at" AND "newer"."digest" > "delivery_graph_proposals"."digest")))`,
|
|
204
|
+
[now(), row.logical_key],
|
|
205
|
+
);
|
|
206
|
+
return toWrite;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Load a proposal that is live and dispatchable RIGHT NOW: it exists, is `staged` (not superseded or
|
|
210
|
+
* already dispatched), and has not aged out of its TTL. Returns null otherwise, so the cockpit
|
|
211
|
+
* dispatch action refuses a stale/unknown/already-dispatched digest cleanly. */
|
|
212
|
+
export async function getStagedProposal(
|
|
213
|
+
data: DataLayer,
|
|
214
|
+
digest: string,
|
|
215
|
+
at: Date = new Date(),
|
|
216
|
+
): Promise<DeliveryGraphProposal | null> {
|
|
217
|
+
const row = await deliveryGraphProposals(data).get(digest);
|
|
218
|
+
if (!row) return null;
|
|
219
|
+
if (row.status !== "staged") return null;
|
|
220
|
+
if (isProposalExpired(row.expires_at, at)) return null;
|
|
221
|
+
return row;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Mark a staged proposal `dispatched` once the operator launches it — it drops out of the cockpit's
|
|
225
|
+
* staged list (the run then shows in the in-flight grid). */
|
|
226
|
+
export async function markProposalDispatched(data: DataLayer, digest: string): Promise<void> {
|
|
227
|
+
await deliveryGraphProposals(data).update(digest, { status: "dispatched", updated_at: now() });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Retire a proposal by flipping it to `expired` — its `graph` payload is unusable (e.g. corrupt JSON
|
|
231
|
+
* detected at dispatch), so it can never launch. Reuses the terminal `expired` status the sweep already
|
|
232
|
+
* uses, so a fail-closed retirement drops the row out of the cockpit's staged grid instead of leaving an
|
|
233
|
+
* undismissable `staged` row that fails every dispatch attempt the same way. */
|
|
234
|
+
export async function markProposalExpired(data: DataLayer, digest: string): Promise<void> {
|
|
235
|
+
await deliveryGraphProposals(data).update(digest, { status: "expired", updated_at: now() });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Age out every `staged` proposal whose TTL has elapsed by flipping it to `expired`, so it drops out
|
|
239
|
+
* of the cockpit's staged grid (which filters to `status = 'staged'`). The grid's datasource filter can
|
|
240
|
+
* only express equality/set-membership — not an `expires_at > now` comparison — so an expired-but-still-
|
|
241
|
+
* `staged` row would otherwise linger in the list indefinitely, only to fail dispatch with "no live
|
|
242
|
+
* staged proposal". This reconciliation sweep (driven by the poller) is the single writer that realises
|
|
243
|
+
* the TTL, reusing the canonical `isProposalExpired` predicate so there is no second definition of
|
|
244
|
+
* "expired". Returns the number of proposals swept. Idempotent: a proposal already terminal (superseded/
|
|
245
|
+
* dispatched/expired) is left untouched.
|
|
246
|
+
*
|
|
247
|
+
* The expiry flip is a GUARDED UPDATE (`... WHERE digest=? AND status='staged'`), not a blind
|
|
248
|
+
* update-by-key. The initial `find()` and the per-row write are separate statements, so a proposal can
|
|
249
|
+
* be dispatched (or superseded) in the window between them; a blind `table.update(digest, …)` would
|
|
250
|
+
* clobber that newer terminal status back to `expired`, silently re-hiding a run the operator just
|
|
251
|
+
* launched. The `status='staged'` guard makes the write a no-op when the row has already moved on, and
|
|
252
|
+
* we count only rows that actually changed (`res.changed`) so the returned tally stays honest. */
|
|
253
|
+
export async function sweepExpiredProposals(data: DataLayer, at: Date = new Date()): Promise<number> {
|
|
254
|
+
const table = deliveryGraphProposals(data);
|
|
255
|
+
const staged = await table.find({ status: "staged" });
|
|
256
|
+
const ts = now();
|
|
257
|
+
const db = data.open();
|
|
258
|
+
let swept = 0;
|
|
259
|
+
for (const row of staged) {
|
|
260
|
+
if (isProposalExpired(row.expires_at, at)) {
|
|
261
|
+
const res = await db.exec(
|
|
262
|
+
`UPDATE "delivery_graph_proposals" SET "status" = 'expired', "updated_at" = ? WHERE "digest" = ? AND "status" = 'staged'`,
|
|
263
|
+
[ts, row.digest],
|
|
264
|
+
);
|
|
265
|
+
swept += res.changed;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return swept;
|
|
269
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// Unit coverage for the
|
|
2
|
-
// (the idempotency key, the
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
1
|
+
// Unit coverage for the delivery-graph run aggregate (ADR 0005 Decision 7) — the pure decision helpers
|
|
2
|
+
// (the idempotency key, the parked human-label map, the derived parked-node phase), plus the durable
|
|
3
|
+
// at-most-once launch-claim fence (`claimRunForLaunch`) exercised against the real provisioned SQLite
|
|
4
|
+
// data layer so its actual `status <> 'running'` compare-and-swap SQL is validated, not just modelled.
|
|
5
|
+
// The COMPOSED dispatch behaviour at the edge is proven by operations/dispatchDeliveryGraph.test.ts
|
|
6
|
+
// and app/deliveryGraphDispatch.test.ts (the operator dispatch action).
|
|
7
7
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
8
8
|
import { tmpdir } from "node:os";
|
|
9
9
|
import { join, resolve } from "node:path";
|
|
@@ -21,8 +21,6 @@ import {
|
|
|
21
21
|
DELIVERY_PHASE,
|
|
22
22
|
deliveryGraphRuns,
|
|
23
23
|
humanTaskElementId,
|
|
24
|
-
isDeliveryGraphApproved,
|
|
25
|
-
parkRunFencedAgainstLaunch,
|
|
26
24
|
parseHumanLabels,
|
|
27
25
|
} from "./deliveryGraphRun.ts";
|
|
28
26
|
import { pollDeliveryGraphPhase } from "./service.ts";
|
|
@@ -124,36 +122,6 @@ test("pollDeliveryGraphPhase: a numeric engine processInstanceKey still matches
|
|
|
124
122
|
});
|
|
125
123
|
});
|
|
126
124
|
|
|
127
|
-
// ── parkRunFencedAgainstLaunch: the approval-park write never clobbers a launched claim ────────────
|
|
128
|
-
test("parkRunFencedAgainstLaunch: an approval-park write onto a launched `running` claim is a no-op — the at-most-once dispatch fence survives (no clobber back to awaiting-approval, no nulled process_key)", async () => {
|
|
129
|
-
await withData(async (data) => {
|
|
130
|
-
const runs = deliveryGraphRuns(data);
|
|
131
|
-
// A concurrent APPROVED submit already launched: the row is `running` with a live instance key.
|
|
132
|
-
await runs.insert({ ...claimRow("running"), process_key: "PI-1" });
|
|
133
|
-
// A racing UNAPPROVED submit that read the pre-launch row now tries to (re-)park it. The guarded
|
|
134
|
-
// write must refuse to overwrite the launched claim — otherwise a later re-submit double-launches.
|
|
135
|
-
await parkRunFencedAgainstLaunch(data, true, claimRow("awaiting-approval"));
|
|
136
|
-
const row = await runs.get("rk");
|
|
137
|
-
assertEquals(row?.status, "running"); // NOT clobbered back to awaiting-approval
|
|
138
|
-
assertEquals(row?.process_key, "PI-1"); // instance key preserved
|
|
139
|
-
});
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
test("parkRunFencedAgainstLaunch: a first park INSERTs the row; a park onto a still-parked row idempotently re-parks it (metadata refreshed, status stays awaiting-approval)", async () => {
|
|
143
|
-
await withData(async (data) => {
|
|
144
|
-
const runs = deliveryGraphRuns(data);
|
|
145
|
-
// First unapproved submit: no row yet → INSERT.
|
|
146
|
-
await parkRunFencedAgainstLaunch(data, false, claimRow("awaiting-approval"));
|
|
147
|
-
assertEquals((await runs.get("rk"))?.status, "awaiting-approval");
|
|
148
|
-
// A second unapproved submit onto the existing parked row: guarded UPDATE re-parks it (status is
|
|
149
|
-
// not `running`, so it applies) without duplicating the row.
|
|
150
|
-
await parkRunFencedAgainstLaunch(data, true, { ...claimRow("awaiting-approval"), digest: "d2" });
|
|
151
|
-
const row = await runs.get("rk");
|
|
152
|
-
assertEquals(row?.status, "awaiting-approval");
|
|
153
|
-
assertEquals(row?.digest, "d2");
|
|
154
|
-
});
|
|
155
|
-
});
|
|
156
|
-
|
|
157
125
|
// ── computeRunKey ─────────────────────────────────────────────────────────────
|
|
158
126
|
test("computeRunKey: a non-blank caller key wins; a blank/absent key falls back to the digest", () => {
|
|
159
127
|
assertEquals(computeRunKey("run-1", "digestX"), "run-1");
|
|
@@ -164,20 +132,6 @@ test("computeRunKey: a non-blank caller key wins; a blank/absent key falls back
|
|
|
164
132
|
assertEquals(computeRunKey(undefined, "digestX"), "digestX");
|
|
165
133
|
});
|
|
166
134
|
|
|
167
|
-
// ── isDeliveryGraphApproved ───────────────────────────────────────────────────
|
|
168
|
-
test("isDeliveryGraphApproved: a non-side-effecting graph needs no approval", () => {
|
|
169
|
-
assertEquals(isDeliveryGraphApproved(false, null, "d"), true);
|
|
170
|
-
assertEquals(isDeliveryGraphApproved(false, "wrong", "d"), true);
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
test("isDeliveryGraphApproved: a side-effecting graph dispatches ONLY with the matching content token", () => {
|
|
174
|
-
assertEquals(isDeliveryGraphApproved(true, "d", "d"), true);
|
|
175
|
-
assertEquals(isDeliveryGraphApproved(true, " d ", "d"), true); // trimmed
|
|
176
|
-
assertEquals(isDeliveryGraphApproved(true, "wrong", "d"), false);
|
|
177
|
-
assertEquals(isDeliveryGraphApproved(true, null, "d"), false);
|
|
178
|
-
assertEquals(isDeliveryGraphApproved(true, "", "d"), false);
|
|
179
|
-
});
|
|
180
|
-
|
|
181
135
|
// ── buildHumanLabels / parseHumanLabels ───────────────────────────────────────
|
|
182
136
|
test("buildHumanLabels: maps each human node's compiled user-task element id → its instruction label", async () => {
|
|
183
137
|
const graph = {
|
package/app/deliveryGraphRun.ts
CHANGED
|
@@ -1,23 +1,27 @@
|
|
|
1
|
-
// app/deliveryGraphRun.ts — the
|
|
1
|
+
// app/deliveryGraphRun.ts — the delivery-graph run AGGREGATE (ADR 0005 Decision 7).
|
|
2
2
|
//
|
|
3
|
-
// The dispatch
|
|
4
|
-
// a RUNNING engine-native process (via the S4
|
|
5
|
-
//
|
|
6
|
-
// module is the durable aggregate that makes
|
|
7
|
-
// WHERE a run is
|
|
3
|
+
// The cockpit dispatch action (`app/deliveryGraphDispatch.ts`, invoked from `dispatchDeliveryGraph`)
|
|
4
|
+
// turns a staged, agent-authored `DeliveryGraph` into a RUNNING engine-native process (via the S4
|
|
5
|
+
// runner). Because these graphs merge PRs and publish packages, dispatch must be idempotent and
|
|
6
|
+
// at-most-once. This module is the durable aggregate that makes that true and gives the cockpit a row
|
|
7
|
+
// to show WHERE a run is:
|
|
8
8
|
//
|
|
9
9
|
// • the idempotency fence — a run is keyed by `run_key` (a caller `idempotencyKey`, else the graph's
|
|
10
|
-
// content digest). A re-
|
|
10
|
+
// content digest). A re-dispatch collapses onto the same row, so an in-flight run short-circuits
|
|
11
11
|
// instead of double-launching (mirrors `plans`' `alreadyRunning`).
|
|
12
|
-
// • the
|
|
13
|
-
//
|
|
12
|
+
// • the content digest — `digest` is the content-address of the compiled definition, persisted so
|
|
13
|
+
// the cockpit and reconcilers can relate a run to the proposal it came from.
|
|
14
14
|
// • the derived parked-node phase — `phase`/`phase_node_id` is the display-only "where is it parked"
|
|
15
15
|
// projection `pollDeliveryGraphPhase` recomputes from engine truth (the running instance's open
|
|
16
16
|
// user tasks), generalising the `epic_phase` derived-phase machinery to a DYNAMIC compiled process.
|
|
17
17
|
//
|
|
18
|
-
// The pure helpers here (`computeRunKey`, `
|
|
19
|
-
//
|
|
20
|
-
//
|
|
18
|
+
// The pure helpers here (`computeRunKey`, `buildHumanLabels`, `deriveDeliveryPhase`) are engine/DB-free
|
|
19
|
+
// so they unit-test in isolation; the dispatch action and the poller supply the I/O.
|
|
20
|
+
//
|
|
21
|
+
// NOTE (issue #460): the `awaiting-approval` status remains a RESERVED member of the lifecycle union
|
|
22
|
+
// (like `abandoned`) but is no longer produced — dispatch is now an operator action in the cockpit, so
|
|
23
|
+
// there is no agent-facing approval gate to park a run at. The old replayable `approvalToken` and the
|
|
24
|
+
// approval-park write were removed with the agent `start` door.
|
|
21
25
|
|
|
22
26
|
import type { DataLayer, ProcessInstanceState } from "@nanobpm/urban";
|
|
23
27
|
import type { CompileDeliveryGraphResult } from "../nano-generated/api-io.d.ts";
|
|
@@ -26,7 +30,7 @@ import { DELIVERY_HUMAN_ELEMENT, isDeliveryHumanElement } from "./deliveryHuman.
|
|
|
26
30
|
|
|
27
31
|
const now = () => new Date().toISOString();
|
|
28
32
|
|
|
29
|
-
/** One
|
|
33
|
+
/** One delivery-graph run — the durable row. `side_effecting` is a SQLite boolean (0/1). */
|
|
30
34
|
export interface DeliveryGraphRun {
|
|
31
35
|
run_key: string;
|
|
32
36
|
process_key: string | null;
|
|
@@ -45,8 +49,9 @@ export interface DeliveryGraphRun {
|
|
|
45
49
|
updated_at: string;
|
|
46
50
|
}
|
|
47
51
|
|
|
48
|
-
/** The run lifecycle. `awaiting-approval`
|
|
49
|
-
*
|
|
52
|
+
/** The run lifecycle. `awaiting-approval` is RESERVED but no longer produced (issue #460 moved dispatch
|
|
53
|
+
* to an operator action, so runs are only ever created at launch) — kept in the union to preserve the
|
|
54
|
+
* durable enum. `running` = dispatched to the engine; `done`/`failed`/`abandoned` = terminal. */
|
|
50
55
|
export const DELIVERY_GRAPH_RUN_STATUSES = [
|
|
51
56
|
"awaiting-approval",
|
|
52
57
|
"running",
|
|
@@ -142,74 +147,14 @@ export async function claimRunForLaunch(
|
|
|
142
147
|
return res.changed === 1;
|
|
143
148
|
}
|
|
144
149
|
|
|
145
|
-
/** Persist a PARKED (non-launch) run row — the approval-gate write for an unapproved side-effecting
|
|
146
|
-
* graph — WITHOUT ever clobbering a concurrently-launched `running` claim. Two concurrent submits of
|
|
147
|
-
* one graph (same `run_key`) can race an APPROVED launch (which inserts/flips a `running` claim via
|
|
148
|
-
* `claimRunForLaunch`) against an UNAPPROVED park: a blind insert-or-update would let the park
|
|
149
|
-
* overwrite that `running` claim back to `awaiting-approval` and null its `process_key`, breaking the
|
|
150
|
-
* at-most-once dispatch fence and letting a later re-submit double-launch the graph's side effects.
|
|
151
|
-
* So mirror the launch fence exactly — a first write is the `run_key` PK insert; on a unique
|
|
152
|
-
* collision (a concurrent submit already wrote the row) OR when a row already exists, re-apply via a
|
|
153
|
-
* single atomic guarded UPDATE `… WHERE status <> 'running'`. One statement, so the check-and-write
|
|
154
|
-
* is atomic even across the delegate's `await` points: a racing `running` claim matches zero rows and
|
|
155
|
-
* survives untouched, while a still-parked or terminal row is idempotently (re-)parked. */
|
|
156
|
-
export async function parkRunFencedAgainstLaunch(
|
|
157
|
-
data: DataLayer,
|
|
158
|
-
existing: boolean,
|
|
159
|
-
row: DeliveryGraphRun,
|
|
160
|
-
): Promise<void> {
|
|
161
|
-
if (!existing) {
|
|
162
|
-
try {
|
|
163
|
-
await deliveryGraphRuns(data).insert(row);
|
|
164
|
-
return;
|
|
165
|
-
} catch (err) {
|
|
166
|
-
if (!isUniqueConstraintFence(err)) throw err;
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
await data
|
|
170
|
-
.open()
|
|
171
|
-
.exec(
|
|
172
|
-
`UPDATE "delivery_graph_runs" SET "process_key" = ?, "process_definition_id" = ?, "digest" = ?, "status" = ?, "side_effecting" = ?, "node_count" = ?, "human_node_count" = ?, "side_effect_count" = ?, "title" = ?, "phase" = ?, "phase_node_id" = ?, "human_labels" = ?, "updated_at" = ? WHERE "run_key" = ? AND "status" <> 'running'`,
|
|
173
|
-
[
|
|
174
|
-
row.process_key,
|
|
175
|
-
row.process_definition_id,
|
|
176
|
-
row.digest,
|
|
177
|
-
row.status,
|
|
178
|
-
row.side_effecting,
|
|
179
|
-
row.node_count,
|
|
180
|
-
row.human_node_count,
|
|
181
|
-
row.side_effect_count,
|
|
182
|
-
row.title,
|
|
183
|
-
row.phase,
|
|
184
|
-
row.phase_node_id,
|
|
185
|
-
row.human_labels,
|
|
186
|
-
row.updated_at,
|
|
187
|
-
row.run_key,
|
|
188
|
-
],
|
|
189
|
-
);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
150
|
/** The idempotency key for a submitted graph: a caller-supplied `idempotencyKey` (trimmed) when
|
|
193
|
-
* present and non-blank, else the graph's content `digest`. So two
|
|
151
|
+
* present and non-blank, else the graph's content `digest`. So two dispatches of the SAME graph (no
|
|
194
152
|
* explicit key) collapse onto one run, and a caller can force a fresh run with an explicit key. */
|
|
195
153
|
export function computeRunKey(idempotencyKey: string | undefined | null, digest: string): string {
|
|
196
154
|
const trimmed = typeof idempotencyKey === "string" ? idempotencyKey.trim() : "";
|
|
197
155
|
return trimmed || digest;
|
|
198
156
|
}
|
|
199
157
|
|
|
200
|
-
/** The approval decision (Decision 7). A graph with NO side-effecting nodes (only `wait`/`human`)
|
|
201
|
-
* needs no approval and dispatches straight away. A SIDE-EFFECTING graph dispatches only when the
|
|
202
|
-
* caller presents `approvalToken == digest` — an approval OF the rendered preview, content-addressed
|
|
203
|
-
* so it cannot be replayed against a different graph. */
|
|
204
|
-
export function isDeliveryGraphApproved(
|
|
205
|
-
sideEffecting: boolean,
|
|
206
|
-
approvalToken: string | undefined | null,
|
|
207
|
-
digest: string,
|
|
208
|
-
): boolean {
|
|
209
|
-
if (!sideEffecting) return true;
|
|
210
|
-
return typeof approvalToken === "string" && approvalToken.trim() === digest;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
158
|
/** The compiled human-task element id for a node's compiled BPMN element (`delivery-human-task__n3`) —
|
|
214
159
|
* the exact id the S4 compiler inlines and the engine reports as a user task's `elementId`. */
|
|
215
160
|
export function humanTaskElementId(element: string): string {
|
package/app/deliveryGraphText.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// app/deliveryGraphText.ts — the shared PARSE step for the human-facing UI JSON-paste ingress
|
|
2
2
|
// (issue #386, ADR 0005). The Delivery Graphs page (`pages/delivery-graphs.page.json`) submits the
|
|
3
3
|
// operator's pasted delivery-graph as a raw JSON STRING (`graphJson`) — the page's text field cannot
|
|
4
|
-
// submit a structured object — so the preview
|
|
5
|
-
// the resulting object to the SAME pure `compileDeliveryGraph` compiler
|
|
6
|
-
//
|
|
4
|
+
// submit a structured object — so the preview+stage ingress operation parses it here before handing
|
|
5
|
+
// the resulting object to the SAME pure `compileDeliveryGraph` compiler the agent-facing compile door
|
|
6
|
+
// uses. This is a UI text adapter, NOT a parallel compile path.
|
|
7
7
|
//
|
|
8
8
|
// PURE and I/O-free so it unit-tests in isolation. A blank field, non-JSON text, or a non-object JSON
|
|
9
9
|
// value maps to a clean `{ ok:false, error }` the ingress surfaces as a 400 with a human banner —
|
package/app/deliveryRunner.ts
CHANGED
|
@@ -20,9 +20,10 @@ import { assertNever, compileDeliveryGraph, DELIVERY_GRAPH_PROCESS_ID } from "./
|
|
|
20
20
|
import { DEFAULT_EVERY_MS, msToIsoDuration, parseProbe, readinessPollEvery } from "./readiness.ts";
|
|
21
21
|
|
|
22
22
|
/** The content digest of a compiled graph — `sha256(bpmn)[:12]` — the single source of truth for the
|
|
23
|
-
* content-addressed deploy id (`delivery-graph-<digest>`) AND the
|
|
24
|
-
*
|
|
25
|
-
*
|
|
23
|
+
* content-addressed deploy id (`delivery-graph-<digest>`) AND the dispatch fence's default idempotency
|
|
24
|
+
* key + the staged-proposal primary key. The runner (deploy id), `app/deliveryGraphDispatch` (dedupe
|
|
25
|
+
* key), and `app/deliveryGraphProposals` (proposal digest) all derive from THIS one function so they
|
|
26
|
+
* can never drift on how a graph is addressed. */
|
|
26
27
|
export function deliveryGraphDigest(bpmn: string): string {
|
|
27
28
|
return createHash("sha256").update(bpmn).digest("hex").slice(0, 12);
|
|
28
29
|
}
|
package/app/service.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
} from "./conformance.ts";
|
|
29
29
|
import { isUniqueConstraintFence } from "./dbFence.ts";
|
|
30
30
|
import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
|
|
31
|
+
import { sweepExpiredProposals } from "./deliveryGraphProposals.ts";
|
|
31
32
|
import { deliveryGraphRuns, deriveDeliveryPhase, parseHumanLabels } from "./deliveryGraphRun.ts";
|
|
32
33
|
import { isDeliveryHumanElement } from "./deliveryHuman.ts";
|
|
33
34
|
import { fleetSupportsDurableResume } from "./durableResume.ts";
|
|
@@ -2304,6 +2305,19 @@ export async function pollDeliveryGraphPhase(
|
|
|
2304
2305
|
}
|
|
2305
2306
|
}
|
|
2306
2307
|
|
|
2308
|
+
/** Poll pass (ADR 0005 Decision 7): age out staged delivery-graph proposals whose TTL has elapsed by
|
|
2309
|
+
* flipping them to `expired`, so they drop out of the cockpit's staged grid rather than lingering there
|
|
2310
|
+
* only to fail dispatch. The grid filters purely on `status = 'staged'` (its datasource cannot express an
|
|
2311
|
+
* `expires_at > now` comparison), so this reconciliation sweep is what realises the proposal TTL. It is
|
|
2312
|
+
* data-only and idempotent — a proposal already terminal is left untouched. */
|
|
2313
|
+
export async function pollDeliveryProposals(data: DataLayer) {
|
|
2314
|
+
try {
|
|
2315
|
+
await sweepExpiredProposals(data);
|
|
2316
|
+
} catch (err) {
|
|
2317
|
+
console.error(`[poller] delivery graph proposals sweep: ${err}`);
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2307
2321
|
export async function pollUserTasks(
|
|
2308
2322
|
data: DataLayer,
|
|
2309
2323
|
engine: EngineClient,
|
|
@@ -2479,6 +2493,7 @@ export async function pollOnce(
|
|
|
2479
2493
|
await pollLineage(data);
|
|
2480
2494
|
await pollUserTasks(data, engine, engineRest);
|
|
2481
2495
|
await pollDeliveryGraphPhase(data, engine);
|
|
2496
|
+
await pollDeliveryProposals(data);
|
|
2482
2497
|
if (engineRest) {
|
|
2483
2498
|
const base = engineRest.restAddress.replace(/\/+$/, "");
|
|
2484
2499
|
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
-- The `staged` delivery-graph proposal store (ADR 0005 Decision 7, issue #460). This realises
|
|
2
|
+
-- `propose → preview → approve → dispatch` as intended: the agent-facing surface ends at
|
|
3
|
+
-- propose → compile → STAGE, and a HUMAN dispatches the staged proposal from the cockpit. The old
|
|
4
|
+
-- `approvalToken` was a REPLAYABLE content digest handed back to the same caller, so any holder of
|
|
5
|
+
-- the API credential self-approved. Removing the dispatch affordance from the agent surface (there is
|
|
6
|
+
-- no `start` endpoint) dissolves that hole: the compile door persists the compiled graph HERE as a
|
|
7
|
+
-- `staged` proposal and returns only a preview + a navigational `reviewUrl` — nothing that can trigger
|
|
8
|
+
-- a run. The cockpit lists these rows, renders the preview, and dispatches the one the operator picks.
|
|
9
|
+
--
|
|
10
|
+
-- • digest (PK) — the content address of the compiled graph (`sha256(compiled.bpmn)[:12]`), the
|
|
11
|
+
-- SAME digest the runner uses for the content-addressed deploy id. It NAMES the proposal so the
|
|
12
|
+
-- agent can hand the operator an unambiguous "dispatch <digest>" and the operator dispatches
|
|
13
|
+
-- EXACTLY the digest they previewed. A re-compile of the same bytes is idempotent (same PK).
|
|
14
|
+
-- • logical_key — the LOGICAL graph identity (the graph's `name`, else the digest) used to
|
|
15
|
+
-- SUPERSEDE: staging a changed graph (new digest) for the same logical key retires the prior
|
|
16
|
+
-- staged proposal, so the cockpit shows one live proposal per logical graph, not every recompile.
|
|
17
|
+
-- • graph — the original `DeliveryGraph` JSON, retained so the cockpit dispatch action can run the
|
|
18
|
+
-- runner for the previewed digest without the agent re-submitting anything.
|
|
19
|
+
-- • preview — the rendered preview JSON (`{ diagram, sideEffects, humanNodes }`) the cockpit shows,
|
|
20
|
+
-- stamped at stage time so the list renders without recompiling.
|
|
21
|
+
-- • status — `staged` (awaiting operator review), `superseded` (replaced by a newer digest for its
|
|
22
|
+
-- logical key), `dispatched` (the operator launched it), or `expired` (aged out of its TTL before
|
|
23
|
+
-- dispatch). Only `staged` rows show in the cockpit; the poller sweeps aged-out `staged` rows to
|
|
24
|
+
-- `expired` (the grid's datasource filter is equality-only, so expiry is realised by that status
|
|
25
|
+
-- flip, not an `expires_at > now` clause).
|
|
26
|
+
-- • expires_at — the TTL horizon. Staged proposals age out of the cockpit list so a stale entry an
|
|
27
|
+
-- operator never dispatched does not linger; the poller flips an aged-out `staged` row to `expired`.
|
|
28
|
+
CREATE TABLE IF NOT EXISTS delivery_graph_proposals (
|
|
29
|
+
digest TEXT PRIMARY KEY,
|
|
30
|
+
logical_key TEXT NOT NULL,
|
|
31
|
+
title TEXT,
|
|
32
|
+
graph TEXT NOT NULL,
|
|
33
|
+
preview TEXT NOT NULL,
|
|
34
|
+
node_count INTEGER NOT NULL DEFAULT 0,
|
|
35
|
+
human_node_count INTEGER NOT NULL DEFAULT 0,
|
|
36
|
+
side_effect_count INTEGER NOT NULL DEFAULT 0,
|
|
37
|
+
side_effecting INTEGER NOT NULL DEFAULT 0,
|
|
38
|
+
status TEXT NOT NULL DEFAULT 'staged',
|
|
39
|
+
created_at TEXT NOT NULL,
|
|
40
|
+
updated_at TEXT NOT NULL,
|
|
41
|
+
expires_at TEXT NOT NULL
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
-- Supersede scans by logical_key; the cockpit list filters by status + expiry.
|
|
45
|
+
CREATE INDEX IF NOT EXISTS ix_delivery_graph_proposals_logical
|
|
46
|
+
ON delivery_graph_proposals (logical_key);
|
|
47
|
+
CREATE INDEX IF NOT EXISTS ix_delivery_graph_proposals_status
|
|
48
|
+
ON delivery_graph_proposals (status);
|
|
@@ -160,6 +160,11 @@ shared JSON contract means either can be swapped in later without touching the a
|
|
|
160
160
|
|
|
161
161
|
### 7. Submission is propose → preview → approve → dispatch, idempotent, over the self-describing endpoint
|
|
162
162
|
|
|
163
|
+
> **Superseded — see the *Amendment (issue #460)* at the end of this section.** The `POST
|
|
164
|
+
> /actions/start/delivery-graph` agent endpoint described in the following paragraph was **never
|
|
165
|
+
> shipped and has been removed**; the agent surface ends at propose → compile → stage and dispatch is
|
|
166
|
+
> operator-only. The paragraph below is retained as the original (Proposed) decision record.
|
|
167
|
+
|
|
163
168
|
Graphs are submitted exactly as epics are today — via a **new (proposed)** `POST
|
|
164
169
|
/actions/start/delivery-graph` endpoint (paths are relative to the agent guide's `__BASE__` prefix,
|
|
165
170
|
matching the guide's style) with the JSON body, discovered via the agent guide (which already
|
|
@@ -174,6 +179,19 @@ at-least-once execution (mirroring the release workflow's `npx semantic-release`
|
|
|
174
179
|
"skip already-published" discipline — `.github/workflows/release.yml`) so a
|
|
175
180
|
resume cannot double-fire.
|
|
176
181
|
|
|
182
|
+
> **Amendment (issue #460): dispatch is operator-only.** As implemented, the "approve → dispatch"
|
|
183
|
+
> half of this decision is **not** an agent endpoint. The agent surface ends at **propose → compile →
|
|
184
|
+
> stage**: the `POST /actions/compile-delivery-graph` door validates + previews the graph and, on
|
|
185
|
+
> success, **stages** it as a proposal (a durable `delivery_graph_proposals` row, content-addressed by
|
|
186
|
+
> `digest`, superseded per logical graph + TTL-bounded), returning only a preview + a navigational
|
|
187
|
+
> `reviewUrl` — no run key, token, or PIK. A **human dispatches** the staged proposal from the cockpit's
|
|
188
|
+
> Delivery Graphs page (`POST /actions/delivery-graph/dispatch` by `digest`, an operator route). The
|
|
189
|
+
> originally-proposed agent `POST /actions/start/delivery-graph` door — where the same caller was handed
|
|
190
|
+
> a content-addressed `approvalToken` to re-submit with — was **removed**: that "approval" was a
|
|
191
|
+
> **replayable** digest returned to the approver, so any holder of the API credential self-approved.
|
|
192
|
+
> Dispatch-by-absence (there is no agent start door) closes that hole categorically; the idempotent
|
|
193
|
+
> at-most-once launch fence is retained on the operator dispatch path.
|
|
194
|
+
|
|
177
195
|
## Consequences
|
|
178
196
|
|
|
179
197
|
- nwf gains a **generic delivery-graph runner** that composes its existing primitives; the motivating
|