@nanobpm/nano-workforce 0.83.0 → 0.85.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/app/feature.ts +33 -11
- package/app/featureGateway.test.ts +60 -2
- package/app/readiness.test.ts +187 -0
- package/app/readiness.ts +220 -9
- package/app/stage.test.ts +45 -1
- package/app/stage.ts +29 -0
- package/db/migrations/040_feature_escalation_open.sql +33 -0
- package/package.json +2 -2
- package/pages/epic-detail.page.json +16 -31
- package/pages/feature.page.json +2 -2
- package/pages/overview.page.json +2 -2
- package/resources/processes/readiness-gate.bpmn +5 -0
- package/scripts/pages-contract.test.ts +22 -7
- package/workers/readiness-probe/worker.test.ts +147 -1
- package/workers/readiness-probe/worker.ts +70 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [0.85.0](https://github.com/nanobpm/nano-workforce/compare/v0.84.0...v0.85.0) (2026-08-17)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **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)
|
|
7
|
+
|
|
8
|
+
# [0.84.0](https://github.com/nanobpm/nano-workforce/compare/v0.83.0...v0.84.0) (2026-08-17)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **ui:** render narrative epic-detail sections with urban prose renderer ([#270](https://github.com/nanobpm/nano-workforce/issues/270)) ([#271](https://github.com/nanobpm/nano-workforce/issues/271)) ([38c67a4](https://github.com/nanobpm/nano-workforce/commit/38c67a479693730b9a79f3a7b0f88ef574d3a456)), closes [nano-ide#274](https://github.com/nano-ide/issues/274) [#87](https://github.com/nanobpm/nano-workforce/issues/87) [274/#275](https://github.com/nanobpm/nano-workforce/issues/275)
|
|
14
|
+
|
|
1
15
|
# [0.83.0](https://github.com/nanobpm/nano-workforce/compare/v0.82.1...v0.83.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);
|