@nanobpm/nano-workforce 0.79.0 → 0.81.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.
@@ -7,7 +7,7 @@
7
7
  // GitHub transport forced off so it is hermetic.
8
8
  import { test } from "node:test";
9
9
  import { assertEquals } from "#test-assert";
10
- import { parsePr, pollIncidentsImpl, repoEnvelopeVars, submitPr } from "./service.ts";
10
+ import { parsePr, pollIncidentsImpl, repoEnvelopeVars, startMerge, submitPr } from "./service.ts";
11
11
 
12
12
  function memTable(rows: any[], key: string) {
13
13
  return {
@@ -331,6 +331,109 @@ test("submitPr defaults convergeOnly to false so the global auto-merge default g
331
331
  });
332
332
  });
333
333
 
334
+ // Lineage threading (issue #245): `submitPr` persists the origin `root_request_key` on the PR row
335
+ // and carries it onto the convergence instance; `startMerge` reads it back off the row onto the
336
+ // merge instance. A human/webhook submit that supplies no root self-roots on the `pr_key` (its own
337
+ // root), and a resubmit that omits the root must not clobber a root already learned.
338
+ function captureRoot() {
339
+ const stores: Record<string, { rows: unknown[]; key: string }> = {
340
+ pull_requests: { rows: [], key: "pr_key" },
341
+ escalations: { rows: [], key: "id" },
342
+ pr_dependencies: { rows: [], key: "pr_key" },
343
+ };
344
+ const data = {
345
+ table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
346
+ } as any;
347
+ let captured: unknown;
348
+ const engine = {
349
+ createInstance: (req: { variables?: Record<string, unknown> }) => {
350
+ captured = req.variables?.rootRequestKey;
351
+ return Promise.resolve({ processInstanceKey: "PI-1" });
352
+ },
353
+ } as any;
354
+ return { data, engine, stores, get: () => captured };
355
+ }
356
+
357
+ test("submitPr persists root_request_key and threads it onto the convergence instance", async () => {
358
+ await withGithubOff(async () => {
359
+ const { data, engine, stores, get } = captureRoot();
360
+ await submitPr(
361
+ data,
362
+ engine,
363
+ { repo: "owner/repo", number: 8, url: "https://github.com/owner/repo/pull/8", prKey: "owner/repo#8" },
364
+ [],
365
+ 20,
366
+ false,
367
+ "owner/repo#1",
368
+ );
369
+ assertEquals(get(), "owner/repo#1");
370
+ const pr = stores.pull_requests.rows[0] as Record<string, unknown>;
371
+ assertEquals(pr.root_request_key, "owner/repo#1");
372
+ });
373
+ });
374
+
375
+ test("submitPr self-roots root_request_key on the pr_key for a human/webhook submit (its own root)", async () => {
376
+ await withGithubOff(async () => {
377
+ const { data, engine, stores, get } = captureRoot();
378
+ await submitPr(data, engine, {
379
+ repo: "owner/repo",
380
+ number: 9,
381
+ url: "https://github.com/owner/repo/pull/9",
382
+ prKey: "owner/repo#9",
383
+ });
384
+ assertEquals(get(), "owner/repo#9");
385
+ const pr = stores.pull_requests.rows[0] as Record<string, unknown>;
386
+ assertEquals(pr.root_request_key, "owner/repo#9");
387
+ });
388
+ });
389
+
390
+ test("submitPr resubmit does not clobber an already-learned root when omitted", async () => {
391
+ await withGithubOff(async () => {
392
+ const { data, engine, stores, get } = captureRoot();
393
+ (stores.pull_requests.rows as unknown[]).push({
394
+ pr_key: "owner/repo#8",
395
+ repo: "owner/repo",
396
+ number: 8,
397
+ url: "https://github.com/owner/repo/pull/8",
398
+ status: "abandoned", // terminal -> re-open path
399
+ current_round: 3,
400
+ root_request_key: "owner/repo#1",
401
+ });
402
+ await submitPr(data, engine, {
403
+ repo: "owner/repo",
404
+ number: 8,
405
+ url: "https://github.com/owner/repo/pull/8",
406
+ prKey: "owner/repo#8",
407
+ });
408
+ assertEquals(get(), "owner/repo#1", "resubmit re-threads the learned root");
409
+ const pr = stores.pull_requests.rows[0] as Record<string, unknown>;
410
+ assertEquals(pr.root_request_key, "owner/repo#1");
411
+ });
412
+ });
413
+
414
+ test("startMerge reads root_request_key off the PR row onto the merge instance", async () => {
415
+ await withGithubOff(async () => {
416
+ const { data, engine, get } = captureRoot();
417
+ await submitPr(
418
+ data,
419
+ engine,
420
+ { repo: "owner/repo", number: 8, url: "https://github.com/owner/repo/pull/8", prKey: "owner/repo#8" },
421
+ [],
422
+ 20,
423
+ false,
424
+ "owner/repo#1",
425
+ );
426
+ await startMerge(data, engine, {
427
+ repo: "owner/repo",
428
+ number: 8,
429
+ url: "https://github.com/owner/repo/pull/8",
430
+ prKey: "owner/repo#8",
431
+ round: 2,
432
+ });
433
+ assertEquals(get(), "owner/repo#1");
434
+ });
435
+ });
436
+
334
437
  // The repository envelope drives the c8ctl harness's isolated workspace provisioning: it is
