@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/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. Four built-in kinds ship (`http`, `command`, `npm`,
13
- // `github-check`); everything else is reached through the `command` escape hatch (ADR 0001 §2
14
- // pinned decision 1). A probe carries NO secret material any credential is read at execution
15
- // time from the typed env-contract (`credentialEnv` names a declared {@link EnvKey}; ADR 0004
16
- // pinned decision 2) and is redacted from every log line.
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
- export type ProbeKind = "http" | "command" | "npm" | "github-check";
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.83.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.53.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": "dataGrid",
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": "round", "dir": "asc" },
153
+ "orderBy": { "field": "created_at", "dir": "asc" },
152
154
  "filter": [{ "field": "plan_key", "eqParam": true }]
153
155
  },
154
- "columns": [
155
- { "field": "round", "header": "Round" },
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": "dataGrid",
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
- "columns": [
180
- { "field": "epoch", "header": "Epoch" },
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": "dataGrid",
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
- "columns": [
233
- { "field": "wave", "header": "Wave" },
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
  {
@@ -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": "escalation_user_task_key",
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": "escalation_user_task_key",
132
+ "showWhenField": "escalation_open",
133
133
  "title": "Answer escalation",
134
134
  "promptField": "escalation_question",
135
135
  "inputKey": "answer",
@@ -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": "escalation_user_task_key",
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": "escalation_user_task_key",
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>