@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
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// nano-workforce — replay-by-reseed / fork of a transcript log (ADR 0056, #251).
|
|
2
|
+
//
|
|
3
|
+
// The H3 read path (#222) can RESUME the same stream from an offset (reattach parity), but it cannot
|
|
4
|
+
// FORK: seed a NEW stream from an existing log so an exited agent's session can be branched or re-run
|
|
5
|
+
// from a chosen point ("what-if" a different continuation). dsh gets this for free because a session IS
|
|
6
|
+
// its append-only log, so forking is just re-seeding a new session from an existing log up to offset N.
|
|
7
|
+
// This module gives the transcript store the same capability WITHOUT touching the store package: it
|
|
8
|
+
// reads the source log and re-records it into a fresh stream through the store's own idempotent,
|
|
9
|
+
// offset-keyed {@link TranscriptStore.record} — so the fork is itself append-only and offset-parity
|
|
10
|
+
// with its source, and replays through the SAME resume-from-offset read path a native stream uses.
|
|
11
|
+
//
|
|
12
|
+
// Invariants preserved (ADR 0056): app-tier only, append-only (we only ever `record`, never mutate),
|
|
13
|
+
// advisory (a fork is a new advisory transcript — it gates no BPMN flow), and offset/resume wire-shape
|
|
14
|
+
// parity (the fork keeps the source offsets, so a reattach behaves identically on the branch).
|
|
15
|
+
|
|
16
|
+
import type { TranscriptChunk, TranscriptLifecycle, TranscriptStore, TranscriptStream } from "@nanobpm/agentic/transcript";
|
|
17
|
+
|
|
18
|
+
/** Raised when a fork/reseed cannot proceed — the source is missing, or the target already exists. */
|
|
19
|
+
export class TranscriptForkError extends Error {
|
|
20
|
+
readonly source: string;
|
|
21
|
+
readonly target: string;
|
|
22
|
+
constructor(source: string, target: string, message: string) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = "TranscriptForkError";
|
|
25
|
+
this.source = source;
|
|
26
|
+
this.target = target;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Options controlling how a source log is reseeded into a new stream. */
|
|
31
|
+
export interface ForkTranscriptOptions {
|
|
32
|
+
/**
|
|
33
|
+
* Seed only chunks with `offset <= throughOffset` (inclusive) — the point the branch diverges from.
|
|
34
|
+
* Omit to fork the WHOLE source log (every retained chunk). A `throughOffset` below the source's
|
|
35
|
+
* oldest retained offset yields an empty fork (a valid, if trivial, branch point).
|
|
36
|
+
*/
|
|
37
|
+
readonly throughOffset?: number;
|
|
38
|
+
/**
|
|
39
|
+
* The forked stream's retention lifecycle. Defaults to `ephemeral` — a fork is a captured branch,
|
|
40
|
+
* retained-whole then swept like any completed session, not a growing live stream.
|
|
41
|
+
*/
|
|
42
|
+
readonly lifecycle?: TranscriptLifecycle;
|
|
43
|
+
/**
|
|
44
|
+
* Allow reseeding into a target that already exists. Off by default: forking onto a populated stream
|
|
45
|
+
* would interleave two logs' bytes and defeat offset-parity, so we refuse rather than clobber. When
|
|
46
|
+
* on, seeding is still idempotent (offset-keyed), so re-running the SAME fork is a safe no-op — but
|
|
47
|
+
* the existing target must already hold exactly this seed prefix (same offsets, same chunk bytes,
|
|
48
|
+
* same lifecycle); a target that diverges from the prefix throws rather than silently interleaving.
|
|
49
|
+
*/
|
|
50
|
+
readonly allowExisting?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The outcome of a {@link forkTranscript}: the new stream, how many chunks it seeded, and its window. */
|
|
54
|
+
export interface ForkResult {
|
|
55
|
+
/** The forked stream id (the `target` argument). */
|
|
56
|
+
readonly stream: string;
|
|
57
|
+
/** The source stream the fork was seeded from. */
|
|
58
|
+
readonly source: string;
|
|
59
|
+
/** Number of chunks newly persisted into the fork. */
|
|
60
|
+
readonly seeded: number;
|
|
61
|
+
/** The highest source offset included in the fork (undefined when the fork is empty). */
|
|
62
|
+
readonly throughOffset?: number;
|
|
63
|
+
/** The forked stream's metadata after seeding. */
|
|
64
|
+
readonly meta: TranscriptStream;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Fork a transcript: seed a NEW stream (`target`) from an existing log (`source`) up to a chosen offset,
|
|
69
|
+
* so an exited session can be branched and replayed independently.
|
|
70
|
+
*
|
|
71
|
+
* The fork keeps the SOURCE offsets (offset-parity), so it resumes through the identical
|
|
72
|
+
* resume-from-offset read path a native stream uses. It reads the source's retained window
|
|
73
|
+
* (`store.read`), takes the prefix at or below `throughOffset` (default: the whole log), and re-records
|
|
74
|
+
* it into `target` via the store's idempotent offset-keyed `record` — so the operation is append-only
|
|
75
|
+
* and safe to re-run. The branch is fully independent of its source thereafter: appending to either
|
|
76
|
+
* stream never affects the other.
|
|
77
|
+
*
|
|
78
|
+
* Throws {@link TranscriptForkError} when the source has no transcript, when the target already
|
|
79
|
+
* exists and `allowExisting` is not set, or when `allowExisting` is set but the existing target does
|
|
80
|
+
* not already match the reseed prefix exactly (divergent chunk bytes/offsets or a different lifecycle).
|
|
81
|
+
*/
|
|
82
|
+
export function forkTranscript(
|
|
83
|
+
store: TranscriptStore,
|
|
84
|
+
source: string,
|
|
85
|
+
target: string,
|
|
86
|
+
options: ForkTranscriptOptions = {},
|
|
87
|
+
): ForkResult {
|
|
88
|
+
if (source === target) {
|
|
89
|
+
throw new TranscriptForkError(source, target, "cannot fork a stream onto itself");
|
|
90
|
+
}
|
|
91
|
+
if (store.get(source) === undefined) {
|
|
92
|
+
throw new TranscriptForkError(source, target, `source stream "${source}" has no transcript to fork`);
|
|
93
|
+
}
|
|
94
|
+
const existing = store.get(target);
|
|
95
|
+
if (existing !== undefined && !options.allowExisting) {
|
|
96
|
+
throw new TranscriptForkError(source, target, `target stream "${target}" already exists (pass allowExisting to reseed it)`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const lifecycle: TranscriptLifecycle = options.lifecycle ?? "ephemeral";
|
|
100
|
+
const through = options.throughOffset;
|
|
101
|
+
const chunks: TranscriptChunk[] = store
|
|
102
|
+
.read(source)
|
|
103
|
+
.filter((c) => through === undefined || c.offset <= through);
|
|
104
|
+
|
|
105
|
+
// Reseeding onto an EXISTING target (allowExisting) is only safe when that target already holds
|
|
106
|
+
// exactly the prefix we are about to seed. `record()` is offset-keyed and idempotent, so it silently
|
|
107
|
+
// no-ops any offset already present — if the existing chunk at that offset differs (or the target
|
|
108
|
+
// carries offsets outside this prefix, or a different lifecycle), the reseed would leave a stream
|
|
109
|
+
// that is a MIXTURE of the prior data and the seed, breaking the documented offset-parity invariant.
|
|
110
|
+
// Validate the overlap before writing and refuse rather than clobber/interleave.
|
|
111
|
+
if (existing !== undefined) {
|
|
112
|
+
if (existing.lifecycle !== lifecycle) {
|
|
113
|
+
throw new TranscriptForkError(
|
|
114
|
+
source,
|
|
115
|
+
target,
|
|
116
|
+
`target stream "${target}" already exists with lifecycle "${existing.lifecycle}", cannot reseed as "${lifecycle}"`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
const seedByOffset = new Map(chunks.map((c) => [c.offset, c.chunk]));
|
|
120
|
+
for (const c of store.read(target)) {
|
|
121
|
+
const expected = seedByOffset.get(c.offset);
|
|
122
|
+
if (expected === undefined || expected !== c.chunk) {
|
|
123
|
+
throw new TranscriptForkError(
|
|
124
|
+
source,
|
|
125
|
+
target,
|
|
126
|
+
`target stream "${target}" already contains data that does not match the reseed prefix at offset ${c.offset}; refusing to interleave`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Open the fork explicitly so an empty fork (throughOffset predating the log) is still a real,
|
|
133
|
+
// listed stream under its own lifecycle rather than a phantom — mirrors the store's open-then-record.
|
|
134
|
+
store.open(target, lifecycle);
|
|
135
|
+
const seeded = chunks.length > 0 ? store.record(target, chunks, lifecycle) : 0;
|
|
136
|
+
|
|
137
|
+
const meta = store.get(target);
|
|
138
|
+
if (meta === undefined) {
|
|
139
|
+
// Defensive: open() above guarantees a row, so this only fires on a store contract breach.
|
|
140
|
+
throw new TranscriptForkError(source, target, `fork of "${source}" into "${target}" did not persist a stream`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const result: ForkResult = {
|
|
144
|
+
stream: target,
|
|
145
|
+
source,
|
|
146
|
+
seeded,
|
|
147
|
+
meta,
|
|
148
|
+
};
|
|
149
|
+
const last = chunks.at(-1);
|
|
150
|
+
return last !== undefined ? { ...result, throughOffset: last.offset } : result;
|
|
151
|
+
}
|
|
@@ -17,11 +17,12 @@
|
|
|
17
17
|
import type { TranscriptChunk, TranscriptStore, TranscriptStream } from "@nanobpm/agentic/transcript";
|
|
18
18
|
import type { AgenticTranscript, AgenticTranscriptData } from "../../nano-generated/api-io.d.ts";
|
|
19
19
|
import { type CorrelationRegistry, jobKeyOfStream } from "./correlation.ts";
|
|
20
|
+
import { utf8ByteLength } from "./transcript-events.ts";
|
|
20
21
|
|
|
21
22
|
/** Total captured bytes across a set of retained chunks (UTF-8, the on-the-wire terminal encoding). */
|
|
22
23
|
export function byteLengthOf(chunks: readonly TranscriptChunk[]): number {
|
|
23
24
|
let total = 0;
|
|
24
|
-
for (const c of chunks) total +=
|
|
25
|
+
for (const c of chunks) total += utf8ByteLength(c.chunk);
|
|
25
26
|
return total;
|
|
26
27
|
}
|
|
27
28
|
|
package/app/delivery.test.ts
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
import { test } from "node:test";
|
|
7
7
|
import { assert, assertEquals } from "#test-assert";
|
|
8
8
|
import type { DataLayer } from "@nanobpm/urban";
|
|
9
|
-
import { deriveDelivery,
|
|
9
|
+
import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
|
|
10
|
+
import { pollDelivery } from "./service.ts";
|
|
10
11
|
|
|
11
12
|
// A tiny in-memory record gateway (all/find/update/insert), mirroring the fake-app style used
|
|
12
13
|
// across the app tests (see app/taskDelta.test.ts), enough to exercise the `pollDelivery` projection.
|
package/app/delivery.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Canonical PR-terminal / epic-delivery derivation, extracted from `service.ts` so that the
|
|
2
|
+
// lineage read-projection (`app/lineage.ts`) can reuse `deriveDelivery`/`TERMINAL_STATUSES`
|
|
3
|
+
// without importing `service.ts` — which imports `pollLineage` back from `lineage.ts` and would
|
|
4
|
+
// otherwise form a `service.ts` ↔ `lineage.ts` module cycle (fragile in ESM). This is the single
|
|
5
|
+
// source of truth for both; `service.ts` re-uses it and remains free to import `pollLineage`.
|
|
6
|
+
|
|
7
|
+
/** A PR is "done" in exactly these states; everything else (converging, waiting_review,
|
|
8
|
+
* escalated, and the merge-stage waiting_deps/waiting_merge/waiting_lane/queued) is in flight. `converged`
|
|
9
|
+
* is terminal only in review-only mode (AUTO_MERGE off); with auto-merge on, a converged PR
|
|
10
|
+
* transitions into the merge stage and lands as `merged`. The status endpoint and the cancel
|
|
11
|
+
* guard both key off this set. */
|
|
12
|
+
export const TERMINAL_STATUSES: readonly string[] = ["converged", "merged", "abandoned"];
|
|
13
|
+
|
|
14
|
+
/** The derived epic delivery signal (issue #171). Distinct from `plan.status`: `status = done`
|
|
15
|
+
* means "the fan-out finished and ≥1 slice opened a PR, dispatched to convergence" (record-results
|
|
16
|
+
* sets it as soon as one PR opened — other slices may be blocked/skipped), which conflates hand-off
|
|
17
|
+
* with landing. `delivery` reports whether those slice PRs have actually MERGED. */
|
|
18
|
+
export type Delivery = "converging" | "landed";
|
|
19
|
+
|
|
20
|
+
/** Rollup of a plan's slice-PR landing state, derived by joining `plan_tasks.pr_key` →
|
|
21
|
+
* `pull_requests.status`. Pure and read-only — the single source of truth for the denormalised
|
|
22
|
+
* `plans.delivery` / `plans.delivery_label` columns the poller projects. */
|
|
23
|
+
export interface DeliveryRollup {
|
|
24
|
+
delivery: Delivery | null;
|
|
25
|
+
label: string | null;
|
|
26
|
+
prsOpened: number;
|
|
27
|
+
prsMerged: number;
|
|
28
|
+
prsInFlight: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Derive the delivery signal for one plan from its status and the statuses of its slice PRs.
|
|
32
|
+
*
|
|
33
|
+
* - `converging` — the plan is `done` but ≥1 slice PR is still non-terminal (in flight).
|
|
34
|
+
* - `landed` — every slice PR merged: `prsInFlight == 0 && prsMerged == prsOpened && prsOpened > 0`.
|
|
35
|
+
* - `null` — no positive signal yet: the plan isn't `done`, it opened no PRs, or every PR is
|
|
36
|
+
* terminal but not all merged (some `abandoned`/`converged` — resolved-not-landed, per the issue).
|
|
37
|
+
*
|
|
38
|
+
* A slice's PR status is "in flight" iff it is NOT in `TERMINAL_STATUSES`; `abandoned`/`converged`
|
|
39
|
+
* count as resolved-not-landed (terminal but not merged), so they never make an epic `landed`. */
|
|
40
|
+
export function deriveDelivery(
|
|
41
|
+
planStatus: string,
|
|
42
|
+
prStatuses: readonly string[],
|
|
43
|
+
): DeliveryRollup {
|
|
44
|
+
const prsOpened = prStatuses.length;
|
|
45
|
+
let prsMerged = 0;
|
|
46
|
+
let prsInFlight = 0;
|
|
47
|
+
for (const s of prStatuses) {
|
|
48
|
+
if (s === "merged") prsMerged++;
|
|
49
|
+
else if (!TERMINAL_STATUSES.includes(s)) prsInFlight++;
|
|
50
|
+
}
|
|
51
|
+
// `delivery` is only meaningful once the fan-out has been dispatched (`status = done`) and at
|
|
52
|
+
// least one slice PR exists; otherwise there is nothing to have landed yet.
|
|
53
|
+
if (planStatus !== "done" || prsOpened === 0) {
|
|
54
|
+
return { delivery: null, label: null, prsOpened, prsMerged, prsInFlight };
|
|
55
|
+
}
|
|
56
|
+
if (prsInFlight > 0) {
|
|
57
|
+
return {
|
|
58
|
+
delivery: "converging",
|
|
59
|
+
label: `${prsMerged}/${prsOpened} slices merged, ${prsInFlight} converging`,
|
|
60
|
+
prsOpened,
|
|
61
|
+
prsMerged,
|
|
62
|
+
prsInFlight,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
if (prsMerged === prsOpened) {
|
|
66
|
+
return {
|
|
67
|
+
delivery: "landed",
|
|
68
|
+
label: `${prsOpened}/${prsOpened} slices merged`,
|
|
69
|
+
prsOpened,
|
|
70
|
+
prsMerged,
|
|
71
|
+
prsInFlight,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
// Every slice PR is terminal but not all merged (some abandoned/converged): resolved, not landed.
|
|
75
|
+
return { delivery: null, label: null, prsOpened, prsMerged, prsInFlight };
|
|
76
|
+
}
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
import { test } from "node:test";
|
|
8
8
|
import { assert, assertEquals } from "#test-assert";
|
|
9
9
|
import { readFileSync } from "node:fs";
|
|
10
|
-
import { PR_ACTIVE_STATUSES, PLAN_ACTIVE_STATUSES, FEATURE_ACTIVE_STATUSES
|
|
10
|
+
import { PR_ACTIVE_STATUSES, PLAN_ACTIVE_STATUSES, FEATURE_ACTIVE_STATUSES } from "./service.ts";
|
|
11
|
+
import { TERMINAL_STATUSES } from "./delivery.ts";
|
|
11
12
|
import { PLAN_TERMINAL_STATUSES } from "./plan.ts";
|
|
12
13
|
import { FEATURE_TERMINAL_STATUSES } from "./feature.ts";
|
|
13
14
|
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// Read-model derivation test for the lineage projection (issue #245). `deriveLineage` is the single
|
|
2
|
+
// source of truth for the denormalised `lineage_threads` rows the poller projects: it must stitch a
|
|
3
|
+
// request → implementation → PR(s) → convergence → merge → outcome arc into one thread, expose the
|
|
4
|
+
// active frontier plus whether the arc has settled, roll epic fan-out up across N slice PRs, and
|
|
5
|
+
// tolerate a human/webhook PR with no originating request (self-rooted, kind `pr`).
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import { assert, assertEquals } from "#test-assert";
|
|
8
|
+
import type { DataLayer } from "@nanobpm/urban";
|
|
9
|
+
import {
|
|
10
|
+
deriveLineage,
|
|
11
|
+
type LineagePr,
|
|
12
|
+
type LineageThreadRow,
|
|
13
|
+
listLineage,
|
|
14
|
+
pollLineage,
|
|
15
|
+
} from "./lineage.ts";
|
|
16
|
+
|
|
17
|
+
function pr(overrides: Partial<LineagePr> & { prKey: string; status: string }): LineagePr {
|
|
18
|
+
return {
|
|
19
|
+
title: overrides.title ?? `title ${overrides.prKey}`,
|
|
20
|
+
url: overrides.url ?? `https://github.com/${overrides.prKey.replace("#", "/pull/")}`,
|
|
21
|
+
round: overrides.round ?? 1,
|
|
22
|
+
processKey: overrides.processKey ?? "p1",
|
|
23
|
+
outcome: overrides.outcome ?? null,
|
|
24
|
+
...overrides,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ── feature arc ──────────────────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
test("feature: implementing before any PR is handed off", () => {
|
|
31
|
+
const t = deriveLineage(
|
|
32
|
+
{ kind: "feature", key: "o/r#1", title: "Add X", issueUrl: "u", status: "running", processKey: "f1" },
|
|
33
|
+
[],
|
|
34
|
+
);
|
|
35
|
+
assertEquals(t.kind, "feature");
|
|
36
|
+
assertEquals(t.stage, "implementing");
|
|
37
|
+
assertEquals(t.stageLabel, "Implementing");
|
|
38
|
+
assert(t.active, "an implementing run is active");
|
|
39
|
+
assertEquals(t.processKey, "f1");
|
|
40
|
+
assertEquals(t.prCount, 0);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("feature: converging hands the narrative to the PR frontier", () => {
|
|
44
|
+
const t = deriveLineage(
|
|
45
|
+
{ kind: "feature", key: "o/r#1", title: "Add X", issueUrl: "u", status: "converging", processKey: "f1" },
|
|
46
|
+
[pr({ prKey: "o/r#2", status: "converging", round: 3, processKey: "c9" })],
|
|
47
|
+
);
|
|
48
|
+
assertEquals(t.stage, "converging");
|
|
49
|
+
assertEquals(t.stageLabel, "Converging (round 3)");
|
|
50
|
+
assertEquals(t.processKey, "c9", "frontier prefers the active PR's instance");
|
|
51
|
+
assert(t.active);
|
|
52
|
+
assertEquals(t.prKeys, ["o/r#2"]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("feature: a merged PR settles the arc (history)", () => {
|
|
56
|
+
const t = deriveLineage(
|
|
57
|
+
{ kind: "feature", key: "o/r#1", title: "Add X", issueUrl: "u", status: "merged", processKey: "f1" },
|
|
58
|
+
[pr({ prKey: "o/r#2", status: "merged" })],
|
|
59
|
+
);
|
|
60
|
+
assertEquals(t.stage, "merged");
|
|
61
|
+
assertEquals(t.stageLabel, "Merged");
|
|
62
|
+
assert(!t.active, "a merged arc has no active frontier");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("feature: an escalated run surfaces the escalation stage", () => {
|
|
66
|
+
const t = deriveLineage(
|
|
67
|
+
{ kind: "feature", key: "o/r#1", title: "X", issueUrl: "u", status: "escalated", processKey: "f1" },
|
|
68
|
+
[],
|
|
69
|
+
);
|
|
70
|
+
assertEquals(t.stage, "escalated");
|
|
71
|
+
assert(t.active);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// ── epic fan-out ─────────────────────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
test("epic: rolls up N slice PRs and stays active while any is in flight", () => {
|
|
77
|
+
const t = deriveLineage(
|
|
78
|
+
{ kind: "epic", key: "o/r#9", title: "Big epic", issueUrl: "u", status: "done", processKey: "e1" },
|
|
79
|
+
[
|
|
80
|
+
pr({ prKey: "o/r#10", status: "merged" }),
|
|
81
|
+
pr({ prKey: "o/r#11", status: "converging", processKey: "c11" }),
|
|
82
|
+
pr({ prKey: "o/r#12", status: "abandoned" }),
|
|
83
|
+
],
|
|
84
|
+
);
|
|
85
|
+
assertEquals(t.kind, "epic");
|
|
86
|
+
assertEquals(t.stage, "converging");
|
|
87
|
+
assertEquals(t.stageLabel, "1/3 slices merged, 1 converging");
|
|
88
|
+
assertEquals(t.processKey, "c11");
|
|
89
|
+
assertEquals(t.prCount, 3);
|
|
90
|
+
assert(t.active);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("epic: all slices merged settles as merged", () => {
|
|
94
|
+
const t = deriveLineage(
|
|
95
|
+
{ kind: "epic", key: "o/r#9", title: "Big epic", issueUrl: "u", status: "done", processKey: "e1" },
|
|
96
|
+
[pr({ prKey: "o/r#10", status: "merged" }), pr({ prKey: "o/r#11", status: "merged" })],
|
|
97
|
+
);
|
|
98
|
+
assertEquals(t.stage, "merged");
|
|
99
|
+
assertEquals(t.stageLabel, "2/2 slices merged");
|
|
100
|
+
assert(!t.active);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("epic: mixed terminal (none in flight, not all merged) is resolved, not landed", () => {
|
|
104
|
+
const t = deriveLineage(
|
|
105
|
+
{ kind: "epic", key: "o/r#9", title: "E", issueUrl: "u", status: "done", processKey: "e1" },
|
|
106
|
+
[pr({ prKey: "o/r#10", status: "merged" }), pr({ prKey: "o/r#11", status: "abandoned" })],
|
|
107
|
+
);
|
|
108
|
+
assertEquals(t.stage, "resolved");
|
|
109
|
+
assert(!t.active);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("epic: planning with no PRs yet", () => {
|
|
113
|
+
const t = deriveLineage(
|
|
114
|
+
{ kind: "epic", key: "o/r#9", title: "E", issueUrl: "u", status: "planning", processKey: "e1" },
|
|
115
|
+
[],
|
|
116
|
+
);
|
|
117
|
+
assertEquals(t.stage, "planning");
|
|
118
|
+
assert(t.active);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// ── self-rooted (human/webhook) PR ───────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
test("pr: a human/webhook PR with no origin is its own root", () => {
|
|
124
|
+
const t = deriveLineage({ kind: "pr", key: "o/r#5" }, [
|
|
125
|
+
pr({ prKey: "o/r#5", status: "waiting_review", round: 2, title: "Fix bug" }),
|
|
126
|
+
]);
|
|
127
|
+
assertEquals(t.kind, "pr");
|
|
128
|
+
assertEquals(t.rootRequestKey, "o/r#5");
|
|
129
|
+
assertEquals(t.stage, "reviewing");
|
|
130
|
+
assertEquals(t.stageLabel, "Awaiting review (round 2)");
|
|
131
|
+
assertEquals(t.title, "Fix bug", "a self-rooted PR takes its title from the PR");
|
|
132
|
+
assertEquals(t.issueUrl, null);
|
|
133
|
+
assert(t.active);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ── poller projection ────────────────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
function memData(): { data: DataLayer; stores: Record<string, any[]> } {
|
|
139
|
+
const stores: Record<string, any[]> = {};
|
|
140
|
+
function tbl(name: string, pk = "id") {
|
|
141
|
+
const rows = (stores[name] ??= [] as any[]);
|
|
142
|
+
const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
|
|
143
|
+
return {
|
|
144
|
+
async all() {
|
|
145
|
+
return rows.slice();
|
|
146
|
+
},
|
|
147
|
+
async get(id: any) {
|
|
148
|
+
return rows.find((r) => r[pk] === id);
|
|
149
|
+
},
|
|
150
|
+
async find(where: any = {}) {
|
|
151
|
+
return rows.filter((r) => match(r, where));
|
|
152
|
+
},
|
|
153
|
+
async insert(row: any) {
|
|
154
|
+
rows.push({ ...row });
|
|
155
|
+
return row[pk];
|
|
156
|
+
},
|
|
157
|
+
async update(id: any, patch: any) {
|
|
158
|
+
const r = rows.find((row) => row[pk] === id);
|
|
159
|
+
if (r) Object.assign(r, patch);
|
|
160
|
+
},
|
|
161
|
+
async delete(id: any) {
|
|
162
|
+
const i = rows.findIndex((r) => r[pk] === id);
|
|
163
|
+
if (i >= 0) rows.splice(i, 1);
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
|
|
168
|
+
return { data, stores };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
test("pollLineage: projects feature, epic, and self-rooted threads onto lineage_threads", async () => {
|
|
172
|
+
const { data, stores } = memData();
|
|
173
|
+
stores.feature_runs = [
|
|
174
|
+
{ feature_key: "o/r#1", title: "Feature", issue_url: "u1", status: "converging", process_key: "f1", pr_key: "o/r#100" },
|
|
175
|
+
];
|
|
176
|
+
stores.plans = [
|
|
177
|
+
{ plan_key: "o/r#2", title: "Epic", issue_url: "u2", status: "done", process_key: "e1" },
|
|
178
|
+
];
|
|
179
|
+
stores.plan_tasks = [
|
|
180
|
+
{ id: 1, plan_key: "o/r#2", pr_key: "o/r#200" },
|
|
181
|
+
{ id: 2, plan_key: "o/r#2", pr_key: "o/r#201" },
|
|
182
|
+
];
|
|
183
|
+
stores.pull_requests = [
|
|
184
|
+
{ 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" },
|
|
185
|
+
{ 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" },
|
|
186
|
+
{ 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" },
|
|
187
|
+
// Human/webhook PR — no root_request_key.
|
|
188
|
+
{ pr_key: "o/r#300", title: "Human PR", url: "x", status: "merged", current_round: 1, process_key: "c4", outcome: null, root_request_key: null },
|
|
189
|
+
];
|
|
190
|
+
|
|
191
|
+
await pollLineage(data);
|
|
192
|
+
|
|
193
|
+
const threads: LineageThreadRow[] = stores.lineage_threads;
|
|
194
|
+
assertEquals(threads.length, 3, "one feature, one epic, one self-rooted PR");
|
|
195
|
+
|
|
196
|
+
const feat = threads.find((t) => t.root_request_key === "o/r#1");
|
|
197
|
+
assert(feat, "feature thread present");
|
|
198
|
+
assertEquals(feat?.kind, "feature");
|
|
199
|
+
assertEquals(feat?.stage, "converging");
|
|
200
|
+
assertEquals(feat?.stage_label, "Converging (round 2)");
|
|
201
|
+
assertEquals(feat?.active, 1);
|
|
202
|
+
assertEquals(JSON.parse(feat?.pr_keys ?? "[]"), ["o/r#100"]);
|
|
203
|
+
|
|
204
|
+
const epic = threads.find((t) => t.root_request_key === "o/r#2");
|
|
205
|
+
assertEquals(epic?.kind, "epic");
|
|
206
|
+
assertEquals(epic?.stage, "converging");
|
|
207
|
+
assertEquals(epic?.pr_count, 2);
|
|
208
|
+
assertEquals(epic?.active, 1);
|
|
209
|
+
|
|
210
|
+
const human = threads.find((t) => t.root_request_key === "o/r#300");
|
|
211
|
+
assertEquals(human?.kind, "pr");
|
|
212
|
+
assertEquals(human?.stage, "merged");
|
|
213
|
+
assertEquals(human?.active, 0);
|
|
214
|
+
|
|
215
|
+
// Idempotent: a second pass with no state change writes nothing new (same row count, same ts).
|
|
216
|
+
const before = stores.lineage_threads.map((r: LineageThreadRow) => r.updated_at);
|
|
217
|
+
await pollLineage(data);
|
|
218
|
+
const after = stores.lineage_threads.map((r: LineageThreadRow) => r.updated_at);
|
|
219
|
+
assertEquals(after, before, "steady-state pass is a no-op");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("pollLineage: a self-rooted PR row (root_request_key === pr_key) projects exactly one thread keyed on its pr_key", async () => {
|
|
223
|
+
// Regression (#245): submitPr now self-roots a human/webhook PR on its own `pr_key` (rather than
|
|
224
|
+
// NULL) so the Lineage page's `lineage_threads.root_request_key → pull_requests.root_request_key`
|
|
225
|
+
// drill-down join is non-empty. Guard that this row shape still projects a single self-rooted
|
|
226
|
+
// thread keyed on the `pr_key` — not double-counted, and not grouped under a phantom origin.
|
|
227
|
+
const { data, stores } = memData();
|
|
228
|
+
stores.feature_runs = [];
|
|
229
|
+
stores.plans = [];
|
|
230
|
+
stores.plan_tasks = [];
|
|
231
|
+
stores.pull_requests = [
|
|
232
|
+
{ pr_key: "o/r#42", title: "Human PR", url: "x", status: "converging", current_round: 1, process_key: "c9", outcome: null, root_request_key: "o/r#42" },
|
|
233
|
+
];
|
|
234
|
+
|
|
235
|
+
await pollLineage(data);
|
|
236
|
+
|
|
237
|
+
const threads: LineageThreadRow[] = stores.lineage_threads;
|
|
238
|
+
assertEquals(threads.length, 1, "one self-rooted thread");
|
|
239
|
+
assertEquals(threads[0].root_request_key, "o/r#42", "thread key equals the PR row's root_request_key so the page join drills down");
|
|
240
|
+
assertEquals(threads[0].kind, "pr");
|
|
241
|
+
assertEquals(JSON.parse(threads[0].pr_keys ?? "[]"), ["o/r#42"]);
|
|
242
|
+
assertEquals(threads[0].pr_count, 1);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("pollLineage: an orphaned non-null root (origin row gone) keys the thread on the stored root, not pr_key", async () => {
|
|
246
|
+
// Regression (#245 review): a PR whose `root_request_key` points at a feature/epic origin row that
|
|
247
|
+
// no longer survives is unclaimed by any feature/epic thread. Keying its self-rooted thread on
|
|
248
|
+
// `pr_key` would leave `lineage_threads.root_request_key` (= pr_key) ≠ `pull_requests.root_request_key`
|
|
249
|
+
// (= the orphaned root), so the page's drill-down join renders an empty PR list. The thread MUST be
|
|
250
|
+
// keyed on the stored `root_request_key`. Two PRs sharing one orphaned root belong to one thread.
|
|
251
|
+
const { data, stores } = memData();
|
|
252
|
+
stores.feature_runs = [];
|
|
253
|
+
stores.plans = [];
|
|
254
|
+
stores.plan_tasks = [];
|
|
255
|
+
stores.pull_requests = [
|
|
256
|
+
{ pr_key: "o/r#71", title: "Orphan A", url: "x", status: "converging", current_round: 1, process_key: "c1", outcome: null, root_request_key: "o/r#7" },
|
|
257
|
+
{ pr_key: "o/r#72", title: "Orphan B", url: "x", status: "merged", current_round: 1, process_key: "c2", outcome: null, root_request_key: "o/r#7" },
|
|
258
|
+
];
|
|
259
|
+
|
|
260
|
+
await pollLineage(data);
|
|
261
|
+
|
|
262
|
+
const threads: LineageThreadRow[] = stores.lineage_threads;
|
|
263
|
+
assertEquals(threads.length, 1, "both orphaned PRs group into one thread under their shared root");
|
|
264
|
+
assertEquals(
|
|
265
|
+
threads[0].root_request_key,
|
|
266
|
+
"o/r#7",
|
|
267
|
+
"thread key equals the stored root_request_key so the page join drills down, not the pr_key",
|
|
268
|
+
);
|
|
269
|
+
assertEquals(threads[0].kind, "pr");
|
|
270
|
+
assertEquals(JSON.parse(threads[0].pr_keys ?? "[]").sort(), ["o/r#71", "o/r#72"]);
|
|
271
|
+
assertEquals(threads[0].pr_count, 2);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("listLineage: unknown root returns nothing; known roots stitched", async () => {
|
|
275
|
+
const { data } = memData();
|
|
276
|
+
const threads = await listLineage(data);
|
|
277
|
+
assertEquals(threads.length, 0);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("listLineage: deterministic order — active frontier first, then by rootRequestKey", async () => {
|
|
281
|
+
// Guard the tie-break (#245 review): equal-`active` threads have no per-thread timestamp to sort
|
|
282
|
+
// on, so ordering must fall back to `rootRequestKey` for a stable, deterministic response instead
|
|
283
|
+
// of jittering across passes. Insert settled roots out of order plus one active root.
|
|
284
|
+
const { data, stores } = memData();
|
|
285
|
+
stores.feature_runs = [];
|
|
286
|
+
stores.plans = [];
|
|
287
|
+
stores.plan_tasks = [];
|
|
288
|
+
stores.pull_requests = [
|
|
289
|
+
{ pr_key: "o/r#3", title: "c", url: "x", status: "merged", current_round: 1, process_key: null, outcome: null, root_request_key: "o/r#3" },
|
|
290
|
+
{ pr_key: "o/r#1", title: "a", url: "x", status: "merged", current_round: 1, process_key: null, outcome: null, root_request_key: "o/r#1" },
|
|
291
|
+
{ pr_key: "o/r#2", title: "b", url: "x", status: "converging", current_round: 1, process_key: "c9", outcome: null, root_request_key: "o/r#2" },
|
|
292
|
+
];
|
|
293
|
+
|
|
294
|
+
const threads = await listLineage(data);
|
|
295
|
+
assertEquals(
|
|
296
|
+
threads.map((t) => t.rootRequestKey),
|
|
297
|
+
["o/r#2", "o/r#1", "o/r#3"],
|
|
298
|
+
"active first, then inactive sorted by rootRequestKey",
|
|
299
|
+
);
|
|
300
|
+
});
|