@nanobpm/nano-workforce 0.109.0 → 0.110.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 +7 -0
- package/app/contracts.ts +9 -0
- package/app/deliveryGraph.test.ts +357 -0
- package/app/deliveryGraph.ts +463 -0
- package/openapi.yaml +244 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
# [0.110.0](https://github.com/nanobpm/nano-workforce/compare/v0.109.0...v0.110.0) (2026-08-20)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* DeliveryGraph JSON contract + pure validator (ADR 0005 S0) ([#384](https://github.com/nanobpm/nano-workforce/issues/384)) ([ae85e1a](https://github.com/nanobpm/nano-workforce/commit/ae85e1af220844f961f6161d4f676aa289d86693)), closes [#375](https://github.com/nanobpm/nano-workforce/issues/375) [#227](https://github.com/nanobpm/nano-workforce/issues/227)
|
|
7
|
+
|
|
1
8
|
# [0.109.0](https://github.com/nanobpm/nano-workforce/compare/v0.108.0...v0.109.0) (2026-08-20)
|
|
2
9
|
|
|
3
10
|
|
package/app/contracts.ts
CHANGED
|
@@ -375,6 +375,15 @@ export const WIRE_CONTRACTS = {
|
|
|
375
375
|
"The mind/world checkpoint JOIN shape (issue #324, ADR 0062 Slice 4/5, the WORLD half). At each push the app derives ONE `{commitSha, effectLedger}` and records it in the durable world store (`world_checkpoints`/`world_effects`) AND passes the SAME object to the mind's `session.checkpoint(commitSha, effectLedger)` (Slice 1, `@nanobpm/agentic/session`), so mind + world commit at the SAME per-PR monotonic offset — closing the divergence failure (harness thinks it hasn't pushed but the push landed, or vice-versa). `effectLedger` entries carry a fence idempotency key (push→commit SHA, PR comment→comment id, `gh merge`→merge key); on a re-lease `restoreWorld` inverts the push (`git fetch && git checkout <commitSha>`) then fence-replays the tail so an already-applied effect is skipped, not repeated. Consume this ONE shape from app/world — do not re-declare a synonym.",
|
|
376
376
|
shape: '{ commitSha: string, effectLedger: Array<{ kind: "push"|"pr-comment"|"merge", idempotencyKey: string, description?: string }> }',
|
|
377
377
|
},
|
|
378
|
+
DeliveryGraph: {
|
|
379
|
+
category: "wire",
|
|
380
|
+
name: "DeliveryGraph",
|
|
381
|
+
owner: "app/deliveryGraph.ts",
|
|
382
|
+
semantics:
|
|
383
|
+
"The agent-authored delivery graph (ADR 0005, slice S0) — the SINGLE agent-facing artifact for a heterogeneous, partly-human, cross-repo delivery runbook, crossing the ingest boundary as DATA (a JSON DAG, never an executable artifact). Declared in openapi.yaml as `DeliveryGraph`; ingest validates the SHAPE there and the SEMANTICS (acyclicity, edge integrity, fact resolution) in the pure `validateDeliveryGraph` (`app/deliveryGraph.ts`). Nodes each name a `kind` from a CLOSED allowlist (`agent`/`wait`/`human`/`connector` — Decision 1/2, the trust boundary) plus their typed `emits[]`; edges name DISCOVERED facts (Decision 3) — `from` is a bare `<nodeId>` or a qualified `<nodeId>.<fact>`. Later slices (compiler/dispatch/execution) build on this ONE shape — consume it, do not re-declare a synonym.",
|
|
384
|
+
shape:
|
|
385
|
+
'{ name?: string, nodes: Array<{ id: string, kind: "agent"|"wait"|"human"|"connector", emits?: Array<{ name: string, type: "string"|"number"|"boolean"|"artifact"|"version"|"url", description?: string }>, agent?: { jobType: string, prompt?: string }, wait?: ReadinessProbe, human?: { formKey?: string, prompt?: string }, connector?: { target: string, dedupeKey?: string, payload?: object } }>, edges?: Array<{ from: string, to: string }> }',
|
|
386
|
+
},
|
|
378
387
|
} as const satisfies Record<string, WireContract>;
|
|
379
388
|
|
|
380
389
|
export const TYPE_CONTRACTS = {
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
// Unit coverage for the pure delivery-graph validator `validateDeliveryGraph` (ADR 0005, slice S0).
|
|
2
|
+
// It exercises the SEMANTIC rules the openapi schema cannot express — the closed-kind allowlist,
|
|
3
|
+
// node-id uniqueness, edge integrity (dangling / self), typed-fact resolution (`from: <node>.<fact>`),
|
|
4
|
+
// and acyclicity — directly, with no HTTP and no side effects, mirroring how app/epicSetValidation
|
|
5
|
+
// unit-tests `validateEpicSet`. Each error class (unknown-kind / dangling / bad-`from` / cycle) has a
|
|
6
|
+
// dedicated case, and a fully-worked well-formed graph proves the happy path returns no errors.
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import { assert, assertEquals } from "#test-assert";
|
|
9
|
+
import {
|
|
10
|
+
DELIVERY_NODE_KINDS,
|
|
11
|
+
type DeliveryGraphError,
|
|
12
|
+
type DeliveryGraphErrorCode,
|
|
13
|
+
validateDeliveryGraph,
|
|
14
|
+
} from "./deliveryGraph.ts";
|
|
15
|
+
|
|
16
|
+
/** The single error in the result, asserting there is exactly one. */
|
|
17
|
+
function only(errors: DeliveryGraphError[]): DeliveryGraphError {
|
|
18
|
+
assertEquals(errors.length, 1, `expected exactly one error, got ${JSON.stringify(errors)}`);
|
|
19
|
+
return errors[0];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Assert the result contains at least one error of the given code. */
|
|
23
|
+
function hasCode(errors: DeliveryGraphError[], code: DeliveryGraphErrorCode): DeliveryGraphError {
|
|
24
|
+
const found = errors.find((e) => e.code === code);
|
|
25
|
+
assert(found !== undefined, `expected an error with code "${code}", got ${JSON.stringify(errors)}`);
|
|
26
|
+
return found;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// A realistic, fully-worked graph mirroring the ADR's motivating case: an agent opens PR #B, a `pr`
|
|
30
|
+
// wait node (S2's kind, referenced by shape only here) watches it merge and emits `mergedSha`, a
|
|
31
|
+
// human does the manual OTP publish emitting `resolvedArtifact`, and a downstream wait consumes that
|
|
32
|
+
// published artifact. Proves nodes, per-kind config, typed emits, and both edge shapes validate.
|
|
33
|
+
const WELL_FORMED = {
|
|
34
|
+
name: "release runbook",
|
|
35
|
+
nodes: [
|
|
36
|
+
{ id: "open-b", kind: "agent", agent: { jobType: "senior:feature", prompt: "un-draft + merge #B" } },
|
|
37
|
+
{
|
|
38
|
+
id: "watch-b",
|
|
39
|
+
kind: "wait",
|
|
40
|
+
wait: { kind: "github-check", target: "owner/repo@main" },
|
|
41
|
+
emits: [{ name: "mergedSha", type: "string" }],
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: "manual-publish",
|
|
45
|
+
kind: "human",
|
|
46
|
+
human: { prompt: "do the manual OTP publish + set up OIDC" },
|
|
47
|
+
emits: [{ name: "resolvedArtifact", type: "artifact" }],
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: "consume-c",
|
|
51
|
+
kind: "wait",
|
|
52
|
+
wait: { kind: "capability", target: "github-releases:owner/repo" },
|
|
53
|
+
},
|
|
54
|
+
{ id: "notify", kind: "connector", connector: { target: "slack:#releases", dedupeKey: "notify-1" } },
|
|
55
|
+
],
|
|
56
|
+
edges: [
|
|
57
|
+
{ from: "open-b", to: "watch-b" },
|
|
58
|
+
{ from: "watch-b.mergedSha", to: "manual-publish" },
|
|
59
|
+
{ from: "manual-publish.resolvedArtifact", to: "consume-c" },
|
|
60
|
+
{ from: "consume-c", to: "notify" },
|
|
61
|
+
],
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
test("a well-formed delivery graph produces no errors", () => {
|
|
65
|
+
assertEquals(validateDeliveryGraph(WELL_FORMED), []);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("an empty node set is rejected", () => {
|
|
69
|
+
const err = only(validateDeliveryGraph({ nodes: [] }));
|
|
70
|
+
assertEquals(err.code, "empty-graph");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("a non-object graph is rejected without throwing", () => {
|
|
74
|
+
assertEquals(validateDeliveryGraph(null).length, 1);
|
|
75
|
+
assertEquals(validateDeliveryGraph(undefined)[0].code, "empty-graph");
|
|
76
|
+
assertEquals(validateDeliveryGraph({ nodes: "nope" })[0].code, "empty-graph");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("unknown-kind: a node kind outside the closed allowlist is rejected, path-qualified", () => {
|
|
80
|
+
const errors = validateDeliveryGraph({
|
|
81
|
+
nodes: [{ id: "x", kind: "script", script: { run: "rm -rf /" } }],
|
|
82
|
+
});
|
|
83
|
+
const err = hasCode(errors, "unknown-kind");
|
|
84
|
+
assertEquals(err.path, "nodes[0].kind");
|
|
85
|
+
assert(err.message.includes(DELIVERY_NODE_KINDS.join(", ")), "message should list the allowlist");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("unknown-kind: the closed allowlist is exactly the four ADR-0005 kinds", () => {
|
|
89
|
+
assertEquals([...DELIVERY_NODE_KINDS], ["agent", "wait", "human", "connector"]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("dangling edge: a `to` that names no node is rejected, path-qualified", () => {
|
|
93
|
+
const errors = validateDeliveryGraph({
|
|
94
|
+
nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" } }],
|
|
95
|
+
edges: [{ from: "a", to: "ghost" }],
|
|
96
|
+
});
|
|
97
|
+
const err = hasCode(errors, "dangling-edge");
|
|
98
|
+
assertEquals(err.path, "edges[0].to");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("dangling edge: a `from` that names no node is rejected, path-qualified", () => {
|
|
102
|
+
const errors = validateDeliveryGraph({
|
|
103
|
+
nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" } }],
|
|
104
|
+
edges: [{ from: "ghost", to: "a" }],
|
|
105
|
+
});
|
|
106
|
+
const err = hasCode(errors, "dangling-edge");
|
|
107
|
+
assertEquals(err.path, "edges[0].from");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("bad-from: a `<node>.<fact>` reference to an undeclared fact is rejected, path-qualified", () => {
|
|
111
|
+
const errors = validateDeliveryGraph({
|
|
112
|
+
nodes: [
|
|
113
|
+
{ id: "a", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "version", type: "version" }] },
|
|
114
|
+
{ id: "b", kind: "agent", agent: { jobType: "j" } },
|
|
115
|
+
],
|
|
116
|
+
edges: [{ from: "a.sha", to: "b" }],
|
|
117
|
+
});
|
|
118
|
+
const err = hasCode(errors, "bad-from");
|
|
119
|
+
assertEquals(err.path, "edges[0].from");
|
|
120
|
+
assert(err.message.includes("sha"), "message should name the missing fact");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("bad-from: a declared fact reference resolves cleanly", () => {
|
|
124
|
+
const errors = validateDeliveryGraph({
|
|
125
|
+
nodes: [
|
|
126
|
+
{ id: "a", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "version", type: "version" }] },
|
|
127
|
+
{ id: "b", kind: "agent", agent: { jobType: "j" } },
|
|
128
|
+
],
|
|
129
|
+
edges: [{ from: "a.version", to: "b" }],
|
|
130
|
+
});
|
|
131
|
+
assertEquals(errors, []);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("a node id containing dots resolves as a whole node, not a fact split", () => {
|
|
135
|
+
const errors = validateDeliveryGraph({
|
|
136
|
+
nodes: [
|
|
137
|
+
{ id: "repo.owner.a", kind: "agent", agent: { jobType: "j" } },
|
|
138
|
+
{ id: "b", kind: "agent", agent: { jobType: "j" } },
|
|
139
|
+
],
|
|
140
|
+
edges: [{ from: "repo.owner.a", to: "b" }],
|
|
141
|
+
});
|
|
142
|
+
assertEquals(errors, []);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("bad-from: an edge that resolves as both a whole node id and a `<node>.<fact>` reference is rejected as ambiguous", () => {
|
|
146
|
+
const errors = validateDeliveryGraph({
|
|
147
|
+
nodes: [
|
|
148
|
+
{ id: "a.b", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "c", type: "version" }] },
|
|
149
|
+
{ id: "a.b.c", kind: "agent", agent: { jobType: "j" } },
|
|
150
|
+
{ id: "d", kind: "agent", agent: { jobType: "j" } },
|
|
151
|
+
],
|
|
152
|
+
edges: [{ from: "a.b.c", to: "d" }],
|
|
153
|
+
});
|
|
154
|
+
const err = hasCode(errors, "bad-from");
|
|
155
|
+
assertEquals(err.path, "edges[0].from");
|
|
156
|
+
assert(err.message.includes("ambiguous"), "message should call out the ambiguity");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("cycle: a self-edge is rejected", () => {
|
|
160
|
+
const errors = validateDeliveryGraph({
|
|
161
|
+
nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" } }],
|
|
162
|
+
edges: [{ from: "a", to: "a" }],
|
|
163
|
+
});
|
|
164
|
+
const err = hasCode(errors, "self-edge");
|
|
165
|
+
assertEquals(err.path, "edges[0]");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("cycle: a multi-node dependency cycle is rejected, naming the cycle", () => {
|
|
169
|
+
const errors = validateDeliveryGraph({
|
|
170
|
+
nodes: [
|
|
171
|
+
{ id: "a", kind: "agent", agent: { jobType: "j" } },
|
|
172
|
+
{ id: "b", kind: "agent", agent: { jobType: "j" } },
|
|
173
|
+
{ id: "c", kind: "agent", agent: { jobType: "j" } },
|
|
174
|
+
],
|
|
175
|
+
edges: [
|
|
176
|
+
{ from: "a", to: "b" },
|
|
177
|
+
{ from: "b", to: "c" },
|
|
178
|
+
{ from: "c", to: "a" },
|
|
179
|
+
],
|
|
180
|
+
});
|
|
181
|
+
const err = hasCode(errors, "cycle");
|
|
182
|
+
assertEquals(err.path, "edges");
|
|
183
|
+
assert(err.message.includes("→"), "cycle message should render the cycle path");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("duplicate-id: two nodes sharing an id is rejected", () => {
|
|
187
|
+
const errors = validateDeliveryGraph({
|
|
188
|
+
nodes: [
|
|
189
|
+
{ id: "a", kind: "agent", agent: { jobType: "j" } },
|
|
190
|
+
{ id: "a", kind: "human" },
|
|
191
|
+
],
|
|
192
|
+
});
|
|
193
|
+
const err = hasCode(errors, "duplicate-id");
|
|
194
|
+
assertEquals(err.path, "nodes[1].id");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("missing-config: a non-human node without its per-kind config is rejected", () => {
|
|
198
|
+
const errors = validateDeliveryGraph({ nodes: [{ id: "a", kind: "wait" }] });
|
|
199
|
+
const err = hasCode(errors, "missing-config");
|
|
200
|
+
assertEquals(err.path, "nodes[0].wait");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("missing-required-field: an agent node whose `agent` config omits `jobType` is rejected", () => {
|
|
204
|
+
const errors = validateDeliveryGraph({ nodes: [{ id: "a", kind: "agent", agent: {} }] });
|
|
205
|
+
const err = hasCode(errors, "missing-required-field");
|
|
206
|
+
assertEquals(err.path, "nodes[0].agent.jobType");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("missing-required-field: a wait node whose probe omits `kind`/`target` is rejected per field", () => {
|
|
210
|
+
const errors = validateDeliveryGraph({ nodes: [{ id: "a", kind: "wait", wait: {} }] });
|
|
211
|
+
hasCode(errors, "missing-required-field");
|
|
212
|
+
assertEquals(
|
|
213
|
+
errors.filter((e) => e.code === "missing-required-field").map((e) => e.path).sort(),
|
|
214
|
+
["nodes[0].wait.kind", "nodes[0].wait.target"],
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("missing-required-field: a connector node whose config has an empty `target` is rejected", () => {
|
|
219
|
+
const errors = validateDeliveryGraph({
|
|
220
|
+
nodes: [{ id: "a", kind: "connector", connector: { target: "" } }],
|
|
221
|
+
});
|
|
222
|
+
const err = hasCode(errors, "missing-required-field");
|
|
223
|
+
assertEquals(err.path, "nodes[0].connector.target");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("a human node may omit its config (generic-fallback resolution lands in S3)", () => {
|
|
227
|
+
assertEquals(validateDeliveryGraph({ nodes: [{ id: "done", kind: "human" }] }), []);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("duplicate-fact: two emits sharing a name on one node is rejected", () => {
|
|
231
|
+
const errors = validateDeliveryGraph({
|
|
232
|
+
nodes: [
|
|
233
|
+
{
|
|
234
|
+
id: "a",
|
|
235
|
+
kind: "agent",
|
|
236
|
+
agent: { jobType: "j" },
|
|
237
|
+
emits: [{ name: "v", type: "version" }, { name: "v", type: "string" }],
|
|
238
|
+
},
|
|
239
|
+
],
|
|
240
|
+
});
|
|
241
|
+
const err = hasCode(errors, "duplicate-fact");
|
|
242
|
+
assertEquals(err.path, "nodes[0].emits[1].name");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("all errors are collected in one pass, not just the first", () => {
|
|
246
|
+
const errors = validateDeliveryGraph({
|
|
247
|
+
nodes: [
|
|
248
|
+
{ id: "a", kind: "bogus" },
|
|
249
|
+
{ id: "a", kind: "agent", agent: { jobType: "j" } },
|
|
250
|
+
],
|
|
251
|
+
edges: [{ from: "ghost", to: "a" }],
|
|
252
|
+
});
|
|
253
|
+
hasCode(errors, "unknown-kind");
|
|
254
|
+
hasCode(errors, "duplicate-id");
|
|
255
|
+
hasCode(errors, "dangling-edge");
|
|
256
|
+
assert(errors.length >= 3, `expected the pass to collect every error, got ${errors.length}`);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("a graph with no edges (independent roots) is valid", () => {
|
|
260
|
+
assertEquals(
|
|
261
|
+
validateDeliveryGraph({
|
|
262
|
+
nodes: [
|
|
263
|
+
{ id: "a", kind: "agent", agent: { jobType: "j" } },
|
|
264
|
+
{ id: "b", kind: "agent", agent: { jobType: "j" } },
|
|
265
|
+
],
|
|
266
|
+
}),
|
|
267
|
+
[],
|
|
268
|
+
);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("a non-array `edges` is rejected as a shape error (`invalid-edges`), not silently treated as no edges", () => {
|
|
272
|
+
const errors = validateDeliveryGraph({
|
|
273
|
+
nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" } }],
|
|
274
|
+
edges: "nope",
|
|
275
|
+
});
|
|
276
|
+
const err = hasCode(errors, "invalid-edges");
|
|
277
|
+
assertEquals(err.path, "edges");
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("a non-object edge entry is a shape error (`invalid-edges`), not `dangling-edge`", () => {
|
|
281
|
+
const errors = validateDeliveryGraph({
|
|
282
|
+
nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" } }],
|
|
283
|
+
edges: ["nope"],
|
|
284
|
+
});
|
|
285
|
+
const err = hasCode(errors, "invalid-edges");
|
|
286
|
+
assertEquals(err.path, "edges[0]");
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("an edge missing string `from`/`to` is a shape error (`invalid-edges`), not `dangling-edge`", () => {
|
|
290
|
+
const errors = validateDeliveryGraph({
|
|
291
|
+
nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" } }],
|
|
292
|
+
edges: [{ from: "", to: 3 }],
|
|
293
|
+
});
|
|
294
|
+
const fromErr = hasCode(errors, "invalid-edges");
|
|
295
|
+
assertEquals(fromErr.path, "edges[0].from");
|
|
296
|
+
assert(
|
|
297
|
+
errors.some((e) => e.code === "invalid-edges" && e.path === "edges[0].to"),
|
|
298
|
+
"expected the missing `to` to also be an invalid-edges shape error",
|
|
299
|
+
);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("invalid-id: a node id violating the openapi id pattern is rejected so downstream id use stays safe", () => {
|
|
303
|
+
const errors = validateDeliveryGraph({
|
|
304
|
+
nodes: [{ id: "1 bad id", kind: "agent", agent: { jobType: "j" } }],
|
|
305
|
+
});
|
|
306
|
+
const err = hasCode(errors, "invalid-id");
|
|
307
|
+
assertEquals(err.path, "nodes[0].id");
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("a `human` node whose `human` config is not an object is rejected, path-qualified", () => {
|
|
311
|
+
const errors = validateDeliveryGraph({
|
|
312
|
+
nodes: [{ id: "done", kind: "human", human: "just do it" }],
|
|
313
|
+
});
|
|
314
|
+
const err = hasCode(errors, "missing-config");
|
|
315
|
+
assertEquals(err.path, "nodes[0].human");
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("invalid-fact-name: an emitted fact name containing a dot is rejected so qualified `from` stays unambiguous", () => {
|
|
319
|
+
const errors = validateDeliveryGraph({
|
|
320
|
+
nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "sha.short", type: "string" }] }],
|
|
321
|
+
});
|
|
322
|
+
const err = hasCode(errors, "invalid-fact-name");
|
|
323
|
+
assertEquals(err.path, "nodes[0].emits[0].name");
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("invalid-fact-name: an emitted fact name over the openapi 128-char cap is rejected so a length-trusting consumer can't be overrun", () => {
|
|
327
|
+
const errors = validateDeliveryGraph({
|
|
328
|
+
nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "f".repeat(129), type: "string" }] }],
|
|
329
|
+
});
|
|
330
|
+
const err = hasCode(errors, "invalid-fact-name");
|
|
331
|
+
assertEquals(err.path, "nodes[0].emits[0].name");
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("invalid-fact-type: an emitted fact with a type outside the allowlist is rejected, path-qualified", () => {
|
|
335
|
+
const errors = validateDeliveryGraph({
|
|
336
|
+
nodes: [
|
|
337
|
+
{
|
|
338
|
+
id: "a",
|
|
339
|
+
kind: "agent",
|
|
340
|
+
agent: { jobType: "j" },
|
|
341
|
+
emits: [{ name: "sha", type: "bogus" }],
|
|
342
|
+
},
|
|
343
|
+
],
|
|
344
|
+
});
|
|
345
|
+
const err = hasCode(errors, "invalid-fact-type");
|
|
346
|
+
assertEquals(err.path, "nodes[0].emits[0].type");
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("invalid-fact-type: an emitted fact missing its `type` is rejected", () => {
|
|
350
|
+
const errors = validateDeliveryGraph({
|
|
351
|
+
nodes: [
|
|
352
|
+
{ id: "a", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "sha" }] },
|
|
353
|
+
],
|
|
354
|
+
});
|
|
355
|
+
const err = hasCode(errors, "invalid-fact-type");
|
|
356
|
+
assertEquals(err.path, "nodes[0].emits[0].type");
|
|
357
|
+
});
|
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
// nano-workforce — the pure, side-effect-free SEMANTIC validator for an agent-authored delivery
|
|
2
|
+
// graph (ADR 0005, slice S0). The `DeliveryGraph` SHAPE is validated at the edge by the openapi
|
|
3
|
+
// schema (`openapi.yaml` → generated `DeliveryGraph` contract); this module validates the semantics
|
|
4
|
+
// the JSON Schema CANNOT express and that a compiler/runner must be able to trust before it does
|
|
5
|
+
// anything:
|
|
6
|
+
//
|
|
7
|
+
// • unknown `kind` — a node whose `kind` is not in the CLOSED allowlist (the trust boundary,
|
|
8
|
+
// Decision 1/2). Defensive because the body arrives untyped from a request.
|
|
9
|
+
// • duplicate node id — two nodes sharing an id, which would make every edge to it ambiguous.
|
|
10
|
+
// • dangling edge — an edge endpoint (`from`/`to`) that names no node in the graph.
|
|
11
|
+
// • bad `from` reference — a qualified `<nodeId>.<fact>` whose fact is not declared in that node's
|
|
12
|
+
// typed `emits[]` (Decision 3/4 — binds are validated, not stringly).
|
|
13
|
+
// • cycle — the edge set must be a DAG (discovered-fact dependencies flow forward only).
|
|
14
|
+
//
|
|
15
|
+
// It is modelled on the epic-set validator `validateEpicSet` (app/plan.ts): a PURE in-memory walk
|
|
16
|
+
// that runs BEFORE any side effect. Unlike `validateEpicSet` (which throws at the first offending
|
|
17
|
+
// edge), this COLLECTS every error and returns them, so a co-designing agent gets ONE actionable,
|
|
18
|
+
// path-qualified list per compile attempt (the S1 compiler surfaces them as `{ ok:false, errors }`).
|
|
19
|
+
// Every error carries a JSON-path-qualified `path` (`nodes[2].kind`, `edges[1].from`, …) so the
|
|
20
|
+
// caller can point the author straight at the offending input.
|
|
21
|
+
|
|
22
|
+
/** The CLOSED node-kind allowlist (ADR 0005 Decision 2) — the trust boundary. Extensible only by a
|
|
23
|
+
* deliberate ADR/PR (add the openapi variant + a case here), never by a graph author. Kept as the
|
|
24
|
+
* single source of truth for "which kinds are legal" so the validator and any future compiler agree. */
|
|
25
|
+
export const DELIVERY_NODE_KINDS = ["agent", "wait", "human", "connector"] as const;
|
|
26
|
+
|
|
27
|
+
/** A node's `kind`, narrowed to the closed allowlist. */
|
|
28
|
+
export type DeliveryNodeKind = (typeof DELIVERY_NODE_KINDS)[number];
|
|
29
|
+
|
|
30
|
+
/** The CLOSED emitted-fact type allowlist (ADR 0005 Decision 3/4) — mirrors the `DeliveryFact.type`
|
|
31
|
+
* enum in `openapi.yaml`. Kept as the single source of truth so the semantic validator rejects an
|
|
32
|
+
* untyped/unknown fact type even when the OpenAPI shape validator is bypassed (a directly-invoked
|
|
33
|
+
* delegate), since later compilation/execution steps rely on this allowlist. */
|
|
34
|
+
export const DELIVERY_FACT_TYPES = ["string", "number", "boolean", "artifact", "version", "url"] as const;
|
|
35
|
+
|
|
36
|
+
/** An emitted fact's declared `type`, narrowed to the closed allowlist. */
|
|
37
|
+
export type DeliveryFactType = (typeof DELIVERY_FACT_TYPES)[number];
|
|
38
|
+
|
|
39
|
+
/** A machine-readable classification of a semantic failure, so a caller can branch on the error
|
|
40
|
+
* class (unknown-kind / dangling / cycle / bad-`from`) without string-matching the message. */
|
|
41
|
+
export type DeliveryGraphErrorCode =
|
|
42
|
+
| "empty-graph"
|
|
43
|
+
| "missing-id"
|
|
44
|
+
| "invalid-id"
|
|
45
|
+
| "duplicate-id"
|
|
46
|
+
| "unknown-kind"
|
|
47
|
+
| "missing-config"
|
|
48
|
+
| "missing-required-field"
|
|
49
|
+
| "duplicate-fact"
|
|
50
|
+
| "invalid-fact-name"
|
|
51
|
+
| "invalid-fact-type"
|
|
52
|
+
| "invalid-edges"
|
|
53
|
+
| "dangling-edge"
|
|
54
|
+
| "bad-from"
|
|
55
|
+
| "self-edge"
|
|
56
|
+
| "cycle";
|
|
57
|
+
|
|
58
|
+
/** A single semantic validation failure. `path` is a JSON-path-qualified pointer at the offending
|
|
59
|
+
* input (`nodes[2].kind`, `edges[1].from`, `nodes[0].emits[1].name`), `message` is human-actionable,
|
|
60
|
+
* and `code` is the stable error class. Shaped so the S1 compiler can forward it verbatim as one of
|
|
61
|
+
* its `{ ok:false, errors:[{ path, message }] }` entries. */
|
|
62
|
+
export interface DeliveryGraphError {
|
|
63
|
+
readonly path: string;
|
|
64
|
+
readonly message: string;
|
|
65
|
+
readonly code: DeliveryGraphErrorCode;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Narrow an untyped value to a plain object so its fields can be read as `unknown`. */
|
|
69
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
70
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** True when `kind` is a member of the closed allowlist. */
|
|
74
|
+
function isDeliveryNodeKind(kind: unknown): kind is DeliveryNodeKind {
|
|
75
|
+
if (typeof kind !== "string") return false;
|
|
76
|
+
for (const k of DELIVERY_NODE_KINDS) if (k === kind) return true;
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** True when `type` is a member of the closed emitted-fact type allowlist. */
|
|
81
|
+
function isDeliveryFactType(type: unknown): type is DeliveryFactType {
|
|
82
|
+
if (typeof type !== "string") return false;
|
|
83
|
+
for (const t of DELIVERY_FACT_TYPES) if (t === type) return true;
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A fact `name` must be a bare identifier (no dots) — mirrors openapi's `DeliveryFact.name`
|
|
88
|
+
* `^[A-Za-z_][A-Za-z0-9_]*$`. `resolveFrom` RELIES on fact names being dot-free (a node id MAY
|
|
89
|
+
* contain dots) to disambiguate a qualified edge `from`, so the semantic validator re-enforces the
|
|
90
|
+
* pattern INDEPENDENTLY of the OpenAPI shape gate: if that gate is bypassed (a direct delegate call,
|
|
91
|
+
* a test, a future internal use), a dotted fact name could otherwise make `<nodeId>.<fact>` resolution
|
|
92
|
+
* ambiguous and quietly build the wrong DAG — undermining the trust boundary this validator exists to
|
|
93
|
+
* hold. */
|
|
94
|
+
const FACT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
95
|
+
const FACT_NAME_MAX_LENGTH = 128;
|
|
96
|
+
|
|
97
|
+
/** A node `id` must match openapi's `DeliveryNodeCommon.id` `^[A-Za-z_][A-Za-z0-9_.-]*$` and stay
|
|
98
|
+
* within its 128-char cap. Re-enforced here INDEPENDENTLY of the OpenAPI shape gate because later
|
|
99
|
+
* compile/render steps trust these ids: an id with whitespace, a leading digit, or an over-long value
|
|
100
|
+
* could otherwise pass semantic validation (a bypassed shape gate — a direct delegate call, a test)
|
|
101
|
+
* and then break id-based compilation/rendering downstream. Unlike a fact name, an id MAY contain
|
|
102
|
+
* dots/hyphens — `resolveFrom` splits a qualified `from` on the LAST dot, so a dotted id stays
|
|
103
|
+
* resolvable while dot-free fact names keep `<nodeId>.<fact>` unambiguous. */
|
|
104
|
+
const NODE_ID_PATTERN = /^[A-Za-z_][A-Za-z0-9_.-]*$/;
|
|
105
|
+
const NODE_ID_MAX_LENGTH = 128;
|
|
106
|
+
|
|
107
|
+
/** The per-kind config key a node of the given kind must carry (`agent` → `agent`, etc.). */
|
|
108
|
+
const CONFIG_KEY: Record<DeliveryNodeKind, string> = {
|
|
109
|
+
agent: "agent",
|
|
110
|
+
wait: "wait",
|
|
111
|
+
human: "human",
|
|
112
|
+
connector: "connector",
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/** The REQUIRED non-empty-string fields inside each kind's per-kind config object, mirroring the
|
|
116
|
+
* `required` lists in openapi (`DeliveryNodeAgent.agent.jobType`, the `ReadinessProbe.kind`/`target`
|
|
117
|
+
* a `wait` reuses, `DeliveryNodeConnector.connector.target`). Re-enforced here INDEPENDENTLY of the
|
|
118
|
+
* OpenAPI shape gate so that, when that gate is bypassed (a direct delegate call, a test, a future
|
|
119
|
+
* internal use), a config object present-but-missing its required fields (e.g. `{ kind:"agent",
|
|
120
|
+
* agent:{} }`) is rejected with an actionable error rather than passing semantic validation and
|
|
121
|
+
* crashing a downstream compiler/runner that assumes those fields exist. `human` has no required
|
|
122
|
+
* config field (its config is optional). Kept as the single source of truth so this list and openapi
|
|
123
|
+
* agree. NOTE: field PRESENCE + non-emptiness is enforced here, not the `ReadinessProbe.kind` enum —
|
|
124
|
+
* that enum evolves per slice (S2 adds `pr`), so enumerating it here would drift; the enum stays
|
|
125
|
+
* owned by the shape gate / `app/readiness.ts`. */
|
|
126
|
+
const REQUIRED_CONFIG_FIELDS: Record<DeliveryNodeKind, readonly string[]> = {
|
|
127
|
+
agent: ["jobType"],
|
|
128
|
+
wait: ["kind", "target"],
|
|
129
|
+
human: [],
|
|
130
|
+
connector: ["target"],
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/** Resolve an edge `from` endpoint against the known node set. A node id MAY itself contain dots (the
|
|
134
|
+
* openapi id pattern allows them) while a fact name (an identifier) cannot, so resolution is
|
|
135
|
+
* disambiguated by the node set rather than by naive splitting: (1) if the WHOLE string is a node id
|
|
136
|
+
* it is a bare completion-fact reference (`nodeId`, no fact); (2) else split at the LAST dot and, if
|
|
137
|
+
* the prefix is a node id, it is a qualified `<nodeId>.<fact>` reference; (3) else it is dangling —
|
|
138
|
+
* return the whole string as the (unresolvable) node id so the caller reports it against `from`.
|
|
139
|
+
* When BOTH interpretations resolve — the whole string is a node id AND its last-dot prefix is a
|
|
140
|
+
* node that emits the suffix as a fact — the reference is genuinely ambiguous; surface it via
|
|
141
|
+
* `ambiguousWith` so the caller rejects it (`bad-from`) rather than silently choosing the whole-node
|
|
142
|
+
* reading and producing an unintended DAG. */
|
|
143
|
+
function resolveFrom(
|
|
144
|
+
from: string,
|
|
145
|
+
nodeFacts: ReadonlyMap<string, ReadonlySet<string>>,
|
|
146
|
+
): { nodeId: string; fact?: string; ambiguousWith?: { nodeId: string; fact: string } } {
|
|
147
|
+
const dot = from.lastIndexOf(".");
|
|
148
|
+
const split =
|
|
149
|
+
dot > 0 && dot < from.length - 1 ? { prefix: from.slice(0, dot), suffix: from.slice(dot + 1) } : undefined;
|
|
150
|
+
if (nodeFacts.has(from)) {
|
|
151
|
+
if (split !== undefined && nodeFacts.get(split.prefix)?.has(split.suffix)) {
|
|
152
|
+
return { nodeId: from, ambiguousWith: { nodeId: split.prefix, fact: split.suffix } };
|
|
153
|
+
}
|
|
154
|
+
return { nodeId: from };
|
|
155
|
+
}
|
|
156
|
+
if (split !== undefined && nodeFacts.has(split.prefix)) return { nodeId: split.prefix, fact: split.suffix };
|
|
157
|
+
return { nodeId: from };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Pure, side-effect-free SEMANTIC validation of a delivery graph (ADR 0005 slice S0). Accepts the
|
|
162
|
+
* graph as `unknown` because it arrives from an untyped request body — every field is read
|
|
163
|
+
* defensively, so a malformed input maps to a clean {@link DeliveryGraphError} (never an uncaught
|
|
164
|
+
* TypeError). Returns every error found (empty array ⇒ the graph is semantically valid), each
|
|
165
|
+
* path-qualified — one entry per offending node/edge/fact, except cycle detection, which reports at
|
|
166
|
+
* most ONE cycle per call to keep the output actionable (fix it and re-validate to surface the next).
|
|
167
|
+
* Run this BEFORE any compile/deploy so a cycle, dangling edge, unknown kind, or unresolvable fact
|
|
168
|
+
* reference is rejected with nothing started.
|
|
169
|
+
*/
|
|
170
|
+
export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
|
|
171
|
+
const errors: DeliveryGraphError[] = [];
|
|
172
|
+
|
|
173
|
+
if (!isRecord(graph) || !Array.isArray(graph.nodes)) {
|
|
174
|
+
return [
|
|
175
|
+
{
|
|
176
|
+
path: "nodes",
|
|
177
|
+
message: "delivery graph must be an object with a `nodes` array",
|
|
178
|
+
code: "empty-graph",
|
|
179
|
+
},
|
|
180
|
+
];
|
|
181
|
+
}
|
|
182
|
+
const nodes = graph.nodes;
|
|
183
|
+
if (nodes.length === 0) {
|
|
184
|
+
errors.push({
|
|
185
|
+
path: "nodes",
|
|
186
|
+
message: "delivery graph is empty — declare at least one node",
|
|
187
|
+
code: "empty-graph",
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Pass 1: node ids + kinds + per-kind config + declared facts. Build the id → declared-facts map
|
|
192
|
+
// used to resolve edge `from` references in pass 2.
|
|
193
|
+
const nodeFacts = new Map<string, Set<string>>();
|
|
194
|
+
nodes.forEach((rawNode, i) => {
|
|
195
|
+
const path = `nodes[${i}]`;
|
|
196
|
+
if (!isRecord(rawNode)) {
|
|
197
|
+
errors.push({ path, message: "each node must be an object", code: "missing-config" });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const id = rawNode.id;
|
|
201
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
202
|
+
errors.push({ path: `${path}.id`, message: "node is missing a string `id`", code: "missing-id" });
|
|
203
|
+
} else {
|
|
204
|
+
if (id.length > NODE_ID_MAX_LENGTH || !NODE_ID_PATTERN.test(id)) {
|
|
205
|
+
// Mirror openapi's `DeliveryNodeCommon.id` pattern/length so an invalid id can't slip past a
|
|
206
|
+
// bypassed shape gate and break id-based compilation/rendering in a later slice.
|
|
207
|
+
errors.push({
|
|
208
|
+
path: `${path}.id`,
|
|
209
|
+
message:
|
|
210
|
+
`node id "${id}" must be a bare identifier (\`^[A-Za-z_][A-Za-z0-9_.-]*$\`, ` +
|
|
211
|
+
`\u2264 ${NODE_ID_MAX_LENGTH} chars) so downstream id-based compilation stays safe`,
|
|
212
|
+
code: "invalid-id",
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
if (nodeFacts.has(id)) {
|
|
216
|
+
errors.push({
|
|
217
|
+
path: `${path}.id`,
|
|
218
|
+
message: `duplicate node id "${id}" — every node id must be unique in the graph`,
|
|
219
|
+
code: "duplicate-id",
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const kind = rawNode.kind;
|
|
225
|
+
if (!isDeliveryNodeKind(kind)) {
|
|
226
|
+
errors.push({
|
|
227
|
+
path: `${path}.kind`,
|
|
228
|
+
message:
|
|
229
|
+
`unknown node kind ${JSON.stringify(kind)} — must be one of ` +
|
|
230
|
+
`${DELIVERY_NODE_KINDS.join(", ")} (the closed vocabulary is the trust boundary)`,
|
|
231
|
+
code: "unknown-kind",
|
|
232
|
+
});
|
|
233
|
+
} else if (kind !== "human") {
|
|
234
|
+
// Every kind but `human` REQUIRES its per-kind config object.
|
|
235
|
+
const configKey = CONFIG_KEY[kind];
|
|
236
|
+
const config = rawNode[configKey];
|
|
237
|
+
if (!isRecord(config)) {
|
|
238
|
+
errors.push({
|
|
239
|
+
path: `${path}.${configKey}`,
|
|
240
|
+
message: `${kind} node is missing its required \`${configKey}\` config`,
|
|
241
|
+
code: "missing-config",
|
|
242
|
+
});
|
|
243
|
+
} else {
|
|
244
|
+
// The config object is present — re-enforce the fields openapi marks REQUIRED (a bypassed
|
|
245
|
+
// shape gate could otherwise let `{ kind:"agent", agent:{} }` through and crash a downstream
|
|
246
|
+
// compiler/runner that trusts those fields exist).
|
|
247
|
+
for (const field of REQUIRED_CONFIG_FIELDS[kind]) {
|
|
248
|
+
const value = config[field];
|
|
249
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
250
|
+
errors.push({
|
|
251
|
+
path: `${path}.${configKey}.${field}`,
|
|
252
|
+
message: `${kind} node's \`${configKey}.${field}\` is required and must be a non-empty string`,
|
|
253
|
+
code: "missing-required-field",
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
} else if (rawNode.human !== undefined && !isRecord(rawNode.human)) {
|
|
259
|
+
// `human` config is OPTIONAL (formKey/prompt both resolve to a generic fallback in S3), but
|
|
260
|
+
// when PRESENT it must be a plain object so later slices can safely read `human.formKey` /
|
|
261
|
+
// `human.prompt` — a string/array/null `human` would crash them downstream.
|
|
262
|
+
errors.push({
|
|
263
|
+
path: `${path}.human`,
|
|
264
|
+
message: "`human` config, when present, must be an object",
|
|
265
|
+
code: "missing-config",
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Collect + validate this node's typed emitted facts (uniqueness within the node). Registered
|
|
270
|
+
// under the id even when other fields are invalid, so downstream edge resolution is best-effort.
|
|
271
|
+
const facts = new Set<string>();
|
|
272
|
+
if (rawNode.emits !== undefined) {
|
|
273
|
+
if (!Array.isArray(rawNode.emits)) {
|
|
274
|
+
errors.push({
|
|
275
|
+
path: `${path}.emits`,
|
|
276
|
+
message: "`emits` must be an array of typed fact declarations",
|
|
277
|
+
code: "missing-config",
|
|
278
|
+
});
|
|
279
|
+
} else {
|
|
280
|
+
rawNode.emits.forEach((rawFact, j) => {
|
|
281
|
+
if (!isRecord(rawFact) || typeof rawFact.name !== "string" || rawFact.name.length === 0) {
|
|
282
|
+
errors.push({
|
|
283
|
+
path: `${path}.emits[${j}].name`,
|
|
284
|
+
message: "each emitted fact needs a non-empty string `name`",
|
|
285
|
+
code: "missing-config",
|
|
286
|
+
});
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (facts.has(rawFact.name)) {
|
|
290
|
+
errors.push({
|
|
291
|
+
path: `${path}.emits[${j}].name`,
|
|
292
|
+
message: `duplicate emitted fact "${rawFact.name}" on node "${String(id)}"`,
|
|
293
|
+
code: "duplicate-fact",
|
|
294
|
+
});
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (rawFact.name.length > FACT_NAME_MAX_LENGTH || !FACT_NAME_PATTERN.test(rawFact.name)) {
|
|
298
|
+
// A fact name must be a dot-free identifier within openapi's 128-char cap (openapi's
|
|
299
|
+
// `DeliveryFact.name` `pattern` + `maxLength`) so a qualified edge `from`
|
|
300
|
+
// "<nodeId>.<fact>" resolves unambiguously and a later step trusting the cap can't be
|
|
301
|
+
// overrun — enforced here too, in case the OpenAPI shape gate is bypassed.
|
|
302
|
+
errors.push({
|
|
303
|
+
path: `${path}.emits[${j}].name`,
|
|
304
|
+
message:
|
|
305
|
+
`emitted fact name "${rawFact.name}" must be a bare identifier ` +
|
|
306
|
+
"(`^[A-Za-z_][A-Za-z0-9_]*$`, no dots) of " +
|
|
307
|
+
`\u2264 ${FACT_NAME_MAX_LENGTH} chars so qualified edge \`from\` references stay unambiguous`,
|
|
308
|
+
code: "invalid-fact-name",
|
|
309
|
+
});
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (!isDeliveryFactType(rawFact.type)) {
|
|
313
|
+
// emits are TYPED (Decision 3/4). An invalid/missing `type` must be rejected even when the
|
|
314
|
+
// OpenAPI shape validator is bypassed, or a later step reading the type allowlist breaks.
|
|
315
|
+
errors.push({
|
|
316
|
+
path: `${path}.emits[${j}].type`,
|
|
317
|
+
message:
|
|
318
|
+
`emitted fact "${rawFact.name}" has an invalid \`type\` — must be one of ` +
|
|
319
|
+
`${DELIVERY_FACT_TYPES.join(", ")}`,
|
|
320
|
+
code: "invalid-fact-type",
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
facts.add(rawFact.name);
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (typeof id === "string" && id.length > 0 && !nodeFacts.has(id)) {
|
|
328
|
+
nodeFacts.set(id, facts);
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
// Pass 2: edges. Resolve each endpoint against the node set and each qualified `from` against the
|
|
333
|
+
// upstream node's declared facts, and build the adjacency for the cycle check.
|
|
334
|
+
const edges: readonly unknown[] = Array.isArray(graph.edges) ? graph.edges : [];
|
|
335
|
+
if (graph.edges !== undefined && !Array.isArray(graph.edges)) {
|
|
336
|
+
// A non-array `edges` must not be silently treated as "no edges" — that would let a malformed
|
|
337
|
+
// body pass semantic validation when the OpenAPI shape validator is bypassed. This is a
|
|
338
|
+
// shape/type error (not an endpoint-resolution failure), so it carries `invalid-edges` — callers
|
|
339
|
+
// branching on error codes must distinguish "edges isn't a list" from a genuine dangling endpoint.
|
|
340
|
+
errors.push({
|
|
341
|
+
path: "edges",
|
|
342
|
+
message: "`edges`, when present, must be an array of `{ from, to }` dependency edges",
|
|
343
|
+
code: "invalid-edges",
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
// consumer (`to`) → set of upstream node ids (`from`'s node) — the dependency direction.
|
|
347
|
+
const adjacency = new Map<string, Set<string>>();
|
|
348
|
+
edges.forEach((rawEdge, i) => {
|
|
349
|
+
const path = `edges[${i}]`;
|
|
350
|
+
// A non-object entry or a missing/empty `from`/`to` is an edge *shape* error, not an
|
|
351
|
+
// endpoint-resolution failure — so it carries `invalid-edges` (like the non-array `edges` case
|
|
352
|
+
// above), reserving `dangling-edge` for a well-formed endpoint that names no node/fact.
|
|
353
|
+
if (!isRecord(rawEdge)) {
|
|
354
|
+
errors.push({ path, message: "each edge must be an object with `from` and `to`", code: "invalid-edges" });
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const from = rawEdge.from;
|
|
358
|
+
const to = rawEdge.to;
|
|
359
|
+
if (typeof from !== "string" || from.length === 0) {
|
|
360
|
+
errors.push({ path: `${path}.from`, message: "edge is missing a string `from`", code: "invalid-edges" });
|
|
361
|
+
}
|
|
362
|
+
if (typeof to !== "string" || to.length === 0) {
|
|
363
|
+
errors.push({ path: `${path}.to`, message: "edge is missing a string `to`", code: "invalid-edges" });
|
|
364
|
+
}
|
|
365
|
+
if (typeof from !== "string" || typeof to !== "string" || from.length === 0 || to.length === 0) {
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (!nodeFacts.has(to)) {
|
|
370
|
+
errors.push({
|
|
371
|
+
path: `${path}.to`,
|
|
372
|
+
message: `edge \`to\` "${to}" names no node in the graph`,
|
|
373
|
+
code: "dangling-edge",
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const { nodeId, fact, ambiguousWith } = resolveFrom(from, nodeFacts);
|
|
378
|
+
if (ambiguousWith !== undefined) {
|
|
379
|
+
errors.push({
|
|
380
|
+
path: `${path}.from`,
|
|
381
|
+
message:
|
|
382
|
+
`edge \`from\` "${from}" is ambiguous — it names both node "${from}" (a completion ` +
|
|
383
|
+
`dependency) and fact "${ambiguousWith.fact}" of node "${ambiguousWith.nodeId}"; rename ` +
|
|
384
|
+
"a node id or choose a different fact to disambiguate",
|
|
385
|
+
code: "bad-from",
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
const upstreamFacts = nodeFacts.get(nodeId);
|
|
389
|
+
if (upstreamFacts === undefined) {
|
|
390
|
+
errors.push({
|
|
391
|
+
path: `${path}.from`,
|
|
392
|
+
message: `edge \`from\` "${from}" names no node in the graph`,
|
|
393
|
+
code: "dangling-edge",
|
|
394
|
+
});
|
|
395
|
+
} else if (fact !== undefined && !upstreamFacts.has(fact)) {
|
|
396
|
+
errors.push({
|
|
397
|
+
path: `${path}.from`,
|
|
398
|
+
message:
|
|
399
|
+
`edge \`from\` "${from}" references fact "${fact}" that node "${nodeId}" does not ` +
|
|
400
|
+
"declare in its `emits[]`",
|
|
401
|
+
code: "bad-from",
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (nodeId === to) {
|
|
406
|
+
errors.push({
|
|
407
|
+
path,
|
|
408
|
+
message: `node "${to}" cannot depend on itself`,
|
|
409
|
+
code: "self-edge",
|
|
410
|
+
});
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Only wire the cycle graph for edges whose endpoints both resolve — a dangling edge is already
|
|
415
|
+
// reported and must not crash the walk.
|
|
416
|
+
if (nodeFacts.has(to) && upstreamFacts !== undefined) {
|
|
417
|
+
const ups = adjacency.get(to) ?? new Set<string>();
|
|
418
|
+
ups.add(nodeId);
|
|
419
|
+
adjacency.set(to, ups);
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
collectCycle(adjacency, errors);
|
|
424
|
+
return errors;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Depth-first cycle detection over the consumer(`to`)→producer(`from`) graph. Pushes ONE
|
|
428
|
+
* {@link DeliveryGraphError} naming the offending cycle (the "reject at the offending edge"
|
|
429
|
+
* guarantee) — a pure in-memory walk, no I/O. Reports at most one cycle so the message stays
|
|
430
|
+
* actionable; the author fixes it and re-validates to surface any next one. */
|
|
431
|
+
function collectCycle(adjacency: Map<string, Set<string>>, errors: DeliveryGraphError[]): void {
|
|
432
|
+
const VISITING = 1;
|
|
433
|
+
const DONE = 2;
|
|
434
|
+
const state = new Map<string, number>();
|
|
435
|
+
let reported = false;
|
|
436
|
+
const visit = (node: string, stack: string[]): void => {
|
|
437
|
+
if (reported) return;
|
|
438
|
+
state.set(node, VISITING);
|
|
439
|
+
stack.push(node);
|
|
440
|
+
for (const next of adjacency.get(node) ?? []) {
|
|
441
|
+
if (reported) break;
|
|
442
|
+
const s = state.get(next);
|
|
443
|
+
if (s === VISITING) {
|
|
444
|
+
const cycleStart = stack.indexOf(next);
|
|
445
|
+
const cycle = [...stack.slice(cycleStart), next];
|
|
446
|
+
errors.push({
|
|
447
|
+
path: "edges",
|
|
448
|
+
message: `dependency cycle detected: ${cycle.join(" → ")} — the graph must be a DAG`,
|
|
449
|
+
code: "cycle",
|
|
450
|
+
});
|
|
451
|
+
reported = true;
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
if (s !== DONE) visit(next, stack);
|
|
455
|
+
}
|
|
456
|
+
stack.pop();
|
|
457
|
+
state.set(node, DONE);
|
|
458
|
+
};
|
|
459
|
+
for (const node of adjacency.keys()) {
|
|
460
|
+
if (reported) break;
|
|
461
|
+
if (state.get(node) !== DONE) visit(node, []);
|
|
462
|
+
}
|
|
463
|
+
}
|
package/openapi.yaml
CHANGED
|
@@ -1253,6 +1253,250 @@ components:
|
|
|
1253
1253
|
everyMs: { type: integer, description: Interval between poll attempts (ms). }
|
|
1254
1254
|
timeoutMs: { type: integer, description: Bounded budget (ms) before the gate escalates. }
|
|
1255
1255
|
backoff: { type: string, enum: [fixed, exponential], description: Backoff shape between attempts. }
|
|
1256
|
+
DeliveryGraph:
|
|
1257
|
+
description: >-
|
|
1258
|
+
An agent-authored delivery graph (ADR 0005) — the SINGLE agent-facing artifact for a
|
|
1259
|
+
heterogeneous, partly-human, cross-repo delivery runbook. It is DATA, never an executable
|
|
1260
|
+
artifact: a JSON DAG whose nodes each name a `kind` from a CLOSED allowlist
|
|
1261
|
+
(`agent`/`wait`/`human`/`connector` — Decision 1/2, the trust boundary) and whose `edges`
|
|
1262
|
+
name DISCOVERED facts (Decision 3). Ingest validates the SHAPE here and the SEMANTICS
|
|
1263
|
+
(acyclicity, edge integrity, fact resolution) in the pure `validateDeliveryGraph`
|
|
1264
|
+
(`app/deliveryGraph.ts`). This slice (S0) defines the vocabulary + validation surface ONLY —
|
|
1265
|
+
no compiler, dispatch, or execution (those land in later slices).
|
|
1266
|
+
type: object
|
|
1267
|
+
additionalProperties: false
|
|
1268
|
+
required:
|
|
1269
|
+
- nodes
|
|
1270
|
+
properties:
|
|
1271
|
+
name:
|
|
1272
|
+
type: string
|
|
1273
|
+
maxLength: 255
|
|
1274
|
+
description: OPTIONAL human-readable label for the graph (shown in the rendered preview).
|
|
1275
|
+
nodes:
|
|
1276
|
+
type: array
|
|
1277
|
+
minItems: 1
|
|
1278
|
+
maxItems: 256
|
|
1279
|
+
items:
|
|
1280
|
+
$ref: "#/components/schemas/DeliveryNode"
|
|
1281
|
+
description: >-
|
|
1282
|
+
The graph's nodes. Each carries a unique `id` and a `kind` from the closed allowlist,
|
|
1283
|
+
plus its per-kind config and its typed `emits[]` declaration. Node ids must be unique
|
|
1284
|
+
across the graph (enforced by `validateDeliveryGraph`).
|
|
1285
|
+
edges:
|
|
1286
|
+
type: array
|
|
1287
|
+
maxItems: 1024
|
|
1288
|
+
items:
|
|
1289
|
+
$ref: "#/components/schemas/DeliveryEdge"
|
|
1290
|
+
description: >-
|
|
1291
|
+
The dependency edges — the graph's discovered-fact topology (Decision 3). Each edge means
|
|
1292
|
+
"`to` proceeds once fact `from` about the upstream node is observable". `from` is either a
|
|
1293
|
+
bare `<nodeId>` (the degenerate "wait for the upstream node's completion" fact) or a
|
|
1294
|
+
qualified `<nodeId>.<fact>` referencing one of that node's declared `emits`. Omit/`[]` for
|
|
1295
|
+
a set of independent (root) nodes. The edge set must be a DAG.
|
|
1296
|
+
DeliveryNode:
|
|
1297
|
+
description: >-
|
|
1298
|
+
One node in a delivery graph. A discriminated union on `kind` over the CLOSED allowlist; the
|
|
1299
|
+
matching per-kind config object (`agent`/`wait`/`connector`) is REQUIRED and names the
|
|
1300
|
+
engine-native body the node delegates to (Decision 2 — the graph schedules, it does not
|
|
1301
|
+
re-implement execution). The `human` config is the sole exception — it is OPTIONAL (a bare
|
|
1302
|
+
`human` node resolves to a generic emit-capturing form fallback in S3).
|
|
1303
|
+
oneOf:
|
|
1304
|
+
- $ref: "#/components/schemas/DeliveryNodeAgent"
|
|
1305
|
+
- $ref: "#/components/schemas/DeliveryNodeWait"
|
|
1306
|
+
- $ref: "#/components/schemas/DeliveryNodeHuman"
|
|
1307
|
+
- $ref: "#/components/schemas/DeliveryNodeConnector"
|
|
1308
|
+
discriminator:
|
|
1309
|
+
propertyName: kind
|
|
1310
|
+
mapping:
|
|
1311
|
+
agent: "#/components/schemas/DeliveryNodeAgent"
|
|
1312
|
+
wait: "#/components/schemas/DeliveryNodeWait"
|
|
1313
|
+
human: "#/components/schemas/DeliveryNodeHuman"
|
|
1314
|
+
connector: "#/components/schemas/DeliveryNodeConnector"
|
|
1315
|
+
DeliveryFact:
|
|
1316
|
+
description: >-
|
|
1317
|
+
A typed output a node declares it will EMIT (ADR 0005 Decision 3/4 — emitted-fact typing).
|
|
1318
|
+
A downstream edge references it as `from: "<nodeId>.<fact>"`, so a bind is validated against
|
|
1319
|
+
this declaration, not stringly. A "click done" human node or a pass-through node declares no
|
|
1320
|
+
facts (`emits` absent/empty) — the degenerate no-emit case.
|
|
1321
|
+
type: object
|
|
1322
|
+
additionalProperties: false
|
|
1323
|
+
required:
|
|
1324
|
+
- name
|
|
1325
|
+
- type
|
|
1326
|
+
properties:
|
|
1327
|
+
name:
|
|
1328
|
+
type: string
|
|
1329
|
+
minLength: 1
|
|
1330
|
+
maxLength: 128
|
|
1331
|
+
pattern: '^[A-Za-z_][A-Za-z0-9_]*$'
|
|
1332
|
+
description: The fact's identifier, referenced downstream as `<nodeId>.<name>`. Must be unique within the node.
|
|
1333
|
+
type:
|
|
1334
|
+
type: string
|
|
1335
|
+
enum: [string, number, boolean, artifact, version, url]
|
|
1336
|
+
description: >-
|
|
1337
|
+
The fact's declared type. `artifact` is a `pkg@version` handle, `version` a bare version
|
|
1338
|
+
string, `url` a location — mirrors the values `capability`/`pr` probes late-bind.
|
|
1339
|
+
description:
|
|
1340
|
+
type: string
|
|
1341
|
+
maxLength: 512
|
|
1342
|
+
description: OPTIONAL human note describing what the fact carries.
|
|
1343
|
+
DeliveryNodeCommon:
|
|
1344
|
+
type: object
|
|
1345
|
+
properties:
|
|
1346
|
+
id:
|
|
1347
|
+
type: string
|
|
1348
|
+
minLength: 1
|
|
1349
|
+
maxLength: 128
|
|
1350
|
+
pattern: '^[A-Za-z_][A-Za-z0-9_.-]*$'
|
|
1351
|
+
description: The node's identifier, unique within the graph and referenced by edges.
|
|
1352
|
+
emits:
|
|
1353
|
+
type: array
|
|
1354
|
+
maxItems: 32
|
|
1355
|
+
items:
|
|
1356
|
+
$ref: "#/components/schemas/DeliveryFact"
|
|
1357
|
+
description: >-
|
|
1358
|
+
The typed facts this node hands forward when it completes (Decision 3/4). Absent/empty for
|
|
1359
|
+
a node that emits nothing. Downstream edges bind these via `from: "<nodeId>.<fact>"`.
|
|
1360
|
+
DeliveryNodeAgent:
|
|
1361
|
+
description: >-
|
|
1362
|
+
An `agent` node — a worker executes an agent job type (the existing fan-out body). Bounded
|
|
1363
|
+
(timeout → escalate) and resumable like every node.
|
|
1364
|
+
allOf:
|
|
1365
|
+
- $ref: "#/components/schemas/DeliveryNodeCommon"
|
|
1366
|
+
- type: object
|
|
1367
|
+
additionalProperties: false
|
|
1368
|
+
required:
|
|
1369
|
+
- id
|
|
1370
|
+
- kind
|
|
1371
|
+
- agent
|
|
1372
|
+
properties:
|
|
1373
|
+
id: { type: string }
|
|
1374
|
+
kind: { type: string, enum: [agent] }
|
|
1375
|
+
emits: { type: array, items: { $ref: "#/components/schemas/DeliveryFact" } }
|
|
1376
|
+
agent:
|
|
1377
|
+
type: object
|
|
1378
|
+
additionalProperties: false
|
|
1379
|
+
required:
|
|
1380
|
+
- jobType
|
|
1381
|
+
properties:
|
|
1382
|
+
jobType:
|
|
1383
|
+
type: string
|
|
1384
|
+
minLength: 1
|
|
1385
|
+
description: The agent job type a worker executes for this node (e.g. `senior:feature`).
|
|
1386
|
+
prompt:
|
|
1387
|
+
type: string
|
|
1388
|
+
maxLength: 20000
|
|
1389
|
+
description: OPTIONAL steering prompt appended to the node's job brief.
|
|
1390
|
+
DeliveryNodeWait:
|
|
1391
|
+
description: >-
|
|
1392
|
+
A `wait` node — a durable `ReadinessProbe` (ADR 0001 §2) watching an external fact. Reuses the
|
|
1393
|
+
existing `ReadinessProbe` shape verbatim (Decision 3 — never a second wait loop); the `pr`
|
|
1394
|
+
merge-state kind is added to that shape by slice S2 and flows in here automatically.
|
|
1395
|
+
allOf:
|
|
1396
|
+
- $ref: "#/components/schemas/DeliveryNodeCommon"
|
|
1397
|
+
- type: object
|
|
1398
|
+
additionalProperties: false
|
|
1399
|
+
required:
|
|
1400
|
+
- id
|
|
1401
|
+
- kind
|
|
1402
|
+
- wait
|
|
1403
|
+
properties:
|
|
1404
|
+
id: { type: string }
|
|
1405
|
+
kind: { type: string, enum: [wait] }
|
|
1406
|
+
emits: { type: array, items: { $ref: "#/components/schemas/DeliveryFact" } }
|
|
1407
|
+
wait:
|
|
1408
|
+
$ref: "#/components/schemas/ReadinessProbe"
|
|
1409
|
+
DeliveryNodeHuman:
|
|
1410
|
+
description: >-
|
|
1411
|
+
A `human` node — a scheduled user task + form (ADR 0002 machinery promoted from exception to
|
|
1412
|
+
node, Decision 4). Surfaces "now do X" on the Tasks inbox, blocks dependents, is answerable by
|
|
1413
|
+
a human OR an agent, is SLA-bounded, and can EMIT a typed fact its form captures.
|
|
1414
|
+
allOf:
|
|
1415
|
+
- $ref: "#/components/schemas/DeliveryNodeCommon"
|
|
1416
|
+
- type: object
|
|
1417
|
+
additionalProperties: false
|
|
1418
|
+
required:
|
|
1419
|
+
- id
|
|
1420
|
+
- kind
|
|
1421
|
+
properties:
|
|
1422
|
+
id: { type: string }
|
|
1423
|
+
kind: { type: string, enum: [human] }
|
|
1424
|
+
emits: { type: array, items: { $ref: "#/components/schemas/DeliveryFact" } }
|
|
1425
|
+
human:
|
|
1426
|
+
type: object
|
|
1427
|
+
additionalProperties: false
|
|
1428
|
+
description: >-
|
|
1429
|
+
OPTIONAL human-node config. `formKey` explicitly attaches a form (else a form is
|
|
1430
|
+
selected by node category, else a generic emit-capturing fallback — resolved in S3).
|
|
1431
|
+
The node's typed output is declared via the node-level `emits[]`.
|
|
1432
|
+
properties:
|
|
1433
|
+
formKey:
|
|
1434
|
+
type: string
|
|
1435
|
+
minLength: 1
|
|
1436
|
+
description: OPTIONAL explicit form to attach at authoring time (specific-else-generic resolution, S3).
|
|
1437
|
+
prompt:
|
|
1438
|
+
type: string
|
|
1439
|
+
maxLength: 20000
|
|
1440
|
+
description: OPTIONAL instruction shown to the human/agent completing the task ("now do X").
|
|
1441
|
+
DeliveryNodeConnector:
|
|
1442
|
+
description: >-
|
|
1443
|
+
A `connector` node — an automated, side-effecting outbound action (the connector I/O surface).
|
|
1444
|
+
Side-effecting, so it carries a `dedupeKey` and tolerates at-least-once execution. The
|
|
1445
|
+
`payload` schema is a minimal forward-declared stub in this slice (ADR 0005 non-goal — the
|
|
1446
|
+
concrete connector I/O lands later).
|
|
1447
|
+
allOf:
|
|
1448
|
+
- $ref: "#/components/schemas/DeliveryNodeCommon"
|
|
1449
|
+
- type: object
|
|
1450
|
+
additionalProperties: false
|
|
1451
|
+
required:
|
|
1452
|
+
- id
|
|
1453
|
+
- kind
|
|
1454
|
+
- connector
|
|
1455
|
+
properties:
|
|
1456
|
+
id: { type: string }
|
|
1457
|
+
kind: { type: string, enum: [connector] }
|
|
1458
|
+
emits: { type: array, items: { $ref: "#/components/schemas/DeliveryFact" } }
|
|
1459
|
+
connector:
|
|
1460
|
+
type: object
|
|
1461
|
+
additionalProperties: false
|
|
1462
|
+
required:
|
|
1463
|
+
- target
|
|
1464
|
+
properties:
|
|
1465
|
+
target:
|
|
1466
|
+
type: string
|
|
1467
|
+
minLength: 1
|
|
1468
|
+
description: The connector action target (forward-declared — the concrete scheme lands in a later slice).
|
|
1469
|
+
dedupeKey:
|
|
1470
|
+
type: string
|
|
1471
|
+
minLength: 1
|
|
1472
|
+
description: >-
|
|
1473
|
+
OPTIONAL idempotency key so an at-least-once resume cannot double-fire this
|
|
1474
|
+
side-effecting node (ADR 0005 Decision 7). Author-supplied or graph-derived.
|
|
1475
|
+
payload:
|
|
1476
|
+
type: object
|
|
1477
|
+
additionalProperties: true
|
|
1478
|
+
description: Minimal forward-declared payload stub — the concrete connector payload schema is deferred (ADR non-goal).
|
|
1479
|
+
DeliveryEdge:
|
|
1480
|
+
description: >-
|
|
1481
|
+
A dependency edge — "`to` proceeds once fact `from` is observable" (ADR 0005 Decision 3).
|
|
1482
|
+
`from` is either a bare `<nodeId>` (wait for the upstream node's completion fact) or a
|
|
1483
|
+
qualified `<nodeId>.<fact>` referencing a declared `emits` fact of that node. Both endpoints
|
|
1484
|
+
must resolve to a node in the graph, the referenced fact must be declared, and the whole edge
|
|
1485
|
+
set must be a DAG — all enforced by `validateDeliveryGraph`.
|
|
1486
|
+
type: object
|
|
1487
|
+
additionalProperties: false
|
|
1488
|
+
required:
|
|
1489
|
+
- from
|
|
1490
|
+
- to
|
|
1491
|
+
properties:
|
|
1492
|
+
from:
|
|
1493
|
+
type: string
|
|
1494
|
+
minLength: 1
|
|
1495
|
+
description: The upstream endpoint — `<nodeId>` (completion) or `<nodeId>.<fact>` (a declared emitted fact).
|
|
1496
|
+
to:
|
|
1497
|
+
type: string
|
|
1498
|
+
minLength: 1
|
|
1499
|
+
description: The dependent node's id — proceeds once `from` is observed.
|
|
1256
1500
|
FeatureStart:
|
|
1257
1501
|
description: The start-feature request body — a SINGLE-issue feature run. Names the target issue
|
|
1258
1502
|
by EXACTLY ONE of `issue` (an `owner/repo#123` reference) or `url` (a bare issue URL), plus a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.110.0",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|