@nanobpm/nano-workforce 0.78.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 +14 -0
- package/app/agentic/README.md +20 -0
- package/app/agentic/cockpit/index.ts +5 -0
- package/app/agentic/cockpit/transcript-derive.test.ts +78 -0
- package/app/agentic/cockpit/transcript-derive.ts +90 -0
- package/app/agentic/transcript-events.drift.test.ts +55 -0
- package/app/agentic/transcript-events.test.ts +186 -0
- package/app/agentic/transcript-events.ts +470 -0
- package/app/agentic/transcript-fork.test.ts +156 -0
- package/app/agentic/transcript-fork.ts +151 -0
- package/app/agentic/transcript-read.ts +2 -1
- 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/service.test.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// GitHub transport forced off so it is hermetic.
|
|
8
8
|
import { test } from "node:test";
|
|
9
9
|
import { assertEquals } from "#test-assert";
|
|
10
|
-
import { parsePr, pollIncidentsImpl, repoEnvelopeVars, submitPr } from "./service.ts";
|
|
10
|
+
import { parsePr, pollIncidentsImpl, repoEnvelopeVars, startMerge, submitPr } from "./service.ts";
|
|
11
11
|
|
|
12
12
|
function memTable(rows: any[], key: string) {
|
|
13
13
|
return {
|
|
@@ -331,6 +331,109 @@ test("submitPr defaults convergeOnly to false so the global auto-merge default g
|
|
|
331
331
|
});
|
|
332
332
|
});
|
|
333
333
|
|
|
334
|
+
// Lineage threading (issue #245): `submitPr` persists the origin `root_request_key` on the PR row
|
|
335
|
+
// and carries it onto the convergence instance; `startMerge` reads it back off the row onto the
|
|
336
|
+
// merge instance. A human/webhook submit that supplies no root self-roots on the `pr_key` (its own
|
|
337
|
+
// root), and a resubmit that omits the root must not clobber a root already learned.
|
|
338
|
+
function captureRoot() {
|
|
339
|
+
const stores: Record<string, { rows: unknown[]; key: string }> = {
|
|
340
|
+
pull_requests: { rows: [], key: "pr_key" },
|
|
341
|
+
escalations: { rows: [], key: "id" },
|
|
342
|
+
pr_dependencies: { rows: [], key: "pr_key" },
|
|
343
|
+
};
|
|
344
|
+
const data = {
|
|
345
|
+
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
346
|
+
} as any;
|
|
347
|
+
let captured: unknown;
|
|
348
|
+
const engine = {
|
|
349
|
+
createInstance: (req: { variables?: Record<string, unknown> }) => {
|
|
350
|
+
captured = req.variables?.rootRequestKey;
|
|
351
|
+
return Promise.resolve({ processInstanceKey: "PI-1" });
|
|
352
|
+
},
|
|
353
|
+
} as any;
|
|
354
|
+
return { data, engine, stores, get: () => captured };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
test("submitPr persists root_request_key and threads it onto the convergence instance", async () => {
|
|
358
|
+
await withGithubOff(async () => {
|
|
359
|
+
const { data, engine, stores, get } = captureRoot();
|
|
360
|
+
await submitPr(
|
|
361
|
+
data,
|
|
362
|
+
engine,
|
|
363
|
+
{ repo: "owner/repo", number: 8, url: "https://github.com/owner/repo/pull/8", prKey: "owner/repo#8" },
|
|
364
|
+
[],
|
|
365
|
+
20,
|
|
366
|
+
false,
|
|
367
|
+
"owner/repo#1",
|
|
368
|
+
);
|
|
369
|
+
assertEquals(get(), "owner/repo#1");
|
|
370
|
+
const pr = stores.pull_requests.rows[0] as Record<string, unknown>;
|
|
371
|
+
assertEquals(pr.root_request_key, "owner/repo#1");
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test("submitPr self-roots root_request_key on the pr_key for a human/webhook submit (its own root)", async () => {
|
|
376
|
+
await withGithubOff(async () => {
|
|
377
|
+
const { data, engine, stores, get } = captureRoot();
|
|
378
|
+
await submitPr(data, engine, {
|
|
379
|
+
repo: "owner/repo",
|
|
380
|
+
number: 9,
|
|
381
|
+
url: "https://github.com/owner/repo/pull/9",
|
|
382
|
+
prKey: "owner/repo#9",
|
|
383
|
+
});
|
|
384
|
+
assertEquals(get(), "owner/repo#9");
|
|
385
|
+
const pr = stores.pull_requests.rows[0] as Record<string, unknown>;
|
|
386
|
+
assertEquals(pr.root_request_key, "owner/repo#9");
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test("submitPr resubmit does not clobber an already-learned root when omitted", async () => {
|
|
391
|
+
await withGithubOff(async () => {
|
|
392
|
+
const { data, engine, stores, get } = captureRoot();
|
|
393
|
+
(stores.pull_requests.rows as unknown[]).push({
|
|
394
|
+
pr_key: "owner/repo#8",
|
|
395
|
+
repo: "owner/repo",
|
|
396
|
+
number: 8,
|
|
397
|
+
url: "https://github.com/owner/repo/pull/8",
|
|
398
|
+
status: "abandoned", // terminal -> re-open path
|
|
399
|
+
current_round: 3,
|
|
400
|
+
root_request_key: "owner/repo#1",
|
|
401
|
+
});
|
|
402
|
+
await submitPr(data, engine, {
|
|
403
|
+
repo: "owner/repo",
|
|
404
|
+
number: 8,
|
|
405
|
+
url: "https://github.com/owner/repo/pull/8",
|
|
406
|
+
prKey: "owner/repo#8",
|
|
407
|
+
});
|
|
408
|
+
assertEquals(get(), "owner/repo#1", "resubmit re-threads the learned root");
|
|
409
|
+
const pr = stores.pull_requests.rows[0] as Record<string, unknown>;
|
|
410
|
+
assertEquals(pr.root_request_key, "owner/repo#1");
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("startMerge reads root_request_key off the PR row onto the merge instance", async () => {
|
|
415
|
+
await withGithubOff(async () => {
|
|
416
|
+
const { data, engine, get } = captureRoot();
|
|
417
|
+
await submitPr(
|
|
418
|
+
data,
|
|
419
|
+
engine,
|
|
420
|
+
{ repo: "owner/repo", number: 8, url: "https://github.com/owner/repo/pull/8", prKey: "owner/repo#8" },
|
|
421
|
+
[],
|
|
422
|
+
20,
|
|
423
|
+
false,
|
|
424
|
+
"owner/repo#1",
|
|
425
|
+
);
|
|
426
|
+
await startMerge(data, engine, {
|
|
427
|
+
repo: "owner/repo",
|
|
428
|
+
number: 8,
|
|
429
|
+
url: "https://github.com/owner/repo/pull/8",
|
|
430
|
+
prKey: "owner/repo#8",
|
|
431
|
+
round: 2,
|
|
432
|
+
});
|
|
433
|
+
assertEquals(get(), "owner/repo#1");
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
|
|
334
437
|
// The repository envelope drives the c8ctl harness's isolated workspace provisioning: it is
|
|
335
438
|
// emitted under the reserved `io.nanobpm.agentTask` namespace with the PR head branch as the
|
|
336
439
|
// checkout ref, and omitted entirely when the head branch couldn't be resolved (so the harness
|
package/app/service.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { readFileSync } from "node:fs";
|
|
|
11
11
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
12
12
|
import { abandonUrl, mintAbandonToken, renderAbandonBrief } from "./abandon.ts";
|
|
13
13
|
import { agentSlaTimeout } from "./agentSla.ts";
|
|
14
|
+
import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
|
|
14
15
|
import { deriveFeatureBlockedPatch, deriveFeatureDelivery, deriveFeatureEscalationPatch, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRun, type FeatureRunStatus, featureRuns } from "./feature.ts";
|
|
15
16
|
import {
|
|
16
17
|
classifyMergeability,
|
|
@@ -26,6 +27,7 @@ import {
|
|
|
26
27
|
type PrState,
|
|
27
28
|
requestCopilotReview,
|
|
28
29
|
} from "./github.ts";
|
|
30
|
+
import { pollLineage } from "./lineage.ts";
|
|
29
31
|
import { mergeLanes, readExclusions } from "./mergeExclusion.ts";
|
|
30
32
|
import { freshHeadRunAction, headRunPresenceCount, loadMergeProtocol } from "./mergeProtocol.ts";
|
|
31
33
|
import { type PrLaneDecision, planPrLane, taskDependencyDepths } from "./mergeTrain.ts";
|
|
@@ -122,77 +124,6 @@ export const MERGE_ADMIN = ["1", "true", "on", "yes"].includes(
|
|
|
122
124
|
|
|
123
125
|
const now = () => new Date().toISOString();
|
|
124
126
|
|
|
125
|
-
/** A PR is "done" in exactly these states; everything else (converging, waiting_review,
|
|
126
|
-
* escalated, and the merge-stage waiting_deps/waiting_merge/waiting_lane/queued) is in flight. `converged`
|
|
127
|
-
* is terminal only in review-only mode (AUTO_MERGE off); with auto-merge on, a converged PR
|
|
128
|
-
* transitions into the merge stage and lands as `merged`. The status endpoint and the cancel
|
|
129
|
-
* guard both key off this set. */
|
|
130
|
-
export const TERMINAL_STATUSES: readonly string[] = ["converged", "merged", "abandoned"];
|
|
131
|
-
|
|
132
|
-
/** The derived epic delivery signal (issue #171). Distinct from `plan.status`: `status = done`
|
|
133
|
-
* means "the fan-out finished and ≥1 slice opened a PR, dispatched to convergence" (record-results
|
|
134
|
-
* sets it as soon as one PR opened — other slices may be blocked/skipped), which conflates hand-off
|
|
135
|
-
* with landing. `delivery` reports whether those slice PRs have actually MERGED. */
|
|
136
|
-
export type Delivery = "converging" | "landed";
|
|
137
|
-
|
|
138
|
-
/** Rollup of a plan's slice-PR landing state, derived by joining `plan_tasks.pr_key` →
|
|
139
|
-
* `pull_requests.status`. Pure and read-only — the single source of truth for the denormalised
|
|
140
|
-
* `plans.delivery` / `plans.delivery_label` columns the poller projects. */
|
|
141
|
-
export interface DeliveryRollup {
|
|
142
|
-
delivery: Delivery | null;
|
|
143
|
-
label: string | null;
|
|
144
|
-
prsOpened: number;
|
|
145
|
-
prsMerged: number;
|
|
146
|
-
prsInFlight: number;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/** Derive the delivery signal for one plan from its status and the statuses of its slice PRs.
|
|
150
|
-
*
|
|
151
|
-
* - `converging` — the plan is `done` but ≥1 slice PR is still non-terminal (in flight).
|
|
152
|
-
* - `landed` — every slice PR merged: `prsInFlight == 0 && prsMerged == prsOpened && prsOpened > 0`.
|
|
153
|
-
* - `null` — no positive signal yet: the plan isn't `done`, it opened no PRs, or every PR is
|
|
154
|
-
* terminal but not all merged (some `abandoned`/`converged` — resolved-not-landed, per the issue).
|
|
155
|
-
*
|
|
156
|
-
* A slice's PR status is "in flight" iff it is NOT in `TERMINAL_STATUSES`; `abandoned`/`converged`
|
|
157
|
-
* count as resolved-not-landed (terminal but not merged), so they never make an epic `landed`. */
|
|
158
|
-
export function deriveDelivery(
|
|
159
|
-
planStatus: string,
|
|
160
|
-
prStatuses: readonly string[],
|
|
161
|
-
): DeliveryRollup {
|
|
162
|
-
const prsOpened = prStatuses.length;
|
|
163
|
-
let prsMerged = 0;
|
|
164
|
-
let prsInFlight = 0;
|
|
165
|
-
for (const s of prStatuses) {
|
|
166
|
-
if (s === "merged") prsMerged++;
|
|
167
|
-
else if (!TERMINAL_STATUSES.includes(s)) prsInFlight++;
|
|
168
|
-
}
|
|
169
|
-
// `delivery` is only meaningful once the fan-out has been dispatched (`status = done`) and at
|
|
170
|
-
// least one slice PR exists; otherwise there is nothing to have landed yet.
|
|
171
|
-
if (planStatus !== "done" || prsOpened === 0) {
|
|
172
|
-
return { delivery: null, label: null, prsOpened, prsMerged, prsInFlight };
|
|
173
|
-
}
|
|
174
|
-
if (prsInFlight > 0) {
|
|
175
|
-
return {
|
|
176
|
-
delivery: "converging",
|
|
177
|
-
label: `${prsMerged}/${prsOpened} slices merged, ${prsInFlight} converging`,
|
|
178
|
-
prsOpened,
|
|
179
|
-
prsMerged,
|
|
180
|
-
prsInFlight,
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
if (prsMerged === prsOpened) {
|
|
184
|
-
return {
|
|
185
|
-
delivery: "landed",
|
|
186
|
-
label: `${prsOpened}/${prsOpened} slices merged`,
|
|
187
|
-
prsOpened,
|
|
188
|
-
prsMerged,
|
|
189
|
-
prsInFlight,
|
|
190
|
-
};
|
|
191
|
-
}
|
|
192
|
-
// Every slice PR is terminal but not all merged (some abandoned/converged): resolved, not landed.
|
|
193
|
-
return { delivery: null, label: null, prsOpened, prsMerged, prsInFlight };
|
|
194
|
-
}
|
|
195
|
-
|
|
196
127
|
interface PullRequest {
|
|
197
128
|
pr_key: string;
|
|
198
129
|
repo: string;
|
|
@@ -233,6 +164,13 @@ interface PullRequest {
|
|
|
233
164
|
// workflow stage.
|
|
234
165
|
incident_key: string | null;
|
|
235
166
|
incident_message: string | null;
|
|
167
|
+
// Lineage projection (037_lineage.sql, issue #245): the stable ORIGIN identity (the issue =
|
|
168
|
+
// feature_key / plan_key) threaded onto this PR by `submitPr`, and passed as a `createInstance`
|
|
169
|
+
// variable onto the convergence + merge instances so every descendant carries the root. For a
|
|
170
|
+
// human-opened / webhook PR with no originating request, `submitPr` self-roots it to its own
|
|
171
|
+
// `pr_key` so the Lineage UI join resolves; a legacy NULL is tolerated the same way by the
|
|
172
|
+
// lineage read projection (`pollLineage`), which self-roots on `pr_key`.
|
|
173
|
+
root_request_key: string | null;
|
|
236
174
|
}
|
|
237
175
|
|
|
238
176
|
interface PrDependency {
|
|
@@ -413,6 +351,7 @@ export async function submitPr(
|
|
|
413
351
|
dependsOn: string[] = [],
|
|
414
352
|
maxRounds: number = MAX_ROUNDS,
|
|
415
353
|
convergeOnly = false,
|
|
354
|
+
rootRequestKey: string | null = null,
|
|
416
355
|
) {
|
|
417
356
|
const table = prs(data);
|
|
418
357
|
const existing = await table.get(parsed.prKey);
|
|
@@ -447,6 +386,15 @@ export async function submitPr(
|
|
|
447
386
|
// Cooperative abandon check (#76): reuse the PR's existing capability token across re-runs (and
|
|
448
387
|
// the later merge instance), or mint one for a first submission.
|
|
449
388
|
const abandonToken = existing?.abandon_token ?? mintAbandonToken();
|
|
389
|
+
// Lineage (issue #245): the origin identity threaded onto this PR + its convergence/merge
|
|
390
|
+
// instances. A caller (feature/epic hand-off) supplies it on the first submit; a resubmit that
|
|
391
|
+
// omits it must not clobber a root already learned, so coalesce onto the existing row's value. A
|
|
392
|
+
// human/webhook submit supplies none → the PR is its OWN root, so self-root on its `pr_key`
|
|
393
|
+
// (never NULL): the Lineage page drills into a thread's member PRs by joining
|
|
394
|
+
// `lineage_threads.root_request_key` → `pull_requests.root_request_key`, and a self-rooted
|
|
395
|
+
// thread's key IS the `pr_key`, so leaving the PR row NULL would render an empty PR list for it.
|
|
396
|
+
// Persisting `pr_key` keeps that join honest (the projection self-roots the same key either way).
|
|
397
|
+
const effectiveRoot = rootRequestKey ?? existing?.root_request_key ?? parsed.prKey;
|
|
450
398
|
if (existing) {
|
|
451
399
|
// A prior run (cancelled, converged, or otherwise superseded) may have left an OPEN
|
|
452
400
|
// escalation row. A fresh convergence run must not inherit that stale answer — the
|
|
@@ -474,6 +422,7 @@ export async function submitPr(
|
|
|
474
422
|
converged_at: null,
|
|
475
423
|
merged_at: null,
|
|
476
424
|
abandon_token: abandonToken,
|
|
425
|
+
root_request_key: effectiveRoot,
|
|
477
426
|
updated_at: ts,
|
|
478
427
|
});
|
|
479
428
|
} else {
|
|
@@ -488,6 +437,7 @@ export async function submitPr(
|
|
|
488
437
|
status: "converging",
|
|
489
438
|
current_round: 1,
|
|
490
439
|
abandon_token: abandonToken,
|
|
440
|
+
root_request_key: effectiveRoot,
|
|
491
441
|
created_at: ts,
|
|
492
442
|
updated_at: ts,
|
|
493
443
|
});
|
|
@@ -503,6 +453,10 @@ export async function submitPr(
|
|
|
503
453
|
round: 1,
|
|
504
454
|
maxRounds: clampRounds(maxRounds, MAX_ROUNDS),
|
|
505
455
|
reviewWaitTimeout: REVIEW_WAIT_TIMEOUT,
|
|
456
|
+
// Lineage (issue #245): carry the origin identity onto the convergence instance so every
|
|
457
|
+
// descendant (and any message it correlates) is stitched back to the originating request.
|
|
458
|
+
// A human/webhook PR that is its own root carries its own `pr_key` (never NULL — see above).
|
|
459
|
+
rootRequestKey: effectiveRoot,
|
|
506
460
|
// Per-request review-only override: carried on the instance so `pr.finalize` can stop at
|
|
507
461
|
// `converged` for this PR without handing off to the merge-loop, independent of the global
|
|
508
462
|
// NANO_PR_AUTO_MERGE default. Only ever narrows (never forces merge on when auto-merge is off).
|
|
@@ -539,6 +493,11 @@ export async function startMerge(
|
|
|
539
493
|
if (!existing?.abandon_token) {
|
|
540
494
|
await prs(data).update(pr.prKey, { abandon_token: abandonToken, updated_at: now() });
|
|
541
495
|
}
|
|
496
|
+
// Lineage (issue #245): the origin identity was persisted on the PR row at submit; carry it onto
|
|
497
|
+
// the merge instance too so the merge stage stays stitched to the originating request. A
|
|
498
|
+
// self-rooted PR carries its own `pr_key`; `?? null` only tolerates a legacy row predating the
|
|
499
|
+
// column.
|
|
500
|
+
const rootRequestKey = existing?.root_request_key ?? null;
|
|
542
501
|
const abUrl = abandonUrl(abandonToken);
|
|
543
502
|
// Resolve the PR head branch so the merge agents (fix-ci, rebase) get an isolated clone checked
|
|
544
503
|
// out on it (same host-git provisioning path as review-round). Best-effort: an unresolved head
|
|
@@ -566,6 +525,8 @@ export async function startMerge(
|
|
|
566
525
|
rebaseRound: 0,
|
|
567
526
|
rebaseMax: MAX_REBASE_ROUNDS,
|
|
568
527
|
agentSlaTimeout: AGENT_SLA_TIMEOUT,
|
|
528
|
+
// Lineage (issue #245): thread the origin identity onto the merge instance (see startMerge).
|
|
529
|
+
rootRequestKey,
|
|
569
530
|
abandonUrl: abUrl,
|
|
570
531
|
abandonBrief: renderAbandonBrief(abUrl),
|
|
571
532
|
// Host-git provisioning (c8ctl): same repository envelope as the convergence loop, so the
|
|
@@ -1610,6 +1571,7 @@ export async function pollOnce(
|
|
|
1610
1571
|
await pollWaveGates(data, engine, token);
|
|
1611
1572
|
await pollDelivery(data);
|
|
1612
1573
|
await pollFeatureDelivery(data);
|
|
1574
|
+
await pollLineage(data);
|
|
1613
1575
|
await pollFeatureEscalations(data, engine);
|
|
1614
1576
|
await pollFeatureBlocked(data, engine);
|
|
1615
1577
|
await pollUserTasks(data, engine);
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
-- Lineage projection (issue #245): thread user intent → progress as one arc.
|
|
2
|
+
--
|
|
3
|
+
-- The lineage already exists in the data layer (feature_runs.pr_key ↔ pull_requests.pr_key,
|
|
4
|
+
-- plan_tasks.pr_key ↔ pull_requests.pr_key, and message correlation by prKey/planKey) but is not
|
|
5
|
+
-- projected as a single narrative. This migration adds the two pieces the read model needs:
|
|
6
|
+
--
|
|
7
|
+
-- 1. `pull_requests.root_request_key` — the stable ORIGIN identity (the issue = feature_key /
|
|
8
|
+
-- plan_key) threaded onto every PR a request spawns. `submitPr` persists it and passes it as a
|
|
9
|
+
-- `createInstance` variable onto the convergence + merge instances; `startMerge` reads it back
|
|
10
|
+
-- off the row. A human-opened / webhook PR with no originating request is self-rooted by
|
|
11
|
+
-- `submitPr` (`root_request_key = pr_key`); a legacy pre-migration NULL is backfilled below to
|
|
12
|
+
-- the SAME root submitPr would persist (feature/epic origin key, else self-rooted pr_key), and
|
|
13
|
+
-- the projection tolerates any residual NULL by self-rooting on `pr_key`.
|
|
14
|
+
--
|
|
15
|
+
-- 2. `lineage_threads` — a DERIVED read table, one row per `root_request_key` (or a self-rooted
|
|
16
|
+
-- PR's own key), recomputed idempotently each poll pass by `pollLineage` (app/lineage.ts) from
|
|
17
|
+
-- the existing gateway joins. It stitches `request → implementation run → PR(s) → convergence →
|
|
18
|
+
-- merge → outcome` into one ordered thread, exposing the active frontier (`stage`/`stage_label`/
|
|
19
|
+
-- `process_key`) plus whether the whole arc has settled (`active`). Urban's datasource cannot
|
|
20
|
+
-- read a SQL VIEW (gateway.ts schema() whitelists only type='table'), so — following the
|
|
21
|
+
-- codebase convention for read-model projections (`plans.delivery`, `feature_runs.delivery_label`)
|
|
22
|
+
-- — this is a denormalised flat table the schema-driven pages read directly.
|
|
23
|
+
--
|
|
24
|
+
-- Forward-only, additive (expand): a nullable column with no default, a new table/indexes, and an
|
|
25
|
+
-- origin-aware backfill of the new column. Numbered after the current highest prefix (036). The
|
|
26
|
+
-- runner wraps each file in its own transaction, so this file must NOT contain BEGIN/COMMIT.
|
|
27
|
+
ALTER TABLE pull_requests ADD COLUMN root_request_key TEXT;
|
|
28
|
+
CREATE INDEX IF NOT EXISTS idx_pr_root ON pull_requests(root_request_key);
|
|
29
|
+
|
|
30
|
+
-- Backfill: every pre-migration PR row has root_request_key = NULL, which breaks the Lineage page's
|
|
31
|
+
-- `lineage_threads.root_request_key → pull_requests.root_request_key` drill-down join (a NULL never
|
|
32
|
+
-- matches a thread key), rendering an empty PR list. Backfill each row with the SAME root `submitPr`
|
|
33
|
+
-- persists going forward, so the join resolves for already-tracked PRs at deploy time too:
|
|
34
|
+
-- 1. a PR spawned by a feature run roots on its origin `feature_runs.feature_key` (submitPr passes
|
|
35
|
+
-- `featureKey` — workers/converge-feature);
|
|
36
|
+
-- 2. a PR spawned by an epic slice roots on its origin `plan_tasks.plan_key` (submitPr passes
|
|
37
|
+
-- `planKey` — workers/record-wave);
|
|
38
|
+
-- 3. any remaining origin-less PR (human/webhook, or an origin row that no longer survives) is
|
|
39
|
+
-- self-rooted on its own `pr_key`, exactly as `submitPr` self-roots a human/webhook PR.
|
|
40
|
+
-- Origin-aware steps run first so a tracked PR keeps its true origin key rather than being self-rooted
|
|
41
|
+
-- (which would orphan it from its feature/epic thread in the drill-down). All idempotent.
|
|
42
|
+
UPDATE pull_requests SET root_request_key = (
|
|
43
|
+
SELECT fr.feature_key FROM feature_runs fr WHERE fr.pr_key = pull_requests.pr_key
|
|
44
|
+
)
|
|
45
|
+
WHERE root_request_key IS NULL
|
|
46
|
+
AND EXISTS (SELECT 1 FROM feature_runs fr WHERE fr.pr_key = pull_requests.pr_key);
|
|
47
|
+
UPDATE pull_requests SET root_request_key = (
|
|
48
|
+
SELECT pt.plan_key FROM plan_tasks pt WHERE pt.pr_key = pull_requests.pr_key
|
|
49
|
+
)
|
|
50
|
+
WHERE root_request_key IS NULL
|
|
51
|
+
AND EXISTS (SELECT 1 FROM plan_tasks pt WHERE pt.pr_key = pull_requests.pr_key);
|
|
52
|
+
UPDATE pull_requests SET root_request_key = pr_key WHERE root_request_key IS NULL;
|
|
53
|
+
|
|
54
|
+
CREATE TABLE IF NOT EXISTS lineage_threads (
|
|
55
|
+
root_request_key TEXT PRIMARY KEY, -- origin issue key (feature_key/plan_key), or a self-rooted pr_key
|
|
56
|
+
kind TEXT NOT NULL, -- feature | epic | pr
|
|
57
|
+
title TEXT, -- best-effort origin/PR title
|
|
58
|
+
issue_url TEXT, -- origin issue URL (NULL for self-rooted PRs)
|
|
59
|
+
stage TEXT NOT NULL, -- active-frontier machine label (implementing|converging|merged|…)
|
|
60
|
+
stage_label TEXT, -- human narrative rollup for the timeline
|
|
61
|
+
process_key TEXT, -- active-frontier process instance (for the processExplorer link)
|
|
62
|
+
pr_keys TEXT, -- JSON array of the member PR keys (fan-out for epics)
|
|
63
|
+
pr_count INTEGER NOT NULL DEFAULT 0,
|
|
64
|
+
active INTEGER NOT NULL DEFAULT 1, -- 1 while the arc has an active frontier, 0 once settled
|
|
65
|
+
created_at TEXT NOT NULL,
|
|
66
|
+
updated_at TEXT NOT NULL
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
CREATE INDEX IF NOT EXISTS idx_lineage_active ON lineage_threads(active);
|
package/openapi.yaml
CHANGED
|
@@ -99,6 +99,99 @@ components:
|
|
|
99
99
|
type: array
|
|
100
100
|
items:
|
|
101
101
|
$ref: "#/components/schemas/ActivePr"
|
|
102
|
+
LineagePrView:
|
|
103
|
+
type: object
|
|
104
|
+
description: A member PR of a lineage thread (issue #245).
|
|
105
|
+
additionalProperties: false
|
|
106
|
+
required:
|
|
107
|
+
- prKey
|
|
108
|
+
- title
|
|
109
|
+
- url
|
|
110
|
+
- status
|
|
111
|
+
- round
|
|
112
|
+
- processKey
|
|
113
|
+
- outcome
|
|
114
|
+
properties:
|
|
115
|
+
prKey:
|
|
116
|
+
type: string
|
|
117
|
+
title:
|
|
118
|
+
type: string
|
|
119
|
+
nullable: true
|
|
120
|
+
url:
|
|
121
|
+
type: string
|
|
122
|
+
status:
|
|
123
|
+
type: string
|
|
124
|
+
round:
|
|
125
|
+
type: integer
|
|
126
|
+
processKey:
|
|
127
|
+
type: string
|
|
128
|
+
nullable: true
|
|
129
|
+
outcome:
|
|
130
|
+
type: string
|
|
131
|
+
nullable: true
|
|
132
|
+
LineageThreadView:
|
|
133
|
+
type: object
|
|
134
|
+
description: One stitched intent → progress arc, keyed by its origin request (issue #245).
|
|
135
|
+
additionalProperties: false
|
|
136
|
+
required:
|
|
137
|
+
- rootRequestKey
|
|
138
|
+
- kind
|
|
139
|
+
- title
|
|
140
|
+
- issueUrl
|
|
141
|
+
- stage
|
|
142
|
+
- stageLabel
|
|
143
|
+
- processKey
|
|
144
|
+
- prKeys
|
|
145
|
+
- prCount
|
|
146
|
+
- active
|
|
147
|
+
- prs
|
|
148
|
+
properties:
|
|
149
|
+
rootRequestKey:
|
|
150
|
+
type: string
|
|
151
|
+
description: The origin issue key (feature_key/plan_key), or a self-rooted pr_key.
|
|
152
|
+
kind:
|
|
153
|
+
type: string
|
|
154
|
+
enum: [feature, epic, pr]
|
|
155
|
+
title:
|
|
156
|
+
type: string
|
|
157
|
+
nullable: true
|
|
158
|
+
issueUrl:
|
|
159
|
+
type: string
|
|
160
|
+
nullable: true
|
|
161
|
+
stage:
|
|
162
|
+
type: string
|
|
163
|
+
description: The active-frontier stage (implementing|converging|merged|…).
|
|
164
|
+
stageLabel:
|
|
165
|
+
type: string
|
|
166
|
+
processKey:
|
|
167
|
+
type: string
|
|
168
|
+
nullable: true
|
|
169
|
+
description: The active-frontier process instance key (for the processExplorer link).
|
|
170
|
+
prKeys:
|
|
171
|
+
type: array
|
|
172
|
+
items:
|
|
173
|
+
type: string
|
|
174
|
+
prCount:
|
|
175
|
+
type: integer
|
|
176
|
+
active:
|
|
177
|
+
type: boolean
|
|
178
|
+
description: True while the arc has an active frontier; false once every stage has settled.
|
|
179
|
+
prs:
|
|
180
|
+
type: array
|
|
181
|
+
items:
|
|
182
|
+
$ref: "#/components/schemas/LineagePrView"
|
|
183
|
+
LineageList:
|
|
184
|
+
type: object
|
|
185
|
+
required:
|
|
186
|
+
- count
|
|
187
|
+
- threads
|
|
188
|
+
properties:
|
|
189
|
+
count:
|
|
190
|
+
type: integer
|
|
191
|
+
threads:
|
|
192
|
+
type: array
|
|
193
|
+
items:
|
|
194
|
+
$ref: "#/components/schemas/LineageThreadView"
|
|
102
195
|
AgenticSupplyWorker:
|
|
103
196
|
type: object
|
|
104
197
|
description: One connected worker in the supply mirror (H5 cockpit; sourced from the H1 presence registry).
|
|
@@ -936,6 +1029,33 @@ paths:
|
|
|
936
1029
|
application/json:
|
|
937
1030
|
schema:
|
|
938
1031
|
$ref: "#/components/schemas/ErrorBody"
|
|
1032
|
+
/lineage:
|
|
1033
|
+
get:
|
|
1034
|
+
operationId: getLineage
|
|
1035
|
+
summary: "Intent to progress lineage projection (issue #245): every request stitched into one arc (request, implementation, PRs, convergence, merge, outcome), active frontier first. Pass root to fetch a single origin's thread."
|
|
1036
|
+
security:
|
|
1037
|
+
- hookSecret: []
|
|
1038
|
+
- {}
|
|
1039
|
+
parameters:
|
|
1040
|
+
- name: root
|
|
1041
|
+
in: query
|
|
1042
|
+
required: false
|
|
1043
|
+
schema:
|
|
1044
|
+
type: string
|
|
1045
|
+
description: "An origin request key (feature_key/plan_key) or a self-rooted pr_key. When set, the response contains just that thread (empty if unknown)."
|
|
1046
|
+
responses:
|
|
1047
|
+
"200":
|
|
1048
|
+
description: The stitched lineage threads.
|
|
1049
|
+
content:
|
|
1050
|
+
application/json:
|
|
1051
|
+
schema:
|
|
1052
|
+
$ref: "#/components/schemas/LineageList"
|
|
1053
|
+
"401":
|
|
1054
|
+
description: Missing/invalid shared secret (only when NANO_PR_WEBHOOK_SECRET is set).
|
|
1055
|
+
content:
|
|
1056
|
+
application/json:
|
|
1057
|
+
schema:
|
|
1058
|
+
$ref: "#/components/schemas/ErrorBody"
|
|
939
1059
|
/agentic/supply:
|
|
940
1060
|
get:
|
|
941
1061
|
operationId: getAgenticSupply
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Tests for GET /app/api/lineage → operation `getLineage` (issue #245). Covers the stitched list
|
|
2
|
+
// projection, the `root` narrowing, an unknown root (empty), and the optional shared-secret guard.
|
|
3
|
+
// A minimal in-memory DataLayer backs the derivation (it reads the feature_runs / plans / plan_tasks
|
|
4
|
+
// / pull_requests tables via `.all()` / `.find()`).
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import { assert, assertEquals } from "#test-assert";
|
|
7
|
+
import type { AppApi } from "@nanobpm/urban";
|
|
8
|
+
import { noopLog } from "../test/log.ts";
|
|
9
|
+
import handler from "./getLineage.ts";
|
|
10
|
+
|
|
11
|
+
function memApp(stores: Record<string, any[]>): AppApi {
|
|
12
|
+
const table = (name: string) => {
|
|
13
|
+
const rows = stores[name] ?? [];
|
|
14
|
+
return {
|
|
15
|
+
async all() {
|
|
16
|
+
return rows.slice();
|
|
17
|
+
},
|
|
18
|
+
async find(where: Record<string, unknown> = {}) {
|
|
19
|
+
return rows.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
return { data: { table }, log: noopLog() } as any as AppApi;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function input(query: Record<string, string> = {}, headers: Record<string, string> = {}) {
|
|
27
|
+
return {
|
|
28
|
+
req: {
|
|
29
|
+
method: "GET",
|
|
30
|
+
path: "/app/api/lineage",
|
|
31
|
+
query: new URLSearchParams(query),
|
|
32
|
+
headers: new Headers(headers),
|
|
33
|
+
text: async () => "",
|
|
34
|
+
} as any,
|
|
35
|
+
params: {},
|
|
36
|
+
query,
|
|
37
|
+
body: undefined,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function fixture(): Record<string, any[]> {
|
|
42
|
+
return {
|
|
43
|
+
feature_runs: [
|
|
44
|
+
{ feature_key: "o/r#1", title: "Feature", issue_url: "u1", status: "converging", process_key: "f1", pr_key: "o/r#100" },
|
|
45
|
+
],
|
|
46
|
+
plans: [
|
|
47
|
+
{ plan_key: "o/r#2", title: "Epic", issue_url: "u2", status: "done", process_key: "e1" },
|
|
48
|
+
],
|
|
49
|
+
plan_tasks: [
|
|
50
|
+
{ id: 1, plan_key: "o/r#2", pr_key: "o/r#200" },
|
|
51
|
+
{ id: 2, plan_key: "o/r#2", pr_key: "o/r#201" },
|
|
52
|
+
],
|
|
53
|
+
pull_requests: [
|
|
54
|
+
{ pr_key: "o/r#100", title: "Feat PR", url: "x", status: "converging", current_round: 2, process_key: "c1", outcome: null, root_request_key: "o/r#1" },
|
|
55
|
+
{ pr_key: "o/r#200", title: "S1", url: "x", status: "merged", current_round: 1, process_key: "c2", outcome: null, root_request_key: "o/r#2" },
|
|
56
|
+
{ pr_key: "o/r#201", title: "S2", url: "x", status: "converging", current_round: 1, process_key: "c3", outcome: null, root_request_key: "o/r#2" },
|
|
57
|
+
{ pr_key: "o/r#300", title: "Human PR", url: "x", status: "merged", current_round: 1, process_key: "c4", outcome: null, root_request_key: null },
|
|
58
|
+
],
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
test("lists every stitched thread, active frontier first", async () => {
|
|
63
|
+
const res = (await handler(input(), memApp(fixture()))) as any;
|
|
64
|
+
assertEquals(res.status, 200);
|
|
65
|
+
assertEquals(res.body.count, 3);
|
|
66
|
+
// Active threads (feature + epic) sort ahead of the settled human PR.
|
|
67
|
+
assert(res.body.threads[res.body.threads.length - 1].active === false);
|
|
68
|
+
const kinds = res.body.threads.map((t: any) => t.kind).sort();
|
|
69
|
+
assertEquals(kinds, ["epic", "feature", "pr"]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("narrows to a single origin thread via ?root=", async () => {
|
|
73
|
+
const res = (await handler(input({ root: "o/r#2" }), memApp(fixture()))) as any;
|
|
74
|
+
assertEquals(res.status, 200);
|
|
75
|
+
assertEquals(res.body.count, 1);
|
|
76
|
+
const t = res.body.threads[0];
|
|
77
|
+
assertEquals(t.kind, "epic");
|
|
78
|
+
assertEquals(t.rootRequestKey, "o/r#2");
|
|
79
|
+
assertEquals(t.prCount, 2);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("unknown root returns an empty thread list", async () => {
|
|
83
|
+
const res = (await handler(input({ root: "o/r#999" }), memApp(fixture()))) as any;
|
|
84
|
+
assertEquals(res.status, 200);
|
|
85
|
+
assertEquals(res.body.count, 0);
|
|
86
|
+
assertEquals(res.body.threads, []);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("shared-secret guard rejects a missing secret when configured", async () => {
|
|
90
|
+
const prev = process.env["NANO_PR_WEBHOOK_SECRET"];
|
|
91
|
+
process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cr3t";
|
|
92
|
+
try {
|
|
93
|
+
const mod = await import(`./getLineage.ts?guard=${Date.now()}`);
|
|
94
|
+
const guarded = mod.default as typeof handler;
|
|
95
|
+
const app = memApp(fixture());
|
|
96
|
+
const bad = (await guarded(input(), app)) as any;
|
|
97
|
+
assertEquals(bad.status, 401);
|
|
98
|
+
const ok = (await guarded(input({}, { "x-hook-secret": "s3cr3t" }), app)) as any;
|
|
99
|
+
assertEquals(ok.status, 200);
|
|
100
|
+
assert("count" in ok.body);
|
|
101
|
+
} finally {
|
|
102
|
+
if (prev === undefined) delete process.env["NANO_PR_WEBHOOK_SECRET"];
|
|
103
|
+
else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
|
|
104
|
+
}
|
|
105
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// GET /app/api/lineage → operationId `getLineage` (issue #245). Project user intent → progress as
|
|
2
|
+
// one lineage arc per origin request, so an operator or an external harness can see, in one place,
|
|
3
|
+
// that "issue #123 → implementation → PR #45 → converging (round 2) → merged" is a single thread of
|
|
4
|
+
// their intent — without opening the DB or joining the tables by hand. Read-only projection over the
|
|
5
|
+
// existing record-gateway joins (the same derivation the `lineage_threads` read table is built from).
|
|
6
|
+
//
|
|
7
|
+
// Optional `root` query param narrows to a single origin's thread (feature_key/plan_key, or a
|
|
8
|
+
// self-rooted pr_key for a human/webhook PR). Absent → every thread, active frontier first.
|
|
9
|
+
//
|
|
10
|
+
// The optional shared-secret guard stays HERE (the runtime does not enforce OpenAPI `security`):
|
|
11
|
+
// when NANO_PR_WEBHOOK_SECRET is set, callers must present it via the x-hook-secret header.
|
|
12
|
+
import { getLineage, listLineage } from "../app/lineage.ts";
|
|
13
|
+
import { envVar } from "../app/version.ts";
|
|
14
|
+
import { defineOperation } from "../nano-generated/operations.ts";
|
|
15
|
+
|
|
16
|
+
const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
|
|
17
|
+
|
|
18
|
+
export default defineOperation("getLineage", async ({ query, req }, app) => {
|
|
19
|
+
if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
|
|
20
|
+
app.log.warn("getLineage rejected: missing/invalid shared secret");
|
|
21
|
+
return { status: 401, body: { error: "unauthorized" } };
|
|
22
|
+
}
|
|
23
|
+
const rawRoot = query.root;
|
|
24
|
+
const root = typeof rawRoot === "string" ? rawRoot.trim() : "";
|
|
25
|
+
if (root) {
|
|
26
|
+
const thread = await getLineage(app.data, root);
|
|
27
|
+
const threads = thread ? [thread] : [];
|
|
28
|
+
return { status: 200, body: { count: threads.length, threads } };
|
|
29
|
+
}
|
|
30
|
+
const threads = await listLineage(app.data);
|
|
31
|
+
return { status: 200, body: { count: threads.length, threads } };
|
|
32
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.80.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",
|
package/pages/cockpit.page.json
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"title": "Nano Workforce",
|
|
11
11
|
"items": [
|
|
12
12
|
{ "label": "Overview", "page": "overview" },
|
|
13
|
+
{ "label": "Lineage", "page": "lineage" },
|
|
13
14
|
{ "label": "Convergence", "page": "home" },
|
|
14
15
|
{ "label": "Epics", "page": "epic" },
|
|
15
16
|
{ "label": "Feature", "page": "feature" },
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"title": "Nano Workforce",
|
|
12
12
|
"items": [
|
|
13
13
|
{ "label": "Overview", "page": "overview" },
|
|
14
|
+
{ "label": "Lineage", "page": "lineage" },
|
|
14
15
|
{ "label": "Convergence", "page": "home" },
|
|
15
16
|
{ "label": "Epics", "page": "epic" },
|
|
16
17
|
{ "label": "Feature", "page": "feature" },
|