@nanobpm/nano-workforce 0.102.1 → 0.104.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,17 @@
1
+ # [0.104.0](https://github.com/nanobpm/nano-workforce/compare/v0.103.0...v0.104.0) (2026-08-19)
2
+
3
+
4
+ ### Features
5
+
6
+ * **durable-resume:** enrolment gate + re-lease world-restore wiring ([#325](https://github.com/nanobpm/nano-workforce/issues/325)) ([#351](https://github.com/nanobpm/nano-workforce/issues/351)) ([b495dfa](https://github.com/nanobpm/nano-workforce/commit/b495dfabfda3e9e0953c6637342b79ca0c8148cc))
7
+
8
+ # [0.103.0](https://github.com/nanobpm/nano-workforce/compare/v0.102.1...v0.103.0) (2026-08-19)
9
+
10
+
11
+ ### Features
12
+
13
+ * **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)
14
+
1
15
  ## [0.102.1](https://github.com/nanobpm/nano-workforce/compare/v0.102.0...v0.102.1) (2026-08-19)
2
16
 
3
17
 
package/app/contracts.ts CHANGED
@@ -402,6 +402,14 @@ export const TYPE_CONTRACTS = {
402
402
  "The mind/world checkpoint contract shape (issue #324, ADR 0062 Slice 4/5). `{ commitSha, effectLedger }` — the ONE type both the world marker (recorded in `world_checkpoints`/`world_effects`) and the mind checkpoint (Slice 1's `session.checkpoint`) derive from, so a single derivation feeds both halves and they cannot diverge. Its `effectLedger` is `Effect[]` (the fence-keyed irreversible-action ledger). The world half imports it from app/world; when Slice 1's harness-side `@nanobpm/agentic/session` lands it MUST reuse this shape, not re-declare a synonym.",
403
403
  module: "app/world/checkpoint.ts",
404
404
  },
405
+ DurableResumeRegistry: {
406
+ category: "type",
407
+ name: "DurableResumeRegistry",
408
+ owner: "app/durableResume.ts",
409
+ semantics:
410
+ "The `durable-resume` ENROLMENT GATE (issue #325, ADR 0062 Slice 5/5, the INTEGRATION slice). `durable-resume` is a worker attribute declared at enrolment (ADR 0056 §7 — capability gates enrolment, NEVER the routing token `network.role#seat`), recorded per worker instance in `worker_durable_resume` (migration 052). The enrol door (`operations/enrolAgenticWorker.ts`) records it via `recordEnrolment`; `app/service.ts` consults `fleetSupportsDurableResume` before emitting the world-restore `commitSha` (the `io.nanobpm.agentTask.repository` envelope) so a re-leased `senior:pr-review` round RESUMES only on a participating fleet and gracefully DEGRADES (redriven from scratch) otherwise. Consume this ONE module for the durable-resume gate — do not re-declare a synonym or read the flag off a second store.",
411
+ module: "app/durableResume.ts",
412
+ },
405
413
  } as const satisfies Record<string, TypeContract>;
406
414
 
