@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,221 @@
|
|
|
1
|
+
# ADR 0006 — Delivery units: one representation for feature / epic / delivery-graph
|
|
2
|
+
|
|
3
|
+
Status: **Proposed.**
|
|
4
|
+
Date: 2026-08-22.
|
|
5
|
+
|
|
6
|
+
> **Scope note.** This is a **nano-workforce-local** ADR — it governs how *this app* represents a
|
|
7
|
+
> unit of delivery work internally. Platform-wide ADRs live in `Magikcraft/nano-bpm/docs/adr`
|
|
8
|
+
> (referenced by number + repo). nano-workforce's own series continues here after ADR 0005.
|
|
9
|
+
|
|
10
|
+
Relates to:
|
|
11
|
+
nano-workforce **ADR 0005** (agent-authored delivery graphs — this ADR carries out 0005's
|
|
12
|
+
already-stated framing that the delivery graph is the *general* form, of which an epic is a waved DAG
|
|
13
|
+
and a feature a degenerate 1-node graph, by converging the data and process encodings onto it),
|
|
14
|
+
nano-workforce **ADR 0001** (cross-repo epics + the generic `ReadinessProbe` wait-gate — the epic
|
|
15
|
+
substrate being consolidated),
|
|
16
|
+
nano-workforce **ADR 0002** (escalations are user tasks + forms — the human-escalation cell that is
|
|
17
|
+
one of the copy-pasted subprocesses),
|
|
18
|
+
nano-bpm **ADR 0065** (reconciling read models / `defineReadModel` — the derivation mechanism S1 uses
|
|
19
|
+
to collapse three bespoke status unions into one),
|
|
20
|
+
nano-ide **#424** (datasource can read a SQL VIEW — the *data-level* unlock),
|
|
21
|
+
nano-workforce **#416** (the PR bumping the testkit to engine-wasm 0.7.2, which executes `callActivity`
|
|
22
|
+
— the *process-level* unlock),
|
|
23
|
+
nano-workforce **#464** (the tracking issue with slices S1–S5),
|
|
24
|
+
nano-workforce **#305** (consolidate escalations on native `user_tasks` — a natural sub-step of S1/S3).
|
|
25
|
+
|
|
26
|
+
## Context
|
|
27
|
+
|
|
28
|
+
### One aggregate, encoded three times
|
|
29
|
+
|
|
30
|
+
nano-workforce models the same real-world thing — **a scheduled unit of work driven to a delivery
|
|
31
|
+
outcome** (typically an agent taking one slice to one merged PR, but a unit may instead `wait`, ask a
|
|
32
|
+
`human`, call a `connector`, or — for a feature — terminate `opened`/`converged` without a merged PR;
|
|
33
|
+
and an epic aggregates *many* such units/PRs) — in three separate representations, each with its own
|
|
34
|
+
table, status union, operator read-surface, `instanceTracking` binding, and dispatch door:
|
|
35
|
+
|
|
36
|
+
| Representation | Data | Process | Shape |
|
|
37
|
+
|---|---|---|---|
|
|
38
|
+
| **Feature** | `feature_runs` (mig. 028) | `resources/processes/feature.bpmn` | one issue → one PR (1-node) |
|
|
39
|
+
| **Epic** | `plans` + `plan_tasks` (mig. 004) | `resources/processes/plan-fanout.bpmn` | fan-out of slices → waves (N-node) |
|
|
40
|
+
| **Delivery graph** | `delivery_graph_runs` (mig. 058) + compiled nodes | compiled BPMN (`app/deliveryGraphCompiler.ts`) | arbitrary DAG |
|
|
41
|
+
|
|
42
|
+
All three are keyed by an issue/run key and carry their own status union. Feature and epic each also
|
|
43
|
+
project a dedicated display read-model VIEW; delivery-graph pages instead bind **directly** to
|
|
44
|
+
`delivery_graph_runs` (`pages/delivery-graphs.page.json`, `pages/delivery-graph-detail.page.json`).
|
|
45
|
+
Feature and epic execute through **hand-authored** BPMN (`feature.bpmn`, `plan-fanout.bpmn`) and
|
|
46
|
+
dispatch hard-coded `senior:*` implementation jobs that funnel downstream to the *same* `pull_requests`
|
|
47
|
+
table (keyed by `pr_key`) — the convergence/merge loop, which they correctly do **not** duplicate.
|
|
48
|
+
Delivery graphs are the exception: only they are **compiled** to BPMN from JSON at runtime, they accept
|
|
49
|
+
the submitted node kind and `agent.jobType` (including `wait`, `human`, and `connector` nodes), and
|
|
50
|
+
`delivery_graph_runs` carries no `pr_key`, so a graph is not inherently PR-producing. So for the
|
|
51
|
+
feature/epic implementation path the *downstream* half of the aggregate is already factored to a single
|
|
52
|
+
source of truth; only the *upstream* "unit of work" half is triplicated.
|
|
53
|
+
|
|
54
|
+
ADR 0005 already names this: "plan-fanout is an epic — a `RecordPlanTask[]` + `dependsOn[]` DAG with
|
|
55
|
+
waves", "convergence-loop is one PR", and a feature is the degenerate one-node graph. The delivery
|
|
56
|
+
graph is the general form. What 0005 did *not* do is converge the data and process encodings onto that
|
|
57
|
+
general form — so we still carry three near-duplicate sources of truth for one aggregate. That is
|
|
58
|
+
precisely the "no drift surfaces / derivation over duplication" hazard this project treats as a defect
|
|
59
|
+
class: a change to the meaning of "a unit of work" has to be made, by hand, in three places that can
|
|
60
|
+
silently drift.
|
|
61
|
+
|
|
62
|
+
### The duplication was *forced* by two "can't-reference" constraints — both now lifted
|
|
63
|
+
|
|
64
|
+
The triplication is not a design preference; it was compelled by two symmetric constraints, one on
|
|
65
|
+
each encoding. Both have now been removed on `main`, which is why consolidation becomes possible now
|
|
66
|
+
rather than earlier.
|
|
67
|
+
|
|
68
|
+
**Data — "the datasource can't read a SQL VIEW."** Because the read layer could not read a VIEW, every
|
|
69
|
+
*display projection* had to be a physically **denormalized table** — or denormalized columns
|
|
70
|
+
hand-maintained on a source table (e.g. migrations 022/029 on `plans`) — rather than a VIEW derived
|
|
71
|
+
from its source. That is what made a *shared* display projection impossible: each representation grew
|
|
72
|
+
its own hand-maintained projection. (The delivery-unit source tables themselves —
|
|
73
|
+
`feature_runs`/028, `plans`+`plan_tasks`/004, `delivery_graph_runs`/058 — are domain stores, not
|
|
74
|
+
projection artifacts.) **Unlocked by nano-ide#424** (the datasource can now read a VIEW) → the derived
|
|
75
|
+
read-model VIEWs added in migrations 059–062, 064, and 073 (later refined by 074/075), with 070–072
|
|
76
|
+
dropping the now-redundant denormalized columns/tables. **Live today.**
|
|
77
|
+
|
|
78
|
+
**Process — "the pinned WASM engine no-ops `callActivity`."** `app/deliveryGraphCompiler.ts:47-51`
|
|
79
|
+
(and again at 604-607) records the constraint verbatim: `callActivity` is "a no-op on the pinned WASM
|
|
80
|
+
engine (the child is never instantiated)", so the compiler — and every hand-written process —
|
|
81
|
+
**inlines** the subprocess body instead of referencing it. The consequence is that the atomic
|
|
82
|
+
*"agent-implement cell"* (`implement-task (senior:*) → "escalated?" gateway → record-escalation →
|
|
83
|
+
user-task → SLA boundary → answer gateway`) exists as **two hand-authored copies** — `feature.bpmn` and
|
|
84
|
+
the multi-instance `implement` subprocess in `plan-fanout.bpmn` — plus a **third generator** in the
|
|
85
|
+
compiler that re-emits it once per graph node. Sibling cells
|
|
86
|
+
(readiness-poll, human-escalation) duplicate the same way. **No `callActivity` exists in any diagram**
|
|
87
|
+
because it did nothing. **Unlocked by #416** (engine-wasm 0.4.0 → **0.7.2**). engine-core executes
|
|
88
|
+
`callActivity` by inline-expanding the called process at deploy (`engine-core/src/model.rs:1217/1255`).
|
|
89
|
+
**Verified live:** a `callActivity` parent+child model deployed through engine-wasm 0.7.2 runs to
|
|
90
|
+
`COMPLETED`. Caveat: #416 bumps only the **dev-only** `@nanobpm/urban-testkit`; the production
|
|
91
|
+
`@nanobpm/urban` broker does not itself pin `engine-wasm`, so this verification proves the in-process
|
|
92
|
+
testkit, not the broker/runtime that will execute future `callActivity` models. S4/S5 therefore also
|
|
93
|
+
carry a **deployment-runtime prerequisite** — the deployed broker's `engine-core` must carry the same
|
|
94
|
+
`callActivity` support — which green testkit CI does not by itself guarantee.
|
|
95
|
+
|
|
96
|
+
### The two constraints are the *same* constraint
|
|
97
|
+
|
|
98
|
+
Both are "an encoding can't *reference* a shared definition, so it *inlines a copy* of it." Data
|
|
99
|
+
inlined projection tables; process inlined subprocess bodies. Both share the same aggregate (the
|
|
100
|
+
delivery unit), the same fix shape (remove the can't-reference constraint, then reference instead of
|
|
101
|
+
copy), and both already have their downstream half factored correctly (`pull_requests`; the
|
|
102
|
+
convergence/merge loop). Removing one constraint without the other would leave the aggregate
|
|
103
|
+
half-consolidated; removing both is what makes a single representation reachable.
|
|
104
|
+
|
|
105
|
+
## Decision
|
|
106
|
+
|
|
107
|
+
Adopt a single internal aggregate — the **delivery unit** — defined around its **nodes**: a node is one
|
|
108
|
+
**scheduled unit of work** whose executor may be an **agent, probe, human, or connector** (ADR 0005
|
|
109
|
+
`wait`/`human`/`connector` nodes are first-class, not exceptions). Its terminal is *typically* one merged
|
|
110
|
+
PR, but PR-less nodes and delivery graphs (`delivery_graph_runs` has no `pr_key`) make PR production
|
|
111
|
+
**optional**.
|
|
112
|
+
It has **two encodings**, each of which now *references* the shared definition rather than inlining
|
|
113
|
+
a copy — expressed here as the **target** state:
|
|
114
|
+
|
|
115
|
+
### 1. Data encoding — one `delivery_unit` aggregate
|
|
116
|
+
|
|
117
|
+
- A `delivery_units` table is the single source of truth for "a unit of work." Feature = a 1-node
|
|
118
|
+
unit; Epic = an N-node waved unit; DeliveryGraph = an arbitrary-DAG unit — a **shape**, not a
|
|
119
|
+
separate table.
|
|
120
|
+
- `feature_runs` / `plans` + `plan_tasks` / `delivery_graph_runs` become **derived VIEWs / rows** over
|
|
121
|
+
`delivery_units` (using the nano-ide#424 VIEW capability), not independent tables. The epic case
|
|
122
|
+
covers **both** levels: the `plans` aggregate row (process key, status, title, lifecycle) becomes a
|
|
123
|
+
row/VIEW over `delivery_units`, and each `plan_tasks` slice becomes a node under it.
|
|
124
|
+
- The three `instanceTracking` bindings and `senior:*` dispatch doors collapse toward one, keyed on
|
|
125
|
+
the delivery unit.
|
|
126
|
+
- **Identity.** `delivery_units` carries a stable `unit_id` plus `(unit_id, node_id)` for the N-node
|
|
127
|
+
cases. The legacy keys are not interchangeable — a feature run and an active epic may share one
|
|
128
|
+
`<owner>/<repo>#<N>` key (`app/feature.ts`), while delivery graphs key on a caller idempotency key or
|
|
129
|
+
content digest (`app/deliveryGraphRun.ts` `computeRunKey`) — so S2's compatibility VIEWs map each
|
|
130
|
+
legacy key onto the new identity, ensuring unrelated runs are never merged onto one row.
|
|
131
|
+
|
|
132
|
+
### 2. Process encoding — shared cells composed by `callActivity`
|
|
133
|
+
|
|
134
|
+
- Extract the atomic *implement-cell* (and its sibling wait-gate and human-escalation cells) into
|
|
135
|
+
standalone processes (`resources/processes/implement-cell.bpmn`, …).
|
|
136
|
+
- Compose them by reference — replacing only the inlined *implement/escalation segment*, not the
|
|
137
|
+
surrounding orchestration: in **feature** (`feature.bpmn`) the readiness preflight, base-branch setup,
|
|
138
|
+
`record-feature`, and convergence handoff are retained; only the implement-cell segment becomes one
|
|
139
|
+
`callActivity`. **Epic** = the multi-instance `implement` body is a `callActivity`; **delivery graph**
|
|
140
|
+
= the compiler *emits* `callActivity` references, not inlined subprocess copies.
|
|
141
|
+
- This is gated on the engine-wasm 0.7.2 unlock, which is now live on `main`.
|
|
142
|
+
|
|
143
|
+
### 3. Status lifecycle — one derived union
|
|
144
|
+
|
|
145
|
+
The three bespoke status unions collapse into **one derived union** via ADR 0065's `defineReadModel`,
|
|
146
|
+
so a change to lifecycle semantics is made once and derived everywhere, not re-declared per
|
|
147
|
+
representation. These unions are **not** identical today — features use
|
|
148
|
+
`running`/`escalated`/`awaiting_operator`/…, plans use `planning`/`dispatched`/`done`, and graphs use
|
|
149
|
+
`running`/`done` with a reserved `awaiting-approval` (`app/feature.ts`, `app/plan.ts`,
|
|
150
|
+
`app/deliveryGraphRun.ts`); §1 additionally makes each `plan_tasks` row a **node**, which carries its own
|
|
151
|
+
`PlanTaskStatus` (`pending`/`waiting-for-lane`/…, `app/plan.ts`). So S1 owns defining the canonical
|
|
152
|
+
**aggregate** state set *and* explicitly deciding whether **node** status is part of that union or a
|
|
153
|
+
separate node contract — plus the per-shape mapping and precedence and the write/`instanceTracking`
|
|
154
|
+
behavior — not merely projecting an existing value.
|
|
155
|
+
|
|
156
|
+
### 4. Preserve — the static-vs-adaptive execution axis (do NOT bundle it)
|
|
157
|
+
|
|
158
|
+
This ADR consolidates the *representation*, not the *execution strategy*. ADR 0005's deliberate
|
|
159
|
+
distinction stays intact: **plan-fanout remains adaptive** (agent-discovered slices, waves that adapt),
|
|
160
|
+
**delivery graphs remain static/compiled**. Both are still *delivery units*; they differ only in how
|
|
161
|
+
their topology is produced. Unifying that axis is explicitly out of scope here.
|
|
162
|
+
|
|
163
|
+
## Consequences
|
|
164
|
+
|
|
165
|
+
- **Single source of truth for a unit of work.** A change to the meaning of "a delivery unit" — a new
|
|
166
|
+
status, a lifecycle rule, a step in the implement cell — is made once and derived into every
|
|
167
|
+
representation, eliminating the three-way drift surface this project treats as a defect class.
|
|
168
|
+
- **Renderability + executability both improve.** One implement-cell process is one thing to keep
|
|
169
|
+
deploy-valid and lay out, instead of **two hand-authored copies plus a compiler generator** that can
|
|
170
|
+
silently diverge (a graph can render and still fail deploy — the copies are exactly where that
|
|
171
|
+
divergence hides).
|
|
172
|
+
- **Migration is incremental and forward-only.** The VIEWs preserve every current read shape while the
|
|
173
|
+
physical model consolidates underneath, and each slice below is independently shippable. Consistent
|
|
174
|
+
with this repo's forward-only, expand-and-contract migration contract (see
|
|
175
|
+
`070_drop_plan_projection_columns.sql`, which treats dropping projection columns as a later contract
|
|
176
|
+
phase), a slice is **not** reverted by reverting the app: rolling back a writer-repointing or
|
|
177
|
+
table-to-VIEW slice requires a **separately designed recovery/compatibility migration**, not a plain
|
|
178
|
+
revert.
|
|
179
|
+
- **Cost.** A backfill/migration for `delivery_units`; a one-time extraction of the shared cells; and
|
|
180
|
+
the process slices are sequenced behind the (now-live) engine-wasm unlock. No behaviour change is
|
|
181
|
+
intended — this is a representation consolidation, guarded by parity tests against the existing VIEWs
|
|
182
|
+
and by the deploy+run engine tests.
|
|
183
|
+
|
|
184
|
+
## Rollout (see #464 for the live checklist)
|
|
185
|
+
|
|
186
|
+
Each slice is independently shippable; the process slices (S4/S5) are sequenced behind the engine-wasm
|
|
187
|
+
0.7.2 unlock. The **dev-testkit** side of that unlock has landed (#416, verified in-process above); S4/S5
|
|
188
|
+
additionally gate on the **deployed broker/runtime** carrying verified `callActivity` support (the
|
|
189
|
+
deployment-runtime prerequisite noted above), not on #416 alone.
|
|
190
|
+
|
|
191
|
+
- **S0 · ADR** — this record.
|
|
192
|
+
- **S1 · status lifecycle** — one derived status union via ADR 0065 `defineReadModel`, replacing the
|
|
193
|
+
three bespoke unions. This **depends on and overlaps** #305 (consolidate escalations on native
|
|
194
|
+
`user_tasks`) but does not subsume it: #305 additionally retires the `feature_runs` escalation
|
|
195
|
+
columns and the bespoke completion doors and updates the escalation UI/forms, which remain #305's
|
|
196
|
+
scope (an adjacent sub-step of S1/S3 per #464).
|
|
197
|
+
- **S2 · `delivery_units` table** — the aggregate. Because current code still **writes**
|
|
198
|
+
`feature_runs` / `plans` / `plan_tasks` / `delivery_graph_runs` directly (`app/feature.ts`,
|
|
199
|
+
`app/plan.ts`, `app/deliveryGraphRun.ts`) — **and** the framework's `instanceTracking` bindings in
|
|
200
|
+
`nano.app.json` write termination status to `feature_runs`, `plans`, and `delivery_graph_runs` — and a
|
|
201
|
+
SQLite VIEW is read-only, follow expand/contract **order**: (a) add `delivery_units` and dual-write it
|
|
202
|
+
alongside the legacy tables; (b) backfill existing/legacy rows; (c) repoint reads to VIEWs/rows derived
|
|
203
|
+
from `delivery_units`, guarded by read-model parity tests. The legacy tables stay **physical
|
|
204
|
+
(writable) through S2** — they must **not** become read-only VIEWs while any writer, including the
|
|
205
|
+
`instanceTracking` termination-reconciliation bindings, still targets them, or reconciliation fails
|
|
206
|
+
with no writable target left for S3 to move. (d) Only after **S3** has moved those `instanceTracking`
|
|
207
|
+
bindings and every other writer off the legacy tables does the table-to-VIEW contract phase retire the
|
|
208
|
+
legacy write paths.
|
|
209
|
+
- **S3 · collapse doors** — unify the three `instanceTracking` bindings + `senior:*` dispatch doors.
|
|
210
|
+
- **S4 · shared cells** — extract the atomic `implement-cell.bpmn` **and its sibling wait-gate and
|
|
211
|
+
human-escalation cells** (Decision §2) into standalone processes; `feature.bpmn` + the `plan-fanout`
|
|
212
|
+
MI body compose them via `callActivity`.
|
|
213
|
+
- **S5 · compiler emits calls** — `deliveryGraphCompiler` references shared cells instead of inlining
|
|
214
|
+
per-node copies.
|
|
215
|
+
|
|
216
|
+
## Non-goals / deferred
|
|
217
|
+
|
|
218
|
+
- **Unifying the static-vs-adaptive execution axis** (see Decision §4) — preserved deliberately.
|
|
219
|
+
- **Changing the downstream PR/convergence loop** — already single-sourced (`pull_requests`); untouched.
|
|
220
|
+
- **Cross-repo/platform representation** — this ADR is nano-workforce-local; any platform-wide delivery
|
|
221
|
+
aggregate would be a separate nano-bpm ADR.
|
package/docs/agent-guide.md
CHANGED
|
@@ -407,9 +407,12 @@ PR #202 → a human does a manual OTP publish → PR #303 consumes the just-publ
|
|
|
407
407
|
lets you compose exactly that as **data** and hand it to a generic runner.
|
|
408
408
|
|
|
409
409
|
You author the graph as **JSON — never BPMN or code** (Decision 1: the agent must never
|
|
410
|
-
author the executable artifact; the closed node vocabulary is the trust boundary).
|
|
411
|
-
|
|
412
|
-
|
|
410
|
+
author the executable artifact; the closed node vocabulary is the trust boundary). Your
|
|
411
|
+
surface ends at **propose → compile → stage**: a single `compile` door validates the JSON,
|
|
412
|
+
renders a preview, and — when valid — **stages the compiled graph as a proposal** for a
|
|
413
|
+
human. **Dispatch is an operator action in the cockpit, not an agent endpoint** (issue #460):
|
|
414
|
+
there is deliberately no `start` door on the agent surface, so there is nothing an agent can
|
|
415
|
+
call — or replay — to launch a run. A human previews the staged proposal and dispatches it.
|
|
413
416
|
|
|
414
417
|
### 9.1 The `DeliveryGraph` shape
|
|
415
418
|
|
|
@@ -445,22 +448,23 @@ A **typed fact** (`emits[]` entry) is `{ name, type, description? }` where
|
|
|
445
448
|
downstream as `<nodeId>.<name>`. A "click done" human node or a pass-through node declares
|
|
446
449
|
no facts.
|
|
447
450
|
|
|
448
|
-
### 9.2 The agent loop: draft → compile →
|
|
451
|
+
### 9.2 The agent loop: draft → compile → stage → ask an operator to dispatch
|
|
449
452
|
|
|
450
453
|
```
|
|
451
454
|
GET __BASE__/agent # ← you are reading it; learn the vocabulary + endpoints
|
|
452
455
|
└─ draft a DeliveryGraph JSON
|
|
453
|
-
└─ POST __BASE__/actions/compile-delivery-graph #
|
|
456
|
+
└─ POST __BASE__/actions/compile-delivery-graph # validate + preview + STAGE
|
|
454
457
|
├─ 400 { ok:false, errors:[{path,message}] } → fix the exact offending input, recompile
|
|
455
|
-
└─ 200 {
|
|
456
|
-
└─
|
|
457
|
-
├─ 400 awaiting-approval (has side effects) → re-POST with approvalToken
|
|
458
|
-
└─ 202 running → track it like a plan (§5)
|
|
458
|
+
└─ 200 { status:"ready", message, digest, preview, reviewUrl }
|
|
459
|
+
└─ ask the operator to preview + dispatch it in the cockpit (there is no start door)
|
|
459
460
|
```
|
|
460
461
|
|
|
461
|
-
**Compile (
|
|
462
|
-
|
|
463
|
-
|
|
462
|
+
**Compile (validate + preview + stage).** The compile door runs the semantic validator and
|
|
463
|
+
the deterministic compiler and, on success, **stages** the compiled graph as a proposal a
|
|
464
|
+
human can dispatch — it does **not** deploy or run anything. Recompiling a graph you are
|
|
465
|
+
still drafting is safe: a re-compile of the same graph is idempotent, and a changed graph
|
|
466
|
+
with the same `name` supersedes the prior staged proposal, so the cockpit shows exactly one
|
|
467
|
+
live proposal per graph.
|
|
464
468
|
|
|
465
469
|
```bash
|
|
466
470
|
curl -sS -X POST __BASE__/actions/compile-delivery-graph \
|
|
@@ -468,49 +472,37 @@ curl -sS -X POST __BASE__/actions/compile-delivery-graph \
|
|
|
468
472
|
-d @graph.json | jq
|
|
469
473
|
```
|
|
470
474
|
|
|
471
|
-
- `200 {
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
`
|
|
476
|
-
|
|
475
|
+
- `200 { status:"ready", message, digest, preview, reviewUrl }` — the graph compiled and is
|
|
476
|
+
**staged for operator review**. `digest` is the content-address that NAMES the proposal (so
|
|
477
|
+
you can tell the operator exactly which one to dispatch); `preview` is `{ diagram,
|
|
478
|
+
sideEffects, humanNodes }` — `diagram` is a mermaid `flowchart` of the resolved graph,
|
|
479
|
+
`humanNodes[]` are the stop-points where it waits for a person, and `sideEffects[]` are the
|
|
480
|
+
`agent`/`connector` actions it **will** perform once an operator dispatches it; `reviewUrl`
|
|
481
|
+
is a **navigational** cockpit deep-link (a pointer only — **not** a dispatch handle). The
|
|
482
|
+
response carries **no run key, no token, and no process-instance key**: nothing you can
|
|
483
|
+
replay to start a run. Your role ends here — hand the operator the `digest` (or `reviewUrl`)
|
|
484
|
+
and ask them to preview and dispatch it.
|
|
477
485
|
- `400 { ok:false, errors:[{ path, message }] }` — every error path-qualified
|
|
478
486
|
(`nodes[2].kind`, `edges[1].from`, …) for unknown kind, dangling edge, a cycle, or an
|
|
479
|
-
unresolvable `from` fact. Fix and recompile.
|
|
480
|
-
|
|
481
|
-
**
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
# alreadyRunning:false, processInstanceKey, processDefinitionId:"delivery-graph-<digest>" }
|
|
499
|
-
```
|
|
500
|
-
|
|
501
|
-
The request body is `{ graph, approvalToken?, idempotencyKey? }`:
|
|
502
|
-
|
|
503
|
-
| field | type | meaning |
|
|
504
|
-
|---|---|---|
|
|
505
|
-
| `graph` | `DeliveryGraph` | the JSON graph. Required. |
|
|
506
|
-
| `approvalToken` | string | the approval **of the rendered preview** (Decision 7). A graph with any **side-effecting** (`agent`/`connector`) node — one that merges PRs / publishes — dispatches **only** when you present its content-addressed token (the `digest`, returned on the first unapproved submit). A graph with **only** `wait`/`human` nodes needs none and dispatches straight away. |
|
|
507
|
-
| `idempotencyKey` | string | optional. A re-POST with the same key (or, when omitted, the same graph — the default key is the content digest) does **not** double-launch: an in-flight run short-circuits with `alreadyRunning: true`. |
|
|
508
|
-
|
|
509
|
-
The running graph registers as a run aggregate, so its current phase / parked node shows
|
|
510
|
-
in the cockpit's **Active Delivery Graphs** grid (e.g. *"parked on human node: manual OTP
|
|
511
|
-
publish"*). Track it like a plan (§5) via its `processInstanceKey`. A `human` node parks
|
|
512
|
-
on the **Tasks** inbox and is answered exactly as an escalation is (§3) — its completion
|
|
513
|
-
emits any declared facts, which downstream edges bind.
|
|
487
|
+
unresolvable `from` fact. Fix and recompile; nothing is staged.
|
|
488
|
+
|
|
489
|
+
**Dispatch (operator-only — NOT on the agent surface).** There is deliberately no agent
|
|
490
|
+
`start` endpoint (Decision 5/7, issue #460). Dispatch is a human action: an operator opens
|
|
491
|
+
the **Delivery Graphs** page in the cockpit, reviews the staged proposal's rendered preview
|
|
492
|
+
(its diagram, the human stop-points, and the side effects a dispatch authorises), and clicks
|
|
493
|
+
**Dispatch** on the one they approve. The operator clicking Dispatch **is** the approval — it
|
|
494
|
+
is content-addressed to the exact digest they previewed, so it cannot be a replay of some
|
|
495
|
+
other graph. Once dispatched, the graph deploys + runs engine-natively and registers as a run
|
|
496
|
+
aggregate, so its current phase / parked node shows in the cockpit's **Active Delivery
|
|
497
|
+
Graphs** grid (e.g. *"parked on human node: manual OTP publish"*). A `human` node parks on the
|
|
498
|
+
**Tasks** inbox and is answered exactly as an escalation is (§3) — its completion emits any
|
|
499
|
+
declared facts, which downstream edges bind.
|
|
500
|
+
|
|
501
|
+
> **Why the split?** Making the compile door the end of the agent surface closes a
|
|
502
|
+
> self-approval hole: the old flow handed the same caller a content-addressed approval token
|
|
503
|
+
> to re-submit with, so any holder of the API credential approved its own graph. Removing the
|
|
504
|
+
> dispatch affordance from the agent surface entirely (capability by absence) means there is
|
|
505
|
+
> nothing to replay — the human in the cockpit is the only actor who can launch side effects.
|
|
514
506
|
|
|
515
507
|
### 9.3 Worked example — the cross-repo human-in-the-loop release
|
|
516
508
|
|
|
@@ -557,11 +549,11 @@ humanNodes: [ { nodeId: "manual-publish", emits: [ { name: "publishedVersion", t
|
|
|
557
549
|
sideEffects: [ { nodeId: "open-pr-c", kind: "agent", … }, { nodeId: "undraft-merge-b", kind: "agent", … } ]
|
|
558
550
|
```
|
|
559
551
|
|
|
560
|
-
Two side-effecting `agent` nodes ⇒
|
|
561
|
-
`
|
|
562
|
-
|
|
563
|
-
(or agent) completes it with the `publishedVersion` — binds that fact into
|
|
564
|
-
carries on to `merge-c`.
|
|
552
|
+
Two side-effecting `agent` nodes ⇒ the compile door **stages** the proposal and hands you a
|
|
553
|
+
`digest` + `reviewUrl`; ask an operator to preview and **Dispatch** it in the cockpit. Once
|
|
554
|
+
they do, the graph runs to `manual-publish`, parks it on the Tasks inbox (`now do X`), and —
|
|
555
|
+
once a human (or agent) completes it with the `publishedVersion` — binds that fact into
|
|
556
|
+
`open-pr-c` and carries on to `merge-c`.
|
|
565
557
|
|
|
566
558
|
To swap the manual PR-#303 path for a **capability** edge instead of a raw `pr` watch, make
|
|
567
559
|
the consumer a `wait` node with `kind: "capability"` (resolving *which published
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// End-to-end proof of the OPERATOR DISPATCH flow (ADR 0005 Decision 7, issue #460) driven through its
|
|
2
|
+
// REAL ingress: the agent `compileDeliveryGraph` door STAGES a proposal, and the operator
|
|
3
|
+
// `dispatchDeliveryGraph` door launches the one the operator picked BY DIGEST. Hermetic: deterministic
|
|
4
|
+
// virtual clock, no network. It proves the acceptance the slice hinges on:
|
|
5
|
+
//
|
|
6
|
+
// • AGENT SURFACE ENDS AT STAGE: compiling a side-effecting graph returns a `ready` preview + a
|
|
7
|
+
// content `digest` and STAGES a durable `delivery_graph_proposals` row — but NO run key, token, or
|
|
8
|
+
// PIK, and NO engine instance is started (the agent cannot reach a run through its surface).
|
|
9
|
+
// • OPERATOR DISPATCH: dispatching that digest deploys + runs the graph engine-natively (the agent
|
|
10
|
+
// side effect fires), marks the proposal `dispatched`, and the run's derived phase shows WHERE it
|
|
11
|
+
// is parked ("Parked on human node: …") via the same `pollDeliveryGraphPhase` projection.
|
|
12
|
+
// • NO REPLAY: there is no agent `start/delivery-graph` operation to call — the self-approval hole is
|
|
13
|
+
// closed by absence.
|
|
14
|
+
// • COMPLETION: when the instance ends, the poller reconciles the run to `done`.
|
|
15
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { join, resolve } from "node:path";
|
|
18
|
+
import { after, describe, test } from "node:test";
|
|
19
|
+
import assert from "node:assert/strict";
|
|
20
|
+
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
21
|
+
import { deliveryGraphProposals } from "../app/deliveryGraphProposals.ts";
|
|
22
|
+
import { deliveryGraphRuns } from "../app/deliveryGraphRun.ts";
|
|
23
|
+
import { pollDeliveryGraphPhase } from "../app/service.ts";
|
|
24
|
+
import type { DeliveryGraph } from "../nano-generated/api-io.d.ts";
|
|
25
|
+
|
|
26
|
+
const APP_ROOT = resolve(import.meta.dirname, "..");
|
|
27
|
+
const GITHUB_ENV: Record<string, string> = { NANO_PR_GITHUB_TRANSPORT: "token", GITHUB_TOKEN: "" };
|
|
28
|
+
|
|
29
|
+
interface StagedResult {
|
|
30
|
+
status: string;
|
|
31
|
+
message: string;
|
|
32
|
+
digest: string;
|
|
33
|
+
reviewUrl?: string;
|
|
34
|
+
}
|
|
35
|
+
interface DispatchResult {
|
|
36
|
+
ok: boolean;
|
|
37
|
+
status?: string;
|
|
38
|
+
runKey?: string;
|
|
39
|
+
digest?: string;
|
|
40
|
+
sideEffecting?: boolean;
|
|
41
|
+
alreadyRunning?: boolean;
|
|
42
|
+
processInstanceKey?: string;
|
|
43
|
+
error?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// A side-effecting graph: an `agent` side effect gated ahead of a `human` stop. Dispatch is an operator
|
|
47
|
+
// action (the agent + the human-facing merge/publish class of graphs Decision 7 protects).
|
|
48
|
+
const GRAPH: DeliveryGraph = {
|
|
49
|
+
name: "release runbook e2e",
|
|
50
|
+
nodes: [
|
|
51
|
+
{ id: "open", kind: "agent", agent: { jobType: "senior:demo", prompt: "open + prep" } },
|
|
52
|
+
{ id: "publish", kind: "human", human: { prompt: "run the manual OTP publish" } },
|
|
53
|
+
],
|
|
54
|
+
edges: [{ from: "open", to: "publish" }],
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
describe("delivery-graph dispatch — agent compiles→stages, operator dispatches by digest, idempotent (#460)", () => {
|
|
58
|
+
const dirs: string[] = [];
|
|
59
|
+
const apps: TestApp[] = [];
|
|
60
|
+
after(async () => {
|
|
61
|
+
for (const app of apps) await app.stop?.();
|
|
62
|
+
for (const d of dirs) rmSync(d, { recursive: true, force: true });
|
|
63
|
+
});
|
|
64
|
+
const boot = async (): Promise<TestApp> => {
|
|
65
|
+
const d = mkdtempSync(join(tmpdir(), "nwf-delivery-dispatch-e2e-"));
|
|
66
|
+
dirs.push(d);
|
|
67
|
+
const app = await bootTestApp(APP_ROOT, { env: { ...GITHUB_ENV, NANO_APP_DB_URL: `file:${join(d, "app.db")}` } });
|
|
68
|
+
apps.push(app);
|
|
69
|
+
return app;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
test("the agent compile door stages a proposal (no run handle); the operator dispatches it by digest, once", async () => {
|
|
73
|
+
const app = await boot();
|
|
74
|
+
assert.ok(app.api, "app declares an `api` binding");
|
|
75
|
+
const api = app.api;
|
|
76
|
+
|
|
77
|
+
let agentFired = 0;
|
|
78
|
+
await app.engine.registerWorker("senior:demo", async () => {
|
|
79
|
+
agentFired++;
|
|
80
|
+
return {};
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ── Agent surface ends at stage: compile → ready + digest, staged, NOTHING launched ───────────
|
|
84
|
+
const staged = await api.call<StagedResult>("compileDeliveryGraph", { body: GRAPH });
|
|
85
|
+
assert.equal(staged.status, 200, "a valid graph compiles");
|
|
86
|
+
assert.equal(staged.body.status, "ready");
|
|
87
|
+
assert.ok(staged.body.digest, "the response carries the content digest that names the proposal");
|
|
88
|
+
// The self-approval hole is closed by ABSENCE: no run key / token / PIK in the response.
|
|
89
|
+
assert.equal((staged.body as unknown as Record<string, unknown>).runKey, undefined);
|
|
90
|
+
assert.equal((staged.body as unknown as Record<string, unknown>).approvalToken, undefined);
|
|
91
|
+
assert.equal((staged.body as unknown as Record<string, unknown>).processInstanceKey, undefined);
|
|
92
|
+
await app.settle();
|
|
93
|
+
assert.equal(agentFired, 0, "a staged graph never dispatched its side effect");
|
|
94
|
+
assert.equal((await deliveryGraphRuns(app.db).all()).length, 0, "no run row while merely staged");
|
|
95
|
+
|
|
96
|
+
// The proposal is durable + visible for operator dispatch.
|
|
97
|
+
const digest = staged.body.digest;
|
|
98
|
+
const proposal = await deliveryGraphProposals(app.db).get(digest);
|
|
99
|
+
assert.ok(proposal, "a delivery_graph_proposals row exists for the staged graph");
|
|
100
|
+
assert.equal(proposal?.status, "staged");
|
|
101
|
+
|
|
102
|
+
// ── Operator dispatch: dispatch the digest → deploys + runs engine-natively ───────────────────
|
|
103
|
+
const dispatched = await api.call<DispatchResult>("dispatchDeliveryGraph", { body: { digest } });
|
|
104
|
+
assert.equal(dispatched.status, 202, "dispatching a staged digest launches the run");
|
|
105
|
+
assert.equal(dispatched.body.status, "running");
|
|
106
|
+
assert.equal(dispatched.body.alreadyRunning, false);
|
|
107
|
+
assert.ok(dispatched.body.processInstanceKey, "the run carries the started engine instance key");
|
|
108
|
+
await app.settle();
|
|
109
|
+
assert.equal(agentFired, 1, "the agent side effect fired exactly once");
|
|
110
|
+
assert.equal((await deliveryGraphProposals(app.db).get(digest))?.status, "dispatched", "the proposal is consumed");
|
|
111
|
+
|
|
112
|
+
// Exactly one running run, carrying its instance.
|
|
113
|
+
const runningRows = await deliveryGraphRuns(app.db).find({ status: "running" });
|
|
114
|
+
assert.equal(runningRows.length, 1, "exactly one running run");
|
|
115
|
+
assert.equal(runningRows[0]?.process_key, dispatched.body.processInstanceKey);
|
|
116
|
+
const runKey = runningRows[0]?.run_key as string;
|
|
117
|
+
|
|
118
|
+
// ── Cockpit phase: the poller derives WHERE the run is parked (the human node) ─────────────────
|
|
119
|
+
await pollDeliveryGraphPhase(app.db, app.engine);
|
|
120
|
+
const phased = await deliveryGraphRuns(app.db).get(runKey);
|
|
121
|
+
assert.equal(phased?.status, "running");
|
|
122
|
+
assert.match(String(phased?.phase), /^Parked on human node:/, `phase shows the parked human node, got ${phased?.phase}`);
|
|
123
|
+
|
|
124
|
+
// ── No replay: the consumed proposal cannot re-launch ─────────────────────────────────────────
|
|
125
|
+
const replay = await api.call<DispatchResult>("dispatchDeliveryGraph", { body: { digest } });
|
|
126
|
+
assert.equal(replay.status, 400, "an already-dispatched digest cannot be re-dispatched");
|
|
127
|
+
await app.settle();
|
|
128
|
+
assert.equal(agentFired, 1, "the agent side effect STILL fired only once (no double-launch)");
|
|
129
|
+
|
|
130
|
+
// ── Completion: complete the human stop → the instance ends → the poller reconciles to done ───
|
|
131
|
+
const open = await app.engine.searchUserTasks({ state: "CREATED" });
|
|
132
|
+
const human = open.find((t) => t.elementId?.startsWith("delivery-human-task__") && !t.elementId?.endsWith("__esc"));
|
|
133
|
+
assert.ok(human, `a human user task is open, got ${JSON.stringify(open.map((t) => t.elementId))}`);
|
|
134
|
+
await app.engine.completeUserTask(human.userTaskKey, { humanOutcome: "completed" });
|
|
135
|
+
await app.settle();
|
|
136
|
+
await pollDeliveryGraphPhase(app.db, app.engine);
|
|
137
|
+
const done = await deliveryGraphRuns(app.db).get(runKey);
|
|
138
|
+
assert.equal(done?.status, "done", "the completed instance reconciled the run to done");
|
|
139
|
+
assert.equal(done?.phase, "Completed");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("a non-side-effecting (human-only) graph: stage then dispatch runs it straight away", async () => {
|
|
143
|
+
const app = await boot();
|
|
144
|
+
assert.ok(app.api);
|
|
145
|
+
const api = app.api;
|
|
146
|
+
const graph: DeliveryGraph = { name: "manual gate", nodes: [{ id: "ack", kind: "human", human: { prompt: "click done" } }] };
|
|
147
|
+
const staged = await api.call<StagedResult>("compileDeliveryGraph", { body: graph });
|
|
148
|
+
assert.equal(staged.status, 200);
|
|
149
|
+
const res = await api.call<DispatchResult>("dispatchDeliveryGraph", { body: { digest: staged.body.digest } });
|
|
150
|
+
assert.equal(res.status, 202, "dispatching a human-only graph runs it");
|
|
151
|
+
assert.equal(res.body.status, "running");
|
|
152
|
+
assert.equal(res.body.sideEffecting, false);
|
|
153
|
+
assert.ok(res.body.processInstanceKey);
|
|
154
|
+
});
|
|
155
|
+
});
|