@nanobpm/nano-workforce 0.109.0 → 0.111.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 +14 -0
- package/app/contracts.ts +9 -0
- package/app/deliveryGraph.test.ts +357 -0
- package/app/deliveryGraph.ts +463 -0
- package/app/github.ts +22 -0
- package/app/readiness.test.ts +160 -0
- package/app/readiness.ts +163 -3
- package/openapi.yaml +248 -3
- package/package.json +1 -1
- package/resources/processes/readiness-gate.bpmn +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [0.111.0](https://github.com/nanobpm/nano-workforce/compare/v0.110.0...v0.111.0) (2026-08-20)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **readiness:** add pr/merge-state ReadinessProbe kind (ADR 0005 S2) ([#385](https://github.com/nanobpm/nano-workforce/issues/385)) ([860e031](https://github.com/nanobpm/nano-workforce/commit/860e03134b428c73426621acb14ccd9a3ab94149)), closes [#258](https://github.com/nanobpm/nano-workforce/issues/258) [owner/repo#N](https://github.com/owner/repo/issues/N) [#377](https://github.com/nanobpm/nano-workforce/issues/377) [owner/repo#N](https://github.com/owner/repo/issues/N)
|
|
7
|
+
|
|
8
|
+
# [0.110.0](https://github.com/nanobpm/nano-workforce/compare/v0.109.0...v0.110.0) (2026-08-20)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* 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)
|
|
14
|
+
|
|
1
15
|
# [0.109.0](https://github.com/nanobpm/nano-workforce/compare/v0.108.0...v0.109.0) (2026-08-20)
|
|
2
16
|
|
|
3
17
|
|
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
|
+
});
|