@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.
@@ -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[] = (node.columns ?? []).map((c: Json) => c.field).filter(Boolean);
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 a grid to plan_reviews (plan-review trace)");
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 columns, so pin them. Assert against the
221
- // visibly displayed `columns` (not `fields`, which also holds binding refs like orderBy.field) so
222
- // a column silently dropped from the grid UI can't pass by being referenced elsewhere.
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 grid must expose the "${col}" column`,
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 wait = nextDelay(attempt, poll);
92
- if (deps.now() + wait >= deadline) {
93
- return { ready: false, detail: "probe budget exhausted; engine timer bounds the wait" };
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
- await deps.wait(wait);
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: defaultProbeExec(),
184
+ exec,
131
185
  env: process.env,
132
186
  now: () => Date.now(),
133
187
  wait: sleep,
134
- publish: async (detail) => {
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
- variables: { ready: true, detail },
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;