@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 +7 -0
- package/app/contracts.ts +23 -0
- package/app/readiness.test.ts +300 -0
- package/app/readiness.ts +493 -0
- package/app/reviewWait.test.ts +19 -0
- package/app/reviewWait.ts +20 -0
- package/e2e/readiness-gate.e2e.ts +285 -0
- package/nano.app.json +4 -0
- package/package.json +1 -1
- package/resources/forms/readiness-escalation.form +29 -0
- package/resources/processes/readiness-gate.bpmn +343 -0
- package/workers/readiness-probe/worker.test.ts +236 -0
- package/workers/readiness-probe/worker.ts +146 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// pr.readiness-probe — the ReadinessProbe executor (ADR 0001 §2, issue #258).
|
|
2
|
+
//
|
|
3
|
+
// The service-task half of the durable wait-gate (`resources/processes/readiness-gate.bpmn`). It is
|
|
4
|
+
// handed a declared `ReadinessProbe` descriptor + a `gateKey` correlation key, and polls the probe
|
|
5
|
+
// with backoff until it goes green — at which point it publishes the `readiness-ready` message the
|
|
6
|
+
// gate's event-based gateway correlates, releasing the wait. It does NOT own the timeout: the gate's
|
|
7
|
+
// timer arm is the authoritative bound (a hung or forever-red probe is escalated by the engine, not
|
|
8
|
+
// by a poller-side sentinel — ADR 0001 §2 pinned decision 3). Because the probe only *reads*
|
|
9
|
+
// readiness, a worker restart simply re-activates this job and re-probes (idempotent / resumable).
|
|
10
|
+
//
|
|
11
|
+
// Secrets never appear in the descriptor, a process variable, or a log line: a credential is read at
|
|
12
|
+
// execution time from the typed env-contract (`credentialEnv` → `readEnv`) and the probe's
|
|
13
|
+
// target/output is redacted before logging (ADR 0004 pinned decision 2).
|
|
14
|
+
import type { AppJobHandler } from "@nanobpm/urban";
|
|
15
|
+
import { readEnvOr } from "../../app/contracts.ts";
|
|
16
|
+
import {
|
|
17
|
+
DEFAULT_EVERY_MS,
|
|
18
|
+
defaultProbeExec,
|
|
19
|
+
nextDelay,
|
|
20
|
+
normalizePoll,
|
|
21
|
+
type ProbeExec,
|
|
22
|
+
type ProbePoll,
|
|
23
|
+
type ProbeResult,
|
|
24
|
+
parseProbe,
|
|
25
|
+
probeBudgetMs,
|
|
26
|
+
probeOnce,
|
|
27
|
+
type ReadinessProbe,
|
|
28
|
+
redactTarget,
|
|
29
|
+
} from "../../app/readiness.ts";
|
|
30
|
+
import type { WorkerInputs, WorkerOutputs } from "../../nano-generated/worker-io.d.ts";
|
|
31
|
+
|
|
32
|
+
// Input/output typed off the model data envelopes (`ReadinessProbeIn` / `ReadinessProbeOut` in
|
|
33
|
+
// readiness-gate.bpmn), the single source of truth for this worker's wire contract (ADR 0040).
|
|
34
|
+
// `probe` is a `nano:reference` to the nested `ReadinessProbe` shape, so it derives to the descriptor.
|
|
35
|
+
type In = WorkerInputs["pr.readiness-probe"];
|
|
36
|
+
type Out = WorkerOutputs["pr.readiness-probe"];
|
|
37
|
+
|
|
38
|
+
/** The message the gate's event-based gateway correlates on `=gateKey` to release the wait. */
|
|
39
|
+
export const READINESS_READY_MESSAGE = "readiness-ready";
|
|
40
|
+
|
|
41
|
+
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
|
42
|
+
|
|
43
|
+
/** The effective poll cadence: the descriptor's values, with `everyMs` defaulting through the env
|
|
44
|
+
* contract (`NANO_READINESS_POLL_EVERY_MS`) when the descriptor omits it, then the built-in
|
|
45
|
+
* defaults/clamps in {@link normalizePoll}. Reads the env value from the injected `env` (not the
|
|
46
|
+
* ambient `process.env`) so the loop is deterministic under test. */
|
|
47
|
+
function effectivePoll(
|
|
48
|
+
poll: ProbePoll | undefined,
|
|
49
|
+
env: Record<string, string | undefined>,
|
|
50
|
+
): ReturnType<typeof normalizePoll> {
|
|
51
|
+
const envEvery = Number(readEnvOr("NANO_READINESS_POLL_EVERY_MS", String(DEFAULT_EVERY_MS), env));
|
|
52
|
+
const everyMs = poll?.everyMs ?? (Number.isFinite(envEvery) && envEvery >= 1 ? envEvery : DEFAULT_EVERY_MS);
|
|
53
|
+
return normalizePoll({ everyMs, timeoutMs: poll?.timeoutMs, backoff: poll?.backoff });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The core poll loop, factored out with injectable I/O + clock + publisher so it is unit-testable
|
|
57
|
+
* without a network, a subprocess, or a real timer. Polls until ready (publishes, returns ready) or
|
|
58
|
+
* the local budget is exhausted (returns not-ready — the engine timer then bounds the wait). */
|
|
59
|
+
export async function pollUntilReady(deps: {
|
|
60
|
+
probe: ReadinessProbe;
|
|
61
|
+
gateKey: string;
|
|
62
|
+
probeTimeout?: string;
|
|
63
|
+
exec: ProbeExec;
|
|
64
|
+
env: Record<string, string | undefined>;
|
|
65
|
+
now: () => number;
|
|
66
|
+
wait: (ms: number) => Promise<void>;
|
|
67
|
+
publish: (detail: string) => Promise<void>;
|
|
68
|
+
log?: (msg: string) => void;
|
|
69
|
+
}): Promise<ProbeResult> {
|
|
70
|
+
const poll = effectivePoll(deps.probe.poll, deps.env);
|
|
71
|
+
// The local budget is bound to the gate PER INSTANCE: it adopts the same seeded `probeTimeout`
|
|
72
|
+
// process variable the gate's engine timers fire off (`=probeTimeout`), falling back to the env-
|
|
73
|
+
// derived twin only when it's absent. Recomputing from the ambient env would let the worker go
|
|
74
|
+
// silent while the engine timer is still waiting if the env changed after the instance was seeded.
|
|
75
|
+
const deadline = deps.now() + probeBudgetMs(deps.probeTimeout, deps.probe, deps.env);
|
|
76
|
+
const label = redactTarget(deps.probe);
|
|
77
|
+
let attempt = 0;
|
|
78
|
+
for (;;) {
|
|
79
|
+
const res: ProbeResult = await probeOnce(deps.probe, deps.exec, deps.env).catch((err) => ({
|
|
80
|
+
ready: false,
|
|
81
|
+
// Never surface the raw error message: a fetch/subprocess error can embed the target URL
|
|
82
|
+
// (with query tokens), command fragments, or other secrets. Log only the error class name.
|
|
83
|
+
detail: `probe error: ${err instanceof Error ? err.name : "Error"}`,
|
|
84
|
+
}));
|
|
85
|
+
deps.log?.(`readiness probe ${label} attempt ${attempt + 1}: ${res.detail}`);
|
|
86
|
+
if (res.ready) {
|
|
87
|
+
await deps.publish(res.detail);
|
|
88
|
+
return res;
|
|
89
|
+
}
|
|
90
|
+
attempt += 1;
|
|
91
|
+
const wait = nextDelay(attempt, poll);
|
|
92
|
+
if (deps.now() + wait >= deadline) {
|
|
93
|
+
return { ready: false, detail: "probe budget exhausted; engine timer bounds the wait" };
|
|
94
|
+
}
|
|
95
|
+
await deps.wait(wait);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Normalize the two required process variables the gate seeds, failing fast when either is unusable.
|
|
100
|
+
*
|
|
101
|
+
* Both are load-bearing for the worker↔gate binding, so a bad value must surface immediately rather
|
|
102
|
+
* than silently degrade:
|
|
103
|
+
* • `gateKey` is returned **raw** (only validated on a trimmed view). The gate's message subscription
|
|
104
|
+
* binds `correlationKey="=gateKey"` — the *untrimmed* process variable — so the publish key must
|
|
105
|
+
* match it byte-for-byte. Trimming the key we publish on would desync a whitespace-seeded `gateKey`
|
|
106
|
+
* from the gate's subscription, leaving a green probe to release the wait only via the timeout arm.
|
|
107
|
+
* • `probeTimeout` is required by the typed input envelope and drives BOTH the gate's engine timers
|
|
108
|
+
* (`=probeTimeout`). A missing/blank value used to fall back silently to the env-derived twin,
|
|
109
|
+
* breaking the per-instance bound (and masking a mis-seeded instance until it escalates); fail fast
|
|
110
|
+
* instead and pass the raw string through so worker and engine share one seeded bound. */
|
|
111
|
+
export function readGateVars(vars: { gateKey?: unknown; probeTimeout?: unknown }): {
|
|
112
|
+
gateKey: string;
|
|
113
|
+
probeTimeout: string;
|
|
114
|
+
} {
|
|
115
|
+
const gateKey = String(vars.gateKey ?? "");
|
|
116
|
+
if (gateKey.trim() === "") throw new Error("readiness-probe: 'gateKey' is required (blank correlation key)");
|
|
117
|
+
const { probeTimeout } = vars;
|
|
118
|
+
if (typeof probeTimeout !== "string" || probeTimeout.trim() === "")
|
|
119
|
+
throw new Error("readiness-probe: 'probeTimeout' is required (per-instance timer bound)");
|
|
120
|
+
return { gateKey, probeTimeout };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
124
|
+
const probe = parseProbe(job.variables.probe);
|
|
125
|
+
const { gateKey, probeTimeout } = readGateVars(job.variables);
|
|
126
|
+
const result = await pollUntilReady({
|
|
127
|
+
probe,
|
|
128
|
+
gateKey,
|
|
129
|
+
probeTimeout,
|
|
130
|
+
exec: defaultProbeExec(),
|
|
131
|
+
env: process.env,
|
|
132
|
+
now: () => Date.now(),
|
|
133
|
+
wait: sleep,
|
|
134
|
+
publish: async (detail) => {
|
|
135
|
+
await app.engine.publishMessage({
|
|
136
|
+
name: READINESS_READY_MESSAGE,
|
|
137
|
+
correlationKey: gateKey,
|
|
138
|
+
variables: { ready: true, detail },
|
|
139
|
+
});
|
|
140
|
+
},
|
|
141
|
+
log: (msg) => app.log.info(msg),
|
|
142
|
+
});
|
|
143
|
+
return { ready: result.ready, detail: result.detail };
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export default handler;
|