@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.
@@ -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.78.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",
@@ -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" },