@nanobpm/nano-workforce 0.83.0 → 0.85.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 +14 -0
- package/app/feature.ts +33 -11
- package/app/featureGateway.test.ts +60 -2
- package/app/readiness.test.ts +187 -0
- package/app/readiness.ts +220 -9
- package/app/stage.test.ts +45 -1
- package/app/stage.ts +29 -0
- package/db/migrations/040_feature_escalation_open.sql +33 -0
- package/package.json +2 -2
- package/pages/epic-detail.page.json +16 -31
- package/pages/feature.page.json +2 -2
- package/pages/overview.page.json +2 -2
- package/resources/processes/readiness-gate.bpmn +5 -0
- package/scripts/pages-contract.test.ts +22 -7
- package/workers/readiness-probe/worker.test.ts +147 -1
- package/workers/readiness-probe/worker.ts +70 -10
|
@@ -112,6 +112,12 @@ function filterFields(filter: Json): string[] {
|
|
|
112
112
|
return filter.map((f: Json) => f?.field).filter(Boolean);
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
// Pull `{{field}}` interpolation names out of a prose renderer's header template (nano-ide#274).
|
|
116
|
+
function templateFields(tpl: Json): string[] {
|
|
117
|
+
if (typeof tpl !== "string") return [];
|
|
118
|
+
return [...tpl.matchAll(/\{\{([^{}]+)\}\}/g)].map((m) => m[1].trim()).filter(Boolean);
|
|
119
|
+
}
|
|
120
|
+
|
|
115
121
|
function collectRefs(page: string, node: Json, out: Ref[]): void {
|
|
116
122
|
if (Array.isArray(node)) {
|
|
117
123
|
for (const v of node) collectRefs(page, v, out);
|
|
@@ -120,10 +126,17 @@ function collectRefs(page: string, node: Json, out: Ref[]): void {
|
|
|
120
126
|
if (!node || typeof node !== "object") return;
|
|
121
127
|
|
|
122
128
|
// Top-level datasource grid: the datasource lives at `node.data`, while `columns`, `rowKey`,
|
|
123
|
-
// `filter`/`tabs`, and `detail` are siblings on the same `node` (the grid props).
|
|
129
|
+
// `filter`/`tabs`, and `detail` are siblings on the same `node` (the grid props). A `prose`
|
|
130
|
+
// renderer (nano-ide#274) binds the same `node.data` but has no `columns`: its displayed content
|
|
131
|
+
// is the header template's `{{field}}` refs plus the single `body` field, so fold those in as
|
|
132
|
+
// "columns" so the field-existence + surface guards below apply to prose sections too.
|
|
124
133
|
const data = node.data;
|
|
125
134
|
if (data && data.kind === "datasource" && typeof data.table === "string") {
|
|
126
|
-
const columns: string[] =
|
|
135
|
+
const columns: string[] = [
|
|
136
|
+
...(node.columns ?? []).map((c: Json) => c.field),
|
|
137
|
+
...templateFields(node.header),
|
|
138
|
+
...(typeof node.body === "string" ? [node.body] : []),
|
|
139
|
+
].filter(Boolean);
|
|
127
140
|
// Every reference that resolves to a column on this table — the runtime 400s on any of them if
|
|
128
141
|
// it names a column the migrations never created, so all must be guarded, not just displayed
|
|
129
142
|
// columns. `detail.fields`/`detail.linkField` render columns of the same top-level row.
|
|
@@ -215,17 +228,19 @@ test("issue #87: plan_reviews is surfaced on the per-epic detail page", async ()
|
|
|
215
228
|
const onEpicDetail = refs.some(
|
|
216
229
|
(r) => r.page === "epic-detail.page.json" && r.table === "plan_reviews",
|
|
217
230
|
);
|
|
218
|
-
assert(onEpicDetail, "epic-detail.page.json must bind
|
|
231
|
+
assert(onEpicDetail, "epic-detail.page.json must bind the plan-review trace to plan_reviews");
|
|
219
232
|
|
|
220
|
-
// The trace is only useful with the verdict + critique
|
|
221
|
-
// visibly displayed `columns` (not `fields`, which also holds binding refs like orderBy.field)
|
|
222
|
-
//
|
|
233
|
+
// The trace is only useful with the verdict + critique surfaced, so pin them. Assert against the
|
|
234
|
+
// visibly displayed `columns` (not `fields`, which also holds binding refs like orderBy.field) —
|
|
235
|
+
// for the `prose` renderer (nano-ide#274) these are the header template's `{{round}}`/`{{approved}}`
|
|
236
|
+
// refs plus the `findings` body — so a field silently dropped from the UI can't pass by being
|
|
237
|
+
// referenced elsewhere.
|
|
223
238
|
const required = ["round", "approved", "findings"];
|
|
224
239
|
for (const r of refs.filter((x) => x.table === "plan_reviews")) {
|
|
225
240
|
for (const col of required) {
|
|
226
241
|
assert(
|
|
227
242
|
r.columns.includes(col),
|
|
228
|
-
`${r.page}: plan_reviews
|
|
243
|
+
`${r.page}: plan_reviews trace must surface the "${col}" field`,
|
|
229
244
|
);
|
|
230
245
|
}
|
|
231
246
|
}
|
|
@@ -9,7 +9,7 @@ import { test } from "node:test";
|
|
|
9
9
|
import { assert, assertEquals, assertRejects } from "#test-assert";
|
|
10
10
|
import type { CommandResult, HttpResponse, ProbeExec, ReadinessProbe } from "../../app/readiness.ts";
|
|
11
11
|
import { parseProbe } from "../../app/readiness.ts";
|
|
12
|
-
import handler, { pollUntilReady, READINESS_READY_MESSAGE, readGateVars } from "./worker.ts";
|
|
12
|
+
import handler, { pollUntilReady, READINESS_READY_MESSAGE, readGateVars, safeBind } from "./worker.ts";
|
|
13
13
|
|
|
14
14
|
// A virtual clock: `now()` advances only when the loop's `wait(ms)` is called, so a never-ready
|
|
15
15
|
// probe races to its deadline in zero real time (no setTimeout) and the test can never hang.
|
|
@@ -40,6 +40,40 @@ function execReturning(seq: Array<HttpResponse>): ProbeExec {
|
|
|
40
40
|
const httpProbe = (poll: ReadinessProbe["poll"]): ReadinessProbe =>
|
|
41
41
|
parseProbe({ kind: "http", target: "https://x/health", poll });
|
|
42
42
|
|
|
43
|
+
test("safeBind: strips reserved keys (ready/detail) so a bind can only ADD outputs, never shadow the payload", () => {
|
|
44
|
+
const cleaned = safeBind({ resolvedArtifact: "@nanobpm/urban@0.54.0", ready: "false", detail: "spoofed" });
|
|
45
|
+
assertEquals(cleaned.resolvedArtifact, "@nanobpm/urban@0.54.0");
|
|
46
|
+
assertEquals("ready" in cleaned, false, "a bound 'ready' can never override the canonical payload");
|
|
47
|
+
assertEquals("detail" in cleaned, false, "a bound 'detail' can never override the canonical payload");
|
|
48
|
+
assertEquals(Object.keys(safeBind(undefined)).length, 0, "an absent bind yields an empty object");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("pollUntilReady: a fallback that throws is caught, logged by class name (no leak), and stays not-ready", async () => {
|
|
52
|
+
const clock = fakeClock();
|
|
53
|
+
const seen: string[] = [];
|
|
54
|
+
let publishes = 0;
|
|
55
|
+
const res = await pollUntilReady({
|
|
56
|
+
probe: httpProbe({ everyMs: 5, timeoutMs: 30, backoff: "fixed" }),
|
|
57
|
+
gateKey: "gate-fallback-throws",
|
|
58
|
+
exec: execReturning([{ status: 503, body: "" }]),
|
|
59
|
+
env: {},
|
|
60
|
+
now: clock.now,
|
|
61
|
+
wait: clock.wait,
|
|
62
|
+
publish: async () => {
|
|
63
|
+
publishes += 1;
|
|
64
|
+
},
|
|
65
|
+
fallback: async () => {
|
|
66
|
+
throw new Error("boom at https://h/p?token=s3cr3t");
|
|
67
|
+
},
|
|
68
|
+
log: (msg) => seen.push(msg),
|
|
69
|
+
});
|
|
70
|
+
assert(!res.ready, "a throwing fallback keeps the not-ready outcome for the engine timer");
|
|
71
|
+
assertEquals(publishes, 0, "nothing is published when the fallback throws");
|
|
72
|
+
const all = seen.join("\n");
|
|
73
|
+
assert(all.includes("fallback error: Error"), "the fallback error is logged by class name");
|
|
74
|
+
assert(!all.includes("s3cr3t"), "the raw error message (with its secret) must not leak");
|
|
75
|
+
});
|
|
76
|
+
|
|
43
77
|
test("pollUntilReady: publishes readiness-ready once and returns ready when a probe goes green", async () => {
|
|
44
78
|
const clock = fakeClock();
|
|
45
79
|
const published: Array<{ detail: string }> = [];
|
|
@@ -59,6 +93,90 @@ test("pollUntilReady: publishes readiness-ready once and returns ready when a pr
|
|
|
59
93
|
assertEquals(published.length, 1, "exactly one readiness message was published");
|
|
60
94
|
});
|
|
61
95
|
|
|
96
|
+
test("pollUntilReady: forwards a matcher's bind through publish into the message variables (#274 Gap B)", async () => {
|
|
97
|
+
// A capability probe resolves a version; its bind must flow through publish so the gate can surface
|
|
98
|
+
// resolvedArtifact as an output. The gh-api stub returns a release whose provenance carries #274.
|
|
99
|
+
const clock = fakeClock();
|
|
100
|
+
const published: Array<{ detail: string; bind?: Record<string, string> }> = [];
|
|
101
|
+
const payload = JSON.stringify([{ tag_name: "@nanobpm/urban@0.54.0", body: "## Provenance\n- #274\n" }]);
|
|
102
|
+
const exec: ProbeExec = {
|
|
103
|
+
async httpGet() {
|
|
104
|
+
return { status: 0, body: "" };
|
|
105
|
+
},
|
|
106
|
+
async run(): Promise<CommandResult> {
|
|
107
|
+
return { code: 0, stdout: payload, stderr: "" };
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
const res = await pollUntilReady({
|
|
111
|
+
probe: parseProbe({
|
|
112
|
+
kind: "capability",
|
|
113
|
+
target: "github-releases:nanobpm/nano-ide",
|
|
114
|
+
match: { capabilityRef: "nano-ide#274", package: "@nanobpm/urban" },
|
|
115
|
+
poll: { everyMs: 5, timeoutMs: 5000, backoff: "fixed" },
|
|
116
|
+
}),
|
|
117
|
+
gateKey: "gate-cap",
|
|
118
|
+
exec,
|
|
119
|
+
env: {},
|
|
120
|
+
now: clock.now,
|
|
121
|
+
wait: clock.wait,
|
|
122
|
+
publish: async (detail, bind) => {
|
|
123
|
+
published.push({ detail, bind });
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
assert(res.ready, "the capability edge resolved");
|
|
127
|
+
assertEquals(published.length, 1, "exactly one readiness message was published");
|
|
128
|
+
assertEquals(published[0]?.bind?.resolvedArtifact, "@nanobpm/urban@0.54.0", "the resolved artifact flowed through the bind");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("pollUntilReady: the gated fallback fires ONCE at budget exhaustion and can still resolve+publish", async () => {
|
|
132
|
+
// Deterministic provenance never resolves (no matching release), so the loop exhausts its budget —
|
|
133
|
+
// the gate boundary. The fallback thunk then verifies empirically and publishes a bound version.
|
|
134
|
+
const clock = fakeClock();
|
|
135
|
+
const published: Array<{ bind?: Record<string, string> }> = [];
|
|
136
|
+
let fallbackCalls = 0;
|
|
137
|
+
const res = await pollUntilReady({
|
|
138
|
+
probe: httpProbe({ everyMs: 5, timeoutMs: 30, backoff: "fixed" }),
|
|
139
|
+
gateKey: "gate-fallback",
|
|
140
|
+
exec: execReturning([{ status: 503, body: "" }]),
|
|
141
|
+
env: {},
|
|
142
|
+
now: clock.now,
|
|
143
|
+
wait: clock.wait,
|
|
144
|
+
publish: async (_detail, bind) => {
|
|
145
|
+
published.push({ bind });
|
|
146
|
+
},
|
|
147
|
+
fallback: async () => {
|
|
148
|
+
fallbackCalls += 1;
|
|
149
|
+
return { ready: true, detail: "verified empirically", bind: { resolvedArtifact: "@nanobpm/urban@0.60.0" } };
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
assert(res.ready, "the boundary fallback resolved the edge");
|
|
153
|
+
assertEquals(fallbackCalls, 1, "the fallback fires exactly once, at the boundary — never per attempt");
|
|
154
|
+
assertEquals(published[0]?.bind?.resolvedArtifact, "@nanobpm/urban@0.60.0");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("pollUntilReady: a fallback that does not resolve leaves the not-ready outcome for the engine timer", async () => {
|
|
158
|
+
const clock = fakeClock();
|
|
159
|
+
let publishes = 0;
|
|
160
|
+
const res = await pollUntilReady({
|
|
161
|
+
probe: httpProbe({ everyMs: 5, timeoutMs: 30, backoff: "fixed" }),
|
|
162
|
+
gateKey: "gate-fallback-noop",
|
|
163
|
+
exec: execReturning([{ status: 503, body: "" }]),
|
|
164
|
+
env: {},
|
|
165
|
+
now: clock.now,
|
|
166
|
+
wait: clock.wait,
|
|
167
|
+
publish: async () => {
|
|
168
|
+
publishes += 1;
|
|
169
|
+
},
|
|
170
|
+
fallback: async () => ({ ready: false, detail: "still nothing" }),
|
|
171
|
+
});
|
|
172
|
+
assert(!res.ready, "an inconclusive fallback keeps the wait bounded by the engine timer");
|
|
173
|
+
assertEquals(publishes, 0, "no readiness signal is published when the fallback does not resolve");
|
|
174
|
+
assert(
|
|
175
|
+
res.detail.includes("still nothing"),
|
|
176
|
+
"the inconclusive fallback's (redacted) diagnostic is surfaced in the returned detail, not discarded",
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
|
|
62
180
|
test("pollUntilReady: a never-green probe exhausts its budget and returns not-ready WITHOUT publishing", async () => {
|
|
63
181
|
const clock = fakeClock();
|
|
64
182
|
let publishes = 0;
|
|
@@ -79,6 +197,34 @@ test("pollUntilReady: a never-green probe exhausts its budget and returns not-re
|
|
|
79
197
|
assert(clock.now() <= 100, "the loop stopped at (or before) its declared budget");
|
|
80
198
|
});
|
|
81
199
|
|
|
200
|
+
test("pollUntilReady: keeps probing up to the deadline — a flip-to-ready in the final backoff window is caught, not missed", async () => {
|
|
201
|
+
// everyMs 10, budget 25: three deterministic probes land at t=0,10,20. A full-backoff sleep from
|
|
202
|
+
// t=20 would jump to t=30 (past the 25ms bound) and stop probing early, missing a green at t=25 and
|
|
203
|
+
// forcing a spurious timeout escalation. The clamp keeps probing to the same bound the engine holds.
|
|
204
|
+
const clock = fakeClock();
|
|
205
|
+
let publishes = 0;
|
|
206
|
+
const exec = execReturning([
|
|
207
|
+
{ status: 503, body: "" },
|
|
208
|
+
{ status: 503, body: "" },
|
|
209
|
+
{ status: 503, body: "" },
|
|
210
|
+
{ status: 200, body: "ok" },
|
|
211
|
+
]);
|
|
212
|
+
const res = await pollUntilReady({
|
|
213
|
+
probe: httpProbe({ everyMs: 10, timeoutMs: 25, backoff: "fixed" }),
|
|
214
|
+
gateKey: "gate-final-window",
|
|
215
|
+
exec,
|
|
216
|
+
env: {},
|
|
217
|
+
now: clock.now,
|
|
218
|
+
wait: clock.wait,
|
|
219
|
+
publish: async () => {
|
|
220
|
+
publishes += 1;
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
assert(res.ready, "the flip-to-ready inside the final backoff window was probed and caught");
|
|
224
|
+
assertEquals(publishes, 1, "the readiness signal was published exactly once");
|
|
225
|
+
assert(clock.now() <= 25, "the worker never probed past the engine-enforced deadline");
|
|
226
|
+
});
|
|
227
|
+
|
|
82
228
|
test("pollUntilReady: an I/O throw is caught and treated as not-ready (never rejects), and its raw message is not leaked", async () => {
|
|
83
229
|
const clock = fakeClock();
|
|
84
230
|
const seen: string[] = [];
|
|
@@ -16,6 +16,7 @@ import { readEnvOr } from "../../app/contracts.ts";
|
|
|
16
16
|
import {
|
|
17
17
|
DEFAULT_EVERY_MS,
|
|
18
18
|
defaultProbeExec,
|
|
19
|
+
makeCapabilityFallback,
|
|
19
20
|
nextDelay,
|
|
20
21
|
normalizePoll,
|
|
21
22
|
type ProbeExec,
|
|
@@ -40,6 +41,22 @@ export const READINESS_READY_MESSAGE = "readiness-ready";
|
|
|
40
41
|
|
|
41
42
|
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
|
42
43
|
|
|
44
|
+
/** The canonical gate-payload keys the matcher's `bind` must never override. `bind` is the
|
|
45
|
+
* kind-agnostic emit primitive (#274 Gap B), but it flows from matcher output into both the
|
|
46
|
+
* `readiness-ready` message variables and the worker output — so a matcher that (accidentally or
|
|
47
|
+
* maliciously) binds `ready`/`detail` could shadow the canonical payload and break the gate
|
|
48
|
+
* contract. Strip them before spreading so a matcher can only ADD outputs, never overwrite the
|
|
49
|
+
* shape the gate correlates on. */
|
|
50
|
+
const RESERVED_BIND_KEYS: ReadonlySet<string> = new Set(["ready", "detail"]);
|
|
51
|
+
export function safeBind(bind?: Record<string, string>): Record<string, string> {
|
|
52
|
+
if (!bind) return {};
|
|
53
|
+
const out: Record<string, string> = {};
|
|
54
|
+
for (const [k, v] of Object.entries(bind)) {
|
|
55
|
+
if (!RESERVED_BIND_KEYS.has(k)) out[k] = v;
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
43
60
|
/** The effective poll cadence: the descriptor's values, with `everyMs` defaulting through the env
|
|
44
61
|
* contract (`NANO_READINESS_POLL_EVERY_MS`) when the descriptor omits it, then the built-in
|
|
45
62
|
* defaults/clamps in {@link normalizePoll}. Reads the env value from the injected `env` (not the
|
|
@@ -64,7 +81,12 @@ export async function pollUntilReady(deps: {
|
|
|
64
81
|
env: Record<string, string | undefined>;
|
|
65
82
|
now: () => number;
|
|
66
83
|
wait: (ms: number) => Promise<void>;
|
|
67
|
-
publish: (detail: string) => Promise<void>;
|
|
84
|
+
publish: (detail: string, bind?: Record<string, string>) => Promise<void>;
|
|
85
|
+
/** An OPTIONAL last-attempt thunk run ONCE at the gate boundary (local budget exhausted) before
|
|
86
|
+
* giving up — the seam for the gated empirical fallback (decision 5). A ready result is published
|
|
87
|
+
* (with its bind) and returned; anything else keeps the not-ready outcome so the engine timer
|
|
88
|
+
* bounds the wait as usual. Kept generic so the loop stays kind-agnostic. */
|
|
89
|
+
fallback?: () => Promise<ProbeResult | null>;
|
|
68
90
|
log?: (msg: string) => void;
|
|
69
91
|
}): Promise<ProbeResult> {
|
|
70
92
|
const poll = effectivePoll(deps.probe.poll, deps.env);
|
|
@@ -84,15 +106,46 @@ export async function pollUntilReady(deps: {
|
|
|
84
106
|
}));
|
|
85
107
|
deps.log?.(`readiness probe ${label} attempt ${attempt + 1}: ${res.detail}`);
|
|
86
108
|
if (res.ready) {
|
|
87
|
-
await deps.publish(res.detail);
|
|
109
|
+
await deps.publish(res.detail, res.bind);
|
|
88
110
|
return res;
|
|
89
111
|
}
|
|
90
112
|
attempt += 1;
|
|
91
|
-
const
|
|
92
|
-
if (
|
|
93
|
-
|
|
113
|
+
const remaining = deadline - deps.now();
|
|
114
|
+
if (remaining <= 0) {
|
|
115
|
+
// The gate boundary: the deterministic poll is exhausted. Give the gated fallback (if any) ONE
|
|
116
|
+
// empirical attempt before conceding to the engine timer — a capability provenance under-reports
|
|
117
|
+
// can still resolve here, exactly once, never per unrelated release.
|
|
118
|
+
const settled = deps.fallback
|
|
119
|
+
? await deps.fallback().catch((err) => {
|
|
120
|
+
// Never swallow a fallback failure silently — it degrades to "not ready" and is hard
|
|
121
|
+
// to diagnose. Log only the error class name (no message), consistent with the main
|
|
122
|
+
// probeOnce error handling, so a target URL/token in the message never leaks.
|
|
123
|
+
deps.log?.(
|
|
124
|
+
`readiness probe ${label} fallback error: ${err instanceof Error ? err.name : "Error"}`,
|
|
125
|
+
);
|
|
126
|
+
return null;
|
|
127
|
+
})
|
|
128
|
+
: null;
|
|
129
|
+
if (settled?.ready) {
|
|
130
|
+
deps.log?.(`readiness probe ${label} fallback: ${settled.detail}`);
|
|
131
|
+
await deps.publish(settled.detail, settled.bind);
|
|
132
|
+
return settled;
|
|
133
|
+
}
|
|
134
|
+
// Surface the fallback's (already-redacted) diagnostic when one ran and reported not-ready, so a
|
|
135
|
+
// timeout escalation is actionable instead of a generic "budget exhausted". `settled` is null when
|
|
136
|
+
// there is no fallback or it threw (logged above), in which case only the generic detail applies.
|
|
137
|
+
return {
|
|
138
|
+
ready: false,
|
|
139
|
+
detail: settled
|
|
140
|
+
? `probe budget exhausted; engine timer bounds the wait (fallback: ${settled.detail})`
|
|
141
|
+
: "probe budget exhausted; engine timer bounds the wait",
|
|
142
|
+
};
|
|
94
143
|
}
|
|
95
|
-
|
|
144
|
+
// Clamp the sleep to the time left until `deadline` so the worker keeps probing right up to the
|
|
145
|
+
// SAME bound the engine timer enforces. Sleeping a full `nextDelay` unconditionally would stop
|
|
146
|
+
// probing up to one backoff early — a window where readiness could flip to ready but no
|
|
147
|
+
// `readiness-ready` message is published, forcing a spurious timeout escalation.
|
|
148
|
+
await deps.wait(Math.min(nextDelay(attempt, poll), remaining));
|
|
96
149
|
}
|
|
97
150
|
}
|
|
98
151
|
|
|
@@ -123,24 +176,31 @@ export function readGateVars(vars: { gateKey?: unknown; probeTimeout?: unknown }
|
|
|
123
176
|
const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
124
177
|
const probe = parseProbe(job.variables.probe);
|
|
125
178
|
const { gateKey, probeTimeout } = readGateVars(job.variables);
|
|
179
|
+
const exec = defaultProbeExec();
|
|
126
180
|
const result = await pollUntilReady({
|
|
127
181
|
probe,
|
|
128
182
|
gateKey,
|
|
129
183
|
probeTimeout,
|
|
130
|
-
exec
|
|
184
|
+
exec,
|
|
131
185
|
env: process.env,
|
|
132
186
|
now: () => Date.now(),
|
|
133
187
|
wait: sleep,
|
|
134
|
-
|
|
188
|
+
// The gated empirical fallback (decision 5) — a no-op for every kind but a `capability` probe
|
|
189
|
+
// that declares a `verifyCommand`, so the deterministic provenance lookup stays the default.
|
|
190
|
+
fallback: makeCapabilityFallback(probe, exec, process.env),
|
|
191
|
+
publish: async (detail, bind) => {
|
|
135
192
|
await app.engine.publishMessage({
|
|
136
193
|
name: READINESS_READY_MESSAGE,
|
|
137
194
|
correlationKey: gateKey,
|
|
138
|
-
|
|
195
|
+
// `bind` is the kind-agnostic emit primitive (#274 Gap B): forward whatever the matcher
|
|
196
|
+
// discovered (e.g. `resolvedArtifact`) into the message so the gate surfaces it as output.
|
|
197
|
+
// Reserved keys are stripped so a bind can only ADD outputs, never shadow `ready`/`detail`.
|
|
198
|
+
variables: { ready: true, detail, ...safeBind(bind) },
|
|
139
199
|
});
|
|
140
200
|
},
|
|
141
201
|
log: (msg) => app.log.info(msg),
|
|
142
202
|
});
|
|
143
|
-
return { ready: result.ready, detail: result.detail };
|
|
203
|
+
return { ready: result.ready, detail: result.detail, ...safeBind(result.bind) };
|
|
144
204
|
};
|
|
145
205
|
|
|
146
206
|
export default handler;
|