@nanobpm/nano-workforce 0.150.0 → 0.150.1

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,9 @@
1
+ ## [0.150.1](https://github.com/nanobpm/nano-workforce/compare/v0.150.0...v0.150.1) (2026-08-28)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **startFeature:** thread probePollEvery through the readiness gate ([#579](https://github.com/nanobpm/nano-workforce/issues/579)) ([#582](https://github.com/nanobpm/nano-workforce/issues/582)) ([d8f7739](https://github.com/nanobpm/nano-workforce/commit/d8f773907e1c66292fa6799f70e8d957f6f5f626)), closes [#295](https://github.com/nanobpm/nano-workforce/issues/295)
6
+
1
7
  ## [0.150.0](https://github.com/nanobpm/nano-workforce/compare/v0.149.0...v0.150.0) (2026-08-28)
2
8
 
3
9
  ### Features
@@ -0,0 +1,195 @@
1
+ // Integration coverage for the intake READINESS gate (issue #295) driven through the operation EDGE —
2
+ // `startFeature` → `parseFeatureReadiness` → the started run's variables. The unit tests in
3
+ // app/featureReadiness.test.ts already prove the parser derives `probes`/`probeTimeout`/`probePollEvery`
4
+ // correctly in isolation, and app/feature.test.ts proves `startFeature` seeds them onto the run. What
5
+ // nothing asserted — and what regressed in issue #579 — is that the OPERATION threads the parser's
6
+ // output through to `startFeature` intact: a too-narrow local dropped `probePollEvery` on the floor, so
7
+ // every gated start (`blockedOn`/`readiness`) 500'd on startFeature's invariant. This file locks the
8
+ // composed door behaviour: a gated start returns 202 and the run it fans out carries non-blank bounds.
9
+ import { test } from "node:test";
10
+ import { assertEquals } from "#test-assert";
11
+ import type { AppApi } from "@nanobpm/urban";
12
+ import { resetDefaultBranchCache } from "../app/github.ts";
13
+ import { noopLog } from "../test/log.ts";
14
+ import { withTrackingViews } from "../test/trackingViews.ts";
15
+ import startFeature from "./startFeature.ts";
16
+
17
+ // ── in-memory github model (default branch = main, so `confirmDefaultBase` is required) ───────────
18
+ function githubFetch(repo: string) {
19
+ return (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
20
+ const u = new URL(String(url));
21
+ const method = (init?.method ?? "GET").toUpperCase();
22
+ const path = u.pathname;
23
+ const json = (obj: unknown, status = 200) =>
24
+ new Response(JSON.stringify(obj), { status, headers: { "content-type": "application/json" } });
25
+ if (method === "GET" && path === `/repos/${repo}`) return Promise.resolve(json({ default_branch: "main" }));
26
+ const refPrefix = `/repos/${repo}/git/ref/heads/`;
27
+ if (method === "GET" && path.startsWith(refPrefix)) {
28
+ const branch = decodeURIComponent(path.slice(refPrefix.length));
29
+ if (branch !== "main") return Promise.resolve(new Response("Not Found", { status: 404 }));
30
+ return Promise.resolve(json({ ref: `refs/heads/${branch}`, object: { sha: `${branch}-sha` } }));
31
+ }
32
+ return Promise.resolve(new Response(`unexpected ${method} ${path}`, { status: 500 }));
33
+ };
34
+ }
35
+
36
+ async function withGithub<T>(repo: string, fn: () => Promise<T>): Promise<T> {
37
+ const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
38
+ const prevTok = process.env["GITHUB_TOKEN"];
39
+ const prevFetch = globalThis.fetch;
40
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
41
+ process.env["GITHUB_TOKEN"] = "tok";
42
+ resetDefaultBranchCache();
43
+ globalThis.fetch = githubFetch(repo) as typeof fetch;
44
+ try {
45
+ return await fn();
46
+ } finally {
47
+ resetDefaultBranchCache();
48
+ globalThis.fetch = prevFetch;
49
+ if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
50
+ else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
51
+ if (prevTok === undefined) delete process.env["GITHUB_TOKEN"];
52
+ else process.env["GITHUB_TOKEN"] = prevTok;
53
+ }
54
+ }
55
+
56
+ // ── in-memory app (data + engine) ────────────────────────────────────────────
57
+ // `started` records each engine.createInstance call so a test can assert the run's seeded variables.
58
+ function makeApp() {
59
+ const tables = new Map<string, Record<string, unknown>[]>();
60
+ const started: { processDefinitionId?: string; variables?: Record<string, unknown> }[] = [];
61
+ const table = (name: string, key: string) => {
62
+ const rows = tables.get(name) ?? (() => {
63
+ const fresh: Record<string, unknown>[] = [];
64
+ tables.set(name, fresh);
65
+ return fresh;
66
+ })();
67
+ return {
68
+ get: (k: unknown) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
69
+ find: (q: Record<string, unknown>) =>
70
+ Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
71
+ findOne: (q: Record<string, unknown>) =>
72
+ Promise.resolve(rows.find((r) => Object.entries(q).every(([f, v]) => r[f] === v)) ?? null),
73
+ insert: (r: Record<string, unknown>) => {
74
+ rows.push(r);
75
+ return Promise.resolve(r);
76
+ },
77
+ update: (k: unknown, patch: Record<string, unknown>) => {
78
+ const row = rows.find((r) => r[key] === k);
79
+ if (row) Object.assign(row, patch);
80
+ return Promise.resolve(row);
81
+ },
82
+ delete: (k: unknown) => {
83
+ const i = rows.findIndex((r) => r[key] === k);
84
+ if (i >= 0) rows.splice(i, 1);
85
+ return Promise.resolve();
86
+ },
87
+ };
88
+ };
89
+ const app = {
90
+ data: { table: withTrackingViews(table) },
91
+ engine: {
92
+ createInstance: (req: { processDefinitionId?: string; variables?: Record<string, unknown> }) => {
93
+ started.push(req);
94
+ return Promise.resolve({ processInstanceKey: "PI-F1" });
95
+ },
96
+ },
97
+ log: noopLog(),
98
+ } as any as AppApi;
99
+ return { app, started };
100
+ }
101
+
102
+ function input(body: unknown) {
103
+ return {
104
+ req: { method: "POST", path: "/", query: new URLSearchParams(), headers: new Headers(), text: async () => "" } as any,
105
+ params: {},
106
+ query: {},
107
+ body,
108
+ };
109
+ }
110
+
111
+ const REPO = "owner/repo";
112
+ const GATED_BASE = { baseBranch: "main", confirmDefaultBase: true } as const;
113
+
114
+ // ── the #579 regression: a gated start must reach 202 AND thread the bounds through ───────────────
115
+
116
+ test("blockedOn gate → 202 and the started run carries non-blank probeTimeout + probePollEvery", async () => {
117
+ await withGithub(REPO, async () => {
118
+ const { app, started } = makeApp();
119
+ const res = (await startFeature(
120
+ input({ issue: `${REPO}#577`, ...GATED_BASE, blockedOn: [`${REPO}#578`] }),
121
+ app,
122
+ )) as any;
123
+ assertEquals(res.status, 202);
124
+ assertEquals(started.length, 1);
125
+ const v = started[0].variables as Record<string, unknown>;
126
+ // The regressed field: it was dropped by a too-narrow local, so the run seeded a blank cadence and
127
+ // startFeature's invariant threw → 500. Both bounds must arrive non-blank.
128
+ assertEquals((v.probeTimeout as string).trim().length > 0, true);
129
+ assertEquals((v.probePollEvery as string).trim().length > 0, true);
130
+ assertEquals(Array.isArray(v.readinessProbes) && (v.readinessProbes as unknown[]).length === 1, true);
131
+ });
132
+ });
133
+
134
+ test("explicit readiness descriptor list → 202 with both bounds threaded to the run", async () => {
135
+ await withGithub(REPO, async () => {
136
+ const { app, started } = makeApp();
137
+ const res = (await startFeature(
138
+ input({
139
+ issue: `${REPO}#577`,
140
+ ...GATED_BASE,
141
+ readiness: [{ kind: "command", target: "gh api repos/owner/repo/issues/578 --jq .state", match: { stdoutIncludes: "closed" } }],
142
+ }),
143
+ app,
144
+ )) as any;
145
+ assertEquals(res.status, 202);
146
+ const v = started[0].variables as Record<string, unknown>;
147
+ assertEquals((v.probeTimeout as string).trim().length > 0, true);
148
+ assertEquals((v.probePollEvery as string).trim().length > 0, true);
149
+ });
150
+ });
151
+
152
+ test("blockedOn + consumerPackage (capability edge) → 202 with both bounds threaded", async () => {
153
+ await withGithub(REPO, async () => {
154
+ const { app, started } = makeApp();
155
+ const res = (await startFeature(
156
+ input({ issue: `${REPO}#577`, ...GATED_BASE, blockedOn: [`${REPO}#578`], consumerPackage: "@nanobpm/engine-wasm" }),
157
+ app,
158
+ )) as any;
159
+ assertEquals(res.status, 202);
160
+ const v = started[0].variables as Record<string, unknown>;
161
+ assertEquals((v.probeTimeout as string).trim().length > 0, true);
162
+ assertEquals((v.probePollEvery as string).trim().length > 0, true);
163
+ const probes = v.readinessProbes as { kind?: string }[];
164
+ assertEquals(probes[0]?.kind, "capability");
165
+ });
166
+ });
167
+
168
+ // ── regression: an UNGATED start still passes null/absent for both bounds (gate skipped) ──────────
169
+
170
+ test("no readiness ⇒ 202 and the run seeds null probeTimeout + probePollEvery (gate skipped)", async () => {
171
+ await withGithub(REPO, async () => {
172
+ const { app, started } = makeApp();
173
+ const res = (await startFeature(input({ issue: `${REPO}#577`, ...GATED_BASE }), app)) as any;
174
+ assertEquals(res.status, 202);
175
+ const v = started[0].variables as Record<string, unknown>;
176
+ assertEquals(v.probeTimeout, null);
177
+ assertEquals(v.probePollEvery, null);
178
+ assertEquals(v.readinessProbes, null);
179
+ });
180
+ });
181
+
182
+ // ── a malformed gate is a caller-meaningful 400, never a 500 ──────────────────────────────────────
183
+
184
+ test("malformed readiness descriptor → 400 at the edge (never a 500)", async () => {
185
+ await withGithub(REPO, async () => {
186
+ const { app, started } = makeApp();
187
+ const res = (await startFeature(
188
+ input({ issue: `${REPO}#577`, ...GATED_BASE, blockedOn: [""] }),
189
+ app,
190
+ )) as any;
191
+ assertEquals(res.status, 400);
192
+ assertEquals(typeof res.body.error, "string");
193
+ assertEquals(started.length, 0);
194
+ });
195
+ });
@@ -13,7 +13,7 @@
13
13
  // confirm-default / shared-base rules, with the same typed-error → HTTP mapping.
