@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.
- package/CHANGELOG.md +20 -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/featureReadModel.test.ts +97 -54
- package/app/featureReadModel.ts +152 -0
- package/app/service.ts +15 -0
- package/app/stage.ts +71 -77
- package/db/migrations/075_delivery_graph_proposals.sql +48 -0
- package/db/migrations/076_feature_read_model_declare_once.sql +53 -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 +2 -2
- 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 +31 -0
- package/scripts/check-migrations.ts +40 -6
- 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
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
|
+
});
|