@nanobpm/nano-workforce 0.138.0 → 0.138.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/AGENTS.md +68 -3
- package/CHANGELOG.md +12 -0
- package/app/deliveryGraphCompiler.test.ts +93 -0
- package/app/deliveryGraphCompiler.ts +82 -8
- package/app/readiness.test.ts +63 -0
- package/app/readiness.ts +57 -4
- package/package.json +1 -1
- package/resources/processes/readiness-gate.bpmn +1 -0
- package/workers/readiness-probe/worker.test.ts +56 -2
- package/workers/readiness-probe/worker.ts +26 -3
package/AGENTS.md
CHANGED
|
@@ -211,6 +211,42 @@ Two different `nano:dataEnvelope` uses have different rules — don't conflate t
|
|
|
211
211
|
deriving a worker's array inputs from the model this way over a hand-typed
|
|
212
212
|
`interface In`; don't "fix" a modelled `list="true"` array back to a joined
|
|
213
213
|
scalar.
|
|
214
|
+
- **A service-task/worker `dataEnvelope.in` field is read from PROCESS scope, not
|
|
215
|
+
task-local `ioMapping`.** (This applies to service-task/worker job envelopes; a
|
|
216
|
+
`dataEnvelope.in` on a **message subscription** — e.g. `CapsResolved` at
|
|
217
|
+
`plan-fanout.bpmn:12` — is instead populated from the correlated **message
|
|
218
|
+
payload**, so this gotcha does not apply there.) At runtime the engine populates
|
|
219
|
+
each declared `.in` field *by name* from the process variables in scope — a
|
|
220
|
+
`<zeebe:ioMapping>` input that synthesises a **new** name with no backing process
|
|
221
|
+
variable is silently ignored, so the field arrives **blank**. The engine itself
|
|
222
|
+
raises no incident for that *omitted* field (an `.in` name with no backing
|
|
223
|
+
variable resolves to null and blanks) — but this is distinct from a FEEL
|
|
224
|
+
`ioMapping` that **errors**: on the pinned engine (`@nanobpm/engine-wasm` 0.8.1's
|
|
225
|
+
`IO_MAPPING_ERROR` fix — before it, a failed mapping silently blanked, e.g. a
|
|
226
|
+
blocked converge-gate's escalation *question*; see the `e2e/*escalation*.e2e.ts`
|
|
227
|
+
comments) a failed FEEL mapping now raises an **incident** rather than blanking
|
|
228
|
+
through. A consumer that fails fast on the blank value will also incident (e.g.
|
|
229
|
+
`pr.readiness-probe` once read an empty `gateKey`;
|
|
230
|
+
`readGateVars` in `workers/readiness-probe/worker.ts` now *throws* on a blank
|
|
231
|
+
`gateKey`, creating an incident). Tasks like `ensure-base-branch` work only because
|
|
232
|
+
`repo`/`baseBranch` already exist as process variables. Seed any field a task must
|
|
233
|
+
read — **that is not already created in the task's visible process scope** — as a
|
|
234
|
+
real process variable at plan start (e.g. in `startPlan`); don't rely on
|
|
235
|
+
`ioMapping` to synthesise it. (A field already materialised in scope by the
|
|
236
|
+
process — e.g. `ReadinessProbeIn.probe`, seeded per multi-instance child via
|
|
237
|
+
`inputElement="probe"` in `resources/processes/feature.bpmn` — needs no such
|
|
238
|
+
seeding.) A **constant/literal** ioMapping source is different again: a
|
|
239
|
+
`<zeebe:input source="=true" target="lastAttempt"/>` (the readiness-gate
|
|
240
|
+
boundary's last-attempt marker — `readiness-gate.bpmn:125`, `feature.bpmn:105`,
|
|
241
|
+
`plan-fanout.bpmn:198`, and the generated `deliveryGraphCompiler.ts:1011`) is
|
|
242
|
+
*evaluated* and materialised into the job's scope, so it needs **no** backing
|
|
243
|
+
process variable — its value is the literal, not a reference to an (absent)
|
|
244
|
+
variable. The "arrives blank" failure above is specific to a field whose value
|
|
245
|
+
must be *derived from a process variable that isn't in scope* (the `gateKey`
|
|
246
|
+
case). Do **not** "fix" a working literal marker like `lastAttempt` by seeding
|
|
247
|
+
it as a process variable: besides being unnecessary, seeding a var an
|
|
248
|
+
in-subprocess gateway reads re-introduces the multi-instance `=null`-shadow
|
|
249
|
+
gotcha above.
|
|
214
250
|
|
|
215
251
|
## Urban page runtime: rendering primitives are not JS-truthy
|
|
216
252
|
|
|
@@ -235,6 +271,21 @@ silently — cheap to avoid, annoying to debug after the fact.
|
|
|
235
271
|
`showWhenField` is hidden for `0`/`null`/`""` alike — so a `0`-or-`NULL` flag
|
|
236
272
|
correctly hides it either way. (This is why the same flag can need `NULL` for a
|
|
237
273
|
badge yet work as `0` for a `showWhenField` button.)
|
|
274
|
+
- **Renderer kinds are a version-pinned set — verify against the installed
|
|
275
|
+
package, not an issue reference.** The page renderers are a finite set fixed by
|
|
276
|
+
the pinned `@nanobpm/urban`; a version *range* only guarantees *some* matching
|
|
277
|
+
build is installed. Do **not** assume a primitive exists because an issue,
|
|
278
|
+
changelog, or *Links* section mentions it — `urban check` (a CI gate) rejects
|
|
279
|
+
unknown renderer kinds only *after* you've authored a page around a phantom one
|
|
280
|
+
(epic #254 bounced three review rounds asserting a page-level "stepper" that
|
|
281
|
+
never shipped). Confirm the kind against the installed `RENDERERS` map
|
|
282
|
+
(`node_modules/@nanobpm/urban/dist/runtime/core/modules/pages.js`) and the kinds
|
|
283
|
+
already used across `pages/*.page.json`. Note a **nano-ide issue can ship a data
|
|
284
|
+
read-model, not a page renderer** (nano-ide#254 shipped the lineage projection, a data
|
|
285
|
+
primitive) — read *what kind* of primitive it delivers. Build composite visuals
|
|
286
|
+
as a `dataGrid` column renderer over stored columns (e.g. the `"kind":
|
|
287
|
+
"pipeline"` column in `pages/feature.page.json`), not a page-level primitive,
|
|
288
|
+
unless you've verified that primitive exists in the installed build.
|
|
238
289
|
|
|
239
290
|
### The top nav has a single source of truth — edit `pages/_nav.json`
|
|
240
291
|
|
|
@@ -271,10 +322,24 @@ Migrations live in `db/migrations/*.sql` and are **auto-applied on boot** from
|
|
|
271
322
|
- Number a new migration after the current highest prefix (they apply in order).
|
|
272
323
|
Check `origin/main`, not your branch point — a fan-out epic branch forks at one
|
|
273
324
|
prefix while `main` keeps advancing, so the branch-local "next" number collides
|
|
274
|
-
on merge.
|
|
325
|
+
on merge. **In a *simultaneous* fan-out wave, checking `origin/main` isn't
|
|
326
|
+
enough**: every sibling forks at the same commit, sees the same highest prefix,
|
|
327
|
+
and independently takes the same next number (main hasn't advanced yet), so the
|
|
328
|
+
planner must **pre-assign each slice a disjoint prefix block at decomposition
|
|
329
|
+
time** (e.g. give slice A `NNN`–`NNN+1`, slice B `NNN+2`–`NNN+3`, plus a separate
|
|
330
|
+
cleanup block — start the whole allocation *after* the current highest committed
|
|
331
|
+
prefix, never at fixed literals like `060`, which are long occupied) rather than
|
|
332
|
+
have each
|
|
333
|
+
agent compute "the next free" prefix. (This is the same "coarsen parallel work on
|
|
334
|
+
a shared surface" principle as the one-task-owns-each-`.bpmn` rule above — a
|
|
335
|
+
migration-prefix block is a shared numbering surface just like a `.bpmn`
|
|
336
|
+
diagram.) Two files must never share a prefix; `npm run check:migrations`
|
|
275
337
|
(a CI gate) enforces this and fails the build on any new duplicate. Because a
|
|
276
|
-
prefix collision only exists in the *union* of two branches
|
|
277
|
-
|
|
338
|
+
**merge-skew** prefix collision only exists in the *union* of two branches (a
|
|
339
|
+
single branch that adds two same-prefix files is a self-collision
|
|
340
|
+
`check:migrations` already catches on the PR itself), this gate — like
|
|
341
|
+
`layout:check`, the navigation-index check (`sync:nav:check`), and a catch-all
|
|
342
|
+
committed-artifact backstop (`git diff --exit-code`) — is also re-run on the
|
|
278
343
|
merge queue's **prospective merged commit** and on **push to `main`** by
|
|
279
344
|
`.github/workflows/invariants.yml` (issue #366), so a merge-skew collision is
|
|
280
345
|
blocked at merge time or fails a `main`-scoped build within minutes rather than
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.138.2](https://github.com/nanobpm/nano-workforce/compare/v0.138.1...v0.138.2) (2026-08-24)
|
|
2
|
+
|
|
3
|
+
### Documentation
|
|
4
|
+
|
|
5
|
+
* **agents:** consolidate retro feedback ([#268](https://github.com/nanobpm/nano-workforce/issues/268), [#322](https://github.com/nanobpm/nano-workforce/issues/322), [#449](https://github.com/nanobpm/nano-workforce/issues/449)); drop stale [#415](https://github.com/nanobpm/nano-workforce/issues/415) ([#518](https://github.com/nanobpm/nano-workforce/issues/518)) ([3c47cb2](https://github.com/nanobpm/nano-workforce/commit/3c47cb21d079b6b9f5470abacb0190373208d0ca)), closes [#416](https://github.com/nanobpm/nano-workforce/issues/416) [#887](https://github.com/nanobpm/nano-workforce/issues/887) [#254](https://github.com/nanobpm/nano-workforce/issues/254) [#254](https://github.com/nanobpm/nano-workforce/issues/254) [nano-ide#254](https://github.com/nanobpm/nano-ide/issues/254)
|
|
6
|
+
|
|
7
|
+
## [0.138.1](https://github.com/nanobpm/nano-workforce/compare/v0.138.0...v0.138.1) (2026-08-24)
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
* **delivery-graph:** self-diagnosing + resumable capability wait-gate escalation ([#514](https://github.com/nanobpm/nano-workforce/issues/514)) ([#515](https://github.com/nanobpm/nano-workforce/issues/515)) ([b13ae50](https://github.com/nanobpm/nano-workforce/commit/b13ae50bf9638a5eabd12342e9c3d1ba7d70eaab)), closes [#499](https://github.com/nanobpm/nano-workforce/issues/499)
|
|
12
|
+
|
|
1
13
|
## [0.138.0](https://github.com/nanobpm/nano-workforce/compare/v0.137.0...v0.138.0) (2026-08-24)
|
|
2
14
|
|
|
3
15
|
### Features
|
|
@@ -160,6 +160,99 @@ test("#499 escalation context: an agent-node timeout escalation seeds a context
|
|
|
160
160
|
assert(escBlock.includes('="none"') && escBlock.includes('target="emitMode"'), "the escalation labels its emit field N/A so the generic form hides the inert value input");
|
|
161
161
|
});
|
|
162
162
|
|
|
163
|
+
// The #514 motivating case: a `capability` wait gate that emits a version, escalating on a
|
|
164
|
+
// false-negative. Its escalation must be self-diagnosing (Defect A) AND resumable with its emit
|
|
165
|
+
// (Defect B). Two emit shapes (`version` → `detail`, `artifact` → `resolvedArtifact`) exercise the
|
|
166
|
+
// per-emit-type mapping.
|
|
167
|
+
const CAP_GATE = {
|
|
168
|
+
name: "capability gate",
|
|
169
|
+
nodes: [
|
|
170
|
+
{ id: "gv", kind: "agent", agent: { jobType: "senior:feature", prompt: "ship the rollup" } },
|
|
171
|
+
{
|
|
172
|
+
id: "n2",
|
|
173
|
+
kind: "wait",
|
|
174
|
+
wait: {
|
|
175
|
+
kind: "capability",
|
|
176
|
+
target: "github-releases:nanobpm/nano-ide",
|
|
177
|
+
match: { package: "@nanobpm/urban", capabilityRef: "#468" },
|
|
178
|
+
onTimeout: "escalate",
|
|
179
|
+
},
|
|
180
|
+
emits: [{ name: "publishedVersion", type: "version" }],
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
id: "n3",
|
|
184
|
+
kind: "wait",
|
|
185
|
+
wait: {
|
|
186
|
+
kind: "capability",
|
|
187
|
+
target: "github-releases:nanobpm/nano-ide",
|
|
188
|
+
match: { package: "@nanobpm/urban", capabilityRef: "#469" },
|
|
189
|
+
onTimeout: "escalate",
|
|
190
|
+
},
|
|
191
|
+
emits: [{ name: "artifactRef", type: "artifact" }],
|
|
192
|
+
},
|
|
193
|
+
{ id: "sink", kind: "connector", connector: { target: "npm:install", dedupeKey: "c1" } },
|
|
194
|
+
],
|
|
195
|
+
edges: [
|
|
196
|
+
{ from: "gv", to: "n2" },
|
|
197
|
+
{ from: "n2.publishedVersion", to: "n3" },
|
|
198
|
+
{ from: "n3.artifactRef", to: "sink" },
|
|
199
|
+
],
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/** The subProcess element id the compiler assigned to a node (elements are positional `n<k>`, not the
|
|
203
|
+
* node id). Located via the subProcess `name="<kind>: <nodeId>"`. */
|
|
204
|
+
function elementForNode(bpmn: string, nodeId: string): string {
|
|
205
|
+
const m = bpmn.match(new RegExp(`<bpmn:subProcess id="([^"]+)" name="[^"]*: ${nodeId}"`));
|
|
206
|
+
assert(m, `a subProcess for node ${nodeId} exists`);
|
|
207
|
+
return m![1];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Slice a compiled BPMN to a node's escalation user task body. */
|
|
211
|
+
function escBlockForNode(bpmn: string, nodeId: string): string {
|
|
212
|
+
const esc = `delivery-human-task__${elementForNode(bpmn, nodeId)}__esc`;
|
|
213
|
+
const start = bpmn.indexOf(`<bpmn:userTask id="${esc}"`);
|
|
214
|
+
assert(start !== -1, `escalation task ${esc} for node ${nodeId} exists`);
|
|
215
|
+
return bpmn.slice(start, bpmn.indexOf("</bpmn:userTask>", start));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
test("#514 Defect A: a capability wait-gate escalation surfaces the probe's last detail, target/match, and observed releases so it is self-diagnosing", async () => {
|
|
219
|
+
const r = await compileOk(CAP_GATE);
|
|
220
|
+
const esc = escBlockForNode(r.bpmn, "n2");
|
|
221
|
+
// The read-only prompt context now folds in the RUNTIME last probe detail + observed candidate summary.
|
|
222
|
+
assert(esc.includes('target="prompt"'), "the escalation seeds a prompt context line");
|
|
223
|
+
assert(esc.includes("Last probe: ") && esc.includes("if (is defined(detail)) then string(detail)"), "the prompt folds in the last probe detail");
|
|
224
|
+
assert(esc.includes("Observed: ") && esc.includes("if (is defined(observed)) then string(observed)"), "the prompt folds in the observed candidate summary");
|
|
225
|
+
// Discrete diagnostic task variables the form/agent can bind directly.
|
|
226
|
+
assert(esc.includes('target="probeDetail"'), "the escalation surfaces the probe's last detail as a discrete variable");
|
|
227
|
+
assert(esc.includes('target="observedReleases"'), "the escalation surfaces the observed candidate releases");
|
|
228
|
+
assert(/source="=nodeInputs\.[^"]+\.probe\.target" target="probeTarget"/.test(esc), "the escalation surfaces the resolved probe target");
|
|
229
|
+
assert(/source="=nodeInputs\.[^"]+\.probe\.match" target="probeMatch"/.test(esc), "the escalation surfaces the resolved probe match");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("#514 Defect B: a resumed wait-node escalation maps the operator-supplied value onto the node's emit source (version→detail, artifact→resolvedArtifact)", async () => {
|
|
233
|
+
const r = await compileOk(CAP_GATE);
|
|
234
|
+
// Red before the fix: the wait escalation forced emitMode="none" (hiding the value field) and carried
|
|
235
|
+
// NO output mapping, so a resume published `<el>_<fact> = null`, starving the downstream consumer.
|
|
236
|
+
const escV = escBlockForNode(r.bpmn, "n2");
|
|
237
|
+
// A `version` emit is sourced from `detail` — the operator's captured `value` must be mapped there.
|
|
238
|
+
assert(escV.includes('="typed"') && escV.includes('target="emitMode"'), "a wait node with emits PRESENTS its value field on escalation, not 'none'");
|
|
239
|
+
assert(escV.includes("publishedVersion (version)") && escV.includes('target="emitLabel"'), "the emit label names the awaited fact");
|
|
240
|
+
assert(/source="=if \(is defined\(value\)\) then value else null" target="detail"/.test(escV), "the operator's value is mapped onto the version emit's source var (detail)");
|
|
241
|
+
|
|
242
|
+
const escA = escBlockForNode(r.bpmn, "n3");
|
|
243
|
+
// An `artifact` emit is ALSO sourced from the generic form's single `value` field (the form has no
|
|
244
|
+
// `resolvedArtifact` field), mapped onto the artifact emit's source var (resolvedArtifact) — so an
|
|
245
|
+
// artifact wait-node escalation is actually resumable via the UI.
|
|
246
|
+
assert(/source="=if \(is defined\(value\)\) then value else null" target="resolvedArtifact"/.test(escA), "the operator's value is mapped onto the artifact emit's source var (resolvedArtifact)");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("#514 Defect B: a service-node escalation (agent) stays inert — no emit field, no resume output mapping (only wait resumes)", async () => {
|
|
250
|
+
const r = await compileOk(CAP_GATE);
|
|
251
|
+
const esc = escBlockForNode(r.bpmn, "gv");
|
|
252
|
+
assert(esc.includes('="none"') && esc.includes('target="emitMode"'), "an agent-node escalation keeps its emit field hidden");
|
|
253
|
+
assert(!esc.includes("<bpmn:output") && !esc.includes("<zeebe:output"), "an agent-node escalation carries no emit-source output mapping");
|
|
254
|
+
});
|
|
255
|
+
|
|
163
256
|
|
|
164
257
|
test("rejects unknown kind (by construction) with a path-qualified error, nothing compiled", async () => {
|
|
165
258
|
const errors = await compileFail({
|
|
@@ -882,7 +882,7 @@ function innerBodyLines(w: NodeWiring): string[] {
|
|
|
882
882
|
case "connector":
|
|
883
883
|
return serviceBodyLines(el, node.id, `type="${DELEGATE_TASK_TYPE.connector}"`, [], `connector → ${node.connector.target}`);
|
|
884
884
|
case "wait":
|
|
885
|
-
return waitBodyLines(el, node
|
|
885
|
+
return waitBodyLines(el, node);
|
|
886
886
|
case "human":
|
|
887
887
|
return humanBodyLines(el, node.id);
|
|
888
888
|
default:
|
|
@@ -951,8 +951,19 @@ function serviceBodyLines(
|
|
|
951
951
|
/** `wait` body: `start → pr.readiness-probe (poll) → ready? → end`, escalating on not-ready or on the
|
|
952
952
|
* `=probeTimeout` engine bound. The probe polls its OWN target, so an unrelated upstream event can
|
|
953
953
|
* never flip it to ready (#274/S2 concurrency-correctness); the `pr` kind (S2) binds `mergedSha`. */
|
|
954
|
-
function waitBodyLines(el: string,
|
|
954
|
+
function waitBodyLines(el: string, node: DeliveryNode): string[] {
|
|
955
|
+
const nodeId = node.id;
|
|
955
956
|
const esc = escalationTaskElement(el);
|
|
957
|
+
const emits = normaliseEmits(node);
|
|
958
|
+
// Defect A: read-only probe diagnostics seeded onto the escalation task so the operator/agent can
|
|
959
|
+
// tell a genuine "not published yet" from a transient false-negative — the probe's last detail, the
|
|
960
|
+
// resolved target/match, and a compact summary of the candidate releases the probe observed.
|
|
961
|
+
const diagnosticInputs = [
|
|
962
|
+
{ source: "=if (is defined(detail)) then detail else null", target: "probeDetail" },
|
|
963
|
+
{ source: "=if (is defined(observed)) then observed else null", target: "observedReleases" },
|
|
964
|
+
{ source: `=nodeInputs.${el}.probe.target`, target: "probeTarget" },
|
|
965
|
+
{ source: `=nodeInputs.${el}.probe.match`, target: "probeMatch" },
|
|
966
|
+
];
|
|
956
967
|
return [
|
|
957
968
|
` <bpmn:startEvent id="${el}_start"><bpmn:outgoing>${el}_i0</bpmn:outgoing></bpmn:startEvent>`,
|
|
958
969
|
` <bpmn:subProcess id="${el}_probeLoop" name="Probe readiness loop: ${escapeXml(nodeId)}">`,
|
|
@@ -962,6 +973,7 @@ function waitBodyLines(el: string, nodeId: string): string[] {
|
|
|
962
973
|
' <zeebe:output source="=if (is defined(detail)) then detail else null" target="detail" />',
|
|
963
974
|
' <zeebe:output source="=if (is defined(resolvedArtifact)) then resolvedArtifact else null" target="resolvedArtifact" />',
|
|
964
975
|
' <zeebe:output source="=if (is defined(mergedSha)) then mergedSha else null" target="mergedSha" />',
|
|
976
|
+
' <zeebe:output source="=if (is defined(observed)) then observed else null" target="observed" />',
|
|
965
977
|
" </zeebe:ioMapping>",
|
|
966
978
|
" </bpmn:extensionElements>",
|
|
967
979
|
` <bpmn:incoming>${el}_i0</bpmn:incoming>`,
|
|
@@ -1024,7 +1036,8 @@ function waitBodyLines(el: string, nodeId: string): string[] {
|
|
|
1024
1036
|
nodeId,
|
|
1025
1037
|
[`${el}_i4`],
|
|
1026
1038
|
`${el}_i5`,
|
|
1027
|
-
|
|
1039
|
+
waitEscalationContextFeel(nodeId),
|
|
1040
|
+
{ resume: { kind: node.kind, emits }, diagnosticInputs },
|
|
1028
1041
|
),
|
|
1029
1042
|
` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_i1</bpmn:incoming><bpmn:incoming>${el}_i5</bpmn:incoming><bpmn:incoming>${el}_i7</bpmn:incoming></bpmn:endEvent>`,
|
|
1030
1043
|
flow(`${el}_i0`, `${el}_start`, `${el}_probeLoop`),
|
|
@@ -1084,15 +1097,60 @@ function humanBodyLines(el: string, nodeId: string): string[] {
|
|
|
1084
1097
|
* convention) that a human OR an agent (ADR 0046) answers to unstick a stalled node. `contextFeel` is
|
|
1085
1098
|
* a FEEL expression yielding the context line seeded onto the generic form's read-only prompt field
|
|
1086
1099
|
* (issue #499) — e.g. "Node n1 (senior:feature) exceeded its SLA (PT30M); …" — so the operator can see
|
|
1087
|
-
* WHICH node timed out and that in-flight work may already exist, instead of a blank form.
|
|
1088
|
-
*
|
|
1100
|
+
* WHICH node timed out and that in-flight work may already exist, instead of a blank form.
|
|
1101
|
+
*
|
|
1102
|
+
* `opts.resume` turns an inert escalation into a RESUMABLE one (issue #514 Defect B): when the parked
|
|
1103
|
+
* `wait` node declares emits, the form must both PRESENT its typed-value field (so `emitMode`/
|
|
1104
|
+
* `emitLabel` are derived from those emits, not forced to "none") and, on completion, MAP the
|
|
1105
|
+
* operator-supplied value onto the node's emit-source variable (`detail` for scalar/version,
|
|
1106
|
+
* `resolvedArtifact` for artifact, `mergedSha` for a merge oid — {@link factSourceVar}). Without that
|
|
1107
|
+
* mapping a naive resume publishes `<el>_<fact> = null`, silently starving the downstream consumer.
|
|
1108
|
+
* `opts.diagnosticInputs` seeds read-only probe context (issue #514 Defect A) onto the same task so the
|
|
1109
|
+
* operator can see WHY the gate escalated (its last probe detail + observed candidate releases). */
|
|
1089
1110
|
function escalationTaskLines(
|
|
1090
1111
|
esc: string,
|
|
1091
1112
|
nodeId: string,
|
|
1092
1113
|
incoming: readonly string[],
|
|
1093
1114
|
outgoing: string,
|
|
1094
1115
|
contextFeel: string,
|
|
1116
|
+
opts?: {
|
|
1117
|
+
resume?: { kind: DeliveryNode["kind"]; emits: readonly DeliveryFact[] };
|
|
1118
|
+
diagnosticInputs?: readonly { source: string; target: string }[];
|
|
1119
|
+
},
|
|
1095
1120
|
): string[] {
|
|
1121
|
+
const emits = opts?.resume?.emits ?? [];
|
|
1122
|
+
const emitMode = emits.length > 0 ? "typed" : "none";
|
|
1123
|
+
const emitLabel = emits.map((e) => `${e.name} (${e.type})`).join(", ");
|
|
1124
|
+
const inputs: string[] = [
|
|
1125
|
+
` <zeebe:input ${attr("source", contextFeel)} target="prompt" />`,
|
|
1126
|
+
` <zeebe:input ${attr("source", `=${feelStr(nodeId)}`)} target="nodeId" />`,
|
|
1127
|
+
` <zeebe:input ${attr("source", `=${feelStr(emitMode)}`)} target="emitMode" />`,
|
|
1128
|
+
];
|
|
1129
|
+
if (emits.length > 0) {
|
|
1130
|
+
inputs.push(` <zeebe:input ${attr("source", `=${feelStr(emitLabel)}`)} target="emitLabel" />`);
|
|
1131
|
+
}
|
|
1132
|
+
for (const di of opts?.diagnosticInputs ?? []) {
|
|
1133
|
+
inputs.push(` <zeebe:input ${attr("source", di.source)} target="${di.target}" />`);
|
|
1134
|
+
}
|
|
1135
|
+
// Defect B: map the operator's captured typed value onto the node's emit-source var, so the
|
|
1136
|
+
// subProcess output ioMapping publishes the SAME `<el>_<fact>` shape a normally-completing node does.
|
|
1137
|
+
const outputs: string[] = [];
|
|
1138
|
+
if (opts?.resume) {
|
|
1139
|
+
const seen = new Set<string>();
|
|
1140
|
+
for (const fact of emits) {
|
|
1141
|
+
const target = factSourceVar(opts.resume.kind, fact);
|
|
1142
|
+
if (seen.has(target)) continue;
|
|
1143
|
+
seen.add(target);
|
|
1144
|
+
// The generic escalation form (`GENERIC_HUMAN_FORM`) captures the operator's answer in a single
|
|
1145
|
+
// `value` field — it has NO `resolvedArtifact` field — so every emit type resumes from `value`,
|
|
1146
|
+
// mapped onto that fact's emit-source var (artifact→resolvedArtifact, version→detail, …). Sourcing
|
|
1147
|
+
// an artifact from a `resolvedArtifact` form field the form never sets would publish null and make
|
|
1148
|
+
// an artifact wait-node escalation non-resumable via the UI.
|
|
1149
|
+
outputs.push(
|
|
1150
|
+
` <zeebe:output ${attr("source", `=if (is defined(value)) then value else null`)} target="${target}" />`,
|
|
1151
|
+
);
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1096
1154
|
return [
|
|
1097
1155
|
` <bpmn:userTask id="${esc}" name="Escalate: ${escapeXml(nodeId)}">`,
|
|
1098
1156
|
" <bpmn:extensionElements>",
|
|
@@ -1100,9 +1158,8 @@ function escalationTaskLines(
|
|
|
1100
1158
|
" <zeebe:userTask />",
|
|
1101
1159
|
' <zeebe:assignmentDefinition candidateGroups="operators" />',
|
|
1102
1160
|
" <zeebe:ioMapping>",
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
` <zeebe:input ${attr("source", '="none"')} target="emitMode" />`,
|
|
1161
|
+
...inputs,
|
|
1162
|
+
...outputs,
|
|
1106
1163
|
" </zeebe:ioMapping>",
|
|
1107
1164
|
" </bpmn:extensionElements>",
|
|
1108
1165
|
...incoming.map((id) => ` <bpmn:incoming>${id}</bpmn:incoming>`),
|
|
@@ -1120,6 +1177,23 @@ function escalationContextFeel(nodeId: string, descriptor: string, timeoutVar: s
|
|
|
1120
1177
|
return `=${head} + string(${timeoutVar}) + ${feelStr(`)${tail}`)}`;
|
|
1121
1178
|
}
|
|
1122
1179
|
|
|
1180
|
+
/** The escalation context line for a `wait` gate (issue #514 Defect A). Extends the base #499 line with
|
|
1181
|
+
* the probe's RUNTIME last `detail` and its observed-candidate summary (`observed`), so a human/agent
|
|
1182
|
+
* reading the (read-only) prompt can immediately tell a genuine "not published yet" from a transient
|
|
1183
|
+
* false-negative — without hunting for the internal variables. Both are folded in defensively (an
|
|
1184
|
+
* as-yet-unset var renders "—", never a FEEL error). */
|
|
1185
|
+
function waitEscalationContextFeel(nodeId: string): string {
|
|
1186
|
+
const base = escalationContextFeel(
|
|
1187
|
+
nodeId,
|
|
1188
|
+
"readiness gate",
|
|
1189
|
+
"probeTimeout",
|
|
1190
|
+
" before its ReadinessProbe went green — decide how to proceed.",
|
|
1191
|
+
);
|
|
1192
|
+
const lastProbe = `(if (is defined(detail)) then string(detail) else "—")`;
|
|
1193
|
+
const observed = `(if (is defined(observed)) then string(observed) else "—")`;
|
|
1194
|
+
return `${base} + " Last probe: " + ${lastProbe} + ". Observed: " + ${observed} + "."`;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1123
1197
|
/** A plain `<bpmn:sequenceFlow>` (6-space indented). */
|
|
1124
1198
|
function flow(id: string, source: string, target: string): string {
|
|
1125
1199
|
return ` <bpmn:sequenceFlow id="${id}" sourceRef="${source}" targetRef="${target}" />`;
|
package/app/readiness.test.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
normalizePoll,
|
|
29
29
|
parseProbe,
|
|
30
30
|
parsePrTarget,
|
|
31
|
+
summariseCapabilityCandidates,
|
|
31
32
|
parsePrView,
|
|
32
33
|
parseReleases,
|
|
33
34
|
parseReleasesTarget,
|
|
@@ -214,6 +215,68 @@ test("matchCapability: the bare '#274' ref form resolves identically to 'nano-id
|
|
|
214
215
|
assertEquals(res.bind?.resolvedArtifact, "@nanobpm/urban@1.2.3");
|
|
215
216
|
});
|
|
216
217
|
|
|
218
|
+
// ── #514 Defect A: the capability gate is self-diagnosing on escalation ──────────────────────────
|
|
219
|
+
|
|
220
|
+
test("#514 Defect A summariseCapabilityCandidates: lists candidate releases (newest first) and whether each references the ref", () => {
|
|
221
|
+
const summary = summariseCapabilityCandidates(capMatch, [
|
|
222
|
+
rel("@nanobpm/urban@0.9.0", [200]),
|
|
223
|
+
rel("@nanobpm/urban@0.82.0", [274]),
|
|
224
|
+
rel("@nanobpm/other@1.0.0", [274]), // wrong package — excluded from the per-package summary
|
|
225
|
+
]);
|
|
226
|
+
assertStringIncludes(summary, "2 @nanobpm/urban release(s) observed");
|
|
227
|
+
assertStringIncludes(summary, "1 referencing #274");
|
|
228
|
+
// Newest candidate first, and the ref-flag per candidate.
|
|
229
|
+
assertStringIncludes(summary, "@nanobpm/urban@0.82.0 refs #274");
|
|
230
|
+
assertStringIncludes(summary, "@nanobpm/urban@0.9.0 no #274");
|
|
231
|
+
assert(!summary.includes("@nanobpm/other"), "a sibling package never leaks into the per-package summary");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("#514 Defect A summariseCapabilityCandidates: no matching releases yields a plain observed note (never throws)", () => {
|
|
235
|
+
assertEquals(summariseCapabilityCandidates(capMatch, []), "no @nanobpm/urban releases observed");
|
|
236
|
+
assertEquals(summariseCapabilityCandidates(capMatch, [rel("@nanobpm/other@1.0.0", [274])]), "no @nanobpm/urban releases observed");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("#514 Defect A summariseCapabilityCandidates: an unparseable capabilityRef is labelled distinctly and omits the meaningless per-candidate refs/no flags", () => {
|
|
240
|
+
const badRef = { ...capMatch, capabilityRef: "not-a-number" };
|
|
241
|
+
const summary = summariseCapabilityCandidates(badRef, [rel("@nanobpm/urban@0.82.0", [274])]);
|
|
242
|
+
assertStringIncludes(summary, "1 @nanobpm/urban release(s) observed (unparseable ref)");
|
|
243
|
+
assertStringIncludes(summary, "@nanobpm/urban@0.82.0");
|
|
244
|
+
assert(!summary.includes("(no ref configured)"), "a configured-but-unparseable ref is NOT reported as missing");
|
|
245
|
+
assert(!summary.includes("no #") && !summary.includes("refs #"), "no meaningless per-candidate ref flags when there is no parseable number");
|
|
246
|
+
assert(!summary.includes("referencing"), "no aggregate referencing count when there is no parseable number");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("#514 Defect A summariseCapabilityCandidates: a genuinely absent ref is labelled '(no ref configured)'", () => {
|
|
250
|
+
const noRef = { ...capMatch, capabilityRef: undefined };
|
|
251
|
+
const summary = summariseCapabilityCandidates(noRef, [rel("@nanobpm/urban@0.82.0", [274])]);
|
|
252
|
+
assertStringIncludes(summary, "1 @nanobpm/urban release(s) observed (no ref configured)");
|
|
253
|
+
assert(!summary.includes("unparseable"), "a missing ref is not reported as unparseable");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("#514 Defect A summariseCapabilityCandidates: caps the newest 8 candidates so a >100-release repo cannot bloat the form/log", () => {
|
|
257
|
+
const many: GithubRelease[] = [];
|
|
258
|
+
for (let i = 1; i <= 20; i++) many.push(rel(`@nanobpm/urban@0.${i}.0`, [200]));
|
|
259
|
+
const summary = summariseCapabilityCandidates(capMatch, many);
|
|
260
|
+
assertStringIncludes(summary, "20 @nanobpm/urban release(s) observed");
|
|
261
|
+
assertStringIncludes(summary, "(+12 more)");
|
|
262
|
+
assertStringIncludes(summary, "@nanobpm/urban@0.20.0 no #274"); // newest is shown
|
|
263
|
+
assert(!summary.includes("@nanobpm/urban@0.1.0 "), "the oldest is trimmed by the cap");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("#514 Defect A matchCapability surfaces `observed` on the not-ready path — a false-negative is diagnosable, not contextless", () => {
|
|
267
|
+
// Red before the fix: matchCapability returned only {ready, detail} with no observed-release context,
|
|
268
|
+
// so an escalation could not tell a genuine 'not published yet' from a transient false-negative.
|
|
269
|
+
const notReady = matchCapability(capMatch, [rel("@nanobpm/urban@0.82.0", [200])]);
|
|
270
|
+
assert(!notReady.ready);
|
|
271
|
+
assertStringIncludes(notReady.observed ?? "", "@nanobpm/urban@0.82.0 no #274");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("#514 Defect A matchCapability surfaces `observed` on the ready path too (the resolving release shows as referencing the ref)", () => {
|
|
275
|
+
const ready = matchCapability(capMatch, [rel("@nanobpm/urban@0.82.0", [274])]);
|
|
276
|
+
assert(ready.ready);
|
|
277
|
+
assertStringIncludes(ready.observed ?? "", "@nanobpm/urban@0.82.0 refs #274");
|
|
278
|
+
});
|
|
279
|
+
|
|
217
280
|
test("cmpVersion: numeric dotted compare (0.9 < 0.54 < 0.60), matching nano-ide publish.mjs", () => {
|
|
218
281
|
assert(cmpVersion("0.9.0", "0.54.0") < 0);
|
|
219
282
|
assert(cmpVersion("0.54.0", "0.60.0") < 0);
|
package/app/readiness.ts
CHANGED
|
@@ -120,6 +120,13 @@ export interface ProbeResult {
|
|
|
120
120
|
readonly ready: boolean;
|
|
121
121
|
readonly detail: string;
|
|
122
122
|
readonly bind?: Record<string, string>;
|
|
123
|
+
/** An OPTIONAL compact, human-readable summary of what the probe actually OBSERVED at poll time
|
|
124
|
+
* (issue #514 Defect A). Diagnostic-only — it is NEVER part of the gate contract, it does not gate
|
|
125
|
+
* readiness, and it carries no secret material (provenance is public). The `capability` kind
|
|
126
|
+
* populates it with {@link summariseCapabilityCandidates} so an ESCALATION (false-negative wait)
|
|
127
|
+
* surfaces the candidate releases the matcher saw and whether each referenced the ref, letting a
|
|
128
|
+
* human/agent tell a genuine "not published yet" from a transient false-negative. */
|
|
129
|
+
readonly observed?: string;
|
|
123
130
|
}
|
|
124
131
|
|
|
125
132
|
/** A single published GitHub Release, reduced to the two fields the capability resolver reads: the
|
|
@@ -448,6 +455,51 @@ function versionForPackage(tag: string, pkg: string): string | undefined {
|
|
|
448
455
|
return /^\d+(\.\d+)*$/.test(v) ? v : undefined;
|
|
449
456
|
}
|
|
450
457
|
|
|
458
|
+
/** A compact, deterministic, BOUNDED summary of the capability candidate releases a probe observed
|
|
459
|
+
* (issue #514 Defect A). Lists the releases tagged `<match.package>@<version>` (newest first) and,
|
|
460
|
+
* for each, whether its body referenced `match.capabilityRef` — so an escalated capability gate can
|
|
461
|
+
* show a human/agent EXACTLY what the probe saw (a genuine "not published yet" vs. a transient
|
|
462
|
+
* false-negative where a matching release was live but its provenance body was momentarily empty).
|
|
463
|
+
* PURE / never throws — a malformed or empty list yields a plain "no releases observed" note.
|
|
464
|
+
* Output is capped (newest {@link CAPABILITY_SUMMARY_LIMIT}) so a repo with hundreds of releases
|
|
465
|
+
* cannot bloat the escalation form or a log line. Provenance is public — nothing here is redacted. */
|
|
466
|
+
export function summariseCapabilityCandidates(
|
|
467
|
+
match: ProbeMatch | undefined,
|
|
468
|
+
releases: readonly GithubRelease[],
|
|
469
|
+
): string {
|
|
470
|
+
const pkg = match?.package;
|
|
471
|
+
const ref = match?.capabilityRef;
|
|
472
|
+
if (!pkg) return "capability: no package configured";
|
|
473
|
+
const num = ref ? capabilityNumber(ref) : undefined;
|
|
474
|
+
const candidates: { version: string; refs: boolean }[] = [];
|
|
475
|
+
for (const rel of releases) {
|
|
476
|
+
if (!rel || typeof rel.tag !== "string" || typeof rel.body !== "string") continue;
|
|
477
|
+
const version = versionForPackage(rel.tag, pkg);
|
|
478
|
+
if (!version) continue;
|
|
479
|
+
candidates.push({ version, refs: num !== undefined && bodyReferences(rel.body, num) });
|
|
480
|
+
}
|
|
481
|
+
if (candidates.length === 0) return `no ${pkg} releases observed`;
|
|
482
|
+
candidates.sort((a, b) => cmpVersion(b.version, a.version)); // newest first, deterministic
|
|
483
|
+
const shown = candidates.slice(0, CAPABILITY_SUMMARY_LIMIT);
|
|
484
|
+
const more = candidates.length > shown.length ? ` (+${candidates.length - shown.length} more)` : "";
|
|
485
|
+
if (num === undefined) {
|
|
486
|
+
// No parseable ref number to test candidates against: distinguish a configured-but-unparseable ref
|
|
487
|
+
// from a genuinely absent one, and OMIT the per-candidate/aggregate "refs/no" flags — with nothing
|
|
488
|
+
// to match, every candidate would carry a meaningless "no", which is misleading rather than diagnostic.
|
|
489
|
+
const why = ref ? "(unparseable ref)" : "(no ref configured)";
|
|
490
|
+
const parts = shown.map((c) => `${pkg}@${c.version}`);
|
|
491
|
+
return `${candidates.length} ${pkg} release(s) observed ${why}: ${parts.join("; ")}${more}`;
|
|
492
|
+
}
|
|
493
|
+
const referencing = candidates.filter((c) => c.refs).length;
|
|
494
|
+
const refLabel = `#${num}`;
|
|
495
|
+
const parts = shown.map((c) => `${pkg}@${c.version} ${c.refs ? `refs ${refLabel}` : `no ${refLabel}`}`);
|
|
496
|
+
return `${candidates.length} ${pkg} release(s) observed, ${referencing} referencing ${refLabel}: ${parts.join("; ")}${more}`;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** Newest-N cap on {@link summariseCapabilityCandidates} so a >100-release repo cannot bloat the
|
|
500
|
+
* escalation form or a warn log line. */
|
|
501
|
+
const CAPABILITY_SUMMARY_LIMIT = 8;
|
|
502
|
+
|
|
451
503
|
/** capability readiness (#274 Gap A): among GitHub Releases tagged `<match.package>@*` whose body
|
|
452
504
|
* references `match.capabilityRef`, resolve the **lowest** SemVer version — that is the version that
|
|
453
505
|
* *first* carries the capability (`firstVersion`). Late-binds it as `{ resolvedArtifact }` so the
|
|
@@ -457,9 +509,10 @@ function versionForPackage(tag: string, pkg: string): string | undefined {
|
|
|
457
509
|
export function matchCapability(match: ProbeMatch | undefined, releases: readonly GithubRelease[]): ProbeResult {
|
|
458
510
|
const pkg = match?.package;
|
|
459
511
|
const ref = match?.capabilityRef;
|
|
460
|
-
|
|
512
|
+
const observed = summariseCapabilityCandidates(match, releases);
|
|
513
|
+
if (!pkg || !ref) return { ready: false, detail: "capability: missing package/capabilityRef", observed };
|
|
461
514
|
const num = capabilityNumber(ref);
|
|
462
|
-
if (!num) return { ready: false, detail: "capability: unparseable capabilityRef (no #NNN)" };
|
|
515
|
+
if (!num) return { ready: false, detail: "capability: unparseable capabilityRef (no #NNN)", observed };
|
|
463
516
|
|
|
464
517
|
let firstVersion: string | undefined;
|
|
465
518
|
for (const rel of releases) {
|
|
@@ -469,9 +522,9 @@ export function matchCapability(match: ProbeMatch | undefined, releases: readonl
|
|
|
469
522
|
if (!bodyReferences(rel.body, num)) continue;
|
|
470
523
|
if (firstVersion === undefined || cmpVersion(version, firstVersion) < 0) firstVersion = version;
|
|
471
524
|
}
|
|
472
|
-
if (firstVersion === undefined) return { ready: false, detail: `capability #${num} not published in ${pkg} yet
|
|
525
|
+
if (firstVersion === undefined) return { ready: false, detail: `capability #${num} not published in ${pkg} yet`, observed };
|
|
473
526
|
const resolvedArtifact = `${pkg}@${firstVersion}`;
|
|
474
|
-
return { ready: true, detail: `capability #${num} carried by ${resolvedArtifact}`, bind: { resolvedArtifact } };
|
|
527
|
+
return { ready: true, detail: `capability #${num} carried by ${resolvedArtifact}`, bind: { resolvedArtifact }, observed };
|
|
475
528
|
}
|
|
476
529
|
|
|
477
530
|
/** The **newest** published SemVer version of `pkg` across `releases` — the target of the gated
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.138.
|
|
3
|
+
"version": "0.138.2",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
<nano:extend name="detail" type="string" optional="true" />
|
|
51
51
|
<nano:extend name="resolvedArtifact" type="string" optional="true" />
|
|
52
52
|
<nano:extend name="mergedSha" type="string" optional="true" />
|
|
53
|
+
<nano:extend name="observed" type="string" optional="true" />
|
|
53
54
|
</nano:shape>
|
|
54
55
|
<nano:shape id="ReadinessReady" name="readiness-ready message payload">
|
|
55
56
|
<nano:extend name="ready" type="boolean" />
|
|
@@ -23,11 +23,12 @@ function execReturning(seq: Array<HttpResponse>, counts: { http: number } = { ht
|
|
|
23
23
|
const httpProbe = (poll?: ReadinessProbe["poll"]): ReadinessProbe =>
|
|
24
24
|
parseProbe({ kind: "http", target: "https://x/health?token=s3cr3t", poll });
|
|
25
25
|
|
|
26
|
-
test("safeBind: strips reserved keys (ready/detail) so a bind can only ADD outputs, never shadow the payload", () => {
|
|
27
|
-
const cleaned = safeBind({ resolvedArtifact: "@nanobpm/urban@0.54.0", ready: "false", detail: "spoofed" });
|
|
26
|
+
test("safeBind: strips reserved keys (ready/detail/observed) so a bind can only ADD outputs, never shadow the payload", () => {
|
|
27
|
+
const cleaned = safeBind({ resolvedArtifact: "@nanobpm/urban@0.54.0", ready: "false", detail: "spoofed", observed: "spoofed" });
|
|
28
28
|
assertEquals(cleaned.resolvedArtifact, "@nanobpm/urban@0.54.0");
|
|
29
29
|
assertEquals("ready" in cleaned, false, "a bound 'ready' can never override the canonical payload");
|
|
30
30
|
assertEquals("detail" in cleaned, false, "a bound 'detail' can never override the canonical payload");
|
|
31
|
+
assertEquals("observed" in cleaned, false, "a bound 'observed' can never clobber the top-level diagnostic summary");
|
|
31
32
|
assertEquals(Object.keys(safeBind(undefined)).length, 0, "an absent bind yields an empty object");
|
|
32
33
|
});
|
|
33
34
|
|
|
@@ -121,6 +122,59 @@ test("probeSingleShot: inconclusive lastAttempt fallback surfaces its redacted d
|
|
|
121
122
|
assert(res.detail.includes("fallback: still nothing"), "the fallback diagnostic is preserved");
|
|
122
123
|
});
|
|
123
124
|
|
|
125
|
+
test("#514 Defect A probeSingleShot: a not-ready capability at the boundary emits a structured WARN with the probe detail + observed releases, and threads `observed` to the return", async () => {
|
|
126
|
+
// Red before the fix: the boundary last-attempt returned a contextless "gate boundary reached" with
|
|
127
|
+
// no warn and no observed-release context, so an escalated false-negative was undiagnosable.
|
|
128
|
+
const capProbe = parseProbe({
|
|
129
|
+
kind: "capability",
|
|
130
|
+
target: "github-releases:nanobpm/nano-ide",
|
|
131
|
+
match: { package: "@nanobpm/urban", capabilityRef: "#468" },
|
|
132
|
+
});
|
|
133
|
+
// A live @nanobpm/urban release exists, but its provenance body does NOT (yet) reference #468 — the
|
|
134
|
+
// exact transient false-negative shape from the incident.
|
|
135
|
+
const releasesJson = JSON.stringify([
|
|
136
|
+
[{ tag_name: "@nanobpm/urban@0.82.0", body: "## Provenance\n- #200\n" }],
|
|
137
|
+
]);
|
|
138
|
+
const exec: ProbeExec = {
|
|
139
|
+
async httpGet(): Promise<HttpResponse> {
|
|
140
|
+
throw new Error("unused");
|
|
141
|
+
},
|
|
142
|
+
async run(): Promise<CommandResult> {
|
|
143
|
+
return { code: 0, stdout: releasesJson, stderr: "" };
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
const warns: string[] = [];
|
|
147
|
+
let publishes = 0;
|
|
148
|
+
const res = await probeSingleShot({
|
|
149
|
+
probe: capProbe,
|
|
150
|
+
exec,
|
|
151
|
+
env: {},
|
|
152
|
+
lastAttempt: true,
|
|
153
|
+
publish: async () => {
|
|
154
|
+
publishes += 1;
|
|
155
|
+
},
|
|
156
|
+
warn: (msg) => warns.push(msg),
|
|
157
|
+
});
|
|
158
|
+
assert(!res.ready, "the boundary attempt is still not-ready");
|
|
159
|
+
assertEquals(publishes, 0, "a not-ready boundary never publishes readiness-ready");
|
|
160
|
+
assertEquals(warns.length, 1, "exactly one structured warn is emitted at the boundary last attempt");
|
|
161
|
+
assert(warns[0].includes("escalating"), "the warn says the gate is escalating");
|
|
162
|
+
assert(warns[0].includes("capability #468 not published"), "the warn carries the probe's last detail");
|
|
163
|
+
assert(warns[0].includes("@nanobpm/urban@0.82.0 no #468"), "the warn carries the observed candidate releases");
|
|
164
|
+
assert((res.observed ?? "").includes("@nanobpm/urban@0.82.0 no #468"), "the observed summary is threaded to the return for the escalation task");
|
|
165
|
+
// The return `detail` must carry the probe's actual last matcher detail — NOT a generic "gate boundary
|
|
166
|
+
// reached" string — because the wait-gate seeds `probeDetail` and the escalation context FEEL from it.
|
|
167
|
+
assert(
|
|
168
|
+
res.detail.includes("capability #468 not published"),
|
|
169
|
+
"the boundary return `detail` carries the probe's diagnostic last detail, keeping the escalation diagnostic",
|
|
170
|
+
);
|
|
171
|
+
assert(
|
|
172
|
+
!res.detail.includes("gate boundary reached"),
|
|
173
|
+
"the boundary return no longer clobbers `detail` with a generic contextless string",
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
|
|
124
178
|
test("probeSingleShot: forwards a matcher's bind through publish into the message variables (#274 Gap B)", async () => {
|
|
125
179
|
const published: Array<{ detail: string; bind?: Record<string, string> }> = [];
|
|
126
180
|
const payload = JSON.stringify([{ tag_name: "@nanobpm/urban@0.54.0", body: "## Provenance\n- #274\n" }]);
|
|
@@ -35,7 +35,7 @@ export { READINESS_READY_MESSAGE };
|
|
|
35
35
|
* kind-agnostic emit primitive (#274 Gap B), but it flows from matcher output into both the
|
|
36
36
|
* `readiness-ready` message variables and the worker output — so a matcher that binds `ready`/`detail`
|
|
37
37
|
* could shadow the canonical payload and break the gate contract. */
|
|
38
|
-
const RESERVED_BIND_KEYS: ReadonlySet<string> = new Set(["ready", "detail"]);
|
|
38
|
+
const RESERVED_BIND_KEYS: ReadonlySet<string> = new Set(["ready", "detail", "observed"]);
|
|
39
39
|
export function safeBind(bind?: Record<string, string>): Record<string, string> {
|
|
40
40
|
if (!bind) return {};
|
|
41
41
|
const out: Record<string, string> = {};
|
|
@@ -60,6 +60,7 @@ export async function probeSingleShot(deps: {
|
|
|
60
60
|
lastAttempt?: boolean;
|
|
61
61
|
fallback?: () => Promise<ProbeResult | null>;
|
|
62
62
|
log?: (msg: string) => void;
|
|
63
|
+
warn?: (msg: string) => void;
|
|
63
64
|
}): Promise<ProbeResult> {
|
|
64
65
|
const label = redactTarget(deps.probe);
|
|
65
66
|
const res: ProbeResult = await probeOnce(deps.probe, deps.exec, deps.env).catch((err) => ({
|
|
@@ -84,9 +85,25 @@ export async function probeSingleShot(deps: {
|
|
|
84
85
|
await deps.publish(settled.detail, settled.bind);
|
|
85
86
|
return settled;
|
|
86
87
|
}
|
|
88
|
+
// Boundary last attempt and STILL not ready: this is the escalation trigger. Surface a structured
|
|
89
|
+
// warn (issue #514 Defect A) carrying the probe's last detail + what it OBSERVED, so a false-negative
|
|
90
|
+
// (a matching release was live but its provenance body was momentarily empty) is diagnosable from the
|
|
91
|
+
// logs — not just a contextless "escalated". `observed` is diagnostic-only and secret-free.
|
|
92
|
+
const failingDetail = settled ? `${res.detail} (fallback: ${settled.detail})` : res.detail;
|
|
93
|
+
const observed = settled?.observed ?? res.observed;
|
|
94
|
+
deps.warn?.(
|
|
95
|
+
`readiness gate escalating ${label}: not ready at boundary — ${failingDetail}` +
|
|
96
|
+
(observed ? `; observed: ${observed}` : ""),
|
|
97
|
+
);
|
|
98
|
+
// Return the actual failing probe detail (not a generic "gate boundary reached" string): downstream
|
|
99
|
+
// the wait-gate seeds `probeDetail` and the escalation context FEEL from `detail`, so overwriting it
|
|
100
|
+
// here would make the escalation non-diagnostic — the very defect this change fixes. The fact that the
|
|
101
|
+
// boundary/timeout was reached is already communicated by the escalation context ("exceeded its SLA …
|
|
102
|
+
// before its ReadinessProbe went green") and the structured WARN above.
|
|
87
103
|
return {
|
|
88
104
|
ready: false,
|
|
89
|
-
detail:
|
|
105
|
+
detail: failingDetail,
|
|
106
|
+
observed,
|
|
90
107
|
};
|
|
91
108
|
}
|
|
92
109
|
|
|
@@ -149,8 +166,14 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
149
166
|
});
|
|
150
167
|
},
|
|
151
168
|
log: (msg) => app.log.info(msg),
|
|
169
|
+
warn: (msg) => app.log.warn(msg),
|
|
152
170
|
});
|
|
153
|
-
return {
|
|
171
|
+
return {
|
|
172
|
+
ready: result.ready,
|
|
173
|
+
detail: result.detail,
|
|
174
|
+
...(result.observed !== undefined ? { observed: result.observed } : {}),
|
|
175
|
+
...safeBind(result.bind),
|
|
176
|
+
};
|
|
154
177
|
};
|
|
155
178
|
|
|
156
179
|
export default handler;
|