14
14
 
15
15
  import { startFeature } from "../app/feature.ts";
16
- import { parseFeatureReadiness } from "../app/featureReadiness.ts";
16
+ import { type FeatureReadiness, parseFeatureReadiness } from "../app/featureReadiness.ts";
17
17
  import { BaseBranchMustExistError } from "../app/github.ts";
18
18
  import {
19
19
  admitPlan,
@@ -23,7 +23,6 @@ import {
23
23
  parseIssue,
24
24
  SharedBaseError,
25
25
  } from "../app/plan.ts";
26
- import type { ReadinessProbe } from "../app/readiness.ts";
27
26
  import { defineOperation } from "../nano-generated/operations.ts";
28
27
 
29
28
  export default defineOperation("startFeature", async ({ body }, app) => {
@@ -126,7 +125,12 @@ export default defineOperation("startFeature", async ({ body }, app) => {
126
125
  // `blockedOn` shorthand (resolved against `consumerPackage`) into the probes + bound the run parks
127
126
  // on before implementing. A malformed gate (bad descriptor, unparseable handle, blank package) is a
128
127
  // 400 at the edge — it must never wait forever at runtime.
129
- let readiness: { probes: ReadinessProbe[]; probeTimeout: string | null };
128
+ // Type the local as the parser's OWN return type (not a hand-written subset): `parseFeatureReadiness`
129
+ // derives `probes`, `probeTimeout` AND `probePollEvery` together, and all three must be threaded to
130
+ // the run. A narrower local silently drops a field the parser produced (issue #579: `probePollEvery`
131
+ // was dropped, so every gated start 500'd on startFeature's invariant) without TypeScript flagging it,
132
+ // because the narrower shape is structurally assignable from the wider return.
133
+ let readiness: FeatureReadiness;
130
134
  try {
131
135
  readiness = parseFeatureReadiness({
132
136
  readiness: "readiness" in body ? body.readiness : undefined,
@@ -138,6 +142,31 @@ export default defineOperation("startFeature", async ({ body }, app) => {
138
142
  app.log.warn("start-feature rejected: invalid readiness gate", { message });
139
143
  return { status: 400, body: { error: message } };
140
144
  }
145
+ // Validate the gate's timing bounds at the EDGE, before dispatch: a non-empty probe set is
146
+ // load-bearing together with a non-blank `probeTimeout` (preflight escalation timers + pr.readiness-probe)
147
+ // and `probePollEvery` (preflight retry cadence). `parseFeatureReadiness` always derives all three
148
+ // together, so this only fires for a mis-derived/hand-seeded gate — but validating here turns that
149
+ // into a caller-meaningful 400 rather than a bare-Error 500 from startFeature's internal invariant.
150
+ if (readiness.probes.length > 0) {
151
+ const missingBounds: string[] = [];
152
+ if ((readiness.probeTimeout ?? "").trim() === "") missingBounds.push("a timeout");
153
+ if ((readiness.probePollEvery ?? "").trim() === "") missingBounds.push("a poll cadence");
154
+ if (missingBounds.length > 0) {
155
+ app.log.warn("start-feature rejected: readiness gate missing timing bound", {
156
+ missing: missingBounds,
157
+ probes: readiness.probes.length,
158
+ });
159
+ return {
160
+ status: 400,
161
+ body: {
162
+ error:
163
+ `readiness gate is malformed: ${readiness.probes.length} probe(s) but the request did not ` +
164
+ `resolve to ${missingBounds.join(" and ")}. A gated start (readiness/blockedOn) must resolve ` +
165
+ `to a non-blank timeout and poll cadence`,
166
+ },
167
+ };
168
+ }
169
+ }
141
170
  const result = await startFeature(
142
171
  app.data,
143
172
  app.engine,
@@ -146,7 +175,7 @@ export default defineOperation("startFeature", async ({ body }, app) => {
146
175
  converge,
147
176
  autoMerge,
148
177
  customInstructions,
149
- { probes: readiness.probes, probeTimeout: readiness.probeTimeout },
178
+ { probes: readiness.probes, probeTimeout: readiness.probeTimeout, probePollEvery: readiness.probePollEvery },
150
179
  );
151
180
  app.log.info("feature run started", {
152
181
  featureKey: parsed.planKey,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.150.0",
3
+ "version": "0.150.1",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",