@nanobpm/nano-workforce 0.102.0 → 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 +14 -0
- package/SPEC.md +24 -2
- package/app/failingChecksSupersede.test.ts +93 -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/app/github.ts +69 -7
- package/app/mergeCiReattempt.test.ts +138 -0
- package/app/mergeRebaseArm.test.ts +5 -2
- 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/resources/processes/merge-loop.bpmn +162 -88
- package/resources/prompts/fix-ci.md +32 -12
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
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
|
+
|
|
8
|
+
## [0.102.1](https://github.com/nanobpm/nano-workforce/compare/v0.102.0...v0.102.1) (2026-08-19)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **merge-loop:** re-attempt merge for stale/transient CI instead of paging a human ([#348](https://github.com/nanobpm/nano-workforce/issues/348)) ([#350](https://github.com/nanobpm/nano-workforce/issues/350)) ([87ce2d6](https://github.com/nanobpm/nano-workforce/commit/87ce2d69e893b4b0f965f481d6c247fb8fcb84e8))
|
|
14
|
+
|
|
1
15
|
# [0.102.0](https://github.com/nanobpm/nano-workforce/compare/v0.101.1...v0.102.0) (2026-08-19)
|
|
2
16
|
|
|
3
17
|
|
package/SPEC.md
CHANGED
|
@@ -424,8 +424,30 @@ start ─► wait: deps merged ─► arm merge ─► wait: mergeable ─┬─
|
|
|
424
424
|
check names ride `appendPrompt`) to green the checks on the branch, then re-arms the
|
|
425
425
|
poller. It repeats while `ciFixRound < ciFixMax`
|
|
426
426
|
(`NANO_PR_MAX_CI_FIX_ROUNDS`, default 3; `0` disables). Only when the budget is
|
|
427
|
-
exhausted, the agent reports `blocked
|
|
428
|
-
|
|
427
|
+
exhausted, the agent reports `blocked` *and the PR is still blocked after a
|
|
428
|
+
ground-truth reconcile*, or the branch is in `conflict` does it fall through to the
|
|
429
|
+
human escalation path.
|
|
430
|
+
|
|
431
|
+
- **Stale / transient checks — re-attempt, don't escalate** (issue #348) — GitHub's CI
|
|
432
|
+
concurrency **cancels** a superseded workflow run while a newer run on the *identical
|
|
433
|
+
head SHA* takes over. Both land in the head's `statusCheckRollup` under the same check
|
|
434
|
+
name — the stale one stamped `CANCELLED`, the live one green. This is a
|
|
435
|
+
**CI-concurrency-cancellation drift class**, not a code defect, defended at three
|
|
436
|
+
layers so it never pages a human:
|
|
437
|
+
- **Derivation (root cause)** — the merge poller's check derivation collapses the
|
|
438
|
+
rollup to the **newest run per `(headSha, checkName)`** (`latestRunPerCheck`) before
|
|
439
|
+
classifying, so a `CANCELLED` run superseded by a newer green run is **not** counted
|
|
440
|
+
as a failing gate. The phantom `blocked` never arises, so `fix-ci` is not even armed.
|
|
441
|
+
- **Agent verdict** — when `fix-ci` pushes nothing because the failing checks are
|
|
442
|
+
stale/transient (head already green), it returns `status: "reattempt"` (with
|
|
443
|
+
`pushed: false`). That routes to `arm-merge` — the merge is simply re-queued from
|
|
444
|
+
ground truth. The prompt reserves `blocked` for a genuine human decision (a missing
|
|
445
|
+
secret, an un-fixable failure), never a self-healing PR.
|
|
446
|
+
- **Reconcile-before-escalate guard** — even a *mislabelled* `blocked` self-heals: a
|
|
447
|
+
`blocked` verdict with no push (`pushed != true`) reconciles **once** via ground
|
|
448
|
+
truth (`gw-ci-blocked` → `ci-reconcile`, which re-arms the canonical merge poller and
|
|
449
|
+
sets `ciBlockedReconciled`), and escalates only if the PR is **still** blocked on the
|
|
450
|
+
re-derived state.
|
|
429
451
|
|
|
430
452
|
- **Discovered dependency** — a `senior:fix-ci` or `senior:rebase` agent may find that
|
|
431
453
|
the PR cannot land because **another PR must merge first** (a required linked-issue
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Derivation-layer guard for the CI-concurrency-cancellation drift class (issue #348).
|
|
2
|
+
//
|
|
3
|
+
// GitHub's CI concurrency cancels a superseded workflow run while a newer run on the *identical
|
|
4
|
+
// head SHA* takes over. Both land in the head's `statusCheckRollup` under the same check name — the
|
|
5
|
+
// stale one stamped `CANCELLED`, the live one green. The merge poller's check derivation used to
|
|
6
|
+
// count that stale `CANCELLED` as a failing required check, so a PR whose head is actually green
|
|
7
|
+
// read as `blocked`, armed `senior:fix-ci`, which honestly pushed nothing and (pre-#348) returned
|
|
8
|
+
// `blocked` → a human merge-escalation for a self-healing PR.
|
|
9
|
+
//
|
|
10
|
+
// The fix collapses the rollup to the NEWEST run per check before classifying, so a superseded
|
|
11
|
+
// `CANCELLED` never counts. These are pure unit tests over the exported derivation helpers.
|
|
12
|
+
|
|
13
|
+
import { test } from "node:test";
|
|
14
|
+
import { assert, assertEquals } from "#test-assert";
|
|
15
|
+
import { allCheckNames, failingCheckNames, latestRunPerCheck } from "./github.ts";
|
|
16
|
+
|
|
17
|
+
test("a CANCELLED run superseded by a newer green run on the same head does not count as failing", () => {
|
|
18
|
+
const rollup = [
|
|
19
|
+
// The superseded run: GitHub CI concurrency cancelled it when a newer run started.
|
|
20
|
+
{ name: "engine-core", conclusion: "CANCELLED", startedAt: "2024-01-01T00:00:00Z", completedAt: "2024-01-01T00:01:00Z" },
|
|
21
|
+
// The live run on the identical head SHA: green.
|
|
22
|
+
{ name: "engine-core", conclusion: "SUCCESS", startedAt: "2024-01-01T00:02:00Z", completedAt: "2024-01-01T00:05:00Z" },
|
|
23
|
+
];
|
|
24
|
+
assertEquals(failingCheckNames(rollup), [], "the stale CANCELLED must not read as a failing gate");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("every CANCELLED-superseded required check on one head SHA is dropped (the #348 instance)", () => {
|
|
28
|
+
// The exact PR #887 evidence: four required checks each with a superseded CANCELLED + a newer
|
|
29
|
+
// green run on the same head. None must count as failing.
|
|
30
|
+
const names = ["engine-core", "engine-wasm read-model wasm32 type-check", "processos", "server"];
|
|
31
|
+
const rollup = names.flatMap((name) => [
|
|
32
|
+
{ name, conclusion: "CANCELLED", startedAt: "2024-01-01T00:00:00Z" },
|
|
33
|
+
{ name, conclusion: "SUCCESS", startedAt: "2024-01-01T00:02:00Z" },
|
|
34
|
+
]);
|
|
35
|
+
assertEquals(failingCheckNames(rollup), [], "no superseded CANCELLED may count as a failing check");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("a genuine failure on the newest run still counts (no false-negative)", () => {
|
|
39
|
+
const rollup = [
|
|
40
|
+
{ name: "engine-core", conclusion: "SUCCESS", startedAt: "2024-01-01T00:00:00Z" },
|
|
41
|
+
// Newest run genuinely failed — this must still be reported.
|
|
42
|
+
{ name: "engine-core", conclusion: "FAILURE", startedAt: "2024-01-01T00:02:00Z" },
|
|
43
|
+
];
|
|
44
|
+
assertEquals(failingCheckNames(rollup), ["engine-core"], "a real failure on the newest run must count");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("a lone CANCELLED with no superseding run still counts (nothing green replaced it)", () => {
|
|
48
|
+
const rollup = [{ name: "engine-core", conclusion: "CANCELLED", startedAt: "2024-01-01T00:00:00Z" }];
|
|
49
|
+
assertEquals(failingCheckNames(rollup), ["engine-core"], "an unsuperseded CANCELLED remains a failing gate");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("ties (missing timestamps) prefer the non-CANCELLED run so the real result wins", () => {
|
|
53
|
+
// GitHub sometimes omits run times; a superseded CANCELLED alongside a completed run must not
|
|
54
|
+
// shadow the real conclusion even when neither carries a timestamp.
|
|
55
|
+
const cancelledFirst = [
|
|
56
|
+
{ name: "server", conclusion: "CANCELLED" },
|
|
57
|
+
{ name: "server", conclusion: "SUCCESS" },
|
|
58
|
+
];
|
|
59
|
+
const successFirst = [
|
|
60
|
+
{ name: "server", conclusion: "SUCCESS" },
|
|
61
|
+
{ name: "server", conclusion: "CANCELLED" },
|
|
62
|
+
];
|
|
63
|
+
assertEquals(failingCheckNames(cancelledFirst), [], "CANCELLED-first tie resolves to the real (green) result");
|
|
64
|
+
assertEquals(failingCheckNames(successFirst), [], "success-first tie keeps the real (green) result");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("latestRunPerCheck keeps exactly one run per check name (newest)", () => {
|
|
68
|
+
const rollup = [
|
|
69
|
+
{ name: "a", conclusion: "CANCELLED", startedAt: "2024-01-01T00:00:00Z" },
|
|
70
|
+
{ name: "a", conclusion: "SUCCESS", startedAt: "2024-01-01T00:02:00Z" },
|
|
71
|
+
{ name: "b", conclusion: "FAILURE", startedAt: "2024-01-01T00:00:00Z" },
|
|
72
|
+
];
|
|
73
|
+
const latest = latestRunPerCheck(rollup);
|
|
74
|
+
assertEquals(latest.length, 2, "one run per distinct check name");
|
|
75
|
+
const a = latest.find((c) => c.name === "a");
|
|
76
|
+
assert(a && a.conclusion === "SUCCESS", "check `a` resolves to its newest (green) run");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("allCheckNames dedupes superseded reruns to a single name", () => {
|
|
80
|
+
const rollup = [
|
|
81
|
+
{ name: "engine-core", conclusion: "CANCELLED", startedAt: "2024-01-01T00:00:00Z" },
|
|
82
|
+
{ name: "engine-core", conclusion: "SUCCESS", startedAt: "2024-01-01T00:02:00Z" },
|
|
83
|
+
];
|
|
84
|
+
assertEquals(allCheckNames(rollup), ["engine-core"], "a superseded rerun must not double-list the check name");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("legacy StatusContext (state + context) supersession is handled by createdAt", () => {
|
|
88
|
+
const rollup = [
|
|
89
|
+
{ context: "ci/legacy", state: "ERROR", createdAt: "2024-01-01T00:00:00Z" },
|
|
90
|
+
{ context: "ci/legacy", state: "SUCCESS", createdAt: "2024-01-01T00:02:00Z" },
|
|
91
|
+
];
|
|
92
|
+
assertEquals(failingCheckNames(rollup), [], "a superseded legacy status context is not a failing gate");
|
|
93
|
+
});
|
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
|
+
}
|