@nanobpm/nano-workforce 0.123.2 → 0.125.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +9 -5
  3. package/app/deliveryGraphDispatch.test.ts +143 -0
  4. package/app/deliveryGraphDispatch.ts +168 -0
  5. package/app/deliveryGraphProposals.test.ts +267 -0
  6. package/app/deliveryGraphProposals.ts +269 -0
  7. package/app/deliveryGraphRun.test.ts +6 -52
  8. package/app/deliveryGraphRun.ts +21 -76
  9. package/app/deliveryGraphText.ts +3 -3
  10. package/app/deliveryRunner.ts +4 -3
  11. package/app/featureReadModel.test.ts +97 -54
  12. package/app/featureReadModel.ts +152 -0
  13. package/app/service.ts +15 -0
  14. package/app/stage.ts +71 -77
  15. package/db/migrations/075_delivery_graph_proposals.sql +48 -0
  16. package/db/migrations/076_feature_read_model_declare_once.sql +53 -0
  17. package/docs/adr/0005-agent-authored-delivery-graphs.md +18 -0
  18. package/docs/adr/0006-delivery-units-one-representation.md +221 -0
  19. package/docs/agent-guide.md +50 -58
  20. package/e2e/delivery-graph-dispatch.e2e.ts +155 -0
  21. package/openapi.yaml +118 -161
  22. package/operations/compileDeliveryGraph.test.ts +100 -37
  23. package/operations/compileDeliveryGraph.ts +64 -18
  24. package/operations/dispatchDeliveryGraph.test.ts +171 -152
  25. package/operations/dispatchDeliveryGraph.ts +79 -99
  26. package/operations/getAgentInstructions.test.ts +10 -6
  27. package/operations/previewDeliveryGraph.test.ts +90 -51
  28. package/operations/previewDeliveryGraph.ts +45 -18
  29. package/package.json +2 -2
  30. package/pages/cockpit/mount.js +19 -12
  31. package/pages/delivery-graphs/mount.js +37 -137
  32. package/pages/delivery-graphs.page.json +50 -3
  33. package/scripts/check-migrations.test.ts +31 -0
  34. package/scripts/check-migrations.ts +40 -6
  35. package/test/cockpit-embed-endpoints.test.ts +59 -36
  36. package/test/delivery-graphs-embed.test.ts +36 -34
  37. package/e2e/delivery-graph-start.e2e.ts +0 -145
  38. package/operations/startDeliveryGraph.integration.test.ts +0 -316
  39. package/operations/startDeliveryGraph.ts +0 -222
@@ -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). Two
411
- doors take that JSON: a **pure `compile`** door you hammer while drafting, and a **gated
412
- `start`** door that dispatches it.
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 → fixapprove start
451
+ ### 9.2 The agent loop: draft → compile → stageask 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 # PURE — validate + preview, repeat freely
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 { ok:true, diagram, bpmn, resolved, humanNodes, sideEffects } → review the preview
456
- └─ POST __BASE__/actions/start/delivery-graph # GATED dispatch
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 (pure preview never deploys).** The fast inner loop. It runs the semantic
462
- validator and the deterministic compiler and returns a preview with **zero side effects**,
463
- so call it as often as you like:
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 { ok:true, diagram, bpmn, resolved, humanNodes, sideEffects }` — `diagram` is a
472
- mermaid `flowchart` of the resolved graph; `bpmn` is the compiled one-shot definition
473
- (deterministic same JSON byte-identical XML, **not** deployed here); `resolved` is
474
- the normalised graph; `humanNodes[]` are the stop-points where it waits for a person;
475
- `sideEffects[]` are the `agent`/`connector` actions it **will** perform (what a human
476
- approves).
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
- **Start (gated dispatch — the OUTER action).** `compile` and `start` are **separate**
482
- operations — there is deliberately **no `dryRun` flag** on start (Decision 5/7). The door
483
- re-validates, re-compiles, then launches the runner:
484
-
485
- ```bash
486
- # First submit a graph with side effects is refused and PARKED for approval:
487
- curl -sS -X POST __BASE__/actions/start/delivery-graph \
488
- -H 'content-type: application/json' \
489
- -d '{ "graph": { } }' | jq
490
- # 400 { ok:false, status:"awaiting-approval", runKey, digest, sideEffecting:true,
491
- # approvalToken:"<digest>", message:"graph has N side-effecting node(s); re-submit with approvalToken" }
492
-
493
- # Approve by re-submitting with the token (== the digest) you were handed:
494
- curl -sS -X POST __BASE__/actions/start/delivery-graph \
495
- -H 'content-type: application/json' \
496
- -d '{ "graph": { }, "approvalToken": "<digest>" }' | jq
497
- # 202 { ok:true, status:"running", runKey, digest, sideEffecting:true,
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-onlyNOT 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 ⇒ `start` **requires approval**: the first submit returns
561
- `awaiting-approval` with an `approvalToken`; re-submit carrying it to dispatch. The graph
562
- then runs to `manual-publish`, parks it on the Tasks inbox (`now do X`), and — once a human
563
- (or agent) completes it with the `publishedVersion` — binds that fact into `open-pr-c` and
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
+ });