@nanobpm/nano-workforce 0.91.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 +12 -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/service.ts +13 -1
- 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/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
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
// Integration + unit coverage for the SET/BATCH admission door (issue #292, slice S2) — driven
|
|
2
|
+
// through the operation EDGE `startEpicSet`. Proves the all-or-nothing admission contract:
|
|
3
|
+
// • a valid DAG of epics admits every member AND stages every edge into `admitted_plan_deps`
|
|
4
|
+
// (S2's FK-free staging), writing NOTHING to the durable `plans` / `plan_deps` graph (that is S3);
|
|
5
|
+
// • a submitted cycle is rejected at the offending edge with NO partial start / NO edge staged;
|
|
6
|
+
// • an edge naming an epic outside the set is a clean 400;
|
|
7
|
+
// • a per-epic admission failure (base rules / shared-base) maps to the same 4xx as the single door;
|
|
8
|
+
// • re-submitting the identical set is a no-op (no duplicate edge, no double-admit).
|
|
9
|
+
// It runs the real delegate against an in-memory app/data/engine and a faked github transport, exactly
|
|
10
|
+
// like startPlanFanout.admission.integration.test.ts — no network, deterministic on a single run.
|
|
11
|
+
// A SQLite-backed FK regression (foreign_keys=ON, migrations 041+043 applied) additionally proves S2
|
|
12
|
+
// admits with NO `plans` row present and stages FK-free, never FK-failing on `plan_deps`.
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { DatabaseSync } from "node:sqlite";
|
|
15
|
+
import { test } from "node:test";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { assertEquals } from "#test-assert";
|
|
18
|
+
import type { AppApi } from "@nanobpm/urban";
|
|
19
|
+
import { resetDefaultBranchCache } from "../app/github.ts";
|
|
20
|
+
import { noopLog } from "../test/log.ts";
|
|
21
|
+
import startEpicSet from "./startEpicSet.ts";
|
|
22
|
+
|
|
23
|
+
// ── in-memory github model (mirrors startPlanFanout.admission.integration.test.ts) ───────────────
|
|
24
|
+
interface GithubState {
|
|
25
|
+
repo: string;
|
|
26
|
+
defaultBranch: string;
|
|
27
|
+
branches: Set<string>;
|
|
28
|
+
creates: { ref: string; sha: string }[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function githubFetch(state: GithubState) {
|
|
32
|
+
return (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
|
33
|
+
const u = new URL(String(url));
|
|
34
|
+
const method = (init?.method ?? "GET").toUpperCase();
|
|
35
|
+
const path = u.pathname;
|
|
36
|
+
const json = (obj: unknown, status = 200) =>
|
|
37
|
+
new Response(JSON.stringify(obj), { status, headers: { "content-type": "application/json" } });
|
|
38
|
+
if (method === "GET" && path === `/repos/${state.repo}`) {
|
|
39
|
+
return Promise.resolve(json({ default_branch: state.defaultBranch }));
|
|
40
|
+
}
|
|
41
|
+
const refPrefix = `/repos/${state.repo}/git/ref/heads/`;
|
|
42
|
+
if (method === "GET" && path.startsWith(refPrefix)) {
|
|
43
|
+
const branch = decodeURIComponent(path.slice(refPrefix.length));
|
|
44
|
+
if (!state.branches.has(branch)) return Promise.resolve(new Response("Not Found", { status: 404 }));
|
|
45
|
+
return Promise.resolve(json({ ref: `refs/heads/${branch}`, object: { sha: `${branch}-sha` } }));
|
|
46
|
+
}
|
|
47
|
+
if (method === "POST" && path === `/repos/${state.repo}/git/refs`) {
|
|
48
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
49
|
+
const bodyObj = JSON.parse(String(init?.body ?? "{}")) as { ref?: string; sha?: string };
|
|
50
|
+
const ref = String(bodyObj.ref ?? "");
|
|
51
|
+
const sha = String(bodyObj.sha ?? "");
|
|
52
|
+
const branch = ref.replace(/^refs\/heads\//, "");
|
|
53
|
+
if (state.branches.has(branch)) return Promise.resolve(json({ message: "Reference already exists" }, 422));
|
|
54
|
+
state.creates.push({ ref, sha });
|
|
55
|
+
state.branches.add(branch);
|
|
56
|
+
return Promise.resolve(json({ ref }, 201));
|
|
57
|
+
}
|
|
58
|
+
return Promise.resolve(new Response(`unexpected ${method} ${path}`, { status: 500 }));
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function withGithub<T>(state: GithubState, fn: () => Promise<T>): Promise<T> {
|
|
63
|
+
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
64
|
+
const prevTok = process.env["GITHUB_TOKEN"];
|
|
65
|
+
const prevFetch = globalThis.fetch;
|
|
66
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
|
|
67
|
+
process.env["GITHUB_TOKEN"] = "tok";
|
|
68
|
+
resetDefaultBranchCache();
|
|
69
|
+
globalThis.fetch = githubFetch(state) as typeof fetch;
|
|
70
|
+
try {
|
|
71
|
+
return await fn();
|
|
72
|
+
} finally {
|
|
73
|
+
resetDefaultBranchCache();
|
|
74
|
+
globalThis.fetch = prevFetch;
|
|
75
|
+
if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
76
|
+
else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
|
|
77
|
+
if (prevTok === undefined) delete process.env["GITHUB_TOKEN"];
|
|
78
|
+
else process.env["GITHUB_TOKEN"] = prevTok;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── in-memory app (data + engine) ────────────────────────────────────────────────────────────────
|
|
83
|
+
// The delegate reads `plans` (admitPlan's shared-base guard) and STAGES into `admitted_epics` +
|
|
84
|
+
// `admitted_plan_deps` (recordAdmittedEpic / recordAdmittedPlanDep) — it never writes the durable
|
|
85
|
+
// `plan_deps`. `admitted_plan_deps` is keyed on `plan_key` but holds MANY rows per key (composite
|
|
86
|
+
// edge), so the generic table's `get` (first row for the key) is not used for it — the delegate only
|
|
87
|
+
// `find`s + `insert`s.
|
|
88
|
+
function makeApp(seedPlans: Record<string, unknown>[] = []) {
|
|
89
|
+
const tables = new Map<string, Record<string, unknown>[]>();
|
|
90
|
+
tables.set("plans", [...seedPlans]);
|
|
91
|
+
const started: { processDefinitionId: string; variables?: Record<string, unknown> }[] = [];
|
|
92
|
+
const table = (name: string, key: string) => {
|
|
93
|
+
const rows = tables.get(name) ?? (() => {
|
|
94
|
+
const fresh: Record<string, unknown>[] = [];
|
|
95
|
+
tables.set(name, fresh);
|
|
96
|
+
return fresh;
|
|
97
|
+
})();
|
|
98
|
+
return {
|
|
99
|
+
get: (k: unknown) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
|
|
100
|
+
find: (q: Record<string, unknown>) =>
|
|
101
|
+
Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
|
|
102
|
+
insert: (r: Record<string, unknown>) => {
|
|
103
|
+
rows.push(r);
|
|
104
|
+
return Promise.resolve(r);
|
|
105
|
+
},
|
|
106
|
+
update: (k: unknown, patch: Record<string, unknown>) => {
|
|
107
|
+
const row = rows.find((r) => r[key] === k);
|
|
108
|
+
if (row) Object.assign(row, patch);
|
|
109
|
+
return Promise.resolve(row);
|
|
110
|
+
},
|
|
111
|
+
delete: (k: unknown) => {
|
|
112
|
+
const i = rows.findIndex((r) => r[key] === k);
|
|
113
|
+
if (i >= 0) rows.splice(i, 1);
|
|
114
|
+
return Promise.resolve();
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
const app = {
|
|
119
|
+
data: { table },
|
|
120
|
+
engine: {
|
|
121
|
+
createInstance: (req: { processDefinitionId: string; variables?: Record<string, unknown> }) => {
|
|
122
|
+
started.push(req);
|
|
123
|
+
return Promise.resolve({ processInstanceKey: "PI-1" });
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
log: noopLog(),
|
|
127
|
+
} as any as AppApi;
|
|
128
|
+
return { app, started, tables };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function input(body: unknown) {
|
|
132
|
+
return {
|
|
133
|
+
req: { method: "POST", path: "/", query: new URLSearchParams(), headers: new Headers(), text: async () => "" } as any,
|
|
134
|
+
params: {},
|
|
135
|
+
query: {},
|
|
136
|
+
body,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function freshGithub(repo: string, extraBranches: string[] = []): GithubState {
|
|
141
|
+
return { repo, defaultBranch: "main", branches: new Set(["main", ...extraBranches]), creates: [] };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const REPO = "owner/repo";
|
|
145
|
+
const call = (app: AppApi, body: unknown) => startEpicSet(input(body), app) as Promise<any>;
|
|
146
|
+
// S2 stages into `admitted_plan_deps` / `admitted_epics` and must NEVER touch the durable `plan_deps`.
|
|
147
|
+
const admittedDepRows = (tables: Map<string, Record<string, unknown>[]>) =>
|
|
148
|
+
tables.get("admitted_plan_deps") ?? [];
|
|
149
|
+
const admittedEpicRows = (tables: Map<string, Record<string, unknown>[]>) =>
|
|
150
|
+
tables.get("admitted_epics") ?? [];
|
|
151
|
+
const planDepsRows = (tables: Map<string, Record<string, unknown>[]>) => tables.get("plan_deps") ?? [];
|
|
152
|
+
|
|
153
|
+
// ── Happy path: a valid DAG admits every member and persists every edge ──────────────────────────
|
|
154
|
+
test("valid DAG: admits all epics and persists all edges", async () => {
|
|
155
|
+
const gh = freshGithub(REPO); // epic/* bases auto-created
|
|
156
|
+
await withGithub(gh, async () => {
|
|
157
|
+
const { app, started, tables } = makeApp();
|
|
158
|
+
const res = await call(app, {
|
|
159
|
+
epics: [
|
|
160
|
+
{ issue: `${REPO}#1`, baseBranch: "epic/producer" },
|
|
161
|
+
{ issue: `${REPO}#2`, baseBranch: "epic/consumer" },
|
|
162
|
+
],
|
|
163
|
+
deps: [{ consumer: `${REPO}#2`, producer: `${REPO}#1`, package: "@scope/pkg", capabilityRef: `${REPO}#1` }],
|
|
164
|
+
});
|
|
165
|
+
assertEquals(res.status, 202);
|
|
166
|
+
assertEquals(res.body.epics.length, 2);
|
|
167
|
+
assertEquals(res.body.roots, [`${REPO}#1`]); // #1 has no inbound edge
|
|
168
|
+
assertEquals(res.body.edges, [
|
|
169
|
+
{ consumer: `${REPO}#2`, producer: `${REPO}#1`, package: "@scope/pkg", capabilityRef: `${REPO}#1` },
|
|
170
|
+
]);
|
|
171
|
+
// S2 admits + STAGES (epics and edges) but NEVER starts an epic and NEVER writes the durable
|
|
172
|
+
// `plans` / `plan_deps` graph (that is S3).
|
|
173
|
+
assertEquals(started.length, 0);
|
|
174
|
+
assertEquals(planDepsRows(tables).length, 0); // durable plan_deps untouched by S2
|
|
175
|
+
const epicRows = admittedEpicRows(tables);
|
|
176
|
+
assertEquals(epicRows.length, 2); // both epics staged (roots included) for S3 to materialize
|
|
177
|
+
const rows = admittedDepRows(tables);
|
|
178
|
+
assertEquals(rows.length, 1);
|
|
179
|
+
assertEquals(rows[0].plan_key, `${REPO}#2`);
|
|
180
|
+
assertEquals(rows[0].depends_on_plan_key, `${REPO}#1`);
|
|
181
|
+
assertEquals(rows[0].package, "@scope/pkg");
|
|
182
|
+
assertEquals(rows[0].capability_ref, `${REPO}#1`);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("independent roots: a set with no deps admits every epic as a root, no edges", async () => {
|
|
187
|
+
const gh = freshGithub(REPO);
|
|
188
|
+
await withGithub(gh, async () => {
|
|
189
|
+
const { app, tables } = makeApp();
|
|
190
|
+
const res = await call(app, {
|
|
191
|
+
epics: [
|
|
192
|
+
{ issue: `${REPO}#1`, baseBranch: "epic/a" },
|
|
193
|
+
{ issue: `${REPO}#2`, baseBranch: "epic/b" },
|
|
194
|
+
],
|
|
195
|
+
});
|
|
196
|
+
assertEquals(res.status, 202);
|
|
197
|
+
assertEquals(res.body.roots.sort(), [`${REPO}#1`, `${REPO}#2`]);
|
|
198
|
+
assertEquals(res.body.edges, []);
|
|
199
|
+
assertEquals(admittedDepRows(tables).length, 0);
|
|
200
|
+
assertEquals(admittedEpicRows(tables).length, 2); // both roots staged
|
|
201
|
+
assertEquals(planDepsRows(tables).length, 0);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// ── Cycle: rejected at the offending edge, nothing half-started ──────────────────────────────────
|
|
206
|
+
test("cycle: rejected 400 with no edge persisted and no branch created", async () => {
|
|
207
|
+
const gh = freshGithub(REPO);
|
|
208
|
+
await withGithub(gh, async () => {
|
|
209
|
+
const { app, tables } = makeApp();
|
|
210
|
+
const res = await call(app, {
|
|
211
|
+
epics: [
|
|
212
|
+
{ issue: `${REPO}#1`, baseBranch: "epic/a" },
|
|
213
|
+
{ issue: `${REPO}#2`, baseBranch: "epic/b" },
|
|
214
|
+
],
|
|
215
|
+
deps: [
|
|
216
|
+
{ consumer: `${REPO}#2`, producer: `${REPO}#1`, package: "p", capabilityRef: `${REPO}#1` },
|
|
217
|
+
{ consumer: `${REPO}#1`, producer: `${REPO}#2`, package: "p", capabilityRef: `${REPO}#2` },
|
|
218
|
+
],
|
|
219
|
+
});
|
|
220
|
+
assertEquals(res.status, 400);
|
|
221
|
+
assertEquals(typeof res.body.error, "string");
|
|
222
|
+
assertEquals(admittedDepRows(tables).length, 0); // nothing staged
|
|
223
|
+
assertEquals(gh.creates, []); // cycle rejected BEFORE any admitPlan side effect
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// ── Dangling edge: an endpoint not in the set is a clean 400, before admission ───────────────────
|
|
228
|
+
test("edge naming an epic outside the set: 400, nothing persisted/created", async () => {
|
|
229
|
+
const gh = freshGithub(REPO);
|
|
230
|
+
await withGithub(gh, async () => {
|
|
231
|
+
const { app, tables } = makeApp();
|
|
232
|
+
const res = await call(app, {
|
|
233
|
+
epics: [{ issue: `${REPO}#1`, baseBranch: "epic/a" }],
|
|
234
|
+
deps: [{ consumer: `${REPO}#1`, producer: `${REPO}#999`, package: "p", capabilityRef: `${REPO}#999` }],
|
|
235
|
+
});
|
|
236
|
+
assertEquals(res.status, 400);
|
|
237
|
+
assertEquals(typeof res.body.error, "string");
|
|
238
|
+
assertEquals(admittedDepRows(tables).length, 0);
|
|
239
|
+
assertEquals(gh.creates, []);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("self-edge: 400", async () => {
|
|
244
|
+
const gh = freshGithub(REPO);
|
|
245
|
+
await withGithub(gh, async () => {
|
|
246
|
+
const { app } = makeApp();
|
|
247
|
+
const res = await call(app, {
|
|
248
|
+
epics: [{ issue: `${REPO}#1`, baseBranch: "epic/a" }],
|
|
249
|
+
deps: [{ consumer: `${REPO}#1`, producer: `${REPO}#1`, package: "p", capabilityRef: `${REPO}#1` }],
|
|
250
|
+
});
|
|
251
|
+
assertEquals(res.status, 400);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// ── Per-epic admission failure maps to the same 4xx as the single door, nothing persisted ────────
|
|
256
|
+
test("unadmittable epic (default base without confirm): 400, no edge persisted", async () => {
|
|
257
|
+
const gh = freshGithub(REPO);
|
|
258
|
+
await withGithub(gh, async () => {
|
|
259
|
+
const { app, tables } = makeApp();
|
|
260
|
+
const res = await call(app, {
|
|
261
|
+
epics: [
|
|
262
|
+
{ issue: `${REPO}#1`, baseBranch: "epic/ok" },
|
|
263
|
+
{ issue: `${REPO}#2`, baseBranch: "main" }, // default branch without confirmDefaultBase → 400
|
|
264
|
+
],
|
|
265
|
+
deps: [{ consumer: `${REPO}#2`, producer: `${REPO}#1`, package: "p", capabilityRef: `${REPO}#1` }],
|
|
266
|
+
});
|
|
267
|
+
assertEquals(res.status, 400);
|
|
268
|
+
assertEquals(admittedDepRows(tables).length, 0); // edges staged only after ALL epics admit
|
|
269
|
+
assertEquals(admittedEpicRows(tables).length, 0); // no epic staged on a partial-admit reject
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("shared custom base with an active plan: 409 (shared-base guard)", async () => {
|
|
274
|
+
const gh = freshGithub(REPO, ["epic/shared"]);
|
|
275
|
+
await withGithub(gh, async () => {
|
|
276
|
+
const { app } = makeApp([
|
|
277
|
+
{ plan_key: `${REPO}#98`, repo: REPO, base_branch: "epic/shared", status: "planning" },
|
|
278
|
+
]);
|
|
279
|
+
const res = await call(app, {
|
|
280
|
+
epics: [{ issue: `${REPO}#1`, baseBranch: "epic/shared" }],
|
|
281
|
+
});
|
|
282
|
+
assertEquals(res.status, 409);
|
|
283
|
+
assertEquals(typeof res.body.error, "string");
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// ── Intra-set shared-base collision: two members reaching for the SAME custom base ───────────────
|
|
288
|
+
// admitPlan's rule 4 only sees DURABLE `plans` rows, and S2 materializes none, so without an
|
|
289
|
+
// in-request guard two epics in one set could both grab the same custom integration branch and
|
|
290
|
+
// silently defeat ADR 0003 rule 4. The door must reject the collision itself, all-or-nothing.
|
|
291
|
+
test("intra-set: two epics on the same custom base, neither opting in → 409, nothing staged", async () => {
|
|
292
|
+
const gh = freshGithub(REPO, ["epic/shared"]);
|
|
293
|
+
await withGithub(gh, async () => {
|
|
294
|
+
const { app, tables } = makeApp();
|
|
295
|
+
const res = await call(app, {
|
|
296
|
+
epics: [
|
|
297
|
+
{ issue: `${REPO}#1`, baseBranch: "epic/shared" },
|
|
298
|
+
{ issue: `${REPO}#2`, baseBranch: "epic/shared" },
|
|
299
|
+
],
|
|
300
|
+
});
|
|
301
|
+
assertEquals(res.status, 409);
|
|
302
|
+
assertEquals(typeof res.body.error, "string");
|
|
303
|
+
assertEquals(admittedEpicRows(tables).length, 0); // all-or-nothing: nothing staged on reject
|
|
304
|
+
assertEquals(admittedDepRows(tables).length, 0);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("intra-set: same custom base admitted when the later epic opts in with allowSharedBase", async () => {
|
|
309
|
+
const gh = freshGithub(REPO, ["epic/shared"]);
|
|
310
|
+
await withGithub(gh, async () => {
|
|
311
|
+
const { app, tables } = makeApp();
|
|
312
|
+
const res = await call(app, {
|
|
313
|
+
epics: [
|
|
314
|
+
{ issue: `${REPO}#1`, baseBranch: "epic/shared" },
|
|
315
|
+
{ issue: `${REPO}#2`, baseBranch: "epic/shared", allowSharedBase: true },
|
|
316
|
+
],
|
|
317
|
+
});
|
|
318
|
+
assertEquals(res.status, 202);
|
|
319
|
+
assertEquals(admittedEpicRows(tables).length, 2);
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("intra-set: two epics on the DEFAULT base (both confirmed) do NOT collide", async () => {
|
|
324
|
+
const gh = freshGithub(REPO);
|
|
325
|
+
await withGithub(gh, async () => {
|
|
326
|
+
const { app, tables } = makeApp();
|
|
327
|
+
const res = await call(app, {
|
|
328
|
+
epics: [
|
|
329
|
+
{ issue: `${REPO}#1`, baseBranch: "main", confirmDefaultBase: true },
|
|
330
|
+
{ issue: `${REPO}#2`, baseBranch: "main", confirmDefaultBase: true },
|
|
331
|
+
],
|
|
332
|
+
});
|
|
333
|
+
assertEquals(res.status, 202); // default branch is exempt from the shared-base guard (rule 3/4)
|
|
334
|
+
assertEquals(admittedEpicRows(tables).length, 2);
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// ── Idempotency: re-submitting the identical set records no duplicate edge ────────────────────────
|
|
339
|
+
test("idempotent: re-submitting the identical set does not duplicate edges", async () => {
|
|
340
|
+
const gh = freshGithub(REPO);
|
|
341
|
+
await withGithub(gh, async () => {
|
|
342
|
+
const { app, tables } = makeApp();
|
|
343
|
+
const set = {
|
|
344
|
+
epics: [
|
|
345
|
+
{ issue: `${REPO}#1`, baseBranch: "epic/producer" },
|
|
346
|
+
{ issue: `${REPO}#2`, baseBranch: "epic/consumer" },
|
|
347
|
+
],
|
|
348
|
+
deps: [{ consumer: `${REPO}#2`, producer: `${REPO}#1`, package: "@scope/pkg", capabilityRef: `${REPO}#1` }],
|
|
349
|
+
};
|
|
350
|
+
const first = await call(app, set);
|
|
351
|
+
assertEquals(first.status, 202);
|
|
352
|
+
assertEquals(admittedDepRows(tables).length, 1);
|
|
353
|
+
assertEquals(admittedEpicRows(tables).length, 2);
|
|
354
|
+
const second = await call(app, set);
|
|
355
|
+
assertEquals(second.status, 202);
|
|
356
|
+
assertEquals(admittedDepRows(tables).length, 1); // no duplicate edge on retry
|
|
357
|
+
assertEquals(admittedEpicRows(tables).length, 2); // no duplicate epic on retry
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// ── Malformed body / references ──────────────────────────────────────────────────────────────────
|
|
362
|
+
test("empty epics array: 400", async () => {
|
|
363
|
+
const gh = freshGithub(REPO);
|
|
364
|
+
await withGithub(gh, async () => {
|
|
365
|
+
const { app } = makeApp();
|
|
366
|
+
const res = await call(app, { epics: [] });
|
|
367
|
+
assertEquals(res.status, 400);
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test("unparseable epic reference: 400", async () => {
|
|
372
|
+
const gh = freshGithub(REPO);
|
|
373
|
+
await withGithub(gh, async () => {
|
|
374
|
+
const { app } = makeApp();
|
|
375
|
+
const res = await call(app, { epics: [{ issue: "not-an-issue", baseBranch: "epic/a" }] });
|
|
376
|
+
assertEquals(res.status, 400);
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// `deps`, when provided, MUST be an array. A non-array `deps` (e.g. an object) must be a clean 400,
|
|
381
|
+
// not silently coerced to `[]` — which would admit the set while dropping every declared edge.
|
|
382
|
+
test("non-array deps: 400, nothing admitted", async () => {
|
|
383
|
+
const gh = freshGithub(REPO);
|
|
384
|
+
await withGithub(gh, async () => {
|
|
385
|
+
const { app } = makeApp();
|
|
386
|
+
const res = await call(app, {
|
|
387
|
+
epics: [{ issue: `${REPO}#1`, baseBranch: "epic/a" }],
|
|
388
|
+
deps: { consumer: `${REPO}#1`, producer: `${REPO}#1` },
|
|
389
|
+
});
|
|
390
|
+
assertEquals(res.status, 400);
|
|
391
|
+
assertEquals(typeof res.body.error, "string");
|
|
392
|
+
assertEquals(gh.creates, []); // rejected BEFORE any admitPlan side effect
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// ── Reference extraction enforces EXACTLY-ONE-of issue|url (the operation contract) ──────────────
|
|
397
|
+
test("epic naming BOTH issue and url: 400, nothing created", async () => {
|
|
398
|
+
const gh = freshGithub(REPO);
|
|
399
|
+
await withGithub(gh, async () => {
|
|
400
|
+
const { app } = makeApp();
|
|
401
|
+
const res = await call(app, {
|
|
402
|
+
epics: [{ issue: `${REPO}#1`, url: `https://github.com/${REPO}/issues/1`, baseBranch: "epic/a" }],
|
|
403
|
+
});
|
|
404
|
+
assertEquals(res.status, 400);
|
|
405
|
+
assertEquals(typeof res.body.error, "string");
|
|
406
|
+
assertEquals(gh.creates, []); // rejected BEFORE any admitPlan side effect
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test("epic with issue:null falls through to a valid url (key-presence must not win)", async () => {
|
|
411
|
+
const gh = freshGithub(REPO);
|
|
412
|
+
await withGithub(gh, async () => {
|
|
413
|
+
const { app } = makeApp();
|
|
414
|
+
const res = await call(app, {
|
|
415
|
+
epics: [{ issue: null, url: `https://github.com/${REPO}/issues/7`, baseBranch: "epic/a" }],
|
|
416
|
+
});
|
|
417
|
+
assertEquals(res.status, 202);
|
|
418
|
+
assertEquals(res.body.epics, [{ planKey: `${REPO}#7`, baseBranch: "epic/a" }]);
|
|
419
|
+
});
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test("epic with neither issue nor url (both null): 400", async () => {
|
|
423
|
+
const gh = freshGithub(REPO);
|
|
424
|
+
await withGithub(gh, async () => {
|
|
425
|
+
const { app } = makeApp();
|
|
426
|
+
const res = await call(app, { epics: [{ issue: null, url: null, baseBranch: "epic/a" }] });
|
|
427
|
+
assertEquals(res.status, 400);
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test("missing body: 400", async () => {
|
|
432
|
+
const gh = freshGithub(REPO);
|
|
433
|
+
await withGithub(gh, async () => {
|
|
434
|
+
const { app } = makeApp();
|
|
435
|
+
const res = await call(app, undefined);
|
|
436
|
+
assertEquals(res.status, 400);
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// ── Malformed deps[] entries map to a clean 400 (never an uncaught TypeError/500), nothing persisted.
|
|
441
|
+
// `deps` arrives from an untyped request body, so a null/non-object entry or a non-string field must
|
|
442
|
+
// be rejected as an EpicSetValidationError → 400, with no edge persisted and no branch created.
|
|
443
|
+
for (const [label, badDep] of [
|
|
444
|
+
["null entry", null],
|
|
445
|
+
["non-object entry (string)", "owner/repo#1"],
|
|
446
|
+
["empty object (missing endpoints)", {}],
|
|
447
|
+
["non-string consumer", { consumer: 1, producer: `${REPO}#1`, package: "p", capabilityRef: `${REPO}#1` }],
|
|
448
|
+
["non-string package", { consumer: `${REPO}#2`, producer: `${REPO}#1`, package: 7, capabilityRef: `${REPO}#1` }],
|
|
449
|
+
] as const) {
|
|
450
|
+
test(`malformed dep (${label}): 400, nothing persisted/created`, async () => {
|
|
451
|
+
const gh = freshGithub(REPO);
|
|
452
|
+
await withGithub(gh, async () => {
|
|
453
|
+
const { app, tables } = makeApp();
|
|
454
|
+
const res = await call(app, {
|
|
455
|
+
epics: [
|
|
456
|
+
{ issue: `${REPO}#1`, baseBranch: "epic/a" },
|
|
457
|
+
{ issue: `${REPO}#2`, baseBranch: "epic/b" },
|
|
458
|
+
],
|
|
459
|
+
deps: [badDep],
|
|
460
|
+
});
|
|
461
|
+
assertEquals(res.status, 400);
|
|
462
|
+
assertEquals(typeof res.body.error, "string");
|
|
463
|
+
assertEquals(admittedDepRows(tables).length, 0);
|
|
464
|
+
assertEquals(gh.creates, []); // rejected BEFORE any admitPlan side effect
|
|
465
|
+
});
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ── SQLite-backed FK regression (issue #292 S2) ──────────────────────────────────────────────────
|
|
470
|
+
// The in-memory data layer above does NOT enforce SQLite constraints — notably `plan_deps.plan_key`'s
|
|
471
|
+
// FK to `plans` (041). So it could not have caught the FK-violation the S2 door originally shipped:
|
|
472
|
+
// it wrote validated edges into `plan_deps` while admitting via `admitPlan` (which creates NO `plans`
|
|
473
|
+
// row), so a first-time set submission FK-failed (500). The fix (design decision on #292): S2 admits
|
|
474
|
+
// + STAGES into its own FK-free `admitted_epics` / `admitted_plan_deps` (043); S3 materializes the
|
|
475
|
+
// durable graph. These tests drive the real delegate against a real `node:sqlite` db with
|
|
476
|
+
// foreign_keys=ON and migrations 041+043 applied, proving the door no longer FK-fails and never
|
|
477
|
+
// writes `plan_deps`.
|
|
478
|
+
|
|
479
|
+
/** A DataLayer over a real in-memory SQLite db with foreign_keys ON, migrations 041+043 applied, and a
|
|
480
|
+
* minimal `plans` shape (the columns admitPlan's shared-base guard reads + the PK 041's FK references).
|
|
481
|
+
* Seeds NO plans rows by default — that is exactly the first-submission condition the old code
|
|
482
|
+
* FK-failed on. */
|
|
483
|
+
function makeSqliteApp(
|
|
484
|
+
seedPlans: { plan_key: string; repo: string; base_branch: string; status: string }[] = [],
|
|
485
|
+
) {
|
|
486
|
+
const db = new DatabaseSync(":memory:");
|
|
487
|
+
db.exec("PRAGMA foreign_keys = ON;");
|
|
488
|
+
db.exec("CREATE TABLE plans (plan_key TEXT PRIMARY KEY, repo TEXT, base_branch TEXT, status TEXT);");
|
|
489
|
+
for (const p of seedPlans) {
|
|
490
|
+
db.prepare("INSERT INTO plans (plan_key, repo, base_branch, status) VALUES (?, ?, ?, ?)")
|
|
491
|
+
.run(p.plan_key, p.repo, p.base_branch, p.status);
|
|
492
|
+
}
|
|
493
|
+
for (const f of ["041_inter_epic_plan_deps.sql", "045_epic_set_admission_staging.sql"]) {
|
|
494
|
+
db.exec(readFileSync(fileURLToPath(new URL(`../db/migrations/${f}`, import.meta.url)), "utf8"));
|
|
495
|
+
}
|
|
496
|
+
const q = (id: string) => `"${id.replace(/"/g, '""')}"`;
|
|
497
|
+
const coerce = (v: unknown) => (v === null ? null : typeof v === "boolean" ? (v ? 1 : 0) : v) as any;
|
|
498
|
+
const table = (name: string, key: string) => ({
|
|
499
|
+
get: (k: unknown) =>
|
|
500
|
+
Promise.resolve(db.prepare(`SELECT * FROM ${q(name)} WHERE ${q(key)} = ?`).get(coerce(k)) ?? null),
|
|
501
|
+
find: (query: Record<string, unknown>) => {
|
|
502
|
+
const keys = Object.keys(query);
|
|
503
|
+
const clause = keys.length ? `WHERE ${keys.map((k) => `${q(k)} = ?`).join(" AND ")}` : "";
|
|
504
|
+
return Promise.resolve(db.prepare(`SELECT * FROM ${q(name)} ${clause}`).all(...keys.map((k) => coerce(query[k]))));
|
|
505
|
+
},
|
|
506
|
+
insert: (r: Record<string, unknown>) => {
|
|
507
|
+
const keys = Object.keys(r).filter((k) => r[k] !== undefined);
|
|
508
|
+
db.prepare(`INSERT INTO ${q(name)} (${keys.map(q).join(", ")}) VALUES (${keys.map(() => "?").join(", ")})`)
|
|
509
|
+
.run(...keys.map((k) => coerce(r[k])));
|
|
510
|
+
return Promise.resolve(r);
|
|
511
|
+
},
|
|
512
|
+
update: () => Promise.resolve(null),
|
|
513
|
+
delete: () => Promise.resolve(),
|
|
514
|
+
});
|
|
515
|
+
const app = {
|
|
516
|
+
data: { table },
|
|
517
|
+
engine: { createInstance: () => Promise.resolve({ processInstanceKey: "PI-1" }) },
|
|
518
|
+
log: noopLog(),
|
|
519
|
+
} as any as AppApi;
|
|
520
|
+
return { app, db };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
test("SQLite (FK ON): admits a set with NO plans row, stages FK-free, never writes plan_deps", async () => {
|
|
524
|
+
const gh = freshGithub(REPO);
|
|
525
|
+
await withGithub(gh, async () => {
|
|
526
|
+
const { app, db } = makeSqliteApp(); // no plans rows — the first-submission FK-failure condition
|
|
527
|
+
try {
|
|
528
|
+
const res = await call(app, {
|
|
529
|
+
epics: [
|
|
530
|
+
{ issue: `${REPO}#1`, baseBranch: "epic/producer" },
|
|
531
|
+
{ issue: `${REPO}#2`, baseBranch: "epic/consumer" },
|
|
532
|
+
],
|
|
533
|
+
deps: [{ consumer: `${REPO}#2`, producer: `${REPO}#1`, package: "@scope/pkg", capabilityRef: `${REPO}#1` }],
|
|
534
|
+
});
|
|
535
|
+
// Under the old code this was a 500 FK violation on `plan_deps.plan_key`.
|
|
536
|
+
assertEquals(res.status, 202);
|
|
537
|
+
const planDepN = db.prepare("SELECT COUNT(*) AS n FROM plan_deps").get() as { n: number };
|
|
538
|
+
assertEquals(planDepN.n, 0); // durable plan_deps untouched by S2
|
|
539
|
+
const staged = db
|
|
540
|
+
.prepare("SELECT plan_key, depends_on_plan_key, package FROM admitted_plan_deps")
|
|
541
|
+
.all() as { plan_key: string; depends_on_plan_key: string; package: string }[];
|
|
542
|
+
assertEquals(staged.length, 1);
|
|
543
|
+
assertEquals(staged[0].plan_key, `${REPO}#2`);
|
|
544
|
+
assertEquals(staged[0].depends_on_plan_key, `${REPO}#1`);
|
|
545
|
+
const epicN = db.prepare("SELECT COUNT(*) AS n FROM admitted_epics").get() as { n: number };
|
|
546
|
+
assertEquals(epicN.n, 2); // both epics staged for S3 to materialize
|
|
547
|
+
} finally {
|
|
548
|
+
db.close();
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
test("SQLite: plan_deps FK rejects an unbacked edge, but the admitted_plan_deps staging twin accepts it", () => {
|
|
554
|
+
const { db } = makeSqliteApp();
|
|
555
|
+
try {
|
|
556
|
+
const edge = (t: string) =>
|
|
557
|
+
`INSERT INTO ${t} (plan_key, depends_on_plan_key, package, capability_ref, created_at) VALUES ('o/r#2','o/r#1','p','o/r#1','t')`;
|
|
558
|
+
let fkThrew = false;
|
|
559
|
+
try {
|
|
560
|
+
db.prepare(edge("plan_deps")).run(); // no plans row for o/r#2 → FK violation
|
|
561
|
+
} catch {
|
|
562
|
+
fkThrew = true;
|
|
563
|
+
}
|
|
564
|
+
assertEquals(fkThrew, true); // proves plan_deps.plan_key REFERENCES plans(plan_key) is real + ON
|
|
565
|
+
db.prepare(edge("admitted_plan_deps")).run(); // FK-free staging twin accepts the same unbacked edge
|
|
566
|
+
const n = db.prepare("SELECT COUNT(*) AS n FROM admitted_plan_deps").get() as { n: number };
|
|
567
|
+
assertEquals(n.n, 1);
|
|
568
|
+
} finally {
|
|
569
|
+
db.close();
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
|