// nano-workforce — the generic artifact-readiness ReadinessProbe (ADR 0001 §2, issue #258). // // This is the CODE half of the durable wait-gate primitive. The gate itself is modelled in the // engine (`resources/processes/readiness-gate.bpmn`): a service task (`pr.readiness-probe`, whose // executor lives in `workers/readiness-probe/`) races, through an event-based gateway, the // readiness message it publishes when a probe goes green against a timer catch that bounds the // wait and escalates. The engine owns token semantics; this module only *reads* readiness, so a // restarted worker simply re-probes (idempotent / resumable). // // The probe is DATA, not code — a {@link ReadinessProbe} descriptor with a `kind` and a per-kind // `match` predicate. Authors add a readiness source by adding a `kind`'s matcher, never by editing // the BPMN or the worker's control flow. Five built-in kinds ship (`http`, `command`, `npm`, // `github-check`, `capability`); everything else is reached through the `command` escape hatch // (ADR 0001 §2 pinned decision 1). The `capability` kind (ADR 0001 §4, issue #274) resolves a // cross-repo capability edge — "which published version first carries capability C?" — from the // publish-provenance substrate and late-binds the discovered `pkg@version` back through the gate // via {@link ProbeResult.bind}, the reusable emit primitive. A probe carries NO secret material — // any credential is read at execution time from the typed env-contract (`credentialEnv` names a // declared {@link EnvKey}; ADR 0004 pinned decision 2) and is redacted from every log line. import { isEnvKey, readEnv, readEnvOr } from "./contracts.ts"; import { allCheckNames, checkConclusions, classifyMergeability, failingCheckNames, type PrState, pendingCheckNames } from "./github.ts"; import { isoDuration, isoDurationToMs } from "./reviewWait.ts"; /** The built-in readiness sources. `command` is the escape hatch that subsumes the long tail * (`gh`, `curl`, `docker manifest inspect`, a custom probe) — adding a first-class kind later is * an additive matcher, not a schema change. `capability` is the first such additive kind (#274): * it resolves "which published version first carries capability C?" from the publish-provenance * substrate and binds the discovered `pkg@version` back through the gate (see {@link matchCapability}). * `pr` (ADR 0005 §2) is the merge-state kind: it lifts the PR-liveness/mergeability READ out of the * merge loop (`app/mergeProtocol.ts` / `app/github.ts`) into a first-class probe so "watch an * in-flight PR reach a declared state" is a graph edge, not logic buried in the merge-loop node * body — the ACTION (landing the PR) stays in that node body; this kind only OBSERVES. */ export type ProbeKind = "http" | "command" | "npm" | "github-check" | "capability" | "pr" | "epic"; /** The declared PR state a `pr` probe waits for (ADR 0005 §2). Each is a discovered fact about an * in-flight PR, read from its live GitHub state and evaluated by {@link matchPr}: * • `ready` — the PR is out of draft (the draft→ready transition is observable). * • `merged` — the PR has landed; binds `mergedSha` (the merge commit) as an output. * • `mergeable` — GitHub reports the PR as landable now ({@link classifyMergeability} `ready`: * CLEAN/HAS_HOOKS/UNSTABLE/BEHIND — required review + checks satisfied). * • `checks-green` — every head check run is complete with none failing (required checks green). */ export type PrCondition = "ready" | "merged" | "mergeable" | "checks-green"; /** The declared epic (plan-fanout) state an `epic` probe waits for (issue #568). An nwf epic fans out * many slice PRs across waves whose numbers are unknown at compose time, so this kind gates on the * app's AGGREGATE ("all slices merged"), not a single PR. Both values mean the same terminal — * "fully merged" — and are read from the app's lineage read-model (`stage === "merged"`, i.e. every * opened slice landed): `merged` is the canonical name; `done` is accepted as a synonym for the plan * aggregate reaching `done`. A failed/abandoned/mixed epic never reports this stage, so it never goes * ready and the bounded wait routes to `onTimeout` rather than hanging. */ export type EpicCondition = "merged" | "done"; /** What the gate does when the bounded wait times out (the engine timer arm fires). */ export type OnTimeout = "escalate" | "fail" | "continue"; /** Backoff policy between poll attempts. */ export type Backoff = "fixed" | "exponential"; // The CLOSED per-field vocabularies `parseProbe` validates against — the single runtime source of // truth for "which probe kinds / onTimeout options / conditions are legal". Exported so the // delivery-graph vocabulary surface (`app/deliveryGraphVocabulary.ts`, S3/#609) DERIVES its // structured description from these exact arrays and a drift test fails the build if a kind/condition // is added here without a matching vocabulary entry (AGENTS.md: no drift surfaces). export const PROBE_KINDS: readonly ProbeKind[] = ["http", "command", "npm", "github-check", "capability", "pr", "epic"]; export const ON_TIMEOUTS: readonly OnTimeout[] = ["escalate", "fail", "continue"]; export const BACKOFFS: readonly Backoff[] = ["fixed", "exponential"]; export const PR_CONDITIONS: readonly PrCondition[] = ["ready", "merged", "mergeable", "checks-green"]; export const EPIC_CONDITIONS: readonly EpicCondition[] = ["merged", "done"]; /** The per-kind readiness predicate. Every field is optional; each kind reads only the ones it * understands and applies a sensible default when a field is absent (see the matchers below). */ export interface ProbeMatch { /** http: the exact HTTP status that means ready (default: any 2xx). */ readonly status?: number; /** http: a substring the response body must contain. */ readonly bodyIncludes?: string; /** command: the exit code that means ready (default: 0). */ readonly exitCode?: number; /** command / npm: a substring stdout must contain. */ readonly stdoutIncludes?: string; /** npm: the version that must be published (default: the version in `pkg@version`). */ readonly version?: string; /** github-check: the check-run conclusion that means ready (default: "success"). */ readonly conclusion?: string; /** github-check: restrict the predicate to the named check run (default: every check run). */ readonly checkName?: string; /** capability: the upstream issue/PR handle the resolved version must carry in its publish * provenance — `nano-ide#274` or the bare `#274`. Required for the `capability` kind. */ readonly capabilityRef?: string; /** capability: the package whose releases are scanned (e.g. `@nanobpm/urban`). Provenance is * per-package scoped — the same `#C` may appear in two packages — so this is required. */ readonly package?: string; /** capability: an OPTIONAL empirical verifier command for the gated fallback (decision 5). Run * ONCE at the gate boundary (poll budget exhausted) against the newest published `package` * version when deterministic provenance resolved nothing; exit 0 binds that newest version. Left * unset, the capability edge is deterministic-only. The resolved `pkg@version` and bare version * are exposed to the command as `RESOLVED_ARTIFACT` / `RESOLVED_VERSION`. */ readonly verifyCommand?: string; /** pr: the declared PR state the probe waits for (default `merged`). One of {@link PrCondition} — * `ready` (out of draft), `merged`, `mergeable`, or `checks-green`. */ readonly prState?: PrCondition; /** epic: the declared epic (plan-fanout) aggregate state the probe waits for (default `merged`). * One of {@link EpicCondition} — `merged`/`done` both mean "fully merged" (every opened slice * landed). Issue #568. */ readonly epicState?: EpicCondition; } /** The poll cadence: how often to re-probe, how long to keep trying, and the backoff shape. */ export interface ProbePoll { readonly everyMs?: number; readonly timeoutMs?: number; readonly backoff?: Backoff; } /** A declared readiness probe — the whole descriptor the gate is handed as a process variable. * Scalar + nested-object shape mirrored by the `ReadinessProbe` `nano:shape` in the BPMN so it is * typed end-to-end. Carries NO secret: `credentialEnv` names a declared env-contract key, never a * value. */ export interface ReadinessProbe { readonly kind: ProbeKind; readonly target: string; readonly match?: ProbeMatch; readonly poll?: ProbePoll; readonly onTimeout?: OnTimeout; /** The declared {@link EnvKey} whose value supplies a credential at execution time (e.g. * `GITHUB_TOKEN` for a private `http` probe's `Authorization` header). Supported for the `http` * kind ONLY — `parseProbe` rejects it on any other kind, which consumes credentials from the * ambient env. Read via `readEnv`, never inlined. */ readonly credentialEnv?: string; } /** The result of a single probe attempt. `detail` is a short, already-redacted human note. * `bind` is the OPTIONAL late-bound value a matcher discovered (the reusable "emit" primitive, * #274 Gap B): a kind-agnostic `key → value` map the worker forwards into the `readiness-ready` * message so the gate can surface it as an output process variable (e.g. the `capability` kind * binds `{ resolvedArtifact: "@nanobpm/urban@0.54.0" }`). Provenance is public, so nothing in * `bind` is redacted; keep values free of any secret material by construction. */ export interface ProbeResult { readonly ready: boolean; readonly detail: string; readonly bind?: Record; /** An OPTIONAL compact, human-readable summary of what the probe actually OBSERVED at poll time * (issue #514 Defect A). Diagnostic-only — it is NEVER part of the gate contract, it does not gate * readiness, and it carries no secret material (provenance is public). The `capability` kind * populates it with {@link summariseCapabilityCandidates} so an ESCALATION (false-negative wait) * surfaces the candidate releases the matcher saw and whether each referenced the ref, letting a * human/agent tell a genuine "not published yet" from a transient false-negative. */ readonly observed?: string; } /** A single published GitHub Release, reduced to the two fields the capability resolver reads: the * `@` tag and the release body carrying the `## Provenance` `#NNN` refs. Kept * separate from I/O so {@link matchCapability} is pure/unit-testable. */ export interface GithubRelease { readonly tag: string; readonly body: string; } /** A raw HTTP response the http matcher inspects (kept separate from I/O so it is pure-testable). */ export interface HttpResponse { readonly status: number; readonly body: string; } /** A raw command result the command/npm matchers inspect. */ export interface CommandResult { readonly code: number; readonly stdout: string; readonly stderr: string; } /** The injectable I/O seam — the ONE place the probe touches the outside world. The default * implementation ({@link defaultProbeExec}) uses `fetch` + `node:child_process`; tests pass a stub * so every matcher and the poll loop are exercised without a network or a subprocess. */ export interface ProbeExec { httpGet(url: string, headers: Record): Promise; run(command: string, env: Record): Promise; } // ── Poll-policy defaults ────────────────────────────────────────────────────────────────────── /** Default interval between poll attempts (ms) when the descriptor omits `poll.everyMs`. */ export const DEFAULT_EVERY_MS = 15_000; /** Default bounded budget (ms) when the descriptor omits `poll.timeoutMs` (30 minutes). */ export const DEFAULT_TIMEOUT_MS = 1_800_000; /** Default backoff shape when the descriptor omits `poll.backoff`. */ export const DEFAULT_BACKOFF: Backoff = "exponential"; /** Ceiling on a single backoff delay (ms) — an exponential ramp can never park a probe for days. */ export const MAX_EVERY_MS = 5 * 60_000; /** Per-attempt I/O deadline (ms) for the default {@link ProbeExec} (60s). Both `fetch` and the * command subprocess are bounded by it, so a single stuck attempt always resolves in bounded time * (as an error the poll loop treats as "not ready yet") instead of hanging the worker forever — * neither the local poll budget nor the engine timer can bound a JS handler blocked inside I/O. */ export const DEFAULT_ATTEMPT_TIMEOUT_MS = 60_000; /** Default gate timeout when neither the descriptor nor `NANO_READINESS_POLL_TIMEOUT` supplies one. */ export const DEFAULT_READINESS_TIMEOUT = "PT30M"; /** The ONE canonical readiness-signal message the wait-gate correlates on to release the wait * (ADR 0001 §2) — the correlation key is per-caller (`=gateKey` for the in-flow probe worker, * `=prKey` for the review-ready wait), not a single fixed key. Both the in-flow probe worker * (`pr.readiness-probe`) and the out-of-band * poller-correlated shape used by the review-ready migration (#259, per #258 pinned decision 3) * publish under this ONE name — so there is a single "wait for the world" message, no bespoke twin. */ export const READINESS_READY_MESSAGE = "readiness-ready"; const str = (v: unknown): string => (typeof v === "string" ? v : v == null ? "" : String(v)); const num = (v: unknown): number | undefined => typeof v === "number" && Number.isFinite(v) ? v : undefined; function isRecord(v: unknown): v is Record { return typeof v === "object" && v !== null && !Array.isArray(v); } /** Parse + validate a raw descriptor (a process variable) into a typed {@link ReadinessProbe}. * Throws a descriptive error on an unknown/missing `kind`, a blank `target`, an invalid * `onTimeout`/`backoff`, or a `credentialEnv` on a non-`http` kind — a malformed probe must fail * loudly at the worker, never silently wait forever (nor let a caller believe a subprocess probe is * authenticated when its credential is silently ignored). * * `opts.allowLateBoundTarget` opts a caller into accepting a fact-bound `.` target for * the `pr`/`epic` kinds — the #548/#570 late-binding reference the delivery-graph compiler rewrites * to the observed handle at dispatch. It is OFF by default: only the delivery-graph dispatch path * (`app/deliveryRunner.ts`) sets it. Every other surface (e.g. feature-intake readiness in * `app/featureReadiness.ts`) has no such compiler rewrite, so a fact-ref target there could never * resolve — keeping it off means a mis-declared gate fails loudly at submit rather than degrading * into a runtime timeout/escalation. */ export function parseProbe(raw: unknown, opts?: { allowLateBoundTarget?: boolean }): ReadinessProbe { const allowLateBoundTarget = opts?.allowLateBoundTarget === true; if (!isRecord(raw)) throw new Error("readiness probe: descriptor must be an object"); const kind = str(raw.kind).trim(); if (!isProbeKind(kind)) { throw new Error(`readiness probe: unknown kind '${kind}' (expected one of ${PROBE_KINDS.join(", ")})`); } const target = str(raw.target).trim(); if (target === "") throw new Error(`readiness probe (${kind}): 'target' is required`); const onTimeoutRaw = str(raw.onTimeout).trim(); if (onTimeoutRaw !== "" && !isOnTimeout(onTimeoutRaw)) { throw new Error(`readiness probe: invalid onTimeout '${onTimeoutRaw}' (expected ${ON_TIMEOUTS.join(", ")})`); } const onTimeout: OnTimeout = onTimeoutRaw === "" ? "escalate" : onTimeoutRaw; const match = isRecord(raw.match) ? parseMatch(raw.match) : undefined; // A capability edge whose ref or package is blank can never resolve — fail loudly here rather than // wait forever (mirroring the blank-`target` guard above). Both are required for this kind. if (kind === "capability") { if (!match?.capabilityRef) { throw new Error("readiness probe (capability): 'match.capabilityRef' is required (e.g. 'nano-ide#274' or '#274')"); } // A ref that carries no numeric issue/PR id (e.g. 'cap274' has a number, but 'nano-ide#' or a // bare word does not) can never resolve — `matchCapability` would only surface it as a timeout // much later. Reject it now, via the SAME canonical parser the resolver uses, so a malformed // edge fails loudly at parse (mirroring the intent of the blank-ref guard above). if (!capabilityNumber(match.capabilityRef)) { throw new Error( `readiness probe (capability): 'match.capabilityRef' ('${match.capabilityRef}') must carry a ` + "numeric issue/PR id (e.g. 'nano-ide#274' or '#274')", ); } if (!match?.package) { throw new Error("readiness probe (capability): 'match.package' is required (provenance is per-package scoped)"); } } // A pr edge whose target names no numeric PR id can never resolve — fail loudly at parse (mirroring // the capability ref guard) rather than surface it as a timeout much later. `owner/repo#123`. A // FACT-BOUND target (`.`, e.g. `open.pr`) is exempt: it is a #548 late-binding // reference the compiler rewrites to the OBSERVED PR at dispatch and the readiness-probe worker // resolves at runtime — it is legitimately not a literal here, so validating it as one would reject // the documented canonical `agent → converge-merge → wait[pr merged]` shape (issue #570). A // genuinely malformed literal (dot-free, e.g. `foo`) is not fact-ref-shaped, so it still fails. The // exemption is gated on `allowLateBoundTarget`: a non-delivery-graph caller (default OFF) has no // compiler rewrite, so a fact-ref target there can never resolve — it must fail loudly at submit. if (kind === "pr" && !(allowLateBoundTarget && isFactRefTarget(target)) && !parsePrTarget(target)) { throw new Error( `readiness probe (pr): 'target' ('${target}') must be an 'owner/repo#' PR reference (e.g. 'nanobpm/nano-workforce#377')`, ); } // An epic edge is keyed by the durable `planKey` (`owner/repo#NN`, the epic issue) — the stable // business id, so a resubmit/replay still resolves (issue #568). Validate it as a literal planKey, // exempting a fact-bound reference for the same #548 late-binding reason as `pr` above (and gated // on the same `allowLateBoundTarget` opt-in, so a non-delivery-graph caller still fails loudly). if (kind === "epic" && !(allowLateBoundTarget && isFactRefTarget(target)) && !parsePrTarget(target)) { throw new Error( `readiness probe (epic): 'target' ('${target}') must be an 'owner/repo#' planKey (the epic issue, e.g. 'nanobpm/nano-workforce#374')`, ); } const poll = isRecord(raw.poll) ? parsePoll(raw.poll) : undefined; const credentialEnv = str(raw.credentialEnv).trim() || undefined; if (credentialEnv !== undefined && !isEnvKey(credentialEnv)) { throw new Error( `readiness probe: credentialEnv '${credentialEnv}' is not a declared env-contract key ` + "(register it in app/contracts.ts) — a probe must never inline a secret", ); } // A credential is only ever consumed by the `http` kind (as an Authorization header). For // command/npm/github-check it would be silently ignored, so reject it here rather than let a // caller believe the subprocess runs authenticated (github-check's `gh api` reads its own token // from the ambient env, not from `credentialEnv`). if (credentialEnv !== undefined && kind !== "http") { throw new Error( `readiness probe (${kind}): 'credentialEnv' is only supported for the 'http' kind ` + "(applied as an Authorization header); it has no effect on a command/npm/github-check probe", ); } return { kind, target, match, poll, onTimeout, credentialEnv }; } // Membership guards that narrow a validated string to its union without a type assertion (the // `no-unsafe-type-assertion` gate bans `as`). function isProbeKind(v: string): v is ProbeKind { for (const k of PROBE_KINDS) if (k === v) return true; return false; } function isOnTimeout(v: string): v is OnTimeout { for (const t of ON_TIMEOUTS) if (t === v) return true; return false; } // `isBackoff` narrows a validated string to its union without a type assertion (the // `no-unsafe-type-assertion` gate bans `as`). function isBackoff(v: string): v is Backoff { for (const b of BACKOFFS) if (b === v) return true; return false; } function parseMatch(raw: Record): ProbeMatch { return { status: num(raw.status), bodyIncludes: str(raw.bodyIncludes).trim() || undefined, exitCode: num(raw.exitCode), stdoutIncludes: str(raw.stdoutIncludes).trim() || undefined, version: str(raw.version).trim() || undefined, conclusion: str(raw.conclusion).trim() || undefined, checkName: str(raw.checkName).trim() || undefined, capabilityRef: str(raw.capabilityRef).trim() || undefined, package: str(raw.package).trim() || undefined, verifyCommand: str(raw.verifyCommand).trim() || undefined, prState: parsePrCondition(raw.prState), epicState: parseEpicCondition(raw.epicState), }; } /** Narrow a raw `match.epicState` to an {@link EpicCondition}, throwing on a non-empty unknown value so * a mistyped state fails loudly at parse rather than waiting forever. An absent/blank value yields * undefined — {@link matchEpic} then applies the `merged` default. */ function parseEpicCondition(raw: unknown): EpicCondition | undefined { const s = str(raw).trim(); if (s === "") return undefined; if (!isEpicCondition(s)) { throw new Error(`readiness probe (epic): invalid match.epicState '${s}' (expected one of ${EPIC_CONDITIONS.join(", ")})`); } return s; } function isEpicCondition(v: string): v is EpicCondition { for (const c of EPIC_CONDITIONS) if (c === v) return true; return false; } // A late-binding probe `target` is a `.` reference to an upstream node's emitted fact // (#548) — the compiler rewrites it to the OBSERVED value at dispatch via a FEEL `context put`, and // the readiness-probe worker resolves it at runtime, so it is legitimately NOT a literal // `owner/repo#N` at parse time (issue #570). A literal PR/epic handle always carries a `#` and // never this dotted, hash-free shape, so the two are unambiguous. Splits on the LAST dot, mirroring // the graph's `resolveFrom` (a node id MAY contain dots; a fact name — matched by FACT_REF_FACT — may // not), so a dot-free malformed literal is not fact-ref-shaped and still fails its kind's validation. const FACT_REF_NODE_ID = /^[A-Za-z_][A-Za-z0-9_.-]*$/; const FACT_REF_FACT = /^[A-Za-z_][A-Za-z0-9_]*$/; /** Whether `target` is a `.` late-binding fact reference (issue #548/#570) rather than a * literal `owner/repo#` handle. See the note above. */ export function isFactRefTarget(target: string): boolean { const t = target.trim(); if (t === "" || t.includes("#")) return false; const dot = t.lastIndexOf("."); if (dot <= 0 || dot === t.length - 1) return false; return FACT_REF_NODE_ID.test(t.slice(0, dot)) && FACT_REF_FACT.test(t.slice(dot + 1)); } /** Narrow a raw `match.prState` to a {@link PrCondition}, throwing on a non-empty unknown value so a * mistyped state (`"landed"` for `"merged"`) fails loudly at parse rather than waiting forever. An * absent/blank value yields undefined — `matchPr` then applies the `merged` default. */ function parsePrCondition(raw: unknown): PrCondition | undefined { const s = str(raw).trim(); if (s === "") return undefined; if (!isPrCondition(s)) { throw new Error(`readiness probe (pr): invalid match.prState '${s}' (expected one of ${PR_CONDITIONS.join(", ")})`); } return s; } function isPrCondition(v: string): v is PrCondition { for (const c of PR_CONDITIONS) if (c === v) return true; return false; } function parsePoll(raw: Record): ProbePoll { const backoffRaw = str(raw.backoff).trim(); if (backoffRaw !== "" && !isBackoff(backoffRaw)) { throw new Error(`readiness probe: invalid backoff '${backoffRaw}' (expected ${BACKOFFS.join(", ")})`); } return { everyMs: num(raw.everyMs), timeoutMs: num(raw.timeoutMs), backoff: backoffRaw === "" ? undefined : backoffRaw, }; } /** The effective poll policy: descriptor values, clamped to sane bounds, with defaults filled in. * `everyMs`/`timeoutMs` below 1ms fall back to their defaults (a zero interval would busy-spin). */ export function normalizePoll(poll: ProbePoll | undefined): Required { const everyRaw = poll?.everyMs; const timeoutRaw = poll?.timeoutMs; const everyMs = typeof everyRaw === "number" && everyRaw >= 1 ? Math.trunc(everyRaw) : DEFAULT_EVERY_MS; const timeoutMs = typeof timeoutRaw === "number" && timeoutRaw >= 1 ? Math.trunc(timeoutRaw) : DEFAULT_TIMEOUT_MS; return { everyMs: Math.min(everyMs, MAX_EVERY_MS), timeoutMs, backoff: poll?.backoff ?? DEFAULT_BACKOFF }; } /** The delay (ms) before the `attempt`-th retry (1-based). Fixed backoff returns `everyMs`; * exponential doubles per attempt, clamped to {@link MAX_EVERY_MS}. */ export function nextDelay(attempt: number, poll: Required): number { if (poll.backoff === "fixed") return poll.everyMs; const factor = 2 ** Math.max(0, attempt - 1); return Math.min(poll.everyMs * factor, MAX_EVERY_MS); } // ── Per-kind matchers (pure — operate on an already-fetched raw response) ─────────────────────── /** http readiness: status matches (`match.status`, else any 2xx) AND, if given, the body contains * `match.bodyIncludes`. */ export function matchHttp(match: ProbeMatch | undefined, resp: HttpResponse): ProbeResult { const statusOk = typeof match?.status === "number" ? resp.status === match.status : resp.status >= 200 && resp.status < 300; const bodyOk = match?.bodyIncludes ? resp.body.includes(match.bodyIncludes) : true; const ready = statusOk && bodyOk; return { ready, detail: `http ${resp.status}${ready ? "" : " (not ready)"}` }; } /** command readiness: exit code matches (`match.exitCode`, else 0) AND, if given, stdout contains * `match.stdoutIncludes`. */ export function matchCommand(match: ProbeMatch | undefined, resp: CommandResult): ProbeResult { const wantCode = typeof match?.exitCode === "number" ? match.exitCode : 0; const codeOk = resp.code === wantCode; const stdoutOk = match?.stdoutIncludes ? resp.stdout.includes(match.stdoutIncludes) : true; const ready = codeOk && stdoutOk; return { ready, detail: `command exit ${resp.code}${ready ? "" : " (not ready)"}` }; } /** npm readiness: `npm view @ version` printed a version (the package@version is * published). If `match.version` (or the version in `pkg@version`) is given, the printed version * must equal it; otherwise any non-empty version means ready. */ export function matchNpm(match: ProbeMatch | undefined, target: string, resp: CommandResult): ProbeResult { if (resp.code !== 0) return { ready: false, detail: "npm view failed (not published yet)" }; const printed = resp.stdout.trim(); if (printed === "") return { ready: false, detail: "npm: version not published yet" }; const want = match?.version ?? versionOf(target); const ready = want ? printed.split(/\s+/).includes(want) || printed === want : true; return { ready, detail: `npm ${ready ? "published" : "version mismatch"}` }; } /** github-check readiness: parse a `check-runs` payload and require the relevant runs to have the * wanted conclusion (`match.conclusion`, else "success"). With `match.checkName` only that check * is considered; otherwise every check run must be complete + successful and at least one exists. */ export function matchGithubCheck(match: ProbeMatch | undefined, payload: unknown): ProbeResult { const runs = checkRunsOf(payload); const want = match?.conclusion ?? "success"; const named = match?.checkName; const relevant = named ? runs.filter((r) => r.name === named) : runs; if (relevant.length === 0) { return { ready: false, detail: named ? `github-check: '${named}' not found yet` : "github-check: no runs yet" }; } const ready = relevant.every((r) => r.status === "completed" && r.conclusion === want); return { ready, detail: `github-check ${ready ? want : "pending/failed"}` }; } interface CheckRun { readonly name: string; readonly status: string; readonly conclusion: string; } function checkRunsOf(payload: unknown): CheckRun[] { const rawRuns = isRecord(payload) && Array.isArray(payload.check_runs) ? payload.check_runs : []; const out: CheckRun[] = []; for (const r of rawRuns) { if (!isRecord(r)) continue; out.push({ name: str(r.name), status: str(r.status), conclusion: str(r.conclusion) }); } return out; } /** The version segment of a `pkg@version` (or `@scope/pkg@version`) target, or undefined. */ function versionOf(target: string): string | undefined { const at = target.lastIndexOf("@"); if (at <= 0) return undefined; const v = target.slice(at + 1).trim(); return v === "" ? undefined : v; } // ── Capability resolver (#274 Gap A — a stable, pure "which version first carries C?" matcher) ─── /** Compare two dotted numeric version strings (`major.minor.patch…`), reusing the exact semantics of * nano-ide `scripts/publish.mjs` `cmpVersion` so the resolver and the publisher agree on ordering. * Missing trailing segments count as 0; returns <0, 0, or >0. */ export function cmpVersion(a: string, b: string): number { const pa = a.split(".").map(Number); const pb = b.split(".").map(Number); for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const d = (pa[i] ?? 0) - (pb[i] ?? 0); if (d !== 0) return d; } return 0; } /** The bare, purely numeric `#NNN` number from a capability handle — `nano-ide#274`, `#274`, or a * naked `274` all normalise to `274`. Returns undefined for anything without a number, so a blank/ * malformed ref never accidentally matches. */ function capabilityNumber(ref: string): string | undefined { const m = ref.match(/(\d+)\s*$/); return m ? m[1] : undefined; } /** Does a release body's `## Provenance` reference `#NNN`? Matched on a `#`-prefixed word boundary so * `#27` never spuriously satisfies `#274`. */ function bodyReferences(body: string, num: string): boolean { return new RegExp(`#${num}(?!\\d)`).test(body); } /** A numeric-dotted SemVer-ish core, e.g. `1.58.0`. */ const VERSION_CORE = /^\d+(\.\d+)*$/; /** Is `tag` a **package-scoped** version tag — `@` (numeric-dotted), the monorepo * convention (e.g. `@nanobpm/urban@0.54.0`, `c8ctl-plugin-nano@1.58.0`)? Used to decide whether a * repo follows the single-package (`v1.58.0` / bare) convention: a repo is "single-package" only when * it emits NO package-scoped tags at all. Anchored on the LAST `@` so scoped npm names (`@org/name@1.0.0`) * are recognised. */ function isPackageScopedVersionTag(tag: string): boolean { const at = tag.lastIndexOf("@"); if (at <= 0) return false; // no `@`, or a leading `@` (scoped-name start) with nothing before it return VERSION_CORE.test(tag.slice(at + 1).trim()); } /** True when the repo's observed release set contains NO `@`-prefixed package-scoped * tags at all — i.e. it follows the single-package `v` / bare `` convention * (semantic-release default), so a bare `v1.58.0` tag may be attributed to the sole package (#764). * A repo using package-scoped tags is a monorepo: bare `v` tags are ignored there, keeping * per-package provenance scoping airtight (a sibling package's provenance can never leak). */ function repoIsSinglePackage(releases: readonly GithubRelease[]): boolean { for (const rel of releases) { if (rel && typeof rel.tag === "string" && isPackageScopedVersionTag(rel.tag)) return false; } return true; } /** The `` a release tag carries for `pkg`, or undefined when it belongs to another package / * is not a version tag. A tag is a candidate for `pkg` when **either**: * * 1. it is `@` (numeric-dotted) — the monorepo convention, per-package scoped so a * sibling package's provenance can never leak; **or** * 2. `singlePackage` is true (the repo emits no package-scoped tags — see {@link repoIsSinglePackage}) * AND the tag is `v` or a bare `` — the single-package / semantic-release-default * convention (#764). In a monorepo (`singlePackage` false) bare `v` tags are ignored, so scoping * stays airtight. */ function versionForPackage(tag: string, pkg: string, singlePackage: boolean): string | undefined { const prefix = `${pkg}@`; if (tag.startsWith(prefix)) { const v = tag.slice(prefix.length).trim(); return VERSION_CORE.test(v) ? v : undefined; } if (singlePackage) { const v = (tag.startsWith("v") ? tag.slice(1) : tag).trim(); if (VERSION_CORE.test(v)) return v; } return undefined; } /** A compact, deterministic, BOUNDED summary of the capability candidate releases a probe observed * (issue #514 Defect A). Lists the releases carrying a resolvable `` version — tagged * `@`, or (for a single-package repo) a `v`/bare `` tag — * newest first, and, * for each, whether its body referenced `match.capabilityRef` — so an escalated capability gate can * show a human/agent EXACTLY what the probe saw (a genuine "not published yet" vs. a transient * false-negative where a matching release was live but its provenance body was momentarily empty). * PURE / never throws — a malformed or empty list yields a plain "no releases observed" note. * Output is capped (newest {@link CAPABILITY_SUMMARY_LIMIT}) so a repo with hundreds of releases * cannot bloat the escalation form or a log line. Provenance is public — nothing here is redacted. */ export function summariseCapabilityCandidates( match: ProbeMatch | undefined, releases: readonly GithubRelease[], singlePackage: boolean = repoIsSinglePackage(releases), ): string { const pkg = match?.package; const ref = match?.capabilityRef; if (!pkg) return "capability: no package configured"; const num = ref ? capabilityNumber(ref) : undefined; const candidates: { version: string; refs: boolean }[] = []; for (const rel of releases) { if (!rel || typeof rel.tag !== "string" || typeof rel.body !== "string") continue; const version = versionForPackage(rel.tag, pkg, singlePackage); if (!version) continue; candidates.push({ version, refs: num !== undefined && bodyReferences(rel.body, num) }); } if (candidates.length === 0) return `no ${pkg} releases observed`; candidates.sort((a, b) => cmpVersion(b.version, a.version)); // newest first, deterministic const shown = candidates.slice(0, CAPABILITY_SUMMARY_LIMIT); const more = candidates.length > shown.length ? ` (+${candidates.length - shown.length} more)` : ""; if (num === undefined) { // No parseable ref number to test candidates against: distinguish a configured-but-unparseable ref // from a genuinely absent one, and OMIT the per-candidate/aggregate "refs/no" flags — with nothing // to match, every candidate would carry a meaningless "no", which is misleading rather than diagnostic. const why = ref ? "(unparseable ref)" : "(no ref configured)"; const parts = shown.map((c) => `${pkg}@${c.version}`); return `${candidates.length} ${pkg} release(s) observed ${why}: ${parts.join("; ")}${more}`; } const referencing = candidates.filter((c) => c.refs).length; const refLabel = `#${num}`; const parts = shown.map((c) => `${pkg}@${c.version} ${c.refs ? `refs ${refLabel}` : `no ${refLabel}`}`); return `${candidates.length} ${pkg} release(s) observed, ${referencing} referencing ${refLabel}: ${parts.join("; ")}${more}`; } /** Newest-N cap on {@link summariseCapabilityCandidates} so a >100-release repo cannot bloat the * escalation form or a warn log line. */ const CAPABILITY_SUMMARY_LIMIT = 8; /** capability readiness (#274 Gap A): among GitHub Releases tagged `@*` whose body * references `match.capabilityRef`, resolve the **lowest** SemVer version — that is the version that * *first* carries the capability (`firstVersion`). Late-binds it as `{ resolvedArtifact }` so the * gate can hand the exact `pkg@version` to the consumer (#274 Gap B). PURE: it operates on an * already-fetched, parsed releases list and NEVER throws — a malformed/empty list is simply * "not ready yet" (keep waiting), so a transient provenance read cannot crash the poll loop. */ export function matchCapability(match: ProbeMatch | undefined, releases: readonly GithubRelease[]): ProbeResult { const pkg = match?.package; const ref = match?.capabilityRef; const singlePackage = repoIsSinglePackage(releases); const observed = summariseCapabilityCandidates(match, releases, singlePackage); if (!pkg || !ref) return { ready: false, detail: "capability: missing package/capabilityRef", observed }; const num = capabilityNumber(ref); if (!num) return { ready: false, detail: "capability: unparseable capabilityRef (no #NNN)", observed }; let firstVersion: string | undefined; for (const rel of releases) { if (!rel || typeof rel.tag !== "string" || typeof rel.body !== "string") continue; const version = versionForPackage(rel.tag, pkg, singlePackage); if (!version) continue; if (!bodyReferences(rel.body, num)) continue; if (firstVersion === undefined || cmpVersion(version, firstVersion) < 0) firstVersion = version; } if (firstVersion === undefined) return { ready: false, detail: `capability #${num} not published in ${pkg} yet`, observed }; const resolvedArtifact = `${pkg}@${firstVersion}`; return { ready: true, detail: `capability #${num} carried by ${resolvedArtifact}`, bind: { resolvedArtifact }, observed }; } /** The **newest** published SemVer version of `pkg` across `releases` — the target of the gated * empirical fallback (decision 5), which installs the latest release and verifies the capability * behaviourally when deterministic provenance resolved nothing. PURE / never throws. */ export function newestPublishedVersion(pkg: string | undefined, releases: readonly GithubRelease[]): string | undefined { if (!pkg) return undefined; let newest: string | undefined; const singlePackage = repoIsSinglePackage(releases); for (const rel of releases) { if (!rel || typeof rel.tag !== "string") continue; const version = versionForPackage(rel.tag, pkg, singlePackage); if (!version) continue; if (newest === undefined || cmpVersion(version, newest) > 0) newest = version; } return newest; } /** Parse a raw `gh api .../releases` payload (already JSON-decoded) into the minimal * {@link GithubRelease} list the resolver reads. Tolerant: non-array/malformed input yields `[]`, * so a bad provenance read degrades to "not ready", never a throw. Also accepts the `--paginate * --slurp` shape — an array whose elements are themselves per-page arrays — flattening one level so * releases beyond the first 100 (the true lowest version that first carried a capability) are seen. */ export function parseReleases(payload: unknown): GithubRelease[] { if (!Array.isArray(payload)) return []; const out: GithubRelease[] = []; const push = (r: unknown): void => { if (!isRecord(r)) return; out.push({ tag: str(r.tag_name), body: str(r.body) }); }; for (const el of payload) { if (Array.isArray(el)) { for (const r of el) push(r); } else { push(el); } } return out; } /** Split a `capability` target (`github-releases:owner/repo`) into the provenance source repo. The * `github-releases:` scheme prefix is optional — a bare `owner/repo` is accepted too. */ export function parseReleasesTarget(target: string): string { const t = target.trim(); const scheme = "github-releases:"; return t.startsWith(scheme) ? t.slice(scheme.length).trim() : t; } /** Build the `gh api` command that lists a repo's releases (the provenance substrate). `gh` reads * its token from the ambient env, exactly like the `github-check` kind — no `credentialEnv`. * `--paginate --slurp` walks the FULL release history (not just the first `per_page=100` page), so a * repo with >100 releases can still surface the lowest version that first carried a capability; * `--slurp` wraps the pages in an outer array that {@link parseReleases} flattens. */ export function githubReleasesCommand(repo: string): string { return `gh api --paginate --slurp ${shellQuote(`repos/${repo}/releases?per_page=100`)} -H ${shellQuote("Accept: application/vnd.github+json")}`; } // ── PR / merge-state probe (ADR 0005 §2 — lift the merge-loop READ into a first-class probe) ───── /** A live PR observation, reduced to the fields {@link matchPr} reads. It extends the shared * {@link PrState} (so `classifyMergeability` and the merge loop's liveness vocabulary are reused * verbatim, never re-derived) and adds `mergedSha`, the merge commit oid a `merged` match binds * downstream. Kept separate from I/O — {@link parsePrView} builds it from an already-fetched * `gh pr view --json …` payload — so the matcher stays pure/unit-testable, exactly like * {@link GithubRelease}. */ export interface PrObservation extends PrState { /** The merge commit oid once landed (`gh pr view --json mergeCommit`), else null. */ readonly mergedSha: string | null; /** Count of head checks still in flight (queued/in progress), so a `checks-green` gate never * reports green while a run hasn't concluded. Derived via `pendingCheckNames`. */ readonly pendingChecks: number; } /** Parse a raw `gh pr view --json state,mergedAt,mergeStateStatus,statusCheckRollup,isDraft,headRefOid,mergeCommit` * payload (already JSON-decoded) into a {@link PrObservation}. Reuses the SAME check-rollup readers * as `fetchPrState` (`failingCheckNames`/`allCheckNames`) so a superseded/cancelled run is collapsed * identically. Tolerant: a malformed/empty payload yields an all-open, no-checks observation, so a * transient read degrades to "not ready" (keep waiting), never a throw. */ export function parsePrView(payload: unknown): PrObservation { const j = isRecord(payload) ? payload : {}; const rollup = Array.isArray(j.statusCheckRollup) ? j.statusCheckRollup : []; const names = failingCheckNames(rollup); const pending = pendingCheckNames(rollup); const merged = str(j.state).toUpperCase() === "MERGED" || str(j.mergedAt).trim() !== ""; const mergeCommit = isRecord(j.mergeCommit) ? j.mergeCommit : undefined; const mergedSha = mergeCommit && str(mergeCommit.oid).trim() !== "" ? str(mergeCommit.oid).trim() : null; return { merged, state: merged ? "merged" : str(j.state).toUpperCase() === "CLOSED" ? "closed" : "open", mergeStateStatus: (str(j.mergeStateStatus) || "UNKNOWN").toUpperCase(), failingChecks: names.length, failingCheckNames: names, totalChecks: rollup.length, presentCheckNames: allCheckNames(rollup), pendingCheckNames: pending, checkConclusions: checkConclusions(rollup), isDraft: j.isDraft === true, headRefOid: str(j.headRefOid).trim() || null, mergeQueueEntry: null, // readiness probe doesn't read native-queue membership (merge-loop-only signal) mergedSha, pendingChecks: pending.length, }; } /** pr readiness (ADR 0005 §2): does the observed PR satisfy the declared `match.prState` * (default `merged`)? PURE — it operates on an already-fetched {@link PrObservation} and NEVER * throws, so a transient/garbled read is simply "not ready yet". A `merged` match binds the merge * commit as `{ mergedSha }` (mirroring the `capability` kind's `resolvedArtifact` bind) so a * downstream edge can pin the exact landed commit. The merge ACTION stays in the merge-loop node * body — this kind only OBSERVES. */ export function matchPr(match: ProbeMatch | undefined, pr: PrObservation): ProbeResult { const want: PrCondition = match?.prState ?? "merged"; switch (want) { case "ready": { // draft→ready: a non-draft PR (already-merged PRs are non-draft too, so they also satisfy it). const ready = !pr.isDraft; return { ready, detail: `pr ${ready ? "ready (not draft)" : "still draft"}` }; } case "merged": { if (!pr.merged) return { ready: false, detail: "pr not merged yet" }; const bind = pr.mergedSha ? { mergedSha: pr.mergedSha } : undefined; return { ready: true, detail: `pr merged${pr.mergedSha ? ` (${pr.mergedSha})` : ""}`, bind }; } case "mergeable": { const m = classifyMergeability(pr); const ready = m === "ready"; return { ready, detail: `pr mergeability ${m}` }; } case "checks-green": { // Required checks green: at least one head run exists, none failing, AND none still in flight. // A queued/in-progress run has no failing conclusion, so counting only `failingChecks` would // report green while checks are still running — `pendingChecks` closes that gap. `failingChecks // < 0` is token mode (checks unenumerable) — stay conservative (not ready), never falsely green. const ready = pr.failingChecks === 0 && pr.pendingChecks === 0 && pr.totalChecks > 0; const detail = pr.totalChecks < 0 ? "pr checks unenumerable (not ready)" : pr.totalChecks === 0 ? "pr no checks yet" : pr.failingChecks > 0 ? `pr checks ${pr.failingChecks} failing` : pr.pendingChecks > 0 ? `pr checks ${pr.pendingChecks} pending` : "pr checks green"; return { ready, detail }; } } } /** Split an `owner/repo#123` PR reference into its repo + numeric PR number, or `null` when it * carries no numeric id (so `parseProbe` can reject a never-resolvable target loudly). The `#` * separator is the canonical — and only — PR handle: an `@N` form is deliberately NOT accepted, as * `owner/repo@` is the repo-ref syntax used elsewhere (`parseRepoRef`), so a numeric `@N` there * would ambiguously mis-parse a git ref as a PR number. Matches the OpenAPI contract + `parseProbe` * error, both of which document `owner/repo#N` only. */ export function parsePrTarget(target: string): { repo: string; number: string } | null { const t = target.trim(); const m = t.match(/^(.+?)#(\d+)$/); if (!m) return null; const repo = m[1].trim(); if (repo === "") return null; return { repo, number: m[2] }; } /** Build the `gh pr view` command that reads a PR's merge-state fields. `gh` reads its token from the * ambient env (like `github-check`/`capability`) — no `credentialEnv`. */ export function prViewCommand(repo: string, number: string): string { return `gh pr view ${shellQuote(number)} --repo ${shellQuote(repo)} --json ${shellQuote("state,mergedAt,mergeStateStatus,statusCheckRollup,isDraft,headRefOid,mergeCommit")}`; } // ── Epic / plan-fanout probe (issue #568 — gate a graph on an epic reaching "fully merged") ────── /** A live epic (plan-fanout) observation, reduced to the fields {@link matchEpic} reads. An nwf epic * fans out many slice PRs across waves, so "fully merged" is an AGGREGATE, read from the app's own * lineage read-model (`/lineage?root=`, {@link parseEpicLineage}) rather than any single PR. * `stage`/`active` are the lineage thread's already-derived frontier: an epic reaches `stage:"merged"` * (and `active:false`) EXACTLY when every opened slice landed (`app/lineage.ts`), while a * failed/abandoned/mixed epic settles on `abandoned`/`resolved` — never `merged` — so it never goes * ready and the bounded wait routes to `onTimeout`. Kept separate from I/O so the matcher stays * pure/unit-testable, exactly like {@link PrObservation}. */ export interface EpicObservation { /** Whether the planKey resolved to a known lineage thread at all (an as-yet-unknown/never-started * epic yields `present:false` → not ready, keep waiting). */ readonly present: boolean; /** The lineage thread's derived frontier stage (e.g. `converging`, `merged`, `resolved`). */ readonly stage: string; /** Whether the arc still has an active frontier (false once every stage has settled). */ readonly active: boolean; /** Count of slice PRs on the epic — bound downstream as a fact (parity with the `pr` kind's * `mergedSha`), so a consumer can pin how many PRs the epic landed. */ readonly prCount: number; } /** Parse a raw `/lineage?root=` response (already JSON-decoded) into an {@link EpicObservation} * for `planKey`. The endpoint returns `{ count, threads: [thread] }` (or `threads: []` for an unknown * root); this reads the thread whose `rootRequestKey` matches `planKey`. Tolerant: a malformed/empty * payload yields an absent observation, so a transient read degrades to "not ready" (keep waiting), * never a throw. */ export function parseEpicLineage(payload: unknown, planKey: string): EpicObservation { const j = isRecord(payload) ? payload : {}; const threads = Array.isArray(j.threads) ? j.threads : []; const key = planKey.trim(); const thread = threads.find((t) => isRecord(t) && str(t.rootRequestKey).trim() === key); if (!isRecord(thread)) return { present: false, stage: "", active: false, prCount: 0 }; const prCount = num(thread.prCount); return { present: true, stage: str(thread.stage).trim().toLowerCase(), active: thread.active === true, prCount: typeof prCount === "number" ? prCount : 0, }; } /** epic readiness (issue #568): does the observed epic satisfy the declared `match.epicState` * (default `merged`)? PURE — it operates on an already-fetched {@link EpicObservation} and NEVER * throws, so a transient/garbled read is simply "not ready yet". "Fully merged" means the lineage * thread settled on `stage:"merged"` — every opened slice landed. A failed/abandoned/mixed epic * settles on another terminal (`abandoned`/`resolved`/`converged`), so it stays not-ready and the * bounded wait routes to `onTimeout` rather than hanging. A `merged` match binds `{ prCount }` so a * downstream edge can pin how many slice PRs the epic landed (parity with the `pr` kind's * `mergedSha`). Both `merged` and `done` map to the same "fully merged" terminal. */ export function matchEpic(match: ProbeMatch | undefined, epic: EpicObservation): ProbeResult { const want: EpicCondition = match?.epicState ?? "merged"; if (!epic.present) return { ready: false, detail: `epic (${want}): planKey not observed yet (not ready)` }; if (epic.stage === "merged" && !epic.active) { return { ready: true, detail: `epic fully merged (${epic.prCount} slices)`, bind: { prCount: String(epic.prCount) } }; } const settled = !epic.active; return { ready: false, detail: settled ? `epic settled on '${epic.stage}' (not fully merged) — routing via onTimeout` : `epic in flight (stage '${epic.stage || "unknown"}', not merged yet)`, observed: `stage=${epic.stage || "unknown"} active=${epic.active} prCount=${epic.prCount}`, }; } /** Build the app's lineage read-model URL for an epic's `planKey`. The app mounts its OpenAPI paths * under `/app/api` (mirroring `abandonUrl`/`blackboardUrl` in `app/blackboard.ts`), and `?root=` * accepts a `plan_key`. `base` is the ONE `NANO_WORKFORCE_BASE_URL` env contract (never a second * name), read through the typed schema — so an epic gate is observed over the SAME reachable base a * remote fleet already uses for the abandon/blackboard hooks. */ export function epicLineageUrl(planKey: string, base: string): string { const b = base.trim().replace(/\/+$/, "") || readEnvOr("NANO_WORKFORCE_BASE_URL"); return `${b}/app/api/lineage?root=${encodeURIComponent(planKey.trim())}`; } // ── Single probe attempt (does I/O via the injected {@link ProbeExec}) ────────────────────────── /** Run ONE probe attempt for `probe`, resolving any credential from the typed env-contract and * dispatching to the kind's matcher. Read-only and idempotent, so a re-run (worker restart) is * safe. A thrown I/O error is caught by the caller (the poll loop) and treated as "not ready yet". */ export async function probeOnce( probe: ReadinessProbe, exec: ProbeExec, env: Record = process.env, ): Promise { const credential = credentialFor(probe, env); switch (probe.kind) { case "http": { const headers: Record = { accept: "*/*" }; if (credential) headers.authorization = `Bearer ${credential}`; return matchHttp(probe.match, await exec.httpGet(probe.target, headers)); } case "command": return matchCommand(probe.match, await exec.run(probe.target, env)); case "npm": return matchNpm(probe.match, probe.target, await exec.run(npmCommand(probe.target), env)); case "github-check": { const { repo, ref } = parseRepoRef(probe.target); const out = await exec.run(githubCheckCommand(repo, ref), env); if (out.code !== 0) return { ready: false, detail: "github-check: gh api failed (not ready)" }; return matchGithubCheck(probe.match, parseJson(out.stdout)); } case "capability": { const repo = parseReleasesTarget(probe.target); const out = await exec.run(githubReleasesCommand(repo), env); if (out.code !== 0) return { ready: false, detail: "capability: gh api failed (not ready)" }; return matchCapability(probe.match, parseReleases(parseJson(out.stdout))); } case "pr": { const ref = parsePrTarget(probe.target); if (!ref) return { ready: false, detail: "pr: unparseable target (not ready)" }; const out = await exec.run(prViewCommand(ref.repo, ref.number), env); if (out.code !== 0) return { ready: false, detail: "pr: gh pr view failed (not ready)" }; return matchPr(probe.match, parsePrView(parseJson(out.stdout))); } case "epic": { // "Fully merged" is the app's AGGREGATE, not a GitHub read — observe it over the app's own // lineage read-model (level-triggered, same poll machinery as `pr`). A fact-bound target that is // still unresolved (`.`) is not a literal planKey, so treat it as "not ready" and // keep waiting rather than issue a malformed request. if (!parsePrTarget(probe.target)) return { ready: false, detail: "epic: unresolved/unparseable planKey (not ready)" }; const url = epicLineageUrl(probe.target, readEnvOr("NANO_WORKFORCE_BASE_URL", "", env)); const resp = await exec.httpGet(url, { accept: "application/json" }); if (resp.status < 200 || resp.status >= 300) return { ready: false, detail: `epic: lineage read HTTP ${resp.status} (not ready)` }; return matchEpic(probe.match, parseEpicLineage(parseJson(resp.body), probe.target)); } } } /** Build the gated empirical fallback for a `capability` probe (decision 5) — a thunk the poll loop * runs ONCE at the gate boundary (local budget exhausted) when deterministic provenance resolved * nothing. It installs nothing itself: it fetches releases, picks the NEWEST published version, and * runs the descriptor's `match.verifyCommand` against it (with `RESOLVED_ARTIFACT`/`RESOLVED_VERSION` * in the env) — exit 0 binds that newest version, letting a capability that provenance under-reported * still resolve empirically. Returns `null` (no fallback) for a non-capability probe, a capability * probe with no `verifyCommand` (deterministic-only), or when no version/releases are available — so * the default path stays a pure deterministic lookup and the agent judgment is the gated exception. */ export function makeCapabilityFallback( probe: ReadinessProbe, exec: ProbeExec, env: Record, ): () => Promise { return async () => { if (probe.kind !== "capability") return null; const verify = probe.match?.verifyCommand; const pkg = probe.match?.package; if (!verify || !pkg) return null; const listed = await exec.run(githubReleasesCommand(parseReleasesTarget(probe.target)), env); if (listed.code !== 0) return null; const newest = newestPublishedVersion(pkg, parseReleases(parseJson(listed.stdout))); if (!newest) return null; const artifact = `${pkg}@${newest}`; const res = await exec.run(verify, { ...env, RESOLVED_ARTIFACT: artifact, RESOLVED_VERSION: newest }); if (res.code === 0) { return { ready: true, detail: `capability verified empirically at ${artifact}`, bind: { resolvedArtifact: artifact } }; } return { ready: false, detail: "capability: empirical verification failed at gate boundary" }; }; } /** Resolve the credential a probe declares, from the typed env-contract only. Returns undefined * when no `credentialEnv` is declared or the key is unset — never a value from the descriptor. */ function credentialFor(probe: ReadinessProbe, env: Record): string | undefined { if (probe.credentialEnv === undefined || !isEnvKey(probe.credentialEnv)) return undefined; return readEnv(probe.credentialEnv, env); } /** Build the `npm view` command for a `pkg@version` target. The target is single-quote-escaped so * a hostile descriptor cannot break out of the argument. */ export function npmCommand(target: string): string { return `npm view ${shellQuote(target)} version`; } /** Build the `gh api` command that lists the check runs for a ref. */ export function githubCheckCommand(repo: string, ref: string): string { return `gh api ${shellQuote(`repos/${repo}/commits/${ref}/check-runs`)} -H ${shellQuote("Accept: application/vnd.github+json")}`; } /** Split an `owner/repo@ref` github-check target into its parts. Defaults the ref to `HEAD`. */ export function parseRepoRef(target: string): { repo: string; ref: string } { const at = target.lastIndexOf("@"); if (at <= 0) return { repo: target, ref: "HEAD" }; return { repo: target.slice(0, at), ref: target.slice(at + 1).trim() || "HEAD" }; } function shellQuote(s: string): string { return `'${s.replace(/'/g, "'\\''")}'`; } function parseJson(text: string): unknown { try { return JSON.parse(text); } catch { return null; } } // ── Timeout duration derivation (probe poll budget → the gate's FEEL timer) ───────────────────── /** Convert a millisecond budget into an ISO-8601 duration for a BPMN ``. * Rounds up to whole seconds (a sub-second budget still yields at least `PT1S`), so the engine * timer never rounds down to a zero-length (immediately-firing) duration. */ export function msToIsoDuration(ms: number): string { const seconds = Math.max(1, Math.ceil(ms / 1000)); return `PT${seconds}S`; } /** The authoritative gate timeout (an ISO-8601 duration) for a probe: the descriptor's * `poll.timeoutMs` when present, else `NANO_READINESS_POLL_TIMEOUT`, else the built-in default. * This is the value the gate's timer arm is seeded with — the engine, not the worker, owns the * bound. Kept here so whoever seeds a readiness-gate instance derives it from ONE place. */ export function readinessTimeout( probe: ReadinessProbe, env: Record = process.env, ): string { const declared = probe.poll?.timeoutMs; if (typeof declared === "number" && declared >= 1) return msToIsoDuration(Math.trunc(declared)); return isoDuration(readEnvOr("NANO_READINESS_POLL_TIMEOUT", DEFAULT_READINESS_TIMEOUT, env), DEFAULT_READINESS_TIMEOUT); } /** The poll cadence (an ISO-8601 duration) seeded onto a readiness-gate instance as `probePollEvery`: * the descriptor's `poll.everyMs` when present, else `NANO_READINESS_POLL_EVERY_MS`, else the built-in * {@link DEFAULT_EVERY_MS}, clamped to {@link MAX_EVERY_MS}. Since Option A (#428), the engine — not the * worker — owns the retry cadence: the `wait-poll` timers in `readiness-gate.bpmn` (and the preflight * loops in `feature.bpmn`/`plan-fanout.bpmn`) read `=probePollEvery`, re-activating the now single-shot * `pr.readiness-probe` once per interval. Derived here so whoever seeds a gate derives the cadence from * ONE place (mirroring {@link readinessTimeout} for the bound), and worker/engine can never drift. * `msToIsoDuration` rounds up (via `Math.ceil`) to a whole second, with a one-second minimum, so the * timer never rounds to an immediately-refiring zero-length duration (which would reintroduce a * busy-spin — the very defect Option A removes). */ export function readinessPollEvery( probe: ReadinessProbe, env: Record = process.env, ): string { const declared = probe.poll?.everyMs; const ms = typeof declared === "number" && declared >= 1 ? Math.min(Math.trunc(declared), MAX_EVERY_MS) : (() => { const envEvery = Number(readEnvOr("NANO_READINESS_POLL_EVERY_MS", String(DEFAULT_EVERY_MS), env)); return Number.isFinite(envEvery) && envEvery >= 1 ? Math.min(Math.trunc(envEvery), MAX_EVERY_MS) : DEFAULT_EVERY_MS; })(); return msToIsoDuration(ms); } /** The effective gate budget in **milliseconds** — the ms twin of {@link readinessTimeout}, resolved * by the SAME precedence (descriptor `poll.timeoutMs`, else `NANO_READINESS_POLL_TIMEOUT`, else the * built-in default) and sharing its env key + default. The worker's local poll budget MUST use this * rather than a hard-coded default: an operator who raises `NANO_READINESS_POLL_TIMEOUT` past the * built-in 30m would otherwise stop the worker probing while the gate's engine timer keeps waiting — * a window in which the artifact can go ready with nothing left to observe it, spuriously escalating * the gate. The declared branch stays exact ms (the gate rounds it up to whole seconds for its ISO * timer); the env branch parses through {@link isoDurationToMs}, the same grammar `readinessTimeout` * validates with, so the two bounds can never drift. */ export function readinessTimeoutMs( probe: ReadinessProbe, env: Record = process.env, ): number { const declared = probe.poll?.timeoutMs; if (typeof declared === "number" && declared >= 1) return Math.trunc(declared); return isoDurationToMs(readEnvOr("NANO_READINESS_POLL_TIMEOUT", DEFAULT_READINESS_TIMEOUT, env), DEFAULT_READINESS_TIMEOUT); } /** The worker's local poll budget in **milliseconds**, bound to the gate **per instance**. * * The gate's engine timers (`resources/processes/readiness-gate.bpmn`) fire off the *process * variable* `probeTimeout` (`=probeTimeout`), seeded once when the gate * instance is created. The worker must adopt that SAME seeded value rather than recompute the bound * from the ambient env ({@link readinessTimeoutMs}): if `NANO_READINESS_POLL_TIMEOUT` changes after * the instance is created (or `probeTimeout` was seeded from a different source), an env-recomputed * worker can stop probing while the engine timer is still waiting — a window where the artifact can * go ready with no worker left to publish `readiness-ready`, spuriously escalating the gate. * * So prefer the seeded `probeTimeout` (parsed through {@link isoDurationToMs}, the same grammar the * engine timer is validated with, so worker-ms and engine-ISO can't drift), and fall back to the * env-derived twin only when it is absent/blank — e.g. a direct caller or unit test that drives the * loop without seeding the process variable. */ export function probeBudgetMs( probeTimeout: string | undefined, probe: ReadinessProbe, env: Record = process.env, ): number { const seeded = (probeTimeout ?? "").trim(); if (seeded !== "") return isoDurationToMs(seeded, DEFAULT_READINESS_TIMEOUT); return readinessTimeoutMs(probe, env); } // ── Default I/O implementation (Node) ─────────────────────────────────────────────────────────── /** The production {@link ProbeExec}: `fetch` for http, a shell subprocess for command/npm/gh. Every * attempt is bounded by `attemptTimeoutMs` ({@link DEFAULT_ATTEMPT_TIMEOUT_MS}) — `fetch` via an * `AbortController` and the subprocess via `exec`'s `timeout`/`killSignal` — so a hung endpoint or a * stuck command becomes a bounded error the poll loop retries, never an I/O block the engine timer * cannot cancel. */ export function defaultProbeExec(attemptTimeoutMs: number = DEFAULT_ATTEMPT_TIMEOUT_MS): ProbeExec { return { async httpGet(url, headers) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), attemptTimeoutMs); try { const r = await fetch(url, { headers, redirect: "follow", signal: controller.signal }); const body = await r.text().catch(() => ""); return { status: r.status, body }; } finally { clearTimeout(timer); } }, async run(command, env) { const { exec } = await import("node:child_process"); return await new Promise((resolve) => { exec( command, { env, maxBuffer: 16 * 1024 * 1024, timeout: attemptTimeoutMs, killSignal: "SIGKILL" }, (err, stdout, stderr) => { const code = err && typeof err.code === "number" ? err.code : err ? 1 : 0; resolve({ code, stdout: String(stdout ?? ""), stderr: String(stderr ?? "") }); }, ); }); }, }; } // ── Log redaction (ADR 0004 pinned decision 2 — never leak a probe's target/output/credential) ── /** A log-safe rendering of a probe: kind + a redacted target (URL userinfo and query string * stripped, since either can carry a token) — never the credential, body, or stdout. A `command` * target is an arbitrary shell snippet that can easily embed a secret, so it is never logged at all: * only the kind + a fixed placeholder is rendered for it. */ export function redactTarget(probe: ReadinessProbe): string { if (probe.kind === "command") return `${probe.kind}:`; return `${probe.kind}:${redactString(probe.target)}`; } /** Strip credential-bearing pieces from a free-form target string for logging: any `user:pass@` * userinfo and any `?query`/`#fragment` (a token often rides the query). */ export function redactString(s: string): string { return s .replace(/\/\/[^/@\s]*@/g, "//***@") .replace(/[?#].*$/, (m) => `${m[0]}***`); }