@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/CHANGELOG.md +7 -0
- package/README.md +9 -1
- package/SPEC.md +6 -2
- package/app/agentGuide.ts +1 -1
- package/app/github.test.ts +179 -1
- package/app/github.ts +132 -0
- package/app/plan.test.ts +268 -20
- package/app/plan.ts +147 -15
- package/nano.app.json +4 -0
- package/openapi.yaml +56 -11
- package/operations/startAndMessage.test.ts +62 -2
- package/operations/startPlanFanout.admission.integration.test.ts +263 -0
- package/operations/startPlanFanout.ts +70 -11
- package/package.json +1 -1
- package/pages/epic.page.json +4 -1
- package/resources/agent-guide.md +38 -2
- package/resources/processes/plan-fanout.bpmn +168 -149
- package/workers/ensure-base-branch/head-task.integration.test.ts +126 -0
- package/workers/ensure-base-branch/worker.test.ts +104 -0
- package/workers/ensure-base-branch/worker.ts +31 -0
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)
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
branch
|
|
385
|
-
merge-to-default side effect
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
401
|
-
|
|
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
|
|
202
|
+
await withGithubStub(async () => {
|
|
146
203
|
const { app: capApp } = captureApp();
|
|
147
|
-
const res = await startPlanFanout(
|
|
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 {
|
|
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
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
|
|
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 =
|
|
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
|
-
|
|
52
|
-
|
|
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.
|
|
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",
|
package/pages/epic.page.json
CHANGED
|
@@ -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 (
|
|
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": "!" } },
|