@nanobpm/nano-workforce 0.116.0 → 0.118.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 +38 -0
- package/app/deliveryGraphRun.test.ts +249 -0
- package/app/deliveryGraphRun.ts +323 -0
- package/app/deliveryGraphText.ts +41 -0
- package/app/deliveryRunner.ts +9 -1
- package/app/instance-tracking.test.ts +32 -0
- package/app/service.ts +39 -0
- package/db/migrations/058_delivery_graph_runs.sql +58 -0
- package/docs/agent-guide.md +173 -0
- package/e2e/delivery-graph-start.e2e.ts +145 -0
- package/nano.app.json +14 -0
- package/openapi.yaml +286 -0
- package/operations/dispatchDeliveryGraph.test.ts +166 -0
- package/operations/dispatchDeliveryGraph.ts +113 -0
- package/operations/getAgentInstructions.test.ts +22 -0
- package/operations/previewDeliveryGraph.test.ts +74 -0
- package/operations/previewDeliveryGraph.ts +59 -0
- package/operations/startDeliveryGraph.integration.test.ts +316 -0
- package/operations/startDeliveryGraph.ts +222 -0
- package/package.json +1 -1
- package/pages/_nav.json +1 -0
- package/pages/board.page.json +4 -0
- package/pages/cockpit.page.json +4 -0
- package/pages/delivery-graph-detail.page.json +114 -0
- package/pages/delivery-graphs.page.json +153 -0
- package/pages/epic-detail.page.json +4 -0
- package/pages/epic.page.json +4 -0
- package/pages/feature.page.json +4 -0
- package/pages/home.page.json +4 -0
- package/pages/lineage.page.json +4 -0
- package/pages/overview.page.json +39 -1
- package/pages/tasks.page.json +4 -0
- package/pages/velocity.page.json +4 -0
- package/scripts/pages-contract.test.ts +67 -0
package/docs/agent-guide.md
CHANGED
|
@@ -394,3 +394,176 @@ When you find a genuine bug or a missing capability in the orchestration itself
|
|
|
394
394
|
When describing the bug, include the concrete evidence you gathered here: the
|
|
395
395
|
`prKey`, the engine `processKey`, the parked element / incident message (§5), and the
|
|
396
396
|
BPMN/prompt file you believe is responsible (§6).
|
|
397
|
+
|
|
398
|
+
---
|
|
399
|
+
|
|
400
|
+
## 9. Author and run a delivery graph (ADR 0005)
|
|
401
|
+
|
|
402
|
+
The two workflows above (§1 convergence-loop, §2 plan-fanout) are each specialised to
|
|
403
|
+
**one** node shape ("an agent implements a slice → opens a PR"). Real delivery is often a
|
|
404
|
+
**heterogeneous, cross-repo, partly-human graph** — e.g. *merge PR #101 → un-draft+merge
|
|
405
|
+
PR #202 → a human does a manual OTP publish → PR #303 consumes the just-published version*. A
|
|
406
|
+
**delivery graph** ([ADR 0005](https://github.com/nanobpm/nano-workforce/blob/main/docs/adr/0005-agent-authored-delivery-graphs.md))
|
|
407
|
+
lets you compose exactly that as **data** and hand it to a generic runner.
|
|
408
|
+
|
|
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.
|
|
413
|
+
|
|
414
|
+
### 9.1 The `DeliveryGraph` shape
|
|
415
|
+
|
|
416
|
+
A `DeliveryGraph` is a JSON **DAG**: `{ name?, nodes[], edges[] }`.
|
|
417
|
+
|
|
418
|
+
- **`nodes[]`** — each node has a unique `id`, a `kind` from the **closed allowlist**
|
|
419
|
+
(`agent` | `wait` | `human` | `connector`), the matching per-kind config, and an
|
|
420
|
+
optional typed `emits[]` declaration (the facts it hands forward).
|
|
421
|
+
- **`edges[]`** — each edge is `{ from, to }` meaning *"`to` proceeds once fact `from` is
|
|
422
|
+
observable"* (Decision 3 — edges are **discovered facts**, not declared values). `from`
|
|
423
|
+
is either a bare **`<nodeId>`** (the degenerate "wait for the upstream node's
|
|
424
|
+
completion" fact) or a **qualified `<nodeId>.<fact>`** referencing one of that node's
|
|
425
|
+
declared `emits`. The whole edge set must be a DAG. Omit / `[]` for independent roots.
|
|
426
|
+
|
|
427
|
+
**The four node kinds** (each delegates to an existing engine-native body — the graph
|
|
428
|
+
layer schedules, it does not re-implement execution):
|
|
429
|
+
|
|
430
|
+
| kind | config | what it does | may `emits`? |
|
|
431
|
+
|---|---|---|---|
|
|
432
|
+
| `agent` | `agent: { jobType, prompt? }` | a worker runs an agent job type (the fan-out body). **Side-effecting.** | yes |
|
|
433
|
+
| `wait` | `wait: <ReadinessProbe>` | a durable, bounded readiness probe — kind ∈ `http`, `command`, `npm`, `github-check`, `capability`, `pr`. Read-only. | yes (binds observed facts) |
|
|
434
|
+
| `human` | `human?: { formKey?, prompt? }` | a scheduled user task + form (the Tasks inbox, §3). Blocks dependents, SLA-bounded, answerable by a human **or** an agent. | yes |
|
|
435
|
+
| `connector` | `connector: { target, dedupeKey?, payload? }` | an automated, side-effecting outbound action. Carries a `dedupeKey` (at-least-once safe). *(payload is a forward-declared stub.)* | yes |
|
|
436
|
+
|
|
437
|
+
A **`wait` node's `wait` is a `ReadinessProbe` verbatim** (the same shape feature-run
|
|
438
|
+
intake uses): `{ kind, target, onTimeout?, match?, poll? }`. The **`pr` kind** watches an
|
|
439
|
+
in-flight PR — `target: "owner/repo#123"`, `match.prState ∈ ready|merged|mergeable|checks-green`
|
|
440
|
+
(default `merged`) — and on a merged match binds `mergedSha` as an output fact.
|
|
441
|
+
|
|
442
|
+
A **typed fact** (`emits[]` entry) is `{ name, type, description? }` where
|
|
443
|
+
`type ∈ string|number|boolean|artifact|version|url` (`artifact` = a `pkg@version` handle,
|
|
444
|
+
`version` = a bare version). `name` matches `^[A-Za-z_][A-Za-z0-9_]*$` and is referenced
|
|
445
|
+
downstream as `<nodeId>.<name>`. A "click done" human node or a pass-through node declares
|
|
446
|
+
no facts.
|
|
447
|
+
|
|
448
|
+
### 9.2 The agent loop: draft → compile → fix → approve → start
|
|
449
|
+
|
|
450
|
+
```
|
|
451
|
+
GET __BASE__/agent # ← you are reading it; learn the vocabulary + endpoints
|
|
452
|
+
└─ draft a DeliveryGraph JSON
|
|
453
|
+
└─ POST __BASE__/actions/compile-delivery-graph # PURE — validate + preview, repeat freely
|
|
454
|
+
├─ 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)
|
|
459
|
+
```
|
|
460
|
+
|
|
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:
|
|
464
|
+
|
|
465
|
+
```bash
|
|
466
|
+
curl -sS -X POST __BASE__/actions/compile-delivery-graph \
|
|
467
|
+
-H 'content-type: application/json' \
|
|
468
|
+
-d @graph.json | jq
|
|
469
|
+
```
|
|
470
|
+
|
|
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).
|
|
477
|
+
- `400 { ok:false, errors:[{ path, message }] }` — every error path-qualified
|
|
478
|
+
(`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.
|
|
514
|
+
|
|
515
|
+
### 9.3 Worked example — the cross-repo human-in-the-loop release
|
|
516
|
+
|
|
517
|
+
*Merge PR #101 (repo 1) → un-draft+merge PR #202 (repo 2) → a **human** runs the manual OTP
|
|
518
|
+
publish and records the version → open+merge PR #303 (repo 3) consuming that version.* The
|
|
519
|
+
`human` node **emits** a typed `version` fact, and the downstream `from:
|
|
520
|
+
"manual-publish.publishedVersion"` edge binds it into the PR-#303 path:
|
|
521
|
+
|
|
522
|
+
```json
|
|
523
|
+
{
|
|
524
|
+
"name": "cross-repo release: merge #101 → un-draft+merge #202 → manual OTP publish → consume in #303",
|
|
525
|
+
"nodes": [
|
|
526
|
+
{ "id": "merge-a", "kind": "wait",
|
|
527
|
+
"wait": { "kind": "pr", "target": "acme/repo-1#101", "match": { "prState": "merged" }, "onTimeout": "escalate" } },
|
|
528
|
+
{ "id": "undraft-merge-b", "kind": "agent",
|
|
529
|
+
"agent": { "jobType": "senior:merge", "prompt": "Take draft PR acme/repo-2#202 out of draft and merge it once its required checks are green." } },
|
|
530
|
+
{ "id": "manual-publish", "kind": "human",
|
|
531
|
+
"human": { "prompt": "Run the manual OTP-authenticated `npm publish` for @acme/widget and set up OIDC trusted publishing. Record the exact published version." },
|
|
532
|
+
"emits": [ { "name": "publishedVersion", "type": "version", "description": "The version just published to npm." } ] },
|
|
533
|
+
{ "id": "open-pr-c", "kind": "agent",
|
|
534
|
+
"agent": { "jobType": "senior:feature", "prompt": "Bump @acme/widget to the published version in acme/repo-3 and open PR #303." } },
|
|
535
|
+
{ "id": "merge-c", "kind": "wait",
|
|
536
|
+
"wait": { "kind": "pr", "target": "acme/repo-3#303", "match": { "prState": "merged" }, "onTimeout": "escalate" } }
|
|
537
|
+
],
|
|
538
|
+
"edges": [
|
|
539
|
+
{ "from": "merge-a", "to": "undraft-merge-b" },
|
|
540
|
+
{ "from": "undraft-merge-b", "to": "manual-publish" },
|
|
541
|
+
{ "from": "manual-publish.publishedVersion", "to": "open-pr-c" },
|
|
542
|
+
{ "from": "open-pr-c", "to": "merge-c" }
|
|
543
|
+
]
|
|
544
|
+
}
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
`compile` returns this preview (abridged):
|
|
548
|
+
|
|
549
|
+
```
|
|
550
|
+
diagram (mermaid flowchart):
|
|
551
|
+
n4["agent: undraft-merge-b"] --> n0["human: manual-publish"]
|
|
552
|
+
n0 -- "publishedVersion" --> n3["agent: open-pr-c"]
|
|
553
|
+
n1["wait: merge-a"] --> n4
|
|
554
|
+
n3 --> n2["wait: merge-c"]
|
|
555
|
+
|
|
556
|
+
humanNodes: [ { nodeId: "manual-publish", emits: [ { name: "publishedVersion", type: "version" } ], … } ]
|
|
557
|
+
sideEffects: [ { nodeId: "open-pr-c", kind: "agent", … }, { nodeId: "undraft-merge-b", kind: "agent", … } ]
|
|
558
|
+
```
|
|
559
|
+
|
|
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`.
|
|
565
|
+
|
|
566
|
+
To swap the manual PR-#303 path for a **capability** edge instead of a raw `pr` watch, make
|
|
567
|
+
the consumer a `wait` node with `kind: "capability"` (resolving *which published
|
|
568
|
+
`pkg@version` first carries the change*) fed by the same `manual-publish.publishedVersion`
|
|
569
|
+
fact — the fact-edge syntax is identical.
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// End-to-end proof of the S5 DISPATCH DOOR (ADR 0005 Decision 7) driven through its REAL ingress — the
|
|
2
|
+
// `startDeliveryGraph` OpenAPI operation (`POST /app/api/actions/start/delivery-graph`), the one
|
|
3
|
+
// contract all three ingress paths (agent POST, raw REST, UI JSON-paste) share. Hermetic: deterministic
|
|
4
|
+
// virtual clock, no network. It proves the acceptance the slice hinges on:
|
|
5
|
+
//
|
|
6
|
+
// • APPROVAL GATE: a side-effecting graph submitted WITHOUT approval is refused + PARKED (400,
|
|
7
|
+
// awaiting-approval, no engine instance, no agent job fired) — and is VISIBLE in the cockpit's
|
|
8
|
+
// `delivery_graph_runs` aggregate so an operator can see it waiting.
|
|
9
|
+
// • DISPATCH: re-submitting the same graph WITH its content-addressed approval token dispatches — the
|
|
10
|
+
// graph deploys + runs engine-natively (the agent side effect fires), and the run's derived phase
|
|
11
|
+
// shows WHERE it is parked ("Parked on human node: …") via the same `pollDeliveryGraphPhase`
|
|
12
|
+
// projection the cockpit reads.
|
|
13
|
+
// • IDEMPOTENCY: a duplicate submit short-circuits (`alreadyRunning`) — the agent side effect fires
|
|
14
|
+
// exactly ONCE, never twice.
|
|
15
|
+
// • COMPLETION: when the instance ends, the poller reconciles the run to `done` (instanceTracking's
|
|
16
|
+
// onTerminated reconciles only TERMINATED, so this poller owns COMPLETED→done).
|
|
17
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { join, resolve } from "node:path";
|
|
20
|
+
import { after, describe, test } from "node:test";
|
|
21
|
+
import assert from "node:assert/strict";
|
|
22
|
+
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
23
|
+
import { deliveryGraphRuns } from "../app/deliveryGraphRun.ts";
|
|
24
|
+
import { pollDeliveryGraphPhase } from "../app/service.ts";
|
|
25
|
+
import type { DeliveryGraph } from "../nano-generated/api-io.d.ts";
|
|
26
|
+
|
|
27
|
+
const APP_ROOT = resolve(import.meta.dirname, "..");
|
|
28
|
+
const GITHUB_ENV: Record<string, string> = { NANO_PR_GITHUB_TRANSPORT: "token", GITHUB_TOKEN: "" };
|
|
29
|
+
|
|
30
|
+
interface StartResult {
|
|
31
|
+
ok: boolean;
|
|
32
|
+
status: string;
|
|
33
|
+
runKey: string;
|
|
34
|
+
digest: string;
|
|
35
|
+
sideEffecting: boolean;
|
|
36
|
+
alreadyRunning?: boolean;
|
|
37
|
+
processInstanceKey?: string;
|
|
38
|
+
approvalToken?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// A side-effecting graph: an `agent` side effect gated ahead of a `human` stop. Approval is required
|
|
42
|
+
// (the agent + the human-facing merge/publish class of graphs Decision 7 protects).
|
|
43
|
+
const GRAPH: DeliveryGraph = {
|
|
44
|
+
name: "release runbook e2e",
|
|
45
|
+
nodes: [
|
|
46
|
+
{ id: "open", kind: "agent", agent: { jobType: "senior:demo", prompt: "open + prep" } },
|
|
47
|
+
{ id: "publish", kind: "human", human: { prompt: "run the manual OTP publish" } },
|
|
48
|
+
],
|
|
49
|
+
edges: [{ from: "open", to: "publish" }],
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
describe("startDeliveryGraph dispatch door — submit → approve → dispatch, idempotent (S5)", () => {
|
|
53
|
+
const dirs: string[] = [];
|
|
54
|
+
const apps: TestApp[] = [];
|
|
55
|
+
after(async () => {
|
|
56
|
+
for (const app of apps) await app.stop?.();
|
|
57
|
+
for (const d of dirs) rmSync(d, { recursive: true, force: true });
|
|
58
|
+
});
|
|
59
|
+
const boot = async (): Promise<TestApp> => {
|
|
60
|
+
const d = mkdtempSync(join(tmpdir(), "nwf-delivery-start-e2e-"));
|
|
61
|
+
dirs.push(d);
|
|
62
|
+
const app = await bootTestApp(APP_ROOT, { env: { ...GITHUB_ENV, NANO_APP_DB_URL: `file:${join(d, "app.db")}` } });
|
|
63
|
+
apps.push(app);
|
|
64
|
+
return app;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
test("a side-effecting graph is parked at approval, then dispatches once approved; a duplicate never double-launches", async () => {
|
|
68
|
+
const app = await boot();
|
|
69
|
+
assert.ok(app.api, "app declares an `api` binding");
|
|
70
|
+
const api = app.api;
|
|
71
|
+
|
|
72
|
+
let agentFired = 0;
|
|
73
|
+
await app.engine.registerWorker("senior:demo", async () => {
|
|
74
|
+
agentFired++;
|
|
75
|
+
return {};
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ── Approval gate: submit WITHOUT approval → refused + parked, nothing launched ────────────────
|
|
79
|
+
const parked = await api.call<StartResult>("startDeliveryGraph", { body: { graph: GRAPH } });
|
|
80
|
+
assert.equal(parked.status, 400, "an unapproved side-effecting graph is refused");
|
|
81
|
+
assert.equal(parked.body.status, "awaiting-approval");
|
|
82
|
+
assert.equal(parked.body.sideEffecting, true);
|
|
83
|
+
assert.ok(parked.body.approvalToken, "the response hands back the approval token to re-submit with");
|
|
84
|
+
await app.settle();
|
|
85
|
+
assert.equal(agentFired, 0, "a parked graph never dispatched its side effect");
|
|
86
|
+
|
|
87
|
+
// The parked run is durable + visible in the cockpit aggregate.
|
|
88
|
+
const runKey = parked.body.runKey;
|
|
89
|
+
const parkedRow = await deliveryGraphRuns(app.db).get(runKey);
|
|
90
|
+
assert.ok(parkedRow, "a delivery_graph_runs row exists for the parked graph");
|
|
91
|
+
assert.equal(parkedRow?.status, "awaiting-approval");
|
|
92
|
+
assert.equal(parkedRow?.process_key, null, "no engine instance while parked");
|
|
93
|
+
|
|
94
|
+
// ── Dispatch: re-submit WITH the token → deploys + runs engine-natively ───────────────────────
|
|
95
|
+
const token = parked.body.approvalToken;
|
|
96
|
+
const dispatched = await api.call<StartResult>("startDeliveryGraph", { body: { graph: GRAPH, approvalToken: token } });
|
|
97
|
+
assert.equal(dispatched.status, 202, "an approved graph dispatches");
|
|
98
|
+
assert.equal(dispatched.body.status, "running");
|
|
99
|
+
assert.equal(dispatched.body.alreadyRunning, false);
|
|
100
|
+
assert.ok(dispatched.body.processInstanceKey, "the run carries the started engine instance key");
|
|
101
|
+
await app.settle();
|
|
102
|
+
assert.equal(agentFired, 1, "the agent side effect fired exactly once");
|
|
103
|
+
|
|
104
|
+
// The run transitioned parked → running IN PLACE (one row, not a duplicate), carrying its instance.
|
|
105
|
+
const runningRows = await deliveryGraphRuns(app.db).find({ status: "running" });
|
|
106
|
+
assert.equal(runningRows.length, 1, "exactly one running run");
|
|
107
|
+
assert.equal(runningRows[0]?.run_key, runKey, "the SAME run row was approved, not a new one");
|
|
108
|
+
assert.equal(runningRows[0]?.process_key, dispatched.body.processInstanceKey);
|
|
109
|
+
|
|
110
|
+
// ── Cockpit phase: the poller derives WHERE the run is parked (the human node) ─────────────────
|
|
111
|
+
await pollDeliveryGraphPhase(app.db, app.engine);
|
|
112
|
+
const phased = await deliveryGraphRuns(app.db).get(runKey);
|
|
113
|
+
assert.equal(phased?.status, "running");
|
|
114
|
+
assert.match(String(phased?.phase), /^Parked on human node:/, `phase shows the parked human node, got ${phased?.phase}`);
|
|
115
|
+
|
|
116
|
+
// ── Idempotency: a duplicate submit short-circuits — the side effect never fires twice ────────
|
|
117
|
+
const dup = await api.call<StartResult>("startDeliveryGraph", { body: { graph: GRAPH, approvalToken: token } });
|
|
118
|
+
assert.equal(dup.status, 202);
|
|
119
|
+
assert.equal(dup.body.alreadyRunning, true, "the re-submit short-circuited the already-running run");
|
|
120
|
+
await app.settle();
|
|
121
|
+
assert.equal(agentFired, 1, "the agent side effect STILL fired only once (no double-launch)");
|
|
122
|
+
|
|
123
|
+
// ── Completion: complete the human stop → the instance ends → the poller reconciles to done ───
|
|
124
|
+
const open = await app.engine.searchUserTasks({ state: "CREATED" });
|
|
125
|
+
const human = open.find((t) => t.elementId?.startsWith("delivery-human-task__") && !t.elementId?.endsWith("__esc"));
|
|
126
|
+
assert.ok(human, `a human user task is open, got ${JSON.stringify(open.map((t) => t.elementId))}`);
|
|
127
|
+
await app.engine.completeUserTask(human.userTaskKey, { humanOutcome: "completed" });
|
|
128
|
+
await app.settle();
|
|
129
|
+
await pollDeliveryGraphPhase(app.db, app.engine);
|
|
130
|
+
const done = await deliveryGraphRuns(app.db).get(runKey);
|
|
131
|
+
assert.equal(done?.status, "done", "the completed instance reconciled the run to done");
|
|
132
|
+
assert.equal(done?.phase, "Completed");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("a non-side-effecting (human-only) graph dispatches with NO approval", async () => {
|
|
136
|
+
const app = await boot();
|
|
137
|
+
assert.ok(app.api);
|
|
138
|
+
const graph: DeliveryGraph = { name: "manual gate", nodes: [{ id: "ack", kind: "human", human: { prompt: "click done" } }] };
|
|
139
|
+
const res = await app.api.call<StartResult>("startDeliveryGraph", { body: { graph } });
|
|
140
|
+
assert.equal(res.status, 202, "a graph with no side effects needs no approval");
|
|
141
|
+
assert.equal(res.body.status, "running");
|
|
142
|
+
assert.equal(res.body.sideEffecting, false);
|
|
143
|
+
assert.ok(res.body.processInstanceKey);
|
|
144
|
+
});
|
|
145
|
+
});
|
package/nano.app.json
CHANGED
|
@@ -81,6 +81,20 @@
|
|
|
81
81
|
}
|
|
82
82
|
},
|
|
83
83
|
"pollMs": 5000
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"table": "delivery_graph_runs",
|
|
87
|
+
"keyField": "process_key",
|
|
88
|
+
"statusField": "status",
|
|
89
|
+
"activeStatuses": [
|
|
90
|
+
"running"
|
|
91
|
+
],
|
|
92
|
+
"onTerminated": {
|
|
93
|
+
"set": {
|
|
94
|
+
"status": "failed"
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
"pollMs": 5000
|
|
84
98
|
}
|
|
85
99
|
],
|
|
86
100
|
"workers": [
|
package/openapi.yaml
CHANGED
|
@@ -1515,6 +1515,107 @@ components:
|
|
|
1515
1515
|
message:
|
|
1516
1516
|
type: string
|
|
1517
1517
|
description: Human-actionable description of the failure.
|
|
1518
|
+
DeliveryGraphTextSubmit:
|
|
1519
|
+
description: >-
|
|
1520
|
+
The human-facing UI JSON-paste PREVIEW request (issue #386). The Delivery Graphs page's text
|
|
1521
|
+
field cannot submit a structured object, so the operator's pasted delivery-graph is carried as
|
|
1522
|
+
a raw JSON STRING (`graphJson`), parsed server-side and handed to the SAME pure
|
|
1523
|
+
`compileDeliveryGraph` compiler the agent-facing door uses. No parallel compile path.
|
|
1524
|
+
type: object
|
|
1525
|
+
additionalProperties: false
|
|
1526
|
+
required:
|
|
1527
|
+
- graphJson
|
|
1528
|
+
properties:
|
|
1529
|
+
graphJson:
|
|
1530
|
+
type: string
|
|
1531
|
+
description: The pasted delivery-graph JSON (a serialised `DeliveryGraph`), parsed server-side.
|
|
1532
|
+
DeliveryGraphTextDispatch:
|
|
1533
|
+
description: >-
|
|
1534
|
+
The human-facing UI JSON-paste DISPATCH request (issue #386). Carries the pasted delivery-graph
|
|
1535
|
+
as a raw JSON STRING plus the operator's explicit `approve` flag and optional idempotency key;
|
|
1536
|
+
parsed server-side and delegated to the ONE gated, idempotent `startDeliveryGraph` contract —
|
|
1537
|
+
there is NO parallel dispatch path.
|
|
1538
|
+
type: object
|
|
1539
|
+
additionalProperties: false
|
|
1540
|
+
required:
|
|
1541
|
+
- graphJson
|
|
1542
|
+
properties:
|
|
1543
|
+
graphJson:
|
|
1544
|
+
type: string
|
|
1545
|
+
description: The pasted delivery-graph JSON (a serialised `DeliveryGraph`), parsed server-side.
|
|
1546
|
+
approve:
|
|
1547
|
+
type: boolean
|
|
1548
|
+
description: >-
|
|
1549
|
+
The operator's approval OF the previewed graph. When true, the door derives the graph's
|
|
1550
|
+
content digest and presents it as the `approvalToken`, so a side-effecting graph the
|
|
1551
|
+
operator reviewed dispatches; when false/absent a side-effecting graph is parked at approval.
|
|
1552
|
+
idempotencyKey:
|
|
1553
|
+
type: string
|
|
1554
|
+
maxLength: 255
|
|
1555
|
+
description: OPTIONAL idempotency key forwarded to `startDeliveryGraph`. Blank/whitespace is treated as absent.
|
|
1556
|
+
DeliveryGraphTextResult:
|
|
1557
|
+
description: >-
|
|
1558
|
+
The UI JSON-paste ingress outcome (issue #386) — a single shape covering the PREVIEW summary,
|
|
1559
|
+
the DISPATCH outcome, and any parse/validation error. `ok` discriminates success; a failure
|
|
1560
|
+
carries a human `error` (and, for a compile failure, path-qualified `errors`).
|
|
1561
|
+
type: object
|
|
1562
|
+
additionalProperties: false
|
|
1563
|
+
required:
|
|
1564
|
+
- ok
|
|
1565
|
+
properties:
|
|
1566
|
+
ok:
|
|
1567
|
+
type: boolean
|
|
1568
|
+
description: True on a successful preview/dispatch; false on a parse/validation failure or an approval park.
|
|
1569
|
+
error:
|
|
1570
|
+
type: string
|
|
1571
|
+
description: A human-readable failure message (surfaced by the page's action banner).
|
|
1572
|
+
errors:
|
|
1573
|
+
type: array
|
|
1574
|
+
items:
|
|
1575
|
+
$ref: "#/components/schemas/DeliveryCompileError"
|
|
1576
|
+
description: Path-qualified validation/compile failures, when the pasted graph was malformed.
|
|
1577
|
+
status:
|
|
1578
|
+
type: string
|
|
1579
|
+
description: The dispatch run's lifecycle position (`running` / `awaiting-approval`), when dispatched.
|
|
1580
|
+
runKey:
|
|
1581
|
+
type: string
|
|
1582
|
+
description: The dispatch run's idempotency key, when dispatched.
|
|
1583
|
+
digest:
|
|
1584
|
+
type: string
|
|
1585
|
+
description: The graph's content digest — the approval token to dispatch a side-effecting graph.
|
|
1586
|
+
sideEffecting:
|
|
1587
|
+
type: boolean
|
|
1588
|
+
description: Whether the graph has any side-effecting (`agent`/`connector`) node.
|
|
1589
|
+
alreadyRunning:
|
|
1590
|
+
type: boolean
|
|
1591
|
+
description: True when a dispatch short-circuited onto an already-running run.
|
|
1592
|
+
processInstanceKey:
|
|
1593
|
+
type: string
|
|
1594
|
+
description: The started engine instance key, when dispatched.
|
|
1595
|
+
processDefinitionId:
|
|
1596
|
+
type: string
|
|
1597
|
+
description: The started process definition id, when dispatched.
|
|
1598
|
+
approvalToken:
|
|
1599
|
+
type: string
|
|
1600
|
+
description: The approval token to re-submit with, when a side-effecting graph was parked pending approval.
|
|
1601
|
+
message:
|
|
1602
|
+
type: string
|
|
1603
|
+
description: Additional detail from the dispatch door (e.g. the approval-park explanation).
|
|
1604
|
+
title:
|
|
1605
|
+
type: string
|
|
1606
|
+
description: The graph's human-readable name, echoed on a successful preview.
|
|
1607
|
+
nodeCount:
|
|
1608
|
+
type: integer
|
|
1609
|
+
description: The compiled graph's node count (preview).
|
|
1610
|
+
humanNodeCount:
|
|
1611
|
+
type: integer
|
|
1612
|
+
description: The compiled graph's human stop-point count (preview).
|
|
1613
|
+
sideEffectCount:
|
|
1614
|
+
type: integer
|
|
1615
|
+
description: The compiled graph's side-effecting node count (preview).
|
|
1616
|
+
diagram:
|
|
1617
|
+
type: string
|
|
1618
|
+
description: The mermaid flowchart of the compiled graph (preview).
|
|
1518
1619
|
ResolvedDeliveryNode:
|
|
1519
1620
|
description: >-
|
|
1520
1621
|
A normalised node in the compiled graph (ADR 0005 slice S1) — its `id`, `kind`, the
|
|
@@ -1713,6 +1814,85 @@ components:
|
|
|
1713
1814
|
items:
|
|
1714
1815
|
$ref: "#/components/schemas/DeliveryCompileError"
|
|
1715
1816
|
description: The path-qualified validation/compile failures (at least one).
|
|
1817
|
+
DeliveryGraphStart:
|
|
1818
|
+
description: >-
|
|
1819
|
+
The `startDeliveryGraph` request body (ADR 0005 slice S5) — the ONE gated dispatch door that
|
|
1820
|
+
turns an agent-authored `DeliveryGraph` into a RUNNING engine-native process. This is the OUTER
|
|
1821
|
+
action, distinct from S1's pure `compileDeliveryGraph`: there is deliberately no `dryRun` flag
|
|
1822
|
+
(Decision 5/7) — compile and start are separate operations. The door re-validates (S0), compiles
|
|
1823
|
+
(S1), and launches the S4 runner. Three ingress paths — an agent-ergonomic POST, a raw REST call,
|
|
1824
|
+
and a UI JSON-paste — all hit this ONE contract.
|
|
1825
|
+
type: object
|
|
1826
|
+
additionalProperties: false
|
|
1827
|
+
required:
|
|
1828
|
+
- graph
|
|
1829
|
+
properties:
|
|
1830
|
+
graph:
|
|
1831
|
+
$ref: "#/components/schemas/DeliveryGraph"
|
|
1832
|
+
approvalToken:
|
|
1833
|
+
type: string
|
|
1834
|
+
description: >-
|
|
1835
|
+
The approval OF the rendered preview (Decision 7). Because a delivery graph merges PRs and
|
|
1836
|
+
publishes packages, a graph with any SIDE-EFFECTING node (`agent`/`connector`) dispatches
|
|
1837
|
+
ONLY when the caller presents the graph's content-addressed approval token (returned as
|
|
1838
|
+
`approvalToken` on a prior unapproved submit's `awaiting-approval` response). A graph with
|
|
1839
|
+
no side effects (only `wait`/`human` nodes) needs none and dispatches straight away.
|
|
1840
|
+
idempotencyKey:
|
|
1841
|
+
type: string
|
|
1842
|
+
maxLength: 255
|
|
1843
|
+
description: >-
|
|
1844
|
+
OPTIONAL caller-supplied idempotency key. A re-POST with the SAME key (or, when omitted, the
|
|
1845
|
+
SAME graph — the default key is the graph's content digest) does NOT double-launch: an
|
|
1846
|
+
in-flight run short-circuits with `alreadyRunning: true`. Blank/whitespace is treated as
|
|
1847
|
+
absent.
|
|
1848
|
+
StartDeliveryGraphResult:
|
|
1849
|
+
description: >-
|
|
1850
|
+
The `startDeliveryGraph` outcome. `status` is the run's lifecycle position: `running` (the graph
|
|
1851
|
+
dispatched — or was already running, see `alreadyRunning`) or `awaiting-approval` (a
|
|
1852
|
+
side-effecting graph was submitted without a valid `approvalToken` — it is PARKED, visible in the
|
|
1853
|
+
cockpit, and refused pending approval; re-POST with the returned `approvalToken` to dispatch).
|
|
1854
|
+
type: object
|
|
1855
|
+
additionalProperties: false
|
|
1856
|
+
required:
|
|
1857
|
+
- ok
|
|
1858
|
+
- status
|
|
1859
|
+
- runKey
|
|
1860
|
+
- digest
|
|
1861
|
+
- sideEffecting
|
|
1862
|
+
properties:
|
|
1863
|
+
ok:
|
|
1864
|
+
type: boolean
|
|
1865
|
+
description: True when the graph dispatched (or was already running); false when parked at approval.
|
|
1866
|
+
status:
|
|
1867
|
+
type: string
|
|
1868
|
+
enum: [running, awaiting-approval]
|
|
1869
|
+
description: The run's lifecycle position after this call.
|
|
1870
|
+
runKey:
|
|
1871
|
+
type: string
|
|
1872
|
+
description: The idempotency key the run is stored under (the caller key, else the content digest).
|
|
1873
|
+
digest:
|
|
1874
|
+
type: string
|
|
1875
|
+
description: The graph's content digest — the content-address of the compiled definition.
|
|
1876
|
+
sideEffecting:
|
|
1877
|
+
type: boolean
|
|
1878
|
+
description: Whether the graph has any side-effecting (`agent`/`connector`) node — i.e. whether approval is required.
|
|
1879
|
+
alreadyRunning:
|
|
1880
|
+
type: boolean
|
|
1881
|
+
description: True when a re-submit short-circuited onto an already-running run (no second launch).
|
|
1882
|
+
processInstanceKey:
|
|
1883
|
+
type: string
|
|
1884
|
+
description: The started engine instance key. Present when `status` is `running`; absent while parked at approval.
|
|
1885
|
+
processDefinitionId:
|
|
1886
|
+
type: string
|
|
1887
|
+
description: The content-addressed deployed process id (`delivery-graph-<digest>`). Present when dispatched.
|
|
1888
|
+
approvalToken:
|
|
1889
|
+
type: string
|
|
1890
|
+
description: >-
|
|
1891
|
+
The token to present as `approvalToken` on a re-POST to dispatch this exact graph. Present
|
|
1892
|
+
when `status` is `awaiting-approval` (equals `digest`).
|
|
1893
|
+
message:
|
|
1894
|
+
type: string
|
|
1895
|
+
description: A human-readable summary of the outcome (e.g. the approval-required reason).
|
|
1716
1896
|
FeatureStart:
|
|
1717
1897
|
description: The start-feature request body — a SINGLE-issue feature run. Names the target issue
|
|
1718
1898
|
by EXACTLY ONE of `issue` (an `owner/repo#123` reference) or `url` (a bare issue URL), plus a
|
|
@@ -2495,6 +2675,112 @@ paths:
|
|
|
2495
2675
|
application/json:
|
|
2496
2676
|
schema:
|
|
2497
2677
|
$ref: "#/components/schemas/CompileDeliveryGraphErrors"
|
|
2678
|
+
/actions/start/delivery-graph:
|
|
2679
|
+
post:
|
|
2680
|
+
operationId: startDeliveryGraph
|
|
2681
|
+
summary: Validate + compile + DISPATCH a delivery graph as a running engine-native process (gated, idempotent). (ADR 0005 slice S5)
|
|
2682
|
+
description: >-
|
|
2683
|
+
The delivery-graph DISPATCH door (ADR 0005 Decision 7) — the single gated OUTER action that
|
|
2684
|
+
turns an agent-authored `DeliveryGraph` into a RUNNING engine-native process. Distinct from S1's
|
|
2685
|
+
pure `compileDeliveryGraph`: compile and start are SEPARATE operations (Decision 5/7), so there
|
|
2686
|
+
is deliberately no `dryRun` flag. The door re-validates the graph (`validateDeliveryGraph`, S0),
|
|
2687
|
+
compiles it (`compileDeliveryGraph`, S1), and LAUNCHES the S4 runner (`runDeliveryGraph`).
|
|
2688
|
+
Because these graphs merge PRs and publish packages, dispatch is GATED: a graph with any
|
|
2689
|
+
side-effecting (`agent`/`connector`) node must present the content-addressed `approvalToken` of
|
|
2690
|
+
its rendered preview, else it is PARKED at approval (a 400 carrying the token, plus a visible
|
|
2691
|
+
`awaiting-approval` run in the cockpit) rather than dispatched. Idempotent: a re-POST of the same
|
|
2692
|
+
graph (or the same `idempotencyKey`) short-circuits an already-running run instead of
|
|
2693
|
+
double-launching. Three ingress paths — an agent-ergonomic POST, a raw REST call, and a UI
|
|
2694
|
+
JSON-paste — all hit this ONE contract.
|
|
2695
|
+
requestBody:
|
|
2696
|
+
required: true
|
|
2697
|
+
content:
|
|
2698
|
+
application/json:
|
|
2699
|
+
schema:
|
|
2700
|
+
$ref: "#/components/schemas/DeliveryGraphStart"
|
|
2701
|
+
responses:
|
|
2702
|
+
"202":
|
|
2703
|
+
description: The graph dispatched (or a re-submit short-circuited an already-running run).
|
|
2704
|
+
content:
|
|
2705
|
+
application/json:
|
|
2706
|
+
schema:
|
|
2707
|
+
$ref: "#/components/schemas/StartDeliveryGraphResult"
|
|
2708
|
+
"400":
|
|
2709
|
+
description: >-
|
|
2710
|
+
The graph failed validation/compilation (path-qualified errors), OR a side-effecting graph
|
|
2711
|
+
was submitted without a valid approval token — it is refused and PARKED at approval (the
|
|
2712
|
+
body carries the `approvalToken` to re-submit with).
|
|
2713
|
+
content:
|
|
2714
|
+
application/json:
|
|
2715
|
+
schema:
|
|
2716
|
+
oneOf:
|
|
2717
|
+
- $ref: "#/components/schemas/CompileDeliveryGraphErrors"
|
|
2718
|
+
- $ref: "#/components/schemas/StartDeliveryGraphResult"
|
|
2719
|
+
/actions/delivery-graph/preview:
|
|
2720
|
+
post:
|
|
2721
|
+
operationId: previewDeliveryGraph
|
|
2722
|
+
summary: UI JSON-paste PREVIEW — parse a pasted delivery-graph JSON string and compile it (PURE). (ADR 0005 S1 / #386)
|
|
2723
|
+
description: >-
|
|
2724
|
+
The human-facing UI JSON-paste PREVIEW ingress (issue #386). The Delivery Graphs page's
|
|
2725
|
+
"Preview" action posts the operator's pasted JSON as a STRING; this door parses it and runs the
|
|
2726
|
+
SAME pure `compileDeliveryGraph` compiler the agent-facing door uses, returning a compact
|
|
2727
|
+
summary (the content `digest` = the approval token, node/human/side-effect counts, the mermaid
|
|
2728
|
+
`diagram`). It is PURE and side-effect-free — nothing is deployed or dispatched. A blank/invalid
|
|
2729
|
+
JSON string, or a graph that fails validation, is a 400 carrying a human `error` (and
|
|
2730
|
+
path-qualified `errors` for a compile failure).
|
|
2731
|
+
requestBody:
|
|
2732
|
+
required: true
|
|
2733
|
+
content:
|
|
2734
|
+
application/json:
|
|
2735
|
+
schema:
|
|
2736
|
+
$ref: "#/components/schemas/DeliveryGraphTextSubmit"
|
|
2737
|
+
responses:
|
|
2738
|
+
"200":
|
|
2739
|
+
description: The pasted graph parsed, validated and compiled — the pure preview summary.
|
|
2740
|
+
content:
|
|
2741
|
+
application/json:
|
|
2742
|
+
schema:
|
|
2743
|
+
$ref: "#/components/schemas/DeliveryGraphTextResult"
|
|
2744
|
+
"400":
|
|
2745
|
+
description: The pasted text was not valid JSON, or the graph failed validation/compilation.
|
|
2746
|
+
content:
|
|
2747
|
+
application/json:
|
|
2748
|
+
schema:
|
|
2749
|
+
$ref: "#/components/schemas/DeliveryGraphTextResult"
|
|
2750
|
+
/actions/delivery-graph/dispatch:
|
|
2751
|
+
post:
|
|
2752
|
+
operationId: dispatchDeliveryGraph
|
|
2753
|
+
summary: UI JSON-paste DISPATCH — parse a pasted delivery-graph JSON string and dispatch it via startDeliveryGraph (gated, idempotent). (ADR 0005 S5 / #386)
|
|
2754
|
+
description: >-
|
|
2755
|
+
The human-facing UI JSON-paste DISPATCH ingress (issue #386). The Delivery Graphs page's
|
|
2756
|
+
"Dispatch" action posts the operator's pasted JSON as a STRING plus an explicit `approve` flag;
|
|
2757
|
+
this door parses it and delegates to the SAME gated, idempotent `startDeliveryGraph` contract —
|
|
2758
|
+
there is NO parallel dispatch path. When `approve` is true the door derives the graph's content
|
|
2759
|
+
digest and presents it as the approval token, so a side-effecting graph the operator reviewed in
|
|
2760
|
+
the preview dispatches; without `approve` a side-effecting graph is PARKED at approval (visible
|
|
2761
|
+
in the in-flight grid) and a non-side-effecting graph dispatches straight away. Idempotent on
|
|
2762
|
+
`idempotencyKey` (else the content digest).
|
|
2763
|
+
requestBody:
|
|
2764
|
+
required: true
|
|
2765
|
+
content:
|
|
2766
|
+
application/json:
|
|
2767
|
+
schema:
|
|
2768
|
+
$ref: "#/components/schemas/DeliveryGraphTextDispatch"
|
|
2769
|
+
responses:
|
|
2770
|
+
"202":
|
|
2771
|
+
description: The graph dispatched (or a re-submit short-circuited an already-running run).
|
|
2772
|
+
content:
|
|
2773
|
+
application/json:
|
|
2774
|
+
schema:
|
|
2775
|
+
$ref: "#/components/schemas/DeliveryGraphTextResult"
|
|
2776
|
+
"400":
|
|
2777
|
+
description: >-
|
|
2778
|
+
The pasted text was not valid JSON, the graph failed validation, or a side-effecting graph
|
|
2779
|
+
was parked pending approval (the body carries the `approvalToken` / `error` to act on).
|
|
2780
|
+
content:
|
|
2781
|
+
application/json:
|
|
2782
|
+
schema:
|
|
2783
|
+
$ref: "#/components/schemas/DeliveryGraphTextResult"
|
|
2498
2784
|
/actions/start/feature:
|
|
2499
2785
|
post:
|
|
2500
2786
|
operationId: startFeature
|