@nanobpm/nano-workforce 0.102.1 → 0.103.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [0.103.0](https://github.com/nanobpm/nano-workforce/compare/v0.102.1...v0.103.0) (2026-08-19)
2
+
3
+
4
+ ### Features
5
+
6
+ * **feature:** intake-time readiness gate for single-issue runs ([#295](https://github.com/nanobpm/nano-workforce/issues/295)) ([#349](https://github.com/nanobpm/nano-workforce/issues/349)) ([09b519b](https://github.com/nanobpm/nano-workforce/commit/09b519be9ff21c8821b3ec3e0687541710be718b)), closes [owner/repo#N](https://github.com/owner/repo/issues/N)
7
+
1
8
  ## [0.102.1](https://github.com/nanobpm/nano-workforce/compare/v0.102.0...v0.102.1) (2026-08-19)
2
9
 
3
10
 
@@ -307,3 +307,74 @@ test("startFeature: persists the real issue title when the fetch succeeds", asyn
307
307
  else process.env["GITHUB_TOKEN"] = prevTok;
308
308
  }
309
309
  });
310
+
311
+ test("startFeature: no readiness ⇒ readinessProbes/probeTimeout/gateKey seeded null (gate skipped)", async () => {
312
+ let captured: any = null;
313
+ const engine = {
314
+ createInstance: (req: any) => {
315
+ captured = req;
316
+ return Promise.resolve({ processInstanceKey: "PI-R0" });
317
+ },
318
+ } as any;
319
+ await startFeature(memData({ feature_runs: { rows: [], key: "feature_key" } }), engine, PARSED, "main", false, false);
320
+ const v = captured.variables;
321
+ assertEquals(v.readinessProbes, null);
322
+ assertEquals(v.probeTimeout, null);
323
+ assertEquals(v.gateKey, null);
324
+ assertEquals(v.resolvedArtifacts, null);
325
+ });
326
+
327
+ test("startFeature: readiness probes seed the gate variables + a non-blank correlation key", async () => {
328
+ let captured: any = null;
329
+ const engine = {
330
+ createInstance: (req: any) => {
331
+ captured = req;
332
+ return Promise.resolve({ processInstanceKey: "PI-R1" });
333
+ },
334
+ } as any;
335
+ const probes = [
336
+ {
337
+ kind: "capability",
338
+ target: "github-releases:nanobpm/nano-bpm",
339
+ match: { package: "@nanobpm/engine-wasm", capabilityRef: "nanobpm/nano-bpm#631" },
340
+ onTimeout: "escalate",
341
+ },
342
+ ] as any;
343
+ await startFeature(
344
+ memData({ feature_runs: { rows: [], key: "feature_key" } }),
345
+ engine,
346
+ PARSED,
347
+ "main",
348
+ false,
349
+ false,
350
+ null,
351
+ { probes, probeTimeout: "PT30M" },
352
+ );
353
+ const v = captured.variables;
354
+ assertEquals(v.readinessProbes, probes);
355
+ assertEquals(v.probeTimeout, "PT30M");
356
+ // The preflight probe worker requires a non-blank gateKey to publish readiness-ready on.
357
+ assertEquals(v.gateKey, "feature-readiness:owner/repo#42");
358
+ assertEquals(v.resolvedArtifacts, null);
359
+ });
360
+
361
+ test("startFeature: probes without a probeTimeout fail fast (both are load-bearing together)", async () => {
362
+ const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-R2" }) } as any;
363
+ let threw = false;
364
+ try {
365
+ await startFeature(
366
+ memData({ feature_runs: { rows: [], key: "feature_key" } }),
367
+ engine,
368
+ PARSED,
369
+ "main",
370
+ false,
371
+ false,
372
+ null,
373
+ { probes: [{ kind: "command", target: "x" }] as any, probeTimeout: null },
374
+ );
375
+ } catch (err) {
376
+ threw = true;
377
+ assertEquals((err as Error).message.includes("probeTimeout"), true);
378
+ }
379
+ assertEquals(threw, true);
380
+ });
package/app/feature.ts CHANGED
@@ -17,8 +17,20 @@
17
17
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
18
18
  import { coalesceTitle, fetchIssueTitle } from "./github.ts";
19
19
  import { ESCALATION_SLA_TIMEOUT, normalizeBaseBranch, type ParsedIssue, renderBaseBranchBrief } from "./plan.ts";
20
+ import type { ReadinessProbe } from "./readiness.ts";
20
21
  import { deriveListBucket, deriveStage } from "./stage.ts";
21
22
 
23
+ /** Optional intake-time readiness gate for a feature run (issue #295): the `capability`/`command`/…
24
+ * probes the run must ALL satisfy before its implementation agent is dispatched (parked, durably, at
25
+ * the leading readiness preflight in feature.bpmn), plus the single ISO-8601 bound the preflight's
26
+ * escalation timers fire off. Both are DERIVED once from the submitted `readiness`/`blockedOn` intake
27
+ * by {@link parseFeatureReadiness} (app/featureReadiness.ts). Empty/absent ⇒ the gate is skipped and
28
+ * the run proceeds straight to implementation, exactly as today's submissions do. */
29
+ export interface FeatureReadinessOptions {
30
+ readonly probes?: ReadinessProbe[];
31
+ readonly probeTimeout?: string | null;
32
+ }
33
+
22
34
  /** The BPMN process this module drives (resources/processes/feature.bpmn). */
23
35
  export const FEATURE_PROCESS_ID = "feature";
24
36
 
@@ -339,7 +351,22 @@ export async function startFeature(
339
351
  converge: boolean,
340
352
  autoMerge: boolean,
341
353
  customInstructions: string | null = null,
354
+ readiness: FeatureReadinessOptions = {},
342
355
  ) {
356
+ // Intake-time readiness gate (issue #295): the probes the run must satisfy before it implements,
357
+ // and the bound its preflight escalation timers fire off. Both are load-bearing together —
358
+ // `pr.readiness-probe` rejects a blank `probeTimeout` and the preflight timers read `=probeTimeout`
359
+ // — so a non-empty probe set seeded without a bound would incident at runtime. Fail fast at the
360
+ // start door instead (mirroring `startPlan`); `parseFeatureReadiness` always derives the two
361
+ // together, so this only fires for a mis-seeded direct caller.
362
+ const readinessProbes = readiness.probes && readiness.probes.length > 0 ? readiness.probes : null;
363
+ if (readinessProbes && (readiness.probeTimeout ?? "").trim() === "") {
364
+ throw new Error(
365
+ `startFeature(${parsed.planKey}): ${readinessProbes.length} readiness probe(s) seeded without a ` +
366
+ "probeTimeout — the preflight escalation timers (=probeTimeout) and pr.readiness-probe both require " +
367
+ "a non-blank bound. Derive it via parseFeatureReadiness before starting a gated feature.",
368
+ );
369
+ }
343
370
  // Operator free-text steering for the implementation agent (issue #172 follow-on): blank/absent →
344
371
  // null so the implement task's `appendPrompt` FEEL (`customInstructions = null`) skips the block
345
372
  // rather than appending an empty "Operator custom instructions" heading.
@@ -447,6 +474,21 @@ export async function startFeature(
447
474
  // task's `appendPrompt` FEEL (feature.bpmn). Null when none was supplied; persists on the
448
475
  // instance so it also rides the answer-loop redispatch back into the same implement task.
449
476
  customInstructions: instructions,
477
+ // Intake-time readiness gate (issue #295): the leading preflight the feature run parks on until
478
+ // every declared probe goes green (feature.bpmn `gw-readiness` → `readiness-preflight`). A
479
+ // submission with NO readiness carries `null` here, so the gateway routes straight to
480
+ // `ensure-base-branch` and the run implements immediately — behaviour unchanged for today's
481
+ // features. `probeTimeout` bounds the preflight's escalation timers (derived once from the same
482
+ // probes); `gateKey` is the non-blank correlation key the probe worker publishes
483
+ // `readiness-ready` on (required even in the preflight, which reads the probe's synchronous
484
+ // result); `resolvedArtifacts` is filled by the preflight on green — the exact `pkg@version`s
485
+ // first carrying each awaited capability — and rides the implement task's `appendPrompt` so the
486
+ // agent bumps the consumer dependency to exactly the bound version. Seeded `null` so a
487
+ // gate-less run still resolves the variable in that FEEL instead of raising an incident.
488
+ readinessProbes,
489
+ probeTimeout: readinessProbes ? (readiness.probeTimeout ?? null) : null,
490
+ gateKey: readinessProbes ? `feature-readiness:${parsed.planKey}` : null,
491
+ resolvedArtifacts: null,
450
492
  },
451
493
  });
