@nanobpm/nano-workforce 0.80.0 → 0.82.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,17 @@
1
+ # [0.82.0](https://github.com/nanobpm/nano-workforce/compare/v0.81.0...v0.82.0) (2026-08-17)
2
+
3
+
4
+ ### Features
5
+
6
+ * reify epic domain lifecycle as derived plans.epic_phase ([#261](https://github.com/nanobpm/nano-workforce/issues/261)) ([#265](https://github.com/nanobpm/nano-workforce/issues/265)) ([4cc9dee](https://github.com/nanobpm/nano-workforce/commit/4cc9deee86b800206d264d96269e6a98e8753883)), closes [#266](https://github.com/nanobpm/nano-workforce/issues/266) [nwf#245](https://github.com/nwf/issues/245) [nano-ide#254](https://github.com/nano-ide/issues/254)
7
+
8
+ # [0.81.0](https://github.com/nanobpm/nano-workforce/compare/v0.80.0...v0.81.0) (2026-08-17)
9
+
10
+
11
+ ### Features
12
+
13
+ * 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)
14
+
1
15
  # [0.80.0](https://github.com/nanobpm/nano-workforce/compare/v0.79.0...v0.80.0) (2026-08-17)
2
16
 
3
17
 
package/SPEC.md CHANGED
@@ -530,6 +530,17 @@ History: done/failed/abandoned) with a `plan_tasks` child grid showing each task
530
530
  status and the PR it produced (`pr_key` cross-references the Pull requests grid for
531
531
  convergence status).
532
532
 
