@nanobpm/nano-workforce 0.131.1 → 0.133.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 +12 -0
- package/app/deliveryConnector.test.ts +19 -0
- package/app/deliveryConnector.ts +50 -10
- package/app/deliveryGraphCompiler.test.ts +23 -0
- package/app/lineage.test.ts +146 -0
- package/app/lineage.ts +100 -3
- package/app/migration079.test.ts +153 -0
- package/app/service.test.ts +28 -1
- package/app/service.ts +15 -0
- package/db/migrations/079_lineage_thread_view_delivery.sql +65 -0
- package/docs/adr/0005-agent-authored-delivery-graphs.md +18 -0
- package/docs/agent-guide.md +47 -1
- package/openapi.yaml +2 -2
- package/package.json +1 -1
- package/pages/lineage.page.json +1 -1
- package/workers/delivery-connector/worker.test.ts +239 -1
- package/workers/delivery-connector/worker.ts +87 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.133.0](https://github.com/nanobpm/nano-workforce/compare/v0.132.0...v0.133.0) (2026-08-24)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* **delivery-graph:** connector `converge`/`converge-merge` target enrolls a PR via submitPr (retire the manual land gate) ([#501](https://github.com/nanobpm/nano-workforce/issues/501)) ([a24562e](https://github.com/nanobpm/nano-workforce/commit/a24562eb12db8e6f81e6f382e94886a537f16378)), closes [#500](https://github.com/nanobpm/nano-workforce/issues/500)
|
|
6
|
+
|
|
7
|
+
## [0.132.0](https://github.com/nanobpm/nano-workforce/compare/v0.131.1...v0.132.0) (2026-08-24)
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* **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)
|
|
12
|
+
|
|
1
13
|
## [0.131.1](https://github.com/nanobpm/nano-workforce/compare/v0.131.0...v0.131.1) (2026-08-24)
|
|
2
14
|
|
|
3
15
|
### Bug Fixes
|
|
@@ -15,10 +15,14 @@ import { join, resolve } from "node:path";
|
|
|
15
15
|
import type { DataLayer } from "@nanobpm/urban";
|
|
16
16
|
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
17
17
|
import {
|
|
18
|
+
CONVERGE_MERGE_TARGET,
|
|
19
|
+
CONVERGE_TARGET,
|
|
18
20
|
connectorDedupeKey,
|
|
21
|
+
convergeOnlyForTarget,
|
|
19
22
|
type DeliveryConnectorDispatchRow,
|
|
20
23
|
deliveryConnectorDispatches,
|
|
21
24
|
dispatchConnector,
|
|
25
|
+
isConvergeTarget,
|
|
22
26
|
} from "./deliveryConnector.ts";
|
|
23
27
|
|
|
24
28
|
const APP_ROOT = resolve(import.meta.dirname, "..");
|
|
@@ -84,6 +88,21 @@ test("connectorDedupeKey: author key wins; else derives <processInstanceKey>:<el
|
|
|
84
88
|
assertEquals(connectorDedupeKey({ processInstanceKey: 12345, elementId: "n3" }), "12345:n3");
|
|
85
89
|
});
|
|
86
90
|
|
|
91
|
+
test("converge targets: `converge`/`converge-merge` are the enrollment targets; `converge` is review-only", () => {
|
|
92
|
+
assertEquals(CONVERGE_TARGET, "converge");
|
|
93
|
+
assertEquals(CONVERGE_MERGE_TARGET, "converge-merge");
|
|
94
|
+
// Only the two converge literals route into `submitPr`; any other target stays a stub dispatch.
|
|
95
|
+
assert(isConvergeTarget("converge"));
|
|
96
|
+
assert(isConvergeTarget("converge-merge"));
|
|
97
|
+
assert(!isConvergeTarget("slack"));
|
|
98
|
+
assert(!isConvergeTarget("Converge"));
|
|
99
|
+
assert(!isConvergeTarget(""));
|
|
100
|
+
// `convergeOnly` default maps onto `submitPr`'s arg: `converge` stops at converged (true),
|
|
101
|
+
// `converge-merge` drives the merge loop (false) — mirroring converge-feature's autoMerge inversion.
|
|
102
|
+
assertEquals(convergeOnlyForTarget("converge"), true);
|
|
103
|
+
assertEquals(convergeOnlyForTarget("converge-merge"), false);
|
|
104
|
+
});
|
|
105
|
+
|
|
87
106
|
test("first dispatch delivers exactly once; a redelivery on the same key dedupes and never re-acts", async () => {
|
|
88
107
|
await withApp(async (app) => {
|
|
89
108
|
const at = "2025-01-01T00:00:00.000Z";
|
package/app/deliveryConnector.ts
CHANGED
|
@@ -33,6 +33,33 @@ export const DELIVERY_CONNECTOR_TASK_TYPE = "pr.delivery-connector";
|
|
|
33
33
|
export const OUTCOME_CLAIMED = "claimed";
|
|
34
34
|
export const OUTCOME_DELIVERED = "delivered";
|
|
35
35
|
|
|
36
|
+
/** The two connector `target`s that enroll an agent-opened PR into the app's SHARED convergence /
|
|
37
|
+
* merge doors via `submitPr` (issue #500) — the delivery-graph side of the exact seam the feature
|
|
38
|
+
* cell reuses (`workers/converge-feature`), no duplicated machinery. `converge-merge` drives review
|
|
39
|
+
* convergence AND the merge loop; `converge` stops at `converged` (converge-only). This is the "real
|
|
40
|
+
* target dispatch" ADR 0005 deferred as a later slice for the connector I/O surface: a `converge`/
|
|
41
|
+
* `converge-merge` connector IS the "automated, side-effecting outbound action" a connector is
|
|
42
|
+
* defined to be. Named constants so the worker's dispatch branch and the docs/preview can never drift
|
|
43
|
+
* on the literal. */
|
|
44
|
+
export const CONVERGE_TARGET = "converge";
|
|
45
|
+
export const CONVERGE_MERGE_TARGET = "converge-merge";
|
|
46
|
+
|
|
47
|
+
/** Is `target` one of the converge-enrollment targets (`converge` / `converge-merge`)? The single
|
|
48
|
+
* predicate the worker branches on to route a dispatch into `submitPr` instead of the forward-declared
|
|
49
|
+
* stub. */
|
|
50
|
+
export function isConvergeTarget(target: string): boolean {
|
|
51
|
+
return target === CONVERGE_TARGET || target === CONVERGE_MERGE_TARGET;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The DEFAULT `convergeOnly` for a converge target: `converge` is review-only (`true` — stop at
|
|
55
|
+
* `converged`), `converge-merge` drives the merge loop too (`false`). Maps directly onto `submitPr`'s
|
|
56
|
+
* `convergeOnly` argument (mirroring how `converge-feature` inverts `autoMerge`). An author may still
|
|
57
|
+
* override it per-dispatch via the connector payload's `convergeOnly`. Only ever consulted behind
|
|
58
|
+
* `isConvergeTarget`, so a non-converge target's `false` is unreachable. */
|
|
59
|
+
export function convergeOnlyForTarget(target: string): boolean {
|
|
60
|
+
return target === CONVERGE_TARGET;
|
|
61
|
+
}
|
|
62
|
+
|
|
36
63
|
/** One durable dispatch-claim row — the at-most-once ledger entry a connector writes before it acts. */
|
|
37
64
|
export interface DeliveryConnectorDispatchRow extends Record<string, unknown> {
|
|
38
65
|
id?: number;
|
|
@@ -77,16 +104,27 @@ export function connectorDedupeKey(input: {
|
|
|
77
104
|
return null;
|
|
78
105
|
}
|
|
79
106
|
|
|
80
|
-
/** The
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
|
|
107
|
+
/** The side effect a connector dispatch performs EXACTLY ONCE per dedupe key. It runs only on the claim
|
|
108
|
+
* winner (or a resumed crashed claim), never on a `deduped` settled redelivery, so a real, non-idempotent
|
|
109
|
+
* side effect (e.g. `submitPr`, which deliberately re-opens a TERMINAL PR) is fenced by the ledger and
|
|
110
|
+
* can never double-fire — the reason the enrollment lives HERE rather than unconditionally around the
|
|
111
|
+
* dispatch. Returns the `detail` recorded on the ledger row. May be async (the real converge enrollment
|
|
112
|
+
* awaits `submitPr`). Must be idempotent so a resumed crashed claim can safely re-perform it — including
|
|
113
|
+
* terminal-safe against a NON-idempotent target (the converge action no-ops when its PR already settled,
|
|
114
|
+
* so a resume can never regress a terminal PR by re-opening it). */
|
|
115
|
+
export type ConnectorAction = (input: {
|
|
84
116
|
target: string;
|
|
85
117
|
payload: Record<string, unknown> | null;
|
|
86
118
|
boundFacts: readonly BoundFact[];
|
|
87
|
-
})
|
|
119
|
+
}) => { detail: string } | Promise<{ detail: string }>;
|
|
120
|
+
|
|
121
|
+
/** The forward-declared connector I/O surface (ADR non-goal — the concrete scheme is deferred). The
|
|
122
|
+
* DEFAULT `ConnectorAction`: a STUB that "performs" the action by returning a deterministic
|
|
123
|
+
* acknowledgement; a caller with a real side effect (the converge worker's `submitPr` enrollment) injects
|
|
124
|
+
* its own action into `dispatchConnector` instead, without touching the idempotency envelope around it. */
|
|
125
|
+
const performConnectorAction: ConnectorAction = (_input) => {
|
|
88
126
|
return { detail: "connector stub — I/O surface forward-declared (ADR 0005 non-goal)" };
|
|
89
|
-
}
|
|
127
|
+
};
|
|
90
128
|
|
|
91
129
|
/** The result of one connector dispatch attempt. `delivered` — the claim was won and the action fired
|
|
92
130
|
* exactly once; `deduped` — the key was already claimed (an at-least-once redelivery), so the recorded
|
|
@@ -107,13 +145,14 @@ async function resumeOrDedupe(
|
|
|
107
145
|
ledger: ReturnType<typeof deliveryConnectorDispatches>,
|
|
108
146
|
row: DeliveryConnectorDispatchRow,
|
|
109
147
|
input: { dedupeKey: string; target: string; payload?: Record<string, unknown> | null; boundFacts?: readonly BoundFact[] | null },
|
|
148
|
+
perform: ConnectorAction,
|
|
110
149
|
): Promise<ConnectorDispatchResult> {
|
|
111
150
|
if (row.outcome === OUTCOME_DELIVERED) {
|
|
112
151
|
return { connectorOutcome: "deduped", connectorDedupeKey: input.dedupeKey, connectorDetail: row.detail ?? "" };
|
|
113
152
|
}
|
|
114
153
|
// Still `claimed` — a prior attempt (sequential or the concurrent-race winner) claimed the key but
|
|
115
154
|
// never recorded delivery. Resume on the existing row rather than dedupe forever on an un-acted claim.
|
|
116
|
-
const { detail } =
|
|
155
|
+
const { detail } = await perform({
|
|
117
156
|
target: input.target,
|
|
118
157
|
payload: input.payload ?? null,
|
|
119
158
|
boundFacts: input.boundFacts ?? [],
|
|
@@ -149,6 +188,7 @@ export async function dispatchConnector(
|
|
|
149
188
|
data: DataLayer,
|
|
150
189
|
input: { dedupeKey: string; target: string; payload?: Record<string, unknown> | null; boundFacts?: readonly BoundFact[] | null },
|
|
151
190
|
at: string,
|
|
191
|
+
perform: ConnectorAction = performConnectorAction,
|
|
152
192
|
): Promise<ConnectorDispatchResult> {
|
|
153
193
|
const ledger = deliveryConnectorDispatches(data);
|
|
154
194
|
const existing = await ledger.findOne({ dedupe_key: input.dedupeKey });
|
|
@@ -167,7 +207,7 @@ export async function dispatchConnector(
|
|
|
167
207
|
if (existing) {
|
|
168
208
|
// A prior attempt recorded (`delivered`) or claimed-but-crashed (`claimed`) this key. Dedupe or
|
|
169
209
|
// resume it on the existing row — the ONE decision shared with the fence-loser path below.
|
|
170
|
-
return resumeOrDedupe(ledger, existing, input);
|
|
210
|
+
return resumeOrDedupe(ledger, existing, input, perform);
|
|
171
211
|
}
|
|
172
212
|
let claimId: number | bigint;
|
|
173
213
|
try {
|
|
@@ -196,11 +236,11 @@ export async function dispatchConnector(
|
|
|
196
236
|
// as the sequential path. Deduping a still-`claimed` winner here would complete the job on our ack,
|
|
197
237
|
// so a winner that then crashed would strand the side effect forever (the engine won't redeliver an
|
|
198
238
|
// acked job); resuming closes that gap and is safe because the action is idempotent.
|
|
199
|
-
if (won) return resumeOrDedupe(ledger, won, input);
|
|
239
|
+
if (won) return resumeOrDedupe(ledger, won, input, perform);
|
|
200
240
|
return { connectorOutcome: "deduped", connectorDedupeKey: input.dedupeKey, connectorDetail: "" };
|
|
201
241
|
}
|
|
202
242
|
// We alone won the claim — perform the side effect exactly once and record its outcome on our row.
|
|
203
|
-
const { detail } =
|
|
243
|
+
const { detail } = await perform({
|
|
204
244
|
target: input.target,
|
|
205
245
|
payload: input.payload ?? null,
|
|
206
246
|
boundFacts: input.boundFacts ?? [],
|
|
@@ -280,6 +280,29 @@ test("sideEffects: agent + connector only; connector carries its dedupeKey", asy
|
|
|
280
280
|
assert(!r.sideEffects.some((s) => s.nodeId === "publish"));
|
|
281
281
|
});
|
|
282
282
|
|
|
283
|
+
test("converge-merge worked graph: agent → connector[converge-merge] → wait[pr,merged] compiles with NO human node (retires the manual land gate, #500)", async () => {
|
|
284
|
+
const graph = {
|
|
285
|
+
name: "open → converge+merge → wait merged",
|
|
286
|
+
nodes: [
|
|
287
|
+
{ id: "open", kind: "agent", agent: { jobType: "senior:feature", prompt: "Implement the change and open a PR." } },
|
|
288
|
+
{ id: "land", kind: "connector", connector: { target: "converge-merge", payload: { pr: "acme/repo#123" } } },
|
|
289
|
+
{ id: "merged", kind: "wait", wait: { kind: "pr", target: "acme/repo#123", match: { prState: "merged" }, onTimeout: "escalate" } },
|
|
290
|
+
],
|
|
291
|
+
edges: [
|
|
292
|
+
{ from: "open", to: "land" },
|
|
293
|
+
{ from: "land", to: "merged" },
|
|
294
|
+
],
|
|
295
|
+
};
|
|
296
|
+
const r = await compileOk(graph);
|
|
297
|
+
// The canonical shape has NO human land-* gate — convergence is driven by the connector itself.
|
|
298
|
+
assertEquals(r.humanNodes.length, 0, "no human node bridges the PR to convergence");
|
|
299
|
+
// The connector is a side effect, naming its converge-merge target; the wait gate is read-only.
|
|
300
|
+
const connector = r.sideEffects.find((s) => s.nodeId === "land");
|
|
301
|
+
assertEquals(connector?.kind, "connector");
|
|
302
|
+
assert(connector?.description.includes("converge-merge"), "the side-effect names the converge-merge target");
|
|
303
|
+
assert(!r.sideEffects.some((s) => s.nodeId === "merged"), "the wait gate is not a side effect");
|
|
304
|
+
});
|
|
305
|
+
|
|
283
306
|
test("resolved edges carry the resolved fromNode and the referenced fact", async () => {
|
|
284
307
|
const r = await compileOk(RELEASE_RUNBOOK);
|
|
285
308
|
const factEdge = r.resolved.edges.find((e) => e.from === "watch-b.mergedSha");
|
package/app/lineage.test.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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(
|