452
494
  const processKey = processInstanceKey == null ? null : String(processInstanceKey);
@@ -0,0 +1,165 @@
1
+ // Unit coverage for the feature-intake readiness gate desugaring (issue #295).
2
+ //
3
+ // `parseFeatureReadiness` turns a submitted feature's optional `readiness`/`blockedOn` intake into the
4
+ // `readinessProbes` + `probeTimeout` process variables the feature.bpmn preflight runs. These tests
5
+ // pin the desugaring: full descriptors round-trip through `parseProbe`, `blockedOn` desugars to
6
+ // `capability` probes (with `consumerPackage`) or `command` state probes (fallback), the bound is
7
+ // derived, and malformed intake fails loudly at submit.
8
+ import { test } from "node:test";
9
+ import { assertEquals } from "#test-assert";
10
+ import { parseFeatureReadiness } from "./featureReadiness.ts";
11
+
12
+ const ENV = { NANO_READINESS_POLL_TIMEOUT: "PT30M" } as Record<string, string | undefined>;
13
+
14
+ test("parseFeatureReadiness: no intake ⇒ empty probes, null bound (gate skipped)", () => {
15
+ assertEquals(parseFeatureReadiness(undefined, ENV), { probes: [], probeTimeout: null });
16
+ assertEquals(parseFeatureReadiness({}, ENV), { probes: [], probeTimeout: null });
17
+ assertEquals(parseFeatureReadiness({ readiness: [], blockedOn: [] }, ENV), { probes: [], probeTimeout: null });
18
+ });
19
+
20
+ test("parseFeatureReadiness: blockedOn + consumerPackage ⇒ capability probes with derived bound", () => {
21
+ const out = parseFeatureReadiness(
22
+ { blockedOn: ["nanobpm/nano-bpm#631", "nanobpm/nano-bpm#808"], consumerPackage: "@nanobpm/engine-wasm" },
23
+ ENV,
24
+ );
25
+ assertEquals(out.probes.length, 2);
26
+ assertEquals(out.probes[0], {
27
+ kind: "capability",
28
+ target: "github-releases:nanobpm/nano-bpm",
29
+ match: { package: "@nanobpm/engine-wasm", capabilityRef: "nanobpm/nano-bpm#631" },
30
+ onTimeout: "escalate",
31
+ });
32
+ assertEquals(out.probes[1].match?.capabilityRef, "nanobpm/nano-bpm#808");
33
+ // Every derived probe shares the env default, so the bound is that default.
34
+ assertEquals(out.probeTimeout, "PT30M");
35
+ });
36
+
37
+ test("parseFeatureReadiness: blockedOn without consumerPackage ⇒ command state probes (merged-is-enough)", () => {
38
+ const out = parseFeatureReadiness({ blockedOn: ["octo/cat#7"] }, ENV);
39
+ assertEquals(out.probes[0], {
40
+ kind: "command",
41
+ target: "gh api repos/octo/cat/issues/7 --jq .state",
42
+ match: { stdoutIncludes: "closed" },
43
+ onTimeout: "escalate",
44
+ });
45
+ assertEquals(out.probeTimeout, "PT30M");
46
+ });
47
+
48
+ test("parseFeatureReadiness: full readiness descriptors round-trip through parseProbe", () => {
49
+ const out = parseFeatureReadiness(
50
+ {
51
+ readiness: [
52
+ { kind: "http", target: "https://example.test/health", match: { status: 200 } },
53
+ { kind: "command", target: "make ready" },
54
+ ],
55
+ },
56
+ ENV,
57
+ );
58
+ assertEquals(out.probes.length, 2);
59
+ assertEquals(out.probes[0].kind, "http");
60
+ assertEquals(out.probes[0].match?.status, 200);
61
+ assertEquals(out.probes[1].kind, "command");
62
+ });
63
+
64
+ test("parseFeatureReadiness: readiness + blockedOn concatenate", () => {
65
+ const out = parseFeatureReadiness(
66
+ { readiness: [{ kind: "command", target: "make ready" }], blockedOn: ["octo/cat#7"] },
67
+ ENV,
68
+ );
69
+ assertEquals(out.probes.length, 2);
70
+ assertEquals(out.probes[0].kind, "command");
71
+ assertEquals(out.probes[1].target, "gh api repos/octo/cat/issues/7 --jq .state");
72
+ });
73
+
74
+ test("parseFeatureReadiness: a longer per-probe budget wins the derived bound", () => {
75
+ const out = parseFeatureReadiness(
76
+ {
77
+ readiness: [
78
+ { kind: "command", target: "a", poll: { timeoutMs: 60_000 } },
79
+ { kind: "command", target: "b", poll: { timeoutMs: 3_600_000 } },
80
+ ],
81
+ },
82
+ ENV,
83
+ );
84
+ assertEquals(out.probeTimeout, "PT3600S");
85
+ });
86
+
87
+ test("parseFeatureReadiness: a bare repo#N handle is rejected (cannot name a provenance repo)", () => {
88
+ let threw = false;
89
+ try {
90
+ parseFeatureReadiness({ blockedOn: ["nano-bpm#631"], consumerPackage: "@nanobpm/engine-wasm" }, ENV);
91
+ } catch (err) {
92
+ threw = true;
93
+ assertEquals((err as Error).message.includes("owner/repo#123"), true);
94
+ }
95
+ assertEquals(threw, true);
96
+ });
97
+
98
+ test("parseFeatureReadiness: a handle whose repo carries shell metacharacters is rejected (no injection into the command probe)", () => {
99
+ // The `command` fallback interpolates `parsed.repo` into a shell string run via `exec`. `parseIssue`'s
100
+ // `owner/repo#N` branch matches `[^#]+` for the slug, so a crafted handle could smuggle `;`/`$()`/backticks
101
+ // into the readiness worker's shell. Desugaring MUST reject any repo that isn't a valid GitHub slug.
102
+ for (const evil of [
103
+ "octo/cat; rm -rf /#7",
104
+ "octo/cat$(touch pwned)#7",
105
+ "octo/`whoami`#7",
106
+ "octo/cat rm#7",
107
+ ]) {
108
+ let threw = false;
109
+ try {
110
+ parseFeatureReadiness({ blockedOn: [evil] }, ENV);
111
+ } catch (err) {
112
+ threw = true;
113
+ assertEquals((err as Error).message.includes("owner/repo"), true);
114
+ }
115
+ assertEquals(threw, true);
116
+ }
117
+ });
118
+
119
+ test("parseFeatureReadiness: a non-string blockedOn entry is rejected", () => {
120
+ let threw = false;
121
+ try {
122
+ parseFeatureReadiness({ blockedOn: [42] }, ENV);
123
+ } catch {
124
+ threw = true;
125
+ }
126
+ assertEquals(threw, true);
127
+ });
128
+
129
+ test("parseFeatureReadiness: a blank consumerPackage is rejected", () => {
130
+ let threw = false;
131
+ try {
132
+ parseFeatureReadiness({ blockedOn: ["octo/cat#7"], consumerPackage: " " }, ENV);
133
+ } catch (err) {
134
+ threw = true;
135
+ assertEquals((err as Error).message.includes("consumerPackage"), true);
136
+ }
137
+ assertEquals(threw, true);
138
+ });
139
+
140
+ test("parseFeatureReadiness: a malformed readiness descriptor fails loudly (unknown kind)", () => {
141
+ let threw = false;
142
+ try {
143
+ parseFeatureReadiness({ readiness: [{ kind: "bogus", target: "x" }] }, ENV);
144
+ } catch {
145
+ threw = true;
146
+ }
147
+ assertEquals(threw, true);
148
+ });
149
+
150
+ test("parseFeatureReadiness: a non-array readiness/blockedOn is rejected", () => {
151
+ let a = false;
152
+ let b = false;
153
+ try {
154
+ parseFeatureReadiness({ readiness: { kind: "command", target: "x" } }, ENV);
155
+ } catch {
156
+ a = true;
157
+ }
158
+ try {
159
+ parseFeatureReadiness({ blockedOn: "octo/cat#7" }, ENV);
160
+ } catch {
161
+ b = true;
162
+ }
163
+ assertEquals(a, true);
164
+ assertEquals(b, true);
165
+ });
@@ -0,0 +1,151 @@
1
+ // nano-workforce — feature-intake readiness gate desugaring (issue #295).
2
+ //
3
+ // The intake-time half of the durable readiness gate for a SINGLE-issue feature run. A submitted
4
+ // feature may carry an optional `readiness` (one or more full {@link ReadinessProbe} descriptors) or
5
+ // the ergonomic shorthand `blockedOn` — a list of upstream issue/PR handles the feature must wait to
6
+ // land before its implementation agent is dispatched. This module turns either form into the SAME
7
+ // `readinessProbes` + `probeTimeout` process variables the (existing) readiness-gate preflight in
8
+ // `resources/processes/feature.bpmn` runs — reusing the production probe kinds, the escalation form,
9
+ // and the `capability` late-bind primitive verbatim (derivation over duplication). No new subsystem:
10
+ // this is intake plumbing on top of `app/readiness.ts` (#258) and the `capability` kind (#274).
11
+ //
12
+ // `blockedOn` desugars per handle:
13
+ // • With a declared `consumerPackage` (the cross-repo case — e.g. a wfd feature gated on
14
+ // `@nanobpm/engine-wasm` carrying `nanobpm/nano-bpm#631`) → a `capability` probe that resolves
15
+ // "which published `pkg@version` FIRST carries this handle?" from publish provenance and
16
+ // late-binds the resolved `pkg@version` back into the run (`resolvedArtifacts`), so the agent can
17
+ // bump the consumer dependency to exactly that version.
18
+ // • Without a `consumerPackage` (no published-artifact edge applies) → a `command` probe that goes
19
+ // green once the referenced issue/PR is closed/merged (`gh api …/issues/<n> --jq .state`), the
20
+ // "merged is enough" fallback.
21
+ //
22
+ // The derivation is pure (no I/O, no engine) so it is trivially unit-testable — the seam
23
+ // `startFeature` calls at submit to seed the gate.
24
+ import { parseIssue } from "./plan.ts";
25
+ import {
26
+ DEFAULT_READINESS_TIMEOUT,
27
+ parseProbe,
28
+ type ReadinessProbe,
29
+ readinessTimeout,
30
+ } from "./readiness.ts";
31
+ import { isoDurationToMs } from "./reviewWait.ts";
32
+
33
+ /** The raw intake shape a submitted feature may carry (all optional). `readiness` is one or more
34
+ * full {@link ReadinessProbe} descriptors; `blockedOn` is the ergonomic shorthand — a list of
35
+ * upstream `owner/repo#N` handles; `consumerPackage` is the npm package whose publish provenance the
36
+ * `blockedOn` shorthand resolves the handles against (e.g. `@nanobpm/engine-wasm`). */
37
+ export interface FeatureReadinessInput {
38
+ readonly readiness?: unknown;
39
+ readonly blockedOn?: unknown;
40
+ readonly consumerPackage?: unknown;
41
+ }
42
+
43
+ /** The desugared gate: the probes the feature must satisfy before it implements, and the single
44
+ * ISO-8601 bound the preflight's escalation timers fire off (the LONGEST of the probes' derived
45
+ * timeouts, so no probe is cut short). `probes` is empty when the feature declared no readiness —
46
+ * the gate is then skipped and the run proceeds straight to implementation (behaviour unchanged). */
47
+ export interface FeatureReadiness {
48
+ readonly probes: ReadinessProbe[];
49
+ readonly probeTimeout: string | null;
50
+ }
51
+
52
+ function isNonEmptyString(v: unknown): v is string {
53
+ return typeof v === "string" && v.trim() !== "";
54
+ }
55
+
56
+ /** A valid GitHub `owner/repo` slug: both segments are restricted to the characters GitHub itself
57
+ * allows (alphanumerics, `-`, `_`, `.`). `parseIssue`'s shorthand branch matches `[^#]+` for the
58
+ * slug, so it would otherwise admit shell metacharacters (`;`, `$( )`, backticks, spaces) that get
59
+ * interpolated verbatim into the `command` probe's `exec` string (and the `capability` target).
60
+ * Constraining the slug here shuts that injection surface for the whole `blockedOn` desugaring. */
61
+ const GITHUB_SLUG = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
62
+
63
+ /** Normalise a single `blockedOn` handle into a `capability` (with `consumerPackage`) or `command`
64
+ * (fallback) probe. The handle MUST parse as a full `owner/repo#N` reference — a bare `repo#N`
65
+ * cannot name a provenance source repo unambiguously, so it fails loudly here rather than desugaring
66
+ * to a probe that can never resolve. */
67
+ function desugarHandle(handle: string, consumerPackage: string | null): ReadinessProbe {
68
+ const parsed = parseIssue(handle.trim());
69
+ if (!parsed) {
70
+ throw new Error(
71
+ `feature readiness: blockedOn handle '${handle}' must be a full 'owner/repo#123' reference ` +
72
+ "(a bare 'repo#123' cannot name the upstream provenance repo)",
73
+ );
74
+ }
75
+ // `parsed.number` is numeric (via `Number`), but `parsed.repo` is an unconstrained slug that lands in
76
+ // a shell `command` target — reject anything that isn't a plain GitHub `owner/repo` before we build it.
77
+ if (!GITHUB_SLUG.test(parsed.repo)) {
78
+ throw new Error(
79
+ `feature readiness: blockedOn handle '${handle}' has an invalid 'owner/repo' slug — only ` +
80
+ "alphanumerics, '-', '_' and '.' are allowed in each segment",
81
+ );
82
+ }
83
+ if (consumerPackage) {
84
+ // The `capability` edge (#274): resolve which published `<consumerPackage>@version` first carries
85
+ // this upstream handle and late-bind that `pkg@version` back into the run. The provenance source
86
+ // repo is the handle's own repo (where the upstream lands and publishes).
87
+ return {
88
+ kind: "capability",
89
+ target: `github-releases:${parsed.repo}`,
90
+ match: { package: consumerPackage, capabilityRef: parsed.planKey },
91
+ // A stuck/never-publishing upstream must ESCALATE (bounded) — never fail or proceed unbound.
92
+ onTimeout: "escalate",
93
+ };
94
+ }
95
+ // Fallback (no published-artifact edge): "merged is enough". `gh` reads its token from the ambient
96
+ // env (like the `github-check`/`capability` kinds), and PRs are issues in the REST API, so a single
97
+ // `/issues/<n>` state check covers both an issue being closed and a PR being merged (→ closed).
98
+ return {
99
+ kind: "command",
100
+ target: `gh api repos/${parsed.repo}/issues/${parsed.number} --jq .state`,
101
+ match: { stdoutIncludes: "closed" },
102
+ onTimeout: "escalate",
103
+ };
104
+ }
105
+
106
+ /** Parse + desugar a feature's optional intake readiness into the gate's `readinessProbes` +
107
+ * `probeTimeout`. Accepts EITHER the full `readiness` descriptor list OR the `blockedOn` shorthand
108
+ * (or both — they concatenate). Returns an empty probe set (gate skipped) when neither is present.
109
+ *
110
+ * Throws a descriptive error on a malformed descriptor (via {@link parseProbe}), a `blockedOn` entry
111
+ * that is not a string, an unparseable handle, or a `consumerPackage` that is present but blank — a
112
+ * mis-declared gate must fail loudly at submit, never wait forever at runtime. */
113
+ export function parseFeatureReadiness(
114
+ input: FeatureReadinessInput | null | undefined,
115
+ env: Record<string, string | undefined> = process.env,
116
+ ): FeatureReadiness {
117
+ const probes: ReadinessProbe[] = [];
118
+ if (input && input.consumerPackage !== undefined && !isNonEmptyString(input.consumerPackage)) {
119
+ throw new Error("feature readiness: 'consumerPackage' must be a non-blank package name when supplied");
120
+ }
121
+ const consumerPackage = input && isNonEmptyString(input.consumerPackage) ? input.consumerPackage.trim() : null;
122
+
123
+ if (input?.readiness !== undefined && input.readiness !== null) {
124
+ if (!Array.isArray(input.readiness)) {
125
+ throw new Error("feature readiness: 'readiness' must be an array of probe descriptors");
126
+ }
127
+ for (const raw of input.readiness) probes.push(parseProbe(raw));
128
+ }
129
+
130
+ if (input?.blockedOn !== undefined && input.blockedOn !== null) {
131
+ if (!Array.isArray(input.blockedOn)) {
132
+ throw new Error("feature readiness: 'blockedOn' must be an array of 'owner/repo#123' handles");
133
+ }
134
+ for (const raw of input.blockedOn) {
135
+ if (!isNonEmptyString(raw)) {
136
+ throw new Error("feature readiness: each 'blockedOn' entry must be a non-blank 'owner/repo#123' handle");
137
+ }
138
+ probes.push(desugarHandle(raw, consumerPackage));
139
+ }
140
+ }
141
+
142
+ if (probes.length === 0) return { probes: [], probeTimeout: null };
143
+
144
+ // One bound governs the whole preflight's escalation timers — the LONGEST of the probes' derived
145
+ // timeouts (via the canonical `readinessTimeout`), so no probe is cut short. Mirrors the epic
146
+ // lowering (app/planLowering.ts) so the feature and epic gates derive the bound identically.
147
+ const probeTimeout = probes
148
+ .map((p) => readinessTimeout(p, env))
149
+ .reduce((a, b) => (isoDurationToMs(b, DEFAULT_READINESS_TIMEOUT) > isoDurationToMs(a, DEFAULT_READINESS_TIMEOUT) ? b : a));
150
+ return { probes, probeTimeout };
151
+ }
@@ -0,0 +1,191 @@
1
+ // End-to-end proof for the intake-time readiness gate seeded into a single-issue feature run
2
+ // (issue #295). Boots the whole app against the WASM engine + virtual clock and drives the REAL
3
+ // `feature.bpmn` with `readinessProbes` seeded — the exact shape `startFeature` seeds for a gated
4
+ // submission — proving the leading readiness-preflight executes on the engine BEFORE the fan-out
5
+ // head (`ensure-base-branch`) and the implement agent:
6
+ // • GATED — a feature seeded with a probe that is ready runs the reused `pr.readiness-probe`
7
+ // worker inside the multi-instance preflight, releases through `pf_gw → pf_end`, and only THEN
8
+ // reaches `ensure-base-branch` and `implement-task` — it never implements before the gate is
9
+ // green, and never escalates.
10
+ // • UNGATED — a feature seeded with `readinessProbes = null` skips the gate entirely
11
+ // (`gw-readiness → ensure-base-branch`), implementing immediately as today's ungated features do.
12
+ //
13
+ // The probe is a deterministic shell builtin (`true`) with a bound artifact, so the gate itself is
14
+ // hermetic (no network, no GitHub). The fan-out head (`pr.ensure-base-branch`) that follows a green
15
+ // gate is handled by the shared hermetic admit-github stub (installAdmitGithub) like the sibling
16
+ // preflight e2e, so the whole flow runs offline. The implement agent (`senior:feature`) has no
17
+ // worker registered here, so the instance simply parks on `implement-task` after the gate — we
18
+ // assert on the cumulative taken sequence flows (the WASM engine folds completed variables away).
19
+ import assert from "node:assert/strict";
20
+ import { mkdtempSync, rmSync } from "node:fs";
21
+ import { tmpdir } from "node:os";
22
+ import { dirname, join, resolve } from "node:path";
23
+ import { after, before, describe, test } from "node:test";
24
+ import { fileURLToPath } from "node:url";
25
+ import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
26
+ import { admitGithubState, installAdmitGithub } from "./support/github-admit.ts";
27
+
28
+ const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
29
+
30
+ const GITHUB_ENV_OVERRIDES: Record<string, string> = {
31
+ NANO_PR_GITHUB_TRANSPORT: "token",
32
+ GITHUB_TOKEN: "",
33
+ };
34
+ const savedEnv = new Map<string, string | undefined>();
35
+
36
+ interface TakenFlow {
37
+ from: string;
38
+ to: string;
39
+ }
40
+
41
+ function takenFlows(app: TestApp): string[] {
42
+ const snapshot = app.snapshot();
43
+ const flows = Array.isArray(snapshot.takenSequenceFlows) ? snapshot.takenSequenceFlows : [];
44
+ return flows
45
+ .filter((f): f is TakenFlow => typeof f === "object" && f !== null && "from" in f && "to" in f)
46
+ .map((f) => `${f.from}->${f.to}`);
47
+ }
48
+
49
+ // The full variable set `startFeature` seeds onto a feature instance (app/feature.ts). We seed it
50
+ // directly so we can inject `readinessProbes` (and the derived `probeTimeout`/`gateKey`) without
51
+ // standing up a whole upstream-dependency set. `baseBranch` is the admit-github default branch, so
52
+ // the fan-out head reads it without creating a ref.
53
+ function featureVars(overrides: Record<string, unknown>): Record<string, unknown> {
54
+ return {
55
+ featureKey: "owner/repo#7",
56
+ repo: "owner/repo",
57
+ issue: "owner/repo#7",
58
+ issueNumber: 7,
59
+ issueUrl: "https://github.com/owner/repo/issues/7",
60
+ task: {
61
+ id: "issue-7",
62
+ title: "owner/repo#7",
63
+ prompt: "Implement the GitHub issue owner/repo#7 end to end.",
64
+ },
65
+ converge: true,
66
+ autoMerge: false,
67
+ claimIssue: true,
68
+ answer: null,
69
+ status: null,
70
+ question: null,
71
+ summary: null,
72
+ pr: null,
73
+ escalationSlaTimeout: "PT24H",
74
+ escalationAssignee: null,
75
+ baseBranch: "main",
76
+ baseBranchBrief: "",
77
+ customInstructions: null,
78
+ readinessProbes: null,
79
+ probeTimeout: null,
80
+ gateKey: null,
81
+ resolvedArtifacts: null,
82
+ ...overrides,
83
+ };
84
+ }
85
+
86
+ async function boot(): Promise<{ app: TestApp; dbDir: string }> {
87
+ const dbDir = mkdtempSync(join(tmpdir(), "nwf-feature-preflight-"));
88
+ const app = await bootTestApp(APP_ROOT, { env: { NANO_APP_DB_URL: `file:${join(dbDir, "app.db")}` } });
89
+ return { app, dbDir };
90
+ }
91
+
92
+ describe("single-issue feature intake readiness gate (feature.bpmn, issue #295)", () => {
93
+ let restoreGithub: (() => void) | undefined;
94
+
95
+ before(() => {
96
+ for (const [k, v] of Object.entries(GITHUB_ENV_OVERRIDES)) {
97
+ savedEnv.set(k, process.env[k]);
98
+ process.env[k] = v;
99
+ }
100
+ // `pr.ensure-base-branch` reads the base ref via the token transport, which would throw
101
+ // `no GitHub transport available` under an empty token. Pin the shared hermetic admit-github
102
+ // stub (dummy token + fetch intercept) like the sibling preflight e2e so base-branch admission
103
+ // is deterministic and offline.
104
+ restoreGithub = installAdmitGithub(admitGithubState("owner/repo", "main"));
105
+ });
106
+ after(() => {
107
+ restoreGithub?.();
108
+ for (const [k, v] of savedEnv) {
109
+ if (v === undefined) delete process.env[k];
110
+ else process.env[k] = v;
111
+ }
112
+ });
113
+
114
+ test("GATED: a feature with a green probe parks on the preflight, releases green, and only THEN implements", async () => {
115
+ const { app, dbDir } = await boot();
116
+ try {
117
+ const { processInstanceKey } = await app.engine.createInstance({
118
+ processDefinitionId: "feature",
119
+ variables: featureVars({
120
+ // The shape `startFeature` seeds for a gated submission — here a hermetic green probe that
121
+ // binds a version, standing in for the `capability` probe (whose green/bind path is
122
+ // unit-tested in featureReadiness.test).
123
+ readinessProbes: [
124
+ {
125
+ kind: "command",
126
+ target: "true",
127
+ resolvedArtifact: "@scope/pkg@1.4.0",
128
+ poll: { everyMs: 5, timeoutMs: 5000, backoff: "fixed" },
129
+ },
130
+ ],
131
+ probeTimeout: "PT30M",
132
+ gateKey: "feature-readiness:owner/repo#7",
133
+ }),
134
+ });
135
+ await app.settle();
136
+
137
+ const flows = takenFlows(app);
138
+ // The feature was gated: it entered the preflight (not the ungated skip) and released green.
139
+ assert.ok(
140
+ flows.includes("gw-readiness->readiness-preflight"),
141
+ `a gated feature enters the preflight (flows: ${flows.join(", ")})`,
142
+ );
143
+ assert.ok(flows.includes("pf_gw->pf_end"), "the probe went green and settled the preflight");
144
+ // Only AFTER the gate does it reach ensure-base-branch and then implement-task — the gate is a
145
+ // true PREFLIGHT, not a parallel afterthought.
146
+ assert.ok(
147
+ flows.includes("readiness-preflight->ensure-base-branch"),
148
+ "the green gate leads into the fan-out head",
149
+ );
150
+ assert.ok(
151
+ flows.includes("ensure-base-branch->implement-task"),
152
+ `the run reaches the implement agent only after the gate (flows: ${flows.join(", ")})`,
153
+ );
154
+ // A green probe never escalates.
155
+ const tasks = await app.engine.searchUserTasks({ processInstanceKey });
156
+ assert.equal(
157
+ tasks.filter((t) => t.elementId === "readiness-escalation-pf").length,
158
+ 0,
159
+ "a green preflight never opens an escalation task",
160
+ );
161
+ } finally {
162
+ await app.stop();
163
+ rmSync(dbDir, { recursive: true, force: true });
164
+ }
165
+ });
166
+
167
+ test("UNGATED: readinessProbes = null skips the gate and implements immediately", async () => {
168
+ const { app, dbDir } = await boot();
169
+ try {
170
+ await app.engine.createInstance({
171
+ processDefinitionId: "feature",
172
+ variables: featureVars({ featureKey: "owner/repo#8", issue: "owner/repo#8", readinessProbes: null }),
173
+ });
174
+ await app.settle();
175
+
176
+ const flows = takenFlows(app);
177
+ assert.ok(
178
+ flows.includes("gw-readiness->ensure-base-branch"),
179
+ `an ungated feature skips straight to the fan-out head (flows: ${flows.join(", ")})`,
180
+ );
181
+ assert.ok(!flows.includes("gw-readiness->readiness-preflight"), "an ungated feature never enters the preflight");
182
+ assert.ok(
183
+ flows.includes("ensure-base-branch->implement-task"),
184
+ "an ungated feature reaches the implement agent",
185
+ );
186
+ } finally {
187
+ await app.stop();
188
+ rmSync(dbDir, { recursive: true, force: true });
189
+ }
190
+ });
191
+ });