@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 +19 -0
- package/SPEC.md +10 -4
- package/app/admittedStaging.test.ts +98 -0
- package/app/contracts.ts +10 -1
- package/app/delivery.ts +61 -0
- package/app/epicBucket.test.ts +57 -0
- package/app/epicSetValidation.test.ts +169 -0
- package/app/migration045.test.ts +97 -0
- package/app/plan.ts +401 -12
- package/app/planGateway.test.ts +106 -0
- package/app/pollUserTasks.test.ts +7 -2
- package/app/service.ts +18 -1
- package/app/userTasks.test.ts +24 -0
- package/app/userTasks.ts +11 -0
- package/db/migrations/043_user_tasks_subject_title.sql +24 -0
- package/db/migrations/044_plan_list_bucket.sql +32 -0
- package/db/migrations/045_epic_set_admission_staging.sql +56 -0
- package/e2e/support/engine-client.ts +24 -8
- package/openapi.yaml +245 -0
- package/operations/acknowledgeEpic.test.ts +130 -0
- package/operations/acknowledgeEpic.ts +63 -0
- package/operations/startEpicSet.admission.integration.test.ts +572 -0
- package/operations/startEpicSet.ts +217 -0
- package/operations/startPlanFanout.ts +5 -56
- package/package.json +1 -1
- package/pages/epic.page.json +15 -3
- package/pages/overview.page.json +13 -1
- package/pages/tasks.page.json +30 -10
- package/scripts/pages-contract.test.ts +17 -18
- package/workers/record-plan/worker.test.ts +1 -0
- package/workers/record-plan/worker.ts +2 -2
- package/workers/record-results/worker.test.ts +19 -1
- package/workers/record-results/worker.ts +3 -3
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 (
|
|
537
|
-
|
|
538
|
-
status
|
|
539
|
-
|
|
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
|
|
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
|
+
});
|