@nanobpm/nano-workforce 0.56.0 → 0.57.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/openapi.yaml CHANGED
@@ -361,9 +361,10 @@ components:
361
361
  per-request review-only override; defaults to false (the global auto-merge default applies).
362
362
  PlanStart:
363
363
  description: The start-plan-fanout request body. Names the target issue by EXACTLY ONE of
364
- `issue` (an `owner/repo#123` reference) or `url` (a bare issue URL). Modeled as `oneOf`
365
- named variants (Camunda REST v2 pattern) so an ambiguous or empty target is a 400 at the
366
- edge, not a silent `issue ?? url` coalesce in the delegate.
364
+ `issue` (an `owner/repo#123` reference) or `url` (a bare issue URL), plus a REQUIRED
365
+ `baseBranch` (ADR 0003) the fleet branches off a blank/absent base is a 400. Modeled as
366
+ `oneOf` named variants (Camunda REST v2 pattern) so an ambiguous or empty target is a 400 at
367
+ the edge, not a silent `issue ?? url` coalesce in the delegate.
367
368
  oneOf:
368
369
  - $ref: "#/components/schemas/PlanStartByIssue"
369
370
  - $ref: "#/components/schemas/PlanStartByUrl"
@@ -372,33 +373,77 @@ components:
372
373
  additionalProperties: false
373
374
  required:
374
375
  - issue
376
+ - baseBranch
375
377
  properties:
376
378
  issue:
377
379
  type: string
378
380
  description: "Issue reference: owner/repo#123."
379
381
  baseBranch:
380
382
  type: string
383
+ minLength: 1
384
+ maxLength: 255
385
+ pattern: '\S'
386
+ description: >-
387
+ REQUIRED target branch the fleet branches off and opens every PR against. Every epic
388
+ launch must name its base explicitly (ADR 0003): a blank/absent value is rejected with a
389
+ 400, not silently coalesced to the repository default branch. Use it to land an entire
390
+ epic on a long-lived integration branch (e.g. `epic/agent-protocol`) so nothing reaches
391
+ the default branch — and any merge-to-default side effect, such as auto-publishing a
392
+ package — until you deliberately merge the integration branch. NOTE: this slice (B0)
393
+ only enforces that `baseBranch` is present and a plausible branch name; branch-existence
394
+ admission (auto-creating a missing `epic/*` base off the default branch HEAD, and
395
+ rejecting a missing non-`epic/*` base with a 400) is specified by ADR 0003 but NOT yet
396
+ enforced here — it lands in a later admission slice.
397
+ allowSharedBase:
398
+ type: boolean
399
+ description: >-
400
+ Reserved for a later ADR 0003 admission slice — NOT yet enforced in this slice (B0). When
401
+ implemented it will opt in to sharing a custom integration base branch with another
402
+ already-active plan: admission will otherwise reject (409) when another active plan already
403
+ targets the same repo + same custom base branch, to stop two epics interleaving commits on
404
+ one integration branch (the repository default branch is exempt from that guard). Accepted
405
+ by the schema today but currently has no runtime effect.
406
+ confirmDefaultBase:
407
+ type: boolean
381
408
  description: >-
382
- Optional target branch the fleet branches off and opens every PR against, instead of the
383
- repository's default branch. Use this to land an entire epic on a long-lived integration
384
- branch (e.g. `epic/agent-protocol`) so nothing reaches the default branch and any
385
- merge-to-default side effect, such as auto-publishing a package until you deliberately
386
- merge the integration branch. Blank/omitted keeps the current behaviour (the repo
387
- default branch).
409
+ Reserved for a later ADR 0003 admission slice NOT yet enforced in this slice (B0). When
410
+ implemented it will be the required acknowledgement when `baseBranch` names the repository
411
+ default branch: targeting the default lands every task directly on it with no integration
412
+ buffer — and fires any merge-to-default side effect per task so admission will reject
413
+ (400) unless confirmed with true (no effect for a non-default base). Accepted by the schema
414
+ today but currently has no runtime effect.
388
415
  PlanStartByUrl:
389
416
  type: object
390
417
  additionalProperties: false
391
418
  required:
392
419
  - url
420
+ - baseBranch
393
421
  properties:
394
422
  url:
395
423
  type: string
