@nanobpm/nano-workforce 0.150.0 → 0.150.2

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,15 @@
1
+ ## [0.150.2](https://github.com/nanobpm/nano-workforce/compare/v0.150.1...v0.150.2) (2026-08-28)
2
+
3
+ ### Bug Fixes
4
+
5
+ * honour X-Forwarded-Prefix in resolveApiBase so the operator guide baseUrl resolves behind the app-view proxy ([#580](https://github.com/nanobpm/nano-workforce/issues/580)) ([21fdeca](https://github.com/nanobpm/nano-workforce/commit/21fdecac2d7c383cca066fa47950d9b6d28f41fc)), closes [#578](https://github.com/nanobpm/nano-workforce/issues/578)
6
+
7
+ ## [0.150.1](https://github.com/nanobpm/nano-workforce/compare/v0.150.0...v0.150.1) (2026-08-28)
8
+
9
+ ### Bug Fixes
10
+
11
+ * **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)
12
+
1
13
  ## [0.150.0](https://github.com/nanobpm/nano-workforce/compare/v0.149.0...v0.150.0) (2026-08-28)
2
14
 
3
15
  ### Features
@@ -41,3 +41,59 @@ test("tolerates a leading slash on the mount suffix", () => {
41
41
  test("strips multiple trailing slashes after the mount suffix", () => {
42
42
  assertEquals(resolveApiBase(req({ host: "h" }, "/app/api/agent/skill///"), "agent/skill"), "http://h/app/api");
43
43
  });
44
+
45
+ test("prepends a validated x-forwarded-prefix to the reconstructed base", () => {
46
+ const r = req(
47
+ { host: "nano.ngrok-free.dev", "x-forwarded-prefix": "/console/app-view/Workforce" },
48
+ "/app/api/agent",
49
+ );
50
+ assertEquals(resolveApiBase(r, "agent"), "http://nano.ngrok-free.dev/console/app-view/Workforce/app/api");
51
+ });
52
+
53
+ test("normalises a trailing slash on x-forwarded-prefix", () => {
54
+ const r = req({ host: "h", "x-forwarded-prefix": "/console/app-view/Workforce/" }, "/app/api/agent");
55
+ assertEquals(resolveApiBase(r, "agent"), "http://h/console/app-view/Workforce/app/api");
56
+ });
57
+
58
+ test("ignores a x-forwarded-prefix carrying a scheme", () => {
59
+ const r = req({ host: "h", "x-forwarded-prefix": "https://evil.test" }, "/app/api/agent");
60
+ assertEquals(resolveApiBase(r, "agent"), "http://h/app/api");
61
+ });
62
+
63
+ test("ignores a x-forwarded-prefix carrying an authority", () => {
64
+ const r = req({ host: "h", "x-forwarded-prefix": "//evil.test" }, "/app/api/agent");
65
+ assertEquals(resolveApiBase(r, "agent"), "http://h/app/api");
66
+ });
67
+
68
+ test("ignores a x-forwarded-prefix with .. traversal", () => {
69
+ const r = req({ host: "h", "x-forwarded-prefix": "/a/../.." }, "/app/api/agent");
70
+ assertEquals(resolveApiBase(r, "agent"), "http://h/app/api");
71
+ });
72
+
73
+ test("ignores a x-forwarded-prefix with percent-encoded .. traversal", () => {
74
+ const r = req({ host: "h", "x-forwarded-prefix": "/a/%2e%2e/%2e%2e" }, "/app/api/agent");
75
+ assertEquals(resolveApiBase(r, "agent"), "http://h/app/api");
76
+ });
77
+
78
+ test("ignores a x-forwarded-prefix with a percent-encoded authority", () => {
79
+ const r = req({ host: "h", "x-forwarded-prefix": "/%2F%2Fevil.test" }, "/app/api/agent");
80
+ assertEquals(resolveApiBase(r, "agent"), "http://h/app/api");
81
+ });
82
+
83
+ test("ignores a relative (non-absolute) x-forwarded-prefix", () => {
84
+ const r = req({ host: "h", "x-forwarded-prefix": "console/app-view/Workforce" }, "/app/api/agent");
85
+ assertEquals(resolveApiBase(r, "agent"), "http://h/app/api");
86
+ });
87
+
88
+ test("prefix composes with x-forwarded-proto and x-forwarded-host", () => {
89
+ const r = req(
90
+ {
91
+ host: "internal",
92
+ "x-forwarded-host": "nano.ngrok-free.dev",
93
+ "x-forwarded-proto": "https",
94
+ "x-forwarded-prefix": "/console/app-view/Workforce",
95
+ },
96
+ "/app/api/agent/skill",
97
+ );
98
+ assertEquals(resolveApiBase(r, "agent/skill"), "https://nano.ngrok-free.dev/console/app-view/Workforce/app/api");
99
+ });
@@ -4,8 +4,9 @@
4
4
  // to the request base (getAgentInstructions, getAgentSkill, …) — per AGENTS.md "Derivation over
5
5
  // duplication: no drift surfaces", proxy-header handling and base-path stripping must not fork.
6
6
  //
7
- // Honour reverse-proxy forwarding headers; fall back to a localhost default when the Host header is
8
- // absent (e.g. a raw unit-test request).
7
+ // Honour reverse-proxy forwarding headers proto, host, and the external path prefix
8
+ // (X-Forwarded-Prefix, e.g. the console app-view proxy's "/console/app-view/{project}") — and fall
9
+ // back to a localhost default when the Host header is absent (e.g. a raw unit-test request).
9
10
 
10
11
  /**
11
12
  * Recover the control-API base from a request, stripping the operation's own mount suffix.
@@ -20,9 +21,29 @@ export function resolveApiBase(req: { path: string; headers: Headers }, mountSuf
20
21
  // x-forwarded-proto is user-controlled behind some proxies; only trust http/https.
21
22
  const proto = rawProto === "http" || rawProto === "https" ? rawProto : "http";
22
23
  const host = (req.headers.get("x-forwarded-host") ?? req.headers.get("host") ?? "").split(",")[0].trim();
24
+ // The external path prefix stripped by a reverse proxy (e.g. the console app-view proxy mounts us
25
+ // under "/console/app-view/{project}"). X-Forwarded-Prefix is the de-facto standard header for it.
26
+ // It is untrusted, proxy-supplied input that ends up in a URL handed to an agent, so validate it as
27
+ // strictly as x-forwarded-proto above: accept only an absolute path of URL-safe path characters —
28
+ // rejecting anything with a scheme, an authority ("//host"), or a "."/".." traversal segment — then
29
+ // drop trailing slashes so it composes cleanly with the base path. Anything else falls back to an
30
+ // empty prefix, i.e. today's behaviour.
31
+ //
32
+ // Percent-encoding can smuggle those forms past a literal check: "%2e%2e" decodes to "..", and
33
+ // "%2f%2f" decodes to an authority-introducing "//". So normalise the common encoded spellings of
34
+ // "." and "/" (case-insensitively) before rejecting dot-segments and "//"; the still-encoded raw
35
+ // value is what we reflect once it validates.
36
+ const rawPrefix = (req.headers.get("x-forwarded-prefix") ?? "").split(",")[0].trim();
37
+ const decodedPrefix = rawPrefix.replace(/%2e/gi, ".").replace(/%2f/gi, "/");
38
+ const prefix =
39
+ /^\/(?!\/)[A-Za-z0-9._~\-/%]*$/.test(rawPrefix) &&
40
+ !decodedPrefix.includes("//") &&
41
+ !/(^|\/)\.\.?(\/|$)/.test(decodedPrefix)
42
+ ? rawPrefix.replace(/\/+$/, "")
43
+ : "";
23
44
  // The op is mounted at "<base>/<mountSuffix>"; strip the trailing segments to recover the base path.
24
45
  const suffix = mountSuffix.replace(/^\/+/, "").replace(/\/+$/, "");
25
46
  const stripRe = new RegExp(`/${suffix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/*$`);
26
47
  const basePath = req.path.replace(stripRe, "") || "/app/api";
27
- return host ? `${proto}://${host}${basePath}` : `http://localhost:3000${basePath}`;
48
+ return host ? `${proto}://${host}${prefix}${basePath}` : `http://localhost:3000${prefix}${basePath}`;
28
49
  }
@@ -86,6 +86,30 @@ test("examples are keyed to the request's control-API base and leave no placehol
86
86
  assert(!md.includes("__ENGINE__"), "no unsubstituted __ENGINE__ placeholder");
87
87
  });
88
88
 
89
+ test("x-forwarded-prefix is prepended to the baseUrl and rendered examples", async () => {
90
+ const proxied = input(
91
+ {
92
+ host: "internal",
93
+ "x-forwarded-host": "nano.ngrok-free.dev",
94
+ "x-forwarded-proto": "https",
95
+ "x-forwarded-prefix": "/console/app-view/Workforce",
96
+ },
97
+ );
98
+ const body = (await handler(proxied, app)) as any;
99
+ assertEquals(body.body.baseUrl, "https://nano.ngrok-free.dev/console/app-view/Workforce/app/api");
100
+ const md = body.body.instructions as string;
101
+ assert(
102
+ md.includes("https://nano.ngrok-free.dev/console/app-view/Workforce/app/api/version"),
103
+ "prefixed base URL substituted into examples",
104
+ );
105
+ });
106
+
107
+ test("a hostile x-forwarded-prefix is ignored rather than reflected into the baseUrl", async () => {
108
+ const hostile = input({ host: "wf.example.com", "x-forwarded-prefix": "https://evil.test" });
109
+ const body = (await handler(hostile, app)) as any;
110
+ assertEquals(body.body.baseUrl, "http://wf.example.com/app/api", "hostile prefix falls back to today's behaviour");
111
+ });
112
+
89
113
  test("x-forwarded-proto is restricted to http/https", async () => {
90
114
  const spoofed = input({ host: "wf.example.com", "x-forwarded-proto": "javascript" });
91
115
  const body = (await handler(spoofed, app)) as any;
@@ -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.2",
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",