407
415
  export const CAPABILITY_URL_CONTRACTS = {
@@ -0,0 +1,89 @@
1
+ // Tests for the `durable-resume` enrolment registry (issue #325, ADR 0062 Slice 5/5) against a REAL
2
+ // in-memory SQLite engine with migration 052 applied — so the upsert, the {0,1} flag domain, and the
3
+ // fleet-level participation probe are proven, not mocked.
4
+ import { test } from "node:test";
5
+ import { assert, assertEquals } from "#test-assert";
6
+ import { memDataFor } from "../test/worldDb.ts";
7
+ import { DurableResumeRegistry, DURABLE_RESUME_ATTR, fleetSupportsDurableResume } from "./durableResume.ts";
8
+
9
+ const mem = () => memDataFor(["052_worker_durable_resume.sql"]);
10
+
11
+ test("DURABLE_RESUME_ATTR is the canonical enrolment-attribute name", () => {
12
+ assertEquals(DURABLE_RESUME_ATTR, "durable-resume");
13
+ });
14
+
15
+ test("recordEnrolment persists a participant and isParticipant reads it back", async () => {
16
+ const { data } = mem();
17
+ const reg = new DurableResumeRegistry(data);
18
+ assertEquals(await reg.isParticipant("w1"), false, "unknown instance is a non-participant (safe default)");
19
+ await reg.recordEnrolment("w1", true);
20
+ assertEquals(await reg.isParticipant("w1"), true);
21
+ });
22
+
23
+ test("recordEnrolment records an explicit non-participant as false", async () => {
24
+ const { data } = mem();
25
+ const reg = new DurableResumeRegistry(data);
26
+ await reg.recordEnrolment("w1", false);
27
+ assertEquals(await reg.isParticipant("w1"), false);
28
+ assertEquals(await reg.anyParticipant(), false, "a recorded non-participant is not a participant");
29
+ });
30
+
31
+ test("recordEnrolment is an idempotent upsert — a re-enrol overwrites the flag", async () => {
32
+ const { data } = mem();
33
+ const reg = new DurableResumeRegistry(data);
34
+ await reg.recordEnrolment("w1", true);
35
+ assertEquals(await reg.isParticipant("w1"), true);
36
+ // A redeploy that drops durable-resume support flips the flag back — no duplicate row, no stale yes.
37
+ await reg.recordEnrolment("w1", false);
38
+ assertEquals(await reg.isParticipant("w1"), false);
39
+ await reg.recordEnrolment("w1", true);
40
+ assertEquals(await reg.isParticipant("w1"), true);
41
+ });
42
+
43
+ test("the registry normalises keys and ignores a blank/whitespace instance", async () => {
44
+ const { data } = mem();
45
+ const reg = new DurableResumeRegistry(data);
46
+ // A blank or whitespace-only key cannot key a reachable row and must never open the fleet gate.
47
+ await reg.recordEnrolment("", true);
48
+ await reg.recordEnrolment(" ", true);
49
+ assertEquals(await reg.anyParticipant(), false, "a blank/whitespace enrolment is ignored, gate stays closed");
50
+ assertEquals(await reg.isParticipant(" "), false, "a blank key is never a participant");
51
+ // A padded key is canonicalised (trimmed) so reads and writes agree on one row — no unreachable dup.
52
+ await reg.recordEnrolment(" w1 ", true);
53
+ assertEquals(await reg.isParticipant("w1"), true, "a padded write is readable by the trimmed key");
54
+ assertEquals(await reg.isParticipant(" w1 "), true, "a padded read normalises to the same row");
55
+ });
56
+
57
+ test("anyParticipant is the fleet-level existence probe over participants", async () => {
58
+ const { data } = mem();
59
+ const reg = new DurableResumeRegistry(data);
60
+ assertEquals(await reg.anyParticipant(), false, "no enrolment yet");
61
+ await reg.recordEnrolment("legacy-1", false);
62
+ await reg.recordEnrolment("legacy-2", false);
63
+ assertEquals(await reg.anyParticipant(), false, "a fleet of only non-participants does not support resume");
64
+ await reg.recordEnrolment("modern-1", true);
65
+ assertEquals(await reg.anyParticipant(), true, "one participant makes the mixed fleet resume-capable");
66
+ });
67
+
68
+ test("fleetSupportsDurableResume mirrors anyParticipant, and degrades to false without a data layer", async () => {
69
+ const { data } = mem();
70
+ assertEquals(await fleetSupportsDurableResume(undefined), false, "no data layer → additive-safe false");
71
+ assertEquals(await fleetSupportsDurableResume(data), false, "no participant enrolled");
72
+ await new DurableResumeRegistry(data).recordEnrolment("w1", true);
73
+ assertEquals(await fleetSupportsDurableResume(data), true);
74
+ });
75
+
76
+ test("fleetSupportsDurableResume degrades to false on a store read failure (legacy DB predating 052)", async () => {
77
+ // A DataLayer whose table has no `worker_durable_resume` — the read throws; the gate must degrade to
78
+ // false (redrive from scratch) rather than blocking a submit/merge on the enrolment registry.
79
+ const { data } = memDataFor([]);
80
+ assertEquals(await fleetSupportsDurableResume(data), false);
81
+ });
82
+
83
+ test("the flag domain is pinned to {0,1} — a participant reads as exactly true", async () => {
84
+ const { data, db } = mem();
85
+ await new DurableResumeRegistry(data).recordEnrolment("w1", true);
86
+ const rows = db.prepare("SELECT durable_resume FROM worker_durable_resume WHERE instance = ?").all("w1");
87
+ assertEquals(rows.length, 1);
88
+ assert(rows[0].durable_resume === 1, "true is stored as the integer 1");
89
+ });
@@ -0,0 +1,141 @@
1
+ // nano-workforce — the `durable-resume` ENROLMENT GATE (issue #325, ADR 0062 Slice 5/5).
2
+ //
3
+ // Durable agent-session resume splits into two halves already landed by the earlier slices: the MIND
4
+ // (the harness conversation — Slices 1–3, restored harness-side via `session/load` / native
5
+ // `--resume`) and the WORLD (the git working tree + irreversible effect ledger — Slice 4,
6
+ // `app/world`, restored by inverting the round's `git push` into `git fetch && git checkout <sha>`).
7
+ // This slice is the INTEGRATION that wires both halves into the running orchestration, behind an
8
+ // enrolment gate, so a re-leased `senior:pr-review` round (ADR 0002 lease-expiry redrive) RESUMES at
9
+ // the last push-checkpoint on a participating harness and gracefully DEGRADES — redriven from scratch,
10
+ // exactly as today — on a harness that does not advertise durable-resume.
11
+ //
12
+ // THE GATE (ADR 0056 §7). `durable-resume` is a WORKER ATTRIBUTE declared at ENROLMENT — never a
13
+ // routing token. The routing token `network.role#seat` is unchanged; there is no BPMN change and no
14
+ // job-type change. A worker's harness advertises durable-resume at enrol (the probe result from Slice
15
+ // 2/3); this registry records that per instance so the app can ask, before it emits the world-restore
16
+ // marker, "does the fleet serving this role include a participant?".
17
+ //
18
+ // WHY FLEET-LEVEL. At the moment the app emits the repo-provisioning envelope it does not yet know
19
+ // WHICH worker will lease the `senior:pr-review` job — any worker enrolled for that role may. So the
20
+ // gate is a fleet-level existence probe ({@link DurableResumeRegistry.anyParticipant}). This is
21
+ // well-defined for a MIXED fleet: emitting the world-restore `commitSha` when at least one participant
22
+ // is enrolled lets a participant RESUME, while a non-participant harness simply ignores the marker
23
+ // (the envelope validators are structurally forward-compatible) and clones the head branch tip —
24
+ // redriving from scratch. When NO participant is enrolled the marker is omitted entirely, so nothing
25
+ // regresses: resume is purely additive.
26
+ //
27
+ // Advisory, app-tier only (ADR 0056): this registry NEVER hard-locks or gates a BPMN sequence flow —
28
+ // it only decides whether an OPTIMISATION (world-restore) is offered inside an activation.
29
+ import type { DataLayer } from "@nanobpm/urban";
30
+
31
+ /**
32
+ * The canonical name of the durable-resume enrolment attribute. A worker advertises it at enrol; the
33
+ * registry records it here. It is an ENROLMENT gate (ADR 0056 §7), never a routing token — do not put
34
+ * it in `network.role#seat`.
35
+ */
36
+ export const DURABLE_RESUME_ATTR = "durable-resume";
37
+
38
+ /** A persisted enrolment row (`worker_durable_resume`): one worker instance's durable-resume flag. */
39
+ interface WorkerDurableResumeRow {
40
+ instance: string;
41
+ durable_resume: number;
42
+ updated_at: string;
43
+ }
44
+
45
+ /** True when `err` is the durable PRIMARY KEY fence firing — a SQLite `UNIQUE constraint failed`
46
+ * raised because a concurrent/duplicate enrol inserted the SAME instance BETWEEN our `findOne` and our
47
+ * `insert`. `recordEnrolment` is an upsert, so a collision means "the row now exists" — the same
48
+ * intended outcome as the update branch, not a surfaced error. Matched on the message substring the
49
+ * RAD `Table` surface propagates verbatim (mirrors `WorldStore`), because that surface hides the
50
+ * concrete driver error type. */
51
+ function isFenceCollision(err: unknown): boolean {
52
+ return err instanceof Error && /UNIQUE constraint failed/i.test(err.message);
53
+ }
54
+
55
+ /**
56
+ * The durable registry of per-worker durable-resume participation, over the `worker_durable_resume`
57
+ * table (`db/migrations/052_worker_durable_resume.sql`). Backed by the app's SQLite DataLayer through
58
+ * the RAD `Table<T>` surface (`data.table(...)`) — NOT hand-written SQL — mirroring `WorldStore`.
59
+ */
60
+ export class DurableResumeRegistry {
61
+ readonly #data: DataLayer;
62
+
63
+ constructor(data: DataLayer) {
64
+ this.#data = data;
65
+ }
66
+
67
+ #table() {
68
+ return this.#data.table<WorkerDurableResumeRow>("worker_durable_resume", "instance");
69
+ }
70
+
71
+ /** Canonicalise an instance key: trim surrounding whitespace and reject a blank one. The instance is
72
+ * the table PRIMARY KEY and drives the fleet-wide gate via {@link anyParticipant}, so a whitespace or
73
+ * differently-trimmed key would create an unreachable row — or, worse, open the gate on a blank key.
74
+ * Normalising here makes every registry entry point safe by default rather than relying on each call
75
+ * site to pre-trim. Returns the trimmed key, or `undefined` when it is empty/whitespace. */
76
+ static #normaliseInstance(instance: string): string | undefined {
77
+ const trimmed = instance.trim();
78
+ return trimmed.length > 0 ? trimmed : undefined;
79
+ }
80
+
81
+ /**
82
+ * Record a worker's durable-resume participation at enrolment (an idempotent UPSERT keyed by
83
+ * `instance`). A re-enrol overwrites the flag so a harness that gains — or loses — durable-resume
84
+ * support across a redeploy is reflected. The `findOne`-then-insert is racy under a concurrent
85
+ * duplicate enrol, so a PRIMARY KEY fence collision folds into the update path rather than surfacing
86
+ * as an error (the same end-state either way). A blank/whitespace `instance` is ignored (no-op) — it
87
+ * cannot key a reachable row and a blank key would let unrelated workers collide on one registry row.
88
+ */
89
+ async recordEnrolment(instance: string, durableResume: boolean): Promise<void> {
90
+ const key = DurableResumeRegistry.#normaliseInstance(instance);
91
+ if (key === undefined) return;
92
+ const table = this.#table();
93
+ const now = new Date().toISOString();
94
+ const flag = durableResume ? 1 : 0;
95
+ const existing = await table.findOne({ instance: key });
96
+ if (existing) {
97
+ await table.update(key, { durable_resume: flag, updated_at: now });
98
+ return;
99
+ }
100
+ try {
101
+ await table.insert({ instance: key, durable_resume: flag, updated_at: now });
102
+ } catch (err) {
103
+ if (!isFenceCollision(err)) throw err;
104
+ await table.update(key, { durable_resume: flag, updated_at: now });
105
+ }
106
+ }
107
+
108
+ /** Whether a specific worker instance is a durable-resume participant. `false` for an unknown
109
+ * instance (never enrolled) or a blank/whitespace key — the safe default (graceful degradation). */
110
+ async isParticipant(instance: string): Promise<boolean> {
111
+ const key = DurableResumeRegistry.#normaliseInstance(instance);
112
+ if (key === undefined) return false;
113
+ const row = await this.#table().findOne({ instance: key });
114
+ return row?.durable_resume === 1;
115
+ }
116
+
117
+ /** Whether the enrolled fleet includes AT LEAST ONE durable-resume participant — the fleet-level
118
+ * gate the world-restore emission consults. `false` when none is enrolled (nobody advertises
119
+ * durable-resume), so the resume marker is omitted and the round redrives from scratch. */
120
+ async anyParticipant(): Promise<boolean> {
121
+ const row = await this.#table().findOne({ durable_resume: 1 });
122
+ return row != null;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Whether the fleet supports durable resume — the app-tier gate for emitting the world-restore
128
+ * `commitSha` (see `app/service.ts`). Best-effort: any read failure (a legacy DB predating migration
129
+ * 052, an in-flight desync) degrades to `false`, so the round redrives from scratch rather than
130
+ * blocking a submit/merge on the enrolment registry. When no data layer is mounted it is likewise
131
+ * `false` — resume is purely additive, so its absence is always the safe direction.
132
+ */
133
+ export async function fleetSupportsDurableResume(data: DataLayer | undefined): Promise<boolean> {
134
+ if (!data) return false;
135
+ try {
136
+ return await new DurableResumeRegistry(data).anyParticipant();
137
+ } catch (err) {
138
+ console.warn(`[durable-resume] fleet participation read: ${err}`);
139
+ return false;
140
+ }
141
+ }
@@ -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
+ });