@nanobpm/nano-workforce 0.163.2 → 0.164.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.
@@ -317,21 +317,24 @@ test("issue #205: overview is the landing page and first nav item", async () =>
317
317
  );
318
318
  }
319
319
 
320
- // Three collapsible active-work sections, one per dispatch surface, each with a
320
+ // Four collapsible active-work sections, one per dispatch surface, each with a
321
321
  // live count in its header (showCount) and a persisted collapse toggle (collapsible). Each filters
322
- // its Active list on a `{field, in:[...]}` predicate: the PR / feature surfaces on `status`, but the
323
- // EPIC surface buckets on the DERIVED `list_bucket` (issue #298) — NOT raw `status` — so a `done`
324
- // epic still converging, or landed-but-unpromoted, does not vanish from the in-flight Epics section
325
- // the instant `status=done`. Guarding the field here is the regression guard for that defect class.
326
- // The epic surface binds the derived `plan_read_model` VIEW (epic #412 the single source of truth
327
- // for the wave/delivery projections it also renders), not the raw `plans` table.
322
+ // its Active list on a `{field, in:[...]}` predicate. The FEATURE and EPIC surfaces bucket on the
323
+ // DERIVED `list_bucket` — NOT raw `status` — over their read-model VIEW (features: issue #637 —
324
+ // fixing a drift where the grid read the vestigial base `feature_runs.status` with an allowlist that
325
+ // hid every converging feature; epics: issue #298), so a `done`-but-unacknowledged item still
326
+ // converging does not vanish from its in-flight section the instant `status` reads terminal. Guarding
327
+ // the field here is the regression guard for that defect class. The feature surface binds the derived
328
+ // `feature_read_model` VIEW (the single source of truth for the `list_bucket` activeness predicate,
329
+ // shared byte-for-byte with the Feature tab); the epic surface binds `plan_read_model` (epic #412),
330
+ // not the raw `plans` table. PRs / delivery graphs still bucket on `status` (see #637 follow-up).
328
331
  const expected: Record<string, { field: string; in: string[] }> = {
329
332
  pull_requests: {
330
333
  field: "status",
331
334
  in: ["converging", "waiting_review", "escalated", "waiting_deps", "waiting_merge", "queued", "merging"],
332
335
  },
333
336
  plan_read_model: { field: "list_bucket", in: ["active"] },
334
- feature_runs: { field: "status", in: ["running", "escalated", "awaiting_operator"] },
337
+ feature_read_model: { field: "list_bucket", in: ["active"] },
335
338
  // The 4th dispatch surface (issue #386) — active delivery graphs. Both in-flight statuses
336
339
  // (`awaiting-approval` parked at the gate, `running` dispatched) show here. Binds the derived
337
340
  // `delivery_graph_read_model` VIEW (S7 / #541 — the single source of truth for the pipeline
@@ -461,3 +464,59 @@ test("issue #521: the Delivery Graphs History tab surfaces dispatch time + the i
461
464
  "the Instance cell's processExplorer link must key on `process_key`",
462
465
  );
463
466
  });
