@nanobpm/nano-workforce 0.115.0 → 0.117.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 +17 -0
- package/app/convergeGate.test.ts +94 -84
- package/app/deliveryGraphRun.test.ts +249 -0
- package/app/deliveryGraphRun.ts +323 -0
- package/app/deliveryRunner.ts +9 -1
- package/app/instance-tracking.test.ts +32 -0
- package/app/persist-escalation.test.ts +0 -33
- package/app/service.ts +39 -0
- package/db/migrations/057_drop_escalation_head_override.sql +16 -0
- package/db/migrations/058_delivery_graph_runs.sql +58 -0
- package/e2e/convergence-escalation.e2e.ts +6 -0
- package/e2e/delivery-graph-start.e2e.ts +145 -0
- package/nano.app.json +16 -1
- package/openapi.yaml +120 -0
- package/operations/startDeliveryGraph.integration.test.ts +316 -0
- package/operations/startDeliveryGraph.ts +222 -0
- package/package.json +1 -1
- package/pages/overview.page.json +34 -0
- package/resources/processes/convergence-loop.bpmn +177 -88
- package/resources/prompts/feature.md +15 -13
- package/resources/prompts/scope-classify.md +142 -0
- package/test/derivation-parity/derivation-parity.test.ts +2 -2
- package/test/derivation-parity/flows.ts +1 -1
- package/workers/converge-gate/worker.ts +14 -157
- package/workers/persist-escalation/worker.ts +1 -8
- package/app/scopeGuard.test.ts +0 -185
- package/app/scopeGuard.ts +0 -165
- package/workers/converge-gate/worker.test.ts +0 -116
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
// app/deliveryGraphRun.ts — the `startDeliveryGraph` run AGGREGATE (ADR 0005 Decision 7, slice S5).
|
|
2
|
+
//
|
|
3
|
+
// The dispatch door (`operations/startDeliveryGraph.ts`) turns an agent-authored `DeliveryGraph` into
|
|
4
|
+
// a RUNNING engine-native process (via the S4 runner) — but because these graphs merge PRs and publish
|
|
5
|
+
// packages, dispatch is GATED on an approval of the rendered preview and must be idempotent. This
|
|
6
|
+
// module is the durable aggregate that makes both properties true and gives the cockpit a row to show
|
|
7
|
+
// WHERE a run is parked:
|
|
8
|
+
//
|
|
9
|
+
// • the idempotency fence — a run is keyed by `run_key` (a caller `idempotencyKey`, else the graph's
|
|
10
|
+
// content digest). A re-POST collapses onto the same row, so an in-flight run short-circuits
|
|
11
|
+
// instead of double-launching (mirrors `plans`' `alreadyRunning`).
|
|
12
|
+
// • the approval token — `digest` is the content-address a side-effecting graph must present as its
|
|
13
|
+
// `approvalToken` to dispatch. Persisted so a second POST re-derives + re-checks it.
|
|
14
|
+
// • the derived parked-node phase — `phase`/`phase_node_id` is the display-only "where is it parked"
|
|
15
|
+
// projection `pollDeliveryGraphPhase` recomputes from engine truth (the running instance's open
|
|
16
|
+
// user tasks), generalising the `epic_phase` derived-phase machinery to a DYNAMIC compiled process.
|
|
17
|
+
//
|
|
18
|
+
// The pure helpers here (`computeRunKey`, `isDeliveryGraphApproved`, `buildHumanLabels`,
|
|
19
|
+
// `deriveDeliveryPhase`) are engine/DB-free so they unit-test in isolation; the door and the poller
|
|
20
|
+
// supply the I/O.
|
|
21
|
+
|
|
22
|
+
import type { DataLayer, ProcessInstanceState } from "@nanobpm/urban";
|
|
23
|
+
import type { CompileDeliveryGraphResult } from "../nano-generated/api-io.d.ts";
|
|
24
|
+
import { isUniqueConstraintFence } from "./dbFence.ts";
|
|
25
|
+
import { DELIVERY_HUMAN_ELEMENT, isDeliveryHumanElement } from "./deliveryHuman.ts";
|
|
26
|
+
|
|
27
|
+
const now = () => new Date().toISOString();
|
|
28
|
+
|
|
29
|
+
/** One `startDeliveryGraph` run — the durable row. `side_effecting` is a SQLite boolean (0/1). */
|
|
30
|
+
export interface DeliveryGraphRun {
|
|
31
|
+
run_key: string;
|
|
32
|
+
process_key: string | null;
|
|
33
|
+
process_definition_id: string | null;
|
|
34
|
+
digest: string;
|
|
35
|
+
status: DeliveryGraphRunStatus;
|
|
36
|
+
side_effecting: number;
|
|
37
|
+
node_count: number;
|
|
38
|
+
human_node_count: number;
|
|
39
|
+
side_effect_count: number;
|
|
40
|
+
title: string | null;
|
|
41
|
+
phase: string | null;
|
|
42
|
+
phase_node_id: string | null;
|
|
43
|
+
human_labels: string | null;
|
|
44
|
+
created_at: string;
|
|
45
|
+
updated_at: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The run lifecycle. `awaiting-approval` = a side-effecting graph parked at the approval gate (no
|
|
49
|
+
* instance started); `running` = dispatched to the engine; `done`/`failed`/`abandoned` = terminal. */
|
|
50
|
+
export const DELIVERY_GRAPH_RUN_STATUSES = [
|
|
51
|
+
"awaiting-approval",
|
|
52
|
+
"running",
|
|
53
|
+
"done",
|
|
54
|
+
"failed",
|
|
55
|
+
"abandoned",
|
|
56
|
+
] as const;
|
|
57
|
+
export type DeliveryGraphRunStatus = typeof DELIVERY_GRAPH_RUN_STATUSES[number];
|
|
58
|
+
|
|
59
|
+
/** The ACTIVE statuses — a run in one of these is still in flight and shows in the cockpit's active
|
|
60
|
+
* grid (`pages/overview.page.json`'s "Active Delivery Graphs" filter). Note this is the DISPLAY set,
|
|
61
|
+
* broader than the instanceTracking binding: only `running` is backed by a live engine instance
|
|
62
|
+
* (non-null `process_key`), so ONLY `running` is instance-tracked (nano.app.json). A parked
|
|
63
|
+
* `awaiting-approval` run has no instance (`process_key` NULL) — it is shown here but not reconciled
|
|
64
|
+
* by the `process_key`-keyed reconciler. */
|
|
65
|
+
export const DELIVERY_GRAPH_ACTIVE_STATUSES: readonly DeliveryGraphRunStatus[] = [
|
|
66
|
+
"awaiting-approval",
|
|
67
|
+
"running",
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
/** The terminal statuses — a run in one of these is done and drops out of the active grid. Mirrors
|
|
71
|
+
* `PLAN_TERMINAL_STATUSES`; the idempotency short-circuit only fires for a NON-terminal run. */
|
|
72
|
+
export const DELIVERY_GRAPH_TERMINAL_STATUSES: readonly DeliveryGraphRunStatus[] = [
|
|
73
|
+
"done",
|
|
74
|
+
"failed",
|
|
75
|
+
"abandoned",
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
/** The display phases for a run's derived projection. `Parked on human node: <label>` is built at
|
|
79
|
+
* derivation time from the parked user task's label, so it is not a member here. */
|
|
80
|
+
export const DELIVERY_PHASE = {
|
|
81
|
+
AWAITING_APPROVAL: "Awaiting approval",
|
|
82
|
+
RUNNING: "Running",
|
|
83
|
+
COMPLETED: "Completed",
|
|
84
|
+
FAILED: "Failed",
|
|
85
|
+
} as const;
|
|
86
|
+
|
|
87
|
+
/** The `delivery_graph_runs` aggregate accessor — the durable run store keyed by `run_key`. */
|
|
88
|
+
export const deliveryGraphRuns = (data: DataLayer) =>
|
|
89
|
+
data.table<DeliveryGraphRun>("delivery_graph_runs", "run_key");
|
|
90
|
+
|
|
91
|
+
/** Atomically claim a run for LAUNCH — the at-most-once dispatch fence. Returns `true` iff THIS caller
|
|
92
|
+
* won the claim and must proceed to `runDeliveryGraph`; `false` iff a concurrent submit already claimed
|
|
93
|
+
* it (the caller must short-circuit as `alreadyRunning` instead of double-launching). Two fences, one
|
|
94
|
+
* per starting state, so dispatch is at-most-once from EITHER — the `run_key` PK guards a first launch
|
|
95
|
+
* and a compare-and-swap on `status` guards a relaunch off a persisted row:
|
|
96
|
+
* • no row yet (`existing` null) → INSERT the claim, fenced by the `run_key` PRIMARY KEY: a racing
|
|
97
|
+
* loser hits `UNIQUE constraint failed` and returns `false`.
|
|
98
|
+
* • a persisted NON-running row (a parked `awaiting-approval` row now being approved, or a terminal
|
|
99
|
+
* row being re-run) → a single guarded UPDATE (`SET status='running' … WHERE status <> 'running'`).
|
|
100
|
+
* It is ONE statement, so the check-and-flip is atomic even across the delegate's `await` points:
|
|
101
|
+
* of two concurrent approved re-submits that both read the same parked row, exactly one flips it
|
|
102
|
+
* (`changed === 1`) and the other matches zero rows (`changed === 0`). This closes the double-launch
|
|
103
|
+
* hole the PK fence alone left open — an `update`-on-existing path has no unique collision to lose,
|
|
104
|
+
* so without this guard both racers would `update` then both launch. The winner writes the run's
|
|
105
|
+
* full metadata afterwards (safe: it is now the sole caller past the fence).
|
|
106
|
+
*
|
|
107
|
+
* The CAS also CLEARS the instance-bound columns (`process_key`, `process_definition_id`, `phase`,
|
|
108
|
+
* `phase_node_id`) to the fresh claim's values IN THE SAME statement. A re-run off a terminal row (or
|
|
109
|
+
* any persisted row) still carries the PRIOR run's `process_key`; flipping `status` alone would make
|
|
110
|
+
* the row briefly visible as `running` while still pointing at the OLD instance key, so the
|
|
111
|
+
* `process_key`-keyed instance-tracking reconciler / poller could act on (and mis-reconcile against)
|
|
112
|
+
* the stale instance before the winner's follow-up metadata write lands. Clearing them atomically with
|
|
113
|
+
* the flip means a claimed `running` row can never be observed with a stale instance key. */
|
|
114
|
+
export async function claimRunForLaunch(
|
|
115
|
+
data: DataLayer,
|
|
116
|
+
existing: boolean,
|
|
117
|
+
claim: DeliveryGraphRun,
|
|
118
|
+
): Promise<boolean> {
|
|
119
|
+
if (!existing) {
|
|
120
|
+
try {
|
|
121
|
+
await deliveryGraphRuns(data).insert(claim);
|
|
122
|
+
return true;
|
|
123
|
+
} catch (err) {
|
|
124
|
+
if (!isUniqueConstraintFence(err)) throw err;
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const res = await data
|
|
129
|
+
.open()
|
|
130
|
+
.exec(
|
|
131
|
+
`UPDATE "delivery_graph_runs" SET "status" = ?, "process_key" = ?, "process_definition_id" = ?, "phase" = ?, "phase_node_id" = ?, "updated_at" = ? WHERE "run_key" = ? AND "status" <> 'running'`,
|
|
132
|
+
[
|
|
133
|
+
claim.status,
|
|
134
|
+
claim.process_key,
|
|
135
|
+
claim.process_definition_id,
|
|
136
|
+
claim.phase,
|
|
137
|
+
claim.phase_node_id,
|
|
138
|
+
claim.updated_at,
|
|
139
|
+
claim.run_key,
|
|
140
|
+
],
|
|
141
|
+
);
|
|
142
|
+
return res.changed === 1;
|
|
143
|
+
}
|
|
144
|
+
|
|
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
|
+
/** 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 POSTs of the SAME graph (no
|
|
194
|
+
* explicit key) collapse onto one run, and a caller can force a fresh run with an explicit key. */
|
|
195
|
+
export function computeRunKey(idempotencyKey: string | undefined | null, digest: string): string {
|
|
196
|
+
const trimmed = typeof idempotencyKey === "string" ? idempotencyKey.trim() : "";
|
|
197
|
+
return trimmed || digest;
|
|
198
|
+
}
|
|
199
|
+
|
|
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
|
+
/** The compiled human-task element id for a node's compiled BPMN element (`delivery-human-task__n3`) —
|
|
214
|
+
* the exact id the S4 compiler inlines and the engine reports as a user task's `elementId`. */
|
|
215
|
+
export function humanTaskElementId(element: string): string {
|
|
216
|
+
return `${DELIVERY_HUMAN_ELEMENT}__${element}`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The first non-blank line of a multi-line instruction, clamped, for a compact parked-node label. */
|
|
220
|
+
function firstLine(text: string | undefined | null): string {
|
|
221
|
+
if (typeof text !== "string") return "";
|
|
222
|
+
const line = text.split("\n").map((l) => l.trim()).find((l) => l.length > 0) ?? "";
|
|
223
|
+
return line.length > 80 ? `${line.slice(0, 77)}…` : line;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Map each human node's compiled user-task element id → a display label (its instruction's first
|
|
227
|
+
* line, else its author node id), from the S1 compile result. Stamped on the run row at dispatch so
|
|
228
|
+
* the poller renders the parked-node phase without recompiling the graph. */
|
|
229
|
+
export function buildHumanLabels(compiled: CompileDeliveryGraphResult): Record<string, string> {
|
|
230
|
+
const elementByNodeId = new Map(compiled.resolved.nodes.map((n) => [n.id, n.element]));
|
|
231
|
+
const labels: Record<string, string> = {};
|
|
232
|
+
for (const stop of compiled.humanNodes) {
|
|
233
|
+
const element = elementByNodeId.get(stop.nodeId);
|
|
234
|
+
if (element === undefined) continue;
|
|
235
|
+
labels[humanTaskElementId(element)] = firstLine(stop.prompt) || stop.nodeId;
|
|
236
|
+
}
|
|
237
|
+
return labels;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Parse a run row's stored `human_labels` JSON back into a map, tolerating a null/blank/corrupt
|
|
241
|
+
* value (→ empty map) so a bad column can never crash the poller. */
|
|
242
|
+
export function parseHumanLabels(raw: string | null | undefined): Record<string, string> {
|
|
243
|
+
if (typeof raw !== "string" || raw.trim() === "") return {};
|
|
244
|
+
try {
|
|
245
|
+
const parsed: unknown = JSON.parse(raw);
|
|
246
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
247
|
+
const out: Record<string, string> = {};
|
|
248
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
249
|
+
if (typeof v === "string") out[k] = v;
|
|
250
|
+
}
|
|
251
|
+
return out;
|
|
252
|
+
} catch {
|
|
253
|
+
return {};
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** The derived (status, phase, parked-node) projection for a RUNNING run, from engine truth. PURE —
|
|
258
|
+
* the poller supplies the instance state + open user tasks, this maps them to the stored projection:
|
|
259
|
+
* • COMPLETED → `done` (instanceTracking does NOT reconcile COMPLETED — this poller owns it).
|
|
260
|
+
* • TERMINATED → `failed` (a safety net; the instanceTracking `onTerminated` edge also flips it).
|
|
261
|
+
* • ACTIVE parked on a human node → `running`, phase `Parked on human node: <label>`.
|
|
262
|
+
* • ACTIVE otherwise (watching a wait/agent node) → `running`, phase `Running`. */
|
|
263
|
+
export interface DeliveryPhaseProjection {
|
|
264
|
+
status: DeliveryGraphRunStatus;
|
|
265
|
+
phase: string;
|
|
266
|
+
phase_node_id: string | null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function deriveDeliveryPhase(
|
|
270
|
+
state: ProcessInstanceState | null | undefined,
|
|
271
|
+
openUserTasks: readonly { elementId?: string }[],
|
|
272
|
+
humanLabels: Record<string, string>,
|
|
273
|
+
): DeliveryPhaseProjection {
|
|
274
|
+
if (state === "COMPLETED") return { status: "done", phase: DELIVERY_PHASE.COMPLETED, phase_node_id: null };
|
|
275
|
+
if (state === "TERMINATED") return { status: "failed", phase: DELIVERY_PHASE.FAILED, phase_node_id: null };
|
|
276
|
+
const parkedOn = openUserTasks
|
|
277
|
+
.map((t) => t.elementId)
|
|
278
|
+
.filter((id): id is string => typeof id === "string" && isDeliveryHumanElement(id))
|
|
279
|
+
.sort()[0];
|
|
280
|
+
if (parkedOn !== undefined) {
|
|
281
|
+
const label = humanLabels[parkedOn] ?? parkedOn;
|
|
282
|
+
return { status: "running", phase: `Parked on human node: ${label}`, phase_node_id: parkedOn };
|
|
283
|
+
}
|
|
284
|
+
return { status: "running", phase: DELIVERY_PHASE.RUNNING, phase_node_id: null };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Build the durable row for a run at a given lifecycle point — the SINGLE row-shape builder both the
|
|
288
|
+
* approval-park and the dispatch write go through, so the two can't drift on which columns a run
|
|
289
|
+
* carries. `createdAt` is preserved across an update (the door passes the existing row's value). */
|
|
290
|
+
export function buildDeliveryGraphRunRow(input: {
|
|
291
|
+
runKey: string;
|
|
292
|
+
digest: string;
|
|
293
|
+
status: DeliveryGraphRunStatus;
|
|
294
|
+
sideEffecting: boolean;
|
|
295
|
+
nodeCount: number;
|
|
296
|
+
humanNodeCount: number;
|
|
297
|
+
sideEffectCount: number;
|
|
298
|
+
title: string | null;
|
|
299
|
+
phase: string;
|
|
300
|
+
processKey?: string | null;
|
|
301
|
+
processDefinitionId?: string | null;
|
|
302
|
+
humanLabels?: Record<string, string>;
|
|
303
|
+
createdAt?: string;
|
|
304
|
+
}): DeliveryGraphRun {
|
|
305
|
+
const at = now();
|
|
306
|
+
return {
|
|
307
|
+
run_key: input.runKey,
|
|
308
|
+
process_key: input.processKey ?? null,
|
|
309
|
+
process_definition_id: input.processDefinitionId ?? null,
|
|
310
|
+
digest: input.digest,
|
|
311
|
+
status: input.status,
|
|
312
|
+
side_effecting: input.sideEffecting ? 1 : 0,
|
|
313
|
+
node_count: input.nodeCount,
|
|
314
|
+
human_node_count: input.humanNodeCount,
|
|
315
|
+
side_effect_count: input.sideEffectCount,
|
|
316
|
+
title: input.title,
|
|
317
|
+
phase: input.phase,
|
|
318
|
+
phase_node_id: null,
|
|
319
|
+
human_labels: input.humanLabels ? JSON.stringify(input.humanLabels) : null,
|
|
320
|
+
created_at: input.createdAt ?? at,
|
|
321
|
+
updated_at: at,
|
|
322
|
+
};
|
|
323
|
+
}
|
package/app/deliveryRunner.ts
CHANGED
|
@@ -18,6 +18,14 @@ import type { EngineClient } from "@nanobpm/urban";
|
|
|
18
18
|
import type { DeliveryGraph, DeliveryNode } from "../nano-generated/api-io.d.ts";
|
|
19
19
|
import { assertNever, compileDeliveryGraph, DELIVERY_GRAPH_PROCESS_ID } from "./deliveryGraphCompiler.ts";
|
|
20
20
|
|
|
21
|
+
/** The content digest of a compiled graph — `sha256(bpmn)[:12]` — the single source of truth for the
|
|
22
|
+
* content-addressed deploy id (`delivery-graph-<digest>`) AND the S5 dispatch door's approval token /
|
|
23
|
+
* default idempotency key. Both the runner (deploy id) and `operations/startDeliveryGraph` (approval +
|
|
24
|
+
* dedupe key) derive from THIS one function so the two can never drift on how a graph is addressed. */
|
|
25
|
+
export function deliveryGraphDigest(bpmn: string): string {
|
|
26
|
+
return createHash("sha256").update(bpmn).digest("hex").slice(0, 12);
|
|
27
|
+
}
|
|
28
|
+
|
|
21
29
|
/** The bounded-timeout / SLA envelope every node inherits (Decision: bounded → escalate). ISO-8601
|
|
22
30
|
* durations. Defaults are conservative; a caller (the S5 door) may tighten them per run. */
|
|
23
31
|
export interface DeliveryRunTimeouts {
|
|
@@ -89,7 +97,7 @@ export function prepareDeliveryGraph(graph: DeliveryGraph, options: DeliveryRunO
|
|
|
89
97
|
const compiled = compileDeliveryGraph(graph);
|
|
90
98
|
if (!compiled.ok) return { ok: false, errors: compiled.errors };
|
|
91
99
|
|
|
92
|
-
const digest =
|
|
100
|
+
const digest = deliveryGraphDigest(compiled.bpmn);
|
|
93
101
|
const processDefinitionId = `${DELIVERY_GRAPH_PROCESS_ID}-${digest}`;
|
|
94
102
|
const bpmn = rewriteProcessId(compiled.bpmn, processDefinitionId);
|
|
95
103
|
|
|
@@ -12,6 +12,7 @@ import { TERMINAL_STATUSES } from "./delivery.ts";
|
|
|
12
12
|
import { PLAN_TERMINAL_STATUSES } from "./plan.ts";
|
|
13
13
|
import { FEATURE_TERMINAL_STATUSES } from "./feature.ts";
|
|
14
14
|
import { CONFORMANCE_REVIEWING_STATUS } from "./conformance.ts";
|
|
15
|
+
import { DELIVERY_GRAPH_TERMINAL_STATUSES } from "./deliveryGraphRun.ts";
|
|
15
16
|
|
|
16
17
|
interface Binding {
|
|
17
18
|
table: string;
|
|
@@ -141,3 +142,34 @@ test("instanceTracking: plan_conformance onTerminated status is not active", asy
|
|
|
141
142
|
`onTerminated review_status "${String(settled)}" must not be listed active`,
|
|
142
143
|
);
|
|
143
144
|
});
|
|
145
|
+
|
|
146
|
+
// The delivery_graph_runs binding tracks ONLY the engine-instance-backed status. Unlike PR/plan/
|
|
147
|
+
// feature bindings, its active set is DELIBERATELY narrower than the code's DISPLAY active set
|
|
148
|
+
// (DELIVERY_GRAPH_ACTIVE_STATUSES = awaiting-approval + running): a parked `awaiting-approval` run
|
|
149
|
+
// has a null `process_key`, so the `process_key`-keyed reconciler cannot track it. Tie the manifest
|
|
150
|
+
// to that invariant so a future change can't silently (a) list a terminal status active — the
|
|
151
|
+
// reconciler would clobber a settled run — or (b) add `awaiting-approval`, which would make the
|
|
152
|
+
// reconciler flip every parked run to `failed` on its next pass (a null key never matches a live
|
|
153
|
+
// instance → "vanished" → onTerminated).
|
|
154
|
+
test("instanceTracking: delivery_graph_runs activeStatuses excludes every terminal status", async () => {
|
|
155
|
+
const b = bindingFor(await bindings(), "delivery_graph_runs");
|
|
156
|
+
for (const terminal of DELIVERY_GRAPH_TERMINAL_STATUSES) {
|
|
157
|
+
assert(!b.activeStatuses?.includes(terminal), `terminal status "${terminal}" must not be active`);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("instanceTracking: delivery_graph_runs activeStatuses is exactly the instance-backed status (running)", async () => {
|
|
162
|
+
const b = bindingFor(await bindings(), "delivery_graph_runs");
|
|
163
|
+
assertEquals([...(b.activeStatuses ?? [])].sort(), ["running"]);
|
|
164
|
+
// A parked run has no engine instance (process_key NULL) — it must NOT be reconciled by this binding.
|
|
165
|
+
assert(!b.activeStatuses?.includes("awaiting-approval"), "awaiting-approval (null process_key) must not be instance-tracked");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("instanceTracking: delivery_graph_runs onTerminated status is not active", async () => {
|
|
169
|
+
const b = bindingFor(await bindings(), "delivery_graph_runs");
|
|
170
|
+
const settled = b.onTerminated.set.status;
|
|
171
|
+
assert(
|
|
172
|
+
typeof settled === "string" && !b.activeStatuses?.includes(settled),
|
|
173
|
+
`onTerminated status "${String(settled)}" must not be listed active`,
|
|
174
|
+
);
|
|
175
|
+
});
|
|
@@ -212,36 +212,3 @@ test("a control-flow arm with a blank question opens nothing so gw-escalated re-
|
|
|
212
212
|
assertEquals(inserts.escalations.length, 0, "no dead escalation is fabricated");
|
|
213
213
|
assertEquals(updates.pull_requests?.length ?? 0, 0, "the PR is never flipped to escalated");
|
|
214
214
|
});
|
|
215
|
-
|
|
216
|
-
// The scope-integrity arm (persist-escalation-blockedcomments) binds the escalation to the reviewed
|
|
217
|
-
// commit (issue #395): it stamps `head_sha` and marks `scope_block` so the converge-gate can honour
|
|
218
|
-
// a same-HEAD human answer as an override instead of re-deriving the block and re-escalating forever.
|
|
219
|
-
test("persist-escalation binds a scope-integrity escalation to the reviewed HEAD (head_sha + scope_block)", async () => {
|
|
220
|
-
const { app, inserts } = fakeApp();
|
|
221
|
-
const job = {
|
|
222
|
-
variables: {
|
|
223
|
-
prKey: "o/r#5",
|
|
224
|
-
round: 2,
|
|
225
|
-
status: "blocked",
|
|
226
|
-
question: "Scope integrity blocked: ...",
|
|
227
|
-
recordRound: false,
|
|
228
|
-
headSha: "HEAD1",
|
|
229
|
-
scopeBlock: true,
|
|
230
|
-
},
|
|
231
|
-
};
|
|
232
|
-
await handler(job as any, app as any);
|
|
233
|
-
assertEquals(inserts.escalations.length, 1);
|
|
234
|
-
assertEquals((inserts.escalations[0] as any).head_sha, "HEAD1", "the escalation carries the reviewed commit");
|
|
235
|
-
assertEquals((inserts.escalations[0] as any).scope_block, 1, "flagged as a scope-integrity block");
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
// Every other escalation arm (agent verdict, no-progress, max-rounds, stalled) omits the scope
|
|
239
|
-
// binding: head_sha stays absent and scope_block defaults to 0, so the override door opens ONLY for
|
|
240
|
-
// the block a human can actually answer.
|
|
241
|
-
test("persist-escalation: a non-scope escalation records no HEAD binding and scope_block 0", async () => {
|
|
242
|
-
const { app, inserts } = fakeApp();
|
|
243
|
-
const job = { variables: { prKey: "o/r#1", round: 3, status: "blocked", question: "max rounds" } };
|
|
244
|
-
await handler(job as any, app as any);
|
|
245
|
-
assertEquals((inserts.escalations[0] as any).head_sha, undefined, "no reviewed HEAD to bind");
|
|
246
|
-
assertEquals((inserts.escalations[0] as any).scope_block, 0, "not a scope-integrity block");
|
|
247
|
-
});
|
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 { deliveryGraphRuns, deriveDeliveryPhase, parseHumanLabels } from "./deliveryGraphRun.ts";
|
|
31
32
|
import { fleetSupportsDurableResume } from "./durableResume.ts";
|
|
32
33
|
import { backfillFeatureStages, deriveFeatureDelivery, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRunStatus, featureEscalations, featureRuns } from "./feature.ts";
|
|
33
34
|
import {
|
|
@@ -2196,6 +2197,43 @@ async function sweepOpenEscalationTasks(base: string, headers: Record<string, st
|
|
|
2196
2197
|
* completed task's row is deleted (answered here, via the task inbox, or out-of-band) and `showCount`
|
|
2197
2198
|
* reflects live pending work. Best-effort + idempotent — per-instance failures are isolated so one bad
|
|
2198
2199
|
* instance never stalls the pass. */
|
|
2200
|
+
/** Poll pass (ADR 0005 slice S5): reconcile each RUNNING delivery-graph run's derived phase from
|
|
2201
|
+
* engine truth, and complete it when its instance ends. A delivery graph is a DYNAMIC compiled
|
|
2202
|
+
* process with no happy-path host worker, so — unlike `plans`/`feature_runs`, whose spine workers
|
|
2203
|
+
* write their own terminal row — this pass owns both the parked-node projection AND the COMPLETED→done
|
|
2204
|
+
* transition (instanceTracking's `onTerminated` edge reconciles only TERMINATED, never COMPLETED, so
|
|
2205
|
+
* a graph that ends normally would otherwise stay `running` forever). Generalises the `epic_phase`
|
|
2206
|
+
* derived-phase machinery to a graph whose element ids aren't known ahead of time: the parked-node
|
|
2207
|
+
* label is derived from the run row's stamped `human_labels` + the instance's OPEN user tasks. Scoped
|
|
2208
|
+
* to `running` rows (an `awaiting-approval` run has no instance yet), so it stays O(in-flight). */
|
|
2209
|
+
export async function pollDeliveryGraphPhase(
|
|
2210
|
+
data: DataLayer,
|
|
2211
|
+
engine: Pick<EngineClient, "searchProcessInstances" | "searchUserTasks">,
|
|
2212
|
+
) {
|
|
2213
|
+
for (const run of await deliveryGraphRuns(data).find({ status: "running" })) {
|
|
2214
|
+
if (!run.process_key) continue;
|
|
2215
|
+
const processKey = run.process_key;
|
|
2216
|
+
try {
|
|
2217
|
+
const [snapshots, tasks] = await Promise.all([
|
|
2218
|
+
engine.searchProcessInstances({ processInstanceKeys: [processKey] }),
|
|
2219
|
+
engine.searchUserTasks({ processInstanceKey: processKey, state: "CREATED" }),
|
|
2220
|
+
]);
|
|
2221
|
+
const state = snapshots.find((s) => String(s.processInstanceKey) === processKey)?.state ?? null;
|
|
2222
|
+
const projection = deriveDeliveryPhase(state, tasks, parseHumanLabels(run.human_labels));
|
|
2223
|
+
if (run.status !== projection.status || run.phase !== projection.phase || run.phase_node_id !== projection.phase_node_id) {
|
|
2224
|
+
await deliveryGraphRuns(data).update(run.run_key, {
|
|
2225
|
+
status: projection.status,
|
|
2226
|
+
phase: projection.phase,
|
|
2227
|
+
phase_node_id: projection.phase_node_id,
|
|
2228
|
+
updated_at: now(),
|
|
2229
|
+
});
|
|
2230
|
+
}
|
|
2231
|
+
} catch (err) {
|
|
2232
|
+
console.error(`[poller] delivery graph ${run.run_key}: ${err}`);
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2199
2237
|
export async function pollUserTasks(
|
|
2200
2238
|
data: DataLayer,
|
|
2201
2239
|
engine: EngineClient,
|
|
@@ -2384,6 +2422,7 @@ export async function pollOnce(
|
|
|
2384
2422
|
await pollLineage(data);
|
|
2385
2423
|
await pollMergesPerDay(data);
|
|
2386
2424
|
await pollUserTasks(data, engine, engineRest);
|
|
2425
|
+
await pollDeliveryGraphPhase(data, engine);
|
|
2387
2426
|
if (engineRest) {
|
|
2388
2427
|
const base = engineRest.restAddress.replace(/\/+$/, "");
|
|
2389
2428
|
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
-- Contract phase for the escalation scope-override columns (migration 056, issue #395).
|
|
2
|
+
--
|
|
3
|
+
-- Migration 056 added `escalations.head_sha` + `escalations.scope_block` to give the DETERMINISTIC
|
|
4
|
+
-- scope-integrity gate a human-override door: it bound a scope escalation to the reviewed HEAD so a
|
|
5
|
+
-- same-HEAD human answer could override the block instead of re-escalating forever. That whole
|
|
6
|
+
-- deterministic gate has since been replaced by the `senior:scope-classify` AGENT classifier, which
|
|
7
|
+
-- reads each closed issue's acceptance criteria and honours the recorded human `answer` directly —
|
|
8
|
+
-- so nothing writes or reads these two columns any more. They are dead schema (a drift surface with
|
|
9
|
+
-- no source of truth behind them), so drop them.
|
|
10
|
+
--
|
|
11
|
+
-- 056 is forward-only and immutable (its ledger row stays), so this is the standard expand→contract
|
|
12
|
+
-- follow-up rather than an edit to 056. Both drops are safe: the columns were nullable/defaulted and
|
|
13
|
+
-- have no remaining writer or reader. Numbered after the current highest prefix (056); the runner
|
|
14
|
+
-- wraps each file in its own transaction, so this file must NOT contain BEGIN/COMMIT.
|
|
15
|
+
ALTER TABLE escalations DROP COLUMN scope_block;
|
|
16
|
+
ALTER TABLE escalations DROP COLUMN head_sha;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
-- The `startDeliveryGraph` run aggregate (ADR 0005 Decision 7, slice S5). The dispatch door
|
|
2
|
+
-- (`operations/startDeliveryGraph.ts`, POST /app/api/actions/start/delivery-graph) turns an
|
|
3
|
+
-- agent-authored `DeliveryGraph` into a RUNNING engine-native process (via the S4 runner) — but
|
|
4
|
+
-- because these graphs merge PRs and publish packages, dispatch is GATED on an approval of the
|
|
5
|
+
-- rendered preview and must be idempotent. This table is the aggregate that makes both properties
|
|
6
|
+
-- durable and gives the cockpit a row to show WHERE a run is parked:
|
|
7
|
+
--
|
|
8
|
+
-- • run_key (PK) — the idempotency key: a caller-supplied `idempotencyKey` or, by default, the
|
|
9
|
+
-- graph's content digest (`sha256(compiled.bpmn)[:12]`). A re-POST of the same graph collapses
|
|
10
|
+
-- onto the same row, so an in-flight (`running`) run short-circuits instead of double-launching
|
|
11
|
+
-- (mirrors `startPlan`'s `alreadyRunning`). The UNIQUE PK is the durable at-most-once fence.
|
|
12
|
+
-- • status — the run lifecycle: `awaiting-approval` (a side-effecting graph parked at the approval
|
|
13
|
+
-- gate — no instance started yet), `running` (dispatched to the engine), `done` (the instance
|
|
14
|
+
-- COMPLETED), `failed` (the instance TERMINATED, or instance-tracking reconciled a vanished
|
|
15
|
+
-- running instance — nano.app.json's `delivery_graph_runs` binding maps `onTerminated` to
|
|
16
|
+
-- `failed`), `abandoned` (a reserved terminal status in the lifecycle union, not currently
|
|
17
|
+
-- produced by the reconciler). `awaiting-approval`/`running` are the ACTIVE (in-flight) statuses shown in
|
|
18
|
+
-- the cockpit's active grid; only `running` is engine-instance-backed and thus instance-tracked
|
|
19
|
+
-- (nano.app.json keys off process_key), while `awaiting-approval` has no instance (process_key
|
|
20
|
+
-- NULL) and is display-only. The terminal three drop out of the cockpit's active grid.
|
|
21
|
+
-- • digest — the content-addressed approval token: a side-effecting graph dispatches only when the
|
|
22
|
+
-- caller presents `approvalToken == digest`. Persisted so a resumed/second POST can re-derive and
|
|
23
|
+
-- re-check it without recompiling out of band.
|
|
24
|
+
-- • phase / phase_node_id — the derived, display-only "where is it parked" projection the poller
|
|
25
|
+
-- (`pollDeliveryGraphPhase`) recomputes from the running instance's open user tasks (generalising
|
|
26
|
+
-- the epic_phase derived-phase machinery): e.g. "Parked on human node: manual OTP publish" vs a
|
|
27
|
+
-- bare "Running". The door seeds `phase` at write time — `Awaiting approval` for a parked
|
|
28
|
+
-- (awaiting-approval) row, `Running` at dispatch — and the poller then refines it from engine truth
|
|
29
|
+
-- for a running instance; `phase_node_id` is NULL until the poller projects a parked human node.
|
|
30
|
+
--
|
|
31
|
+
-- process_key is the started instance key (NULL while `awaiting-approval`); the counts are the
|
|
32
|
+
-- compiled graph's shape (nodes / human stop-points / side effects), stamped at dispatch so the grid
|
|
33
|
+
-- and the approval gate need no recompile to render.
|
|
34
|
+
CREATE TABLE IF NOT EXISTS delivery_graph_runs (
|
|
35
|
+
run_key TEXT PRIMARY KEY,
|
|
36
|
+
process_key TEXT,
|
|
37
|
+
process_definition_id TEXT,
|
|
38
|
+
digest TEXT NOT NULL,
|
|
39
|
+
status TEXT NOT NULL,
|
|
40
|
+
side_effecting INTEGER NOT NULL DEFAULT 0,
|
|
41
|
+
node_count INTEGER NOT NULL DEFAULT 0,
|
|
42
|
+
human_node_count INTEGER NOT NULL DEFAULT 0,
|
|
43
|
+
side_effect_count INTEGER NOT NULL DEFAULT 0,
|
|
44
|
+
title TEXT,
|
|
45
|
+
phase TEXT,
|
|
46
|
+
phase_node_id TEXT,
|
|
47
|
+
-- JSON map of compiled human-task element id (`delivery-human-task__<element>`) → a display label
|
|
48
|
+
-- (the human node's instruction first line, else its author node id). Stamped at dispatch so
|
|
49
|
+
-- `pollDeliveryGraphPhase` can render "Parked on human node: <label>" from the run row + the open
|
|
50
|
+
-- user tasks alone — no recompile of the graph in the poller.
|
|
51
|
+
human_labels TEXT,
|
|
52
|
+
created_at TEXT NOT NULL,
|
|
53
|
+
updated_at TEXT NOT NULL
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
-- The instance-tracking reconciler and pollDeliveryGraphPhase both scan by process instance key.
|
|
57
|
+
CREATE INDEX IF NOT EXISTS ix_delivery_graph_runs_process_key
|
|
58
|
+
ON delivery_graph_runs (process_key);
|
|
@@ -87,6 +87,12 @@ describe("nano-workforce PR review-loop escalation (U4 userTask)", () => {
|
|
|
87
87
|
capturedAnswer = (job.variables as Record<string, unknown>).answer;
|
|
88
88
|
return { status: "converged", summary: "resolved after the human answer" };
|
|
89
89
|
});
|
|
90
|
+
// `senior:scope-classify` is likewise an externalTaskType (no app worker): the converged round
|
|
91
|
+
// routes through it before finalizing. Stub it as "scope delivered" so the happy path reaches
|
|
92
|
+
// persist-converged rather than parking on an unserviced agent job.
|
|
93
|
+
await app.engine.registerWorker("senior:scope-classify", () => {
|
|
94
|
+
return { scopeBlocked: false, scopeBlockReason: "" };
|
|
95
|
+
});
|
|
90
96
|
});
|
|
91
97
|
|
|
92
98
|
after(async () => {
|