396
424
  description: A bare issue URL, when no `owner/repo#123` reference is supplied.
397
425
  baseBranch:
398
426
  type: string
427
+ minLength: 1
428
+ maxLength: 255
429
+ pattern: '\S'
430
+ description: >-
431
+ REQUIRED target branch the fleet branches off and opens every PR against. See
432
+ `PlanStartByIssue.baseBranch`.
433
+ allowSharedBase:
434
+ type: boolean
435
+ description: >-
436
+ Reserved for a later ADR 0003 admission slice — NOT yet enforced in this slice (B0).
437
+ Accepted by the schema today but currently has no runtime effect. When implemented it
438
+ will opt in to sharing a custom integration base branch with another already-active plan.
439
+ See `PlanStartByIssue.allowSharedBase`.
440
+ confirmDefaultBase:
441
+ type: boolean
399
442
  description: >-
400
- Optional target branch the fleet branches off and opens every PR against, instead of the
401
- repository's default branch. See `PlanStartByIssue.baseBranch`.
443
+ Reserved for a later ADR 0003 admission slice NOT yet enforced in this slice (B0).
444
+ Accepted by the schema today but currently has no runtime effect. When implemented it
445
+ will be the required acknowledgement when `baseBranch` names the repository default
446
+ branch. See `PlanStartByIssue.confirmDefaultBase`.
402
447
  MessageResult:
403
448
  type: object
404
449
  description: The result of publishing a message / answering an escalation. Shape varies by message
@@ -12,6 +12,7 @@ import { noopLog } from "../test/log.ts";
12
12
  import startConvergenceLoop from "./startConvergenceLoop.ts";
13
13
  import startPlanFanout from "./startPlanFanout.ts";
14
14
  import postMessage from "./postMessage.ts";
15
+ import { resetDefaultBranchCache } from "../app/github.ts";
15
16
 
16
17
  const app = { log: noopLog() } as any as AppApi;
17
18
 
@@ -72,13 +73,60 @@ function withGithubOff(run: () => Promise<void>): Promise<void> {
72
73
  const prevTok = process.env["GITHUB_TOKEN"];
73
74
  process.env["NANO_PR_GITHUB_TRANSPORT"] = "token"; // no token → meta fetch is skipped
74
75
  delete process.env["GITHUB_TOKEN"];
76
+ resetDefaultBranchCache(); // start cold so a prior warmed cache can't mask the no-transport path
75
77
  return run().finally(() => {
78
+ resetDefaultBranchCache();
76
79
  if (prev !== undefined) process.env["NANO_PR_GITHUB_TRANSPORT"] = prev;
77
80
  else delete process.env["NANO_PR_GITHUB_TRANSPORT"];
78
81
  if (prevTok !== undefined) process.env["GITHUB_TOKEN"] = prevTok;
79
82
  });
80
83
  }
81
84
 