533
+ **Epic domain phase** (issue #261): `plans.status` only distinguishes the process-instance
534
+ terminal (`dispatched` = "fan-out job done"), not the epic's *domain* lifecycle. The read model
535
+ therefore also carries a derived, display-only `plans.epic_phase` — **Planning → Reviewing →
536
+ Implementing (wave n/t) → Trial merging → Finalizing → Dispatched** — projected at write time from
537
+ `plan-fanout.bpmn`'s named activities via each spine worker's BPMN element id (`app/epicPhase.ts`,
538
+ the single binding; nwf is the first consumer of the urban phase-projection primitive, nano-ide#266).
539
+ The `Implementing` band is wave-labelled from the levelize records (`plan_tasks` waves). The epic /
540
+ epic-detail pages surface it as a **Phase** column. It never gates control flow (that stays driven by
541
+ the process `currentWave`/`waveCount`/`gate_wave`); a post-dispatch cross-instance rollup into
542
+ Converging/Merging is a later seam (nwf#245 / nano-ide#254).
543
+
533
544
  ### 13.1 Dependency waves + merge barrier (issues #20, #26, release-notes-concierge)
534
545
 
535
546
  The flat `implement → record-results` shape above evolved into a **wave loop**. The
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,62 @@
1
+ // Read-model derivation test for the epic domain phase (issue #261). `deriveEpicPhase` /
2
+ // `implementingPhase` are the single source of truth for the write-time projection each spine
3
+ // worker stamps onto `plans.epic_phase`. The projection binds structurally to plan-fanout.bpmn's
4
+ // named activities via the job's BPMN element id (mirroring the urban #266 phase primitive), so the
5
+ // epic view can show WHICH phase an epic is in — not only the process-instance terminal status.
6
+ import { test } from "node:test";
7
+ import { assertEquals } from "#test-assert";
8
+ import { deriveEpicPhase, EPIC_PHASE, implementingPhase } from "./epicPhase.ts";
9
+
10
+ test("deriveEpicPhase maps each spine element to its domain phase", () => {
11
+ // Planning genesis + hand-off into Reviewing when the plan is recorded.
12
+ assertEquals(deriveEpicPhase("plan"), EPIC_PHASE.PLANNING);
13
+ assertEquals(deriveEpicPhase("ensure-base-branch"), EPIC_PHASE.PLANNING);
14
+ assertEquals(deriveEpicPhase("record-plan"), EPIC_PHASE.REVIEWING);
15
+ assertEquals(deriveEpicPhase("review-plan"), EPIC_PHASE.REVIEWING);
16
+ assertEquals(deriveEpicPhase("record-plan-review"), EPIC_PHASE.REVIEWING);
17
+ assertEquals(deriveEpicPhase("plan-review-decision"), EPIC_PHASE.REVIEWING);
18
+ // Trial-merge band.
19
+ assertEquals(deriveEpicPhase("trial-merge"), EPIC_PHASE.TRIAL_MERGING);
20
+ assertEquals(deriveEpicPhase("record-trial-merge"), EPIC_PHASE.TRIAL_MERGING);
21
+ assertEquals(deriveEpicPhase("trial-merge-decision"), EPIC_PHASE.TRIAL_MERGING);
22
+ assertEquals(deriveEpicPhase("resolve-trial-attention"), EPIC_PHASE.TRIAL_MERGING);
23
+ // Finalize step's lasting result is the "Fleet dispatched" terminal.
24
+ assertEquals(deriveEpicPhase("record-results"), EPIC_PHASE.DISPATCHED);
25
+ });
26
+
27
+ test("deriveEpicPhase wave-labels the Implementing band from the levelize records", () => {
28
+ // select-wave / record-wave / the implement MI + wait-wave-merged all read as Implementing,
29
+ // labelled with the 1-based wave from the wave/levelize records (0-based `current`).
30
+ assertEquals(
31
+ deriveEpicPhase("select-wave", { current: 0, total: 3 }),
32
+ "Implementing (wave 1/3)",
33
+ );
34
+ assertEquals(
35
+ deriveEpicPhase("record-wave", { current: 2, total: 3 }),
36
+ "Implementing (wave 3/3)",
37
+ );
38
+ assertEquals(
39
+ deriveEpicPhase("wait-wave-merged", { current: 1, total: 3 }),
40
+ "Implementing (wave 2/3)",
41
+ );
42
+ assertEquals(deriveEpicPhase("implement-task", { current: 0, total: 1 }), "Implementing (wave 1/1)");
43
+ });
44
+
45
+ test("deriveEpicPhase returns null for a non-spine element so a stray write never clobbers", () => {
46
+ assertEquals(deriveEpicPhase(undefined), null);
47
+ assertEquals(deriveEpicPhase(null), null);
48
+ assertEquals(deriveEpicPhase(""), null);
49
+ assertEquals(deriveEpicPhase("some-unrelated-element"), null);
50
+ });
51
+
52
+ test("implementingPhase clamps the 1-based label to the total and degrades gracefully", () => {
53
+ assertEquals(implementingPhase(0, 2), "Implementing (wave 1/2)");
54
+ // A `current` at/over the last index (record-wave pins current_wave to waveCount-1 on the final
55
+ // wave) never reads past n/n.
56
+ assertEquals(implementingPhase(5, 3), "Implementing (wave 3/3)");
57
+ // Unusable wave numbers (taskless plan / NaN counter) degrade to a bare Implementing — never
58
+ // "wave NaN/…".
59
+ assertEquals(implementingPhase(0, 0), "Implementing");
60
+ assertEquals(implementingPhase(undefined, undefined), "Implementing");
61
+ assertEquals(implementingPhase("x", "y"), "Implementing");
62
+ });
@@ -0,0 +1,125 @@
1
+ // app/epicPhase.ts — reify the epic's own domain lifecycle as a derived `epic_phase` (issue #261).
2
+ //
3
+ // `plans.status` only distinguishes `planning` / `dispatched` / `done` / `failed` / `abandoned` —
4
+ // and `dispatched` is the `plan-fanout.bpmn` PROCESS-INSTANCE terminal ("fan-out job done"), not the
5
+ // epic's domain phase. `plan-fanout.bpmn` already models the rich lifecycle as named activities
6
+ // (Ensure base branch → Plan → Review plan → Select wave → Implement task → Trial merge → Finalize
7
+ // → "Fleet dispatched"); this module reifies that lifecycle as a stored, display-only projection so
8
+ // the epic view can show which phase the epic is in.
9
+ //
10
+ // Convention over declaration: the phases ARE the activities plan-fanout.bpmn already names. Each
11
+ // spine worker derives its projection from its OWN BPMN element id (`job.elementId`) — no annotation
12
+ // map on the model, no second reconciliation pass — mirroring the urban structural phase-projection
13
+ // primitive (nano-ide#266), which derives the phase from the furthest element reached in
14
+ // write-provenance. This module is the single binding (nwf is #266's first consumer).
15
+ //
16
+ // Write-time projection: because the phase only advances when a worker writes, each spine worker
17
+ // stamps the phase the epic is ENTERING as a result of its write — the write points ARE the phase
18
+ // boundaries. Two structural defaults are coarsened where the raw activity label would mislead
19
+ // (documented on `ELEMENT_PHASE` below): `select-wave` reads as `Implementing (wave n/t)` because it
20
+ // dispatches and durably marks the (write-silent) `implement` multi-instance subProcess, and
21
+ // `record-results` reads as the `Dispatched` terminal ("Fleet dispatched").
22
+ //
23
+ // Cross-instance rollup (later): post-dispatch, the epic's effective phase extends into the
24
+ // convergence/merge loops carried on separate top-level instances correlated by lineage
25
+ // (`rootRequestKey`, nwf#245 / nano-ide#254). Once #266's Tier-2 rollup lands, `epic_phase` can
26
+ // advance past `Dispatched` into Converging/Merging with no new wiring here — the seam is this
27
+ // module's derivation staying the single source.
28
+
29
+ /** The epic's domain phases — the vocabulary the derivation projects onto `plans.epic_phase`.
30
+ * Shared with the feature-view stage vocabulary (nwf#254), which uses the same stored-projection
31
+ * pattern. `Implementing` is wave-labelled at derivation time (see {@link implementingPhase}). */
32
+ export const EPIC_PHASE = {
33
+ PLANNING: "Planning",
34
+ REVIEWING: "Reviewing",
35
+ IMPLEMENTING: "Implementing",
36
+ TRIAL_MERGING: "Trial merging",
37
+ FINALIZING: "Finalizing",
38
+ DISPATCHED: "Dispatched",
39
+ } as const;
40
+
41
+ /** Coerce a wave index/count to a non-negative integer, or null when it isn't one. Mirrors the
42
+ * `toWave` coercion the wave workers already apply, so a NaN/absent counter degrades to an
43
+ * unlabelled `Implementing` rather than emitting `wave NaN/…`. */
44
+ const toWave = (v: unknown): number | null => {
45
+ const n = Math.trunc(Number(v));
46
+ return Number.isFinite(n) && n >= 0 ? n : null;
47
+ };
48
+
49
+ /**
50
+ * `Implementing (wave n/t)` — special-cased from the wave/levelize records (`plan_tasks` waves),
51
+ * NOT the raw multi-instance counter. `current` is the 0-based wave index carried on the process
52
+ * (`currentWave` / the projected `current_wave`); the label is 1-based and clamped to `total` so a
53
+ * final wave reads `n/n`. Falls back to a bare `Implementing` when the wave numbers aren't usable
54
+ * (e.g. a taskless plan with `total` 0), so the phase never renders `wave NaN`.
55
+ */
56
+ export function implementingPhase(current: unknown, total: unknown): string {
57
+ const t = toWave(total);
58
+ const c = toWave(current);
59
+ if (t !== null && t > 0 && c !== null) {
60
+ const n = Math.min(c + 1, t);
61
+ return `${EPIC_PHASE.IMPLEMENTING} (wave ${n}/${t})`;
62
+ }
63
+ return EPIC_PHASE.IMPLEMENTING;
64
+ }
65
+
66
+ /**
67
+ * Structural binding: `plan-fanout.bpmn` element id → the domain phase the epic is IN while that
68
+ * element (or the write-silent agent step it hands off to) runs. Complete over the epic's spine, so
69
+ * the projection is derivable from provenance alone (the urban #266 semantics). Two entries are
70
+ * deliberately COARSENED from their raw activity label because the structural default misleads:
71
+ * • `record-plan` ("Record plan & levelize") → Reviewing: recording the plan hands the epic to the
72
+ * `review-plan` agent, so the review phase should already read while that (write-silent) agent
73
+ * runs. `record-plan-review` re-affirms Reviewing on each round/escalation.
74
+ * • `select-wave` ("Select wave") → Implementing: it dispatches the wave and is the last host write
75
+ * before the write-silent `implement` MI, so it durably marks the implementation phase for the
76
+ * wave it launches (wave-labelled via {@link implementingPhase} at the call site).
77
+ * • `record-results` ("Finalize plan") → Dispatched: the finalize step's lasting result is the
78
+ * "Fleet dispatched" terminal end event.
79
+ * `record-wave`'s next phase is data-dependent (trial-merge vs. next wave vs. finalize), so it is
80
+ * resolved at its call site rather than from the element id alone; its structural fallback here is
81
+ * the wave it just landed.
82
+ */
83
+ const ELEMENT_PHASE: Readonly<Record<string, string>> = {
84
+ "ensure-base-branch": EPIC_PHASE.PLANNING,
85
+ "plan": EPIC_PHASE.PLANNING,
86
+ "record-plan": EPIC_PHASE.REVIEWING,
87
+ "review-plan": EPIC_PHASE.REVIEWING,
88
+ "record-plan-review": EPIC_PHASE.REVIEWING,
89
+ "plan-review-decision": EPIC_PHASE.REVIEWING,
90
+ "select-wave": EPIC_PHASE.IMPLEMENTING,
91
+ "implement": EPIC_PHASE.IMPLEMENTING,
92
+ "implement-task": EPIC_PHASE.IMPLEMENTING,
93
+ "feature-escalation": EPIC_PHASE.IMPLEMENTING,
94
+ "record-wave": EPIC_PHASE.IMPLEMENTING,
95
+ "wait-wave-merged": EPIC_PHASE.IMPLEMENTING,
96
+ "trial-merge": EPIC_PHASE.TRIAL_MERGING,
97
+ "record-trial-merge": EPIC_PHASE.TRIAL_MERGING,
98
+ "trial-merge-decision": EPIC_PHASE.TRIAL_MERGING,
99
+ "resolve-trial-attention": EPIC_PHASE.TRIAL_MERGING,
100
+ "record-results": EPIC_PHASE.DISPATCHED,
101
+ };
102
+
103
+ /** Optional wave context for a wave-bearing phase, sourced from the wave/levelize records. */
104
+ export interface WaveContext {
105
+ current?: unknown;
106
+ total?: unknown;
107
+ }
108
+
109
+ /**
110
+ * Derive the epic phase for a spine element from its BPMN element id, or `null` when the element
111
+ * doesn't mark a phase — so a non-spine write (e.g. a poller reconcile pass) never clobbers
112
+ * `epic_phase`. A wave-bearing phase (`Implementing`) is wave-labelled from {@link WaveContext} when
113
+ * supplied. This is the single structural deriver; workers pass `job.elementId` so the phase name is
114
+ * never hardcoded at the call site.
115
+ */
116
+ export function deriveEpicPhase(
117
+ elementId: string | undefined | null,
118
+ wave?: WaveContext,
119
+ ): string | null {
120
+ if (!elementId) return null;
121
+ const base = ELEMENT_PHASE[elementId];
122
+ if (base === undefined) return null;
123
+ if (base === EPIC_PHASE.IMPLEMENTING) return implementingPhase(wave?.current, wave?.total);
124
+ return base;
125
+ }
package/app/plan.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  // hand-written SQL — matching app/service.ts.
12
12
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
13
13
  import { blackboardUrl, mintBlackboardToken, renderCoordinationBrief } from "./blackboard.ts";
14
+ import { EPIC_PHASE } from "./epicPhase.ts";
14
15
  import { DEFAULT_ESCALATION_SLA_TIMEOUT, escalationSlaTimeout } from "./escalationSla.ts";
15
16
  import { coalesceTitle, ensureBaseBranch, fetchDefaultBranch, fetchIssueTitle } from "./github.ts";
16
17
  import { clearExclusions } from "./mergeExclusion.ts";
@@ -82,6 +83,12 @@ export interface Plan {
82
83
  // in app/service.ts); `delivery_label` is the human rollup for the epic detail view. Display-only.
83
84
  delivery: string | null;
84
85
  delivery_label: string | null;
86
+ // Derived epic domain phase (038_plan_epic_phase.sql, #261): the epic's own lifecycle phase —
87
+ // Planning / Reviewing / Implementing (wave n/t) / Trial merging / Finalizing / Dispatched —
88
+ // projected at write time from plan-fanout.bpmn's named activities (app/epicPhase.ts), so the epic
89
+ // view can show which phase the epic is IN rather than only the process-instance terminal status.
90
+ // Display-only; NULL until the lifecycle first stamps it (grandfathers pre-#261 rows).
91
+ epic_phase: string | null;
85
92
  created_at: string;
86
93
  updated_at: string;
87
94
  }
@@ -436,6 +443,9 @@ export async function startPlan(
436
443
  issue_url: parsed.url,
437
444
  title,
438
445
  outcome: null,
446
+ // Genesis of the domain lifecycle (#261): the epic re-enters Planning. Cleared of any stale
447
+ // terminal phase from the prior run so the re-plan reads correctly from the first pass.
448
+ epic_phase: EPIC_PHASE.PLANNING,
439
449
  blackboard_token: token,
440
450
  base_branch: base,
441
451
  updated_at: ts,
@@ -449,6 +459,8 @@ export async function startPlan(
449
459
  title,
450
460
  status: "planning",
451
461
  task_count: 0,
462
+ // Genesis of the domain lifecycle (#261): a fresh epic starts in Planning.
463
+ epic_phase: EPIC_PHASE.PLANNING,
452
464
  blackboard_token: token,
453
465
  base_branch: base,
454
466
  created_at: ts,
@@ -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
+ });