@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 +7 -0
- package/app/feature.test.ts +71 -0
- package/app/feature.ts +42 -0
- package/app/featureReadiness.test.ts +165 -0
- package/app/featureReadiness.ts +151 -0
- package/e2e/feature-preflight.e2e.ts +191 -0
- package/openapi.yaml +102 -2
- package/operations/startFeature.ts +29 -1
- package/package.json +1 -1
- package/resources/processes/feature.bpmn +300 -77
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
|
|
package/app/feature.test.ts
CHANGED
|
@@ -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
|
+
});
|