@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/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.0",
|
|
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.54.0"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@biomejs/biome": "^2.4.11",
|
|
@@ -137,38 +137,36 @@
|
|
|
137
137
|
}
|
|
138
138
|
},
|
|
139
139
|
{
|
|
140
|
-
"type": "
|
|
140
|
+
"type": "prose",
|
|
141
141
|
"id": "plan-reviews",
|
|
142
142
|
"props": {
|
|
143
143
|
"title": "Plan review trace",
|
|
144
144
|
"refreshMs": 5000,
|
|
145
145
|
"collapsible": true,
|
|
146
146
|
"defaultCollapsed": true,
|
|
147
|
+
"measure": 80,
|
|
148
|
+
"empty": "No plan reviews recorded yet.",
|
|
147
149
|
"data": {
|
|
148
150
|
"kind": "datasource",
|
|
149
151
|
"source": "app",
|
|
150
152
|
"table": "plan_reviews",
|
|
151
|
-
"orderBy": { "field": "
|
|
153
|
+
"orderBy": { "field": "created_at", "dir": "asc" },
|
|
152
154
|
"filter": [{ "field": "plan_key", "eqParam": true }]
|
|
153
155
|
},
|
|
154
|
-
"
|
|
155
|
-
|
|
156
|
-
{ "field": "epoch", "header": "Epoch" },
|
|
157
|
-
{ "field": "approved", "header": "Approved? (1/0)" },
|
|
158
|
-
{ "field": "findings", "header": "Reviewer findings" },
|
|
159
|
-
{ "field": "created_at", "header": "Recorded" }
|
|
160
|
-
]
|
|
156
|
+
"header": "Round {{round}} · epoch {{epoch}} · approved {{approved}} · {{created_at}}",
|
|
157
|
+
"body": "findings"
|
|
161
158
|
}
|
|
162
159
|
},
|
|
163
160
|
{
|
|
164
|
-
"type": "
|
|
161
|
+
"type": "prose",
|
|
165
162
|
"id": "plan-review-escalations",
|
|
166
163
|
"props": {
|
|
167
164
|
"title": "Plan-review escalations",
|
|
168
|
-
"rowKey": "id",
|
|
169
165
|
"refreshMs": 5000,
|
|
170
166
|
"collapsible": true,
|
|
171
167
|
"defaultCollapsed": true,
|
|
168
|
+
"measure": 80,
|
|
169
|
+
"empty": "No plan-review escalations.",
|
|
172
170
|
"data": {
|
|
173
171
|
"kind": "datasource",
|
|
174
172
|
"source": "app",
|
|
@@ -176,16 +174,8 @@
|
|
|
176
174
|
"orderBy": { "field": "id", "dir": "desc" },
|
|
177
175
|
"filter": [{ "field": "plan_key", "eqParam": true }]
|
|
178
176
|
},
|
|
179
|
-
"
|
|
180
|
-
|
|
181
|
-
{ "field": "round", "header": "Round" },
|
|
182
|
-
{ "field": "findings", "header": "Findings" },
|
|
183
|
-
{ "field": "status", "header": "Status" },
|
|
184
|
-
{ "field": "directive", "header": "Directive" },
|
|
185
|
-
{ "field": "note", "header": "Human note" },
|
|
186
|
-
{ "field": "asked_at", "header": "Asked" },
|
|
187
|
-
{ "field": "answered_at", "header": "Answered" }
|
|
188
|
-
]
|
|
177
|
+
"header": "Round {{round}} · epoch {{epoch}} · {{status}} · directive {{directive}} · asked {{asked_at}} · answered {{answered_at}} · note {{note}}",
|
|
178
|
+
"body": "findings"
|
|
189
179
|
}
|
|
190
180
|
},
|
|
191
181
|
{
|
|
@@ -214,14 +204,15 @@
|
|
|
214
204
|
}
|
|
215
205
|
},
|
|
216
206
|
{
|
|
217
|
-
"type": "
|
|
207
|
+
"type": "prose",
|
|
218
208
|
"id": "coordination-notes",
|
|
219
209
|
"props": {
|
|
220
210
|
"title": "Coordination notes",
|
|
221
|
-
"rowKey": "id",
|
|
222
211
|
"refreshMs": 5000,
|
|
223
212
|
"collapsible": true,
|
|
224
213
|
"defaultCollapsed": true,
|
|
214
|
+
"measure": 80,
|
|
215
|
+
"empty": "No coordination notes posted yet.",
|
|
225
216
|
"data": {
|
|
226
217
|
"kind": "datasource",
|
|
227
218
|
"source": "app",
|
|
@@ -229,14 +220,8 @@
|
|
|
229
220
|
"orderBy": { "field": "id", "dir": "asc" },
|
|
230
221
|
"filter": [{ "field": "plan_key", "eqParam": true }]
|
|
231
222
|
},
|
|
232
|
-
"
|
|
233
|
-
|
|
234
|
-
{ "field": "author_task", "header": "Agent" },
|
|
235
|
-
{ "field": "kind", "header": "Kind" },
|
|
236
|
-
{ "field": "files", "header": "Files" },
|
|
237
|
-
{ "field": "body", "header": "Note" },
|
|
238
|
-
{ "field": "created_at", "header": "Posted" }
|
|
239
|
-
]
|
|
223
|
+
"header": "Wave {{wave}} · {{author_task}} · {{kind}} · files {{files}} · {{created_at}}",
|
|
224
|
+
"body": "body"
|
|
240
225
|
}
|
|
241
226
|
},
|
|
242
227
|
{
|
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>
|