@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 +12 -0
- package/app/resolveApiBase.test.ts +56 -0
- package/app/resolveApiBase.ts +24 -3
- package/operations/getAgentInstructions.test.ts +24 -0
- package/operations/startFeature.readiness.integration.test.ts +195 -0
- package/operations/startFeature.ts +33 -4
- package/package.json +1 -1
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
|
+
});
|
package/app/resolveApiBase.ts
CHANGED
|
@@ -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
|
|
8
|
-
//
|
|
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
|
-
|
|
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.
|
|
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",
|