85
+ // Force the token transport with a stubbed `globalThis.fetch` that serves a minimal in-memory
86
+ // github model, so `admitPlan` (which now calls `ensureBaseBranch` + `fetchDefaultBranch`) can run
87
+ // without touching the network. The default branch is `main`; an epic/* base is auto-created off it.
88
+ function withGithubStub(run: () => Promise<void>): Promise<void> {
89
+ const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
90
+ const prevTok = process.env["GITHUB_TOKEN"];
91
+ const prevFetch = globalThis.fetch;
92
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
93
+ process.env["GITHUB_TOKEN"] = "tok";
94
+ resetDefaultBranchCache(); // isolate: don't inherit or leak the owner/repo default-branch entry
95
+ const branches = new Map<string, string>([["main", "mainsha"]]);
96
+ globalThis.fetch = ((url: string | URL | Request, init?: RequestInit): Promise<Response> => {
97
+ const u = new URL(String(url));
98
+ const method = (init?.method ?? "GET").toUpperCase();
99
+ const path = u.pathname;
100
+ const json = (obj: unknown, status = 200) =>
101
+ new Response(JSON.stringify(obj), { status, headers: { "content-type": "application/json" } });
102
+ if (method === "GET" && path === "/repos/owner/repo") return Promise.resolve(json({ default_branch: "main" }));
103
+ const refPrefix = "/repos/owner/repo/git/ref/heads/";
104
+ if (method === "GET" && path.startsWith(refPrefix)) {
105
+ const branch = decodeURIComponent(path.slice(refPrefix.length));
106
+ const sha = branches.get(branch);
107
+ if (sha === undefined) return Promise.resolve(new Response("Not Found", { status: 404 }));
108
+ return Promise.resolve(json({ ref: `refs/heads/${branch}`, object: { sha } }));
109
+ }
110
+ if (method === "POST" && path === "/repos/owner/repo/git/refs") {
111
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
112
+ const body = JSON.parse(String(init?.body ?? "{}")) as { ref?: string; sha?: string };
113
+ const branch = String(body.ref ?? "").replace(/^refs\/heads\//, "");
114
+ if (branches.has(branch)) return Promise.resolve(json({ message: "Reference already exists" }, 422));
115
+ branches.set(branch, String(body.sha ?? ""));
116
+ return Promise.resolve(json({ ref: body.ref }, 201));
117
+ }
118
+ return Promise.resolve(new Response(`unexpected ${method} ${path}`, { status: 500 }));
119
+ }) as typeof fetch;
120
+ return run().finally(() => {
121
+ resetDefaultBranchCache();
122
+ globalThis.fetch = prevFetch;
123
+ if (prevMode !== undefined) process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
124
+ else delete process.env["NANO_PR_GITHUB_TRANSPORT"];
125
+ if (prevTok !== undefined) process.env["GITHUB_TOKEN"] = prevTok;
126
+ else delete process.env["GITHUB_TOKEN"];
127
+ });
128
+ }
129
+
82
130
  test("startConvergenceLoop → 400 (not 500) on a missing request body", async () => {
83
131
  const res = await startConvergenceLoop(input(undefined), app);
84
132
  const r = res as any;
@@ -133,6 +181,15 @@ test("startPlanFanout → 400 on an invalid baseBranch (not persisted/rendered)"
133
181
  assertEquals(typeof r.body.error, "string");
134
182
  });
135
183
 
184
+ test("startPlanFanout → 400 on a missing baseBranch (not persisted/rendered)", async () => {
185
+ // A blank/absent baseBranch must be rejected at the edge as a 400 (MissingBaseBranchError),
186
+ // never silently coalesced to the repository default branch (ADR 0003, B0).
187
+ const res = await startPlanFanout(input({ issue: "owner/repo#123" }), app);
188
+ const r = res as any;
189
+ assertEquals(r.status, 400);
190
+ assertEquals(typeof r.body.error, "string");
191
+ });
192
+
136
193
  test("startConvergenceLoop narrows the `url` variant (no `pr` key)", async () => {
137
194
  await withGithubOff(async () => {
138
195
  const { app: capApp } = captureApp();
@@ -142,9 +199,12 @@ test("startConvergenceLoop narrows the `url` variant (no `pr` key)", async () =>
142
199
  });
143
200
 
144
201
  test("startPlanFanout narrows the `url` variant (no `issue` key)", async () => {
145
- await withGithubOff(async () => {
202
+ await withGithubStub(async () => {
146
203
  const { app: capApp } = captureApp();
147
- const res = await startPlanFanout(input({ url: "https://github.com/owner/repo/issues/12" }), capApp);
204
+ const res = await startPlanFanout(
205
+ input({ url: "https://github.com/owner/repo/issues/12", baseBranch: "epic/agent-protocol" }),
206
+ capApp,
207
+ );
148
208
  assertEquals((res as any).status, 202);
149
209
  });
150
210
  });
@@ -0,0 +1,263 @@
1
+ // Integration coverage for the base-branch ADMISSION gate (ADR 0003) driven through the operation
2
+ // EDGE — `startPlanFanout` → `admitPlan` → HTTP status. The unit tests in app/plan.test.ts already
3
+ // prove `admitPlan`'s decision matrix in isolation; this file proves the COMPOSED behaviour at the
4
+ // door: each admission rule maps to the correct HTTP status (400 / 409) and each accept path reaches
5
+ // the 202 fan-out. It runs the real delegate against an in-memory app/data/engine and a faked github
6
+ // transport (token mode + stubbed `globalThis.fetch`) — no network, deterministic on a single run.
7
+ import { test } from "node:test";
8
+ import { assertEquals } from "#test-assert";
9
+ import type { AppApi } from "@nanobpm/urban";
10
+ import { resetDefaultBranchCache } from "../app/github.ts";
11
+ import { noopLog } from "../test/log.ts";
12
+ import startPlanFanout from "./startPlanFanout.ts";
13
+
14
+ // ── in-memory github model ───────────────────────────────────────────────────
15
+ // A minimal fake of the GitHub REST surface `admitPlan` touches: the repo-meta GET (default branch),
16
+ // the ref GET (branch existence, returning a synthetic per-branch head sha) and the ref-create POST.
17
+ // `default_branch` is `main`; a missing `epic/*` base is auto-created off the default branch's head
18
+ // sha (which `admitPlan` reads via the ref GET, so here that is `main-sha`). `creates` records the
19
+ // full ref-create POST body (`ref` + `sha`) so a test can assert a branch was (or was NOT) created
20
+ // AND that the create payload points the new ref at the resolved base sha, not a stale/blank value.
21
+ interface GithubState {
22
+ repo: string;
23
+ defaultBranch: string;
24
+ branches: Set<string>;
25
+ creates: { ref: string; sha: string }[];
26
+ }
27
+
28
+ function githubFetch(state: GithubState) {
29
+ return (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
30
+ const u = new URL(String(url));
31
+ const method = (init?.method ?? "GET").toUpperCase();
32
+ const path = u.pathname;
33
+ const json = (obj: unknown, status = 200) =>
34
+ new Response(JSON.stringify(obj), { status, headers: { "content-type": "application/json" } });
35
+ if (method === "GET" && path === `/repos/${state.repo}`) {
36
+ return Promise.resolve(json({ default_branch: state.defaultBranch }));
37
+ }
38
+ const refPrefix = `/repos/${state.repo}/git/ref/heads/`;
39
+ if (method === "GET" && path.startsWith(refPrefix)) {
40
+ const branch = decodeURIComponent(path.slice(refPrefix.length));
41
+ if (!state.branches.has(branch)) return Promise.resolve(new Response("Not Found", { status: 404 }));
42
+ return Promise.resolve(json({ ref: `refs/heads/${branch}`, object: { sha: `${branch}-sha` } }));
43
+ }
44
+ if (method === "POST" && path === `/repos/${state.repo}/git/refs`) {
45
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
46
+ const body = JSON.parse(String(init?.body ?? "{}")) as { ref?: string; sha?: string };
47
+ const ref = String(body.ref ?? "");
48
+ const sha = String(body.sha ?? "");
49
+ const branch = ref.replace(/^refs\/heads\//, "");
50
+ if (state.branches.has(branch)) return Promise.resolve(json({ message: "Reference already exists" }, 422));
51
+ state.creates.push({ ref, sha });
52
+ state.branches.add(branch);
53
+ return Promise.resolve(json({ ref }, 201));
54
+ }
55
+ return Promise.resolve(new Response(`unexpected ${method} ${path}`, { status: 500 }));
56
+ };
57
+ }
58
+
59
+ async function withGithub<T>(state: GithubState, fn: () => Promise<T>): Promise<T> {
60
+ const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
61
+ const prevTok = process.env["GITHUB_TOKEN"];
62
+ const prevFetch = globalThis.fetch;
63
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
64
+ process.env["GITHUB_TOKEN"] = "tok";
65
+ resetDefaultBranchCache(); // isolate: don't inherit or leak another test's default-branch entry
66
+ globalThis.fetch = githubFetch(state) as typeof fetch;
67
+ try {
68
+ return await fn();
69
+ } finally {
70
+ resetDefaultBranchCache();
71
+ globalThis.fetch = prevFetch;
72
+ if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
73
+ else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
74
+ if (prevTok === undefined) delete process.env["GITHUB_TOKEN"];
75
+ else process.env["GITHUB_TOKEN"] = prevTok;
76
+ }
77
+ }
78
+
79
+ // ── in-memory app (data + engine) ────────────────────────────────────────────
80
+ // A generic table over an array, matching the DataLayer surface `startPlan`/`findActivePlansByBase`
81
+ // use (get/find/insert/update/delete). `seedPlans` pre-loads the `plans` table so the shared-base
82
+ // guard has active rows to find. `started` records each engine.createInstance call so an accept path
83
+ // can be asserted to have fanned out.
84
+ function makeApp(seedPlans: Record<string, unknown>[] = []) {
85
+ const tables = new Map<string, Record<string, unknown>[]>();
86
+ tables.set("plans", [...seedPlans]);
87
+ const started: { processDefinitionId: string; variables?: Record<string, unknown> }[] = [];
88
+ const table = (name: string, key: string) => {
89
+ const rows = tables.get(name) ?? (() => {
90
+ const fresh: Record<string, unknown>[] = [];
91
+ tables.set(name, fresh);
92
+ return fresh;
93
+ })();
94
+ return {
95
+ get: (k: unknown) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
96
+ find: (q: Record<string, unknown>) =>
97
+ Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
98
+ insert: (r: Record<string, unknown>) => {
99
+ rows.push(r);
100
+ return Promise.resolve(r);
101
+ },
102
+ update: (k: unknown, patch: Record<string, unknown>) => {
103
+ const row = rows.find((r) => r[key] === k);
104
+ if (row) Object.assign(row, patch);
105
+ return Promise.resolve(row);
106
+ },
107
+ delete: (k: unknown) => {
108
+ const i = rows.findIndex((r) => r[key] === k);
109
+ if (i >= 0) rows.splice(i, 1);
110
+ return Promise.resolve();
111
+ },
112
+ };
113
+ };
114
+ const app = {
115
+ data: { table },
116
+ engine: {
117
+ createInstance: (req: { processDefinitionId: string; variables?: Record<string, unknown> }) => {
118
+ started.push(req);
119
+ return Promise.resolve({ processInstanceKey: "PI-1" });
120
+ },
121
+ },
122
+ log: noopLog(),
123
+ } as any as AppApi;
124
+ return { app, started };
125
+ }
126
+
127
+ function input(body: unknown) {
128
+ return {
129
+ req: { method: "POST", path: "/", query: new URLSearchParams(), headers: new Headers(), text: async () => "" } as any,
130
+ params: {},
131
+ query: {},
132
+ body,
133
+ };
134
+ }
135
+
136
+ function freshGithub(repo: string, extraBranches: string[] = []): GithubState {
137
+ return { repo, defaultBranch: "main", branches: new Set(["main", ...extraBranches]), creates: [] };
138
+ }
139
+
140
+ // ── Rule 1 — required + explicit ──────────────────────────────────────────────
141
+
142
+ // Rule 1 rejects both a MISSING baseBranch field and an explicit blank/whitespace
143
+ // one — the latter is a distinct edge input that must not slip past as a "present"
144
+ // value. Table-drive both so the required-and-explicit rule is covered end to end.
145
+ for (const [label, body] of [
146
+ ["missing field", { issue: "owner/repo#1" }],
147
+ ["empty string", { issue: "owner/repo#1", baseBranch: "" }],
148
+ ["whitespace only", { issue: "owner/repo#1", baseBranch: " " }],
149
+ ] as const) {
150
+ test(`edge: ${label} baseBranch → 400`, async () => {
151
+ const gh = freshGithub("owner/repo");
152
+ await withGithub(gh, async () => {
153
+ const { app, started } = makeApp();
154
+ const res = (await startPlanFanout(input(body), app)) as any;
155
+ assertEquals(res.status, 400);
156
+ assertEquals(typeof res.body.error, "string");
157
+ assertEquals(started.length, 0); // rejected before any fan-out
158
+ assertEquals(gh.creates, []); // no ref created on a rejected input
159
+ });
160
+ });
161
+ }
162
+
163
+ // ── Rule 2 — create-if-missing (epic/* guard), synchronously at the edge ──────
164
+
165
+ test("edge: non-epic/* base that does not exist → 400 (BaseBranchMustExistError path)", async () => {
166
+ // A typo'd, non-epic/* base is NOT auto-created — admitPlan throws BaseBranchMustExistError
167
+ // synchronously, which the delegate maps to a clean 400 at the door (not a late per-task failure).
168
+ const gh = freshGithub("owner/repo"); // "release-9" absent, not epic/* → must-exist
169
+ await withGithub(gh, async () => {
170
+ const { app, started } = makeApp();
171
+ const res = (await startPlanFanout(input({ issue: "owner/repo#2", baseBranch: "release-9" }), app)) as any;
172
+ assertEquals(res.status, 400);
173
+ assertEquals(typeof res.body.error, "string");
174
+ assertEquals(started.length, 0);
175
+ assertEquals(gh.creates, []); // never created
176
+ });
177
+ });
178
+
179
+ test("edge: missing epic/* base → created off default HEAD, then 202", async () => {
180
+ const gh = freshGithub("owner/repo"); // epic/new absent → auto-created off main
181
+ await withGithub(gh, async () => {
182
+ const { app, started } = makeApp();
183
+ const res = (await startPlanFanout(input({ issue: "owner/repo#3", baseBranch: "epic/new" }), app)) as any;
184
+ assertEquals(res.status, 202);
185
+ // Created off the resolved base sha: the ref-create payload names epic/new AND points it at the
186
+ // default branch's head sha (`main-sha` here), proving the create body carries the base sha.
187
+ assertEquals(gh.creates, [{ ref: "refs/heads/epic/new", sha: "main-sha" }]);
188
+ assertEquals(started.length, 1);
189
+ });
190
+ });
191
+
192
+ // ── Rule 3 — confirm-default ──────────────────────────────────────────────────
193
+
194
+ test("edge: target == default branch WITHOUT confirmDefaultBase → 400", async () => {
195
+ const gh = freshGithub("owner/repo"); // base "main" IS the default
196
+ await withGithub(gh, async () => {
197
+ const { app, started } = makeApp();
198
+ const res = (await startPlanFanout(input({ issue: "owner/repo#4", baseBranch: "main" }), app)) as any;
199
+ assertEquals(res.status, 400);
200
+ assertEquals(typeof res.body.error, "string");
201
+ assertEquals(started.length, 0);
202
+ });
203
+ });
204
+
205
+ test("edge: target == default branch WITH confirmDefaultBase → 202", async () => {
206
+ const gh = freshGithub("owner/repo");
207
+ await withGithub(gh, async () => {
208
+ const { app, started } = makeApp();
209
+ const res = (await startPlanFanout(
210
+ input({ issue: "owner/repo#5", baseBranch: "main", confirmDefaultBase: true }),
211
+ app,
212
+ )) as any;
213
+ assertEquals(res.status, 202);
214
+ assertEquals(started.length, 1);
215
+ });
216
+ });
217
+
218
+ // ── Rule 4 — shared-base guard ────────────────────────────────────────────────
219
+
220
+ test("edge: active plan on the same CUSTOM base WITHOUT allowSharedBase → 409", async () => {
221
+ const gh = freshGithub("owner/repo", ["epic/shared"]); // base exists → ensureBaseBranch no-ops
222
+ await withGithub(gh, async () => {
223
+ // A DIFFERENT active plan already targets epic/shared on this repo.
224
+ const { app, started } = makeApp([
225
+ { plan_key: "owner/repo#98", repo: "owner/repo", base_branch: "epic/shared", status: "planning" },
226
+ ]);
227
+ const res = (await startPlanFanout(input({ issue: "owner/repo#6", baseBranch: "epic/shared" }), app)) as any;
228
+ assertEquals(res.status, 409);
229
+ assertEquals(typeof res.body.error, "string");
230
+ assertEquals(started.length, 0);
231
+ });
232
+ });
233
+
234
+ test("edge: same CUSTOM base WITH allowSharedBase → 202", async () => {
235
+ const gh = freshGithub("owner/repo", ["epic/shared"]);
236
+ await withGithub(gh, async () => {
237
+ const { app, started } = makeApp([
238
+ { plan_key: "owner/repo#98", repo: "owner/repo", base_branch: "epic/shared", status: "planning" },
239
+ ]);
240
+ const res = (await startPlanFanout(
241
+ input({ issue: "owner/repo#7", baseBranch: "epic/shared", allowSharedBase: true }),
242
+ app,
243
+ )) as any;
244
+ assertEquals(res.status, 202);
245
+ assertEquals(started.length, 1);
246
+ });
247
+ });
248
+
249
+ test("edge: two plans sharing the DEFAULT branch → 202 (default is exempt from the shared-base guard)", async () => {
250
+ const gh = freshGithub("owner/repo"); // base "main" == default → exempt
251
+ await withGithub(gh, async () => {
252
+ // An active plan already targets main; a second one is still admitted (confirmed default).
253
+ const { app, started } = makeApp([
254
+ { plan_key: "owner/repo#97", repo: "owner/repo", base_branch: "main", status: "planning" },
255
+ ]);
256
+ const res = (await startPlanFanout(
257
+ input({ issue: "owner/repo#8", baseBranch: "main", confirmDefaultBase: true }),
258
+ app,
259
+ )) as any;
260
+ assertEquals(res.status, 202);
261
+ assertEquals(started.length, 1);
262
+ });
263
+ });
@@ -10,7 +10,16 @@
10
10
  // ONE of `issue` or `url` — so an empty or ambiguous target is a 400 at the edge; this delegate just
11
11
  // narrows the validated variant and keeps the issue-FORMAT parse guard (schema can't express it).
12
12
 
13
- import { InvalidBaseBranchError, normalizeBaseBranch, parseIssue, startPlan } from "../app/plan.ts";
13
+ import { BaseBranchMustExistError } from "../app/github.ts";
14
+ import {
15
+ admitPlan,
16
+ DefaultBaseNotConfirmedError,
17
+ InvalidBaseBranchError,
18
+ MissingBaseBranchError,
19
+ parseIssue,
20
+ SharedBaseError,
21
+ startPlan,
22
+ } from "../app/plan.ts";
14
23
  import { defineOperation } from "../nano-generated/operations.ts";
15
24
 
16
25
  export default defineOperation("startPlanFanout", async ({ body }, app) => {
@@ -26,16 +35,29 @@ export default defineOperation("startPlanFanout", async ({ body }, app) => {
26
35
  app.log.warn("start-plan rejected: unparseable issue reference", { raw });
27
36
  return { status: 400, body: { error: "could not parse issue (use owner/repo#123 or an issue URL)" } };
28
37
  }
29
- // Optional epic base branch: the branch the fleet branches off and opens every PR against instead
30
- // of the repo default. Present on both oneOf variants; blank/absent keeps the default-branch
31
- // behaviour. It is later interpolated into the authoritative implementer prompt (with `git`/`gh`
32
- // shell snippets), so validate/normalise it HERE a non-blank value that isn't a plausible git
33
- // branch name is a 400 at the edge, never persisted or rendered. `normalizeBaseBranch` blank → null.
34
- const baseBranch = "baseBranch" in body && typeof body.baseBranch === "string" ? body.baseBranch : null;
35
- let normalizedBase: string | null;
38
+ // Epic base branch (ADR 0003): admit the launch through the fail-fast `admitPlan` gate BEFORE any
39
+ // fan-out. It composes the four ordered admission rules required+explicit, create-if-missing
40
+ // (epic/* guard, run synchronously so a typo is a clean edge 400), confirm-default, and
41
+ // shared-base and returns the normalized base. Errors map to specific HTTP statuses at the edge.
42
+ const rawBase = "baseBranch" in body && typeof body.baseBranch === "string" ? body.baseBranch : null;
43
+ const allowSharedBase = "allowSharedBase" in body && body.allowSharedBase === true;
44
+ const confirmDefaultBase = "confirmDefaultBase" in body && body.confirmDefaultBase === true;
45
+ const token = process.env.GITHUB_TOKEN ?? "";
46
+ let normalizedBase: string;
36
47
  try {
37
- normalizedBase = normalizeBaseBranch(baseBranch);
48
+ normalizedBase = await admitPlan(app.data, parsed.repo, rawBase, token, {
49
+ allowSharedBase,
50
+ confirmDefaultBase,
51
+ selfPlanKey: parsed.planKey,
52
+ });
38
53
  } catch (err) {
54
+ if (err instanceof MissingBaseBranchError) {
55
+ app.log.warn("start-plan rejected: missing base branch");
56
+ return {
57
+ status: 400,
58
+ body: { error: "baseBranch is required (name the integration branch, e.g. epic/agent-protocol)" },
59
+ };
60
+ }
39
61
  if (err instanceof InvalidBaseBranchError) {
40
62
  app.log.warn("start-plan rejected: invalid base branch", { baseBranch: err.value });
41
63
  return {
@@ -43,13 +65,50 @@ export default defineOperation("startPlanFanout", async ({ body }, app) => {
43
65
  body: { error: "invalid baseBranch (must be a plausible git branch name, e.g. epic/agent-protocol)" },
44
66
  };
45
67
  }
68
+ if (err instanceof BaseBranchMustExistError) {
69
+ app.log.warn("start-plan rejected: base branch does not exist", { baseBranch: err.branch });
70
+ return {
71
+ status: 400,
72
+ body: {
73
+ error:
74
+ `baseBranch "${err.branch}" does not exist and is not an epic/* branch, so it is not ` +
75
+ `auto-created — create it first, or use the epic/* convention`,
76
+ },
77
+ };
78
+ }
79
+ if (err instanceof DefaultBaseNotConfirmedError) {
80
+ app.log.warn("start-plan rejected: default base not confirmed", { baseBranch: err.branch });
81
+ return {
82
+ status: 400,
83
+ body: {
84
+ error:
85
+ `baseBranch "${err.branch}" is the repository default branch — every task would land ` +
86
+ `directly on it with no integration branch. Re-submit with confirmDefaultBase: true to proceed`,
87
+ },
88
+ };
89
+ }
90
+ if (err instanceof SharedBaseError) {
91
+ app.log.warn("start-plan rejected: shared base branch", { baseBranch: err.branch });
92
+ return {
93
+ status: 409,
94
+ body: {
95
+ error:
96
+ `baseBranch "${err.branch}" is already in use by another active epic. Re-submit with ` +
97
+ `allowSharedBase: true to stack on it, or name a distinct epic/* branch`,
98
+ },
99
+ };
100
+ }
46
101
  throw err;
47
102
  }
48
103
  const result = await startPlan(app.data, app.engine, parsed, normalizedBase);
104
+ const alreadyRunning = "alreadyRunning" in result && result.alreadyRunning === true;
49
105
  app.log.info("plan fan-out started", {
50
106
  planKey: parsed.planKey,
51
- baseBranch: normalizedBase ?? "(default branch)",
52
- alreadyRunning: "alreadyRunning" in result && result.alreadyRunning === true,
107
+ // The base the caller requested. When `alreadyRunning`, `startPlan` short-circuits before this
108
+ // base takes effect (it may not match the in-flight plan's persisted base), so name it as the
109
+ // request — not the effective base — to keep the log honest.
110
+ requestedBaseBranch: normalizedBase,
111
+ alreadyRunning,
53
112
  });
54
113
  return { status: 202, body: result };
55
114
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.56.0",
3
+ "version": "0.57.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -38,7 +38,9 @@
38
38
  "action": { "path": "/app/api/actions/start/plan-fanout", "body": "{{form}}" },
39
39
  "fields": [
40
40
  { "key": "issue", "label": "owner/repo#123 or a GitHub issue URL", "type": "text" },
41
- { "key": "baseBranch", "label": "Base branch (blank = repo default; e.g. epic/agent-protocol to land the whole epic on an integration branch)", "type": "text" }
41
+ { "key": "baseBranch", "label": "Base branch (REQUIRED; e.g. epic/agent-protocol to land the whole epic on an integration branch). A missing epic/* branch is auto-created off default HEAD; a non-epic/* branch must already exist.", "type": "text" },
42
+ { "key": "confirmDefaultBase", "label": "Confirm landing on the default branch \u2014 required only when the base above IS the repository default (every task lands directly on it, with any merge-to-default side effect firing per task)", "type": "checkbox" },
43
+ { "key": "allowSharedBase", "label": "Allow sharing a custom integration branch with another active epic \u2014 required only when another in-flight epic already targets this same custom base", "type": "checkbox" }
42
44
  ]
43
45
  }
44
46
  },
@@ -70,6 +72,7 @@
70
72
  "columns": [
71
73
  { "field": "plan_key", "header": "Epic", "link": { "kind": "page", "page": "epic-detail", "keyField": "plan_key" } },
72
74
  { "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
75
+ { "field": "base_branch", "header": "Base branch" },
73
76
  { "field": "wave_label", "header": "Wave" },
74
77
  { "field": "task_count", "header": "Tasks" },
75
78
  { "field": "open_plan_findings", "header": "Attention", "badge": { "tone": "danger", "label": "!" } },