@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.
@@ -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,66 @@
1
+ // Regression guard for migration 052 (issue #325, ADR 0062 Slice 5/5): the durable `durable-resume`
2
+ // enrolment registry. The table is FK-free (enrolment is per-worker and connection-agnostic, with no
3
+ // `pull_requests`/`plans` parent), the `instance` PRIMARY KEY makes `recordEnrolment` an idempotent
4
+ // upsert, and `CHECK(durable_resume IN (0,1))` pins the gate's boolean domain.
5
+ import { readFileSync } from "node:fs";
6
+ import { DatabaseSync } from "node:sqlite";
7
+ import test from "node:test";
8
+ import { fileURLToPath } from "node:url";
9
+ import { assertEquals, assertThrows } from "#test-assert";
10
+
11
+ function migratedDb(): DatabaseSync {
12
+ const db = new DatabaseSync(":memory:");
13
+ db.exec("PRAGMA foreign_keys = ON;");
14
+ // Deliberately NO parent tables — the enrolment table must apply and accept rows on a bare db.
15
+ const sql = readFileSync(fileURLToPath(new URL("../db/migrations/052_worker_durable_resume.sql", import.meta.url)), "utf8");
16
+ db.exec(sql);
17
+ return db;
18
+ }
19
+
20
+ const upsert = (db: DatabaseSync, instance: string, flag: number) =>
21
+ db
22
+ .prepare(
23
+ `INSERT INTO worker_durable_resume (instance, durable_resume, updated_at) VALUES (?, ?, 't')
24
+ ON CONFLICT(instance) DO UPDATE SET durable_resume = excluded.durable_resume`,
25
+ )
26
+ .run(instance, flag);
27
+
28
+ test("migration 052 applies cleanly with NO parent tables (FK-free) and records an enrolment", () => {
29
+ const db = migratedDb();
30
+ upsert(db, "w1", 1);
31
+ const row = db.prepare("SELECT instance, durable_resume FROM worker_durable_resume WHERE instance = ?").get("w1") as {
32
+ instance: string;
33
+ durable_resume: number;
34
+ };
35
+ assertEquals(row.instance, "w1");
36
+ assertEquals(row.durable_resume, 1);
37
+ });
38
+
39
+ test("instance PRIMARY KEY makes a re-enrol an upsert (one row per worker, latest flag wins)", () => {
40
+ const db = migratedDb();
41
+ upsert(db, "w1", 1);
42
+ upsert(db, "w1", 0);
43
+ const count = Number((db.prepare("SELECT COUNT(*) c FROM worker_durable_resume").get() as { c: number }).c);
44
+ assertEquals(count, 1, "no duplicate row for one instance");
45
+ const row = db.prepare("SELECT durable_resume FROM worker_durable_resume WHERE instance = 'w1'").get() as { durable_resume: number };
46
+ assertEquals(row.durable_resume, 0, "the latest enrolment flag wins");
47
+ });
48
+
49
+ test("durable_resume defaults to 0 (a non-participant) when unset", () => {
50
+ const db = migratedDb();
51
+ db.prepare("INSERT INTO worker_durable_resume (instance, updated_at) VALUES ('w1', 't')").run();
52
+ const row = db.prepare("SELECT durable_resume FROM worker_durable_resume WHERE instance = 'w1'").get() as { durable_resume: number };
53
+ assertEquals(row.durable_resume, 0);
54
+ });
55
+
56
+ test("CHECK(durable_resume IN (0,1)): the gate's boolean domain is pinned at the schema", () => {
57
+ const db = migratedDb();
58
+ assertThrows(
59
+ () => db.prepare("INSERT INTO worker_durable_resume (instance, durable_resume, updated_at) VALUES ('w1', 2, 't')").run(),
60
+ undefined,
61
+ "CHECK constraint failed",
62
+ );
63
+ upsert(db, "yes", 1);
64
+ upsert(db, "no", 0);
65
+ assertEquals(Number((db.prepare("SELECT COUNT(*) c FROM worker_durable_resume").get() as { c: number }).c), 2);
66
+ });
@@ -7,7 +7,10 @@
7
7
  // GitHub transport forced off so it is hermetic.
8
8
  import { test } from "node:test";
9
9
  import { assertEquals } from "#test-assert";
10
- import { parsePr, pollCapabilityGatesImpl, pollIncidentsImpl, pollWaveGatesImpl, repoEnvelopeVars, startMerge, submitPr } from "./service.ts";
10
+ import { memDataFor } from "../test/worldDb.ts";
11
+ import { DurableResumeRegistry } from "./durableResume.ts";
12
+ import { WorldStore } from "./world/index.ts";
13
+ import { parsePr, pollCapabilityGatesImpl, pollIncidentsImpl, pollWaveGatesImpl, repoEnvelopeVars, startMerge, submitPr, worldRestoreSha } from "./service.ts";
11
14
 
