@nanobpm/nano-workforce 0.131.1 → 0.132.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.132.0](https://github.com/nanobpm/nano-workforce/compare/v0.131.1...v0.132.0) (2026-08-24)
2
+
3
+ ### Features
4
+
5
+ * **lineage:** surface delivery-graph runs as fan-in parent threads ([#504](https://github.com/nanobpm/nano-workforce/issues/504)) ([c62b925](https://github.com/nanobpm/nano-workforce/commit/c62b92556dd8ec21f42bc70ca4eba5455e448343)), closes [#498](https://github.com/nanobpm/nano-workforce/issues/498) [#498](https://github.com/nanobpm/nano-workforce/issues/498)
6
+
1
7
  ## [0.131.1](https://github.com/nanobpm/nano-workforce/compare/v0.131.0...v0.131.1) (2026-08-24)
2
8
 
3
9
  ### Bug Fixes
@@ -131,6 +131,91 @@ test("feature/self-rooted threads carry no epic phase label", () => {
131
131
  assertEquals(self.epicPhaseLabel, null, "a self-rooted PR is not an epic slice");
132
132
  });
133
133
 
134
+ // ── delivery-graph fan-in parent (issue #498) ─────────────────────────────────────────────────
135
+
136
+ test("delivery: a running run with no PR yet is implementing, frontier from its phase", () => {
137
+ const t = deriveLineage(
138
+ { kind: "delivery", key: "dg-abc", title: "Ship widget", status: "running", phase: "Running", processKey: "d1" },
139
+ [],
140
+ );
141
+ assertEquals(t.kind, "delivery");
142
+ assertEquals(t.rootRequestKey, "dg-abc");
143
+ assertEquals(t.stage, "implementing");
144
+ assertEquals(t.stageLabel, "Running", "the frontier reflects the run's derived phase");
145
+ assert(t.active, "a running run is active");
146
+ assertEquals(t.processKey, "d1");
147
+ assertEquals(t.title, "Ship widget");
148
+ assertEquals(t.issueUrl, null, "a delivery run is keyed by run_key, not a GitHub issue");
149
+ assertEquals(t.epicPhaseLabel, null, "a delivery run's member PRs are not epic slices");
150
+ assertEquals(t.prCount, 0);
151
+ });
152
+
153
+ test("delivery: downstream PR convergences nest under the run and temper the frontier to converging", () => {
154
+ const t = deriveLineage(
155
+ { kind: "delivery", key: "dg-abc", title: "Ship widget", status: "running", phase: "Parked on human node: publish", processKey: "d1" },
156
+ [
157
+ pr({ prKey: "a/b#1", status: "merged" }),
158
+ pr({ prKey: "c/d#9", status: "converging", processKey: "c9" }),
159
+ ],
160
+ );
161
+ assertEquals(t.kind, "delivery");
162
+ assertEquals(t.stage, "converging", "a member PR still in flight tempers the frontier to converging");
163
+ assertEquals(t.stageLabel, "Parked on human node: publish", "the label still prefers the run's stamped phase");
164
+ assertEquals(t.processKey, "c9", "frontier prefers the in-flight member PR's instance");
165
+ assertEquals(t.prKeys, ["a/b#1", "c/d#9"], "heterogeneous downstream PRs across repos nest under the run");
166
+ assert(t.active);
167
+ });
168
+
169
+ test("delivery: a done run settles as resolved (no active frontier)", () => {
170
+ const t = deriveLineage(
171
+ { kind: "delivery", key: "dg-abc", title: "Ship widget", status: "done", phase: "Completed", processKey: "d1" },
172
+ [pr({ prKey: "a/b#1", status: "merged" })],
173
+ );
174
+ assertEquals(t.stage, "resolved");
175
+ assertEquals(t.stageLabel, "Completed");
176
+ assert(!t.active, "a completed run has no active frontier");
177
+ });
178
+
179
+ test("delivery: a failed run settles as abandoned", () => {
180
+ const t = deriveLineage(
181
+ { kind: "delivery", key: "dg-abc", title: "Ship widget", status: "failed", phase: "Failed", processKey: "d1" },
182
+ [],
183
+ );
184
+ assertEquals(t.stage, "abandoned");
185
+ assert(!t.active);
186
+ });
187
+
188
+ test("delivery: an abandoned run with no phase is labeled 'Abandoned', not 'Failed'", () => {
189
+ // `deliveryOriginStage` folds both `failed` and `abandoned` statuses onto the `abandoned` stage, so
190
+ // the label must consult the run status: a genuinely abandoned run reads "Abandoned" (only a failed
191
+ // one reads "Failed"). Regress with no stamped phase so the status-derived fallback label is used.
192
+ const t = deriveLineage(
193
+ { kind: "delivery", key: "dg-abc", title: "Ship widget", status: "abandoned", phase: null, processKey: "d1" },
194
+ [],
195
+ );
196
+ assertEquals(t.stage, "abandoned");
197
+ assertEquals(t.stageLabel, "Abandoned", "an abandoned run is not mislabeled as failed");
198
+ assert(!t.active);
199
+ });
200
+
201
+ test("delivery: a failed run with no phase is labeled 'Failed'", () => {
202
+ const t = deriveLineage(
203
+ { kind: "delivery", key: "dg-abc", title: "Ship widget", status: "failed", phase: null, processKey: "d1" },
204
+ [],
205
+ );
206
+ assertEquals(t.stage, "abandoned");
207
+ assertEquals(t.stageLabel, "Failed");
208
+ });
209
+
210
+ test("delivery: with no stamped phase, the frontier falls back to a status-derived label", () => {
211
+ const t = deriveLineage(
212
+ { kind: "delivery", key: "dg-abc", title: "Ship widget", status: "running", phase: null, processKey: "d1" },
213
+ [],
214
+ );
215
+ assertEquals(t.stage, "implementing");
216
+ assertEquals(t.stageLabel, "Running", "null phase falls back to the status-derived frontier label");
217
+ });
218
+
134
219
  // ── self-rooted (human/webhook) PR ───────────────────────────────────────────────────────────
135
220
 
136
221
  test("pr: a human/webhook PR with no origin is its own root", () => {
@@ -237,6 +322,67 @@ test("pollLineage: projects feature, epic, and self-rooted threads onto lineage_
237
322
  assertEquals(after, before, "steady-state pass is a no-op");
238
323
  });
239
324
 
325
+ test("pollLineage: projects a delivery-graph run as a fan-in parent thread with its downstream PRs nested", async () => {
326
+ // Issue #498: a dispatched delivery-graph run appears as its own thread, and the downstream PR
327
+ // convergences threaded to it (root_request_key = run_key) across DIFFERENT repos nest under it
328
+ // rather than appearing as disconnected self-rooted PRs.
329
+ const { data, stores } = memData();
330
+ stores.feature_runs = [];
331
+ stores.plans = [];
332
+ stores.plan_tasks = [];
333
+ stores.delivery_graph_runs = [
334
+ { run_key: "dg-xyz", title: "Ship widget across repos", status: "running", phase: "Running", process_key: "P-dg" },
335
+ ];
336
+ stores.pull_requests = [
337
+ { pr_key: "a/b#1", title: "widget in a/b", url: "x", status: "merged", current_round: 1, process_key: "c1", outcome: null, root_request_key: "dg-xyz" },
338
+ { pr_key: "c/d#9", title: "widget in c/d", url: "x", status: "converging", current_round: 2, process_key: "c2", outcome: null, root_request_key: "dg-xyz" },
339
+ ];
340
+
341
+ await pollLineage(data);
342
+
343
+ const threads: LineageThreadRow[] = stores.lineage_threads;
344
+ assertEquals(threads.length, 1, "one delivery thread, not two disconnected self-rooted PRs");
345
+ const dg = threads.find((t) => t.root_request_key === "dg-xyz");
346
+ assert(dg, "delivery thread present, keyed on run_key");
347
+ // A member PR still converging tempers the frontier to converging; the label prefers the run phase.
348
+ assertEquals(dg?.stage, "converging");
349
+ assertEquals(dg?.stage_label, "Running");
350
+ assertEquals(dg?.active, 1);
351
+ assertEquals(dg?.pr_count, 2);
352
+ assertEquals(JSON.parse(dg?.pr_keys ?? "[]").sort(), ["a/b#1", "c/d#9"]);
353
+ // A delivery run's member PRs are not epic slices, so no epic phase label is projected onto them.
354
+ const prById = (k: string) => stores.pull_requests.find((r: any) => r.pr_key === k);
355
+ assertEquals(prById("a/b#1").epic_phase_label ?? null, null);
356
+ assertEquals(prById("c/d#9").epic_phase_label ?? null, null);
357
+ });
358
+
359
+ test("pollLineage: a delivery run_key colliding with a feature key does not overwrite the feature thread", async () => {
360
+ // The SQL view (migration 079) classifies epic > feature > delivery, so a `delivery_graph_runs.run_key`
361
+ // that equals an existing `feature_key`/`plan_key` must NOT clobber that earlier thread — otherwise the
362
+ // poller would stamp delivery-derived frontier columns onto a row the view still classifies feature/epic,
363
+ // and the two projections drift. The colliding run is skipped; feature precedence is preserved.
364
+ const { data, stores } = memData();
365
+ stores.feature_runs = [
366
+ { feature_key: "o/r#7", title: "Feature seven", issue_url: "u7", status: "converging", process_key: "f7", pr_key: "o/r#700" },
367
+ ];
368
+ stores.plans = [];
369
+ stores.plan_tasks = [];
370
+ stores.delivery_graph_runs = [
371
+ { run_key: "o/r#7", title: "Colliding run", status: "running", phase: "Running", process_key: "P-dup" },
372
+ ];
373
+ stores.pull_requests = [
374
+ { pr_key: "o/r#700", title: "Feat PR", url: "x", status: "converging", current_round: 2, process_key: "c1", outcome: null, root_request_key: "o/r#7" },
375
+ ];
376
+
377
+ await pollLineage(data);
378
+
379
+ const threads: LineageThreadRow[] = stores.lineage_threads;
380
+ assertEquals(threads.length, 1, "the colliding run does not create a second row for the same key");
381
+ const t = threads.find((r) => r.root_request_key === "o/r#7");
382
+ assert(t, "the single thread for the shared key is the feature thread");
383
+ assertEquals(t?.stage_label, "Converging (round 2)", "feature frontier wins; the delivery run did not overwrite it");
384
+ });
385
+
240
386
  test("pollLineage: a self-rooted PR row (root_request_key === pr_key) projects exactly one thread keyed on its pr_key", async () => {
241
387
  // Regression (#245): submitPr now self-roots a human/webhook PR on its own `pr_key` (rather than
242
388
  // NULL) so the Lineage page's `lineage_threads.root_request_key → pull_requests.root_request_key`
package/app/lineage.ts CHANGED
@@ -20,14 +20,15 @@
20
20
  // `pr_key` (kind `pr`), and also tolerates a legacy NULL `root_request_key` the same way.
21
21
  import type { DataLayer } from "@nanobpm/urban";
22
22
  import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
23
+ import { type DeliveryGraphRun, deliveryGraphRuns } from "./deliveryGraphRun.ts";
23
24
  import { type FeatureRun, featureRuns } from "./feature.ts";
24
25
  import { derivedTrackingTable } from "./instanceTracking.ts";
25
26
  import { type Plan, type PlanTask, plans, planTasks } from "./plan.ts";
26
27
 
27
28
  const now = () => new Date().toISOString();
28
29
 
29
- /** The three origin shapes a lineage arc can spring from. */
30
- export type LineageKind = "feature" | "epic" | "pr";
30
+ /** The origin shapes a lineage arc can spring from. */
31
+ export type LineageKind = "feature" | "epic" | "pr" | "delivery";
31
32
 
32
33
  /** A member PR of a lineage thread — the subset of `pull_requests` the projection reads. */
33
34
  export interface LineagePr {
@@ -66,6 +67,23 @@ export type LineageOrigin =
66
67
  // A human/webhook PR with no originating request: its own root.
67
68
  kind: "pr";
68
69
  key: string;
70
+ }
71
+ | {
72
+ // A delivery-graph run (issue #498): a FAN-IN parent thread. The run is the thread root, and
73
+ // the heterogeneous downstream tasks it spawns — PR convergences across different repos/issue
74
+ // numbers, package publishes, human gates — nest under it (they thread `root_request_key =
75
+ // run_key`, mirroring how `submitPr` threads feature/epic roots). Closer to an epic than a
76
+ // single-PR arc.
77
+ kind: "delivery";
78
+ key: string;
79
+ title: string | null;
80
+ status: string;
81
+ // The run's already-derived display phase (`delivery_graph_runs.phase`, recomputed by
82
+ // `pollDeliveryGraphPhase` from engine truth — generalised from `epic_phase`), e.g. "Running",
83
+ // "Parked on human node: manual OTP publish", "Completed". NULL until the poller stamps one; the
84
+ // thread then falls back to a status-derived frontier label.
85
+ phase: string | null;
86
+ processKey: string | null;
69
87
  };
70
88
 
71
89
  /** One stitched arc: `request → implementation → PR(s) → convergence → merge → outcome`. */
@@ -241,6 +259,18 @@ export function deriveLineage(origin: LineageOrigin, prsIn: readonly LineagePr[]
241
259
  stageLabel = featureStageLabel(stage);
242
260
  processKey = origin.processKey ?? rep?.processKey ?? null;
243
261
  }
262
+ } else if (origin.kind === "delivery") {
263
+ // Fan-in parent (issue #498): the delivery-graph RUN is the thread root; its heterogeneous
264
+ // downstream PR convergences (across different repos/issues) nest under it. Unlike an epic's
265
+ // slice rollup, a run's narrative is "where is the run" — so the frontier reflects the run's OWN
266
+ // derived phase (`delivery_graph_runs.phase`), with member PRs shown as nested children. The
267
+ // machine `stage` is derived from the run status (tempered to `converging` while a member PR is
268
+ // still in flight); the human `stageLabel` prefers the run's stamped phase, else a status label.
269
+ stage = deliveryOriginStage(origin.status, prs);
270
+ stageLabel = origin.phase ?? deliveryStageLabel(stage, origin.status);
271
+ // Active-frontier instance: an in-flight member PR's process, else the run's own.
272
+ const activePr = prs.find((p) => !TERMINAL_STATUSES.includes(p.status));
273
+ processKey = activePr?.processKey ?? origin.processKey ?? rep?.processKey ?? null;
244
274
  } else {
245
275
  // Self-rooted PR (human/webhook): the PR IS the whole arc.
246
276
  stage = rep ? prStage(rep.status) : "converging";
@@ -259,7 +289,9 @@ export function deriveLineage(origin: LineageOrigin, prsIn: readonly LineagePr[]
259
289
  rootRequestKey: origin.key,
260
290
  kind: origin.kind,
261
291
  title: origin.kind === "pr" ? (rep?.title ?? null) : origin.title,
262
- issueUrl: origin.kind === "pr" ? null : origin.issueUrl,
292
+ // Only feature/epic threads root on a GitHub issue; a self-rooted PR and a delivery-graph run
293
+ // (issue #498, keyed by `run_key`) have none.
294
+ issueUrl: origin.kind === "feature" || origin.kind === "epic" ? origin.issueUrl : null,
263
295
  stage,
264
296
  stageLabel,
265
297
  epicPhaseLabel,
@@ -293,6 +325,45 @@ function featureStageLabel(stage: LineageStage): string {
293
325
  }
294
326
  }
295
327
 
328
+ /** Map a delivery-graph run's lifecycle status onto a frontier stage (issue #498). A `running` run
329
+ * with a member PR still in flight reads as `converging` (the fan-in is landing PRs); otherwise it is
330
+ * `implementing`. Terminal run statuses settle: `done` → `resolved` (the run completed), `failed` /
331
+ * `abandoned` → `abandoned`. `awaiting-approval` (reserved, no longer produced) parks at `planning`. */
332
+ function deliveryOriginStage(status: string, prs: readonly LineagePr[]): LineageStage {
333
+ switch (status) {
334
+ case "awaiting-approval":
335
+ return "planning";
336
+ case "running":
337
+ return prs.some((p) => !TERMINAL_STATUSES.includes(p.status)) ? "converging" : "implementing";
338
+ case "done":
339
+ return "resolved";
340
+ case "failed":
341
+ case "abandoned":
342
+ return "abandoned";
343
+ default:
344
+ return "implementing";
345
+ }
346
+ }
347
+
348
+ /** The fallback frontier label for a delivery thread when the run has not stamped a `phase` yet.
349
+ * `deliveryOriginStage` folds both the `failed` and `abandoned` run statuses onto the terminal
350
+ * `abandoned` stage, so the stage alone cannot tell them apart — take the run `status` too and label a
351
+ * genuinely `abandoned` run "Abandoned" (only a `failed` run reads "Failed"). */
352
+ function deliveryStageLabel(stage: LineageStage, status: string): string {
353
+ switch (stage) {
354
+ case "planning":
355
+ return "Awaiting approval";
356
+ case "converging":
357
+ return "Converging";
358
+ case "resolved":
359
+ return "Completed";
360
+ case "abandoned":
361
+ return status === "abandoned" ? "Abandoned" : "Failed";
362
+ default:
363
+ return "Running";
364
+ }
365
+ }
366
+
296
367
  // ── gateway glue ───────────────────────────────────────────────────────────────────────────────
297
368
 
298
369
  /** The subset of `pull_requests` the lineage projection reads. */
@@ -450,6 +521,21 @@ async function collectThreads(
450
521
  threads.set(plan.plan_key, deriveLineage(epicOrigin(plan), prs.map(toLineagePr)));
451
522
  }
452
523
 
524
+ // Delivery-graph runs (issue #498): each run is a fan-in parent thread keyed on its `run_key`,
525
+ // attaching the downstream PRs threaded to it (`pull_requests.root_request_key = run_key`). Mirrors
526
+ // the feature/epic loops — a run with no PR landed yet still projects a thread (its derived phase).
527
+ const deliveryRows = await deliveryGraphRuns(data).all();
528
+ for (const run of deliveryRows) {
529
+ // Feature/epic precedence: the SQL view's CASE classifies epic > feature > delivery, so a
530
+ // `run_key` that collides with an existing `plan_key`/`feature_key` must NOT overwrite that
531
+ // thread — otherwise the poller projection would stamp delivery-derived frontier columns onto a
532
+ // row the view still classifies epic/feature, and the two drift. Skip the colliding run so the
533
+ // earlier feature/epic thread (and the view's precedence) stays intact.
534
+ if (threads.has(run.run_key)) continue;
535
+ const prs = collectRootPrs(run.run_key, null, prsByRoot, prByKey, claimed);
536
+ threads.set(run.run_key, deriveLineage(deliveryGraphOrigin(run), prs.map(toLineagePr)));
537
+ }
538
+
453
539
  // Any PR not claimed by a feature/epic root is its own root: a human/webhook PR, a legacy row
454
540
  // predating migration 037's backfill, or a `root_request_key` whose origin row no longer survives.
455
541
  // Key each such thread by the root STORED on the PR row (`root_request_key`, falling back to
@@ -532,6 +618,17 @@ function epicOrigin(plan: Plan): LineageOrigin {
532
618
  };
533
619
  }
534
620
 
621
+ function deliveryGraphOrigin(run: DeliveryGraphRun): LineageOrigin {
622
+ return {
623
+ kind: "delivery",
624
+ key: run.run_key,
625
+ title: run.title,
626
+ status: run.status,
627
+ phase: run.phase,
628
+ processKey: run.process_key,
629
+ };
630
+ }
631
+
535
632
  /** On-demand: the stitched thread for one origin issue (or self-rooted PR), computed from the live
536
633
  * rows. Returns null when the root is unknown. */
537
634
  export async function getLineage(
@@ -0,0 +1,153 @@
1
+ // Read-model guard for migration 079's extended `lineage_thread_view` VIEW (issue #498: surface
2
+ // delivery-graph runs in the Lineage tab as a fan-in parent thread). Mirrors app/migration064.test.ts:
3
+ // apply the migration to a real in-memory SQLite DB and assert the VIEW's output over sample rows —
4
+ // so this exercises the real view, not a re-implementation.
5
+ //
6
+ // The extended view adds a third origin arm: a root that matches a `delivery_graph_runs.run_key`
7
+ // derives `kind = 'delivery'`, `title` from the run, and a NULL `issue_url` (a run is keyed by
8
+ // run_key/digest, not a GitHub issue). The epic/feature/pr arms must keep behaving exactly as 064's
9
+ // view did (precedence unchanged).
10
+ import { readFileSync } from "node:fs";
11
+ import { DatabaseSync } from "node:sqlite";
12
+ import { test } from "node:test";
13
+ import { fileURLToPath } from "node:url";
14
+ import { assertEquals } from "#test-assert";
15
+
16
+ const MIGRATION_064 = fileURLToPath(new URL("../db/migrations/064_lineage_thread_view.sql", import.meta.url));
17
+ const MIGRATION_079 = fileURLToPath(
18
+ new URL("../db/migrations/079_lineage_thread_view_delivery.sql", import.meta.url),
19
+ );
20
+
21
+ /** A DB with the base shapes the view reads (`lineage_threads`, `plans`, `feature_runs`,
22
+ * `delivery_graph_runs`) plus 064 then 079 applied in order — so this exercises the real DROP VIEW +
23
+ * re-CREATE the migration performs, not just the final definition. */
24
+ function viewDb(): DatabaseSync {
25
+ const db = new DatabaseSync(":memory:");
26
+ db.exec(
27
+ `CREATE TABLE lineage_threads (
28
+ root_request_key TEXT PRIMARY KEY, title TEXT, stage TEXT,
29
+ stage_label TEXT, process_key TEXT, pr_keys TEXT, pr_count INTEGER, active INTEGER,
30
+ created_at TEXT, updated_at TEXT);
31
+ CREATE TABLE plans (plan_key TEXT PRIMARY KEY, title TEXT, issue_url TEXT);
32
+ CREATE TABLE feature_runs (feature_key TEXT PRIMARY KEY, title TEXT, issue_url TEXT);
33
+ CREATE TABLE delivery_graph_runs (run_key TEXT PRIMARY KEY, title TEXT, phase TEXT, status TEXT);`,
34
+ );
35
+ db.exec(readFileSync(MIGRATION_064, "utf8"));
36
+ db.exec(readFileSync(MIGRATION_079, "utf8"));
37
+ return db;
38
+ }
39
+
40
+ /** Insert a `lineage_threads` row exactly as `pollLineage` denormalises one (post-072 schema — no
41
+ * `kind`/`issue_url` columns; the view derives both from the origin joins). */
42
+ function addThread(
43
+ db: DatabaseSync,
44
+ row: {
45
+ root_request_key: string;
46
+ title: string | null;
47
+ stage: string;
48
+ stage_label: string | null;
49
+ process_key: string | null;
50
+ pr_keys: string | null;
51
+ pr_count: number;
52
+ active: number;
53
+ },
54
+ ): void {
55
+ db.prepare(
56
+ `INSERT INTO lineage_threads (root_request_key, title, stage, stage_label,
57
+ process_key, pr_keys, pr_count, active, created_at, updated_at)
58
+ VALUES (@root_request_key, @title, @stage, @stage_label, @process_key,
59
+ @pr_keys, @pr_count, @active, 't0', 't1')`,
60
+ ).run(row);
61
+ }
62
+
63
+ test("lineage_thread_view derives kind/title/NULL issue_url for a delivery-graph thread from the run origin", () => {
64
+ const db = viewDb();
65
+ db.prepare(
66
+ "INSERT INTO delivery_graph_runs (run_key, title, phase, status) VALUES (?, ?, ?, ?)",
67
+ ).run("dg-abc123", "Ship widget across repos", "Parked on human node: manual OTP publish", "running");
68
+ // pollLineage wrote the fan-in run's procedural frontier onto lineage_threads, keyed on run_key.
69
+ addThread(db, {
70
+ root_request_key: "dg-abc123",
71
+ title: "Ship widget across repos",
72
+ stage: "converging",
73
+ stage_label: "Parked on human node: manual OTP publish",
74
+ process_key: "P-dg",
75
+ pr_keys: '["a/b#1","c/d#9"]',
76
+ pr_count: 2,
77
+ active: 1,
78
+ });
79
+
80
+ const v = db
81
+ .prepare("SELECT * FROM lineage_thread_view WHERE root_request_key = ?")
82
+ .get("dg-abc123") as Record<string, unknown>;
83
+ // Derived from the delivery_graph_runs join.
84
+ assertEquals(v.kind, "delivery");
85
+ assertEquals(v.title, "Ship widget across repos");
86
+ // A run is keyed by run_key/digest, not a GitHub issue — issue_url is always NULL.
87
+ assertEquals(v.issue_url, null);
88
+ // Procedural frontier columns pass through unchanged from lineage_threads (the run's derived phase).
89
+ assertEquals(v.stage, "converging");
90
+ assertEquals(v.stage_label, "Parked on human node: manual OTP publish");
91
+ assertEquals(v.process_key, "P-dg");
92
+ assertEquals(v.pr_keys, '["a/b#1","c/d#9"]');
93
+ assertEquals(v.pr_count, 2);
94
+ assertEquals(v.active, 1);
95
+ });
96
+
97
+ test("lineage_thread_view renders a delivery thread with no PR landed yet (empty member set, run phase)", () => {
98
+ const db = viewDb();
99
+ db.prepare(
100
+ "INSERT INTO delivery_graph_runs (run_key, title, phase, status) VALUES (?, ?, ?, ?)",
101
+ ).run("dg-empty", "Fresh run", "Running", "running");
102
+ addThread(db, {
103
+ root_request_key: "dg-empty",
104
+ title: "Fresh run",
105
+ stage: "implementing",
106
+ stage_label: "Running",
107
+ process_key: "P-e",
108
+ pr_keys: "[]",
109
+ pr_count: 0,
110
+ active: 1,
111
+ });
112
+
113
+ const v = db
114
+ .prepare("SELECT kind, title, issue_url, stage_label, pr_count FROM lineage_thread_view WHERE root_request_key = ?")
115
+ .get("dg-empty") as Record<string, unknown>;
116
+ assertEquals(v.kind, "delivery");
117
+ assertEquals(v.title, "Fresh run");
118
+ assertEquals(v.issue_url, null);
119
+ assertEquals(v.stage_label, "Running");
120
+ assertEquals(v.pr_count, 0);
121
+ });
122
+
123
+ test("lineage_thread_view keeps the epic/feature/pr arms unchanged after the delivery arm is added", () => {
124
+ const db = viewDb();
125
+ db.prepare("INSERT INTO plans (plan_key, title, issue_url) VALUES ('o/r#2', 'Epic', 'u-epic')").run();
126
+ db.prepare("INSERT INTO feature_runs (feature_key, title, issue_url) VALUES ('o/r#1', 'Feat', 'u-feat')").run();
127
+ const rows = [
128
+ { root_request_key: "o/r#2", kind: "epic", title: "Epic", issue_url: "u-epic" },
129
+ { root_request_key: "o/r#1", kind: "feature", title: "Feat", issue_url: "u-feat" },
130
+ { root_request_key: "o/r#30", kind: "pr", title: "PR", issue_url: null },
131
+ ];
132
+ for (const r of rows) {
133
+ addThread(db, {
134
+ root_request_key: r.root_request_key,
135
+ title: r.title,
136
+ stage: "opened",
137
+ stage_label: "Opened",
138
+ process_key: null,
139
+ pr_keys: "[]",
140
+ pr_count: 1,
141
+ active: 1,
142
+ });
143
+ }
144
+
145
+ for (const r of rows) {
146
+ const v = db
147
+ .prepare("SELECT kind, title, issue_url FROM lineage_thread_view WHERE root_request_key = ?")
148
+ .get(r.root_request_key) as Record<string, unknown>;
149
+ assertEquals(v.kind, r.kind);
150
+ assertEquals(v.title, r.title);
151
+ assertEquals(v.issue_url, r.issue_url);
152
+ }
153
+ });
@@ -0,0 +1,65 @@
1
+ -- Surface delivery-graph runs in the Lineage read model (issue #498: "surface dynamic delivery
2
+ -- graphs in the Lineage tab as a fan-in parent thread").
3
+ --
4
+ -- 064_lineage_thread_view.sql created `lineage_thread_view`, deriving a thread's view-expressible
5
+ -- identity columns (`kind`, `title`, `issue_url`) from the `plans` / `feature_runs` origin joins and
6
+ -- passing the procedural frontier columns through from `lineage_threads`. It matched a root against
7
+ -- exactly two origin tables (else a self-rooted `'pr'`), so a delivery-graph run — a SEPARATE
8
+ -- aggregate (`delivery_graph_runs`, keyed by `run_key`) — was structurally invisible: its thread fell
9
+ -- through to `'pr'` with a NULL title.
10
+ --
11
+ -- `collectThreads` (app/lineage.ts) now enumerates `delivery_graph_runs` as a fan-in parent thread
12
+ -- keyed on `run_key`, so `pollLineage` writes a `lineage_threads` row for each run. Extend the view
13
+ -- with a third origin arm so it derives that row's identity from the run:
14
+ -- • `kind` — 'delivery' when the root matches a `delivery_graph_runs.run_key` (after the
15
+ -- epic/feature arms, mirroring the precedence in `collectThreads` / `deriveLineage`).
16
+ -- • `title` — the run's `title` (its authored delivery-graph title).
17
+ -- • `issue_url` — NULL: a delivery-graph run is keyed by `run_key`/`digest`, not a GitHub issue,
18
+ -- exactly as `deriveLineage` sets it (only feature/epic threads root on an issue).
19
+ -- The procedural frontier columns (`stage`/`stage_label`/`process_key`/`pr_keys`/`pr_count`/`active`)
20
+ -- still pass through from `lineage_threads`, so a delivery thread's frontier reflects the run's
21
+ -- derived phase that `pollLineage` wrote.
22
+ --
23
+ -- A VIEW cannot be `ALTER`ed, so DROP the old definition and CREATE the extended one. This is a NEW
24
+ -- forward-only migration — 064 stays immutable. The view remains a plain `CREATE VIEW <name> AS
25
+ -- SELECT … FROM …` (no CTE, no select-list subquery, every column aliased) so the static
26
+ -- pages↔schema contract guard can still introspect its output columns; the added CASE arm and LEFT
27
+ -- JOIN keep the SAME output column set, so the repointed Lineage page renders identically.
28
+ --
29
+ -- Forward-only, additive: no schema change to any base table, no DROP of `lineage_threads`. The
30
+ -- runner wraps each file in its own transaction, so this file must NOT contain BEGIN/COMMIT.
31
+
32
+ DROP VIEW IF EXISTS lineage_thread_view;
33
+
34
+ CREATE VIEW lineage_thread_view AS
35
+ SELECT
36
+ lt.root_request_key AS root_request_key,
37
+ CASE
38
+ WHEN pl.plan_key IS NOT NULL THEN 'epic'
39
+ WHEN fr.feature_key IS NOT NULL THEN 'feature'
40
+ WHEN dg.run_key IS NOT NULL THEN 'delivery'
41
+ ELSE 'pr'
42
+ END AS kind,
43
+ CASE
44
+ WHEN pl.plan_key IS NOT NULL THEN pl.title
45
+ WHEN fr.feature_key IS NOT NULL THEN fr.title
46
+ WHEN dg.run_key IS NOT NULL THEN dg.title
47
+ ELSE lt.title
48
+ END AS title,
49
+ CASE
50
+ WHEN pl.plan_key IS NOT NULL THEN pl.issue_url
51
+ WHEN fr.feature_key IS NOT NULL THEN fr.issue_url
52
+ ELSE NULL
53
+ END AS issue_url,
54
+ lt.stage AS stage,
55
+ lt.stage_label AS stage_label,
56
+ lt.process_key AS process_key,
57
+ lt.pr_keys AS pr_keys,
58
+ lt.pr_count AS pr_count,
59
+ lt.active AS active,
60
+ lt.created_at AS created_at,
61
+ lt.updated_at AS updated_at
62
+ FROM lineage_threads lt
63
+ LEFT JOIN plans pl ON pl.plan_key = lt.root_request_key
64
+ LEFT JOIN feature_runs fr ON fr.feature_key = lt.root_request_key
65
+ LEFT JOIN delivery_graph_runs dg ON dg.run_key = lt.root_request_key;
package/openapi.yaml CHANGED
@@ -148,10 +148,10 @@ components:
148
148
  properties:
149
149
  rootRequestKey:
150
150
  type: string
151
- description: The origin issue key (feature_key/plan_key), or a self-rooted pr_key.
151
+ description: The origin issue key (feature_key/plan_key), a self-rooted pr_key, or a delivery-graph run_key.
152
152
  kind:
153
153
  type: string
154
- enum: [feature, epic, pr]
154
+ enum: [feature, epic, pr, delivery]
155
155
  title:
156
156
  type: string
157
157
  nullable: true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.131.1",
3
+ "version": "0.132.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -70,7 +70,7 @@
70
70
  "type": "text",
71
71
  "id": "subtitle",
72
72
  "props": {
73
- "text": "One narrative per request, not a card-swap. Each row is a single arc of your intent \u2014 request \u2192 implementing \u2192 PR opened \u2192 converging (round n) \u2192 merged/converged/abandoned \u2014 stitched from the feature/epic that started it, the PR(s) it produced, and their convergence + merge outcome. The Stage column is the active frontier; drill into a row for its member PR(s) and rounds. Epics fan out to N PR sub-threads. A human/webhook PR with no originating request is shown as its own root.",
73
+ "text": "One narrative per request, not a card-swap. Each row is a single arc of your intent \u2014 request \u2192 implementing \u2192 PR opened \u2192 converging (round n) \u2192 merged/converged/abandoned \u2014 stitched from the feature/epic that started it, the PR(s) it produced, and their convergence + merge outcome. The Stage column is the active frontier; drill into a row for its member PR(s) and rounds. Epics fan out to N PR sub-threads. A delivery-graph run is a fan-in parent thread: the heterogeneous downstream PR convergences it spawns (across different repos/issues) nest under the run, whose frontier reflects its derived phase. A human/webhook PR with no originating request is shown as its own root.",
74
74
  "variant": "sub"
75
75
  }
76
76
  },