@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 +6 -0
- package/app/delivery.ts +100 -48
- package/app/deliveryStatuses.ts +13 -0
- package/app/planReadModel.test.ts +470 -0
- package/app/planReadModel.ts +159 -0
- package/app/planRollups.ts +127 -0
- package/db/migrations/082_plan_rollups_declare_once.sql +74 -0
- package/db/migrations/083_plan_read_model_declare_once.sql +84 -0
- package/package.json +1 -1
- package/app/delivery.test.ts +0 -76
- package/app/planWaveSummary.test.ts +0 -170
- package/app/plansReadModel.test.ts +0 -406
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
|
-
|
|
8
|
-
|
|
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
|
-
*
|
|
12
|
-
export
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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"];
|