@nanobpm/nano-workforce 0.136.0 → 0.137.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 CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.137.0](https://github.com/nanobpm/nano-workforce/compare/v0.136.0...v0.137.0) (2026-08-24)
2
+
3
+ ### Features
4
+
5
+ * single-source plan-family read models onto @nanobpm/urban defineRollup/defineReadModel ([#493](https://github.com/nanobpm/nano-workforce/issues/493)) ([#512](https://github.com/nanobpm/nano-workforce/issues/512)) ([2837636](https://github.com/nanobpm/nano-workforce/commit/283763689cc2d0780393efe7c51b09d7c2bef767)), closes [468/#469](https://github.com/468/nano-workforce/issues/469) [#2](https://github.com/nanobpm/nano-workforce/issues/2) [#503](https://github.com/nanobpm/nano-workforce/issues/503)
6
+
1
7
  ## [0.136.0](https://github.com/nanobpm/nano-workforce/compare/v0.135.0...v0.136.0) (2026-08-24)
2
8
 
3
9
  ### Features
package/app/delivery.ts CHANGED
@@ -3,13 +3,38 @@
3
3
  // without importing `service.ts` — which imports `pollLineage` back from `lineage.ts` and would
4
4
  // otherwise form a `service.ts` ↔ `lineage.ts` module cycle (fragile in ESM). This is the single
5
5
  // source of truth for both; `service.ts` re-uses it and remains free to import `pollLineage`.
6
+ //
7
+ // ADR-0065 / issue #493. `deriveDelivery`/`deriveEpicBucket`/`epicIsAcknowledgeable` are no longer
8
+ // hand-authored oracles: they are now THIN ADAPTERS over the ONE `plan_read_model` declaration
9
+ // (app/planReadModel.ts) and the `plan_delivery_counts` rollup (app/planRollups.ts). Each routes
10
+ // through the framework's runtime backend — `planDeliveryCounts.reduce` (the TS group-reduce) for the
11
+ // slice-PR counts, and `planReadModel.evaluate` (the TS derivation) for the per-row `delivery` /
12
+ // `list_bucket` / `ack_open` signals — so these façades and the superseding SQLite VIEWs (migrations
13
+ // 082/083) compute byte-identical values by construction, guarded by `assertReadModelParity` /
14
+ // `assertRollupParity` (app/planReadModel.test.ts). Only the pre-formatted `label` display string is
15
+ // still assembled here (D3 — display formatting stays out of the framework AST).
6
16
 
7
- /** A PR is "done" in exactly these states; everything else (converging, waiting_review,
8
- * escalated, and the merge-stage waiting_deps/waiting_merge/waiting_lane/queued) is in flight. `converged`
17
+ import { TERMINAL_STATUSES } from "./deliveryStatuses.ts";
18
+ import {
19
+ DELIVERY_COUNTS_LOOKUP,
20
+ EFFECTIVE_STATUS_COLUMN,
21
+ planReadModel,
22
+ WAVE_PROGRESS_LOOKUP,
23
+ } from "./planReadModel.ts";
24
+ import { PR_TRACKING_RELATION, planDeliveryCounts } from "./planRollups.ts";
25
+
26
+ /** The synthetic correlation key threaded through the adapters: the base row's `plan_key` and each
27
+ * synthesised slice `plan_tasks`/`plan_delivery_counts` row share this value so the compiled rollup
28
+ * lookup / group-reduce correlate exactly as they do on real rows (mirrors app/stage.ts `SELF_KEY`). */
29
+ const SELF_KEY = "self";
30
+
31
+ /** The PR statuses that are TERMINAL for delivery — re-exported from the canonical leaf module
32
+ * (app/deliveryStatuses.ts) that BOTH this façade's consumers and the `plan_delivery_counts` rollup
33
+ * (app/planRollups.ts) read, so the SQL VIEW counts and the TS adapters can never drift. `converged`
9
34
  * is terminal only in review-only mode (AUTO_MERGE off); with auto-merge on, a converged PR
10
- * transitions into the merge stage and lands as `merged`. The status endpoint and the cancel
11
- * guard both key off this set. */
12
- export const TERMINAL_STATUSES: readonly string[] = ["converged", "merged", "abandoned"];
35
+ * transitions into the merge stage and lands as `merged`. The status endpoint and the cancel guard
36
+ * both key off this set. */
37
+ export { TERMINAL_STATUSES };
13
38
 
14
39
  /** The derived epic delivery signal (issue #171). Distinct from `plan.status`: `status = done`
15
40
  * means "the fan-out finished and ≥1 slice opened a PR, dispatched to convergence" (record-results
@@ -41,38 +66,35 @@ export function deriveDelivery(
41
66
  planStatus: string,
42
67
  prStatuses: readonly string[],
43
68
  ): DeliveryRollup {
44
- const prsOpened = prStatuses.length;
45
- let prsMerged = 0;
46
- let prsInFlight = 0;
47
- for (const s of prStatuses) {
48
- if (s === "merged") prsMerged++;
49
- else if (!TERMINAL_STATUSES.includes(s)) prsInFlight++;
50
- }
51
- // `delivery` is only meaningful once the fan-out has been dispatched (`status = done`) and at
52
- // least one slice PR exists; otherwise there is nothing to have landed yet.
53
- if (planStatus !== "done" || prsOpened === 0) {
54
- return { delivery: null, label: null, prsOpened, prsMerged, prsInFlight };
55
- }
56
- if (prsInFlight > 0) {
57
- return {
58
- delivery: "converging",
59
- label: `${prsMerged}/${prsOpened} slices merged, ${prsInFlight} converging`,
60
- prsOpened,
61
- prsMerged,
62
- prsInFlight,
63
- };
64
- }
65
- if (prsMerged === prsOpened) {
66
- return {
67
- delivery: "landed",
68
- label: `${prsOpened}/${prsOpened} slices merged`,
69
- prsOpened,
70
- prsMerged,
71
- prsInFlight,
72
- };
73
- }
74
- // Every slice PR is terminal but not all merged (some abandoned/converged): resolved, not landed.
75
- return { delivery: null, label: null, prsOpened, prsMerged, prsInFlight };
69
+ // Lower the caller's per-slice PR statuses into the two LEAF relations the `plan_delivery_counts`
70
+ // rollup reduces over (`plan_tasks` LEFT JOIN `pull_requests__tracking`): one opened slice per PR
71
+ // status. Callers already resolve the PR's terminal-folded `derived_status` (service.ts reads it via
72
+ // `prsTracking`), so it is fed under the tracking relation's `derived_status` column — the SAME
73
+ // column the managed VIEW joins — and the framework group-reduce folds the SAME three counts
74
+ // (`prs_opened`/`prs_merged`/`prs_in_flight`) the VIEW does; a missing/dangling PR status counts as
75
+ // in-flight, never false-`landed`.
76
+ const taskRows = prStatuses.map((_, i) => ({ plan_key: SELF_KEY, pr_key: `pr${i}`, wave: null, status: "opened" }));
77
+ const prRows = prStatuses.map((s, i) => ({ pr_key: `pr${i}`, derived_status: s }));
78
+ const [counts] = planDeliveryCounts.reduce({ plan_tasks: taskRows, [PR_TRACKING_RELATION]: prRows });
79
+ const prsOpened = Number(counts?.prs_opened ?? 0);
80
+ const prsMerged = Number(counts?.prs_merged ?? 0);
81
+ const prsInFlight = Number(counts?.prs_in_flight ?? 0);
82
+
83
+ // Derive the `delivery` signal from the ONE `plan_read_model` declaration, feeding the folded counts
84
+ // as the `plan_delivery_counts` lookup's single candidate row (the TS twin of the VIEW's LEFT JOIN).
85
+ const raw = planReadModel.evaluate(
86
+ { plan_key: SELF_KEY, status: planStatus, [EFFECTIVE_STATUS_COLUMN]: planStatus, acknowledged_at: null },
87
+ undefined,
88
+ { [DELIVERY_COUNTS_LOOKUP]: counts ? [counts] : [], [WAVE_PROGRESS_LOOKUP]: [] },
89
+ ).delivery;
90
+ const delivery: Delivery | null = raw === "converging" || raw === "landed" ? raw : null;
91
+
92
+ // The pre-formatted human label stays hand-authored here (D3 — display formatting is out of the
93
+ // framework AST); it mirrors the `plan_read_model` VIEW's `delivery_label` display column (083).
94
+ let label: string | null = null;
95
+ if (delivery === "converging") label = `${prsMerged}/${prsOpened} slices merged, ${prsInFlight} converging`;
96
+ else if (delivery === "landed") label = `${prsOpened}/${prsOpened} slices merged`;
97
+ return { delivery, label, prsOpened, prsMerged, prsInFlight };
76
98
  }
77
99
 
78
100
  /** The `plan.status` values that mean the epic's fan-out lifecycle is still LIVE — the planner is
@@ -108,16 +130,8 @@ export function deriveEpicBucket(
108
130
  delivery: string | null | undefined,
109
131
  acknowledgedAt: string | null | undefined,
110
132
  ): "active" | "history" {
111
- if (EPIC_LIVE_STATUSES.some((s) => s === status)) return "active";
112
- if (status === "done") {
113
- // A still-`converging` epic is Active regardless of any (stray) acknowledged_at — it is genuinely
114
- // working and is not acknowledgeable, so it can never be ticked off mid-flight (fail-closed).
115
- if (delivery === "converging") return "active";
116
- // Otherwise `done` — landed or resolved-not-landed/poller-pending (`delivery = null`): stay Active
117
- // until the operator dismisses it, so a just-`done` epic never flickers into History.
118
- return (acknowledgedAt ?? null) === null ? "active" : "history";
119
- }
120
- return "history";
133
+ const raw = evalEpicRow(status, delivery, acknowledgedAt).list_bucket;
134
+ return raw === "active" ? "active" : "history";
121
135
  }
122
136
 
123
137
  /** True iff an epic carries the operator "Dismiss" (acknowledge) affordance — a `done` epic whose
@@ -133,5 +147,43 @@ export function epicIsAcknowledgeable(
133
147
  status: string,
134
148
  delivery: string | null | undefined,
135
149
  ): boolean {
136
- return status === "done" && delivery !== "converging";
150
+ // The `ack_open` derivation folds in the `acknowledged_at IS NULL` gate; evaluate it with a null
151
+ // acknowledgement to isolate the "acknowledgeABLE" predicate (`done` ∧ resolved) from "ack OPEN".
152
+ return evalEpicRow(status, delivery, null).ack_open === 1;
153
+ }
154
+
155
+ /** Evaluate the `plan_read_model` per-row derivations (`list_bucket`/`ack_open`) for an epic whose
156
+ * effective status, already-computed `delivery`, and acknowledgement the caller supplies. The model
157
+ * recomputes `delivery` internally from its `plan_delivery_counts` lookup + base status, so — the twin
158
+ * of app/stage.ts's `openTaskRows` synthesis — we SYNTHESISE the lookup row + base status that make the
159
+ * model's internal `delivery` equal the passed value: `converging`/`landed` need a `done` base status
160
+ * with an in-flight / all-merged count row; a null/other `delivery` needs an empty count (`prs_opened =
161
+ * 0` ⇒ the model's first CASE arm ⇒ null). The status-classifying arms read the effective status under
162
+ * `derived_status`, so the caller's `status` is fed there verbatim. */
163
+ function evalEpicRow(
164
+ status: string,
165
+ delivery: string | null | undefined,
166
+ acknowledgedAt: string | null | undefined,
167
+ ): Record<string, unknown> {
168
+ const merged = delivery === "landed";
169
+ const inFlight = delivery === "converging";
170
+ const opened = merged || inFlight;
171
+ const dcRow = {
172
+ plan_key: SELF_KEY,
173
+ prs_opened: opened ? 1 : 0,
174
+ prs_merged: merged ? 1 : 0,
175
+ prs_in_flight: inFlight ? 1 : 0,
176
+ };
177
+ return planReadModel.evaluate(
178
+ {
179
+ plan_key: SELF_KEY,
180
+ // Force the model's internal `delivery` to the passed value: a `done` base status enables the
181
+ // non-null arms for converging/landed; any status with a zero-opened count folds to null.
182
+ status: opened ? "done" : status,
183
+ [EFFECTIVE_STATUS_COLUMN]: status,
184
+ acknowledged_at: acknowledgedAt ?? null,
185
+ },
186
+ undefined,
187
+ { [DELIVERY_COUNTS_LOOKUP]: [dcRow], [WAVE_PROGRESS_LOOKUP]: [] },
188
+ );
137
189
  }
@@ -0,0 +1,13 @@
1
+ // The PR statuses that are TERMINAL for epic delivery — a slice PR in any of these is resolved (not
2
+ // in flight). This is the ONE canonical set, factored into a dependency-neutral leaf module (imports
3
+ // nothing) so BOTH sides of the single-sourced delivery derivation read the SAME value and can never
4
+ // drift: the runtime adapters + consumers via `app/delivery.ts` (which re-exports it as
5
+ // `TERMINAL_STATUSES`), and the `plan_delivery_counts` / `plan_wave_counts` rollups' in-flight fold via
6
+ // `app/planRollups.ts`. Adding or removing a terminal state here changes both the SQL VIEW counts and
7
+ // the TS reduce at once.
8
+ //
9
+ // `converged` is terminal only in review-only mode (AUTO_MERGE off); with auto-merge on, a converged PR
10
+ // transitions into the merge stage and lands as `merged`. `merged` is the landed state; `abandoned` is
11
+ // the resolved-not-landed state. Everything else (converging, waiting_review, escalated, and the
12
+ // merge-stage waiting_deps/waiting_merge/waiting_lane/queued) is in flight.
13
+ export const TERMINAL_STATUSES: readonly string[] = ["converged", "merged", "abandoned"];