@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
|
@@ -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/app/github.ts
CHANGED
|
@@ -597,6 +597,28 @@ export function failingCheckNames(rollup: RollupEntry[]): string[] {
|
|
|
597
597
|
return names;
|
|
598
598
|
}
|
|
599
599
|
|
|
600
|
+
/** Names of checks that are still in flight — queued or in progress, i.e. NOT yet complete and not a
|
|
601
|
+
* hard failure. Covers the CheckRun shape (`status` QUEUED/IN_PROGRESS/PENDING/WAITING/… anything but
|
|
602
|
+
* COMPLETED) and the legacy StatusContext shape (`state` PENDING/EXPECTED). Derived over the newest
|
|
603
|
+
* run per check (`latestRunPerCheck`) like {@link failingCheckNames}, so a superseded run doesn't
|
|
604
|
+
* linger as pending. A `checks-green` gate MUST count these so it never reports green while a run has
|
|
605
|
+
* not yet concluded (a pending run has no failing conclusion, so it would otherwise slip through). */
|
|
606
|
+
export function pendingCheckNames(rollup: RollupEntry[]): string[] {
|
|
607
|
+
const names: string[] = [];
|
|
608
|
+
for (const c of latestRunPerCheck(rollup)) {
|
|
609
|
+
const status = (c.status || "").toUpperCase();
|
|
610
|
+
if (status !== "") {
|
|
611
|
+
// CheckRun: anything other than COMPLETED is still running/queued.
|
|
612
|
+
if (status !== "COMPLETED") names.push(checkKey(c));
|
|
613
|
+
} else {
|
|
614
|
+
// Legacy StatusContext: PENDING/EXPECTED are not-yet-concluded.
|
|
615
|
+
const state = (c.state || "").toUpperCase();
|
|
616
|
+
if (state === "PENDING" || state === "EXPECTED") names.push(checkKey(c));
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
return names;
|
|
620
|
+
}
|
|
621
|
+
|
|
600
622
|
/** Names of every head check present, regardless of state. Covers both the CheckRun shape
|
|
601
623
|
* (`name`/`workflowName`) and the legacy StatusContext shape (`context`). Used to test whether a
|
|
602
624
|
* repo's *required* checks are present on the head — so an unrelated always-on check (e.g.
|
package/app/readiness.test.ts
CHANGED
|
@@ -21,17 +21,22 @@ import {
|
|
|
21
21
|
matchGithubCheck,
|
|
22
22
|
matchHttp,
|
|
23
23
|
matchNpm,
|
|
24
|
+
matchPr,
|
|
24
25
|
msToIsoDuration,
|
|
25
26
|
newestPublishedVersion,
|
|
26
27
|
nextDelay,
|
|
27
28
|
normalizePoll,
|
|
28
29
|
parseProbe,
|
|
30
|
+
parsePrTarget,
|
|
31
|
+
parsePrView,
|
|
29
32
|
parseReleases,
|
|
30
33
|
parseReleasesTarget,
|
|
31
34
|
parseRepoRef,
|
|
32
35
|
probeBudgetMs,
|
|
33
36
|
probeOnce,
|
|
34
37
|
type ProbeExec,
|
|
38
|
+
type PrObservation,
|
|
39
|
+
prViewCommand,
|
|
35
40
|
readinessTimeout,
|
|
36
41
|
readinessTimeoutMs,
|
|
37
42
|
redactString,
|
|
@@ -375,6 +380,161 @@ test("probeOnce github-check: a failed gh api call is not-ready (never throws)",
|
|
|
375
380
|
assert(!res.ready);
|
|
376
381
|
});
|
|
377
382
|
|
|
383
|
+
// ── parseProbe: pr kind (ADR 0005 §2 — owner/repo#N target + validated prState) ──────────────────
|
|
384
|
+
test("parseProbe: accepts an owner/repo#N pr probe and defaults onTimeout to escalate (timeout → escalate)", () => {
|
|
385
|
+
const p = parseProbe({ kind: "pr", target: "nanobpm/nano-workforce#377" });
|
|
386
|
+
assertEquals(p.kind, "pr");
|
|
387
|
+
assertEquals(p.onTimeout, "escalate");
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test("parseProbe: a pr probe whose target carries no numeric PR id throws (never resolvable)", () => {
|
|
391
|
+
assertThrows(() => parseProbe({ kind: "pr", target: "nanobpm/nano-workforce" }), Error, "owner/repo#<number>");
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test("parseProbe: a pr probe with an unknown match.prState throws (mistyped state fails loudly)", () => {
|
|
395
|
+
assertThrows(
|
|
396
|
+
() => parseProbe({ kind: "pr", target: "o/r#1", match: { prState: "landed" } }),
|
|
397
|
+
Error,
|
|
398
|
+
"invalid match.prState",
|
|
399
|
+
);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test("parseProbe: a valid pr probe round-trips its prState", () => {
|
|
403
|
+
const p = parseProbe({ kind: "pr", target: "o/r#12", match: { prState: "mergeable" } });
|
|
404
|
+
assertEquals(p.match?.prState, "mergeable");
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// ── matchPr (pure — operates on an already-fetched PR observation) ────────────────────────────────
|
|
408
|
+
function prObs(over: Partial<PrObservation> = {}): PrObservation {
|
|
409
|
+
return {
|
|
410
|
+
merged: false,
|
|
411
|
+
state: "open",
|
|
412
|
+
mergeStateStatus: "UNKNOWN",
|
|
413
|
+
failingChecks: 0,
|
|
414
|
+
failingCheckNames: [],
|
|
415
|
+
totalChecks: 0,
|
|
416
|
+
presentCheckNames: [],
|
|
417
|
+
isDraft: false,
|
|
418
|
+
headRefOid: "abc123",
|
|
419
|
+
mergedSha: null,
|
|
420
|
+
pendingChecks: 0,
|
|
421
|
+
...over,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
test("matchPr: prState 'ready' is the draft→ready transition (a non-draft PR is ready)", () => {
|
|
426
|
+
assert(!matchPr({ prState: "ready" }, prObs({ isDraft: true })).ready);
|
|
427
|
+
assert(matchPr({ prState: "ready" }, prObs({ isDraft: false })).ready);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test("matchPr: prState 'merged' waits for the merge and binds mergedSha (mirrors resolvedArtifact)", () => {
|
|
431
|
+
assert(!matchPr({ prState: "merged" }, prObs({ merged: false })).ready);
|
|
432
|
+
const res = matchPr({ prState: "merged" }, prObs({ merged: true, state: "merged", mergedSha: "deadbeef" }));
|
|
433
|
+
assert(res.ready);
|
|
434
|
+
assertEquals(res.bind?.mergedSha, "deadbeef");
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test("matchPr: 'merged' is the default when no prState is declared", () => {
|
|
438
|
+
assert(!matchPr(undefined, prObs({ merged: false })).ready);
|
|
439
|
+
assert(matchPr(undefined, prObs({ merged: true, state: "merged" })).ready);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test("matchPr: prState 'mergeable' reuses classifyMergeability (CLEAN is ready, BLOCKED is not)", () => {
|
|
443
|
+
assert(matchPr({ prState: "mergeable" }, prObs({ mergeStateStatus: "CLEAN" })).ready);
|
|
444
|
+
assert(!matchPr({ prState: "mergeable" }, prObs({ mergeStateStatus: "BLOCKED", failingChecks: 1 })).ready);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test("matchPr: prState 'checks-green' needs a present, non-failing, non-pending head run", () => {
|
|
448
|
+
assert(matchPr({ prState: "checks-green" }, prObs({ totalChecks: 2, failingChecks: 0 })).ready);
|
|
449
|
+
assert(!matchPr({ prState: "checks-green" }, prObs({ totalChecks: 2, failingChecks: 1 })).ready);
|
|
450
|
+
assert(!matchPr({ prState: "checks-green" }, prObs({ totalChecks: 0, failingChecks: 0 })).ready);
|
|
451
|
+
// A run still queued/in-progress (no failing conclusion yet) must NOT read as green.
|
|
452
|
+
assert(!matchPr({ prState: "checks-green" }, prObs({ totalChecks: 2, failingChecks: 0, pendingChecks: 1 })).ready);
|
|
453
|
+
// token mode (checks unenumerable, totalChecks < 0) stays conservative — never falsely green.
|
|
454
|
+
assert(!matchPr({ prState: "checks-green" }, prObs({ totalChecks: -1, failingChecks: -1 })).ready);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test("matchPr: a not-yet-satisfied state is not-ready — the bounded gate keeps waiting → timeout escalates", () => {
|
|
458
|
+
// Every un-reached state resolves to ready:false, which is exactly what the engine timer arm bounds
|
|
459
|
+
// (onTimeout defaults to 'escalate'): a PR that never lands is never falsely resolved.
|
|
460
|
+
assert(!matchPr({ prState: "merged" }, prObs({ merged: false })).ready);
|
|
461
|
+
assert(!matchPr({ prState: "ready" }, prObs({ isDraft: true })).ready);
|
|
462
|
+
assert(!matchPr({ prState: "checks-green" }, prObs({ totalChecks: 1, failingChecks: 1 })).ready);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
// ── parsePrView + probeOnce pr dispatch (injected exec — no I/O) ─────────────────────────────────
|
|
466
|
+
test("parsePrView: reduces a gh pr view payload and collapses the check rollup", () => {
|
|
467
|
+
const obs = parsePrView({
|
|
468
|
+
state: "OPEN",
|
|
469
|
+
mergeStateStatus: "clean",
|
|
470
|
+
isDraft: false,
|
|
471
|
+
headRefOid: "sha1",
|
|
472
|
+
statusCheckRollup: [
|
|
473
|
+
{ name: "build", status: "COMPLETED", conclusion: "SUCCESS" },
|
|
474
|
+
{ name: "lint", status: "COMPLETED", conclusion: "FAILURE" },
|
|
475
|
+
],
|
|
476
|
+
});
|
|
477
|
+
assertEquals(obs.merged, false);
|
|
478
|
+
assertEquals(obs.mergeStateStatus, "CLEAN");
|
|
479
|
+
assertEquals(obs.totalChecks, 2);
|
|
480
|
+
assertEquals(obs.failingCheckNames, ["lint"]);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
test("parsePrView: an in-flight run is counted as pending (so checks-green stays not-green)", () => {
|
|
484
|
+
const obs = parsePrView({
|
|
485
|
+
state: "OPEN",
|
|
486
|
+
statusCheckRollup: [
|
|
487
|
+
{ name: "build", status: "COMPLETED", conclusion: "SUCCESS" },
|
|
488
|
+
{ name: "e2e", status: "IN_PROGRESS" },
|
|
489
|
+
],
|
|
490
|
+
});
|
|
491
|
+
assertEquals(obs.failingChecks, 0);
|
|
492
|
+
assertEquals(obs.pendingChecks, 1);
|
|
493
|
+
assert(!matchPr({ prState: "checks-green" }, obs).ready);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test("parsePrView: a merged PR carries its merge commit oid", () => {
|
|
497
|
+
const obs = parsePrView({ state: "MERGED", mergedAt: "2026-08-20T00:00:00Z", mergeCommit: { oid: "cafe" } });
|
|
498
|
+
assertEquals(obs.merged, true);
|
|
499
|
+
assertEquals(obs.state, "merged");
|
|
500
|
+
assertEquals(obs.mergedSha, "cafe");
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
test("parsePrView: a garbled payload degrades to an all-open, no-checks observation (never throws)", () => {
|
|
504
|
+
const obs = parsePrView(null);
|
|
505
|
+
assertEquals(obs.merged, false);
|
|
506
|
+
assertEquals(obs.totalChecks, 0);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
test("probeOnce pr: builds a quoted `gh pr view` command and matches merged, binding mergedSha", async () => {
|
|
510
|
+
const cap: { cmd?: string } = {};
|
|
511
|
+
const exec = stubExec({
|
|
512
|
+
command: { code: 0, stdout: JSON.stringify({ state: "MERGED", mergeCommit: { oid: "abc" } }), stderr: "" },
|
|
513
|
+
capture: cap,
|
|
514
|
+
});
|
|
515
|
+
const res = await probeOnce(parseProbe({ kind: "pr", target: "nanobpm/nano-workforce#377" }), exec, {});
|
|
516
|
+
assert(res.ready);
|
|
517
|
+
assertEquals(res.bind?.mergedSha, "abc");
|
|
518
|
+
assertStringIncludes(cap.cmd ?? "", "gh pr view '377' --repo 'nanobpm/nano-workforce'");
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
test("probeOnce pr: a failed gh pr view call is not-ready (never throws)", async () => {
|
|
522
|
+
const exec = stubExec({ command: { code: 1, stdout: "", stderr: "no pr" } });
|
|
523
|
+
const res = await probeOnce(parseProbe({ kind: "pr", target: "o/r#1" }), exec, {});
|
|
524
|
+
assert(!res.ready);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
test("parsePrTarget: parses owner/repo#N, rejects @N and a bare repo", () => {
|
|
528
|
+
assertEquals(parsePrTarget("o/r#12"), { repo: "o/r", number: "12" });
|
|
529
|
+
// `@N` is deliberately NOT a PR handle — it's the repo-ref syntax, so it must not parse as a PR.
|
|
530
|
+
assertEquals(parsePrTarget("o/r@34"), null);
|
|
531
|
+
assertEquals(parsePrTarget("o/r"), null);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
test("prViewCommand: single-quote-escapes its args", () => {
|
|
535
|
+
assertStringIncludes(prViewCommand("o/r", "9"), "gh pr view '9' --repo 'o/r'");
|
|
536
|
+
});
|
|
537
|
+
|
|
378
538
|
// ── backoff + poll normalisation ──────────────────────────────────────────────────────────────
|
|
379
539
|
test("normalizePoll: fills defaults and clamps everyMs to the ceiling", () => {
|
|
380
540
|
const d = normalizePoll(undefined);
|