@nanobpm/nano-workforce 0.80.0 → 0.81.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [0.81.0](https://github.com/nanobpm/nano-workforce/compare/v0.80.0...v0.81.0) (2026-08-17)
2
+
3
+
4
+ ### Features
5
+
6
+ * durable artifact-readiness wait-gate primitive (ADR 0001 §2) ([#260](https://github.com/nanobpm/nano-workforce/issues/260)) ([e787488](https://github.com/nanobpm/nano-workforce/commit/e787488c074b9e4e44946a4144b814b34caffe57)), closes [#258](https://github.com/nanobpm/nano-workforce/issues/258) [#259](https://github.com/nanobpm/nano-workforce/issues/259) [#258](https://github.com/nanobpm/nano-workforce/issues/258)
7
+
1
8
  # [0.80.0](https://github.com/nanobpm/nano-workforce/compare/v0.79.0...v0.80.0) (2026-08-17)
2
9
 
3
10
 
package/app/contracts.ts CHANGED
@@ -208,6 +208,22 @@ export const ENV_CONTRACTS = {
208
208
  "SLA timeout for an agent (service) task before its boundary timer fires and the PR escalates for human attention (ISO-8601 duration). A malformed value falls back to the default.",
209
209
  default: "PT2H",
210
210
  },
211
+ NANO_READINESS_POLL_TIMEOUT: {
212
+ category: "env",
213
+ name: "NANO_READINESS_POLL_TIMEOUT",
214
+ owner: "app/readiness.ts",
215
+ semantics:
216
+ "Default bounded timeout (FEEL/ISO-8601 duration) for a ReadinessProbe wait-gate when the probe descriptor declares no poll.timeoutMs. The gate's event-based-gateway timer arm fires after it and escalates, so a probe that never goes green can never wedge a plan. A malformed value falls back to the default.",
217
+ default: "PT30M",
218
+ },
219
+ NANO_READINESS_POLL_EVERY_MS: {
220
+ category: "env",
221
+ name: "NANO_READINESS_POLL_EVERY_MS",
222
+ owner: "workers/readiness-probe/worker.ts",
223
+ semantics:
224
+ "Default interval in milliseconds between ReadinessProbe attempts when the probe descriptor declares no poll.everyMs.",
225
+ default: "15000",
226
+ },
211
227
  NANO_APP_DB_URL: {
212
228
  category: "env",
213
229
  name: "NANO_APP_DB_URL",
@@ -259,6 +275,13 @@ export const ENV_CONTRACTS = {
259
275
  /** The set of declared config-key names — the single typed vocabulary of env keys. */
260
276
  export type EnvKey = keyof typeof ENV_CONTRACTS;
261
277
 
278
+ /** Whether `name` is a declared {@link EnvKey}. A runtime-narrowing guard so a value carried in as
279
+ * a plain string (e.g. a probe descriptor's `credentialEnv`) can be validated against the ONE
280
+ * schema before it is read through {@link readEnv} — an undeclared key is rejected, never read. */
281
+ export function isEnvKey(name: string): name is EnvKey {
282
+ return Object.hasOwn(ENV_CONTRACTS, name);
283
+ }
284
+
262
285
  /** Every declared env contract, widened to {@link EnvContract} (assignment-widening — no `as`), so
263
286
  * callers can read the optional `default`/`rejectedSynonyms`/`secret` fields on any entry. */
264
287
  export function envContracts(): EnvContract[] {
@@ -0,0 +1,300 @@
1
+ // Unit coverage for the ReadinessProbe core (app/readiness.ts, issue #258 / ADR 0001 §2).
2
+ //
3
+ // The gate's engine model is proven end-to-end in e2e/readiness-gate.e2e.ts; these tests pin the
4
+ // pure surface: descriptor parse/validation, each kind's matcher, the injectable `probeOnce`
5
+ // dispatch (no network / subprocess), backoff, the ms→ISO timeout derivation, and log redaction.
6
+ import { test } from "node:test";
7
+ import { assert, assertEquals, assertRejects, assertStringIncludes, assertThrows } from "#test-assert";
8
+ import {
9
+ type CommandResult,
10
+ DEFAULT_ATTEMPT_TIMEOUT_MS,
11
+ DEFAULT_EVERY_MS,
12
+ DEFAULT_TIMEOUT_MS,
13
+ defaultProbeExec,
14
+ type HttpResponse,
15
+ MAX_EVERY_MS,
16
+ matchCommand,
17
+ matchGithubCheck,
18
+ matchHttp,
19
+ matchNpm,
20
+ msToIsoDuration,
21
+ nextDelay,
22
+ normalizePoll,
23
+ parseProbe,
24
+ parseRepoRef,
25
+ probeBudgetMs,
26
+ probeOnce,
27
+ type ProbeExec,
28
+ readinessTimeout,
29
+ readinessTimeoutMs,
30
+ redactString,
31
+ redactTarget,
32
+ } from "./readiness.ts";
33
+
34
+ // A ProbeExec stub: canned http/command responses, capturing the last command it was asked to run.
35
+ function stubExec(opts: { http?: HttpResponse; command?: CommandResult; capture?: { cmd?: string; headers?: Record<string, string> } }): ProbeExec {
36
+ return {
37
+ async httpGet(_url, headers) {
38
+ if (opts.capture) opts.capture.headers = headers;
39
+ return opts.http ?? { status: 0, body: "" };
40
+ },
41
+ async run(command) {
42
+ if (opts.capture) opts.capture.cmd = command;
43
+ return opts.command ?? { code: 0, stdout: "", stderr: "" };
44
+ },
45
+ };
46
+ }
47
+
48
+ // ── parseProbe ──────────────────────────────────────────────────────────────────────────────
49
+ test("parseProbe: accepts a minimal http probe and defaults onTimeout to escalate", () => {
50
+ const p = parseProbe({ kind: "http", target: "https://x/health" });
51
+ assertEquals(p.kind, "http");
52
+ assertEquals(p.target, "https://x/health");
53
+ assertEquals(p.onTimeout, "escalate");
54
+ });
55
+
56
+ test("parseProbe: rejects an unknown kind", () => {
57
+ assertThrows(() => parseProbe({ kind: "oci", target: "img:tag" }), Error, "unknown kind");
58
+ });
59
+
60
+ test("parseProbe: rejects a blank target", () => {
61
+ assertThrows(() => parseProbe({ kind: "command", target: " " }), Error, "'target' is required");
62
+ });
63
+
64
+ test("parseProbe: rejects an invalid onTimeout", () => {
65
+ assertThrows(() => parseProbe({ kind: "http", target: "x", onTimeout: "retry" }), Error, "invalid onTimeout");
66
+ });
67
+
68
+ test("parseProbe: rejects an invalid poll.backoff (a malformed probe must fail loudly, never silently default)", () => {
69
+ assertThrows(
70
+ () => parseProbe({ kind: "http", target: "x", poll: { backoff: "linear" } }),
71
+ Error,
72
+ "invalid backoff",
73
+ );
74
+ });
75
+
76
+ test("parseProbe: rejects an undeclared credentialEnv (a probe must never inline a secret)", () => {
77
+ assertThrows(
78
+ () => parseProbe({ kind: "http", target: "x", credentialEnv: "MY_SECRET" }),
79
+ Error,
80
+ "not a declared env-contract key",
81
+ );
82
+ });
83
+
84
+ test("parseProbe: rejects a credentialEnv on a non-http kind (a subprocess probe never consumes it)", () => {
85
+ assertThrows(
86
+ () => parseProbe({ kind: "github-check", target: "o/r@abc", credentialEnv: "GITHUB_TOKEN" }),
87
+ Error,
88
+ "only supported for the 'http' kind",
89
+ );
90
+ });
91
+
92
+ test("parseProbe: accepts a declared credentialEnv (http) and parses nested match/poll", () => {
93
+ const p = parseProbe({
94
+ kind: "http",
95
+ target: "https://x/health",
96
+ credentialEnv: "GITHUB_TOKEN",
97
+ match: { status: 200, checkName: "build" },
98
+ poll: { everyMs: 1000, timeoutMs: 60000, backoff: "fixed" },
99
+ });
100
+ assertEquals(p.credentialEnv, "GITHUB_TOKEN");
101
+ assertEquals(p.match?.checkName, "build");
102
+ assertEquals(p.poll?.backoff, "fixed");
103
+ });
104
+
105
+ // ── matchers ────────────────────────────────────────────────────────────────────────────────
106
+ test("matchHttp: any 2xx is ready by default; a 503 is not", () => {
107
+ assert(matchHttp(undefined, { status: 204, body: "" }).ready);
108
+ assert(!matchHttp(undefined, { status: 503, body: "" }).ready);
109
+ });
110
+
111
+ test("matchHttp: an explicit status + bodyIncludes are both required", () => {
112
+ const m = { status: 200, bodyIncludes: "OK" };
113
+ assert(matchHttp(m, { status: 200, body: "all OK here" }).ready);
114
+ assert(!matchHttp(m, { status: 200, body: "degraded" }).ready);
115
+ assert(!matchHttp(m, { status: 201, body: "OK" }).ready);
116
+ });
117
+
118
+ test("matchCommand: exit 0 is ready by default; stdoutIncludes narrows it", () => {
119
+ assert(matchCommand(undefined, { code: 0, stdout: "", stderr: "" }).ready);
120
+ assert(!matchCommand(undefined, { code: 1, stdout: "", stderr: "" }).ready);
121
+ assert(matchCommand({ stdoutIncludes: "ready" }, { code: 0, stdout: "svc ready", stderr: "" }).ready);
122
+ assert(!matchCommand({ stdoutIncludes: "ready" }, { code: 0, stdout: "starting", stderr: "" }).ready);
123
+ });
124
+
125
+ test("matchNpm: a printed version means published; a failed view is not-ready", () => {
126
+ assert(matchNpm(undefined, "pkg@1.2.3", { code: 0, stdout: "1.2.3\n", stderr: "" }).ready);
127
+ assert(!matchNpm(undefined, "pkg@1.2.3", { code: 1, stdout: "", stderr: "E404" }).ready);
128
+ assert(!matchNpm(undefined, "pkg@1.2.3", { code: 0, stdout: "", stderr: "" }).ready);
129
+ });
130
+
131
+ test("matchNpm: the version in pkg@version must match the printed version", () => {
132
+ assert(!matchNpm(undefined, "pkg@2.0.0", { code: 0, stdout: "1.9.9", stderr: "" }).ready);
133
+ assert(matchNpm(undefined, "pkg@2.0.0", { code: 0, stdout: "2.0.0", stderr: "" }).ready);
134
+ });
135
+
136
+ test("matchGithubCheck: all runs must be completed+success; a pending run is not-ready", () => {
137
+ const green = { check_runs: [{ name: "build", status: "completed", conclusion: "success" }] };
138
+ const pending = { check_runs: [{ name: "build", status: "in_progress", conclusion: "" }] };
139
+ assert(matchGithubCheck(undefined, green).ready);
140
+ assert(!matchGithubCheck(undefined, pending).ready);
141
+ assert(!matchGithubCheck(undefined, { check_runs: [] }).ready);
142
+ });
143
+
144
+ test("matchGithubCheck: checkName restricts the predicate to that run", () => {
145
+ const payload = {
146
+ check_runs: [
147
+ { name: "build", status: "completed", conclusion: "success" },
148
+ { name: "flaky", status: "completed", conclusion: "failure" },
149
+ ],
150
+ };
151
+ assert(matchGithubCheck({ checkName: "build" }, payload).ready);
152
+ assert(!matchGithubCheck({ checkName: "flaky" }, payload).ready);
153
+ assert(!matchGithubCheck({ checkName: "missing" }, payload).ready);
154
+ });
155
+
156
+ // ── probeOnce dispatch (injected exec — no I/O) ───────────────────────────────────────────────
157
+ test("probeOnce http: injects a Bearer credential from the declared env-contract, redacting nothing into the target", async () => {
158
+ const cap: { headers?: Record<string, string> } = {};
159
+ const exec = stubExec({ http: { status: 200, body: "ok" }, capture: cap });
160
+ const p = parseProbe({ kind: "http", target: "https://x/health", credentialEnv: "GITHUB_TOKEN" });
161
+ const res = await probeOnce(p, exec, { GITHUB_TOKEN: "tkn" });
162
+ assert(res.ready);
163
+ assertEquals(cap.headers?.authorization, "Bearer tkn");
164
+ });
165
+
166
+ test("probeOnce npm: builds a quoted `npm view … version` command", async () => {
167
+ const cap: { cmd?: string } = {};
168
+ const exec = stubExec({ command: { code: 0, stdout: "1.0.0", stderr: "" }, capture: cap });
169
+ const res = await probeOnce(parseProbe({ kind: "npm", target: "@scope/pkg@1.0.0" }), exec, {});
170
+ assert(res.ready);
171
+ assertStringIncludes(cap.cmd ?? "", "npm view '@scope/pkg@1.0.0' version");
172
+ });
173
+
174
+ test("probeOnce github-check: parses gh api JSON and requires success", async () => {
175
+ const cap: { cmd?: string } = {};
176
+ const exec = stubExec({
177
+ command: { code: 0, stdout: JSON.stringify({ check_runs: [{ name: "ci", status: "completed", conclusion: "success" }] }), stderr: "" },
178
+ capture: cap,
179
+ });
180
+ const res = await probeOnce(parseProbe({ kind: "github-check", target: "o/r@main" }), exec, {});
181
+ assert(res.ready);
182
+ assertStringIncludes(cap.cmd ?? "", "repos/o/r/commits/main/check-runs");
183
+ });
184
+
185
+ test("probeOnce github-check: a failed gh api call is not-ready (never throws)", async () => {
186
+ const exec = stubExec({ command: { code: 1, stdout: "", stderr: "not found" } });
187
+ const res = await probeOnce(parseProbe({ kind: "github-check", target: "o/r@main" }), exec, {});
188
+ assert(!res.ready);
189
+ });
190
+
191
+ // ── backoff + poll normalisation ──────────────────────────────────────────────────────────────
192
+ test("normalizePoll: fills defaults and clamps everyMs to the ceiling", () => {
193
+ const d = normalizePoll(undefined);
194
+ assertEquals(d.everyMs, DEFAULT_EVERY_MS);
195
+ assertEquals(d.timeoutMs, DEFAULT_TIMEOUT_MS);
196
+ assertEquals(d.backoff, "exponential");
197
+ assertEquals(normalizePoll({ everyMs: 10 * 60_000 }).everyMs, MAX_EVERY_MS);
198
+ });
199
+
200
+ test("nextDelay: fixed returns everyMs; exponential doubles and clamps", () => {
201
+ const fixed = normalizePoll({ everyMs: 1000, backoff: "fixed" });
202
+ assertEquals(nextDelay(1, fixed), 1000);
203
+ assertEquals(nextDelay(5, fixed), 1000);
204
+ const exp = normalizePoll({ everyMs: 1000, backoff: "exponential" });
205
+ assertEquals(nextDelay(1, exp), 1000);
206
+ assertEquals(nextDelay(3, exp), 4000);
207
+ assertEquals(nextDelay(30, exp), MAX_EVERY_MS);
208
+ });
209
+
210
+ // ── timeout derivation ────────────────────────────────────────────────────────────────────────
211
+ test("msToIsoDuration: rounds up to whole seconds, never zero", () => {
212
+ assertEquals(msToIsoDuration(1000), "PT1S");
213
+ assertEquals(msToIsoDuration(1500), "PT2S");
214
+ assertEquals(msToIsoDuration(1), "PT1S");
215
+ });
216
+
217
+ test("readinessTimeout: derives from poll.timeoutMs, else the env default, else PT30M", () => {
218
+ assertEquals(readinessTimeout(parseProbe({ kind: "http", target: "x", poll: { timeoutMs: 60000 } }), {}), "PT60S");
219
+ assertEquals(readinessTimeout(parseProbe({ kind: "http", target: "x" }), {}), "PT30M");
220
+ assertEquals(
221
+ readinessTimeout(parseProbe({ kind: "http", target: "x" }), { NANO_READINESS_POLL_TIMEOUT: "PT5M" }),
222
+ "PT5M",
223
+ );
224
+ });
225
+
226
+ test("readinessTimeoutMs: the ms twin of readinessTimeout — same precedence, no drift with the gate timer", () => {
227
+ // Declared budget is taken verbatim in ms (the gate rounds it up to whole seconds for its ISO timer).
228
+ assertEquals(readinessTimeoutMs(parseProbe({ kind: "http", target: "x", poll: { timeoutMs: 60000 } }), {}), 60000);
229
+ // Omitted + no env → the built-in default (30m), matching readinessTimeout's PT30M.
230
+ assertEquals(readinessTimeoutMs(parseProbe({ kind: "http", target: "x" }), {}), 1_800_000);
231
+ // Omitted + env → the env budget in ms. Regression: this used to fall back to the hard-coded 30m,
232
+ // stranding the worker while the gate timer waited the full env budget.
233
+ assertEquals(
234
+ readinessTimeoutMs(parseProbe({ kind: "http", target: "x" }), { NANO_READINESS_POLL_TIMEOUT: "PT2H" }),
235
+ 7_200_000,
236
+ );
237
+ });
238
+
239
+ test("probeBudgetMs: prefers the seeded probeTimeout (the gate timer's bound), falling back to the env twin", () => {
240
+ const probe = parseProbe({ kind: "http", target: "x" });
241
+ // The seeded probeTimeout wins over the ambient env — binding worker and engine to ONE per-instance
242
+ // value: a stale env can't shorten the worker while the engine timer waits the seeded budget.
243
+ assertEquals(probeBudgetMs("PT45M", probe, { NANO_READINESS_POLL_TIMEOUT: "PT1M" }), 2_700_000);
244
+ // Absent/blank probeTimeout → fall back to the env-derived twin (readinessTimeoutMs).
245
+ assertEquals(probeBudgetMs(undefined, probe, { NANO_READINESS_POLL_TIMEOUT: "PT2H" }), 7_200_000);
246
+ assertEquals(probeBudgetMs(" ", probe, {}), 1_800_000);
247
+ // A malformed seeded value degrades to the built-in default (30m), matching isoDurationToMs.
248
+ assertEquals(probeBudgetMs("nonsense", probe, { NANO_READINESS_POLL_TIMEOUT: "PT1M" }), 1_800_000);
249
+ });
250
+
251
+ // ── repo/ref parse + redaction ──────────────────────────────────────────────────────────────
252
+ test("parseRepoRef: splits owner/repo@ref and defaults the ref to HEAD", () => {
253
+ assertEquals(parseRepoRef("o/r@abc123"), { repo: "o/r", ref: "abc123" });
254
+ assertEquals(parseRepoRef("o/r"), { repo: "o/r", ref: "HEAD" });
255
+ });
256
+
257
+ test("redactString/redactTarget: strip userinfo and query (a token often rides either)", () => {
258
+ assertEquals(redactString("https://user:pass@host/path?token=abc"), "https://***@host/path?***");
259
+ assertStringIncludes(redactTarget(parseProbe({ kind: "http", target: "https://h/p?tok=s3cr3t" })), "?***");
260
+ const t = redactTarget(parseProbe({ kind: "http", target: "https://h/p?tok=s3cr3t" }));
261
+ assert(!t.includes("s3cr3t"), "the secret must not survive redaction");
262
+ });
263
+
264
+ test("redactTarget: a command target is never logged — only the kind + a fixed placeholder", () => {
265
+ const ct = redactTarget(parseProbe({ kind: "command", target: "curl -H 'Authorization: Bearer s3cr3t' https://h/p" }));
266
+ assertEquals(ct, "command:<redacted>");
267
+ assert(!ct.includes("s3cr3t"), "an arbitrary shell snippet's secrets must never survive to a log line");
268
+ });
269
+
270
+ // ── default ProbeExec: every attempt is bounded (a stuck probe can never hang the worker) ─────
271
+ test("defaultProbeExec.run: a command that outlives the attempt timeout resolves bounded, non-zero", async () => {
272
+ const exec = defaultProbeExec(50);
273
+ const start = Date.now();
274
+ const out = await exec.run("sleep 5", process.env);
275
+ const elapsed = Date.now() - start;
276
+ assert(out.code !== 0, "a killed (timed-out) command must report a non-zero exit code, i.e. not ready");
277
+ assert(elapsed < 4000, `the attempt must resolve in bounded time, not run to completion (took ${elapsed}ms)`);
278
+ });
279
+
280
+ test("defaultProbeExec.httpGet: a hung endpoint aborts at the attempt timeout instead of hanging forever", async () => {
281
+ const { createServer } = await import("node:http");
282
+ const server = createServer(() => {
283
+ /* never responds — the request hangs until the client aborts */
284
+ });
285
+ await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
286
+ const addr = server.address();
287
+ const port = typeof addr === "object" && addr ? addr.port : 0;
288
+ try {
289
+ const exec = defaultProbeExec(50);
290
+ const start = Date.now();
291
+ await assertRejects(() => exec.httpGet(`http://127.0.0.1:${port}/`, {}));
292
+ assert(Date.now() - start < 4000, "the fetch must abort at the attempt deadline, not hang");
293
+ } finally {
294
+ server.close();
295
+ }
296
+ });
297
+
298
+ test("DEFAULT_ATTEMPT_TIMEOUT_MS is a sane bounded default", () => {
299
+ assert(DEFAULT_ATTEMPT_TIMEOUT_MS > 0 && DEFAULT_ATTEMPT_TIMEOUT_MS <= 5 * 60_000);
300
+ });