@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 +7 -0
- package/app/delivery.test.ts +2 -1
- package/app/delivery.ts +76 -0
- package/app/instance-tracking.test.ts +2 -1
- package/app/lineage.test.ts +300 -0
- package/app/lineage.ts +537 -0
- package/app/migration037.test.ts +62 -0
- package/app/retro.ts +1 -1
- package/app/service.test.ts +104 -1
- package/app/service.ts +33 -71
- package/db/migrations/037_lineage.sql +69 -0
- package/openapi.yaml +120 -0
- package/operations/getLineage.test.ts +105 -0
- package/operations/getLineage.ts +32 -0
- package/package.json +1 -1
- package/pages/cockpit.page.json +1 -0
- package/pages/epic-detail.page.json +1 -0
- package/pages/epic.page.json +1 -0
- package/pages/feature.page.json +1 -0
- package/pages/home.page.json +4 -0
- package/pages/lineage.page.json +146 -0
- package/pages/overview.page.json +1 -0
- package/pages/tasks.page.json +4 -0
- package/workers/converge-feature/worker.ts +1 -1
- package/workers/record-wave/worker.ts +2 -2
package/app/lineage.ts
ADDED
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
// Lineage projection (issue #245): thread user intent → progress as one arc.
|
|
2
|
+
//
|
|
3
|
+
// The stages of a request (feature/epic issue → implementation → PR → convergence → merge → outcome)
|
|
4
|
+
// already exist in the data layer as separate rows, keyed by the origin identity that `submitPr`
|
|
5
|
+
// threads onto every descendant (`pull_requests.root_request_key`, mirrored from `feature_runs` /
|
|
6
|
+
// `plans`). This module STITCHES them into one ordered narrative per `root_request_key`, exposing
|
|
7
|
+
// the active frontier (the stage currently in motion) plus settled history — the read model behind
|
|
8
|
+
// the "one narrative per intent, not a card-swap" UI.
|
|
9
|
+
//
|
|
10
|
+
// Two halves:
|
|
11
|
+
// • `deriveLineage` — a PURE function: origin (feature run / epic plan / self-rooted PR) + its PR
|
|
12
|
+
// rows → a `LineageThread` (stage, human label, active flag, member PRs). No I/O, fully tested.
|
|
13
|
+
// • `getLineage` / `pollLineage` — the gateway glue: read the live rows for a root (or every root)
|
|
14
|
+
// and project them. `pollLineage` denormalises the result onto the `lineage_threads` read table
|
|
15
|
+
// the schema-driven pages consume (Urban's datasource cannot read a SQL VIEW), mirroring the
|
|
16
|
+
// `pollDelivery` / `pollFeatureDelivery` convention.
|
|
17
|
+
//
|
|
18
|
+
// Human-opened / webhook PRs with no originating request are their OWN root: `submitPr` self-roots
|
|
19
|
+
// them by persisting `root_request_key = pr_key`. The projection self-roots the thread on the
|
|
20
|
+
// `pr_key` (kind `pr`), and also tolerates a legacy NULL `root_request_key` the same way.
|
|
21
|
+
import type { DataLayer } from "@nanobpm/urban";
|
|
22
|
+
import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
|
|
23
|
+
import { type FeatureRun, featureRuns } from "./feature.ts";
|
|
24
|
+
import { type Plan, type PlanTask, plans, planTasks } from "./plan.ts";
|
|
25
|
+
|
|
26
|
+
const now = () => new Date().toISOString();
|
|
27
|
+
|
|
28
|
+
/** The three origin shapes a lineage arc can spring from. */
|
|
29
|
+
export type LineageKind = "feature" | "epic" | "pr";
|
|
30
|
+
|
|
31
|
+
/** A member PR of a lineage thread — the subset of `pull_requests` the projection reads. */
|
|
32
|
+
export interface LineagePr {
|
|
33
|
+
prKey: string;
|
|
34
|
+
title: string | null;
|
|
35
|
+
url: string;
|
|
36
|
+
status: string;
|
|
37
|
+
round: number;
|
|
38
|
+
processKey: string | null;
|
|
39
|
+
outcome: string | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The origin (request) a thread is rooted on. Discriminated by `kind`. */
|
|
43
|
+
export type LineageOrigin =
|
|
44
|
+
| {
|
|
45
|
+
kind: "feature";
|
|
46
|
+
key: string;
|
|
47
|
+
title: string | null;
|
|
48
|
+
issueUrl: string | null;
|
|
49
|
+
status: string;
|
|
50
|
+
processKey: string | null;
|
|
51
|
+
}
|
|
52
|
+
| {
|
|
53
|
+
kind: "epic";
|
|
54
|
+
key: string;
|
|
55
|
+
title: string | null;
|
|
56
|
+
issueUrl: string | null;
|
|
57
|
+
status: string;
|
|
58
|
+
processKey: string | null;
|
|
59
|
+
}
|
|
60
|
+
| {
|
|
61
|
+
// A human/webhook PR with no originating request: its own root.
|
|
62
|
+
kind: "pr";
|
|
63
|
+
key: string;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** One stitched arc: `request → implementation → PR(s) → convergence → merge → outcome`. */
|
|
67
|
+
export interface LineageThread {
|
|
68
|
+
rootRequestKey: string;
|
|
69
|
+
kind: LineageKind;
|
|
70
|
+
title: string | null;
|
|
71
|
+
issueUrl: string | null;
|
|
72
|
+
/** Active-frontier machine label — the stage currently in motion (or the settled terminal). */
|
|
73
|
+
stage: LineageStage;
|
|
74
|
+
/** Human narrative rollup for the timeline (e.g. "Converging (round 2)", "3/5 slices merged, …"). */
|
|
75
|
+
stageLabel: string;
|
|
76
|
+
/** The active-frontier process instance (for the processExplorer link), best-effort. */
|
|
77
|
+
processKey: string | null;
|
|
78
|
+
prKeys: string[];
|
|
79
|
+
prCount: number;
|
|
80
|
+
/** True while the arc has an active frontier; false once every stage has settled. */
|
|
81
|
+
active: boolean;
|
|
82
|
+
prs: LineagePr[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The controlled vocabulary of frontier stages, ordered request → outcome. */
|
|
86
|
+
export const LINEAGE_STAGES = [
|
|
87
|
+
"planning", // epic: planner decomposing the issue
|
|
88
|
+
"implementing", // feature/epic: agent(s) building, no PR handed off yet
|
|
89
|
+
"escalated", // parked on a human answer (implementation- or review-phase)
|
|
90
|
+
"blocked", // awaiting operator acknowledgement (blocked run)
|
|
91
|
+
"opened", // a PR was opened but not yet enrolled into convergence
|
|
92
|
+
"converging", // enrolled: review rounds in flight
|
|
93
|
+
"reviewing", // waiting on an external review
|
|
94
|
+
"merging", // converged: the merge-loop is landing it
|
|
95
|
+
"merged", // terminal: landed
|
|
96
|
+
"converged", // terminal: review consensus reached, not merged (converge-only)
|
|
97
|
+
"abandoned", // terminal: gave up
|
|
98
|
+
"resolved", // terminal: settled without a clean merged/converged (mixed epic outcome)
|
|
99
|
+
] as const;
|
|
100
|
+
export type LineageStage = (typeof LINEAGE_STAGES)[number];
|
|
101
|
+
|
|
102
|
+
const TERMINAL_STAGES: readonly LineageStage[] = ["merged", "converged", "abandoned", "resolved"];
|
|
103
|
+
|
|
104
|
+
/** Map a single PR's `pull_requests.status` onto a frontier stage. */
|
|
105
|
+
function prStage(status: string): LineageStage {
|
|
106
|
+
switch (status) {
|
|
107
|
+
case "converging":
|
|
108
|
+
return "converging";
|
|
109
|
+
case "waiting_review":
|
|
110
|
+
return "reviewing";
|
|
111
|
+
case "escalated":
|
|
112
|
+
return "escalated";
|
|
113
|
+
case "waiting_deps":
|
|
114
|
+
case "waiting_merge":
|
|
115
|
+
case "waiting_lane":
|
|
116
|
+
case "queued":
|
|
117
|
+
case "merging":
|
|
118
|
+
return "merging";
|
|
119
|
+
case "converged":
|
|
120
|
+
return "converged";
|
|
121
|
+
case "merged":
|
|
122
|
+
return "merged";
|
|
123
|
+
case "abandoned":
|
|
124
|
+
return "abandoned";
|
|
125
|
+
default:
|
|
126
|
+
// Unknown/legacy status: treat as in-flight convergence rather than silently terminal.
|
|
127
|
+
return "converging";
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Map a feature run's own (pre-hand-off) status onto a frontier stage. */
|
|
132
|
+
function featureOriginStage(status: string): LineageStage {
|
|
133
|
+
switch (status) {
|
|
134
|
+
case "running":
|
|
135
|
+
return "implementing";
|
|
136
|
+
case "escalated":
|
|
137
|
+
return "escalated";
|
|
138
|
+
case "awaiting_operator":
|
|
139
|
+
return "blocked";
|
|
140
|
+
case "opened":
|
|
141
|
+
return "opened";
|
|
142
|
+
case "blocked":
|
|
143
|
+
case "failed":
|
|
144
|
+
return "abandoned";
|
|
145
|
+
case "skipped":
|
|
146
|
+
case "abandoned":
|
|
147
|
+
return "abandoned";
|
|
148
|
+
default:
|
|
149
|
+
return "implementing";
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Pick the PR that best represents a single-PR arc's frontier: the sole active one, else the last
|
|
154
|
+
* one in the caller-supplied order. `deriveLineage` supplies a `prKey`-sorted list, so among several
|
|
155
|
+
* terminal PRs this is the deterministic last-by-key — NOT a timestamp-based "most recent". */
|
|
156
|
+
function representativePr(prs: readonly LineagePr[]): LineagePr | null {
|
|
157
|
+
if (prs.length === 0) return null;
|
|
158
|
+
const active = prs.find((p) => !TERMINAL_STATUSES.includes(p.status));
|
|
159
|
+
return active ?? prs[prs.length - 1];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function prStageLabel(stage: LineageStage, round: number): string {
|
|
163
|
+
switch (stage) {
|
|
164
|
+
case "converging":
|
|
165
|
+
return round > 0 ? `Converging (round ${round})` : "Converging";
|
|
166
|
+
case "reviewing":
|
|
167
|
+
return round > 0 ? `Awaiting review (round ${round})` : "Awaiting review";
|
|
168
|
+
case "escalated":
|
|
169
|
+
return "Escalated — awaiting answer";
|
|
170
|
+
case "merging":
|
|
171
|
+
return "Merging";
|
|
172
|
+
case "merged":
|
|
173
|
+
return "Merged";
|
|
174
|
+
case "converged":
|
|
175
|
+
return "Converged (not merged)";
|
|
176
|
+
case "abandoned":
|
|
177
|
+
return "Abandoned";
|
|
178
|
+
default:
|
|
179
|
+
return "Opened";
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Derive the stitched thread for one root from its origin and PR rows. Pure + total. */
|
|
184
|
+
export function deriveLineage(origin: LineageOrigin, prsIn: readonly LineagePr[]): LineageThread {
|
|
185
|
+
// Sort deterministically by PR key so the projection is stable across passes: the upstream
|
|
186
|
+
// collection order is not guaranteed (DataLayer `.all()` has no `ORDER BY`, plus Map iteration),
|
|
187
|
+
// so an ordering-only difference would otherwise rewrite `lineage_threads.pr_keys` (and could
|
|
188
|
+
// flip the representative-PR pick among terminal PRs) on steady-state polls, defeating the
|
|
189
|
+
// idempotence check in `pollLineage`.
|
|
190
|
+
const prs = [...prsIn].sort((a, b) => a.prKey.localeCompare(b.prKey));
|
|
191
|
+
const prKeys = prs.map((p) => p.prKey);
|
|
192
|
+
const rep = representativePr(prs);
|
|
193
|
+
|
|
194
|
+
let stage: LineageStage;
|
|
195
|
+
let stageLabel: string;
|
|
196
|
+
let processKey: string | null;
|
|
197
|
+
|
|
198
|
+
if (origin.kind === "epic") {
|
|
199
|
+
// Fan-out: roll the slice PRs up via the shared delivery derivation, then translate to a stage.
|
|
200
|
+
// Pass the epic's real status (not a hard-coded "done"): we only consume the rollup's PR counts,
|
|
201
|
+
// which `deriveDelivery` computes independently of `planStatus`, but threading the true status
|
|
202
|
+
// keeps this correct if the derivation ever gates those counts on it.
|
|
203
|
+
const rollup = deriveDelivery(origin.status, prs.map((p) => p.status));
|
|
204
|
+
const anyActive = prs.some((p) => !TERMINAL_STATUSES.includes(p.status));
|
|
205
|
+
if (prs.length === 0) {
|
|
206
|
+
stage = origin.status === "planning" ? "planning" : "implementing";
|
|
207
|
+
stageLabel = origin.status === "planning" ? "Planning" : "Implementing";
|
|
208
|
+
} else if (anyActive) {
|
|
209
|
+
stage = "converging";
|
|
210
|
+
stageLabel = `${rollup.prsMerged}/${rollup.prsOpened} slices merged, ${rollup.prsInFlight} converging`;
|
|
211
|
+
} else if (rollup.prsMerged === rollup.prsOpened) {
|
|
212
|
+
stage = "merged";
|
|
213
|
+
stageLabel = `${rollup.prsOpened}/${rollup.prsOpened} slices merged`;
|
|
214
|
+
} else {
|
|
215
|
+
stage = "resolved";
|
|
216
|
+
stageLabel = `${rollup.prsMerged}/${rollup.prsOpened} slices merged (rest resolved)`;
|
|
217
|
+
}
|
|
218
|
+
// Active-frontier instance: an in-flight slice PR's process, else the plan's own.
|
|
219
|
+
const activePr = prs.find((p) => !TERMINAL_STATUSES.includes(p.status));
|
|
220
|
+
processKey = activePr?.processKey ?? origin.processKey ?? rep?.processKey ?? null;
|
|
221
|
+
} else if (origin.kind === "feature") {
|
|
222
|
+
if (rep && !isPreHandoff(origin.status)) {
|
|
223
|
+
// Handed off (converging, or a reconciled terminal): the PR frontier drives the narrative.
|
|
224
|
+
stage = prStage(rep.status);
|
|
225
|
+
stageLabel = prStageLabel(stage, rep.round);
|
|
226
|
+
processKey = rep.processKey ?? origin.processKey ?? null;
|
|
227
|
+
} else {
|
|
228
|
+
// Still in the request/implementation phase — no hand-off yet (or the PR row is missing).
|
|
229
|
+
stage = featureOriginStage(origin.status);
|
|
230
|
+
stageLabel = featureStageLabel(stage);
|
|
231
|
+
processKey = origin.processKey ?? rep?.processKey ?? null;
|
|
232
|
+
}
|
|
233
|
+
} else {
|
|
234
|
+
// Self-rooted PR (human/webhook): the PR IS the whole arc.
|
|
235
|
+
stage = rep ? prStage(rep.status) : "converging";
|
|
236
|
+
stageLabel = rep ? prStageLabel(stage, rep.round) : "Converging";
|
|
237
|
+
processKey = rep?.processKey ?? null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const active = !TERMINAL_STAGES.includes(stage);
|
|
241
|
+
return {
|
|
242
|
+
rootRequestKey: origin.key,
|
|
243
|
+
kind: origin.kind,
|
|
244
|
+
title: origin.kind === "pr" ? (rep?.title ?? null) : origin.title,
|
|
245
|
+
issueUrl: origin.kind === "pr" ? null : origin.issueUrl,
|
|
246
|
+
stage,
|
|
247
|
+
stageLabel,
|
|
248
|
+
processKey,
|
|
249
|
+
prKeys,
|
|
250
|
+
prCount: prKeys.length,
|
|
251
|
+
active,
|
|
252
|
+
prs,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function isPreHandoff(status: string): boolean {
|
|
257
|
+
return status === "running" || status === "escalated" || status === "awaiting_operator" ||
|
|
258
|
+
status === "opened";
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function featureStageLabel(stage: LineageStage): string {
|
|
262
|
+
switch (stage) {
|
|
263
|
+
case "implementing":
|
|
264
|
+
return "Implementing";
|
|
265
|
+
case "escalated":
|
|
266
|
+
return "Escalated — awaiting answer";
|
|
267
|
+
case "blocked":
|
|
268
|
+
return "Blocked — awaiting operator";
|
|
269
|
+
case "opened":
|
|
270
|
+
return "PR opened";
|
|
271
|
+
case "abandoned":
|
|
272
|
+
return "Abandoned";
|
|
273
|
+
default:
|
|
274
|
+
return prStageLabel(stage, 0);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ── gateway glue ───────────────────────────────────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
/** The subset of `pull_requests` the lineage projection reads. */
|
|
281
|
+
interface PrRow {
|
|
282
|
+
pr_key: string;
|
|
283
|
+
title: string | null;
|
|
284
|
+
url: string;
|
|
285
|
+
status: string;
|
|
286
|
+
current_round: number;
|
|
287
|
+
process_key: string | null;
|
|
288
|
+
outcome: string | null;
|
|
289
|
+
root_request_key: string | null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const prRows = (data: DataLayer) => data.table<PrRow>("pull_requests", "pr_key");
|
|
293
|
+
|
|
294
|
+
/** The denormalised read-table row `pollLineage` projects, one per root. */
|
|
295
|
+
export interface LineageThreadRow {
|
|
296
|
+
root_request_key: string;
|
|
297
|
+
kind: string;
|
|
298
|
+
title: string | null;
|
|
299
|
+
issue_url: string | null;
|
|
300
|
+
stage: string;
|
|
301
|
+
stage_label: string | null;
|
|
302
|
+
process_key: string | null;
|
|
303
|
+
pr_keys: string | null;
|
|
304
|
+
pr_count: number;
|
|
305
|
+
active: number;
|
|
306
|
+
created_at: string;
|
|
307
|
+
updated_at: string;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const lineageThreads = (data: DataLayer) =>
|
|
311
|
+
data.table<LineageThreadRow>("lineage_threads", "root_request_key");
|
|
312
|
+
|
|
313
|
+
function toLineagePr(row: PrRow): LineagePr {
|
|
314
|
+
return {
|
|
315
|
+
prKey: row.pr_key,
|
|
316
|
+
title: row.title,
|
|
317
|
+
url: row.url,
|
|
318
|
+
status: row.status,
|
|
319
|
+
round: row.current_round,
|
|
320
|
+
processKey: row.process_key,
|
|
321
|
+
outcome: row.outcome,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Assemble the origin + PR set for every root from the live gateway rows, then derive each thread.
|
|
326
|
+
* Reused by both `getLineage` (single root, on demand) and `pollLineage` (all roots, projected). */
|
|
327
|
+
async function collectThreads(data: DataLayer): Promise<Map<string, LineageThread>> {
|
|
328
|
+
const allPrs = await prRows(data).all();
|
|
329
|
+
const prByKey = new Map<string, PrRow>();
|
|
330
|
+
for (const pr of allPrs) prByKey.set(pr.pr_key, pr);
|
|
331
|
+
|
|
332
|
+
// Group PRs by their threaded root; a NULL root defers to self-rooting below.
|
|
333
|
+
const prsByRoot = new Map<string, PrRow[]>();
|
|
334
|
+
for (const pr of allPrs) {
|
|
335
|
+
if (!pr.root_request_key) continue;
|
|
336
|
+
const bucket = prsByRoot.get(pr.root_request_key) ?? [];
|
|
337
|
+
bucket.push(pr);
|
|
338
|
+
prsByRoot.set(pr.root_request_key, bucket);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const claimed = new Set<string>(); // pr_keys already attached to a feature/epic root
|
|
342
|
+
const threads = new Map<string, LineageThread>();
|
|
343
|
+
|
|
344
|
+
const featureRows = await featureRuns(data).all();
|
|
345
|
+
for (const run of featureRows) {
|
|
346
|
+
const prs = collectRootPrs(run.feature_key, run.pr_key, prsByRoot, prByKey, claimed);
|
|
347
|
+
threads.set(
|
|
348
|
+
run.feature_key,
|
|
349
|
+
deriveLineage(featureOrigin(run), prs.map(toLineagePr)),
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const planRows = await plans(data).all();
|
|
354
|
+
// Prefetch every plan_task once and group by plan_key rather than issuing one query per plan: this
|
|
355
|
+
// runs on the poller path AND the GET /lineage derivation, so an N+1 over plans would scale poorly
|
|
356
|
+
// (mirrors the single-prefetch convention in pollDelivery/pollFeatureDelivery in app/service.ts).
|
|
357
|
+
const tasksByPlan = new Map<string, PlanTask[]>();
|
|
358
|
+
for (const task of await planTasks(data).all()) {
|
|
359
|
+
const bucket = tasksByPlan.get(task.plan_key) ?? [];
|
|
360
|
+
bucket.push(task);
|
|
361
|
+
tasksByPlan.set(task.plan_key, bucket);
|
|
362
|
+
}
|
|
363
|
+
for (const plan of planRows) {
|
|
364
|
+
const taskPrKeys = (tasksByPlan.get(plan.plan_key) ?? [])
|
|
365
|
+
.map((t) => t.pr_key)
|
|
366
|
+
.filter((k): k is string => !!k);
|
|
367
|
+
const prs = collectEpicPrs(plan.plan_key, taskPrKeys, prsByRoot, prByKey, claimed);
|
|
368
|
+
threads.set(plan.plan_key, deriveLineage(epicOrigin(plan), prs.map(toLineagePr)));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Any PR not claimed by a feature/epic root is its own root: a human/webhook PR, a legacy row
|
|
372
|
+
// predating migration 037's backfill, or a `root_request_key` whose origin row no longer survives.
|
|
373
|
+
// Key each such thread by the root STORED on the PR row (`root_request_key`, falling back to
|
|
374
|
+
// `pr_key` only for a legacy NULL) so the thread key equals `pull_requests.root_request_key` and
|
|
375
|
+
// the Lineage page's `lineage_threads.root_request_key → pull_requests.root_request_key`
|
|
376
|
+
// drill-down join resolves — keying on `pr_key` when the row carries a non-null orphaned root would
|
|
377
|
+
// render an empty PR list. Group PRs that share one orphaned root into a single thread (they came
|
|
378
|
+
// from the same request) rather than clobbering each other in the map.
|
|
379
|
+
const selfRooted = new Map<string, PrRow[]>();
|
|
380
|
+
for (const pr of allPrs) {
|
|
381
|
+
if (claimed.has(pr.pr_key)) continue;
|
|
382
|
+
const rootKey = pr.root_request_key ?? pr.pr_key;
|
|
383
|
+
const bucket = selfRooted.get(rootKey) ?? [];
|
|
384
|
+
bucket.push(pr);
|
|
385
|
+
selfRooted.set(rootKey, bucket);
|
|
386
|
+
}
|
|
387
|
+
for (const [rootKey, prs] of selfRooted) {
|
|
388
|
+
threads.set(rootKey, deriveLineage({ kind: "pr", key: rootKey }, prs.map(toLineagePr)));
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return threads;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Union the PRs a feature root owns: those threaded to it + its own denormalised `pr_key`. */
|
|
395
|
+
function collectRootPrs(
|
|
396
|
+
root: string,
|
|
397
|
+
ownPrKey: string | null,
|
|
398
|
+
prsByRoot: Map<string, PrRow[]>,
|
|
399
|
+
prByKey: Map<string, PrRow>,
|
|
400
|
+
claimed: Set<string>,
|
|
401
|
+
): PrRow[] {
|
|
402
|
+
const out = new Map<string, PrRow>();
|
|
403
|
+
for (const pr of prsByRoot.get(root) ?? []) out.set(pr.pr_key, pr);
|
|
404
|
+
if (ownPrKey) {
|
|
405
|
+
const row = prByKey.get(ownPrKey);
|
|
406
|
+
if (row) out.set(row.pr_key, row);
|
|
407
|
+
}
|
|
408
|
+
for (const key of out.keys()) claimed.add(key);
|
|
409
|
+
return [...out.values()];
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Union the slice PRs an epic root owns: those threaded to it + every `plan_tasks.pr_key`. */
|
|
413
|
+
function collectEpicPrs(
|
|
414
|
+
root: string,
|
|
415
|
+
taskPrKeys: readonly string[],
|
|
416
|
+
prsByRoot: Map<string, PrRow[]>,
|
|
417
|
+
prByKey: Map<string, PrRow>,
|
|
418
|
+
claimed: Set<string>,
|
|
419
|
+
): PrRow[] {
|
|
420
|
+
const out = new Map<string, PrRow>();
|
|
421
|
+
for (const pr of prsByRoot.get(root) ?? []) out.set(pr.pr_key, pr);
|
|
422
|
+
for (const key of taskPrKeys) {
|
|
423
|
+
const row = prByKey.get(key);
|
|
424
|
+
if (row) out.set(row.pr_key, row);
|
|
425
|
+
}
|
|
426
|
+
for (const key of out.keys()) claimed.add(key);
|
|
427
|
+
return [...out.values()];
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function featureOrigin(run: FeatureRun): LineageOrigin {
|
|
431
|
+
return {
|
|
432
|
+
kind: "feature",
|
|
433
|
+
key: run.feature_key,
|
|
434
|
+
title: run.title,
|
|
435
|
+
issueUrl: run.issue_url,
|
|
436
|
+
status: run.status,
|
|
437
|
+
processKey: run.process_key,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function epicOrigin(plan: Plan): LineageOrigin {
|
|
442
|
+
return {
|
|
443
|
+
kind: "epic",
|
|
444
|
+
key: plan.plan_key,
|
|
445
|
+
title: plan.title,
|
|
446
|
+
issueUrl: plan.issue_url,
|
|
447
|
+
status: plan.status,
|
|
448
|
+
processKey: plan.process_key,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** On-demand: the stitched thread for one origin issue (or self-rooted PR), computed from the live
|
|
453
|
+
* rows. Returns null when the root is unknown. */
|
|
454
|
+
export async function getLineage(
|
|
455
|
+
data: DataLayer,
|
|
456
|
+
rootRequestKey: string,
|
|
457
|
+
): Promise<LineageThread | null> {
|
|
458
|
+
const threads = await collectThreads(data);
|
|
459
|
+
return threads.get(rootRequestKey) ?? null;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** All stitched threads, active frontier first, then by `rootRequestKey` for a stable,
|
|
463
|
+
* deterministic order (the projection has no per-thread timestamp to sort on, and equal-`active`
|
|
464
|
+
* ties would otherwise be nondeterministic across passes). */
|
|
465
|
+
export async function listLineage(data: DataLayer): Promise<LineageThread[]> {
|
|
466
|
+
const threads = await collectThreads(data);
|
|
467
|
+
return [...threads.values()].sort(
|
|
468
|
+
(a, b) => Number(b.active) - Number(a.active) || a.rootRequestKey.localeCompare(b.rootRequestKey),
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Poller pass: recompute every thread and denormalise it onto `lineage_threads` so the
|
|
473
|
+
* schema-driven pages can read the single-narrative view as flat rows. Idempotent — writes only
|
|
474
|
+
* when the projection actually changes. Best-effort; per-root failures are isolated. */
|
|
475
|
+
export async function pollLineage(data: DataLayer): Promise<void> {
|
|
476
|
+
let threads: Map<string, LineageThread>;
|
|
477
|
+
try {
|
|
478
|
+
threads = await collectThreads(data);
|
|
479
|
+
} catch (err) {
|
|
480
|
+
console.error(`[poller] lineage collect: ${err}`);
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
const table = lineageThreads(data);
|
|
484
|
+
for (const thread of threads.values()) {
|
|
485
|
+
try {
|
|
486
|
+
const prKeysJson = JSON.stringify(thread.prKeys);
|
|
487
|
+
const active = thread.active ? 1 : 0;
|
|
488
|
+
const existing = await table.get(thread.rootRequestKey);
|
|
489
|
+
if (
|
|
490
|
+
existing &&
|
|
491
|
+
existing.kind === thread.kind &&
|
|
492
|
+
existing.title === thread.title &&
|
|
493
|
+
existing.issue_url === thread.issueUrl &&
|
|
494
|
+
existing.stage === thread.stage &&
|
|
495
|
+
existing.stage_label === thread.stageLabel &&
|
|
496
|
+
existing.process_key === thread.processKey &&
|
|
497
|
+
existing.pr_keys === prKeysJson &&
|
|
498
|
+
existing.pr_count === thread.prCount &&
|
|
499
|
+
existing.active === active
|
|
500
|
+
) {
|
|
501
|
+
continue; // steady state — no write
|
|
502
|
+
}
|
|
503
|
+
const ts = now();
|
|
504
|
+
if (existing) {
|
|
505
|
+
await table.update(thread.rootRequestKey, {
|
|
506
|
+
kind: thread.kind,
|
|
507
|
+
title: thread.title,
|
|
508
|
+
issue_url: thread.issueUrl,
|
|
509
|
+
stage: thread.stage,
|
|
510
|
+
stage_label: thread.stageLabel,
|
|
511
|
+
process_key: thread.processKey,
|
|
512
|
+
pr_keys: prKeysJson,
|
|
513
|
+
pr_count: thread.prCount,
|
|
514
|
+
active,
|
|
515
|
+
updated_at: ts,
|
|
516
|
+
});
|
|
517
|
+
} else {
|
|
518
|
+
await table.insert({
|
|
519
|
+
root_request_key: thread.rootRequestKey,
|
|
520
|
+
kind: thread.kind,
|
|
521
|
+
title: thread.title,
|
|
522
|
+
issue_url: thread.issueUrl,
|
|
523
|
+
stage: thread.stage,
|
|
524
|
+
stage_label: thread.stageLabel,
|
|
525
|
+
process_key: thread.processKey,
|
|
526
|
+
pr_keys: prKeysJson,
|
|
527
|
+
pr_count: thread.prCount,
|
|
528
|
+
active,
|
|
529
|
+
created_at: ts,
|
|
530
|
+
updated_at: ts,
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
} catch (err) {
|
|
534
|
+
console.error(`[poller] lineage ${thread.rootRequestKey}: ${err}`);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Regression guard for migration 037's root_request_key backfill (PR #253, issue #245). The Lineage
|
|
2
|
+
// page drills a thread's PRs via `lineage_threads.root_request_key → pull_requests.root_request_key`,
|
|
3
|
+
// where the thread key is the origin (feature_key / plan_key), or a self-rooted pr_key. A legacy PR
|
|
4
|
+
// predates the column (NULL), so the backfill must reconstruct the SAME root `submitPr` persists going
|
|
5
|
+
// forward — the feature/epic ORIGIN key for a tracked PR, self-rooted pr_key only for an origin-less
|
|
6
|
+
// one. Self-rooting a tracked PR would orphan it from its feature/epic thread (empty drill-down list).
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { DatabaseSync } from "node:sqlite";
|
|
9
|
+
import test from "node:test";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { assertEquals } from "#test-assert";
|
|
12
|
+
|
|
13
|
+
function migratedDb(): DatabaseSync {
|
|
14
|
+
const db = new DatabaseSync(":memory:");
|
|
15
|
+
// Minimal pre-037 shape: pull_requests WITHOUT root_request_key (037 ADD COLUMNs it), plus the two
|
|
16
|
+
// origin tables the backfill joins.
|
|
17
|
+
db.exec(`
|
|
18
|
+
CREATE TABLE pull_requests (pr_key TEXT PRIMARY KEY, repo TEXT, number INTEGER, url TEXT,
|
|
19
|
+
status TEXT, created_at TEXT, updated_at TEXT);
|
|
20
|
+
CREATE TABLE feature_runs (feature_key TEXT PRIMARY KEY, pr_key TEXT);
|
|
21
|
+
CREATE TABLE plans (plan_key TEXT PRIMARY KEY);
|
|
22
|
+
CREATE TABLE plan_tasks (id INTEGER PRIMARY KEY AUTOINCREMENT, plan_key TEXT, pr_key TEXT);
|
|
23
|
+
`);
|
|
24
|
+
const ins = (k: string) =>
|
|
25
|
+
db
|
|
26
|
+
.prepare(
|
|
27
|
+
`INSERT INTO pull_requests (pr_key, repo, number, url, status, created_at, updated_at)
|
|
28
|
+
VALUES (?, 'o/r', 1, 'u', 'converging', 't', 't')`,
|
|
29
|
+
)
|
|
30
|
+
.run(k);
|
|
31
|
+
ins("o/r#10"); // spawned by a feature run
|
|
32
|
+
ins("o/r#20"); // spawned by an epic slice
|
|
33
|
+
ins("o/r#30"); // origin-less (human/webhook)
|
|
34
|
+
db.prepare("INSERT INTO feature_runs (feature_key, pr_key) VALUES ('o/r#1', 'o/r#10')").run();
|
|
35
|
+
db.prepare("INSERT INTO plans (plan_key) VALUES ('o/r#2')").run();
|
|
36
|
+
db.prepare("INSERT INTO plan_tasks (plan_key, pr_key) VALUES ('o/r#2', 'o/r#20')").run();
|
|
37
|
+
|
|
38
|
+
const sql = readFileSync(
|
|
39
|
+
fileURLToPath(new URL("../db/migrations/037_lineage.sql", import.meta.url)),
|
|
40
|
+
"utf8",
|
|
41
|
+
);
|
|
42
|
+
db.exec(sql);
|
|
43
|
+
return db;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
test("migration 037 backfill roots each legacy PR on its true origin, self-rooting only origin-less rows", () => {
|
|
47
|
+
const db = migratedDb();
|
|
48
|
+
const rootOf = (k: string) =>
|
|
49
|
+
(
|
|
50
|
+
db.prepare("SELECT root_request_key AS r FROM pull_requests WHERE pr_key = ?").get(k) as {
|
|
51
|
+
r: string;
|
|
52
|
+
}
|
|
53
|
+
).r;
|
|
54
|
+
|
|
55
|
+
// A feature-spawned PR roots on its feature_runs origin, NOT its own pr_key — otherwise the
|
|
56
|
+
// drill-down join against the feature thread (keyed feature_key) yields an empty PR list.
|
|
57
|
+
assertEquals(rootOf("o/r#10"), "o/r#1");
|
|
58
|
+
// An epic-slice PR roots on its plan_tasks origin.
|
|
59
|
+
assertEquals(rootOf("o/r#20"), "o/r#2");
|
|
60
|
+
// An origin-less PR self-roots on its own pr_key.
|
|
61
|
+
assertEquals(rootOf("o/r#30"), "o/r#30");
|
|
62
|
+
});
|
package/app/retro.ts
CHANGED
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
// app/plan.ts, app/blackboard.ts, and app/taskDelta.ts.
|
|
16
16
|
import type { DataLayer, EngineClient, Logger } from "@nanobpm/urban";
|
|
17
17
|
import { isUniqueViolation, readBlackboard } from "./blackboard.ts";
|
|
18
|
+
import { TERMINAL_STATUSES } from "./delivery.ts";
|
|
18
19
|
import { planReviews, planTasks } from "./plan.ts";
|
|
19
|
-
import { TERMINAL_STATUSES } from "./service.ts";
|
|
20
20
|
import { aggregateEpicDeltas } from "./taskDelta.ts";
|
|
21
21
|
|
|
22
22
|
export const RETRO_PROCESS_ID = "retro";
|