@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.
@@ -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.
@@ -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);