@nanobpm/nano-workforce 0.84.0 → 0.85.1
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/feature.page.json +2 -2
- package/pages/overview.page.json +2 -2
- package/resources/processes/readiness-gate.bpmn +5 -0
- package/workers/readiness-probe/worker.test.ts +147 -1
- package/workers/readiness-probe/worker.ts +70 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
## [0.85.1](https://github.com/nanobpm/nano-workforce/compare/v0.85.0...v0.85.1) (2026-08-18)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* **deps:** bump @nanobpm/urban to ^0.55.0 ([#276](https://github.com/nanobpm/nano-workforce/issues/276)) ([08fe794](https://github.com/nanobpm/nano-workforce/commit/08fe794280c32e5a63cc4fd5a166dd4e35c1b8e8)), closes [276/#277](https://github.com/nanobpm/nano-workforce/issues/277)
|
|
7
|
+
|
|
8
|
+
# [0.85.0](https://github.com/nanobpm/nano-workforce/compare/v0.84.0...v0.85.0) (2026-08-17)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **readiness:** capability probe kind — resolve capability→version from publish provenance, late-bind + pin ([#274](https://github.com/nanobpm/nano-workforce/issues/274)) ([#275](https://github.com/nanobpm/nano-workforce/issues/275)) ([33171b7](https://github.com/nanobpm/nano-workforce/commit/33171b73a69d5b130f205be12e7f0988b3f268a6)), closes [#258](https://github.com/nanobpm/nano-workforce/issues/258)
|
|
14
|
+
|
|
1
15
|
# [0.84.0](https://github.com/nanobpm/nano-workforce/compare/v0.83.0...v0.84.0) (2026-08-17)
|
|
2
16
|
|
|
3
17
|
|
package/app/feature.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
18
18
|
import { coalesceTitle, fetchIssueTitle } from "./github.ts";
|
|
19
19
|
import { ESCALATION_SLA_TIMEOUT, normalizeBaseBranch, type ParsedIssue, renderBaseBranchBrief } from "./plan.ts";
|
|
20
|
-
import { deriveListBucket, deriveStage } from "./stage.ts";
|
|
20
|
+
import { deriveEscalationOpen, deriveListBucket, deriveStage } from "./stage.ts";
|
|
21
21
|
|
|
22
22
|
/** The BPMN process this module drives (resources/processes/feature.bpmn). */
|
|
23
23
|
export const FEATURE_PROCESS_ID = "feature";
|
|
@@ -63,6 +63,16 @@ export interface FeatureRun {
|
|
|
63
63
|
* (`completeUserTaskAttributed`) and the pages gate the answer controls on (`showWhenField`). Set by
|
|
64
64
|
* `pollFeatureEscalations` while parked; NULL otherwise. */
|
|
65
65
|
escalation_user_task_key: string | null;
|
|
66
|
+
/** Gateway projection (issue #272): the single fail-closed "open escalation" display signal, `1` iff
|
|
67
|
+
* ALL THREE escalation columns agree the run is parked at an answerable escalation
|
|
68
|
+
* (`status='escalated'` AND `escalation_user_task_key` non-NULL AND `escalation_question` non-NULL),
|
|
69
|
+
* else `0`. Derived by `deriveEscalationOpen` at write time. The escalation tuple is spread across
|
|
70
|
+
* three independently-written columns, so an interim read can see a TORN state (a live pointer with a
|
|
71
|
+
* blank question, or a status lagging behind a cleared question); the pages gate the Abandon action
|
|
72
|
+
* and the answer form on THIS conjunction instead of on `escalation_user_task_key` alone, so a torn
|
|
73
|
+
* tuple renders as not-escalated rather than escalated-but-blank. NULL only on legacy rows before the
|
|
74
|
+
* projection reached them (backfilled once at boot). */
|
|
75
|
+
escalation_open: number | null;
|
|
66
76
|
/** The completable native `feature-blocked` user-task key the "Acknowledge blocked" affordance posts
|
|
67
77
|
* to (`completeUserTaskAttributed`) and the pages gate the acknowledge control on (`showWhenField`).
|
|
68
78
|
* Kept DISTINCT from `escalation_user_task_key` so the two human tasks (an escalation answer vs a
|
|
@@ -296,6 +306,7 @@ const PROJECTION_OUTPUT_KEYS: readonly (keyof FeatureRun)[] = [
|
|
|
296
306
|
"stage_skipped",
|
|
297
307
|
"attention",
|
|
298
308
|
"list_bucket",
|
|
309
|
+
"escalation_open",
|
|
299
310
|
];
|
|
300
311
|
|
|
301
312
|
/** True when a patch changes at least one field the projection derives from OR one it writes — i.e. the
|
|
@@ -326,6 +337,13 @@ function projectFeatureRun(row: Partial<FeatureRun>): Partial<FeatureRun> {
|
|
|
326
337
|
stage_skipped: skipped,
|
|
327
338
|
attention,
|
|
328
339
|
list_bucket: deriveListBucket(row.status, row.acknowledged_at ?? null),
|
|
340
|
+
escalation_open: deriveEscalationOpen({
|
|
341
|
+
status: row.status,
|
|
342
|
+
escalation_question: row.escalation_question ?? null,
|
|
343
|
+
escalation_user_task_key: row.escalation_user_task_key ?? null,
|
|
344
|
+
})
|
|
345
|
+
? 1
|
|
346
|
+
: 0,
|
|
329
347
|
};
|
|
330
348
|
}
|
|
331
349
|
|
|
@@ -370,21 +388,25 @@ export const featureRuns = (data: DataLayer) => {
|
|
|
370
388
|
});
|
|
371
389
|
};
|
|
372
390
|
|
|
373
|
-
/** Re-project every feature_runs row through the gateway so rows
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
391
|
+
/** Re-project every feature_runs row through the gateway so rows missing any projection column get
|
|
392
|
+
* correct `stage`/`stage_state`/`stage_skipped`/`attention`/`list_bucket`/`escalation_open` values.
|
|
393
|
+
* Catches rows written before migration 039 (the pipeline columns) AND rows written before migration
|
|
394
|
+
* 040 (whose `escalation_open` column is NULL, issue #272). Idempotent and safe to re-run: it
|
|
395
|
+
* re-derives from each row's own stored fields, so a second pass is a no-op. Runs once at boot
|
|
396
|
+
* (pollOnce) — the gateway keeps every future write fresh, so this only needs to catch legacy rows.
|
|
397
|
+
* Reprojecting on a missing `escalation_open` matters for a run parked at a LIVE escalation when
|
|
398
|
+
* migration 040 lands: `pollFeatureEscalations` writes nothing while it stays parked (no change), so
|
|
399
|
+
* without this the fail-closed signal would stay NULL and hide a genuinely-open escalation. */
|
|
378
400
|
export async function backfillFeatureStages(data: DataLayer): Promise<number> {
|
|
379
401
|
const table = featureRuns(data);
|
|
380
402
|
const rows = await table.all();
|
|
381
403
|
let stamped = 0;
|
|
382
404
|
for (const row of rows) {
|
|
383
|
-
// Only touch rows the projection has never reached — a legacy
|
|
384
|
-
// still NULL. The gateway keeps every write fresh, so
|
|
385
|
-
// skipping them avoids a full-table rewrite on every boot and
|
|
386
|
-
// rows actually backfilled (not the total row count).
|
|
387
|
-
if (row.stage != null) continue;
|
|
405
|
+
// Only touch rows the projection has never fully reached — a legacy row whose `stage` (pre-039) or
|
|
406
|
+
// `escalation_open` (pre-040) column is still NULL. The gateway keeps every write fresh, so a
|
|
407
|
+
// fully-projected row needs no re-write; skipping them avoids a full-table rewrite on every boot and
|
|
408
|
+
// keeps `stamped` an honest count of rows actually backfilled (not the total row count).
|
|
409
|
+
if (row.stage != null && row.escalation_open != null) continue;
|
|
388
410
|
// Re-derive the projection from the legacy row's own stored fields and write it. (An empty patch
|
|
389
411
|
// would now short-circuit the projecting proxy — it only reprojects on a projection-input change —
|
|
390
412
|
// so backfill projects explicitly rather than relying on an empty-patch reproject.)
|
|
@@ -72,6 +72,56 @@ test("update {status:'escalated'} on a row with no pr_key projects Implementing
|
|
|
72
72
|
assertEquals(rows[0].stage_state, null);
|
|
73
73
|
});
|
|
74
74
|
|
|
75
|
+
// issue #272: the fail-closed open-escalation projection. The escalation tuple is spread across three
|
|
76
|
+
// independently-written columns; the gateway projects `escalation_open=1` ONLY when all three agree.
|
|
77
|
+
test("escalation_open is 1 only when status=escalated AND pointer AND question all present", async () => {
|
|
78
|
+
const { data, rows } = memData();
|
|
79
|
+
rows.push({ feature_key: "o/r#esc", status: "running", pr_key: null, converge: 1, auto_merge: 1 });
|
|
80
|
+
// A complete, answerable escalation → 1.
|
|
81
|
+
await featureRuns(data).update("o/r#esc", {
|
|
82
|
+
status: "escalated",
|
|
83
|
+
escalation_user_task_key: "ut-9",
|
|
84
|
+
escalation_question: "which base branch?",
|
|
85
|
+
});
|
|
86
|
+
assertEquals(rows[0].escalation_open, 1);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("a torn escalation tuple projects escalation_open=0 (renders not-escalated, fail closed)", async () => {
|
|
90
|
+
const { data, rows } = memData();
|
|
91
|
+
// Simulate the exit-path/poller race: the question was cleared while status + pointer still lag in the
|
|
92
|
+
// escalated projection (the mirror tear observed on nwf#270). The page must NOT show an escalation.
|
|
93
|
+
rows.push({
|
|
94
|
+
feature_key: "o/r#torn",
|
|
95
|
+
status: "escalated",
|
|
96
|
+
pr_key: null,
|
|
97
|
+
converge: 1,
|
|
98
|
+
auto_merge: 1,
|
|
99
|
+
escalation_user_task_key: "ut-9",
|
|
100
|
+
escalation_question: "which base?",
|
|
101
|
+
escalation_open: 1,
|
|
102
|
+
});
|
|
103
|
+
await featureRuns(data).update("o/r#torn", { escalation_question: null });
|
|
104
|
+
assertEquals(rows[0].escalation_open, 0);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("clearing the escalation tuple flips escalation_open to 0 without waiting a poll pass", async () => {
|
|
108
|
+
const { data, rows } = memData();
|
|
109
|
+
// The answer operation eagerly clears pointer + question; the gateway reprojects on that same write,
|
|
110
|
+
// so the affordance disappears immediately (issue #272 acceptance: no poll-pass lag).
|
|
111
|
+
rows.push({
|
|
112
|
+
feature_key: "o/r#ans",
|
|
113
|
+
status: "escalated",
|
|
114
|
+
pr_key: null,
|
|
115
|
+
converge: 1,
|
|
116
|
+
auto_merge: 1,
|
|
117
|
+
escalation_user_task_key: "ut-9",
|
|
118
|
+
escalation_question: "which base?",
|
|
119
|
+
escalation_open: 1,
|
|
120
|
+
});
|
|
121
|
+
await featureRuns(data).update("o/r#ans", { escalation_user_task_key: null, escalation_question: null });
|
|
122
|
+
assertEquals(rows[0].escalation_open, 0);
|
|
123
|
+
});
|
|
124
|
+
|
|
75
125
|
test("a run with converge=false projects stage_skipped containing Converging and Merging", async () => {
|
|
76
126
|
const { data, rows } = memData();
|
|
77
127
|
rows.push({ feature_key: "o/r#5", status: "running", converge: 0, auto_merge: 0 });
|
|
@@ -162,9 +212,12 @@ test("backfillFeatureStages stamps legacy terminal and live rows with helper-der
|
|
|
162
212
|
// Legacy rows written before migration 039 → projection columns absent/NULL.
|
|
163
213
|
rows.push({ feature_key: "o/r#8", status: "merged", converge: 1, auto_merge: 1, acknowledged_at: "2024-01-01T00:00:00Z" });
|
|
164
214
|
rows.push({ feature_key: "o/r#9", status: "running", pr_key: null, converge: 0, auto_merge: 0 });
|
|
215
|
+
// A legacy row parked at a LIVE escalation when migration 040 lands: escalation_open is NULL and the
|
|
216
|
+
// poller won't re-write it while it stays parked, so backfill MUST reproject it to 1 (issue #272).
|
|
217
|
+
rows.push({ feature_key: "o/r#esc", status: "escalated", pr_key: null, converge: 1, auto_merge: 1, escalation_user_task_key: "ut-9", escalation_question: "which base?" });
|
|
165
218
|
|
|
166
219
|
const stamped = await backfillFeatureStages(data);
|
|
167
|
-
assertEquals(stamped,
|
|
220
|
+
assertEquals(stamped, 3);
|
|
168
221
|
|
|
169
222
|
const terminal = rows.find((r) => r.feature_key === "o/r#8");
|
|
170
223
|
assertEquals(terminal.stage, "Done");
|
|
@@ -176,19 +229,24 @@ test("backfillFeatureStages stamps legacy terminal and live rows with helper-der
|
|
|
176
229
|
assertEquals(live.stage_state, null);
|
|
177
230
|
assertEquals(live.stage_skipped, "Converging Merging");
|
|
178
231
|
assertEquals(live.list_bucket, "active");
|
|
232
|
+
assertEquals(live.escalation_open, 0);
|
|
233
|
+
|
|
234
|
+
const escalated = rows.find((r) => r.feature_key === "o/r#esc");
|
|
235
|
+
assertEquals(escalated.escalation_open, 1);
|
|
179
236
|
});
|
|
180
237
|
|
|
181
238
|
test("backfillFeatureStages skips already-projected rows and counts only rows it stamps", async () => {
|
|
182
239
|
const { data, rows } = memData();
|
|
183
240
|
// One legacy row (no projection) + one already-projected row (gateway kept it fresh).
|
|
184
241
|
rows.push({ feature_key: "o/r#legacy", status: "merged", converge: 1, auto_merge: 1, acknowledged_at: null });
|
|
185
|
-
rows.push({ feature_key: "o/r#fresh", status: "merged", converge: 1, auto_merge: 1, acknowledged_at: null, stage: "Done", stage_state: "ok", stage_skipped: "", attention: null, list_bucket: "active", updated_at: "0" });
|
|
242
|
+
rows.push({ feature_key: "o/r#fresh", status: "merged", converge: 1, auto_merge: 1, acknowledged_at: null, stage: "Done", stage_state: "ok", stage_skipped: "", attention: null, list_bucket: "active", escalation_open: 0, updated_at: "0" });
|
|
186
243
|
|
|
187
244
|
const stamped = await backfillFeatureStages(data);
|
|
188
245
|
// Only the legacy row is stamped; the already-projected row is skipped.
|
|
189
246
|
assertEquals(stamped, 1);
|
|
190
247
|
const legacy = rows.find((r) => r.feature_key === "o/r#legacy");
|
|
191
248
|
assertEquals(legacy.stage, "Done");
|
|
249
|
+
assertEquals(legacy.escalation_open, 0);
|
|
192
250
|
// The already-projected row was not re-written (its sentinel updated_at is untouched).
|
|
193
251
|
const fresh = rows.find((r) => r.feature_key === "o/r#fresh");
|
|
194
252
|
assertEquals(fresh.updated_at, "0");
|
package/app/readiness.test.ts
CHANGED
|
@@ -7,20 +7,27 @@ import { test } from "node:test";
|
|
|
7
7
|
import { assert, assertEquals, assertRejects, assertStringIncludes, assertThrows } from "#test-assert";
|
|
8
8
|
import {
|
|
9
9
|
type CommandResult,
|
|
10
|
+
cmpVersion,
|
|
10
11
|
DEFAULT_ATTEMPT_TIMEOUT_MS,
|
|
11
12
|
DEFAULT_EVERY_MS,
|
|
12
13
|
DEFAULT_TIMEOUT_MS,
|
|
13
14
|
defaultProbeExec,
|
|
15
|
+
type GithubRelease,
|
|
14
16
|
type HttpResponse,
|
|
17
|
+
makeCapabilityFallback,
|
|
15
18
|
MAX_EVERY_MS,
|
|
19
|
+
matchCapability,
|
|
16
20
|
matchCommand,
|
|
17
21
|
matchGithubCheck,
|
|
18
22
|
matchHttp,
|
|
19
23
|
matchNpm,
|
|
20
24
|
msToIsoDuration,
|
|
25
|
+
newestPublishedVersion,
|
|
21
26
|
nextDelay,
|
|
22
27
|
normalizePoll,
|
|
23
28
|
parseProbe,
|
|
29
|
+
parseReleases,
|
|
30
|
+
parseReleasesTarget,
|
|
24
31
|
parseRepoRef,
|
|
25
32
|
probeBudgetMs,
|
|
26
33
|
probeOnce,
|
|
@@ -102,6 +109,186 @@ test("parseProbe: accepts a declared credentialEnv (http) and parses nested matc
|
|
|
102
109
|
assertEquals(p.poll?.backoff, "fixed");
|
|
103
110
|
});
|
|
104
111
|
|
|
112
|
+
// ── parseProbe: capability kind (#274 — required capabilityRef + package, fail loudly on blank) ──
|
|
113
|
+
test("parseProbe: a capability probe with a blank capabilityRef throws (a never-resolvable edge must fail loudly)", () => {
|
|
114
|
+
assertThrows(
|
|
115
|
+
() => parseProbe({ kind: "capability", target: "github-releases:nanobpm/nano-ide", match: { package: "@nanobpm/urban" } }),
|
|
116
|
+
Error,
|
|
117
|
+
"capabilityRef' is required",
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("parseProbe: a capability probe with a blank package throws (provenance is per-package scoped)", () => {
|
|
122
|
+
assertThrows(
|
|
123
|
+
() => parseProbe({ kind: "capability", target: "github-releases:nanobpm/nano-ide", match: { capabilityRef: "#274" } }),
|
|
124
|
+
Error,
|
|
125
|
+
"package' is required",
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("parseProbe: a capability probe whose capabilityRef carries no numeric id throws (never resolvable)", () => {
|
|
130
|
+
assertThrows(
|
|
131
|
+
() =>
|
|
132
|
+
parseProbe({
|
|
133
|
+
kind: "capability",
|
|
134
|
+
target: "github-releases:nanobpm/nano-ide",
|
|
135
|
+
match: { capabilityRef: "nano-ide#", package: "@nanobpm/urban" },
|
|
136
|
+
}),
|
|
137
|
+
Error,
|
|
138
|
+
"must carry a",
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("parseProbe: a valid capability probe round-trips its required match fields", () => {
|
|
143
|
+
const p = parseProbe({
|
|
144
|
+
kind: "capability",
|
|
145
|
+
target: "github-releases:nanobpm/nano-ide",
|
|
146
|
+
match: { capabilityRef: "nano-ide#274", package: "@nanobpm/urban", verifyCommand: "node -e 0" },
|
|
147
|
+
});
|
|
148
|
+
assertEquals(p.kind, "capability");
|
|
149
|
+
assertEquals(p.match?.capabilityRef, "nano-ide#274");
|
|
150
|
+
assertEquals(p.match?.package, "@nanobpm/urban");
|
|
151
|
+
assertEquals(p.match?.verifyCommand, "node -e 0");
|
|
152
|
+
assertEquals(p.onTimeout, "escalate");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// ── matchCapability: the deterministic lowest-version-per-package resolver (#274 Gap A) ──────────
|
|
156
|
+
const rel = (tag: string, refs: number[]): GithubRelease => ({
|
|
157
|
+
tag,
|
|
158
|
+
body: `Automated release of \`${tag}\`.\n\n## Provenance\n${refs.map((n) => `- #${n}`).join("\n")}\n`,
|
|
159
|
+
});
|
|
160
|
+
const capMatch = { capabilityRef: "nano-ide#274", package: "@nanobpm/urban" };
|
|
161
|
+
|
|
162
|
+
test("matchCapability: a capability in exactly one release resolves that version and binds resolvedArtifact", () => {
|
|
163
|
+
const res = matchCapability(capMatch, [rel("@nanobpm/urban@0.54.0", [273, 274, 275])]);
|
|
164
|
+
assert(res.ready);
|
|
165
|
+
assertEquals(res.bind?.resolvedArtifact, "@nanobpm/urban@0.54.0");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("matchCapability: present in multiple releases resolves the LOWEST version (first-carries)", () => {
|
|
169
|
+
const releases = [
|
|
170
|
+
rel("@nanobpm/urban@0.60.0", [274]),
|
|
171
|
+
rel("@nanobpm/urban@0.54.0", [274]),
|
|
172
|
+
rel("@nanobpm/urban@0.9.0", [274]), // 0.9 < 0.54 numerically — cmpVersion, not lexicographic
|
|
173
|
+
];
|
|
174
|
+
const res = matchCapability(capMatch, releases);
|
|
175
|
+
assert(res.ready);
|
|
176
|
+
assertEquals(res.bind?.resolvedArtifact, "@nanobpm/urban@0.9.0");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("matchCapability: an absent capability is not-ready (still waiting), never throws", () => {
|
|
180
|
+
const res = matchCapability(capMatch, [rel("@nanobpm/urban@0.54.0", [200, 201])]);
|
|
181
|
+
assert(!res.ready);
|
|
182
|
+
assertEquals(res.bind, undefined);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("matchCapability: the same #C in another package resolves ONLY within the named package", () => {
|
|
186
|
+
const releases = [
|
|
187
|
+
rel("@nanobpm/other@1.0.0", [274]), // same #274, wrong package — must be ignored
|
|
188
|
+
rel("@nanobpm/urban@0.55.0", [274]),
|
|
189
|
+
];
|
|
190
|
+
const res = matchCapability(capMatch, releases);
|
|
191
|
+
assert(res.ready);
|
|
192
|
+
assertEquals(res.bind?.resolvedArtifact, "@nanobpm/urban@0.55.0");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("matchCapability: a prefix ref (#27) never spuriously satisfies #274", () => {
|
|
196
|
+
assert(!matchCapability(capMatch, [rel("@nanobpm/urban@0.54.0", [27])]).ready);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("matchCapability: a malformed/empty releases list is not-ready and never throws", () => {
|
|
200
|
+
assert(!matchCapability(capMatch, []).ready);
|
|
201
|
+
// biome-ignore lint/suspicious/noExplicitAny: deliberately malformed rows exercise the tolerant guard.
|
|
202
|
+
assert(!matchCapability(capMatch, [{ tag: 123, body: null } as any, null as any]).ready);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("matchCapability: the bare '#274' ref form resolves identically to 'nano-ide#274'", () => {
|
|
206
|
+
const res = matchCapability({ capabilityRef: "#274", package: "@nanobpm/urban" }, [rel("@nanobpm/urban@1.2.3", [274])]);
|
|
207
|
+
assert(res.ready);
|
|
208
|
+
assertEquals(res.bind?.resolvedArtifact, "@nanobpm/urban@1.2.3");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("cmpVersion: numeric dotted compare (0.9 < 0.54 < 0.60), matching nano-ide publish.mjs", () => {
|
|
212
|
+
assert(cmpVersion("0.9.0", "0.54.0") < 0);
|
|
213
|
+
assert(cmpVersion("0.54.0", "0.60.0") < 0);
|
|
214
|
+
assertEquals(cmpVersion("1.2", "1.2.0"), 0);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("newestPublishedVersion: picks the highest version of the named package only", () => {
|
|
218
|
+
const releases = [rel("@nanobpm/urban@0.9.0", []), rel("@nanobpm/urban@0.54.0", []), rel("@nanobpm/other@9.9.9", [])];
|
|
219
|
+
assertEquals(newestPublishedVersion("@nanobpm/urban", releases), "0.54.0");
|
|
220
|
+
assertEquals(newestPublishedVersion("@nanobpm/missing", releases), undefined);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("parseReleases: reduces a gh api payload to {tag, body}; non-array input yields []", () => {
|
|
224
|
+
const parsed = parseReleases([{ tag_name: "@nanobpm/urban@0.54.0", body: "## Provenance\n- #274" }, { nope: 1 }]);
|
|
225
|
+
assertEquals(parsed[0]?.tag, "@nanobpm/urban@0.54.0");
|
|
226
|
+
assertEquals(parseReleases("not-an-array").length, 0);
|
|
227
|
+
assertEquals(parseReleases(null).length, 0);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("parseReleases: flattens the --paginate --slurp array-of-pages shape (>100 releases are seen)", () => {
|
|
231
|
+
const slurped = [
|
|
232
|
+
[{ tag_name: "@nanobpm/urban@0.54.0", body: "- #274" }],
|
|
233
|
+
[{ tag_name: "@nanobpm/urban@0.9.0", body: "- #274" }],
|
|
234
|
+
];
|
|
235
|
+
const tags = parseReleases(slurped).map((r) => r.tag);
|
|
236
|
+
assertEquals(tags.includes("@nanobpm/urban@0.54.0"), true, "first page's release is seen");
|
|
237
|
+
assertEquals(tags.includes("@nanobpm/urban@0.9.0"), true, "a later page's release is seen too");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("parseReleasesTarget: strips the optional github-releases: scheme, else passes owner/repo through", () => {
|
|
241
|
+
assertEquals(parseReleasesTarget("github-releases:nanobpm/nano-ide"), "nanobpm/nano-ide");
|
|
242
|
+
assertEquals(parseReleasesTarget("nanobpm/nano-ide"), "nanobpm/nano-ide");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// ── probeOnce capability dispatch + gated fallback (#274 decision 5) ─────────────────────────────
|
|
246
|
+
test("probeOnce capability: queries the repo's releases and binds the resolved artifact", async () => {
|
|
247
|
+
const cap: { cmd?: string } = {};
|
|
248
|
+
const payload = JSON.stringify([{ tag_name: "@nanobpm/urban@0.54.0", body: "## Provenance\n- #274\n" }]);
|
|
249
|
+
const exec = stubExec({ command: { code: 0, stdout: payload, stderr: "" }, capture: cap });
|
|
250
|
+
const res = await probeOnce(parseProbe({ kind: "capability", target: "github-releases:nanobpm/nano-ide", match: capMatch }), exec, {});
|
|
251
|
+
assert(res.ready);
|
|
252
|
+
assertEquals(res.bind?.resolvedArtifact, "@nanobpm/urban@0.54.0");
|
|
253
|
+
assertStringIncludes(cap.cmd ?? "", "repos/nanobpm/nano-ide/releases");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("probeOnce capability: a failed gh api call is not-ready (never throws)", async () => {
|
|
257
|
+
const exec = stubExec({ command: { code: 1, stdout: "", stderr: "boom" } });
|
|
258
|
+
const res = await probeOnce(parseProbe({ kind: "capability", target: "nanobpm/nano-ide", match: capMatch }), exec, {});
|
|
259
|
+
assert(!res.ready);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("makeCapabilityFallback: null for a capability probe with no verifyCommand (deterministic-only)", async () => {
|
|
263
|
+
const probe = parseProbe({ kind: "capability", target: "nanobpm/nano-ide", match: capMatch });
|
|
264
|
+
const fb = makeCapabilityFallback(probe, stubExec({}), {});
|
|
265
|
+
assertEquals(await fb(), null);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("makeCapabilityFallback: verifies the NEWEST version empirically and binds it when the verifier passes", async () => {
|
|
269
|
+
const payload = JSON.stringify([
|
|
270
|
+
{ tag_name: "@nanobpm/urban@0.54.0", body: "no ref" },
|
|
271
|
+
{ tag_name: "@nanobpm/urban@0.60.0", body: "no ref" },
|
|
272
|
+
]);
|
|
273
|
+
const seen: string[] = [];
|
|
274
|
+
const exec: ProbeExec = {
|
|
275
|
+
async httpGet() {
|
|
276
|
+
return { status: 0, body: "" };
|
|
277
|
+
},
|
|
278
|
+
async run(command, env) {
|
|
279
|
+
seen.push(command);
|
|
280
|
+
if (command.includes("gh api")) return { code: 0, stdout: payload, stderr: "" };
|
|
281
|
+
// the verifier: assert the newest version was handed to it via the env
|
|
282
|
+
return { code: env.RESOLVED_VERSION === "0.60.0" ? 0 : 1, stdout: "", stderr: "" };
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
const probe = parseProbe({ kind: "capability", target: "nanobpm/nano-ide", match: { ...capMatch, verifyCommand: "verify.sh" } });
|
|
286
|
+
const res = await makeCapabilityFallback(probe, exec, {})();
|
|
287
|
+
assert(res?.ready);
|
|
288
|
+
assertEquals(res?.bind?.resolvedArtifact, "@nanobpm/urban@0.60.0");
|
|
289
|
+
assert(seen.some((c) => c === "verify.sh"), "the verifier command was run at the boundary");
|
|
290
|
+
});
|
|
291
|
+
|
|
105
292
|
// ── matchers ────────────────────────────────────────────────────────────────────────────────
|
|
106
293
|
test("matchHttp: any 2xx is ready by default; a 503 is not", () => {
|
|
107
294
|
assert(matchHttp(undefined, { status: 204, body: "" }).ready);
|
package/app/readiness.ts
CHANGED
|
@@ -9,18 +9,23 @@
|
|
|
9
9
|
//
|
|
10
10
|
// The probe is DATA, not code — a {@link ReadinessProbe} descriptor with a `kind` and a per-kind
|
|
11
11
|
// `match` predicate. Authors add a readiness source by adding a `kind`'s matcher, never by editing
|
|
12
|
-
// the BPMN or the worker's control flow.
|
|
13
|
-
// `github-check`); everything else is reached through the `command` escape hatch
|
|
14
|
-
// pinned decision 1).
|
|
15
|
-
//
|
|
16
|
-
//
|
|
12
|
+
// the BPMN or the worker's control flow. Five built-in kinds ship (`http`, `command`, `npm`,
|
|
13
|
+
// `github-check`, `capability`); everything else is reached through the `command` escape hatch
|
|
14
|
+
// (ADR 0001 §2 pinned decision 1). The `capability` kind (ADR 0001 §4, issue #274) resolves a
|
|
15
|
+
// cross-repo capability edge — "which published version first carries capability C?" — from the
|
|
16
|
+
// publish-provenance substrate and late-binds the discovered `pkg@version` back through the gate
|
|
17
|
+
// via {@link ProbeResult.bind}, the reusable emit primitive. A probe carries NO secret material —
|
|
18
|
+
// any credential is read at execution time from the typed env-contract (`credentialEnv` names a
|
|
19
|
+
// declared {@link EnvKey}; ADR 0004 pinned decision 2) and is redacted from every log line.
|
|
17
20
|
import { isEnvKey, readEnv, readEnvOr } from "./contracts.ts";
|
|
18
21
|
import { isoDuration, isoDurationToMs } from "./reviewWait.ts";
|
|
19
22
|
|
|
20
23
|
/** The built-in readiness sources. `command` is the escape hatch that subsumes the long tail
|
|
21
24
|
* (`gh`, `curl`, `docker manifest inspect`, a custom probe) — adding a first-class kind later is
|
|
22
|
-
* an additive matcher, not a schema change.
|
|
23
|
-
|
|
25
|
+
* an additive matcher, not a schema change. `capability` is the first such additive kind (#274):
|
|
26
|
+
* it resolves "which published version first carries capability C?" from the publish-provenance
|
|
27
|
+
* substrate and binds the discovered `pkg@version` back through the gate (see {@link matchCapability}). */
|
|
28
|
+
export type ProbeKind = "http" | "command" | "npm" | "github-check" | "capability";
|
|
24
29
|
|
|
25
30
|
/** What the gate does when the bounded wait times out (the engine timer arm fires). */
|
|
26
31
|
export type OnTimeout = "escalate" | "fail" | "continue";
|
|
@@ -28,7 +33,7 @@ export type OnTimeout = "escalate" | "fail" | "continue";
|
|
|
28
33
|
/** Backoff policy between poll attempts. */
|
|
29
34
|
export type Backoff = "fixed" | "exponential";
|
|
30
35
|
|
|
31
|
-
const PROBE_KINDS: readonly ProbeKind[] = ["http", "command", "npm", "github-check"];
|
|
36
|
+
const PROBE_KINDS: readonly ProbeKind[] = ["http", "command", "npm", "github-check", "capability"];
|
|
32
37
|
const ON_TIMEOUTS: readonly OnTimeout[] = ["escalate", "fail", "continue"];
|
|
33
38
|
const BACKOFFS: readonly Backoff[] = ["fixed", "exponential"];
|
|
34
39
|
|
|
@@ -49,6 +54,18 @@ export interface ProbeMatch {
|
|
|
49
54
|
readonly conclusion?: string;
|
|
50
55
|
/** github-check: restrict the predicate to the named check run (default: every check run). */
|
|
51
56
|
readonly checkName?: string;
|
|
57
|
+
/** capability: the upstream issue/PR handle the resolved version must carry in its publish
|
|
58
|
+
* provenance — `nano-ide#274` or the bare `#274`. Required for the `capability` kind. */
|
|
59
|
+
readonly capabilityRef?: string;
|
|
60
|
+
/** capability: the package whose releases are scanned (e.g. `@nanobpm/urban`). Provenance is
|
|
61
|
+
* per-package scoped — the same `#C` may appear in two packages — so this is required. */
|
|
62
|
+
readonly package?: string;
|
|
63
|
+
/** capability: an OPTIONAL empirical verifier command for the gated fallback (decision 5). Run
|
|
64
|
+
* ONCE at the gate boundary (poll budget exhausted) against the newest published `package`
|
|
65
|
+
* version when deterministic provenance resolved nothing; exit 0 binds that newest version. Left
|
|
66
|
+
* unset, the capability edge is deterministic-only. The resolved `pkg@version` and bare version
|
|
67
|
+
* are exposed to the command as `RESOLVED_ARTIFACT` / `RESOLVED_VERSION`. */
|
|
68
|
+
readonly verifyCommand?: string;
|
|
52
69
|
}
|
|
53
70
|
|
|
54
71
|
/** The poll cadence: how often to re-probe, how long to keep trying, and the backoff shape. */
|
|
@@ -75,10 +92,24 @@ export interface ReadinessProbe {
|
|
|
75
92
|
readonly credentialEnv?: string;
|
|
76
93
|
}
|
|
77
94
|
|
|
78
|
-
/** The result of a single probe attempt. `detail` is a short, already-redacted human note.
|
|
95
|
+
/** The result of a single probe attempt. `detail` is a short, already-redacted human note.
|
|
96
|
+
* `bind` is the OPTIONAL late-bound value a matcher discovered (the reusable "emit" primitive,
|
|
97
|
+
* #274 Gap B): a kind-agnostic `key → value` map the worker forwards into the `readiness-ready`
|
|
98
|
+
* message so the gate can surface it as an output process variable (e.g. the `capability` kind
|
|
99
|
+
* binds `{ resolvedArtifact: "@nanobpm/urban@0.54.0" }`). Provenance is public, so nothing in
|
|
100
|
+
* `bind` is redacted; keep values free of any secret material by construction. */
|
|
79
101
|
export interface ProbeResult {
|
|
80
102
|
readonly ready: boolean;
|
|
81
103
|
readonly detail: string;
|
|
104
|
+
readonly bind?: Record<string, string>;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A single published GitHub Release, reduced to the two fields the capability resolver reads: the
|
|
108
|
+
* `<package>@<version>` tag and the release body carrying the `## Provenance` `#NNN` refs. Kept
|
|
109
|
+
* separate from I/O so {@link matchCapability} is pure/unit-testable. */
|
|
110
|
+
export interface GithubRelease {
|
|
111
|
+
readonly tag: string;
|
|
112
|
+
readonly body: string;
|
|
82
113
|
}
|
|
83
114
|
|
|
84
115
|
/** A raw HTTP response the http matcher inspects (kept separate from I/O so it is pure-testable). */
|
|
@@ -148,6 +179,26 @@ export function parseProbe(raw: unknown): ReadinessProbe {
|
|
|
148
179
|
const onTimeout: OnTimeout = onTimeoutRaw === "" ? "escalate" : onTimeoutRaw;
|
|
149
180
|
|
|
150
181
|
const match = isRecord(raw.match) ? parseMatch(raw.match) : undefined;
|
|
182
|
+
// A capability edge whose ref or package is blank can never resolve — fail loudly here rather than
|
|
183
|
+
// wait forever (mirroring the blank-`target` guard above). Both are required for this kind.
|
|
184
|
+
if (kind === "capability") {
|
|
185
|
+
if (!match?.capabilityRef) {
|
|
186
|
+
throw new Error("readiness probe (capability): 'match.capabilityRef' is required (e.g. 'nano-ide#274' or '#274')");
|
|
187
|
+
}
|
|
188
|
+
// A ref that carries no numeric issue/PR id (e.g. 'cap274' has a number, but 'nano-ide#' or a
|
|
189
|
+
// bare word does not) can never resolve — `matchCapability` would only surface it as a timeout
|
|
190
|
+
// much later. Reject it now, via the SAME canonical parser the resolver uses, so a malformed
|
|
191
|
+
// edge fails loudly at parse (mirroring the intent of the blank-ref guard above).
|
|
192
|
+
if (!capabilityNumber(match.capabilityRef)) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
`readiness probe (capability): 'match.capabilityRef' ('${match.capabilityRef}') must carry a ` +
|
|
195
|
+
"numeric issue/PR id (e.g. 'nano-ide#274' or '#274')",
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
if (!match?.package) {
|
|
199
|
+
throw new Error("readiness probe (capability): 'match.package' is required (provenance is per-package scoped)");
|
|
200
|
+
}
|
|
201
|
+
}
|
|
151
202
|
const poll = isRecord(raw.poll) ? parsePoll(raw.poll) : undefined;
|
|
152
203
|
const credentialEnv = str(raw.credentialEnv).trim() || undefined;
|
|
153
204
|
if (credentialEnv !== undefined && !isEnvKey(credentialEnv)) {
|
|
@@ -197,6 +248,9 @@ function parseMatch(raw: Record<string, unknown>): ProbeMatch {
|
|
|
197
248
|
version: str(raw.version).trim() || undefined,
|
|
198
249
|
conclusion: str(raw.conclusion).trim() || undefined,
|
|
199
250
|
checkName: str(raw.checkName).trim() || undefined,
|
|
251
|
+
capabilityRef: str(raw.capabilityRef).trim() || undefined,
|
|
252
|
+
package: str(raw.package).trim() || undefined,
|
|
253
|
+
verifyCommand: str(raw.verifyCommand).trim() || undefined,
|
|
200
254
|
};
|
|
201
255
|
}
|
|
202
256
|
|
|
@@ -304,6 +358,126 @@ function versionOf(target: string): string | undefined {
|
|
|
304
358
|
return v === "" ? undefined : v;
|
|
305
359
|
}
|
|
306
360
|
|
|
361
|
+
// ── Capability resolver (#274 Gap A — a stable, pure "which version first carries C?" matcher) ───
|
|
362
|
+
|
|
363
|
+
/** Compare two dotted numeric version strings (`major.minor.patch…`), reusing the exact semantics of
|
|
364
|
+
* nano-ide `scripts/publish.mjs` `cmpVersion` so the resolver and the publisher agree on ordering.
|
|
365
|
+
* Missing trailing segments count as 0; returns <0, 0, or >0. */
|
|
366
|
+
export function cmpVersion(a: string, b: string): number {
|
|
367
|
+
const pa = a.split(".").map(Number);
|
|
368
|
+
const pb = b.split(".").map(Number);
|
|
369
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
370
|
+
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
371
|
+
if (d !== 0) return d;
|
|
372
|
+
}
|
|
373
|
+
return 0;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** The bare, purely numeric `#NNN` number from a capability handle — `nano-ide#274`, `#274`, or a
|
|
377
|
+
* naked `274` all normalise to `274`. Returns undefined for anything without a number, so a blank/
|
|
378
|
+
* malformed ref never accidentally matches. */
|
|
379
|
+
function capabilityNumber(ref: string): string | undefined {
|
|
380
|
+
const m = ref.match(/(\d+)\s*$/);
|
|
381
|
+
return m ? m[1] : undefined;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Does a release body's `## Provenance` reference `#NNN`? Matched on a `#`-prefixed word boundary so
|
|
385
|
+
* `#27` never spuriously satisfies `#274`. */
|
|
386
|
+
function bodyReferences(body: string, num: string): boolean {
|
|
387
|
+
return new RegExp(`#${num}(?!\\d)`).test(body);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** The `<version>` of a release tagged exactly `<package>@<version>` (numeric-dotted), or undefined
|
|
391
|
+
* when the tag belongs to another package or is not a version tag. Per-package scoping is enforced
|
|
392
|
+
* here: a sibling package's provenance can never leak into this package's resolution. */
|
|
393
|
+
function versionForPackage(tag: string, pkg: string): string | undefined {
|
|
394
|
+
const prefix = `${pkg}@`;
|
|
395
|
+
if (!tag.startsWith(prefix)) return undefined;
|
|
396
|
+
const v = tag.slice(prefix.length).trim();
|
|
397
|
+
return /^\d+(\.\d+)*$/.test(v) ? v : undefined;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** capability readiness (#274 Gap A): among GitHub Releases tagged `<match.package>@*` whose body
|
|
401
|
+
* references `match.capabilityRef`, resolve the **lowest** SemVer version — that is the version that
|
|
402
|
+
* *first* carries the capability (`firstVersion`). Late-binds it as `{ resolvedArtifact }` so the
|
|
403
|
+
* gate can hand the exact `pkg@version` to the consumer (#274 Gap B). PURE: it operates on an
|
|
404
|
+
* already-fetched, parsed releases list and NEVER throws — a malformed/empty list is simply
|
|
405
|
+
* "not ready yet" (keep waiting), so a transient provenance read cannot crash the poll loop. */
|
|
406
|
+
export function matchCapability(match: ProbeMatch | undefined, releases: readonly GithubRelease[]): ProbeResult {
|
|
407
|
+
const pkg = match?.package;
|
|
408
|
+
const ref = match?.capabilityRef;
|
|
409
|
+
if (!pkg || !ref) return { ready: false, detail: "capability: missing package/capabilityRef" };
|
|
410
|
+
const num = capabilityNumber(ref);
|
|
411
|
+
if (!num) return { ready: false, detail: "capability: unparseable capabilityRef (no #NNN)" };
|
|
412
|
+
|
|
413
|
+
let firstVersion: string | undefined;
|
|
414
|
+
for (const rel of releases) {
|
|
415
|
+
if (!rel || typeof rel.tag !== "string" || typeof rel.body !== "string") continue;
|
|
416
|
+
const version = versionForPackage(rel.tag, pkg);
|
|
417
|
+
if (!version) continue;
|
|
418
|
+
if (!bodyReferences(rel.body, num)) continue;
|
|
419
|
+
if (firstVersion === undefined || cmpVersion(version, firstVersion) < 0) firstVersion = version;
|
|
420
|
+
}
|
|
421
|
+
if (firstVersion === undefined) return { ready: false, detail: `capability #${num} not published in ${pkg} yet` };
|
|
422
|
+
const resolvedArtifact = `${pkg}@${firstVersion}`;
|
|
423
|
+
return { ready: true, detail: `capability #${num} carried by ${resolvedArtifact}`, bind: { resolvedArtifact } };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** The **newest** published SemVer version of `pkg` across `releases` — the target of the gated
|
|
427
|
+
* empirical fallback (decision 5), which installs the latest release and verifies the capability
|
|
428
|
+
* behaviourally when deterministic provenance resolved nothing. PURE / never throws. */
|
|
429
|
+
export function newestPublishedVersion(pkg: string | undefined, releases: readonly GithubRelease[]): string | undefined {
|
|
430
|
+
if (!pkg) return undefined;
|
|
431
|
+
let newest: string | undefined;
|
|
432
|
+
for (const rel of releases) {
|
|
433
|
+
if (!rel || typeof rel.tag !== "string") continue;
|
|
434
|
+
const version = versionForPackage(rel.tag, pkg);
|
|
435
|
+
if (!version) continue;
|
|
436
|
+
if (newest === undefined || cmpVersion(version, newest) > 0) newest = version;
|
|
437
|
+
}
|
|
438
|
+
return newest;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Parse a raw `gh api .../releases` payload (already JSON-decoded) into the minimal
|
|
442
|
+
* {@link GithubRelease} list the resolver reads. Tolerant: non-array/malformed input yields `[]`,
|
|
443
|
+
* so a bad provenance read degrades to "not ready", never a throw. Also accepts the `--paginate
|
|
444
|
+
* --slurp` shape — an array whose elements are themselves per-page arrays — flattening one level so
|
|
445
|
+
* releases beyond the first 100 (the true lowest version that first carried a capability) are seen. */
|
|
446
|
+
export function parseReleases(payload: unknown): GithubRelease[] {
|
|
447
|
+
if (!Array.isArray(payload)) return [];
|
|
448
|
+
const out: GithubRelease[] = [];
|
|
449
|
+
const push = (r: unknown): void => {
|
|
450
|
+
if (!isRecord(r)) return;
|
|
451
|
+
out.push({ tag: str(r.tag_name), body: str(r.body) });
|
|
452
|
+
};
|
|
453
|
+
for (const el of payload) {
|
|
454
|
+
if (Array.isArray(el)) {
|
|
455
|
+
for (const r of el) push(r);
|
|
456
|
+
} else {
|
|
457
|
+
push(el);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return out;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Split a `capability` target (`github-releases:owner/repo`) into the provenance source repo. The
|
|
464
|
+
* `github-releases:` scheme prefix is optional — a bare `owner/repo` is accepted too. */
|
|
465
|
+
export function parseReleasesTarget(target: string): string {
|
|
466
|
+
const t = target.trim();
|
|
467
|
+
const scheme = "github-releases:";
|
|
468
|
+
return t.startsWith(scheme) ? t.slice(scheme.length).trim() : t;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** Build the `gh api` command that lists a repo's releases (the provenance substrate). `gh` reads
|
|
472
|
+
* its token from the ambient env, exactly like the `github-check` kind — no `credentialEnv`.
|
|
473
|
+
* `--paginate --slurp` walks the FULL release history (not just the first `per_page=100` page), so a
|
|
474
|
+
* repo with >100 releases can still surface the lowest version that first carried a capability;
|
|
475
|
+
* `--slurp` wraps the pages in an outer array that {@link parseReleases} flattens. */
|
|
476
|
+
export function githubReleasesCommand(repo: string): string {
|
|
477
|
+
return `gh api --paginate --slurp ${shellQuote(`repos/${repo}/releases?per_page=100`)} -H ${shellQuote("Accept: application/vnd.github+json")}`;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
|
|
307
481
|
// ── Single probe attempt (does I/O via the injected {@link ProbeExec}) ──────────────────────────
|
|
308
482
|
|
|
309
483
|
/** Run ONE probe attempt for `probe`, resolving any credential from the typed env-contract and
|
|
@@ -332,9 +506,46 @@ export async function probeOnce(
|
|
|
332
506
|
if (out.code !== 0) return { ready: false, detail: "github-check: gh api failed (not ready)" };
|
|
333
507
|
return matchGithubCheck(probe.match, parseJson(out.stdout));
|
|
334
508
|
}
|
|
509
|
+
case "capability": {
|
|
510
|
+
const repo = parseReleasesTarget(probe.target);
|
|
511
|
+
const out = await exec.run(githubReleasesCommand(repo), env);
|
|
512
|
+
if (out.code !== 0) return { ready: false, detail: "capability: gh api failed (not ready)" };
|
|
513
|
+
return matchCapability(probe.match, parseReleases(parseJson(out.stdout)));
|
|
514
|
+
}
|
|
335
515
|
}
|
|
336
516
|
}
|
|
337
517
|
|
|
518
|
+
/** Build the gated empirical fallback for a `capability` probe (decision 5) — a thunk the poll loop
|
|
519
|
+
* runs ONCE at the gate boundary (local budget exhausted) when deterministic provenance resolved
|
|
520
|
+
* nothing. It installs nothing itself: it fetches releases, picks the NEWEST published version, and
|
|
521
|
+
* runs the descriptor's `match.verifyCommand` against it (with `RESOLVED_ARTIFACT`/`RESOLVED_VERSION`
|
|
522
|
+
* in the env) — exit 0 binds that newest version, letting a capability that provenance under-reported
|
|
523
|
+
* still resolve empirically. Returns `null` (no fallback) for a non-capability probe, a capability
|
|
524
|
+
* probe with no `verifyCommand` (deterministic-only), or when no version/releases are available — so
|
|
525
|
+
* the default path stays a pure deterministic lookup and the agent judgment is the gated exception. */
|
|
526
|
+
export function makeCapabilityFallback(
|
|
527
|
+
probe: ReadinessProbe,
|
|
528
|
+
exec: ProbeExec,
|
|
529
|
+
env: Record<string, string | undefined>,
|
|
530
|
+
): () => Promise<ProbeResult | null> {
|
|
531
|
+
return async () => {
|
|
532
|
+
if (probe.kind !== "capability") return null;
|
|
533
|
+
const verify = probe.match?.verifyCommand;
|
|
534
|
+
const pkg = probe.match?.package;
|
|
535
|
+
if (!verify || !pkg) return null;
|
|
536
|
+
const listed = await exec.run(githubReleasesCommand(parseReleasesTarget(probe.target)), env);
|
|
537
|
+
if (listed.code !== 0) return null;
|
|
538
|
+
const newest = newestPublishedVersion(pkg, parseReleases(parseJson(listed.stdout)));
|
|
539
|
+
if (!newest) return null;
|
|
540
|
+
const artifact = `${pkg}@${newest}`;
|
|
541
|
+
const res = await exec.run(verify, { ...env, RESOLVED_ARTIFACT: artifact, RESOLVED_VERSION: newest });
|
|
542
|
+
if (res.code === 0) {
|
|
543
|
+
return { ready: true, detail: `capability verified empirically at ${artifact}`, bind: { resolvedArtifact: artifact } };
|
|
544
|
+
}
|
|
545
|
+
return { ready: false, detail: "capability: empirical verification failed at gate boundary" };
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
338
549
|
/** Resolve the credential a probe declares, from the typed env-contract only. Returns undefined
|
|
339
550
|
* when no `credentialEnv` is declared or the key is unset — never a value from the descriptor. */
|
|
340
551
|
function credentialFor(probe: ReadinessProbe, env: Record<string, string | undefined>): string | undefined {
|
package/app/stage.test.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { test } from "node:test";
|
|
6
6
|
import { assert, assertEquals } from "#test-assert";
|
|
7
7
|
import { FEATURE_RUN_STATUSES } from "./feature.ts";
|
|
8
|
-
import { deriveListBucket, deriveStage, type StageInput } from "./stage.ts";
|
|
8
|
+
import { deriveEscalationOpen, deriveListBucket, deriveStage, type StageInput } from "./stage.ts";
|
|
9
9
|
|
|
10
10
|
const base = (over: Partial<StageInput> & { status: string }): StageInput => ({
|
|
11
11
|
pr_key: null,
|
|
@@ -105,3 +105,47 @@ test("deriveListBucket: history iff terminal AND acknowledged, else active", ()
|
|
|
105
105
|
assertEquals(deriveListBucket("running", "2024-01-01T00:00:00Z"), "active");
|
|
106
106
|
assertEquals(deriveListBucket("blocked", "2024-01-01T00:00:00Z"), "history");
|
|
107
107
|
});
|
|
108
|
+
|
|
109
|
+
// deriveEscalationOpen (issue #272): the single fail-closed "open escalation" display signal. TRUE iff
|
|
110
|
+
// all three independently-written escalation columns AGREE the run is parked at an answerable
|
|
111
|
+
// escalation; any single missing/torn field yields FALSE so the pages render not-escalated.
|
|
112
|
+
test("deriveEscalationOpen: true only when status, pointer AND question all present", () => {
|
|
113
|
+
assertEquals(
|
|
114
|
+
deriveEscalationOpen({ status: "escalated", escalation_user_task_key: "ut-7", escalation_question: "which base?" }),
|
|
115
|
+
true,
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("deriveEscalationOpen: torn tuple (pointer set, question blank) renders as NOT escalated", () => {
|
|
120
|
+
// The mirror tear observed on nwf#270: status=escalated + live pointer + blank question.
|
|
121
|
+
assertEquals(
|
|
122
|
+
deriveEscalationOpen({ status: "escalated", escalation_user_task_key: "ut-7", escalation_question: null }),
|
|
123
|
+
false,
|
|
124
|
+
);
|
|
125
|
+
assertEquals(
|
|
126
|
+
deriveEscalationOpen({ status: "escalated", escalation_user_task_key: "ut-7", escalation_question: "" }),
|
|
127
|
+
false,
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("deriveEscalationOpen: torn tuple (question set, pointer null) renders as NOT escalated", () => {
|
|
132
|
+
// The entry-window tear: record-feature-escalation persisted the question but the poller has not yet
|
|
133
|
+
// denormalised the pointer.
|
|
134
|
+
assertEquals(
|
|
135
|
+
deriveEscalationOpen({ status: "escalated", escalation_user_task_key: null, escalation_question: "which base?" }),
|
|
136
|
+
false,
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("deriveEscalationOpen: a resumed run whose status lags behind a cleared tuple is NOT escalated", () => {
|
|
141
|
+
// status still 'escalated' but the answer op already cleared pointer + question → fail closed.
|
|
142
|
+
assertEquals(
|
|
143
|
+
deriveEscalationOpen({ status: "escalated", escalation_user_task_key: null, escalation_question: null }),
|
|
144
|
+
false,
|
|
145
|
+
);
|
|
146
|
+
// A non-escalated status can never be open regardless of stray column values.
|
|
147
|
+
assertEquals(
|
|
148
|
+
deriveEscalationOpen({ status: "running", escalation_user_task_key: "ut-7", escalation_question: "which base?" }),
|
|
149
|
+
false,
|
|
150
|
+
);
|
|
151
|
+
});
|
package/app/stage.ts
CHANGED
|
@@ -103,6 +103,35 @@ export function deriveStage(run: StageInput): DerivedStage {
|
|
|
103
103
|
return { stage, state, skipped: skippedKeys.join(" "), attention };
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/** Derive the single fail-closed "open escalation" display signal for one feature run (issue #272).
|
|
107
|
+
*
|
|
108
|
+
* The open-escalation condition is jointly encoded by THREE independently-written columns —
|
|
109
|
+
* `status='escalated'`, `escalation_user_task_key` (the completable pointer), and `escalation_question`
|
|
110
|
+
* — owned by different writers on different schedules (the `record-feature-escalation` service task
|
|
111
|
+
* sets the question; `pollFeatureEscalations`/`deriveFeatureEscalationPatch` sets status + pointer; the
|
|
112
|
+
* answer operation clears the tuple). Because they are not written as one atomic tuple, a reader can
|
|
113
|
+
* observe a TORN interim state (e.g. `status=escalated` + pointer set + `question=null`) and render a
|
|
114
|
+
* self-contradictory escalation — an "answer me" affordance with nothing to answer.
|
|
115
|
+
*
|
|
116
|
+
* Collapse that class at the consumer: the pages gate the escalation affordances (Abandon / answer
|
|
117
|
+
* form) on this ONE derived conjunction rather than on any single column, so a torn tuple renders as
|
|
118
|
+
* NOT escalated (fail closed) instead of escalated-but-blank. `true` iff ALL THREE fields agree the run
|
|
119
|
+
* is parked at an answerable escalation; any missing field yields `false`. Maintained as a write-time
|
|
120
|
+
* projection by the feature_runs gateway (like `stage`/`list_bucket`), so it stays fresh on every write
|
|
121
|
+
* — including the answer operation's eager tuple-clear, which makes the affordance disappear WITHOUT
|
|
122
|
+
* waiting a poll pass. Pure and read-only. */
|
|
123
|
+
export function deriveEscalationOpen(run: {
|
|
124
|
+
status: string;
|
|
125
|
+
escalation_question?: string | null;
|
|
126
|
+
escalation_user_task_key?: string | null;
|
|
127
|
+
}): boolean {
|
|
128
|
+
return (
|
|
129
|
+
run.status === "escalated" &&
|
|
130
|
+
(run.escalation_user_task_key ?? "") !== "" &&
|
|
131
|
+
(run.escalation_question ?? "") !== ""
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
106
135
|
/** The Active/History partition label (§5), maintained at write time so the flat-DSL page tabs filter
|
|
107
136
|
* on a stored `list_bucket` column with only `in` clauses. `history` iff the row is in a truly-terminal
|
|
108
137
|
* status AND acknowledged; otherwise `active` (live runs + terminal-but-UNACKNOWLEDGED runs). */
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
-- 040_feature_escalation_open.sql — issue #272: collapse the torn open-escalation projection.
|
|
2
|
+
--
|
|
3
|
+
-- A feature run's "open escalation" condition is jointly encoded by THREE independently-written
|
|
4
|
+
-- columns — `status='escalated'`, `escalation_user_task_key` (the completable pointer), and
|
|
5
|
+
-- `escalation_question` — owned by DIFFERENT writers on DIFFERENT schedules:
|
|
6
|
+
-- • `record-feature-escalation` (service task) persists `escalation_question` (pointer still NULL).
|
|
7
|
+
-- • `pollFeatureEscalations` / `deriveFeatureEscalationPatch` (async poller) sets `status='escalated'`
|
|
8
|
+
-- + denormalises `escalation_user_task_key` on the next pass, and clears the tuple when the task
|
|
9
|
+
-- is gone — but only on the next pass.
|
|
10
|
+
-- • the answer/complete operation (`answerFeatureEscalation`) clears the pointer + question eagerly.
|
|
11
|
+
-- Because they are never written as one atomic tuple, a reader can observe a TORN interim state (e.g.
|
|
12
|
+
-- `status=escalated` + pointer set + `question=null`) and the page renders a self-contradictory
|
|
13
|
+
-- escalation — an "answer me" affordance (Abandon action, answer form) for a run with nothing to
|
|
14
|
+
-- answer (observed on nwf#270).
|
|
15
|
+
--
|
|
16
|
+
-- Fix: derive the display-state, don't denormalise it, and FAIL CLOSED. `escalation_open` is a single
|
|
17
|
+
-- write-time-projected signal — `1` iff ALL THREE columns agree the run is parked at an answerable
|
|
18
|
+
-- escalation (`status='escalated'` AND `escalation_user_task_key` non-NULL AND `escalation_question`
|
|
19
|
+
-- non-NULL), else `0`. The pages gate the escalation affordances on THIS conjunction instead of on
|
|
20
|
+
-- `escalation_user_task_key` alone, so a torn tuple renders as NOT escalated rather than
|
|
21
|
+
-- escalated-but-blank. It mirrors the existing `stage` / `list_bucket` display projections: maintained
|
|
22
|
+
-- by the feature_runs gateway (app/feature.ts) from the pure `deriveEscalationOpen` helper (app/stage.ts)
|
|
23
|
+
-- on every write — never hand-derived in SQL, the page, or a poller. Because the gateway reprojects on
|
|
24
|
+
-- the answer operation's eager tuple-clear (which touches projection inputs), the affordance disappears
|
|
25
|
+
-- WITHOUT waiting a poll pass.
|
|
26
|
+
--
|
|
27
|
+
-- Forward-only, additive (expand): nullable with no default, so pre-#272 rows grandfather in as NULL
|
|
28
|
+
-- and never gate control flow. `backfillFeatureStages` (app/feature.ts) stamps rows whose
|
|
29
|
+
-- `escalation_open` is still NULL once at boot — including a run parked at a LIVE escalation when this
|
|
30
|
+
-- lands, which the poller would otherwise never re-write while it stays parked — and the gateway keeps
|
|
31
|
+
-- every future write fresh. Numbered after the current highest prefix on origin/main (039); the runner
|
|
32
|
+
-- wraps each file in its own transaction, so this file must NOT contain BEGIN/COMMIT.
|
|
33
|
+
ALTER TABLE feature_runs ADD COLUMN escalation_open INTEGER;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.85.1",
|
|
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",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
55
|
"@nanobpm/agentic": "^0.1.0",
|
|
56
|
-
"@nanobpm/urban": "^0.
|
|
56
|
+
"@nanobpm/urban": "^0.55.0"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@biomejs/biome": "^2.4.11",
|
package/pages/feature.page.json
CHANGED
|
@@ -96,7 +96,7 @@
|
|
|
96
96
|
{
|
|
97
97
|
"label": "Abandon",
|
|
98
98
|
"confirm": "Abandon this escalated task? The run gives up on it (no PR).",
|
|
99
|
-
"showWhenField": "
|
|
99
|
+
"showWhenField": "escalation_open",
|
|
100
100
|
"action": {
|
|
101
101
|
"path": "/app/api/actions/answer-escalation",
|
|
102
102
|
"body": { "userTaskKey": "{{row.escalation_user_task_key}}", "resolution": "abandon" }
|
|
@@ -129,7 +129,7 @@
|
|
|
129
129
|
{ "field": "delivery_label", "label": "Delivery" }
|
|
130
130
|
],
|
|
131
131
|
"form": {
|
|
132
|
-
"showWhenField": "
|
|
132
|
+
"showWhenField": "escalation_open",
|
|
133
133
|
"title": "Answer escalation",
|
|
134
134
|
"promptField": "escalation_question",
|
|
135
135
|
"inputKey": "answer",
|
package/pages/overview.page.json
CHANGED
|
@@ -123,7 +123,7 @@
|
|
|
123
123
|
{
|
|
124
124
|
"label": "Abandon",
|
|
125
125
|
"confirm": "Abandon this escalated task? The run gives up on it (no PR).",
|
|
126
|
-
"showWhenField": "
|
|
126
|
+
"showWhenField": "escalation_open",
|
|
127
127
|
"action": {
|
|
128
128
|
"path": "/app/api/actions/answer-escalation",
|
|
129
129
|
"body": { "userTaskKey": "{{row.escalation_user_task_key}}", "resolution": "abandon" }
|
|
@@ -145,7 +145,7 @@
|
|
|
145
145
|
{ "field": "outcome", "label": "Outcome" }
|
|
146
146
|
],
|
|
147
147
|
"form": {
|
|
148
|
-
"showWhenField": "
|
|
148
|
+
"showWhenField": "escalation_open",
|
|
149
149
|
"title": "Answer escalation",
|
|
150
150
|
"promptField": "escalation_question",
|
|
151
151
|
"inputKey": "answer",
|
|
@@ -19,6 +19,9 @@
|
|
|
19
19
|
<nano:extend name="version" type="string" optional="true" />
|
|
20
20
|
<nano:extend name="conclusion" type="string" optional="true" />
|
|
21
21
|
<nano:extend name="checkName" type="string" optional="true" />
|
|
22
|
+
<nano:extend name="capabilityRef" type="string" optional="true" />
|
|
23
|
+
<nano:extend name="package" type="string" optional="true" />
|
|
24
|
+
<nano:extend name="verifyCommand" type="string" optional="true" />
|
|
22
25
|
</nano:shape>
|
|
23
26
|
<nano:shape id="ReadinessProbePoll" name="Readiness probe — poll policy">
|
|
24
27
|
<nano:extend name="everyMs" type="integer" optional="true" />
|
|
@@ -42,10 +45,12 @@
|
|
|
42
45
|
<nano:shape id="ReadinessProbeOut" name="Readiness probe — result">
|
|
43
46
|
<nano:extend name="ready" type="boolean" />
|
|
44
47
|
<nano:extend name="detail" type="string" optional="true" />
|
|
48
|
+
<nano:extend name="resolvedArtifact" type="string" optional="true" />
|
|
45
49
|
</nano:shape>
|
|
46
50
|
<nano:shape id="ReadinessReady" name="readiness-ready message payload">
|
|
47
51
|
<nano:extend name="ready" type="boolean" />
|
|
48
52
|
<nano:extend name="detail" type="string" optional="true" />
|
|
53
|
+
<nano:extend name="resolvedArtifact" type="string" optional="true" />
|
|
49
54
|
</nano:shape>
|
|
50
55
|
</nano:shapes>
|
|
51
56
|
</bpmn:extensionElements>
|
|
@@ -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;
|