467
+
468
+ test("issue #637: the Overview 'Active Features' grid buckets on the derived read model, in parity with the Feature tab", async () => {
469
+ // The bug: Overview's "Active Features" grid read the VESTIGIAL base `feature_runs.status` column
470
+ // with a hand-maintained allowlist (`running`/`escalated`/`awaiting_operator` — not even a valid
471
+ // feature-status set: those are delivery-graph statuses cloned from the "Active Delivery Graphs"
472
+ // grid). A feature only ever surfaced there if it happened to be `escalated`; its whole convergence
473
+ // life (`converging`/`waiting_review`/`waiting_merge`/…) was invisible on Overview while the SAME
474
+ // feature showed on the dedicated Feature tab — a state-tear between two surfaces that must agree.
475
+ //
476
+ // The fix repoints the grid at the derived `feature_read_model` VIEW filtered on the canonical
477
+ // `list_bucket IN ('active')` activeness predicate — the single declare-once column (app/
478
+ // featureReadModel.ts, guarded byte-for-byte by check:derivation-parity) that the Feature tab's
479
+ // "Feature runs" grid already uses. This test pins that parity so the tear can't silently return.
480
+ const overview = JSON.parse(readFileSync(`${ROOT}pages/overview.page.json`, "utf8"));
481
+ const feature = JSON.parse(readFileSync(`${ROOT}pages/feature.page.json`, "utf8"));
482
+
483
+ const ovGrid = (overview.nodes ?? []).find(
484
+ (n: Json) => n.type === "dataGrid" && n.props?.title === "Active Features",
485
+ );
486
+ assert(ovGrid, "overview.page.json must have an 'Active Features' grid");
487
+
488
+ // It must bind the derived read-model VIEW, never the vestigial base `feature_runs` table.
489
+ assert(
490
+ ovGrid.props?.data?.table === "feature_read_model",
491
+ "the Overview 'Active Features' grid must bind the derived feature_read_model VIEW, not the base feature_runs table",
492
+ );
493
+
494
+ // And it must filter on the canonical `list_bucket IN ('active')` activeness predicate — never a
495
+ // base-`status` allowlist.
496
+ const ovFilters: Json[] = ovGrid.props?.data?.filter ?? [];
497
+ assert(
498
+ !ovFilters.some((f: Json) => f.field === "status"),
499
+ "the Overview 'Active Features' grid must NOT re-encode activeness as a base-`status` allowlist",
500
+ );
501
+ const ovBucket = ovFilters.find((f: Json) => f.field === "list_bucket");
502
+ assert(
503
+ ovBucket && JSON.stringify(ovBucket.in) === JSON.stringify(["active"]),
504
+ "the Overview 'Active Features' grid must filter `list_bucket IN ['active']`",
505
+ );
506
+
507
+ // Parity: the Feature tab's "Feature runs" grid answers "is this feature active?" identically —
508
+ // same VIEW, same activeness predicate — so the two surfaces show the same active-feature set.
509
+ const featGrid = (feature.nodes ?? []).find(
510
+ (n: Json) => n.type === "dataGrid" && n.props?.title === "Feature runs",
511
+ );
512
+ assert(featGrid, "feature.page.json must have a 'Feature runs' grid");
513
+ assert(
514
+ featGrid.props?.data?.table === ovGrid.props?.data?.table,
515
+ "the Overview and Feature-tab feature grids must bind the SAME table (feature_read_model)",
516
+ );
517
+ const featBucket = (featGrid.props?.data?.filter ?? []).find((f: Json) => f.field === "list_bucket");
518
+ assert(
519
+ featBucket && JSON.stringify(featBucket.in) === JSON.stringify(ovBucket.in),
520
+ "the Overview and Feature-tab feature grids must apply the IDENTICAL list_bucket activeness predicate",
521
+ );
522
+ });
@@ -0,0 +1,66 @@
1
+ // pr.merge-stall-probe — the mergeable-wait timer fired: the in-process poller never published
2
+ // `merge-ready` within `mergeableWaitTimeout` (it died across a redeploy, errored on this PR, or
3
+ // skipped the verdict), so the instance would otherwise sit wedged at `waiting_merge` forever with
4
+ // no timeout and no escalation (issue #636). This is the bounded-wait backstop: re-derive
5
+ // ground-truth mergeability directly from GitHub — reusing the poller's own classifier
6
+ // (`classifyMergeability` over a fresh `fetchPrState`, protocol-aware via `loadMergeProtocol`) — and
7
+ // emit `mergeState` so the *existing* `gw-mergeable` routes the token to the correct arm:
8
+ // • ready → attempt-merge
9
+ // • conflict → rebase arm
10
+ // • blocked → CI-fix arm
11
+ // • draft / waiting (still computing) → not-landable escalation (gw-mergeable default)
12
+ // The timer only guarantees the remediation machinery is ENTERED when the poller is dead; every
13
+ // downstream arm is the same one the live poller feeds. A bounded `mergeStallRounds` counter
14
+ // (incremented by this task's output mapping, capped by `mergeStallMax`) stops the probe from
15
+ // re-arming forever: once exhausted, `gw-merge-stall` routes to human escalation instead.
16
+ import type { AppJobHandler } from "@nanobpm/urban";
17
+ import { classifyMergeability, fetchPrState } from "../../app/github.ts";
18
+ import { loadMergeProtocol } from "../../app/mergeProtocol.ts";
19
+ import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
20
+
21
+ // Input typed off the model data envelope (`MergeStallProbeIn` in merge-loop.bpmn) — ADR 0040.
22
+ type In = WorkerInputs["pr.merge-stall-probe"];
23
+
24
+ interface Out extends Record<string, unknown> {
25
+ // Mirrors the poller's `merge-ready {mergeState}` payload so the existing `gw-mergeable` FEEL
26
+ // routes it. `waiting` (GitHub still computing / no verdict) is surfaced verbatim; gw-mergeable
27
+ // has no `waiting` arm, so it falls to the not-landable default and escalates — the correct
28
+ // backstop when the poller is dead and 30 minutes on GitHub still cannot settle the PR.
29
+ mergeState: string;
30
+ failingChecks: number;
31
+ failingChecksList: string;
32
+ }
33
+
34
+ const handler: AppJobHandler<In, Out> = async (job, app) => {
35
+ const { repo, prNumber } = job.variables;
36
+ const token = process.env.GITHUB_TOKEN ?? "";
37
+
38
+ // Re-derive ground truth exactly as the poller does (service.ts block 2). Load the repo's merge
39
+ // protocol so the protocol-aware backstop (#392) gates a red DECLARED-required check even when
40
+ // GitHub reports UNSTABLE. Any transport hiccup is treated as "still waiting" — never a spurious
41
+ // ready/conflict verdict — so a bad read is surfaced as `waiting`, which `gw-mergeable` routes to
42
+ // its not-landable default (human escalation), erring toward a human rather than misrouting the
43
+ // token to a wrong remediation arm.
44
+ const st = await fetchPrState(repo, prNumber, token).catch(() => null);
45
+ if (st === null) {
46
+ return { mergeState: "waiting", failingChecks: 0, failingChecksList: "" };
47
+ }
48
+ const protocol = await loadMergeProtocol(repo, token).catch(() => null);
49
+ const mergeState = classifyMergeability(st, protocol ?? undefined);
50
+
51
+ app.log.info("merge-stall-probe: poller stalled — re-derived mergeability", {
52
+ prKey: job.variables.prKey,
53
+ mergeState,
54
+ mergeStateStatus: st.mergeStateStatus,
55
+ });
56
+
57
+ return {
58
+ mergeState,
59
+ // Carried for the CI-fix arm (mergeState "blocked" = a failed required check), mirroring the
60
+ // poller's `merge-ready` payload so fix-ci knows which gates to green.
61
+ failingChecks: st.failingChecks,
62
+ failingChecksList: st.failingCheckNames.join("\n"),
63
+ };
64
+ };
65
+
66
+ export default handler;