12
15
  function memTable(rows: any[], key: string) {
13
16
  return {
@@ -519,6 +522,35 @@ test("repoEnvelopeVars emits commitSha only for a well-formed 40-hex SHA (world-
519
522
  assertEquals("commitSha" in none, false);
520
523
  });
521
524
 
525
+ // Durable-resume enrolment gate (issue #325, ADR 0062 Slice 5/5): `worldRestoreSha` — the seam
526
+ // `submitPr`/`startMerge` thread into `repoEnvelopeVars` — hands the harness the last push-checkpoint
527
+ // ONLY when the enrolled fleet advertises `durable-resume`. With no participant it degrades to null,
528
+ // so the round redrives from scratch (exactly as today). Proven against a REAL in-memory SQLite db
529
+ // with the world (049) + enrolment (052) schemas applied.
530
+ test("worldRestoreSha is gated on the durable-resume enrolment: participant → SHA, none → null", async () => {
531
+ const { data } = memDataFor(["049_world_checkpoint.sql", "052_worker_durable_resume.sql"]);
532
+ const PR = "owner/repo#7";
533
+ const sha = "77ee0993cc6ad4493da0f7551212ef16722135db";
534
+ await new WorldStore(data).recordCheckpoint({ prKey: PR, roundNo: 1, commitSha: sha });
535
+
536
+ // No participant enrolled yet — graceful degradation: no resume marker even though a checkpoint exists.
537
+ assertEquals(await worldRestoreSha(data, PR), null, "no participant → redrive from scratch");
538
+
539
+ // A non-participant enrolment still does not open the gate (a fleet of only non-participants).
540
+ await new DurableResumeRegistry(data).recordEnrolment("legacy-1", false);
541
+ assertEquals(await worldRestoreSha(data, PR), null, "only non-participants → still scratch");
542
+
543
+ // One participant makes the mixed fleet resume-capable: the checkpoint SHA is now emitted.
544
+ await new DurableResumeRegistry(data).recordEnrolment("modern-1", true);
545
+ assertEquals(await worldRestoreSha(data, PR), sha, "a participant → resume at the checkpoint SHA");
546
+ });
547
+
548
+ test("worldRestoreSha is null when a participant is enrolled but the PR has no checkpoint yet", async () => {
549
+ const { data } = memDataFor(["049_world_checkpoint.sql", "052_worker_durable_resume.sql"]);
550
+ await new DurableResumeRegistry(data).recordEnrolment("modern-1", true);
551
+ assertEquals(await worldRestoreSha(data, "owner/repo#8"), null, "nothing to reconstruct on a first activation");
552
+ });
553
+
522
554
  // `parsePr` is total on any input: it is called unguarded from several workers (progress-check,
523
555
  // persist-round, persist-escalation, record-dependency) with a process variable that a regression
524
556
  // — or an older in-flight instance — could carry as a non-string. `.trim()` on a non-string throws,
package/app/service.ts CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  UnresolvableCapabilityRefError,
23
23
  } from "./capabilityNeed.ts";
24
24
  import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
25
+ import { fleetSupportsDurableResume } from "./durableResume.ts";
25
26
  import { backfillFeatureStages, deriveFeatureDelivery, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRunStatus, featureEscalations, featureRuns } from "./feature.ts";
26
27
  import {
27
28
  classifyMergeability,
@@ -441,6 +442,19 @@ async function lastPushedSha(data: DataLayer, prKey: string): Promise<string | n
441
442
  }
442
443
  }
443
444
 
445
+ /** The world-restore SHA to emit into the repo-provisioning envelope for a PR, GATED on the
446
+ * `durable-resume` enrolment (issue #325, ADR 0062 Slice 5/5). Only when the enrolled fleet includes a
447
+ * durable-resume participant (`fleetSupportsDurableResume`) do we hand the harness the last
448
+ * push-checkpoint so a replacement activation RESUMES by reconstructing the exact pushed tree
449
+ * (inverting `git push` → `git fetch && git checkout <sha>`). With no participant the marker is
450
+ * omitted (`null`), so the round redrives from scratch — graceful degradation, exactly as today.
451
+ * Resume is purely additive: gating on the enrolment attribute, not a sequence flow, keeps the
452
+ * engine/C8 job protocol untouched (ADR 0056 boundary). */
453
+ export async function worldRestoreSha(data: DataLayer, prKey: string): Promise<string | null> {
454
+ if (!(await fleetSupportsDurableResume(data))) return null;
455
+ return lastPushedSha(data, prKey);
456
+ }
457
+
444
458
  /** Register a PR row (if new) and start the convergence process. Idempotent on prKey. Optional
445
459
  * `dependsOn` (explicit refs) is unioned with any `Depends-on:` line parsed from the PR body and
446
460
  * recorded as the PR's merge-stage dependency set. */