335
438
  // emitted under the reserved `io.nanobpm.agentTask` namespace with the PR head branch as the
336
439
  // checkout ref, and omitted entirely when the head branch couldn't be resolved (so the harness
package/app/service.ts CHANGED
@@ -11,6 +11,7 @@ import { readFileSync } from "node:fs";
11
11
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
12
12
  import { abandonUrl, mintAbandonToken, renderAbandonBrief } from "./abandon.ts";
13
13
  import { agentSlaTimeout } from "./agentSla.ts";
14
+ import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
14
15
  import { deriveFeatureBlockedPatch, deriveFeatureDelivery, deriveFeatureEscalationPatch, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRun, type FeatureRunStatus, featureRuns } from "./feature.ts";
15
16
  import {
16
17
  classifyMergeability,
@@ -26,6 +27,7 @@ import {
26
27
  type PrState,
27
28
  requestCopilotReview,
28
29
  } from "./github.ts";
30
+ import { pollLineage } from "./lineage.ts";
29
31
  import { mergeLanes, readExclusions } from "./mergeExclusion.ts";
30
32
  import { freshHeadRunAction, headRunPresenceCount, loadMergeProtocol } from "./mergeProtocol.ts";
31
33
  import { type PrLaneDecision, planPrLane, taskDependencyDepths } from "./mergeTrain.ts";
@@ -122,77 +124,6 @@ export const MERGE_ADMIN = ["1", "true", "on", "yes"].includes(
122
124
 
123
125
  const now = () => new Date().toISOString();
124
126
 
125
- /** A PR is "done" in exactly these states; everything else (converging, waiting_review,
126
- * escalated, and the merge-stage waiting_deps/waiting_merge/waiting_lane/queued) is in flight. `converged`
127
- * is terminal only in review-only mode (AUTO_MERGE off); with auto-merge on, a converged PR
128
- * transitions into the merge stage and lands as `merged`. The status endpoint and the cancel
129
- * guard both key off this set. */
130
- export const TERMINAL_STATUSES: readonly string[] = ["converged", "merged", "abandoned"];
131
-
132
- /** The derived epic delivery signal (issue #171). Distinct from `plan.status`: `status = done`
133
- * means "the fan-out finished and ≥1 slice opened a PR, dispatched to convergence" (record-results
134
- * sets it as soon as one PR opened — other slices may be blocked/skipped), which conflates hand-off
135
- * with landing. `delivery` reports whether those slice PRs have actually MERGED. */
136
- export type Delivery = "converging" | "landed";
137
-
138
- /** Rollup of a plan's slice-PR landing state, derived by joining `plan_tasks.pr_key` →
139
- * `pull_requests.status`. Pure and read-only — the single source of truth for the denormalised
140
- * `plans.delivery` / `plans.delivery_label` columns the poller projects. */
141
- export interface DeliveryRollup {
142
- delivery: Delivery | null;
143
- label: string | null;
144
- prsOpened: number;
145
- prsMerged: number;
146
- prsInFlight: number;
147
- }
148
-
149
- /** Derive the delivery signal for one plan from its status and the statuses of its slice PRs.
150
- *
151
- * - `converging` — the plan is `done` but ≥1 slice PR is still non-terminal (in flight).
152
- * - `landed` — every slice PR merged: `prsInFlight == 0 && prsMerged == prsOpened && prsOpened > 0`.
153
- * - `null` — no positive signal yet: the plan isn't `done`, it opened no PRs, or every PR is
154
- * terminal but not all merged (some `abandoned`/`converged` — resolved-not-landed, per the issue).
155
- *
156
- * A slice's PR status is "in flight" iff it is NOT in `TERMINAL_STATUSES`; `abandoned`/`converged`
157
- * count as resolved-not-landed (terminal but not merged), so they never make an epic `landed`. */
158
- export function deriveDelivery(
159
- planStatus: string,
160
- prStatuses: readonly string[],
161
- ): DeliveryRollup {
162
- const prsOpened = prStatuses.length;
163
- let prsMerged = 0;
164
- let prsInFlight = 0;
165
- for (const s of prStatuses) {
166
- if (s === "merged") prsMerged++;
167
- else if (!TERMINAL_STATUSES.includes(s)) prsInFlight++;
168
- }
169
- // `delivery` is only meaningful once the fan-out has been dispatched (`status = done`) and at
170
- // least one slice PR exists; otherwise there is nothing to have landed yet.
171
- if (planStatus !== "done" || prsOpened === 0) {
172
- return { delivery: null, label: null, prsOpened, prsMerged, prsInFlight };
173
- }
174
- if (prsInFlight > 0) {
175
- return {
176
- delivery: "converging",
177
- label: `${prsMerged}/${prsOpened} slices merged, ${prsInFlight} converging`,
178
- prsOpened,
179
- prsMerged,
180
- prsInFlight,
181
- };
182
- }
183
- if (prsMerged === prsOpened) {
184
- return {
185
- delivery: "landed",
186
- label: `${prsOpened}/${prsOpened} slices merged`,
187
- prsOpened,
188
- prsMerged,
189
- prsInFlight,
190
- };
191
- }
192
- // Every slice PR is terminal but not all merged (some abandoned/converged): resolved, not landed.
193
- return { delivery: null, label: null, prsOpened, prsMerged, prsInFlight };
194
- }
195
-
196
127
  interface PullRequest {
197
128
  pr_key: string;
198
129
  repo: string;
@@ -233,6 +164,13 @@ interface PullRequest {
233
164
  // workflow stage.
234
165
  incident_key: string | null;
235
166
  incident_message: string | null;
167
+ // Lineage projection (037_lineage.sql, issue #245): the stable ORIGIN identity (the issue =
168
+ // feature_key / plan_key) threaded onto this PR by `submitPr`, and passed as a `createInstance`
169
+ // variable onto the convergence + merge instances so every descendant carries the root. For a
170
+ // human-opened / webhook PR with no originating request, `submitPr` self-roots it to its own
171
+ // `pr_key` so the Lineage UI join resolves; a legacy NULL is tolerated the same way by the
172
+ // lineage read projection (`pollLineage`), which self-roots on `pr_key`.
173
+ root_request_key: string | null;
236
174
  }
237
175
 
238
176
  interface PrDependency {
@@ -413,6 +351,7 @@ export async function submitPr(
413
351
  dependsOn: string[] = [],
414
352
  maxRounds: number = MAX_ROUNDS,
415
353
  convergeOnly = false,
354
+ rootRequestKey: string | null = null,
416
355
  ) {
417
356
  const table = prs(data);
418
357
  const existing = await table.get(parsed.prKey);
@@ -447,6 +386,15 @@ export async function submitPr(
447
386
  // Cooperative abandon check (#76): reuse the PR's existing capability token across re-runs (and
448
387
  // the later merge instance), or mint one for a first submission.
449
388
  const abandonToken = existing?.abandon_token ?? mintAbandonToken();
389
+ // Lineage (issue #245): the origin identity threaded onto this PR + its convergence/merge
390
+ // instances. A caller (feature/epic hand-off) supplies it on the first submit; a resubmit that
391
+ // omits it must not clobber a root already learned, so coalesce onto the existing row's value. A
392
+ // human/webhook submit supplies none → the PR is its OWN root, so self-root on its `pr_key`
393
+ // (never NULL): the Lineage page drills into a thread's member PRs by joining
394
+ // `lineage_threads.root_request_key` → `pull_requests.root_request_key`, and a self-rooted
395
+ // thread's key IS the `pr_key`, so leaving the PR row NULL would render an empty PR list for it.
396
+ // Persisting `pr_key` keeps that join honest (the projection self-roots the same key either way).
397
+ const effectiveRoot = rootRequestKey ?? existing?.root_request_key ?? parsed.prKey;
450
398
  if (existing) {
451
399
  // A prior run (cancelled, converged, or otherwise superseded) may have left an OPEN
452
400
  // escalation row. A fresh convergence run must not inherit that stale answer — the
@@ -474,6 +422,7 @@ export async function submitPr(
474
422
  converged_at: null,
475
423
  merged_at: null,
476
424
  abandon_token: abandonToken,
425
+ root_request_key: effectiveRoot,
477
426
  updated_at: ts,
478
427
  });
479
428
  } else {
@@ -488,6 +437,7 @@ export async function submitPr(
488
437
  status: "converging",
489
438
  current_round: 1,
490
439
  abandon_token: abandonToken,
440
+ root_request_key: effectiveRoot,
491
441
  created_at: ts,
492
442
  updated_at: ts,
493
443
  });
@@ -503,6 +453,10 @@ export async function submitPr(
503
453
  round: 1,
504
454
  maxRounds: clampRounds(maxRounds, MAX_ROUNDS),
505
455
  reviewWaitTimeout: REVIEW_WAIT_TIMEOUT,
456
+ // Lineage (issue #245): carry the origin identity onto the convergence instance so every
457
+ // descendant (and any message it correlates) is stitched back to the originating request.
458
+ // A human/webhook PR that is its own root carries its own `pr_key` (never NULL — see above).
459
+ rootRequestKey: effectiveRoot,
506
460
  // Per-request review-only override: carried on the instance so `pr.finalize` can stop at
507
461
  // `converged` for this PR without handing off to the merge-loop, independent of the global
508
462
  // NANO_PR_AUTO_MERGE default. Only ever narrows (never forces merge on when auto-merge is off).
@@ -539,6 +493,11 @@ export async function startMerge(
539
493
  if (!existing?.abandon_token) {
540
494
  await prs(data).update(pr.prKey, { abandon_token: abandonToken, updated_at: now() });
541
495
  }
496
+ // Lineage (issue #245): the origin identity was persisted on the PR row at submit; carry it onto
497
+ // the merge instance too so the merge stage stays stitched to the originating request. A
498
+ // self-rooted PR carries its own `pr_key`; `?? null` only tolerates a legacy row predating the
499
+ // column.
500
+ const rootRequestKey = existing?.root_request_key ?? null;
542
501
  const abUrl = abandonUrl(abandonToken);
543
502
  // Resolve the PR head branch so the merge agents (fix-ci, rebase) get an isolated clone checked
544
503
  // out on it (same host-git provisioning path as review-round). Best-effort: an unresolved head
@@ -566,6 +525,8 @@ export async function startMerge(
566
525
  rebaseRound: 0,
567
526
  rebaseMax: MAX_REBASE_ROUNDS,
568
527
  agentSlaTimeout: AGENT_SLA_TIMEOUT,
528
+ // Lineage (issue #245): thread the origin identity onto the merge instance (see startMerge).
529
+ rootRequestKey,
569
530
  abandonUrl: abUrl,
570
531
  abandonBrief: renderAbandonBrief(abUrl),
571
532
  // Host-git provisioning (c8ctl): same repository envelope as the convergence loop, so the
@@ -1610,6 +1571,7 @@ export async function pollOnce(
1610
1571
  await pollWaveGates(data, engine, token);
1611
1572
  await pollDelivery(data);
1612
1573
  await pollFeatureDelivery(data);
1574
+ await pollLineage(data);
1613
1575
  await pollFeatureEscalations(data, engine);
1614
1576
  await pollFeatureBlocked(data, engine);
1615
1577
  await pollUserTasks(data, engine);
@@ -0,0 +1,69 @@
1
+ -- Lineage projection (issue #245): thread user intent → progress as one arc.
2
+ --
3
+ -- The lineage already exists in the data layer (feature_runs.pr_key ↔ pull_requests.pr_key,
4
+ -- plan_tasks.pr_key ↔ pull_requests.pr_key, and message correlation by prKey/planKey) but is not
5
+ -- projected as a single narrative. This migration adds the two pieces the read model needs:
6
+ --
7
+ -- 1. `pull_requests.root_request_key` — the stable ORIGIN identity (the issue = feature_key /
8
+ -- plan_key) threaded onto every PR a request spawns. `submitPr` persists it and passes it as a
9
+ -- `createInstance` variable onto the convergence + merge instances; `startMerge` reads it back
10
+ -- off the row. A human-opened / webhook PR with no originating request is self-rooted by
11
+ -- `submitPr` (`root_request_key = pr_key`); a legacy pre-migration NULL is backfilled below to
12
+ -- the SAME root submitPr would persist (feature/epic origin key, else self-rooted pr_key), and
13
+ -- the projection tolerates any residual NULL by self-rooting on `pr_key`.
14
+ --
15
+ -- 2. `lineage_threads` — a DERIVED read table, one row per `root_request_key` (or a self-rooted
16
+ -- PR's own key), recomputed idempotently each poll pass by `pollLineage` (app/lineage.ts) from
17
+ -- the existing gateway joins. It stitches `request → implementation run → PR(s) → convergence →
18
+ -- merge → outcome` into one ordered thread, exposing the active frontier (`stage`/`stage_label`/
19
+ -- `process_key`) plus whether the whole arc has settled (`active`). Urban's datasource cannot
20
+ -- read a SQL VIEW (gateway.ts schema() whitelists only type='table'), so — following the
21
+ -- codebase convention for read-model projections (`plans.delivery`, `feature_runs.delivery_label`)
22
+ -- — this is a denormalised flat table the schema-driven pages read directly.
23
+ --
24
+ -- Forward-only, additive (expand): a nullable column with no default, a new table/indexes, and an
25
+ -- origin-aware backfill of the new column. Numbered after the current highest prefix (036). The
26
+ -- runner wraps each file in its own transaction, so this file must NOT contain BEGIN/COMMIT.
27
+ ALTER TABLE pull_requests ADD COLUMN root_request_key TEXT;
28
+ CREATE INDEX IF NOT EXISTS idx_pr_root ON pull_requests(root_request_key);
29
+
30
+ -- Backfill: every pre-migration PR row has root_request_key = NULL, which breaks the Lineage page's
31
+ -- `lineage_threads.root_request_key → pull_requests.root_request_key` drill-down join (a NULL never
32
+ -- matches a thread key), rendering an empty PR list. Backfill each row with the SAME root `submitPr`
33
+ -- persists going forward, so the join resolves for already-tracked PRs at deploy time too:
34
+ -- 1. a PR spawned by a feature run roots on its origin `feature_runs.feature_key` (submitPr passes
35
+ -- `featureKey` — workers/converge-feature);
36
+ -- 2. a PR spawned by an epic slice roots on its origin `plan_tasks.plan_key` (submitPr passes
37
+ -- `planKey` — workers/record-wave);
38
+ -- 3. any remaining origin-less PR (human/webhook, or an origin row that no longer survives) is
39
+ -- self-rooted on its own `pr_key`, exactly as `submitPr` self-roots a human/webhook PR.
40
+ -- Origin-aware steps run first so a tracked PR keeps its true origin key rather than being self-rooted
41
+ -- (which would orphan it from its feature/epic thread in the drill-down). All idempotent.
42
+ UPDATE pull_requests SET root_request_key = (
43
+ SELECT fr.feature_key FROM feature_runs fr WHERE fr.pr_key = pull_requests.pr_key
44
+ )
45
+ WHERE root_request_key IS NULL
46
+ AND EXISTS (SELECT 1 FROM feature_runs fr WHERE fr.pr_key = pull_requests.pr_key);
47
+ UPDATE pull_requests SET root_request_key = (
48
+ SELECT pt.plan_key FROM plan_tasks pt WHERE pt.pr_key = pull_requests.pr_key
49
+ )
50
+ WHERE root_request_key IS NULL
51
+ AND EXISTS (SELECT 1 FROM plan_tasks pt WHERE pt.pr_key = pull_requests.pr_key);
52
+ UPDATE pull_requests SET root_request_key = pr_key WHERE root_request_key IS NULL;
53
+
54
+ CREATE TABLE IF NOT EXISTS lineage_threads (
55
+ root_request_key TEXT PRIMARY KEY, -- origin issue key (feature_key/plan_key), or a self-rooted pr_key
56
+ kind TEXT NOT NULL, -- feature | epic | pr
57
+ title TEXT, -- best-effort origin/PR title
58
+ issue_url TEXT, -- origin issue URL (NULL for self-rooted PRs)
59
+ stage TEXT NOT NULL, -- active-frontier machine label (implementing|converging|merged|…)
60
+ stage_label TEXT, -- human narrative rollup for the timeline
61
+ process_key TEXT, -- active-frontier process instance (for the processExplorer link)
62
+ pr_keys TEXT, -- JSON array of the member PR keys (fan-out for epics)
63
+ pr_count INTEGER NOT NULL DEFAULT 0,
64
+ active INTEGER NOT NULL DEFAULT 1, -- 1 while the arc has an active frontier, 0 once settled
65
+ created_at TEXT NOT NULL,
66
+ updated_at TEXT NOT NULL
67
+ );
68
+
69
+ CREATE INDEX IF NOT EXISTS idx_lineage_active ON lineage_threads(active);
@@ -0,0 +1,285 @@
1
+ // End-to-end proof for the durable artifact-readiness wait-gate (ADR 0001 §2, issue #258).
2
+ //
3
+ // Boots this whole Urban app in-process against the WASM engine + virtual clock via `bootTestApp`
4
+ // and drives the real `readiness-gate` process — the reusable primitive: a parallel fork arms the
5
+ // `pr.readiness-probe` service task (an app-hosted worker) alongside an event-based gateway that
6
+ // races the `readiness-ready` message the probe publishes against a bounded timer catch.
7
+ //
8
+ // Two load-bearing behaviours are proven end to end:
9
+ // • READY — a `command` probe that is green immediately (`true`) drives the probe worker to
10
+ // publish `readiness-ready`, the gateway correlates it, and the gate releases through
11
+ // `wait-ready → gate-ready`.
12
+ // • BOUNDED (the red-first "the wait cannot hang" gate) — a `command` probe that is never green
13
+ // (`false`) exhausts the worker's local budget WITHOUT publishing; the wait does not hang, and
14
+ // when the engine timer (the authoritative bound) fires it escalates onto the native
15
+ // `readiness-escalation` userTask. A gate modelled without the timer arm could never satisfy
16
+ // this — the token would sit on `wait-ready` forever.
17
+ //
18
+ // The probes are deterministic shell builtins (`true`/`false`) so the flow is hermetic — no
19
+ // network, no GitHub. GitHub transport is still forced offline to match the sibling e2es.
20
+ import assert from "node:assert/strict";
21
+ import { mkdtempSync, rmSync } from "node:fs";
22
+ import { tmpdir } from "node:os";
23
+ import { dirname, join, resolve } from "node:path";
24
+ import { after, before, describe, test } from "node:test";
25
+ import { fileURLToPath } from "node:url";
26
+ import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
27
+
28
+ const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
29
+
30
+ const GITHUB_ENV_OVERRIDES: Record<string, string> = {
31
+ NANO_PR_GITHUB_TRANSPORT: "token",
32
+ GITHUB_TOKEN: "",
33
+ };
34
+ const savedEnv = new Map<string, string | undefined>();
35
+
36
+ interface TakenFlow {
37
+ from: string;
38
+ to: string;
39
+ }
40
+
41
+ function takenFlows(app: TestApp): string[] {
42
+ const snapshot = app.snapshot();
43
+ const flows = Array.isArray(snapshot.takenSequenceFlows) ? snapshot.takenSequenceFlows : [];
44
+ return flows
45
+ .filter((f): f is TakenFlow => typeof f === "object" && f !== null && "from" in f && "to" in f)
46
+ .map((f) => `${f.from}->${f.to}`);
47
+ }
48
+
49
+ // Each scenario boots its own app so `takenSequenceFlows` (engine-global + cumulative) reflects
50
+ // exactly one instance's history.
51
+ async function boot(): Promise<{ app: TestApp; dbDir: string }> {
52
+ const dbDir = mkdtempSync(join(tmpdir(), "nwf-readiness-"));
53
+ const app = await bootTestApp(APP_ROOT, { env: { NANO_APP_DB_URL: `file:${join(dbDir, "app.db")}` } });
54
+ return { app, dbDir };
55
+ }
56
+
57
+ describe("nano-workforce artifact-readiness wait-gate (readiness-gate.bpmn)", () => {
58
+ before(() => {
59
+ for (const [k, v] of Object.entries(GITHUB_ENV_OVERRIDES)) {
60
+ savedEnv.set(k, process.env[k]);
61
+ process.env[k] = v;
62
+ }
63
+ });
64
+
65
+ after(() => {
66
+ for (const [k, v] of savedEnv) {
67
+ if (v === undefined) delete process.env[k];
68
+ else process.env[k] = v;
69
+ }
70
+ });
71
+
72
+ test("READY: a green probe publishes readiness-ready and the gate releases through wait-ready → gate-ready", async () => {
73
+ const { app, dbDir } = await boot();
74
+ try {
75
+ await app.engine.createInstance({
76
+ processDefinitionId: "readiness-gate",
77
+ variables: {
78
+ gateKey: "gate-ready-1",
79
+ probe: { kind: "command", target: "true", poll: { everyMs: 5, timeoutMs: 5000, backoff: "fixed" } },
80
+ // A long engine timer that must NOT fire — readiness wins the race first.
81
+ probeTimeout: "PT30M",
82
+ onTimeout: "escalate",
83
+ },
84
+ });
85
+ await app.settle();
86
+
87
+ const flows = takenFlows(app);
88
+ assert.ok(
89
+ flows.includes("wait-ready->gate-ready"),
90
+ `the gate released on the readiness signal (flows: ${flows.join(", ")})`,
91
+ );
92
+ assert.ok(
93
+ flows.includes("probe->probe-done"),
94
+ "the probe branch settled after publishing the readiness signal",
95
+ );
96
+ // The gate never timed out — no escalation userTask exists.
97
+ const tasks = await app.engine.searchUserTasks({});
98
+ assert.equal(
99
+ tasks.filter((t) => t.elementId === "readiness-escalation").length,
100
+ 0,
101
+ "a probe that went green never escalates",
102
+ );
103
+ } finally {
104
+ await app.stop();
105
+ rmSync(dbDir, { recursive: true, force: true });
106
+ }
107
+ });
108
+
109
+ test("BOUNDED: a never-green probe cannot hang — the engine timer fires and escalates onto the userTask", async () => {
110
+ const { app, dbDir } = await boot();
111
+ try {
112
+ const { processInstanceKey } = await app.engine.createInstance({
113
+ processDefinitionId: "readiness-gate",
114
+ variables: {
115
+ gateKey: "gate-timeout-1",
116
+ // `false` is never ready; a tiny local budget makes the worker exhaust fast (real time),
117
+ // leaving the ENGINE timer as the authoritative bound.
118
+ probe: { kind: "command", target: "false", poll: { everyMs: 5, timeoutMs: 40, backoff: "fixed" } },
119
+ probeTimeout: "PT1M",
120
+ onTimeout: "escalate",
121
+ },
122
+ });
123
+ await app.settle();
124
+
125
+ // The wait has NOT hung and has NOT yet escalated: the probe branch settled not-ready, and the
126
+ // gate is parked on the timer catch — no escalation userTask before the timer's duration.
127
+ const beforeTimer = await app.engine.searchUserTasks({ processInstanceKey });
128
+ assert.equal(
129
+ beforeTimer.filter((t) => t.elementId === "readiness-escalation").length,
130
+ 0,
131
+ "the gate is still bounded-waiting on the timer, not prematurely escalated",
132
+ );
133
+ const beforeFlows = takenFlows(app);
134
+ assert.ok(!beforeFlows.includes("wait-ready->gate-ready"), "a never-green probe never releases as ready");
135
+
136
+ // Advancing past the engine timer is the ONLY thing that ends the wait — proving the bound is
137
+ // engine-owned. The token races off the timer catch onto the escalation userTask.
138
+ await app.advanceTime(61_000);
139
+
140
+ const afterFlows = takenFlows(app);
141
+ assert.ok(
142
+ afterFlows.includes("wait-timeout->gw-onTimeout") && afterFlows.includes("gw-onTimeout->readiness-escalation"),
143
+ `the timer bounded the wait and routed to escalation (flows: ${afterFlows.join(", ")})`,
144
+ );
145
+ const escalations = (await app.engine.searchUserTasks({ processInstanceKey })).filter(
146
+ (t) => t.elementId === "readiness-escalation",
147
+ );
148
+ assert.equal(escalations.length, 1, "the bounded timeout opened exactly one escalation userTask");
149
+
150
+ // Completing the escalation with `acknowledge` releases the gate to its `escalated` terminal
151
+ // via the resolution gateway (default arm) — the primitive is fully durable.
152
+ await app.engine.completeUserTask(escalations[0].userTaskKey, { resolution: "acknowledge" });
153
+ await app.settle();
154
+ const ackFlows = takenFlows(app);
155
+ assert.ok(
156
+ ackFlows.includes("readiness-escalation->gw-resolution") && ackFlows.includes("gw-resolution->gate-escalated"),
157
+ "acknowledging the escalation routes through the resolution gateway to gate-escalated",
158
+ );
159
+ } finally {
160
+ await app.stop();
161
+ rmSync(dbDir, { recursive: true, force: true });
162
+ }
163
+ });
164
+
165
+ test("ABANDON: an operator who abandons the escalation drives the gate to its `failed` terminal, not `escalated`", async () => {
166
+ const { app, dbDir } = await boot();
167
+ try {
168
+ const { processInstanceKey } = await app.engine.createInstance({
169
+ processDefinitionId: "readiness-gate",
170
+ variables: {
171
+ gateKey: "gate-abandon-1",
172
+ probe: { kind: "command", target: "false", poll: { everyMs: 5, timeoutMs: 40, backoff: "fixed" } },
173
+ probeTimeout: "PT1M",
174
+ onTimeout: "escalate",
175
+ },
176
+ });
177
+ await app.settle();
178
+ await app.advanceTime(61_000);
179
+
180
+ const escalations = (await app.engine.searchUserTasks({ processInstanceKey })).filter(
181
+ (t) => t.elementId === "readiness-escalation",
182
+ );
183
+ assert.equal(escalations.length, 1, "the bounded timeout opened exactly one escalation userTask");
184
+
185
+ // `abandon` ("give up on this gate") must NOT be a no-op: it routes to gate-failed, not gate-escalated.
186
+ await app.engine.completeUserTask(escalations[0].userTaskKey, { resolution: "abandon" });
187
+ await app.settle();
188
+ const flows = takenFlows(app);
189
+ assert.ok(
190
+ flows.includes("gw-resolution->gate-failed"),
191
+ `abandoning the escalation routes to gate-failed (flows: ${flows.join(", ")})`,
192
+ );
193
+ assert.ok(
194
+ !flows.includes("gw-resolution->gate-escalated"),
195
+ "abandon must not reach the escalated terminal",
196
+ );
197
+ } finally {
198
+ await app.stop();
199
+ rmSync(dbDir, { recursive: true, force: true });
200
+ }
201
+ });
202
+
203
+ test("onTimeout=continue: a bounded timeout proceeds (no escalation) when the caller declares continue", async () => {
204
+ const { app, dbDir } = await boot();
205
+ try {
206
+ await app.engine.createInstance({
207
+ processDefinitionId: "readiness-gate",
208
+ variables: {
209
+ gateKey: "gate-continue-1",
210
+ probe: { kind: "command", target: "false", poll: { everyMs: 5, timeoutMs: 40, backoff: "fixed" } },
211
+ probeTimeout: "PT1M",
212
+ onTimeout: "continue",
213
+ },
214
+ });
215
+ await app.settle();
216
+ await app.advanceTime(61_000);
217
+
218
+ const flows = takenFlows(app);
219
+ assert.ok(
220
+ flows.includes("gw-onTimeout->gate-continued"),
221
+ `a continue-on-timeout gate proceeds past the bounded wait (flows: ${flows.join(", ")})`,
222
+ );
223
+ } finally {
224
+ await app.stop();
225
+ rmSync(dbDir, { recursive: true, force: true });
226
+ }
227
+ });
228
+
229
+ test("DEFAULT SAFETY: an omitted onTimeout escalates (the gateway default is the safety path, not continue)", async () => {
230
+ const { app, dbDir } = await boot();
231
+ try {
232
+ await app.engine.createInstance({
233
+ processDefinitionId: "readiness-gate",
234
+ variables: {
235
+ gateKey: "gate-default-1",
236
+ // Neither probe.onTimeout nor a top-level onTimeout is declared.
237
+ probe: { kind: "command", target: "false", poll: { everyMs: 5, timeoutMs: 40, backoff: "fixed" } },
238
+ probeTimeout: "PT1M",
239
+ },
240
+ });
241
+ await app.settle();
242
+ await app.advanceTime(61_000);
243
+
244
+ const flows = takenFlows(app);
245
+ assert.ok(
246
+ flows.includes("gw-onTimeout->readiness-escalation"),
247
+ `an omitted onTimeout must default to escalation, never silently continue (flows: ${flows.join(", ")})`,
248
+ );
249
+ assert.ok(
250
+ !flows.includes("gw-onTimeout->gate-continued"),
251
+ "the safety default must not route to continue",
252
+ );
253
+ } finally {
254
+ await app.stop();
255
+ rmSync(dbDir, { recursive: true, force: true });
256
+ }
257
+ });
258
+
259
+ test("probe.onTimeout wins: the descriptor field is preferred over a top-level onTimeout (one source of truth)", async () => {
260
+ const { app, dbDir } = await boot();
261
+ try {
262
+ await app.engine.createInstance({
263
+ processDefinitionId: "readiness-gate",
264
+ variables: {
265
+ gateKey: "gate-probe-pref-1",
266
+ // The descriptor asks to continue; a stale top-level onTimeout says escalate. probe wins.
267
+ probe: { kind: "command", target: "false", onTimeout: "continue", poll: { everyMs: 5, timeoutMs: 40, backoff: "fixed" } },
268
+ probeTimeout: "PT1M",
269
+ onTimeout: "escalate",
270
+ },
271
+ });
272
+ await app.settle();
273
+ await app.advanceTime(61_000);
274
+
275
+ const flows = takenFlows(app);
276
+ assert.ok(
277
+ flows.includes("gw-onTimeout->gate-continued"),
278
+ `probe.onTimeout ("continue") must win over the top-level onTimeout ("escalate") (flows: ${flows.join(", ")})`,
279
+ );
280
+ } finally {
281
+ await app.stop();
282
+ rmSync(dbDir, { recursive: true, force: true });
283
+ }
284
+ });
285
+ });
package/nano.app.json CHANGED
@@ -165,6 +165,10 @@
165
165
  {
166
166
  "taskType": "pr.converge-gate",
167
167
  "handler": "workers/converge-gate/worker.ts"
168
+ },
169
+ {
170
+ "taskType": "pr.readiness-probe",
171
+ "handler": "workers/readiness-probe/worker.ts"
168
172
  }
169
173
  ],
170
174
  "externalTaskTypes": [