@nanobpm/nano-workforce 0.79.0 → 0.80.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,10 @@
1
+ # [0.80.0](https://github.com/nanobpm/nano-workforce/compare/v0.79.0...v0.80.0) (2026-08-17)
2
+
3
+
4
+ ### Features
5
+
6
+ * thread request→PR lineage and project intent→progress ([#245](https://github.com/nanobpm/nano-workforce/issues/245)) ([#253](https://github.com/nanobpm/nano-workforce/issues/253)) ([b603276](https://github.com/nanobpm/nano-workforce/commit/b603276ce65500b351293497e9af7a3e899d73e5))
7
+
1
8
  # [0.79.0](https://github.com/nanobpm/nano-workforce/compare/v0.78.0...v0.79.0) (2026-08-17)
2
9
 
3
10
 
@@ -6,7 +6,8 @@
6
6
  import { test } from "node:test";
7
7
  import { assert, assertEquals } from "#test-assert";
8
8
  import type { DataLayer } from "@nanobpm/urban";
9
- import { deriveDelivery, pollDelivery, TERMINAL_STATUSES } from "./service.ts";
9
+ import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
10
+ import { pollDelivery } from "./service.ts";
10
11
 
11
12
  // A tiny in-memory record gateway (all/find/update/insert), mirroring the fake-app style used
12
13
  // across the app tests (see app/taskDelta.test.ts), enough to exercise the `pollDelivery` projection.
@@ -0,0 +1,76 @@
1
+ // Canonical PR-terminal / epic-delivery derivation, extracted from `service.ts` so that the
2
+ // lineage read-projection (`app/lineage.ts`) can reuse `deriveDelivery`/`TERMINAL_STATUSES`
3
+ // without importing `service.ts` — which imports `pollLineage` back from `lineage.ts` and would
4
+ // otherwise form a `service.ts` ↔ `lineage.ts` module cycle (fragile in ESM). This is the single
5
+ // source of truth for both; `service.ts` re-uses it and remains free to import `pollLineage`.
6
+
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`
9
+ * 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"];
13
+
14
+ /** The derived epic delivery signal (issue #171). Distinct from `plan.status`: `status = done`
15
+ * means "the fan-out finished and ≥1 slice opened a PR, dispatched to convergence" (record-results
16
+ * sets it as soon as one PR opened — other slices may be blocked/skipped), which conflates hand-off
17
+ * with landing. `delivery` reports whether those slice PRs have actually MERGED. */
18
+ export type Delivery = "converging" | "landed";
19
+
20
+ /** Rollup of a plan's slice-PR landing state, derived by joining `plan_tasks.pr_key` →
21
+ * `pull_requests.status`. Pure and read-only — the single source of truth for the denormalised
22
+ * `plans.delivery` / `plans.delivery_label` columns the poller projects. */
23
+ export interface DeliveryRollup {
24
+ delivery: Delivery | null;
25
+ label: string | null;
26
+ prsOpened: number;
27
+ prsMerged: number;
28
+ prsInFlight: number;
29
+ }
30
+
31
+ /** Derive the delivery signal for one plan from its status and the statuses of its slice PRs.
32
+ *
33
+ * - `converging` — the plan is `done` but ≥1 slice PR is still non-terminal (in flight).
34
+ * - `landed` — every slice PR merged: `prsInFlight == 0 && prsMerged == prsOpened && prsOpened > 0`.
35
+ * - `null` — no positive signal yet: the plan isn't `done`, it opened no PRs, or every PR is
36
+ * terminal but not all merged (some `abandoned`/`converged` — resolved-not-landed, per the issue).
37
+ *
38
+ * A slice's PR status is "in flight" iff it is NOT in `TERMINAL_STATUSES`; `abandoned`/`converged`
39
+ * count as resolved-not-landed (terminal but not merged), so they never make an epic `landed`. */
40
+ export function deriveDelivery(
41
+ planStatus: string,
42
+ prStatuses: readonly string[],
43
+ ): 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 };
76
+ }
@@ -7,7 +7,8 @@
7
7
  import { test } from "node:test";
8
8
  import { assert, assertEquals } from "#test-assert";
9
9
  import { readFileSync } from "node:fs";
10
- import { PR_ACTIVE_STATUSES, PLAN_ACTIVE_STATUSES, FEATURE_ACTIVE_STATUSES, TERMINAL_STATUSES } from "./service.ts";
10
+ import { PR_ACTIVE_STATUSES, PLAN_ACTIVE_STATUSES, FEATURE_ACTIVE_STATUSES } from "./service.ts";
11
+ import { TERMINAL_STATUSES } from "./delivery.ts";
11
12
  import { PLAN_TERMINAL_STATUSES } from "./plan.ts";
12
13
  import { FEATURE_TERMINAL_STATUSES } from "./feature.ts";
13
14
 
@@ -0,0 +1,300 @@
1
+ // Read-model derivation test for the lineage projection (issue #245). `deriveLineage` is the single
2
+ // source of truth for the denormalised `lineage_threads` rows the poller projects: it must stitch a
3
+ // request → implementation → PR(s) → convergence → merge → outcome arc into one thread, expose the
4
+ // active frontier plus whether the arc has settled, roll epic fan-out up across N slice PRs, and
5
+ // tolerate a human/webhook PR with no originating request (self-rooted, kind `pr`).
6
+ import { test } from "node:test";
7
+ import { assert, assertEquals } from "#test-assert";
8
+ import type { DataLayer } from "@nanobpm/urban";
9
+ import {
10
+ deriveLineage,
11
+ type LineagePr,
12
+ type LineageThreadRow,
13
+ listLineage,
14
+ pollLineage,
15
+ } from "./lineage.ts";
16
+
17
+ function pr(overrides: Partial<LineagePr> & { prKey: string; status: string }): LineagePr {
18
+ return {
19
+ title: overrides.title ?? `title ${overrides.prKey}`,
20
+ url: overrides.url ?? `https://github.com/${overrides.prKey.replace("#", "/pull/")}`,
21
+ round: overrides.round ?? 1,
22
+ processKey: overrides.processKey ?? "p1",
23
+ outcome: overrides.outcome ?? null,
24
+ ...overrides,
25
+ };
26
+ }
27
+
28
+ // ── feature arc ──────────────────────────────────────────────────────────────────────────────
29
+
30
+ test("feature: implementing before any PR is handed off", () => {
31
+ const t = deriveLineage(
32
+ { kind: "feature", key: "o/r#1", title: "Add X", issueUrl: "u", status: "running", processKey: "f1" },
33
+ [],
34
+ );
35
+ assertEquals(t.kind, "feature");
36
+ assertEquals(t.stage, "implementing");
37
+ assertEquals(t.stageLabel, "Implementing");
38
+ assert(t.active, "an implementing run is active");
39
+ assertEquals(t.processKey, "f1");
40
+ assertEquals(t.prCount, 0);
41
+ });
42
+
43
+ test("feature: converging hands the narrative to the PR frontier", () => {
44
+ const t = deriveLineage(
45
+ { kind: "feature", key: "o/r#1", title: "Add X", issueUrl: "u", status: "converging", processKey: "f1" },
46
+ [pr({ prKey: "o/r#2", status: "converging", round: 3, processKey: "c9" })],
47
+ );
48
+ assertEquals(t.stage, "converging");
49
+ assertEquals(t.stageLabel, "Converging (round 3)");
50
+ assertEquals(t.processKey, "c9", "frontier prefers the active PR's instance");
51
+ assert(t.active);
52
+ assertEquals(t.prKeys, ["o/r#2"]);
53
+ });
54
+
55
+ test("feature: a merged PR settles the arc (history)", () => {
56
+ const t = deriveLineage(
57
+ { kind: "feature", key: "o/r#1", title: "Add X", issueUrl: "u", status: "merged", processKey: "f1" },
58
+ [pr({ prKey: "o/r#2", status: "merged" })],
59
+ );
60
+ assertEquals(t.stage, "merged");
61
+ assertEquals(t.stageLabel, "Merged");
62
+ assert(!t.active, "a merged arc has no active frontier");
63
+ });
64
+
65
+ test("feature: an escalated run surfaces the escalation stage", () => {
66
+ const t = deriveLineage(
67
+ { kind: "feature", key: "o/r#1", title: "X", issueUrl: "u", status: "escalated", processKey: "f1" },
68
+ [],
69
+ );
70
+ assertEquals(t.stage, "escalated");
71
+ assert(t.active);
72
+ });
73
+
74
+ // ── epic fan-out ─────────────────────────────────────────────────────────────────────────────
75
+
76
+ test("epic: rolls up N slice PRs and stays active while any is in flight", () => {
77
+ const t = deriveLineage(
78
+ { kind: "epic", key: "o/r#9", title: "Big epic", issueUrl: "u", status: "done", processKey: "e1" },
79
+ [
80
+ pr({ prKey: "o/r#10", status: "merged" }),
81
+ pr({ prKey: "o/r#11", status: "converging", processKey: "c11" }),
82
+ pr({ prKey: "o/r#12", status: "abandoned" }),
83
+ ],
84
+ );
85
+ assertEquals(t.kind, "epic");
86
+ assertEquals(t.stage, "converging");
87
+ assertEquals(t.stageLabel, "1/3 slices merged, 1 converging");
88
+ assertEquals(t.processKey, "c11");
89
+ assertEquals(t.prCount, 3);
90
+ assert(t.active);
91
+ });
92
+
93
+ test("epic: all slices merged settles as merged", () => {
94
+ const t = deriveLineage(
95
+ { kind: "epic", key: "o/r#9", title: "Big epic", issueUrl: "u", status: "done", processKey: "e1" },
96
+ [pr({ prKey: "o/r#10", status: "merged" }), pr({ prKey: "o/r#11", status: "merged" })],
97
+ );
98
+ assertEquals(t.stage, "merged");
99
+ assertEquals(t.stageLabel, "2/2 slices merged");
100
+ assert(!t.active);
101
+ });
102
+
103
+ test("epic: mixed terminal (none in flight, not all merged) is resolved, not landed", () => {
104
+ const t = deriveLineage(
105
+ { kind: "epic", key: "o/r#9", title: "E", issueUrl: "u", status: "done", processKey: "e1" },
106
+ [pr({ prKey: "o/r#10", status: "merged" }), pr({ prKey: "o/r#11", status: "abandoned" })],
107
+ );
108
+ assertEquals(t.stage, "resolved");
109
+ assert(!t.active);
110
+ });
111
+
112
+ test("epic: planning with no PRs yet", () => {
113
+ const t = deriveLineage(
114
+ { kind: "epic", key: "o/r#9", title: "E", issueUrl: "u", status: "planning", processKey: "e1" },
115
+ [],
116
+ );
117
+ assertEquals(t.stage, "planning");
118
+ assert(t.active);
119
+ });
120
+
121
+ // ── self-rooted (human/webhook) PR ───────────────────────────────────────────────────────────
122
+
123
+ test("pr: a human/webhook PR with no origin is its own root", () => {
124
+ const t = deriveLineage({ kind: "pr", key: "o/r#5" }, [
125
+ pr({ prKey: "o/r#5", status: "waiting_review", round: 2, title: "Fix bug" }),
126
+ ]);
127
+ assertEquals(t.kind, "pr");
128
+ assertEquals(t.rootRequestKey, "o/r#5");
129
+ assertEquals(t.stage, "reviewing");
130
+ assertEquals(t.stageLabel, "Awaiting review (round 2)");
131
+ assertEquals(t.title, "Fix bug", "a self-rooted PR takes its title from the PR");
132
+ assertEquals(t.issueUrl, null);
133
+ assert(t.active);
134
+ });
135
+
136
+ // ── poller projection ────────────────────────────────────────────────────────────────────────
137
+
138
+ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
139
+ const stores: Record<string, any[]> = {};
140
+ function tbl(name: string, pk = "id") {
141
+ const rows = (stores[name] ??= [] as any[]);
142
+ const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
143
+ return {
144
+ async all() {
145
+ return rows.slice();
146
+ },
147
+ async get(id: any) {
148
+ return rows.find((r) => r[pk] === id);
149
+ },
150
+ async find(where: any = {}) {
151
+ return rows.filter((r) => match(r, where));
152
+ },
153
+ async insert(row: any) {
154
+ rows.push({ ...row });
155
+ return row[pk];
156
+ },
157
+ async update(id: any, patch: any) {
158
+ const r = rows.find((row) => row[pk] === id);
159
+ if (r) Object.assign(r, patch);
160
+ },
161
+ async delete(id: any) {
162
+ const i = rows.findIndex((r) => r[pk] === id);
163
+ if (i >= 0) rows.splice(i, 1);
164
+ },
165
+ };
166
+ }
167
+ const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
168
+ return { data, stores };
169
+ }
170
+
171
+ test("pollLineage: projects feature, epic, and self-rooted threads onto lineage_threads", async () => {
172
+ const { data, stores } = memData();
173
+ stores.feature_runs = [
174
+ { feature_key: "o/r#1", title: "Feature", issue_url: "u1", status: "converging", process_key: "f1", pr_key: "o/r#100" },
175
+ ];
176
+ stores.plans = [
177
+ { plan_key: "o/r#2", title: "Epic", issue_url: "u2", status: "done", process_key: "e1" },
178
+ ];
179
+ stores.plan_tasks = [
180
+ { id: 1, plan_key: "o/r#2", pr_key: "o/r#200" },
181
+ { id: 2, plan_key: "o/r#2", pr_key: "o/r#201" },
182
+ ];
183
+ stores.pull_requests = [
184
+ { pr_key: "o/r#100", title: "Feat PR", url: "x", status: "converging", current_round: 2, process_key: "c1", outcome: null, root_request_key: "o/r#1" },
185
+ { pr_key: "o/r#200", title: "S1", url: "x", status: "merged", current_round: 1, process_key: "c2", outcome: null, root_request_key: "o/r#2" },
186
+ { pr_key: "o/r#201", title: "S2", url: "x", status: "converging", current_round: 1, process_key: "c3", outcome: null, root_request_key: "o/r#2" },
187
+ // Human/webhook PR — no root_request_key.
188
+ { pr_key: "o/r#300", title: "Human PR", url: "x", status: "merged", current_round: 1, process_key: "c4", outcome: null, root_request_key: null },
189
+ ];
190
+
191
+ await pollLineage(data);
192
+
193
+ const threads: LineageThreadRow[] = stores.lineage_threads;
194
+ assertEquals(threads.length, 3, "one feature, one epic, one self-rooted PR");
195
+
196
+ const feat = threads.find((t) => t.root_request_key === "o/r#1");
197
+ assert(feat, "feature thread present");
198
+ assertEquals(feat?.kind, "feature");
199
+ assertEquals(feat?.stage, "converging");
200
+ assertEquals(feat?.stage_label, "Converging (round 2)");
201
+ assertEquals(feat?.active, 1);
202
+ assertEquals(JSON.parse(feat?.pr_keys ?? "[]"), ["o/r#100"]);
203
+
204
+ const epic = threads.find((t) => t.root_request_key === "o/r#2");
205
+ assertEquals(epic?.kind, "epic");
206
+ assertEquals(epic?.stage, "converging");
207
+ assertEquals(epic?.pr_count, 2);
208
+ assertEquals(epic?.active, 1);
209
+
210
+ const human = threads.find((t) => t.root_request_key === "o/r#300");
211
+ assertEquals(human?.kind, "pr");
212
+ assertEquals(human?.stage, "merged");
213
+ assertEquals(human?.active, 0);
214
+
215
+ // Idempotent: a second pass with no state change writes nothing new (same row count, same ts).
216
+ const before = stores.lineage_threads.map((r: LineageThreadRow) => r.updated_at);
217
+ await pollLineage(data);
218
+ const after = stores.lineage_threads.map((r: LineageThreadRow) => r.updated_at);
219
+ assertEquals(after, before, "steady-state pass is a no-op");
220
+ });
221
+
222
+ test("pollLineage: a self-rooted PR row (root_request_key === pr_key) projects exactly one thread keyed on its pr_key", async () => {
223
+ // Regression (#245): submitPr now self-roots a human/webhook PR on its own `pr_key` (rather than
224
+ // NULL) so the Lineage page's `lineage_threads.root_request_key → pull_requests.root_request_key`
225
+ // drill-down join is non-empty. Guard that this row shape still projects a single self-rooted
226
+ // thread keyed on the `pr_key` — not double-counted, and not grouped under a phantom origin.
227
+ const { data, stores } = memData();
228
+ stores.feature_runs = [];
229
+ stores.plans = [];
230
+ stores.plan_tasks = [];
231
+ stores.pull_requests = [
232
+ { pr_key: "o/r#42", title: "Human PR", url: "x", status: "converging", current_round: 1, process_key: "c9", outcome: null, root_request_key: "o/r#42" },
233
+ ];
234
+
235
+ await pollLineage(data);
236
+
237
+ const threads: LineageThreadRow[] = stores.lineage_threads;
238
+ assertEquals(threads.length, 1, "one self-rooted thread");
239
+ assertEquals(threads[0].root_request_key, "o/r#42", "thread key equals the PR row's root_request_key so the page join drills down");
240
+ assertEquals(threads[0].kind, "pr");
241
+ assertEquals(JSON.parse(threads[0].pr_keys ?? "[]"), ["o/r#42"]);
242
+ assertEquals(threads[0].pr_count, 1);
243
+ });
244
+
245
+ test("pollLineage: an orphaned non-null root (origin row gone) keys the thread on the stored root, not pr_key", async () => {
246
+ // Regression (#245 review): a PR whose `root_request_key` points at a feature/epic origin row that
247
+ // no longer survives is unclaimed by any feature/epic thread. Keying its self-rooted thread on
248
+ // `pr_key` would leave `lineage_threads.root_request_key` (= pr_key) ≠ `pull_requests.root_request_key`
249
+ // (= the orphaned root), so the page's drill-down join renders an empty PR list. The thread MUST be
250
+ // keyed on the stored `root_request_key`. Two PRs sharing one orphaned root belong to one thread.
251
+ const { data, stores } = memData();
252
+ stores.feature_runs = [];
253
+ stores.plans = [];
254
+ stores.plan_tasks = [];
255
+ stores.pull_requests = [
256
+ { pr_key: "o/r#71", title: "Orphan A", url: "x", status: "converging", current_round: 1, process_key: "c1", outcome: null, root_request_key: "o/r#7" },
257
+ { pr_key: "o/r#72", title: "Orphan B", url: "x", status: "merged", current_round: 1, process_key: "c2", outcome: null, root_request_key: "o/r#7" },
258
+ ];
259
+
260
+ await pollLineage(data);
261
+
262
+ const threads: LineageThreadRow[] = stores.lineage_threads;
263
+ assertEquals(threads.length, 1, "both orphaned PRs group into one thread under their shared root");
264
+ assertEquals(
265
+ threads[0].root_request_key,
266
+ "o/r#7",
267
+ "thread key equals the stored root_request_key so the page join drills down, not the pr_key",
268
+ );
269
+ assertEquals(threads[0].kind, "pr");
270
+ assertEquals(JSON.parse(threads[0].pr_keys ?? "[]").sort(), ["o/r#71", "o/r#72"]);
271
+ assertEquals(threads[0].pr_count, 2);
272
+ });
273
+
274
+ test("listLineage: unknown root returns nothing; known roots stitched", async () => {
275
+ const { data } = memData();
276
+ const threads = await listLineage(data);
277
+ assertEquals(threads.length, 0);
278
+ });
279
+
280
+ test("listLineage: deterministic order — active frontier first, then by rootRequestKey", async () => {
281
+ // Guard the tie-break (#245 review): equal-`active` threads have no per-thread timestamp to sort
282
+ // on, so ordering must fall back to `rootRequestKey` for a stable, deterministic response instead
283
+ // of jittering across passes. Insert settled roots out of order plus one active root.
284
+ const { data, stores } = memData();
285
+ stores.feature_runs = [];
286
+ stores.plans = [];
287
+ stores.plan_tasks = [];
288
+ stores.pull_requests = [
289
+ { pr_key: "o/r#3", title: "c", url: "x", status: "merged", current_round: 1, process_key: null, outcome: null, root_request_key: "o/r#3" },
290
+ { pr_key: "o/r#1", title: "a", url: "x", status: "merged", current_round: 1, process_key: null, outcome: null, root_request_key: "o/r#1" },
291
+ { pr_key: "o/r#2", title: "b", url: "x", status: "converging", current_round: 1, process_key: "c9", outcome: null, root_request_key: "o/r#2" },
292
+ ];
293
+
294
+ const threads = await listLineage(data);
295
+ assertEquals(
296
+ threads.map((t) => t.rootRequestKey),
297
+ ["o/r#2", "o/r#1", "o/r#3"],
298
+ "active first, then inactive sorted by rootRequestKey",
299
+ );
300
+ });