@nanobpm/nano-workforce 0.90.0 → 0.92.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 CHANGED
@@ -1,3 +1,22 @@
1
+ # [0.92.0](https://github.com/nanobpm/nano-workforce/compare/v0.91.0...v0.92.0) (2026-08-19)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **e2e:** polyfill EngineClient.openUserTasks in the testkit shim ([#309](https://github.com/nanobpm/nano-workforce/issues/309)) ([#312](https://github.com/nanobpm/nano-workforce/issues/312)) ([22609d5](https://github.com/nanobpm/nano-workforce/commit/22609d57789d09a1062cf8388b37a76ef293d4f0)), closes [#297](https://github.com/nanobpm/nano-workforce/issues/297) [#294](https://github.com/nanobpm/nano-workforce/issues/294)
7
+
8
+
9
+ ### Features
10
+
11
+ * **epics:** bucket epics on delivery, not raw status, with a dismiss affordance ([#303](https://github.com/nanobpm/nano-workforce/issues/303)) ([4f9bd1a](https://github.com/nanobpm/nano-workforce/commit/4f9bd1a6d2853ffeacbdd6b744d9dd4efffba0ea)), closes [#298](https://github.com/nanobpm/nano-workforce/issues/298) [#298](https://github.com/nanobpm/nano-workforce/issues/298)
12
+
13
+ # [0.91.0](https://github.com/nanobpm/nano-workforce/compare/v0.90.0...v0.91.0) (2026-08-19)
14
+
15
+
16
+ ### Features
17
+
18
+ * **tasks:** show subject title with repo/issue# as subtitle ([#308](https://github.com/nanobpm/nano-workforce/issues/308)) ([#311](https://github.com/nanobpm/nano-workforce/issues/311)) ([5771b38](https://github.com/nanobpm/nano-workforce/commit/5771b38abc33f4f88329d69c808214dfd42f5113))
19
+
1
20
  # [0.90.0](https://github.com/nanobpm/nano-workforce/compare/v0.89.0...v0.90.0) (2026-08-19)
2
21
 
3
22
 
package/SPEC.md CHANGED
@@ -533,10 +533,16 @@ the same flat operation the form posts. `baseBranch`
533
533
  is required and admitted through the ADR 0003 gate (auto-create `epic/*`, confirm-default,
534
534
  shared-base guard).
535
535
 
536
- **Visibility**: the home page adds a **Plans** grid (Active: planning/dispatched;
537
- History: done/failed/abandoned) with a `plan_tasks` child grid showing each task's
538
- status and the PR it produced (`pr_key` cross-references the Pull requests grid for
539
- convergence status).
536
+ **Visibility**: the home page adds a **Plans** grid (`plan_tasks` child grid showing each
537
+ task's status and the PR it produced — `pr_key` cross-references the Pull requests grid for
538
+ convergence status). Epics bucket into Active / History on the **derived `plans.list_bucket`**
539
+ (issue #298), NOT raw `plans.status`: bucketing on raw `status` made an epic vanish from Active
540
+ the instant `status=done`, even though `done` only means "fan-out dispatched to convergence" —
541
+ its slice PRs may still be **converging**, or all merged (**landed**) but still needing the
542
+ integration→main promotion PR. `deriveEpicBucket` (app/delivery.ts) keeps a `done` epic in Active
543
+ until an operator **dismisses** it (`POST /actions/acknowledge-epic` stamps `plans.acknowledged_at`,
544
+ the twin of the feature-run tick-off); a still-`converging` epic is never dismissable, and
545
+ `failed`/`abandoned` epics fall to History directly. Projected at write time by the `plans` gateway.
540
546
 
541
547
  **Epic domain phase** (issue #261): `plans.status` only distinguishes the process-instance
542
548
  terminal (`dispatched` = "fan-out job done"), not the epic's *domain* lifecycle. The read model
@@ -0,0 +1,98 @@
1
+ // Unit coverage for the S2 admission-STAGING data layer (issue #292 slice S2): `recordAdmittedEpic`
2
+ // and `recordAdmittedPlanDep`, the FK-free twins the set/batch door persists into instead of the
3
+ // durable `plans` / `plan_deps` graph. Driven against the same minimal in-memory data layer style as
4
+ // app/planDeps.test.ts. The edge writer shares `insertEdgeIdempotent` with `recordPlanDep`, so these
5
+ // pin the staging-specific surface: per-table isolation, epic idempotency on `plan_key`, and that a
6
+ // self-edge is still rejected.
7
+ import { test } from "node:test";
8
+ import { assertEquals, assertRejects } from "#test-assert";
9
+ import { recordAdmittedEpic, recordAdmittedPlanDep } from "./plan.ts";
10
+
11
+ /** Minimal in-memory data layer with per-name tables (so `admitted_epics` and `admitted_plan_deps`
12
+ * stay isolated), matching the equality-filtered `find` / append `insert` / `get`-first style. */
13
+ function memData() {
14
+ const tables = new Map<string, any[]>();
15
+ const rowsOf = (name: string) => tables.get(name) ?? (tables.set(name, []), tables.get(name)!);
16
+ return {
17
+ tables,
18
+ data: {
19
+ table: (name: string, key: string) => {
20
+ const rows = rowsOf(name);
21
+ return {
22
+ get: (k: any) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
23
+ find: (q: any) =>
24
+ Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
25
+ insert: (r: any) => {
26
+ rows.push(r);
27
+ return Promise.resolve(r);
28
+ },
29
+ };
30
+ },
31
+ } as any,
32
+ };
33
+ }
34
+
35
+ const EPIC = {
36
+ plan_key: "owner/repo#1",
37
+ repo: "owner/repo",
38
+ issue_number: 1,
39
+ issue_url: "https://github.com/owner/repo/issues/1",
40
+ base_branch: "epic/a",
41
+ };
42
+
43
+ test("recordAdmittedEpic stages an epic with a stamped created_at", async () => {
44
+ const { data, tables } = memData();
45
+ const row = await recordAdmittedEpic(data, EPIC);
46
+ assertEquals(tables.get("admitted_epics")!.length, 1);
47
+ assertEquals(row.plan_key, "owner/repo#1");
48
+ assertEquals(row.base_branch, "epic/a");
49
+ assertEquals(typeof row.created_at, "string");
50
+ assertEquals(row.created_at.length > 0, true);
51
+ });
52
+
53
+ test("recordAdmittedEpic is idempotent on plan_key (no second row, existing wins)", async () => {
54
+ const { data, tables } = memData();
55
+ const first = await recordAdmittedEpic(data, EPIC);
56
+ const again = await recordAdmittedEpic(data, { ...EPIC, base_branch: "epic/ignored-on-dupe" });
57
+ assertEquals(tables.get("admitted_epics")!.length, 1);
58
+ assertEquals(again.created_at, first.created_at);
59
+ assertEquals(again.base_branch, "epic/a"); // the existing staged row is returned, not overwritten
60
+ });
61
+
62
+ test("recordAdmittedPlanDep stages an edge and stays out of plan_deps", async () => {
63
+ const { data, tables } = memData();
64
+ await recordAdmittedPlanDep(data, {
65
+ plan_key: "owner/repo#2",
66
+ depends_on_plan_key: "owner/repo#1",
67
+ package: "@nanobpm/p",
68
+ capability_ref: "owner/repo#1",
69
+ });
70
+ assertEquals(tables.get("admitted_plan_deps")!.length, 1);
71
+ assertEquals(tables.get("plan_deps") ?? [], []); // never touches the durable graph
72
+ });
73
+
74
+ test("recordAdmittedPlanDep is idempotent on a duplicate edge (no second row)", async () => {
75
+ const { data, tables } = memData();
76
+ const edge = {
77
+ plan_key: "owner/repo#2",
78
+ depends_on_plan_key: "owner/repo#1",
79
+ package: "@nanobpm/p",
80
+ capability_ref: "owner/repo#1",
81
+ };
82
+ await recordAdmittedPlanDep(data, edge);
83
+ await recordAdmittedPlanDep(data, { ...edge, package: "@nanobpm/ignored-on-dupe" });
84
+ assertEquals(tables.get("admitted_plan_deps")!.length, 1);
85
+ });
86
+
87
+ test("recordAdmittedPlanDep rejects a self-edge", async () => {
88
+ const { data, tables } = memData();
89
+ await assertRejects(() =>
90
+ recordAdmittedPlanDep(data, {
91
+ plan_key: "owner/repo#1",
92
+ depends_on_plan_key: "owner/repo#1",
93
+ package: "@nanobpm/p",
94
+ capability_ref: "owner/repo#1",
95
+ }),
96
+ );
97
+ assertEquals(tables.get("admitted_plan_deps") ?? [], []);
98
+ });
package/app/contracts.ts CHANGED
@@ -343,6 +343,15 @@ export const WIRE_CONTRACTS = {
343
343
  shape:
344
344
  '{ provider: "github", url: string, ref: string, singleBranch: true, filter: "blob:none", baseRef?: string }',
345
345
  },
346
+ "epicSet.submit": {
347
+ category: "wire",
348
+ name: "epicSet.submit",
349
+ owner: "operations/startEpicSet.ts",
350
+ semantics:
351
+ "Set/batch admission payload POSTed to /actions/start/epic-set (issue #292, slice S2). Submits a whole set of epics plus the inter-epic dependency edges between them in one all-or-nothing call. Each `epics[]` member carries the same per-epic admission inputs as PlanStart (issue|url + baseBranch + allowSharedBase/confirmDefaultBase); each `deps[]` edge declares `consumer` waits for `producer`'s published { package, capabilityRef } capability, both endpoints naming epics in the set. Declared in openapi.yaml as EpicSetStart. S2 admits + STAGES the set into its own FK-free `admitted_epics` / `admitted_plan_deps` (043) — it writes NEITHER `plans` NOR `plan_deps`; slice S3 (lowering) reads that staging to materialize the durable graph, and S4 (visibility) builds on it — consume this ONE shape, do not re-declare a synonym.",
352
+ shape:
353
+ '{ epics: Array<{ issue|url: string, baseBranch: string, allowSharedBase?: boolean, confirmDefaultBase?: boolean }>, deps?: Array<{ consumer: string, producer: string, package: string, capabilityRef: string }> }',
354
+ },
346
355
  } as const satisfies Record<string, WireContract>;
347
356
 
348
357
  export const TYPE_CONTRACTS = {
@@ -359,7 +368,7 @@ export const TYPE_CONTRACTS = {
359
368
  name: "PlanDep",
360
369
  owner: "app/plan.ts",
361
370
  semantics:
362
- "One INTER-epic dependency edge (issue #292): dependent epic `plan_key` waits for producer epic `depends_on_plan_key`, gated by the producer's `{ package, capability_ref }` capability descriptor. Set admission (S2), planner lowering (S3), and operator visibility (S4) all import this ONE row shape from app/plan.ts — no re-declared synonym.",
371
+ "One INTER-epic dependency edge (issue #292): dependent epic `plan_key` waits for producer epic `depends_on_plan_key`, gated by the producer's `{ package, capability_ref }` capability descriptor. This ONE row shape backs BOTH the durable `plan_deps` table (materialized by planner lowering S3) AND its FK-free admission-staging twin `admitted_plan_deps` (staged by the S2 door). Set admission (S2), planner lowering (S3), and operator visibility (S4) all import it from app/plan.ts — no re-declared synonym.",
363
372
  module: "app/plan.ts",
364
373
  },
365
374
  } as const satisfies Record<string, TypeContract>;
package/app/delivery.ts CHANGED
@@ -74,3 +74,64 @@ export function deriveDelivery(
74
74
  // Every slice PR is terminal but not all merged (some abandoned/converged): resolved, not landed.
75
75
  return { delivery: null, label: null, prsOpened, prsMerged, prsInFlight };
76
76
  }
77
+
78
+ /** The `plan.status` values that mean the epic's fan-out lifecycle is still LIVE — the planner is
79
+ * decomposing (`planning`) or the fleet is implementing (`dispatched`). Both are unambiguously
80
+ * in-flight, so an epic in either status is always in the Active bucket regardless of `delivery`. */
81
+ export const EPIC_LIVE_STATUSES: readonly string[] = ["planning", "dispatched"];
82
+
83
+ /** The Active/History partition for an EPIC (issue #298), the twin of feature runs'
84
+ * `deriveListBucket` (app/stage.ts). It exists because an epic must NOT vanish from the Active list
85
+ * the instant `plan.status` becomes `done`: `done` only means "the fan-out finished and ≥1 slice
86
+ * opened a PR, dispatched to convergence" (see the `Delivery` doc above — it "conflates hand-off with
87
+ * landing"), so a `done` epic whose slice PRs are still CONVERGING, or one that has fully LANDED but
88
+ * still needs its integration→main promotion PR raised, still needs the operator's attention.
89
+ *
90
+ * Bucket on the derived `delivery` rollup (the single source of truth this consumes), NOT raw
91
+ * `status`. An epic is *in-flight* (`active`) while:
92
+ * • `status` is live (`planning`/`dispatched`), OR
93
+ * • it is `done` and NOT yet acknowledged — a done epic stays visible/actionable (surfacing
94
+ * `delivery_label`, e.g. "5/5 slices merged, promote to main") until the operator dismisses it,
95
+ * mirroring feature runs' terminal tick-off. This deliberately covers `delivery = null` too:
96
+ * record-results only reaches `done` with ≥1 opened PR (a zero-PR plan is `failed`, #86), so a
97
+ * just-`done` epic whose `delivery` the poller has not yet projected must NOT flicker into
98
+ * History — the very vanish this issue fixes. A `converging` epic is likewise Active, and its
99
+ * Dismiss affordance stays closed (see {@link epicIsAcknowledgeable}) so it is never ticked off
100
+ * mid-flight.
101
+ *
102
+ * It falls to `history` only once truly resolved: a `done` epic the operator has acknowledged, or a
103
+ * terminal non-`done` status (`failed`/`abandoned`, which carry their own incident signal and need no
104
+ * tick-off). Pure and read-only; projected at write time by the `plans` gateway (app/plan.ts) onto
105
+ * `plans.list_bucket`. */
106
+ export function deriveEpicBucket(
107
+ status: string,
108
+ delivery: string | null | undefined,
109
+ acknowledgedAt: string | null | undefined,
110
+ ): "active" | "history" {
111
+ if (EPIC_LIVE_STATUSES.includes(status)) return "active";
112
+ if (status === "done") {
113
+ // A still-`converging` epic is Active regardless of any (stray) acknowledged_at — it is genuinely
114
+ // working and is not acknowledgeable, so it can never be ticked off mid-flight (fail-closed).
115
+ if (delivery === "converging") return "active";
116
+ // Otherwise `done` — landed or resolved-not-landed/poller-pending (`delivery = null`): stay Active
117
+ // until the operator dismisses it, so a just-`done` epic never flickers into History.
118
+ return (acknowledgedAt ?? null) === null ? "active" : "history";
119
+ }
120
+ return "history";
121
+ }
122
+
123
+ /** True iff an epic carries the operator "Dismiss" (acknowledge) affordance — a `done` epic whose
124
+ * fan-out has RESOLVED (it is no longer `converging`): every slice PR has reached a terminal state,
125
+ * whether all merged (`delivery = landed` — promote to main, then dismiss) or resolved-not-landed
126
+ * (`delivery = null` — some abandoned/converged). This is the set of Active epics a tick-off may move
127
+ * to History. A live (`planning`/`dispatched`) or still-`converging` epic is genuinely working —
128
+ * nothing to tick off — so its Dismiss stays closed; a `failed`/`abandoned` epic is already in
129
+ * History. The `acknowledgeEpic` operation guards on this (409 otherwise) and the gateway projects it
130
+ * to `plans.ack_open` (1/0) so the page's `showWhenField` Dismiss button renders only for a resolved-
131
+ * but-unacknowledged epic. */
132
+ export function epicIsAcknowledgeable(
133
+ status: string,
134
+ delivery: string | null | undefined,
135
+ ): boolean {
136
+ return status === "done" && delivery !== "converging";
137
+ }
@@ -0,0 +1,57 @@
1
+ // Read-model derivation test for the EPIC Active/History bucket (issue #298). `deriveEpicBucket` is
2
+ // the single source of truth for `plans.list_bucket`, and `epicIsAcknowledgeable` gates the Dismiss
3
+ // affordance (`plans.ack_open` + the acknowledge-epic 409 guard). The defect this guards: any epic
4
+ // list that buckets on RAW terminal `status` (`done`) instead of the derived `delivery` rollup makes
5
+ // an epic vanish from Active the instant `status=done` — while its slices are still converging, or
6
+ // while it has landed but still needs its integration→main promotion PR.
7
+ import { test } from "node:test";
8
+ import { assert, assertEquals } from "#test-assert";
9
+ import { deriveEpicBucket, EPIC_LIVE_STATUSES, epicIsAcknowledgeable } from "./delivery.ts";
10
+
11
+ test("a live epic (planning/dispatched) is always Active, whatever the delivery", () => {
12
+ for (const status of EPIC_LIVE_STATUSES) {
13
+ assertEquals(deriveEpicBucket(status, null, null), "active", `status=${status}`);
14
+ // delivery is always null pre-`done`, but guard the predicate anyway.
15
+ assertEquals(deriveEpicBucket(status, "converging", null), "active", `status=${status}`);
16
+ }
17
+ });
18
+
19
+ // The core regression (red before the fix): a `done` epic whose slices are still converging must NOT
20
+ // fall to History just because `status=done`.
21
+ test("done + converging -> Active (the epic must not vanish while slices converge)", () => {
22
+ assertEquals(deriveEpicBucket("done", "converging", null), "active");
23
+ // An operator has no way to acknowledge a converging epic away either.
24
+ assert(!epicIsAcknowledgeable("done", "converging"));
25
+ });
26
+
27
+ test("done + landed + unacknowledged -> Active (stays actionable until dismissed)", () => {
28
+ assertEquals(deriveEpicBucket("done", "landed", null), "active");
29
+ assert(epicIsAcknowledgeable("done", "landed"));
30
+ });
31
+
32
+ test("done + landed + acknowledged -> History", () => {
33
+ assertEquals(deriveEpicBucket("done", "landed", "2024-01-01T00:00:00Z"), "history");
34
+ });
35
+
36
+ // A `done` epic with no positive delivery signal is either just-`done` (the poller has not projected
37
+ // `delivery` yet) or resolved-not-landed (every PR terminal, not all merged). Either way it must NOT
38
+ // flicker into History on `status=done` alone — it stays Active and acknowledgeable until dismissed.
39
+ test("done + delivery=null (poller-pending / resolved-not-landed) -> Active, acknowledgeable", () => {
40
+ assertEquals(deriveEpicBucket("done", null, null), "active");
41
+ assert(epicIsAcknowledgeable("done", null));
42
+ // Once the operator dismisses it (acknowledged), it settles to History.
43
+ assertEquals(deriveEpicBucket("done", null, "2024-01-01T00:00:00Z"), "history");
44
+ });
45
+
46
+ test("terminal non-done statuses (failed/abandoned) -> History, not acknowledgeable", () => {
47
+ for (const status of ["failed", "abandoned"]) {
48
+ assertEquals(deriveEpicBucket(status, null, null), "history", `status=${status}`);
49
+ assert(!epicIsAcknowledgeable(status, null), `status=${status}`);
50
+ }
51
+ });
52
+
53
+ // Fail-closed: an acknowledged timestamp on a still-converging epic must NOT drag it to History (only
54
+ // a LANDED epic is acknowledgeable; a stale/premature stamp is ignored by the bucket).
55
+ test("acknowledged_at on a converging epic is ignored -> still Active", () => {
56
+ assertEquals(deriveEpicBucket("done", "converging", "2024-01-01T00:00:00Z"), "active");
57
+ });
@@ -0,0 +1,169 @@
1
+ // Unit coverage for the pure set-admission validator `validateEpicSet` (issue #292, slice S2). It
2
+ // exercises the SHAPE + DAG rules directly (no HTTP / no admitPlan), mirroring how app/plan.test.ts
3
+ // unit-tests the other pure plan helpers. The operation edge test
4
+ // (operations/startEpicSet.admission.integration.test.ts) proves the composed door behaviour.
5
+ import { test } from "node:test";
6
+ import { assertEquals } from "#test-assert";
7
+ import { EpicSetValidationError, validateEpicSet } from "./plan.ts";
8
+
9
+ const REPO = "owner/repo";
10
+ const key = (n: number) => `${REPO}#${n}`;
11
+
12
+ function expectReject(fn: () => unknown): EpicSetValidationError {
13
+ try {
14
+ fn();
15
+ } catch (err) {
16
+ if (err instanceof EpicSetValidationError) return err;
17
+ throw err;
18
+ }
19
+ throw new Error("expected EpicSetValidationError, but validation passed");
20
+ }
21
+
22
+ test("valid linear DAG resolves every edge to plan keys", () => {
23
+ const edges = validateEpicSet(
24
+ [key(1), key(2), key(3)],
25
+ [
26
+ { consumer: key(2), producer: key(1), package: "p1", capabilityRef: key(1) },
27
+ { consumer: key(3), producer: key(2), package: "p2", capabilityRef: key(2) },
28
+ ],
29
+ );
30
+ assertEquals(edges, [
31
+ { consumer: key(2), producer: key(1), package: "p1", capabilityRef: key(1) },
32
+ { consumer: key(3), producer: key(2), package: "p2", capabilityRef: key(2) },
33
+ ]);
34
+ });
35
+
36
+ test("a diamond DAG (two producers into one consumer) is acyclic and accepted", () => {
37
+ const edges = validateEpicSet(
38
+ [key(1), key(2), key(3), key(4)],
39
+ [
40
+ { consumer: key(3), producer: key(1), package: "a", capabilityRef: key(1) },
41
+ { consumer: key(3), producer: key(2), package: "b", capabilityRef: key(2) },
42
+ { consumer: key(4), producer: key(3), package: "c", capabilityRef: key(3) },
43
+ ],
44
+ );
45
+ assertEquals(edges.length, 3);
46
+ });
47
+
48
+ test("empty set is rejected", () => {
49
+ assertEquals(expectReject(() => validateEpicSet([], [])).status, 400);
50
+ });
51
+
52
+ test("duplicate epic in the set is rejected", () => {
53
+ assertEquals(expectReject(() => validateEpicSet([key(1), key(1)], [])).status, 400);
54
+ });
55
+
56
+ test("edge endpoint outside the set is rejected", () => {
57
+ const err = expectReject(() =>
58
+ validateEpicSet([key(1)], [{ consumer: key(1), producer: key(2), package: "p", capabilityRef: key(2) }]),
59
+ );
60
+ assertEquals(err.status, 400);
61
+ });
62
+
63
+ test("self-edge is rejected", () => {
64
+ const err = expectReject(() =>
65
+ validateEpicSet([key(1)], [{ consumer: key(1), producer: key(1), package: "p", capabilityRef: key(1) }]),
66
+ );
67
+ assertEquals(err.status, 400);
68
+ });
69
+
70
+ test("blank package is rejected", () => {
71
+ const err = expectReject(() =>
72
+ validateEpicSet(
73
+ [key(1), key(2)],
74
+ [{ consumer: key(2), producer: key(1), package: " ", capabilityRef: key(1) }],
75
+ ),
76
+ );
77
+ assertEquals(err.status, 400);
78
+ });
79
+
80
+ test("blank capabilityRef is rejected", () => {
81
+ const err = expectReject(() =>
82
+ validateEpicSet(
83
+ [key(1), key(2)],
84
+ [{ consumer: key(2), producer: key(1), package: "p", capabilityRef: "" }],
85
+ ),
86
+ );
87
+ assertEquals(err.status, 400);
88
+ });
89
+
90
+ test("a two-node cycle is rejected", () => {
91
+ const err = expectReject(() =>
92
+ validateEpicSet(
93
+ [key(1), key(2)],
94
+ [
95
+ { consumer: key(2), producer: key(1), package: "p", capabilityRef: key(1) },
96
+ { consumer: key(1), producer: key(2), package: "p", capabilityRef: key(2) },
97
+ ],
98
+ ),
99
+ );
100
+ assertEquals(err.status, 400);
101
+ });
102
+
103
+ test("a longer cycle (1→2→3→1) is rejected", () => {
104
+ const err = expectReject(() =>
105
+ validateEpicSet(
106
+ [key(1), key(2), key(3)],
107
+ [
108
+ { consumer: key(1), producer: key(2), package: "p", capabilityRef: key(2) },
109
+ { consumer: key(2), producer: key(3), package: "p", capabilityRef: key(3) },
110
+ { consumer: key(3), producer: key(1), package: "p", capabilityRef: key(1) },
111
+ ],
112
+ ),
113
+ );
114
+ assertEquals(err.status, 400);
115
+ });
116
+
117
+ test("a duplicate edge in one submission is collapsed, not rejected", () => {
118
+ const edges = validateEpicSet(
119
+ [key(1), key(2)],
120
+ [
121
+ { consumer: key(2), producer: key(1), package: "p", capabilityRef: key(1) },
122
+ { consumer: key(2), producer: key(1), package: "p", capabilityRef: key(1) },
123
+ ],
124
+ );
125
+ assertEquals(edges.length, 1);
126
+ });
127
+
128
+ test("edge endpoints given as issue URLs resolve to the same plan keys", () => {
129
+ const edges = validateEpicSet(
130
+ [key(1), key(2)],
131
+ [
132
+ {
133
+ consumer: `https://github.com/${REPO}/issues/2`,
134
+ producer: `https://github.com/${REPO}/issues/1`,
135
+ package: "p",
136
+ capabilityRef: `https://github.com/${REPO}/issues/1`,
137
+ },
138
+ ],
139
+ );
140
+ assertEquals(edges[0].consumer, key(2));
141
+ assertEquals(edges[0].producer, key(1));
142
+ });
143
+
144
+ // `deps` endpoints arrive untyped from the request body, so a padded-but-valid endpoint
145
+ // (" owner/repo#1 ") must be trimmed and accepted — matching how the epic-member path trims — not
146
+ // rejected as unparseable.
147
+ test("whitespace-padded dep endpoints are trimmed and resolved", () => {
148
+ const edges = validateEpicSet(
149
+ [key(1), key(2)],
150
+ [{ consumer: ` ${key(2)} `, producer: ` ${key(1)}`, package: "p", capabilityRef: key(1) }],
151
+ );
152
+ assertEquals(edges, [{ consumer: key(2), producer: key(1), package: "p", capabilityRef: key(1) }]);
153
+ });
154
+
155
+ // `deps` arrives untyped from the request body, so a malformed entry must reject with a clean
156
+ // EpicSetValidationError (400), never an uncaught TypeError. Each of these would previously have
157
+ // thrown a raw TypeError (mapping to a 500 at the edge) before the defensive shape checks.
158
+ for (const badDep of [
159
+ null,
160
+ "owner/repo#1", // non-object
161
+ {}, // missing endpoints
162
+ { consumer: 1, producer: key(1), package: "p", capabilityRef: key(1) }, // non-string consumer
163
+ { consumer: key(2), producer: key(1), package: 7, capabilityRef: key(1) }, // non-string package
164
+ ] as const) {
165
+ test(`malformed dep entry (${JSON.stringify(badDep)}) is rejected as a 400`, () => {
166
+ const err = expectReject(() => validateEpicSet([key(1), key(2)], [badDep]));
167
+ assertEquals(err.status, 400);
168
+ });
169
+ }
@@ -0,0 +1,97 @@
1
+ // Regression guard for migration 045 (issue #292 slice S2): the durable constraints on the FK-FREE
2
+ // admission-staging tables `admitted_epics` / `admitted_plan_deps`. Its sibling migration041.test.ts
3
+ // proves `plan_deps` foreign-keys its consumer to an admitted `plans` row; this proves the staging
4
+ // twin is deliberately NOT FK-constrained — it must accept an edge (and an epic) whose plan row does
5
+ // not exist yet, since S2 stages BEFORE any `plans` row is materialized — while still rejecting a
6
+ // self-edge (CHECK) and a duplicate (PRIMARY KEY), exactly like `plan_deps`.
7
+ import { readFileSync } from "node:fs";
8
+ import { DatabaseSync } from "node:sqlite";
9
+ import test from "node:test";
10
+ import { fileURLToPath } from "node:url";
11
+ import { assertEquals } from "#test-assert";
12
+
13
+ function migratedDb(): DatabaseSync {
14
+ const db = new DatabaseSync(":memory:");
15
+ db.exec("PRAGMA foreign_keys = ON;");
16
+ // NOTE: deliberately NO `plans` table — the staging tables must apply and accept rows with no plan
17
+ // graph in existence (that is the whole point of FK-free staging).
18
+ const sql = readFileSync(
19
+ fileURLToPath(new URL("../db/migrations/045_epic_set_admission_staging.sql", import.meta.url)),
20
+ "utf8",
21
+ );
22
+ db.exec(sql);
23
+ return db;
24
+ }
25
+
26
+ const insertEdge = (db: DatabaseSync, planKey: string, dependsOn: string) =>
27
+ db
28
+ .prepare(
29
+ `INSERT INTO admitted_plan_deps (plan_key, depends_on_plan_key, package, capability_ref, created_at)
30
+ VALUES (?, ?, '@nanobpm/p', ?, 't')`,
31
+ )
32
+ .run(planKey, dependsOn, dependsOn);
33
+
34
+ const insertEpic = (db: DatabaseSync, planKey: string) =>
35
+ db
36
+ .prepare(
37
+ `INSERT INTO admitted_epics (plan_key, repo, issue_number, issue_url, base_branch, created_at)
38
+ VALUES (?, 'o/r', 1, 'https://github.com/o/r/issues/1', 'epic/a', 't')`,
39
+ )
40
+ .run(planKey);
41
+
42
+ test("migration 045 applies cleanly with NO plans table (FK-free) and stages an edge", () => {
43
+ const db = migratedDb();
44
+ insertEdge(db, "o/r#2", "o/r#1"); // neither endpoint has a plans row — must NOT FK-fail
45
+ const row = db
46
+ .prepare("SELECT plan_key, depends_on_plan_key, package FROM admitted_plan_deps WHERE plan_key = ?")
47
+ .get("o/r#2") as { plan_key: string; depends_on_plan_key: string; package: string };
48
+ assertEquals(row.plan_key, "o/r#2");
49
+ assertEquals(row.depends_on_plan_key, "o/r#1");
50
+ assertEquals(row.package, "@nanobpm/p");
51
+ });
52
+
53
+ test("migration 045 stages an admitted epic with no plans row", () => {
54
+ const db = migratedDb();
55
+ insertEpic(db, "o/r#1");
56
+ const row = db.prepare("SELECT plan_key, base_branch FROM admitted_epics WHERE plan_key = ?").get("o/r#1") as {
57
+ plan_key: string;
58
+ base_branch: string;
59
+ };
60
+ assertEquals(row.plan_key, "o/r#1");
61
+ assertEquals(row.base_branch, "epic/a");
62
+ });
63
+
64
+ test("migration 045 CHECK rejects a self-edge", () => {
65
+ const db = migratedDb();
66
+ let threw = false;
67
+ try {
68
+ insertEdge(db, "o/r#1", "o/r#1");
69
+ } catch {
70
+ threw = true;
71
+ }
72
+ assertEquals(threw, true);
73
+ });
74
+
75
+ test("migration 045 PRIMARY KEY rejects a duplicate consumer→producer edge", () => {
76
+ const db = migratedDb();
77
+ insertEdge(db, "o/r#2", "o/r#1");
78
+ let threw = false;
79
+ try {
80
+ insertEdge(db, "o/r#2", "o/r#1");
81
+ } catch {
82
+ threw = true;
83
+ }
84
+ assertEquals(threw, true);
85
+ });
86
+
87
+ test("migration 045 PRIMARY KEY rejects a duplicate staged epic", () => {
88
+ const db = migratedDb();
89
+ insertEpic(db, "o/r#1");
90
+ let threw = false;
91
+ try {
92
+ insertEpic(db, "o/r#1");
93
+ } catch {
94
+ threw = true;
95
+ }
96
+ assertEquals(threw, true);
97
+ });