@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.
@@ -0,0 +1,493 @@
1
+ // nano-workforce — the generic artifact-readiness ReadinessProbe (ADR 0001 §2, issue #258).
2
+ //
3
+ // This is the CODE half of the durable wait-gate primitive. The gate itself is modelled in the
4
+ // engine (`resources/processes/readiness-gate.bpmn`): a service task (`pr.readiness-probe`, whose
5
+ // executor lives in `workers/readiness-probe/`) races, through an event-based gateway, the
6
+ // readiness message it publishes when a probe goes green against a timer catch that bounds the
7
+ // wait and escalates. The engine owns token semantics; this module only *reads* readiness, so a
8
+ // restarted worker simply re-probes (idempotent / resumable).
9
+ //
10
+ // The probe is DATA, not code — a {@link ReadinessProbe} descriptor with a `kind` and a per-kind
11
+ // `match` predicate. Authors add a readiness source by adding a `kind`'s matcher, never by editing
12
+ // the BPMN or the worker's control flow. Four built-in kinds ship (`http`, `command`, `npm`,
13
+ // `github-check`); everything else is reached through the `command` escape hatch (ADR 0001 §2
14
+ // pinned decision 1). A probe carries NO secret material — any credential is read at execution
15
+ // time from the typed env-contract (`credentialEnv` names a declared {@link EnvKey}; ADR 0004
16
+ // pinned decision 2) and is redacted from every log line.
17
+ import { isEnvKey, readEnv, readEnvOr } from "./contracts.ts";
18
+ import { isoDuration, isoDurationToMs } from "./reviewWait.ts";
19
+
20
+ /** The built-in readiness sources. `command` is the escape hatch that subsumes the long tail
21
+ * (`gh`, `curl`, `docker manifest inspect`, a custom probe) — adding a first-class kind later is
22
+ * an additive matcher, not a schema change. */
23
+ export type ProbeKind = "http" | "command" | "npm" | "github-check";
24
+
25
+ /** What the gate does when the bounded wait times out (the engine timer arm fires). */
26
+ export type OnTimeout = "escalate" | "fail" | "continue";
27
+
28
+ /** Backoff policy between poll attempts. */
29
+ export type Backoff = "fixed" | "exponential";
30
+
31
+ const PROBE_KINDS: readonly ProbeKind[] = ["http", "command", "npm", "github-check"];
32
+ const ON_TIMEOUTS: readonly OnTimeout[] = ["escalate", "fail", "continue"];
33
+ const BACKOFFS: readonly Backoff[] = ["fixed", "exponential"];
34
+
35
+ /** The per-kind readiness predicate. Every field is optional; each kind reads only the ones it
36
+ * understands and applies a sensible default when a field is absent (see the matchers below). */
37
+ export interface ProbeMatch {
38
+ /** http: the exact HTTP status that means ready (default: any 2xx). */
39
+ readonly status?: number;
40
+ /** http: a substring the response body must contain. */
41
+ readonly bodyIncludes?: string;
42
+ /** command: the exit code that means ready (default: 0). */
43
+ readonly exitCode?: number;
44
+ /** command / npm: a substring stdout must contain. */
45
+ readonly stdoutIncludes?: string;
46
+ /** npm: the version that must be published (default: the version in `pkg@version`). */
47
+ readonly version?: string;
48
+ /** github-check: the check-run conclusion that means ready (default: "success"). */
49
+ readonly conclusion?: string;
50
+ /** github-check: restrict the predicate to the named check run (default: every check run). */
51
+ readonly checkName?: string;
52
+ }
53
+
54
+ /** The poll cadence: how often to re-probe, how long to keep trying, and the backoff shape. */
55
+ export interface ProbePoll {
56
+ readonly everyMs?: number;
57
+ readonly timeoutMs?: number;
58
+ readonly backoff?: Backoff;
59
+ }
60
+
61
+ /** A declared readiness probe — the whole descriptor the gate is handed as a process variable.
62
+ * Scalar + nested-object shape mirrored by the `ReadinessProbe` `nano:shape` in the BPMN so it is
63
+ * typed end-to-end. Carries NO secret: `credentialEnv` names a declared env-contract key, never a
64
+ * value. */
65
+ export interface ReadinessProbe {
66
+ readonly kind: ProbeKind;
67
+ readonly target: string;
68
+ readonly match?: ProbeMatch;
69
+ readonly poll?: ProbePoll;
70
+ readonly onTimeout?: OnTimeout;
71
+ /** The declared {@link EnvKey} whose value supplies a credential at execution time (e.g.
72
+ * `GITHUB_TOKEN` for a private `http` probe's `Authorization` header). Supported for the `http`
73
+ * kind ONLY — `parseProbe` rejects it on any other kind, which consumes credentials from the
74
+ * ambient env. Read via `readEnv`, never inlined. */
75
+ readonly credentialEnv?: string;
76
+ }
77
+
78
+ /** The result of a single probe attempt. `detail` is a short, already-redacted human note. */
79
+ export interface ProbeResult {
80
+ readonly ready: boolean;
81
+ readonly detail: string;
82
+ }
83
+
84
+ /** A raw HTTP response the http matcher inspects (kept separate from I/O so it is pure-testable). */
85
+ export interface HttpResponse {
86
+ readonly status: number;
87
+ readonly body: string;
88
+ }
89
+
90
+ /** A raw command result the command/npm matchers inspect. */
91
+ export interface CommandResult {
92
+ readonly code: number;
93
+ readonly stdout: string;
94
+ readonly stderr: string;
95
+ }
96
+
97
+ /** The injectable I/O seam — the ONE place the probe touches the outside world. The default
98
+ * implementation ({@link defaultProbeExec}) uses `fetch` + `node:child_process`; tests pass a stub
99
+ * so every matcher and the poll loop are exercised without a network or a subprocess. */
100
+ export interface ProbeExec {
101
+ httpGet(url: string, headers: Record<string, string>): Promise<HttpResponse>;
102
+ run(command: string, env: Record<string, string | undefined>): Promise<CommandResult>;
103
+ }
104
+
105
+ // ── Poll-policy defaults ──────────────────────────────────────────────────────────────────────
106
+ /** Default interval between poll attempts (ms) when the descriptor omits `poll.everyMs`. */
107
+ export const DEFAULT_EVERY_MS = 15_000;
108
+ /** Default bounded budget (ms) when the descriptor omits `poll.timeoutMs` (30 minutes). */
109
+ export const DEFAULT_TIMEOUT_MS = 1_800_000;
110
+ /** Default backoff shape when the descriptor omits `poll.backoff`. */
111
+ export const DEFAULT_BACKOFF: Backoff = "exponential";
112
+ /** Ceiling on a single backoff delay (ms) — an exponential ramp can never park a probe for days. */
113
+ export const MAX_EVERY_MS = 5 * 60_000;
114
+ /** Per-attempt I/O deadline (ms) for the default {@link ProbeExec} (60s). Both `fetch` and the
115
+ * command subprocess are bounded by it, so a single stuck attempt always resolves in bounded time
116
+ * (as an error the poll loop treats as "not ready yet") instead of hanging the worker forever —
117
+ * neither the local poll budget nor the engine timer can bound a JS handler blocked inside I/O. */
118
+ export const DEFAULT_ATTEMPT_TIMEOUT_MS = 60_000;
119
+ /** Default gate timeout when neither the descriptor nor `NANO_READINESS_POLL_TIMEOUT` supplies one. */
120
+ export const DEFAULT_READINESS_TIMEOUT = "PT30M";
121
+
122
+ const str = (v: unknown): string => (typeof v === "string" ? v : v == null ? "" : String(v));
123
+ const num = (v: unknown): number | undefined =>
124
+ typeof v === "number" && Number.isFinite(v) ? v : undefined;
125
+
126
+ function isRecord(v: unknown): v is Record<string, unknown> {
127
+ return typeof v === "object" && v !== null && !Array.isArray(v);
128
+ }
129
+
130
+ /** Parse + validate a raw descriptor (a process variable) into a typed {@link ReadinessProbe}.
131
+ * Throws a descriptive error on an unknown/missing `kind`, a blank `target`, an invalid
132
+ * `onTimeout`/`backoff`, or a `credentialEnv` on a non-`http` kind — a malformed probe must fail
133
+ * loudly at the worker, never silently wait forever (nor let a caller believe a subprocess probe is
134
+ * authenticated when its credential is silently ignored). */
135
+ export function parseProbe(raw: unknown): ReadinessProbe {
136
+ if (!isRecord(raw)) throw new Error("readiness probe: descriptor must be an object");
137
+ const kind = str(raw.kind).trim();
138
+ if (!isProbeKind(kind)) {
139
+ throw new Error(`readiness probe: unknown kind '${kind}' (expected one of ${PROBE_KINDS.join(", ")})`);
140
+ }
141
+ const target = str(raw.target).trim();
142
+ if (target === "") throw new Error(`readiness probe (${kind}): 'target' is required`);
143
+
144
+ const onTimeoutRaw = str(raw.onTimeout).trim();
145
+ if (onTimeoutRaw !== "" && !isOnTimeout(onTimeoutRaw)) {
146
+ throw new Error(`readiness probe: invalid onTimeout '${onTimeoutRaw}' (expected ${ON_TIMEOUTS.join(", ")})`);
147
+ }
148
+ const onTimeout: OnTimeout = onTimeoutRaw === "" ? "escalate" : onTimeoutRaw;
149
+
150
+ const match = isRecord(raw.match) ? parseMatch(raw.match) : undefined;
151
+ const poll = isRecord(raw.poll) ? parsePoll(raw.poll) : undefined;
152
+ const credentialEnv = str(raw.credentialEnv).trim() || undefined;
153
+ if (credentialEnv !== undefined && !isEnvKey(credentialEnv)) {
154
+ throw new Error(
155
+ `readiness probe: credentialEnv '${credentialEnv}' is not a declared env-contract key ` +
156
+ "(register it in app/contracts.ts) — a probe must never inline a secret",
157
+ );
158
+ }
159
+ // A credential is only ever consumed by the `http` kind (as an Authorization header). For
160
+ // command/npm/github-check it would be silently ignored, so reject it here rather than let a
161
+ // caller believe the subprocess runs authenticated (github-check's `gh api` reads its own token
162
+ // from the ambient env, not from `credentialEnv`).
163
+ if (credentialEnv !== undefined && kind !== "http") {
164
+ throw new Error(
165
+ `readiness probe (${kind}): 'credentialEnv' is only supported for the 'http' kind ` +
166
+ "(applied as an Authorization header); it has no effect on a command/npm/github-check probe",
167
+ );
168
+ }
169
+
170
+ return { kind, target, match, poll, onTimeout, credentialEnv };
171
+ }
172
+
173
+ // Membership guards that narrow a validated string to its union without a type assertion (the
174
+ // `no-unsafe-type-assertion` gate bans `as`).
175
+ function isProbeKind(v: string): v is ProbeKind {
176
+ for (const k of PROBE_KINDS) if (k === v) return true;
177
+ return false;
178
+ }
179
+ function isOnTimeout(v: string): v is OnTimeout {
180
+ for (const t of ON_TIMEOUTS) if (t === v) return true;
181
+ return false;
182
+ }
183
+
184
+ // `isBackoff` narrows a validated string to its union without a type assertion (the
185
+ // `no-unsafe-type-assertion` gate bans `as`).
186
+ function isBackoff(v: string): v is Backoff {
187
+ for (const b of BACKOFFS) if (b === v) return true;
188
+ return false;
189
+ }
190
+
191
+ function parseMatch(raw: Record<string, unknown>): ProbeMatch {
192
+ return {
193
+ status: num(raw.status),
194
+ bodyIncludes: str(raw.bodyIncludes).trim() || undefined,
195
+ exitCode: num(raw.exitCode),
196
+ stdoutIncludes: str(raw.stdoutIncludes).trim() || undefined,
197
+ version: str(raw.version).trim() || undefined,
198
+ conclusion: str(raw.conclusion).trim() || undefined,
199
+ checkName: str(raw.checkName).trim() || undefined,
200
+ };
201
+ }
202
+
203
+ function parsePoll(raw: Record<string, unknown>): ProbePoll {
204
+ const backoffRaw = str(raw.backoff).trim();
205
+ if (backoffRaw !== "" && !isBackoff(backoffRaw)) {
206
+ throw new Error(`readiness probe: invalid backoff '${backoffRaw}' (expected ${BACKOFFS.join(", ")})`);
207
+ }
208
+ return {
209
+ everyMs: num(raw.everyMs),
210
+ timeoutMs: num(raw.timeoutMs),
211
+ backoff: backoffRaw === "" ? undefined : backoffRaw,
212
+ };
213
+ }
214
+
215
+ /** The effective poll policy: descriptor values, clamped to sane bounds, with defaults filled in.
216
+ * `everyMs`/`timeoutMs` below 1ms fall back to their defaults (a zero interval would busy-spin). */
217
+ export function normalizePoll(poll: ProbePoll | undefined): Required<ProbePoll> {
218
+ const everyRaw = poll?.everyMs;
219
+ const timeoutRaw = poll?.timeoutMs;
220
+ const everyMs = typeof everyRaw === "number" && everyRaw >= 1 ? Math.trunc(everyRaw) : DEFAULT_EVERY_MS;
221
+ const timeoutMs =
222
+ typeof timeoutRaw === "number" && timeoutRaw >= 1 ? Math.trunc(timeoutRaw) : DEFAULT_TIMEOUT_MS;
223
+ return { everyMs: Math.min(everyMs, MAX_EVERY_MS), timeoutMs, backoff: poll?.backoff ?? DEFAULT_BACKOFF };
224
+ }
225
+
226
+ /** The delay (ms) before the `attempt`-th retry (1-based). Fixed backoff returns `everyMs`;
227
+ * exponential doubles per attempt, clamped to {@link MAX_EVERY_MS}. */
228
+ export function nextDelay(attempt: number, poll: Required<ProbePoll>): number {
229
+ if (poll.backoff === "fixed") return poll.everyMs;
230
+ const factor = 2 ** Math.max(0, attempt - 1);
231
+ return Math.min(poll.everyMs * factor, MAX_EVERY_MS);
232
+ }
233
+
234
+ // ── Per-kind matchers (pure — operate on an already-fetched raw response) ───────────────────────
235
+
236
+ /** http readiness: status matches (`match.status`, else any 2xx) AND, if given, the body contains
237
+ * `match.bodyIncludes`. */
238
+ export function matchHttp(match: ProbeMatch | undefined, resp: HttpResponse): ProbeResult {
239
+ const statusOk =
240
+ typeof match?.status === "number" ? resp.status === match.status : resp.status >= 200 && resp.status < 300;
241
+ const bodyOk = match?.bodyIncludes ? resp.body.includes(match.bodyIncludes) : true;
242
+ const ready = statusOk && bodyOk;
243
+ return { ready, detail: `http ${resp.status}${ready ? "" : " (not ready)"}` };
244
+ }
245
+
246
+ /** command readiness: exit code matches (`match.exitCode`, else 0) AND, if given, stdout contains
247
+ * `match.stdoutIncludes`. */
248
+ export function matchCommand(match: ProbeMatch | undefined, resp: CommandResult): ProbeResult {
249
+ const wantCode = typeof match?.exitCode === "number" ? match.exitCode : 0;
250
+ const codeOk = resp.code === wantCode;
251
+ const stdoutOk = match?.stdoutIncludes ? resp.stdout.includes(match.stdoutIncludes) : true;
252
+ const ready = codeOk && stdoutOk;
253
+ return { ready, detail: `command exit ${resp.code}${ready ? "" : " (not ready)"}` };
254
+ }
255
+
256
+ /** npm readiness: `npm view <pkg>@<version> version` printed a version (the package@version is
257
+ * published). If `match.version` (or the version in `pkg@version`) is given, the printed version
258
+ * must equal it; otherwise any non-empty version means ready. */
259
+ export function matchNpm(match: ProbeMatch | undefined, target: string, resp: CommandResult): ProbeResult {
260
+ if (resp.code !== 0) return { ready: false, detail: "npm view failed (not published yet)" };
261
+ const printed = resp.stdout.trim();
262
+ if (printed === "") return { ready: false, detail: "npm: version not published yet" };
263
+ const want = match?.version ?? versionOf(target);
264
+ const ready = want ? printed.split(/\s+/).includes(want) || printed === want : true;
265
+ return { ready, detail: `npm ${ready ? "published" : "version mismatch"}` };
266
+ }
267
+
268
+ /** github-check readiness: parse a `check-runs` payload and require the relevant runs to have the
269
+ * wanted conclusion (`match.conclusion`, else "success"). With `match.checkName` only that check
270
+ * is considered; otherwise every check run must be complete + successful and at least one exists. */
271
+ export function matchGithubCheck(match: ProbeMatch | undefined, payload: unknown): ProbeResult {
272
+ const runs = checkRunsOf(payload);
273
+ const want = match?.conclusion ?? "success";
274
+ const named = match?.checkName;
275
+ const relevant = named ? runs.filter((r) => r.name === named) : runs;
276
+ if (relevant.length === 0) {
277
+ return { ready: false, detail: named ? `github-check: '${named}' not found yet` : "github-check: no runs yet" };
278
+ }
279
+ const ready = relevant.every((r) => r.status === "completed" && r.conclusion === want);
280
+ return { ready, detail: `github-check ${ready ? want : "pending/failed"}` };
281
+ }
282
+
283
+ interface CheckRun {
284
+ readonly name: string;
285
+ readonly status: string;
286
+ readonly conclusion: string;
287
+ }
288
+
289
+ function checkRunsOf(payload: unknown): CheckRun[] {
290
+ const rawRuns = isRecord(payload) && Array.isArray(payload.check_runs) ? payload.check_runs : [];
291
+ const out: CheckRun[] = [];
292
+ for (const r of rawRuns) {
293
+ if (!isRecord(r)) continue;
294
+ out.push({ name: str(r.name), status: str(r.status), conclusion: str(r.conclusion) });
295
+ }
296
+ return out;
297
+ }
298
+
299
+ /** The version segment of a `pkg@version` (or `@scope/pkg@version`) target, or undefined. */
300
+ function versionOf(target: string): string | undefined {
301
+ const at = target.lastIndexOf("@");
302
+ if (at <= 0) return undefined;
303
+ const v = target.slice(at + 1).trim();
304
+ return v === "" ? undefined : v;
305
+ }
306
+
307
+ // ── Single probe attempt (does I/O via the injected {@link ProbeExec}) ──────────────────────────
308
+
309
+ /** Run ONE probe attempt for `probe`, resolving any credential from the typed env-contract and
310
+ * dispatching to the kind's matcher. Read-only and idempotent, so a re-run (worker restart) is
311
+ * safe. A thrown I/O error is caught by the caller (the poll loop) and treated as "not ready yet".
312
+ */
313
+ export async function probeOnce(
314
+ probe: ReadinessProbe,
315
+ exec: ProbeExec,
316
+ env: Record<string, string | undefined> = process.env,
317
+ ): Promise<ProbeResult> {
318
+ const credential = credentialFor(probe, env);
319
+ switch (probe.kind) {
320
+ case "http": {
321
+ const headers: Record<string, string> = { accept: "*/*" };
322
+ if (credential) headers.authorization = `Bearer ${credential}`;
323
+ return matchHttp(probe.match, await exec.httpGet(probe.target, headers));
324
+ }
325
+ case "command":
326
+ return matchCommand(probe.match, await exec.run(probe.target, env));
327
+ case "npm":
328
+ return matchNpm(probe.match, probe.target, await exec.run(npmCommand(probe.target), env));
329
+ case "github-check": {
330
+ const { repo, ref } = parseRepoRef(probe.target);
331
+ const out = await exec.run(githubCheckCommand(repo, ref), env);
332
+ if (out.code !== 0) return { ready: false, detail: "github-check: gh api failed (not ready)" };
333
+ return matchGithubCheck(probe.match, parseJson(out.stdout));
334
+ }
335
+ }
336
+ }
337
+
338
+ /** Resolve the credential a probe declares, from the typed env-contract only. Returns undefined
339
+ * when no `credentialEnv` is declared or the key is unset — never a value from the descriptor. */
340
+ function credentialFor(probe: ReadinessProbe, env: Record<string, string | undefined>): string | undefined {
341
+ if (probe.credentialEnv === undefined || !isEnvKey(probe.credentialEnv)) return undefined;
342
+ return readEnv(probe.credentialEnv, env);
343
+ }
344
+
345
+ /** Build the `npm view` command for a `pkg@version` target. The target is single-quote-escaped so
346
+ * a hostile descriptor cannot break out of the argument. */
347
+ export function npmCommand(target: string): string {
348
+ return `npm view ${shellQuote(target)} version`;
349
+ }
350
+
351
+ /** Build the `gh api` command that lists the check runs for a ref. */
352
+ export function githubCheckCommand(repo: string, ref: string): string {
353
+ return `gh api ${shellQuote(`repos/${repo}/commits/${ref}/check-runs`)} -H ${shellQuote("Accept: application/vnd.github+json")}`;
354
+ }
355
+
356
+ /** Split an `owner/repo@ref` github-check target into its parts. Defaults the ref to `HEAD`. */
357
+ export function parseRepoRef(target: string): { repo: string; ref: string } {
358
+ const at = target.lastIndexOf("@");
359
+ if (at <= 0) return { repo: target, ref: "HEAD" };
360
+ return { repo: target.slice(0, at), ref: target.slice(at + 1).trim() || "HEAD" };
361
+ }
362
+
363
+ function shellQuote(s: string): string {
364
+ return `'${s.replace(/'/g, "'\\''")}'`;
365
+ }
366
+
367
+ function parseJson(text: string): unknown {
368
+ try {
369
+ return JSON.parse(text);
370
+ } catch {
371
+ return null;
372
+ }
373
+ }
374
+
375
+ // ── Timeout duration derivation (probe poll budget → the gate's FEEL timer) ─────────────────────
376
+
377
+ /** Convert a millisecond budget into an ISO-8601 duration for a BPMN `<bpmn:timeDuration>`.
378
+ * Rounds up to whole seconds (a sub-second budget still yields at least `PT1S`), so the engine
379
+ * timer never rounds down to a zero-length (immediately-firing) duration. */
380
+ export function msToIsoDuration(ms: number): string {
381
+ const seconds = Math.max(1, Math.ceil(ms / 1000));
382
+ return `PT${seconds}S`;
383
+ }
384
+
385
+ /** The authoritative gate timeout (an ISO-8601 duration) for a probe: the descriptor's
386
+ * `poll.timeoutMs` when present, else `NANO_READINESS_POLL_TIMEOUT`, else the built-in default.
387
+ * This is the value the gate's timer arm is seeded with — the engine, not the worker, owns the
388
+ * bound. Kept here so whoever seeds a readiness-gate instance derives it from ONE place. */
389
+ export function readinessTimeout(
390
+ probe: ReadinessProbe,
391
+ env: Record<string, string | undefined> = process.env,
392
+ ): string {
393
+ const declared = probe.poll?.timeoutMs;
394
+ if (typeof declared === "number" && declared >= 1) return msToIsoDuration(Math.trunc(declared));
395
+ return isoDuration(readEnvOr("NANO_READINESS_POLL_TIMEOUT", DEFAULT_READINESS_TIMEOUT, env), DEFAULT_READINESS_TIMEOUT);
396
+ }
397
+
398
+ /** The effective gate budget in **milliseconds** — the ms twin of {@link readinessTimeout}, resolved
399
+ * by the SAME precedence (descriptor `poll.timeoutMs`, else `NANO_READINESS_POLL_TIMEOUT`, else the
400
+ * built-in default) and sharing its env key + default. The worker's local poll budget MUST use this
401
+ * rather than a hard-coded default: an operator who raises `NANO_READINESS_POLL_TIMEOUT` past the
402
+ * built-in 30m would otherwise stop the worker probing while the gate's engine timer keeps waiting —
403
+ * a window in which the artifact can go ready with nothing left to observe it, spuriously escalating
404
+ * the gate. The declared branch stays exact ms (the gate rounds it up to whole seconds for its ISO
405
+ * timer); the env branch parses through {@link isoDurationToMs}, the same grammar `readinessTimeout`
406
+ * validates with, so the two bounds can never drift. */
407
+ export function readinessTimeoutMs(
408
+ probe: ReadinessProbe,
409
+ env: Record<string, string | undefined> = process.env,
410
+ ): number {
411
+ const declared = probe.poll?.timeoutMs;
412
+ if (typeof declared === "number" && declared >= 1) return Math.trunc(declared);
413
+ return isoDurationToMs(readEnvOr("NANO_READINESS_POLL_TIMEOUT", DEFAULT_READINESS_TIMEOUT, env), DEFAULT_READINESS_TIMEOUT);
414
+ }
415
+
416
+ /** The worker's local poll budget in **milliseconds**, bound to the gate **per instance**.
417
+ *
418
+ * The gate's engine timers (`resources/processes/readiness-gate.bpmn`) fire off the *process
419
+ * variable* `probeTimeout` (`<bpmn:timeDuration>=probeTimeout</…>`), seeded once when the gate
420
+ * instance is created. The worker must adopt that SAME seeded value rather than recompute the bound
421
+ * from the ambient env ({@link readinessTimeoutMs}): if `NANO_READINESS_POLL_TIMEOUT` changes after
422
+ * the instance is created (or `probeTimeout` was seeded from a different source), an env-recomputed
423
+ * worker can stop probing while the engine timer is still waiting — a window where the artifact can
424
+ * go ready with no worker left to publish `readiness-ready`, spuriously escalating the gate.
425
+ *
426
+ * So prefer the seeded `probeTimeout` (parsed through {@link isoDurationToMs}, the same grammar the
427
+ * engine timer is validated with, so worker-ms and engine-ISO can't drift), and fall back to the
428
+ * env-derived twin only when it is absent/blank — e.g. a direct caller or unit test that drives the
429
+ * loop without seeding the process variable. */
430
+ export function probeBudgetMs(
431
+ probeTimeout: string | undefined,
432
+ probe: ReadinessProbe,
433
+ env: Record<string, string | undefined> = process.env,
434
+ ): number {
435
+ const seeded = (probeTimeout ?? "").trim();
436
+ if (seeded !== "") return isoDurationToMs(seeded, DEFAULT_READINESS_TIMEOUT);
437
+ return readinessTimeoutMs(probe, env);
438
+ }
439
+
440
+ // ── Default I/O implementation (Node) ───────────────────────────────────────────────────────────
441
+
442
+ /** The production {@link ProbeExec}: `fetch` for http, a shell subprocess for command/npm/gh. Every
443
+ * attempt is bounded by `attemptTimeoutMs` ({@link DEFAULT_ATTEMPT_TIMEOUT_MS}) — `fetch` via an
444
+ * `AbortController` and the subprocess via `exec`'s `timeout`/`killSignal` — so a hung endpoint or a
445
+ * stuck command becomes a bounded error the poll loop retries, never an I/O block the engine timer
446
+ * cannot cancel. */
447
+ export function defaultProbeExec(attemptTimeoutMs: number = DEFAULT_ATTEMPT_TIMEOUT_MS): ProbeExec {
448
+ return {
449
+ async httpGet(url, headers) {
450
+ const controller = new AbortController();
451
+ const timer = setTimeout(() => controller.abort(), attemptTimeoutMs);
452
+ try {
453
+ const r = await fetch(url, { headers, redirect: "follow", signal: controller.signal });
454
+ const body = await r.text().catch(() => "");
455
+ return { status: r.status, body };
456
+ } finally {
457
+ clearTimeout(timer);
458
+ }
459
+ },
460
+ async run(command, env) {
461
+ const { exec } = await import("node:child_process");
462
+ return await new Promise<CommandResult>((resolve) => {
463
+ exec(
464
+ command,
465
+ { env, maxBuffer: 16 * 1024 * 1024, timeout: attemptTimeoutMs, killSignal: "SIGKILL" },
466
+ (err, stdout, stderr) => {
467
+ const code = err && typeof err.code === "number" ? err.code : err ? 1 : 0;
468
+ resolve({ code, stdout: String(stdout ?? ""), stderr: String(stderr ?? "") });
469
+ },
470
+ );
471
+ });
472
+ },
473
+ };
474
+ }
475
+
476
+ // ── Log redaction (ADR 0004 pinned decision 2 — never leak a probe's target/output/credential) ──
477
+
478
+ /** A log-safe rendering of a probe: kind + a redacted target (URL userinfo and query string
479
+ * stripped, since either can carry a token) — never the credential, body, or stdout. A `command`
480
+ * target is an arbitrary shell snippet that can easily embed a secret, so it is never logged at all:
481
+ * only the kind + a fixed placeholder is rendered for it. */
482
+ export function redactTarget(probe: ReadinessProbe): string {
483
+ if (probe.kind === "command") return `${probe.kind}:<redacted>`;
484
+ return `${probe.kind}:${redactString(probe.target)}`;
485
+ }
486
+
487
+ /** Strip credential-bearing pieces from a free-form target string for logging: any `user:pass@`
488
+ * userinfo and any `?query`/`#fragment` (a token often rides the query). */
489
+ export function redactString(s: string): string {
490
+ return s
491
+ .replace(/\/\/[^/@\s]*@/g, "//***@")
492
+ .replace(/[?#].*$/, (m) => `${m[0]}***`);
493
+ }
@@ -8,6 +8,7 @@ import {
8
8
  clampNudgeMinutes,
9
9
  DEFAULT_REVIEW_NUDGE_MINUTES,
10
10
  DEFAULT_REVIEW_WAIT_TIMEOUT,
11
+ isoDurationToMs,
11
12
  MAX_REVIEW_NUDGE_MINUTES,
12
13
  reviewWaitTimeout,
13
14
  } from "./reviewWait.ts";
@@ -36,6 +37,24 @@ test("reviewWaitTimeout: a custom fallback is used when the value is invalid", (
36
37
  assertEquals(reviewWaitTimeout(undefined, "PT10M"), "PT10M");
37
38
  });
38
39
 
40
+ test("isoDurationToMs: converts each component and sums them", () => {
41
+ assertEquals(isoDurationToMs("PT1S", "PT0S"), 1000);
42
+ assertEquals(isoDurationToMs("PT45S", "PT0S"), 45_000);
43
+ assertEquals(isoDurationToMs("PT30M", "PT0S"), 1_800_000);
44
+ assertEquals(isoDurationToMs("PT2H", "PT0S"), 7_200_000);
45
+ assertEquals(isoDurationToMs("PT1H30M", "PT0S"), 5_400_000);
46
+ assertEquals(isoDurationToMs("P1D", "PT0S"), 86_400_000);
47
+ assertEquals(isoDurationToMs("P1DT2H", "PT0S"), 93_600_000);
48
+ assertEquals(isoDurationToMs("P1W", "PT0S"), 604_800_000);
49
+ });
50
+
51
+ test("isoDurationToMs: blank / absent / malformed → the (parsed) default", () => {
52
+ assertEquals(isoDurationToMs(undefined, "PT30M"), 1_800_000);
53
+ assertEquals(isoDurationToMs("", "PT30M"), 1_800_000);
54
+ assertEquals(isoDurationToMs("20m", "PT5M"), 300_000); // missing leading P → default
55
+ assertEquals(isoDurationToMs("garbage", "PT5M"), 300_000);
56
+ });
57
+
39
58
  test("clampNudgeMinutes: blank / absent / non-numeric → fallback", () => {
40
59
  assertEquals(clampNudgeMinutes(""), DEFAULT_REVIEW_NUDGE_MINUTES);
41
60
  assertEquals(clampNudgeMinutes(" "), DEFAULT_REVIEW_NUDGE_MINUTES);
package/app/reviewWait.ts CHANGED
@@ -30,6 +30,26 @@ export function isoDuration(raw: string | undefined, def: string): string {
30
30
  return s !== "" && ISO_DURATION.test(s) ? s : def;
31
31
  }
32
32
 
33
+ /** Convert an ISO-8601 duration to whole milliseconds, sharing {@link isoDuration}'s validation and
34
+ * grammar so a value's ms budget and its BPMN timer string can never derive from two parsers. `raw`
35
+ * is validated (and normalised) through {@link isoDuration} first, falling back to `def` when it is
36
+ * absent/blank/malformed. Calendar components carry no anchor date in a bare duration, so `Y`/`M`
37
+ * use pragmatic fixed lengths (365d / 30d); the realistic inputs here are seconds…days. */
38
+ export function isoDurationToMs(raw: string | undefined, def: string): number {
39
+ const m = ISO_DURATION.exec(isoDuration(raw, def));
40
+ if (!m) return 0;
41
+ const n = (g?: string): number => (g ? Number.parseInt(g, 10) : 0);
42
+ return (
43
+ n(m[1]) * 31_536_000_000 + // years (365d)
44
+ n(m[2]) * 2_592_000_000 + // months (30d)
45
+ n(m[3]) * 604_800_000 + // weeks
46
+ n(m[4]) * 86_400_000 + // days
47
+ n(m[6]) * 3_600_000 + // hours
48
+ n(m[7]) * 60_000 + // minutes
49
+ n(m[8]) * 1000 // seconds
50
+ );
51
+ }
52
+
33
53
  /** Validate an ISO-8601 duration for the review-wait timer, falling back to `def` when the value
34
54
  * is absent, blank, or malformed. Thin wrapper over {@link isoDuration}. */
35
55
  export function reviewWaitTimeout(
@@ -0,0 +1,12 @@
1
+ -- 038_plan_epic_phase.sql — issue #261: reify the epic's own domain lifecycle as a derived,
2
+ -- write-time-projected `epic_phase`, so the epic/plan view can show WHICH phase an epic is in —
3
+ -- Planning / Reviewing / Implementing (wave n/t) / Trial merging / Finalizing / Dispatched —
4
+ -- instead of only the process-instance terminal status (`plans.status`, whose `dispatched` is the
5
+ -- `plan-fanout.bpmn` fan-out terminal, not the epic's domain phase).
6
+ --
7
+ -- Forward-only, additive (expand): a nullable TEXT column, display-only. NULL until the plan
8
+ -- lifecycle first stamps it (grandfathering pre-#261 rows), so it never gates control flow. The
9
+ -- value is derived structurally from `plan-fanout.bpmn`'s named activities via each spine worker's
10
+ -- BPMN element id (`app/epicPhase.ts`) and written through the existing plan write path — mirroring
11
+ -- the wave-progress / delivery display projections already on this table.
12
+ ALTER TABLE plans ADD COLUMN epic_phase TEXT;