@nanobpm/nano-workforce 0.117.0 → 0.118.1
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/invariants.yml +8 -5
- package/.github/workflows/release.yml +55 -6
- package/CHANGELOG.md +15 -0
- package/README.md +21 -0
- package/app/agentic/families/presence.family.test.ts +12 -0
- package/app/agentic/families/presence.family.ts +15 -0
- package/app/agentic/families/relay.family.test.ts +227 -0
- package/app/agentic/families/relay.family.ts +137 -20
- package/app/deliveryGraphText.ts +41 -0
- package/docs/agent-guide.md +173 -0
- package/openapi.yaml +166 -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/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 +6 -2
- package/pages/tasks.page.json +4 -0
- package/pages/velocity.page.json +4 -0
- package/scripts/pages-contract.test.ts +67 -0
|
@@ -33,7 +33,9 @@ import {
|
|
|
33
33
|
type TranscriptStream,
|
|
34
34
|
} from "@nanobpm/agentic/transcript";
|
|
35
35
|
import type { Logger } from "@nanobpm/urban";
|
|
36
|
+
import { currentCorrelation, type JobContext, jobKeyOfStream } from "../correlation.ts";
|
|
36
37
|
import type { AgenticContext, AgenticFamily } from "../registry.ts";
|
|
38
|
+
import { currentPresenceRegistry } from "./presence.family.ts";
|
|
37
39
|
|
|
38
40
|
/** The stable family name this slice registers under the seam (distinct from the wire family key). */
|
|
39
41
|
export const RELAY_FAMILY_NAME = "relay";
|
|
@@ -81,6 +83,22 @@ interface StreamState {
|
|
|
81
83
|
producer?: string;
|
|
82
84
|
/** Set once an ephemeral stream has been flushed & completed (so it is not re-completed). */
|
|
83
85
|
completed: boolean;
|
|
86
|
+
/**
|
|
87
|
+
* Set once a `job:<jobKey>` stream has been linked into the correlation registry (H6, #149), so
|
|
88
|
+
* the link is attempted at most once per stream. It stays `false` while the producer's presence
|
|
89
|
+
* instance is not yet resolvable (a register/produce race), so a later `produce` frame retries.
|
|
90
|
+
*/
|
|
91
|
+
linked: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The minimal correlation write-side the relay slice drives (H6, #149): link a producing worker
|
|
96
|
+
* instance to the jobKey it is relaying, and release it when the job's stream ends. Structural so
|
|
97
|
+
* the service depends on a shape, not the whole {@link CorrelationRegistry}.
|
|
98
|
+
*/
|
|
99
|
+
export interface CorrelationLink {
|
|
100
|
+
link(instance: string, jobKey: string, context?: JobContext): void;
|
|
101
|
+
releaseJob(jobKey: string): void;
|
|
84
102
|
}
|
|
85
103
|
|
|
86
104
|
export interface RelayTranscriptServiceOptions {
|
|
@@ -104,6 +122,21 @@ export interface RelayTranscriptServiceOptions {
|
|
|
104
122
|
* against a bare source too (and is harmless when the migration already ran).
|
|
105
123
|
*/
|
|
106
124
|
readonly ensureSchema?: boolean;
|
|
125
|
+
/**
|
|
126
|
+
* The correlation write-side seam (H6, #149). When present, a first `produce` frame for a
|
|
127
|
+
* `job:<jobKey>` stream links the producing worker instance → jobKey here, and the stream's
|
|
128
|
+
* completion / producer disconnect releases it. Defaults to the process-wide correlation registry
|
|
129
|
+
* ({@link currentCorrelation}); absent (`() => undefined`) → no linking (advisory, never an error).
|
|
130
|
+
*/
|
|
131
|
+
readonly correlation?: () => CorrelationLink | undefined;
|
|
132
|
+
/**
|
|
133
|
+
* Resolve the presence instance that owns a connection — the connection → instance join the
|
|
134
|
+
* correlation write-side needs to attribute a `produce` frame's jobKey. When omitted,
|
|
135
|
+
* `RelayTranscriptService` defaults it to `() => undefined` (no linking); the family factory
|
|
136
|
+
* {@link createRelayFamily} is what wires it to the mounted presence registry's resolver
|
|
137
|
+
* ({@link currentPresenceRegistry}). A resolver returning undefined → no linking (advisory).
|
|
138
|
+
*/
|
|
139
|
+
readonly instanceForConnection?: (connectionId: string) => string | undefined;
|
|
107
140
|
}
|
|
108
141
|
|
|
109
142
|
/** The minimal per-connection surface the relay handler receives from the hub (a {@link RelayHub} `RelayConnection`). */
|
|
@@ -128,10 +161,16 @@ export class RelayTranscriptService {
|
|
|
128
161
|
readonly #registry: ConnectionRegistry;
|
|
129
162
|
readonly #log: Logger;
|
|
130
163
|
readonly #streams = new Map<string, StreamState>();
|
|
164
|
+
/** The correlation write-side accessor (H6, #149) — resolved per call so a late family mount wins. */
|
|
165
|
+
readonly #correlation: () => CorrelationLink | undefined;
|
|
166
|
+
/** The connection → producing-instance resolver (H6, #149). */
|
|
167
|
+
readonly #instanceForConnection: (connectionId: string) => string | undefined;
|
|
131
168
|
|
|
132
169
|
constructor(options: RelayTranscriptServiceOptions) {
|
|
133
170
|
this.#registry = options.registry;
|
|
134
171
|
this.#log = options.log;
|
|
172
|
+
this.#correlation = options.correlation ?? currentCorrelation;
|
|
173
|
+
this.#instanceForConnection = options.instanceForConnection ?? (() => undefined);
|
|
135
174
|
// Persistence is advisory: a store that can't be constructed or whose schema can't be applied
|
|
136
175
|
// (locked/permission-denied/unavailable SQLite) must NOT fail the family mount — fall back to
|
|
137
176
|
// running the relay unpersisted rather than tearing down the whole agentic channel.
|
|
@@ -191,23 +230,31 @@ export class RelayTranscriptService {
|
|
|
191
230
|
* re-completing already-persisted offsets is a no-op. Returns the number of newly-persisted chunks.
|
|
192
231
|
*/
|
|
193
232
|
completeStream(stream: string): number {
|
|
194
|
-
if (!this.store) return 0;
|
|
195
233
|
const state = this.#stateFor(stream);
|
|
196
234
|
const source = this.relay.ring(stream) ?? EMPTY_SOURCE;
|
|
197
|
-
let flushed
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
235
|
+
let flushed = 0;
|
|
236
|
+
// Persistence is advisory: with no store there is nothing to flush, but the in-memory lifecycle
|
|
237
|
+
// must still transition (mark completed, unlink correlation, drop producer) so an unpersisted
|
|
238
|
+
// ephemeral stream completes exactly once and #reconcile does not keep retrying it every frame.
|
|
239
|
+
if (this.store) {
|
|
240
|
+
try {
|
|
241
|
+
flushed = this.store.flush(stream, source, state.lifecycle);
|
|
242
|
+
} catch (err) {
|
|
243
|
+
// A flush failure must not bubble into the hub's frame handler and take down unrelated
|
|
244
|
+
// streams. Log and leave the stream uncompleted so a later pass retries.
|
|
245
|
+
this.#log.warn("agentic relay stream flush failed — leaving stream uncompleted", {
|
|
246
|
+
stream,
|
|
247
|
+
lifecycle: state.lifecycle,
|
|
248
|
+
err: String(err),
|
|
249
|
+
});
|
|
250
|
+
return 0;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (state.lifecycle === "ephemeral") {
|
|
254
|
+
state.completed = true;
|
|
255
|
+
// Job end: release the jobKey ⇄ instance correlation so the worker's supply row clears it.
|
|
256
|
+
this.#unlink(stream, state);
|
|
209
257
|
}
|
|
210
|
-
if (state.lifecycle === "ephemeral") state.completed = true;
|
|
211
258
|
// Drop producer ownership so a later reconcile does not re-flush a completed stream.
|
|
212
259
|
state.producer = undefined;
|
|
213
260
|
this.#log.info("agentic relay stream flushed", {
|
|
@@ -283,19 +330,83 @@ export class RelayTranscriptService {
|
|
|
283
330
|
if (readProp(frame.payload, "op") !== "produce") return;
|
|
284
331
|
const stream = readProp(frame.payload, "stream");
|
|
285
332
|
if (typeof stream !== "string" || stream === "") return;
|
|
286
|
-
this.#stateFor(stream)
|
|
333
|
+
const state = this.#stateFor(stream);
|
|
334
|
+
// A completed stream is terminal: a late `produce` frame (e.g. arriving after job-end
|
|
335
|
+
// completion released the correlation) must not re-own or re-link it — doing so would
|
|
336
|
+
// resurrect a jobKey after it was released. Ignore ownership updates once completed.
|
|
337
|
+
if (state.completed) return;
|
|
338
|
+
state.producer = conn.id;
|
|
339
|
+
this.#link(stream, conn.id, state);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* H6 write-side (#149): on the first `produce` for a `job:<jobKey>` stream, link the producing
|
|
344
|
+
* worker instance → jobKey in the correlation registry, from data already crossing the wire (the
|
|
345
|
+
* jobKey is decoded from the stream id; the instance is resolved from the producing connection).
|
|
346
|
+
* That lights up the worker's `jobKeys` in the supply feed and repoints its drill stream at the
|
|
347
|
+
* jobKey-scoped relay stream. Idempotent per stream; retries on a later frame while the producer's
|
|
348
|
+
* presence instance is not yet resolvable (a register/produce race). Advisory — never throws into
|
|
349
|
+
* the frame handler.
|
|
350
|
+
*/
|
|
351
|
+
#link(stream: string, connectionId: string, state: StreamState): void {
|
|
352
|
+
if (state.linked) return;
|
|
353
|
+
const jobKey = jobKeyOfStream(stream);
|
|
354
|
+
if (jobKey === undefined) return;
|
|
355
|
+
const instance = this.#instanceForConnection(connectionId);
|
|
356
|
+
if (instance === undefined || instance === "") return;
|
|
357
|
+
const correlation = this.#correlation();
|
|
358
|
+
if (!correlation) return;
|
|
359
|
+
try {
|
|
360
|
+
correlation.link(instance, jobKey);
|
|
361
|
+
state.linked = true;
|
|
362
|
+
} catch (err) {
|
|
363
|
+
// Advisory — never throws into the frame handler. Swallow a throwing injectable correlation and
|
|
364
|
+
// leave the stream UNLINKED so a later `produce` retries the link.
|
|
365
|
+
this.#log.warn("agentic relay correlation link failed — leaving stream unlinked", {
|
|
366
|
+
stream,
|
|
367
|
+
jobKey,
|
|
368
|
+
err: String(err),
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** H6 write-side (#149): release a `job:<jobKey>` stream's correlation on completion / disconnect. */
|
|
374
|
+
#unlink(stream: string, state: StreamState): void {
|
|
375
|
+
if (!state.linked) return;
|
|
376
|
+
const jobKey = jobKeyOfStream(stream);
|
|
377
|
+
if (jobKey === undefined) return;
|
|
378
|
+
try {
|
|
379
|
+
this.#correlation()?.releaseJob(jobKey);
|
|
380
|
+
state.linked = false;
|
|
381
|
+
} catch (err) {
|
|
382
|
+
// Advisory — never throws into the frame handler. Swallow a throwing injectable correlation and
|
|
383
|
+
// leave `state.linked` true so the flag honestly records that the release did NOT happen (rather
|
|
384
|
+
// than falsely clearing it). Note there is no automatic retry once the stream completes:
|
|
385
|
+
// `completeStream` sets `state.completed` and clears `state.producer`, so `#reconcile` no longer
|
|
386
|
+
// revisits it and `teardown` skips already-completed streams. A retry only occurs on the
|
|
387
|
+
// dead-producer path, where `#reconcile` re-invokes `#unlink` on a still-uncompleted stream.
|
|
388
|
+
this.#log.warn("agentic relay correlation release failed — leaving stream linked", {
|
|
389
|
+
stream,
|
|
390
|
+
jobKey,
|
|
391
|
+
err: String(err),
|
|
392
|
+
});
|
|
393
|
+
}
|
|
287
394
|
}
|
|
288
395
|
|
|
289
396
|
/**
|
|
290
397
|
* Flush + complete every ephemeral stream whose producer connection is no longer live (the S1
|
|
291
|
-
* registry dropped it on close or liveness timeout)
|
|
292
|
-
* prune: it runs on each inbound frame, and shutdown
|
|
398
|
+
* registry dropped it on close or liveness timeout), and release its job correlation (H6, #149).
|
|
399
|
+
* Lazy, like the relay hub's own dead-subscriber prune: it runs on each inbound frame, and shutdown
|
|
400
|
+
* covers the quiescent tail via {@link teardown}. The correlation release is store-independent (it
|
|
401
|
+
* runs even for an unpersisted relay), so a dropped worker's `jobKeys` always clear.
|
|
293
402
|
*/
|
|
294
403
|
#reconcile(): void {
|
|
295
404
|
for (const [stream, state] of this.#streams) {
|
|
296
|
-
if (state.completed || state.lifecycle !== "ephemeral") continue;
|
|
297
405
|
if (state.producer !== undefined && !this.#registry.has(state.producer)) {
|
|
298
|
-
|
|
406
|
+
// Producer connection gone → the job it was relaying ended: release its correlation.
|
|
407
|
+
this.#unlink(stream, state);
|
|
408
|
+
// ...and flush+complete an ephemeral, not-yet-completed transcript exactly as before.
|
|
409
|
+
if (!state.completed && state.lifecycle === "ephemeral") this.completeStream(stream);
|
|
299
410
|
}
|
|
300
411
|
}
|
|
301
412
|
}
|
|
@@ -303,7 +414,7 @@ export class RelayTranscriptService {
|
|
|
303
414
|
#stateFor(stream: string): StreamState {
|
|
304
415
|
let state = this.#streams.get(stream);
|
|
305
416
|
if (state === undefined) {
|
|
306
|
-
state = { lifecycle: "ephemeral", completed: false };
|
|
417
|
+
state = { lifecycle: "ephemeral", completed: false, linked: false };
|
|
307
418
|
this.#streams.set(stream, state);
|
|
308
419
|
}
|
|
309
420
|
return state;
|
|
@@ -344,6 +455,12 @@ export function createRelayFamily(options: {
|
|
|
344
455
|
relay: options.relay,
|
|
345
456
|
transcript: options.transcript,
|
|
346
457
|
ensureSchema: options.ensureSchema,
|
|
458
|
+
// H6 write-side (#149): resolve the producing connection's presence instance from the live
|
|
459
|
+
// presence registry, and link/release against the process-wide correlation registry. Both
|
|
460
|
+
// are read per call, so this works regardless of family mount order (relay may mount before
|
|
461
|
+
// presence/correlation). Absent registries → no linking, still advisory-correct.
|
|
462
|
+
instanceForConnection: (connectionId) => currentPresenceRegistry()?.instanceForConnection(connectionId),
|
|
463
|
+
correlation: currentCorrelation,
|
|
347
464
|
});
|
|
348
465
|
setCurrentRelayTranscriptService(service);
|
|
349
466
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// app/deliveryGraphText.ts — the shared PARSE step for the human-facing UI JSON-paste ingress
|
|
2
|
+
// (issue #386, ADR 0005). The Delivery Graphs page (`pages/delivery-graphs.page.json`) submits the
|
|
3
|
+
// operator's pasted delivery-graph as a raw JSON STRING (`graphJson`) — the page's text field cannot
|
|
4
|
+
// submit a structured object — so the preview/dispatch ingress operations parse it here before handing
|
|
5
|
+
// the resulting object to the SAME pure `compileDeliveryGraph` compiler / gated `startDeliveryGraph`
|
|
6
|
+
// door the agent-facing paths use. This is a UI text adapter, NOT a parallel compile/dispatch path.
|
|
7
|
+
//
|
|
8
|
+
// PURE and I/O-free so it unit-tests in isolation. A blank field, non-JSON text, or a non-object JSON
|
|
9
|
+
// value maps to a clean `{ ok:false, error }` the ingress surfaces as a 400 with a human banner —
|
|
10
|
+
// never a 500.
|
|
11
|
+
|
|
12
|
+
/** The result of parsing a UI JSON-paste body: the parsed graph (still `unknown` — the compiler/door
|
|
13
|
+
* run the real shape + semantic validation), or a human-readable parse error. */
|
|
14
|
+
export type ParseDeliveryGraphTextResult =
|
|
15
|
+
| { ok: true; graph: unknown }
|
|
16
|
+
| { ok: false; error: string };
|
|
17
|
+
|
|
18
|
+
/** Parse a UI JSON-paste request body (`{ graphJson: string, … }`) into a candidate delivery graph.
|
|
19
|
+
* Guards the three ways the paste can be unusable BEFORE any compile/dispatch runs: a missing/blank
|
|
20
|
+
* `graphJson`, text that is not valid JSON, and JSON that is not an object (e.g. a bare array or
|
|
21
|
+
* scalar). The returned `graph` is deliberately `unknown` — `compileDeliveryGraph` /
|
|
22
|
+
* `validateDeliveryGraph` own the real validation. */
|
|
23
|
+
export function parseDeliveryGraphText(body: unknown): ParseDeliveryGraphTextResult {
|
|
24
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
25
|
+
return { ok: false, error: "request body must carry a `graphJson` string" };
|
|
26
|
+
}
|
|
27
|
+
const graphJson = "graphJson" in body ? body.graphJson : undefined;
|
|
28
|
+
if (typeof graphJson !== "string" || graphJson.trim() === "") {
|
|
29
|
+
return { ok: false, error: "paste a delivery-graph JSON into the field" };
|
|
30
|
+
}
|
|
31
|
+
let graph: unknown;
|
|
32
|
+
try {
|
|
33
|
+
graph = JSON.parse(graphJson);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
return { ok: false, error: `not valid JSON: ${err instanceof Error ? err.message : String(err)}` };
|
|
36
|
+
}
|
|
37
|
+
if (!graph || typeof graph !== "object" || Array.isArray(graph)) {
|
|
38
|
+
return { ok: false, error: "the pasted JSON must be a delivery-graph object" };
|
|
39
|
+
}
|
|
40
|
+
return { ok: true, graph };
|
|
41
|
+
}
|
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.
|
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
|
|
@@ -2615,6 +2716,71 @@ paths:
|
|
|
2615
2716
|
oneOf:
|
|
2616
2717
|
- $ref: "#/components/schemas/CompileDeliveryGraphErrors"
|
|
2617
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"
|
|
2618
2784
|
/actions/start/feature:
|
|
2619
2785
|
post:
|
|
2620
2786
|
operationId: startFeature
|