@@ -547,9 +561,11 @@ export async function submitPr(
547
561
  const abUrl = abandonUrl(abandonToken);
548
562
  // World-restore (issue #324, ADR 0062 Slice 4/5): a re-run of convergence for a PR that already
549
563
  // pushed is a resume — carry its last durable push-checkpoint so a replacement activation on a
550
- // fresh worktree reconstructs the tree to the EXACT pushed SHA. Absent (null) on a first submit,
551
- // which leaves the envelope unchanged.
552
- const worldSha = await lastPushedSha(data, parsed.prKey);
564
+ // fresh worktree reconstructs the tree to the EXACT pushed SHA. GATED (issue #325, Slice 5/5) on the
565
+ // fleet advertising `durable-resume`: with no participant it stays null, so the round redrives from
566
+ // scratch (graceful degradation). Absent (null) on a first submit, which leaves the envelope
567
+ // unchanged.
568
+ const worldSha = await worldRestoreSha(data, parsed.prKey);
553
569
  const { processInstanceKey } = await engine.createInstance({
554
570
  processDefinitionId: PROCESS_ID,
555
571
  variables: {
@@ -623,8 +639,10 @@ export async function startMerge(
623
639
  console.warn(`[startMerge] ${pr.prKey} head branch unresolved — merge-agent workspace won't be provisioned`);
624
640
  }
625
641
  // World-restore (issue #324): the merge stage runs on the same durable working tree; carry the
626
- // last push-checkpoint so a replacement fix-ci/rebase activation reconstructs the exact SHA.
627
- const worldSha = await lastPushedSha(data, pr.prKey);
642
+ // last push-checkpoint so a replacement fix-ci/rebase activation reconstructs the exact SHA. GATED
643
+ // (issue #325, Slice 5/5) on the fleet advertising `durable-resume` — otherwise null, so the merge
644
+ // agents redrive from scratch (graceful degradation).
645
+ const worldSha = await worldRestoreSha(data, pr.prKey);
628
646
  const { processInstanceKey } = await engine.createInstance({
629
647
  processDefinitionId: MERGE_PROCESS_ID,
630
648
  variables: {
@@ -0,0 +1,35 @@
1
+ -- 052_worker_durable_resume.sql — issue #325 (ADR 0062, Slice 5/5): the ENROLMENT GATE for durable
2
+ -- agent-session resume. Slices 1–4 built the mind (harness conversation) and world (git tree + effect
3
+ -- ledger) halves; this slice wires them into the running orchestration behind a `durable-resume`
4
+ -- enrolment gate so a re-leased `senior:pr-review` round RESUMES at the last push-checkpoint on a
5
+ -- participating harness, and gracefully DEGRADES (redriven from scratch, exactly as today) on one
6
+ -- that does not advertise it.
7
+ --
8
+ -- `durable-resume` is a WORKER ATTRIBUTE declared at enrolment (ADR 0056 §7 — capability gates
9
+ -- enrolment, it is NEVER in the routing token `network.role#seat`). The registry records, per worker
10
+ -- instance, whether that worker's harness advertises durable-resume (the probe result from Slice
11
+ -- 2/3). The world-restore `commitSha` is emitted into the repo-provisioning envelope ONLY when the
12
+ -- fleet includes a participant; a fleet with no participant emits no resume marker and clones the
13
+ -- head branch tip — the pre-#324 behaviour. Resume is purely additive, never a new sequence-flow
14
+ -- gate (ADR 0056 boundary): the engine/C8 job protocol is untouched.
15
+ --
16
+ -- One FK-free table keyed by the worker instance (`register.instance` / the enrol `instance`). It is
17
+ -- FK-free by design — enrolment is per-worker and connection-agnostic, with no `pull_requests`/`plans`
18
+ -- parent to reference. EXPAND (additive) phase: one new table + its index; nothing is dropped or
19
+ -- renamed. Numbered after the current highest prefix on origin/main (051). The runner wraps each file
20
+ -- in its own transaction, so this file must NOT contain BEGIN/COMMIT.
21
+
22
+ CREATE TABLE IF NOT EXISTS worker_durable_resume (
23
+ instance TEXT PRIMARY KEY, -- the worker instance id (enrol `instance` / register.instance)
24
+ durable_resume INTEGER NOT NULL DEFAULT 0, -- 1 when the worker's harness advertises durable-resume, else 0
25
+ updated_at TEXT NOT NULL,
26
+ -- `durable_resume` is a strict boolean domain — the gate reads it as "does this worker participate?",
27
+ -- so a stray value (a future writer bug, a corrupt row on this externalised enrolment boundary) would
28
+ -- make the gate mis-decide whether to emit the resume marker. Pin it to {0,1} at the schema.
29
+ CHECK (durable_resume IN (0, 1))
30
+ );
31
+
32
+ -- The gate asks "does the fleet include a durable-resume participant?" — an existence probe over the
33
+ -- participants. Index the flag so that lookup is a covered scan, not a table walk.
34
+ CREATE INDEX IF NOT EXISTS idx_worker_durable_resume_flag
35
+ ON worker_durable_resume(durable_resume);
@@ -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
+ });