@nanobpm/nano-workforce 0.120.0 → 0.120.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/app/capabilityNeed.test.ts +4 -2
- package/app/capabilityNeed.ts +3 -1
- package/app/deliveryGraphCompiler.test.ts +72 -44
- package/app/deliveryGraphCompiler.ts +121 -19
- package/app/deliveryGraphRun.test.ts +2 -2
- package/app/deliveryRunner.test.ts +29 -17
- package/app/deliveryRunner.ts +31 -10
- package/app/feature.test.ts +3 -1
- package/app/feature.ts +9 -0
- package/app/featureReadiness.test.ts +7 -4
- package/app/featureReadiness.ts +8 -3
- package/app/plan.test.ts +1 -1
- package/app/plan.ts +9 -0
- package/app/planFanoutPreflight.test.ts +12 -12
- package/app/planLowering.test.ts +2 -0
- package/app/planLowering.ts +7 -2
- package/app/pollUserTasks.test.ts +49 -1
- package/app/readiness.test.ts +9 -0
- package/app/readiness.ts +25 -0
- package/app/service.ts +24 -6
- package/biome.json +24 -1
- package/e2e/delivery-graph.e2e.ts +2 -1
- package/e2e/feature-preflight.e2e.ts +2 -0
- package/e2e/inter-epic-dependency.e2e.ts +7 -1
- package/e2e/plan-fanout-preflight.e2e.ts +2 -0
- package/e2e/readiness-gate.e2e.ts +27 -11
- package/operations/compileDeliveryGraph.test.ts +3 -0
- package/operations/compileDeliveryGraph.ts +1 -1
- package/operations/dispatchDeliveryGraph.ts +1 -1
- package/operations/previewDeliveryGraph.ts +1 -1
- package/operations/startDeliveryGraph.ts +1 -1
- package/package.json +3 -2
- package/resources/processes/feature.bpmn +168 -52
- package/resources/processes/plan-fanout.bpmn +168 -52
- package/resources/processes/readiness-gate.bpmn +194 -84
- package/workers/readiness-probe/worker.test.ts +82 -238
- package/workers/readiness-probe/worker.ts +46 -128
|
@@ -1,29 +1,21 @@
|
|
|
1
1
|
// pr.readiness-probe — the ReadinessProbe executor (ADR 0001 §2, issue #258).
|
|
2
2
|
//
|
|
3
|
-
// The service-task half of the durable wait-gate (`resources/processes/readiness-gate.bpmn`).
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// gate's event-based gateway correlates
|
|
7
|
-
//
|
|
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).
|
|
3
|
+
// The service-task half of the durable wait-gate (`resources/processes/readiness-gate.bpmn`). Since
|
|
4
|
+
// #428, the worker performs exactly one probe per activation; retry cadence and timeout bounds live in
|
|
5
|
+
// BPMN timers owned by the deterministic engine. A green probe publishes the `readiness-ready` message
|
|
6
|
+
// the gate's event-based gateway correlates; a red probe returns not-ready and lets the model schedule
|
|
7
|
+
// the next activation or the final timeout path.
|
|
10
8
|
//
|
|
11
9
|
// Secrets never appear in the descriptor, a process variable, or a log line: a credential is read at
|
|
12
10
|
// execution time from the typed env-contract (`credentialEnv` → `readEnv`) and the probe's
|
|
13
11
|
// target/output is redacted before logging (ADR 0004 pinned decision 2).
|
|
14
12
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
15
|
-
import { readEnvOr } from "../../app/contracts.ts";
|
|
16
13
|
import {
|
|
17
|
-
DEFAULT_EVERY_MS,
|
|
18
14
|
defaultProbeExec,
|
|
19
15
|
makeCapabilityFallback,
|
|
20
|
-
nextDelay,
|
|
21
|
-
normalizePoll,
|
|
22
16
|
type ProbeExec,
|
|
23
|
-
type ProbePoll,
|
|
24
17
|
type ProbeResult,
|
|
25
18
|
parseProbe,
|
|
26
|
-
probeBudgetMs,
|
|
27
19
|
probeOnce,
|
|
28
20
|
READINESS_READY_MESSAGE,
|
|
29
21
|
type ReadinessProbe,
|
|
@@ -31,26 +23,18 @@ import {
|
|
|
31
23
|
} from "../../app/readiness.ts";
|
|
32
24
|
import type { WorkerInputs, WorkerOutputs } from "../../nano-generated/worker-io.d.ts";
|
|
33
25
|
|
|
34
|
-
// Input/output typed off the model data envelopes (`ReadinessProbeIn` / `ReadinessProbeOut`
|
|
35
|
-
//
|
|
36
|
-
// `probe` is a `nano:reference` to the nested `ReadinessProbe` shape, so it derives to the descriptor.
|
|
26
|
+
// Input/output typed off the model data envelopes (`ReadinessProbeIn` / `ReadinessProbeOut`), the
|
|
27
|
+
// single source of truth for this worker's wire contract (ADR 0040).
|
|
37
28
|
type In = WorkerInputs["pr.readiness-probe"];
|
|
38
29
|
type Out = WorkerOutputs["pr.readiness-probe"];
|
|
39
30
|
|
|
40
|
-
/** The message the gate's event-based gateway correlates on `=gateKey` to release the wait.
|
|
41
|
-
* Re-exported from the canonical source (`app/readiness.ts`) so the worker and every out-of-band
|
|
42
|
-
* publisher (the review-ready poller, #259) share ONE message name — no drift-prone local twin.
|
|
43
|
-
* Re-exports the binding already imported above rather than re-referencing the module. */
|
|
31
|
+
/** The message the gate's event-based gateway correlates on `=gateKey` to release the wait. */
|
|
44
32
|
export { READINESS_READY_MESSAGE };
|
|
45
33
|
|
|
46
|
-
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
|
47
|
-
|
|
48
34
|
/** The canonical gate-payload keys the matcher's `bind` must never override. `bind` is the
|
|
49
35
|
* kind-agnostic emit primitive (#274 Gap B), but it flows from matcher output into both the
|
|
50
|
-
* `readiness-ready` message variables and the worker output — so a matcher that
|
|
51
|
-
*
|
|
52
|
-
* contract. Strip them before spreading so a matcher can only ADD outputs, never overwrite the
|
|
53
|
-
* shape the gate correlates on. */
|
|
36
|
+
* `readiness-ready` message variables and the worker output — so a matcher that binds `ready`/`detail`
|
|
37
|
+
* could shadow the canonical payload and break the gate contract. */
|
|
54
38
|
const RESERVED_BIND_KEYS: ReadonlySet<string> = new Set(["ready", "detail"]);
|
|
55
39
|
export function safeBind(bind?: Record<string, string>): Record<string, string> {
|
|
56
40
|
if (!bind) return {};
|
|
@@ -61,110 +45,52 @@ export function safeBind(bind?: Record<string, string>): Record<string, string>
|
|
|
61
45
|
return out;
|
|
62
46
|
}
|
|
63
47
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
* defaults/clamps in {@link normalizePoll}. Reads the env value from the injected `env` (not the
|
|
67
|
-
* ambient `process.env`) so the loop is deterministic under test. */
|
|
68
|
-
function effectivePoll(
|
|
69
|
-
poll: ProbePoll | undefined,
|
|
70
|
-
env: Record<string, string | undefined>,
|
|
71
|
-
): ReturnType<typeof normalizePoll> {
|
|
72
|
-
const envEvery = Number(readEnvOr("NANO_READINESS_POLL_EVERY_MS", String(DEFAULT_EVERY_MS), env));
|
|
73
|
-
const everyMs = poll?.everyMs ?? (Number.isFinite(envEvery) && envEvery >= 1 ? envEvery : DEFAULT_EVERY_MS);
|
|
74
|
-
return normalizePoll({ everyMs, timeoutMs: poll?.timeoutMs, backoff: poll?.backoff });
|
|
48
|
+
function errorDetail(err: unknown): string {
|
|
49
|
+
return `probe error: ${err instanceof Error ? err.name : "Error"}`;
|
|
75
50
|
}
|
|
76
51
|
|
|
77
|
-
/**
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
export async function
|
|
52
|
+
/** Single activation of the readiness probe. The engine owns retry cadence and the timeout boundary;
|
|
53
|
+
* this function performs one `probeOnce`, publishes only when ready, and optionally performs exactly
|
|
54
|
+
* one empirical fallback on a model-marked last attempt. */
|
|
55
|
+
export async function probeSingleShot(deps: {
|
|
81
56
|
probe: ReadinessProbe;
|
|
82
|
-
gateKey: string;
|
|
83
|
-
probeTimeout?: string;
|
|
84
57
|
exec: ProbeExec;
|
|
85
58
|
env: Record<string, string | undefined>;
|
|
86
|
-
now: () => number;
|
|
87
|
-
wait: (ms: number) => Promise<void>;
|
|
88
59
|
publish: (detail: string, bind?: Record<string, string>) => Promise<void>;
|
|
89
|
-
|
|
90
|
-
* giving up — the seam for the gated empirical fallback (decision 5). A ready result is published
|
|
91
|
-
* (with its bind) and returned; anything else keeps the not-ready outcome so the engine timer
|
|
92
|
-
* bounds the wait as usual. Kept generic so the loop stays kind-agnostic. */
|
|
60
|
+
lastAttempt?: boolean;
|
|
93
61
|
fallback?: () => Promise<ProbeResult | null>;
|
|
94
62
|
log?: (msg: string) => void;
|
|
95
63
|
}): Promise<ProbeResult> {
|
|
96
|
-
const poll = effectivePoll(deps.probe.poll, deps.env);
|
|
97
|
-
// The local budget is bound to the gate PER INSTANCE: it adopts the same seeded `probeTimeout`
|
|
98
|
-
// process variable the gate's engine timers fire off (`=probeTimeout`), falling back to the env-
|
|
99
|
-
// derived twin only when it's absent. Recomputing from the ambient env would let the worker go
|
|
100
|
-
// silent while the engine timer is still waiting if the env changed after the instance was seeded.
|
|
101
|
-
const deadline = deps.now() + probeBudgetMs(deps.probeTimeout, deps.probe, deps.env);
|
|
102
64
|
const label = redactTarget(deps.probe);
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
// Never swallow a fallback failure silently — it degrades to "not ready" and is hard
|
|
125
|
-
// to diagnose. Log only the error class name (no message), consistent with the main
|
|
126
|
-
// probeOnce error handling, so a target URL/token in the message never leaks.
|
|
127
|
-
deps.log?.(
|
|
128
|
-
`readiness probe ${label} fallback error: ${err instanceof Error ? err.name : "Error"}`,
|
|
129
|
-
);
|
|
130
|
-
return null;
|
|
131
|
-
})
|
|
132
|
-
: null;
|
|
133
|
-
if (settled?.ready) {
|
|
134
|
-
deps.log?.(`readiness probe ${label} fallback: ${settled.detail}`);
|
|
135
|
-
await deps.publish(settled.detail, settled.bind);
|
|
136
|
-
return settled;
|
|
137
|
-
}
|
|
138
|
-
// Surface the fallback's (already-redacted) diagnostic when one ran and reported not-ready, so a
|
|
139
|
-
// timeout escalation is actionable instead of a generic "budget exhausted". `settled` is null when
|
|
140
|
-
// there is no fallback or it threw (logged above), in which case only the generic detail applies.
|
|
141
|
-
return {
|
|
142
|
-
ready: false,
|
|
143
|
-
detail: settled
|
|
144
|
-
? `probe budget exhausted; engine timer bounds the wait (fallback: ${settled.detail})`
|
|
145
|
-
: "probe budget exhausted; engine timer bounds the wait",
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
// Clamp the sleep to the time left until `deadline` so the worker keeps probing right up to the
|
|
149
|
-
// SAME bound the engine timer enforces. Sleeping a full `nextDelay` unconditionally would stop
|
|
150
|
-
// probing up to one backoff early — a window where readiness could flip to ready but no
|
|
151
|
-
// `readiness-ready` message is published, forcing a spurious timeout escalation.
|
|
152
|
-
await deps.wait(Math.min(nextDelay(attempt, poll), remaining));
|
|
65
|
+
const res: ProbeResult = await probeOnce(deps.probe, deps.exec, deps.env).catch((err) => ({
|
|
66
|
+
ready: false,
|
|
67
|
+
detail: errorDetail(err),
|
|
68
|
+
}));
|
|
69
|
+
deps.log?.(`readiness probe ${label}: ${res.detail}`);
|
|
70
|
+
if (res.ready) {
|
|
71
|
+
await deps.publish(res.detail, res.bind);
|
|
72
|
+
return res;
|
|
73
|
+
}
|
|
74
|
+
if (deps.lastAttempt !== true) return res;
|
|
75
|
+
|
|
76
|
+
const settled = deps.fallback
|
|
77
|
+
? await deps.fallback().catch((err) => {
|
|
78
|
+
deps.log?.(`readiness probe ${label} fallback error: ${err instanceof Error ? err.name : "Error"}`);
|
|
79
|
+
return null;
|
|
80
|
+
})
|
|
81
|
+
: null;
|
|
82
|
+
if (settled?.ready) {
|
|
83
|
+
deps.log?.(`readiness probe ${label} fallback: ${settled.detail}`);
|
|
84
|
+
await deps.publish(settled.detail, settled.bind);
|
|
85
|
+
return settled;
|
|
153
86
|
}
|
|
87
|
+
return {
|
|
88
|
+
ready: false,
|
|
89
|
+
detail: settled ? `gate boundary reached (fallback: ${settled.detail})` : "gate boundary reached",
|
|
90
|
+
};
|
|
154
91
|
}
|
|
155
92
|
|
|
156
|
-
/** Normalize the
|
|
157
|
-
*
|
|
158
|
-
* Both are load-bearing for the worker↔gate binding, so a bad value must surface immediately rather
|
|
159
|
-
* than silently degrade:
|
|
160
|
-
* • `gateKey` is returned **raw** (only validated on a trimmed view). The gate's message subscription
|
|
161
|
-
* binds `correlationKey="=gateKey"` — the *untrimmed* process variable — so the publish key must
|
|
162
|
-
* match it byte-for-byte. Trimming the key we publish on would desync a whitespace-seeded `gateKey`
|
|
163
|
-
* from the gate's subscription, leaving a green probe to release the wait only via the timeout arm.
|
|
164
|
-
* • `probeTimeout` is required by the typed input envelope and drives BOTH the gate's engine timers
|
|
165
|
-
* (`=probeTimeout`). A missing/blank value used to fall back silently to the env-derived twin,
|
|
166
|
-
* breaking the per-instance bound (and masking a mis-seeded instance until it escalates); fail fast
|
|
167
|
-
* instead and pass the raw string through so worker and engine share one seeded bound. */
|
|
93
|
+
/** Normalize the required process variables the gate seeds, failing fast when either is unusable. */
|
|
168
94
|
export function readGateVars(vars: { gateKey?: unknown; probeTimeout?: unknown }): {
|
|
169
95
|
gateKey: string;
|
|
170
96
|
probeTimeout: string;
|
|
@@ -179,26 +105,18 @@ export function readGateVars(vars: { gateKey?: unknown; probeTimeout?: unknown }
|
|
|
179
105
|
|
|
180
106
|
const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
181
107
|
const probe = parseProbe(job.variables.probe);
|
|
182
|
-
const { gateKey
|
|
108
|
+
const { gateKey } = readGateVars(job.variables);
|
|
183
109
|
const exec = defaultProbeExec();
|
|
184
|
-
const result = await
|
|
110
|
+
const result = await probeSingleShot({
|
|
185
111
|
probe,
|
|
186
|
-
gateKey,
|
|
187
|
-
probeTimeout,
|
|
188
112
|
exec,
|
|
189
113
|
env: process.env,
|
|
190
|
-
|
|
191
|
-
wait: sleep,
|
|
192
|
-
// The gated empirical fallback (decision 5) — a no-op for every kind but a `capability` probe
|
|
193
|
-
// that declares a `verifyCommand`, so the deterministic provenance lookup stays the default.
|
|
114
|
+
lastAttempt: job.variables.lastAttempt === true,
|
|
194
115
|
fallback: makeCapabilityFallback(probe, exec, process.env),
|
|
195
116
|
publish: async (detail, bind) => {
|
|
196
117
|
await app.engine.publishMessage({
|
|
197
118
|
name: READINESS_READY_MESSAGE,
|
|
198
119
|
correlationKey: gateKey,
|
|
199
|
-
// `bind` is the kind-agnostic emit primitive (#274 Gap B): forward whatever the matcher
|
|
200
|
-
// discovered (e.g. `resolvedArtifact`) into the message so the gate surfaces it as output.
|
|
201
|
-
// Reserved keys are stripped so a bind can only ADD outputs, never shadow `ready`/`detail`.
|
|
202
120
|
variables: { ready: true, detail, ...safeBind(bind) },
|
|
203
121
|
});
|
|
204
122
|
},
|