@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/app/plan.test.ts
CHANGED
|
@@ -121,7 +121,7 @@ test("re-plan of a finished issue clears stale plan_reviews rows", async () => {
|
|
|
121
121
|
number: 7,
|
|
122
122
|
url: "https://github.com/owner/repo/issues/7",
|
|
123
123
|
planKey: PLAN_KEY,
|
|
124
|
-
});
|
|
124
|
+
}, "epic/agent-protocol");
|
|
125
125
|
|
|
126
126
|
assertEquals(stores.plan_reviews.rows.length, 0);
|
|
127
127
|
assertEquals(stores.plan_tasks.rows.length, 0);
|
|
@@ -179,7 +179,7 @@ test("re-plan of a finished issue clears stale open escalations and the denormal
|
|
|
179
179
|
number: 8,
|
|
180
180
|
url: "https://github.com/owner/repo/issues/8",
|
|
181
181
|
planKey: PLAN_KEY,
|
|
182
|
-
});
|
|
182
|
+
}, "epic/agent-protocol");
|
|
183
183
|
|
|
184
184
|
// Stale escalation rows from the prior run must not survive a re-plan …
|
|
185
185
|
assertEquals(stores.plan_escalations.rows.length, 0);
|
|
@@ -428,21 +428,22 @@ test("answerPlanEscalation records directive, clears the plan pointer, and publi
|
|
|
428
428
|
);
|
|
429
429
|
});
|
|
430
430
|
|
|
431
|
-
// Coverage for the epic base-branch control (issue nano-ide #124 / 019_plan_base_branch.sql).
|
|
431
|
+
// Coverage for the epic base-branch control (issue nano-ide #124 / 019_plan_base_branch.sql; ADR 0003).
|
|
432
432
|
//
|
|
433
|
-
//
|
|
434
|
-
// integration branch instead of the repo default, keeping an epic off the default branch
|
|
435
|
-
// merge-to-default side effect such as auto-publishing) until the integration branch is
|
|
436
|
-
// merged.
|
|
437
|
-
//
|
|
438
|
-
//
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
433
|
+
// Every plan must pin a base branch so the fleet branches off — and opens every PR against — a
|
|
434
|
+
// long-lived integration branch instead of the repo default, keeping an epic off the default branch
|
|
435
|
+
// (and off any merge-to-default side effect such as auto-publishing) until the integration branch is
|
|
436
|
+
// deliberately merged. Since ADR 0003, base is REQUIRED: `normalizeBaseBranch` rejects a blank/absent
|
|
437
|
+
// value (`MissingBaseBranchError`) instead of falling back to the default. `renderBaseBranchBrief` is
|
|
438
|
+
// the authoritative prompt override, and `startPlan` persists the branch and seeds BOTH the
|
|
439
|
+
// `baseBranch` variable and the `baseBranchBrief` (which rides `appendPrompt`) unconditionally.
|
|
440
|
+
import { InvalidBaseBranchError, MissingBaseBranchError, normalizeBaseBranch, renderBaseBranchBrief } from "./plan.ts";
|
|
441
|
+
|
|
442
|
+
test("normalizeBaseBranch: blank/whitespace/undefined → MissingBaseBranchError; a real branch is trimmed", () => {
|
|
443
|
+
assertThrows(() => normalizeBaseBranch(undefined), MissingBaseBranchError);
|
|
444
|
+
assertThrows(() => normalizeBaseBranch(null), MissingBaseBranchError);
|
|
445
|
+
assertThrows(() => normalizeBaseBranch(""), MissingBaseBranchError);
|
|
446
|
+
assertThrows(() => normalizeBaseBranch(" "), MissingBaseBranchError);
|
|
446
447
|
assertEquals(normalizeBaseBranch(" epic/agent-protocol "), "epic/agent-protocol");
|
|
447
448
|
});
|
|
448
449
|
|
|
@@ -450,6 +451,8 @@ test("normalizeBaseBranch: accepts conservative git-branch shapes", () => {
|
|
|
450
451
|
assertEquals(normalizeBaseBranch("main"), "main");
|
|
451
452
|
assertEquals(normalizeBaseBranch("release-1.2"), "release-1.2");
|
|
452
453
|
assertEquals(normalizeBaseBranch("feature/x_y.z"), "feature/x_y.z");
|
|
454
|
+
// A plausible `epic/*` integration branch (the 019 convention) is returned unchanged.
|
|
455
|
+
assertEquals(normalizeBaseBranch("epic/agent-protocol"), "epic/agent-protocol");
|
|
453
456
|
});
|
|
454
457
|
|
|
455
458
|
test("normalizeBaseBranch: rejects injection-prone / implausible branch names", () => {
|
|
@@ -516,7 +519,7 @@ test("startPlan pins the base branch: persisted on the row + seeded as baseBranc
|
|
|
516
519
|
assertEquals(seen.baseBranchBrief.includes("gh pr create --base epic/agent-protocol"), true);
|
|
517
520
|
});
|
|
518
521
|
|
|
519
|
-
test("startPlan
|
|
522
|
+
test("startPlan renders baseBranchBrief unconditionally now that base is required", async () => {
|
|
520
523
|
const PLAN_KEY = "owner/repo#200";
|
|
521
524
|
const stores: Record<string, { rows: any[]; key: string }> = {
|
|
522
525
|
plans: { rows: [], key: "plan_key" },
|
|
@@ -539,9 +542,254 @@ test("startPlan without a base branch keeps default-branch behaviour (null row +
|
|
|
539
542
|
number: 200,
|
|
540
543
|
url: "https://github.com/owner/repo/issues/200",
|
|
541
544
|
planKey: PLAN_KEY,
|
|
545
|
+
}, "epic/gate-branch");
|
|
546
|
+
|
|
547
|
+
assertEquals((stores.plans.rows[0] as any).base_branch, "epic/gate-branch");
|
|
548
|
+
assertEquals(seen.baseBranch, "epic/gate-branch");
|
|
549
|
+
// The brief is always rendered — there is no null fork any more.
|
|
550
|
+
assertEquals(seen.baseBranchBrief.includes("gh pr create --base epic/gate-branch"), true);
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
test("startPlan grandfathers a pre-existing null base_branch row: re-plan reads it without error", async () => {
|
|
554
|
+
// Pre-ADR-0003 / in-flight rows carry base_branch = null (the column stays nullable). Re-planning
|
|
555
|
+
// such a finished issue must read the old null row without error and re-pin it to the new explicit
|
|
556
|
+
// base — the required-ness is enforced at admission of the new launch, not by a DB NOT NULL.
|
|
557
|
+
const PLAN_KEY = "owner/repo#201";
|
|
558
|
+
const stores: Record<string, { rows: any[]; key: string }> = {
|
|
559
|
+
plans: { rows: [{ plan_key: PLAN_KEY, status: "done", task_count: 0, base_branch: null }], key: "plan_key" },
|
|
560
|
+
plan_tasks: { rows: [], key: "id" },
|
|
561
|
+
plan_reviews: { rows: [], key: "plan_key" },
|
|
562
|
+
plan_escalations: { rows: [], key: "id" },
|
|
563
|
+
plan_task_deps: { rows: [], key: "plan_key" },
|
|
564
|
+
};
|
|
565
|
+
const data = memData(stores);
|
|
566
|
+
let seen: any = null;
|
|
567
|
+
const engine = {
|
|
568
|
+
createInstance: (req: any) => {
|
|
569
|
+
seen = req.variables;
|
|
570
|
+
return Promise.resolve({ processInstanceKey: "PI-3" });
|
|
571
|
+
},
|
|
572
|
+
} as any;
|
|
573
|
+
|
|
574
|
+
await startPlan(data, engine, {
|
|
575
|
+
repo: "owner/repo",
|
|
576
|
+
number: 201,
|
|
577
|
+
url: "https://github.com/owner/repo/issues/201",
|
|
578
|
+
planKey: PLAN_KEY,
|
|
579
|
+
}, "epic/gate-branch");
|
|
580
|
+
|
|
581
|
+
// The grandfathered null row is re-pinned to the new explicit base without throwing.
|
|
582
|
+
assertEquals((stores.plans.rows[0] as any).base_branch, "epic/gate-branch");
|
|
583
|
+
assertEquals(seen.baseBranch, "epic/gate-branch");
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
// ── admitPlan decision matrix (ADR 0003 §Decision, rules 1-4) ────────────────
|
|
587
|
+
// The fail-fast admission gate composes four ORDERED rules before any fan-out. These drive it
|
|
588
|
+
// through a faked github transport (token mode + stubbed `globalThis.fetch`) and an in-memory
|
|
589
|
+
// `plans` table, asserting each rule's accept/reject and that the ORDER is load-bearing.
|
|
590
|
+
import { BaseBranchMustExistError } from "./github.ts";
|
|
591
|
+
import { admitPlan, DefaultBaseNotConfirmedError, findActivePlansByBase, SharedBaseError } from "./plan.ts";
|
|
592
|
+
|
|
593
|
+
interface AdmitRepo {
|
|
594
|
+
repo: string;
|
|
595
|
+
defaultBranch: string;
|
|
596
|
+
branches: Set<string>;
|
|
597
|
+
creates: string[]; // refs created via POST
|
|
598
|
+
metaCalls: number; // GETs to /repos/:repo (default-branch resolution)
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function admitFetch(state: AdmitRepo) {
|
|
602
|
+
return (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
|
603
|
+
const u = new URL(String(url));
|
|
604
|
+
const method = (init?.method ?? "GET").toUpperCase();
|
|
605
|
+
const path = u.pathname;
|
|
606
|
+
if (method === "GET" && path === `/repos/${state.repo}`) {
|
|
607
|
+
state.metaCalls += 1;
|
|
608
|
+
return Promise.resolve(
|
|
609
|
+
new Response(JSON.stringify({ default_branch: state.defaultBranch }), {
|
|
610
|
+
status: 200,
|
|
611
|
+
headers: { "content-type": "application/json" },
|
|
612
|
+
}),
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
const refPrefix = `/repos/${state.repo}/git/ref/heads/`;
|
|
616
|
+
if (method === "GET" && path.startsWith(refPrefix)) {
|
|
617
|
+
const branch = decodeURIComponent(path.slice(refPrefix.length));
|
|
618
|
+
if (!state.branches.has(branch)) return Promise.resolve(new Response("Not Found", { status: 404 }));
|
|
619
|
+
return Promise.resolve(
|
|
620
|
+
new Response(JSON.stringify({ ref: `refs/heads/${branch}`, object: { sha: `${branch}-sha` } }), {
|
|
621
|
+
status: 200,
|
|
622
|
+
headers: { "content-type": "application/json" },
|
|
623
|
+
}),
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
if (method === "POST" && path === `/repos/${state.repo}/git/refs`) {
|
|
627
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
628
|
+
const body = JSON.parse(String(init?.body ?? "{}")) as { ref?: string };
|
|
629
|
+
const ref = String(body.ref ?? "");
|
|
630
|
+
const branch = ref.replace(/^refs\/heads\//, "");
|
|
631
|
+
if (state.branches.has(branch)) {
|
|
632
|
+
return Promise.resolve(new Response(JSON.stringify({ message: "Reference already exists" }), { status: 422 }));
|
|
633
|
+
}
|
|
634
|
+
state.creates.push(ref);
|
|
635
|
+
state.branches.add(branch);
|
|
636
|
+
return Promise.resolve(new Response(JSON.stringify({ ref }), { status: 201 }));
|
|
637
|
+
}
|
|
638
|
+
return Promise.resolve(new Response(`unexpected ${method} ${path}`, { status: 500 }));
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
async function withAdmit<T>(state: AdmitRepo, fn: () => Promise<T>): Promise<T> {
|
|
643
|
+
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
644
|
+
const prevFetch = globalThis.fetch;
|
|
645
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
|
|
646
|
+
globalThis.fetch = admitFetch(state) as typeof fetch;
|
|
647
|
+
try {
|
|
648
|
+
return await fn();
|
|
649
|
+
} finally {
|
|
650
|
+
globalThis.fetch = prevFetch;
|
|
651
|
+
if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
652
|
+
else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function admitData(planRows: any[] = []) {
|
|
657
|
+
return memData({ plans: { rows: planRows, key: "plan_key" } });
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
test("admitPlan rule 1: blank/absent base → MissingBaseBranchError (before any github call)", async () => {
|
|
661
|
+
const state: AdmitRepo = { repo: "o/r1", defaultBranch: "main", branches: new Set(["main"]), creates: [], metaCalls: 0 };
|
|
662
|
+
await withAdmit(state, async () => {
|
|
663
|
+
await assertRejects(() => admitPlan(admitData(), state.repo, "", "tok"), MissingBaseBranchError);
|
|
664
|
+
await assertRejects(() => admitPlan(admitData(), state.repo, null, "tok"), MissingBaseBranchError);
|
|
542
665
|
});
|
|
666
|
+
// Rule 1 fires before rule 2/3 — the default-branch endpoint is never hit.
|
|
667
|
+
assertEquals(state.metaCalls, 0);
|
|
668
|
+
assertEquals(state.creates.length, 0);
|
|
669
|
+
});
|
|
543
670
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
671
|
+
test("admitPlan rule 1: implausible base → InvalidBaseBranchError", async () => {
|
|
672
|
+
const state: AdmitRepo = { repo: "o/r1b", defaultBranch: "main", branches: new Set(["main"]), creates: [], metaCalls: 0 };
|
|
673
|
+
await withAdmit(state, async () => {
|
|
674
|
+
await assertRejects(() => admitPlan(admitData(), state.repo, "bad branch;rm -rf", "tok"), InvalidBaseBranchError);
|
|
675
|
+
});
|
|
676
|
+
assertEquals(state.metaCalls, 0);
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
test("admitPlan rule 2: missing non-epic/* base → BaseBranchMustExistError (synchronous edge-400 path)", async () => {
|
|
680
|
+
const state: AdmitRepo = { repo: "o/r2", defaultBranch: "main", branches: new Set(["main"]), creates: [], metaCalls: 0 };
|
|
681
|
+
await withAdmit(state, async () => {
|
|
682
|
+
await assertRejects(() => admitPlan(admitData(), state.repo, "release-9", "tok"), BaseBranchMustExistError);
|
|
683
|
+
});
|
|
684
|
+
assertEquals(state.creates.length, 0);
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
test("admitPlan rule 2: missing epic/* base → created off default HEAD, then admitted", async () => {
|
|
688
|
+
const state: AdmitRepo = { repo: "o/r3", defaultBranch: "main", branches: new Set(["main"]), creates: [], metaCalls: 0 };
|
|
689
|
+
const base = await withAdmit(state, () => admitPlan(admitData(), state.repo, "epic/new-thing", "tok"));
|
|
690
|
+
assertEquals(base, "epic/new-thing");
|
|
691
|
+
assertEquals(state.creates, ["refs/heads/epic/new-thing"]);
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
test("admitPlan rule 3: default-branch target WITHOUT confirmDefaultBase → DefaultBaseNotConfirmedError", async () => {
|
|
695
|
+
const state: AdmitRepo = { repo: "o/r4", defaultBranch: "main", branches: new Set(["main"]), creates: [], metaCalls: 0 };
|
|
696
|
+
await withAdmit(state, async () => {
|
|
697
|
+
await assertRejects(() => admitPlan(admitData(), state.repo, "main", "tok"), DefaultBaseNotConfirmedError);
|
|
698
|
+
});
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
test("admitPlan rule 3: default-branch target WITH confirmDefaultBase → admitted", async () => {
|
|
702
|
+
const state: AdmitRepo = { repo: "o/r5", defaultBranch: "main", branches: new Set(["main"]), creates: [], metaCalls: 0 };
|
|
703
|
+
const base = await withAdmit(state, () =>
|
|
704
|
+
admitPlan(admitData(), state.repo, "main", "tok", { confirmDefaultBase: true }),
|
|
705
|
+
);
|
|
706
|
+
assertEquals(base, "main");
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
test("admitPlan rule 4: active shared CUSTOM base WITHOUT allowSharedBase → SharedBaseError", async () => {
|
|
710
|
+
const state: AdmitRepo = { repo: "o/r6", defaultBranch: "main", branches: new Set(["main", "epic/shared"]), creates: [], metaCalls: 0 };
|
|
711
|
+
const planRows = [{ plan_key: "o/r6#1", repo: "o/r6", base_branch: "epic/shared", status: "planning" }];
|
|
712
|
+
await withAdmit(state, async () => {
|
|
713
|
+
await assertRejects(() => admitPlan(admitData(planRows), state.repo, "epic/shared", "tok"), SharedBaseError);
|
|
714
|
+
});
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
test("admitPlan rule 4: same-issue re-submit is admitted — selfPlanKey excludes the launch's OWN active row", async () => {
|
|
718
|
+
// Idempotency regression: startPlan short-circuits an in-flight plan to `alreadyRunning`, but that
|
|
719
|
+
// reachable only if admitPlan does NOT 409 the retry against the plan's own active row. With
|
|
720
|
+
// selfPlanKey set, the shared-base guard excludes that row, so the same-issue re-submit is admitted.
|
|
721
|
+
const state: AdmitRepo = { repo: "o/r6b", defaultBranch: "main", branches: new Set(["main", "epic/shared"]), creates: [], metaCalls: 0 };
|
|
722
|
+
const planRows = [{ plan_key: "o/r6b#1", repo: "o/r6b", base_branch: "epic/shared", status: "planning" }];
|
|
723
|
+
const base = await withAdmit(state, () =>
|
|
724
|
+
admitPlan(admitData(planRows), state.repo, "epic/shared", "tok", { selfPlanKey: "o/r6b#1" }),
|
|
725
|
+
);
|
|
726
|
+
assertEquals(base, "epic/shared");
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
test("admitPlan rule 4: a DIFFERENT active plan on the same base still trips the guard even with selfPlanKey set", async () => {
|
|
730
|
+
// selfPlanKey excludes only the launch's own row — a genuine collision with another epic still 409s.
|
|
731
|
+
const state: AdmitRepo = { repo: "o/r6c", defaultBranch: "main", branches: new Set(["main", "epic/shared"]), creates: [], metaCalls: 0 };
|
|
732
|
+
const planRows = [{ plan_key: "o/r6c#2", repo: "o/r6c", base_branch: "epic/shared", status: "planning" }];
|
|
733
|
+
await withAdmit(state, async () => {
|
|
734
|
+
await assertRejects(
|
|
735
|
+
() => admitPlan(admitData(planRows), state.repo, "epic/shared", "tok", { selfPlanKey: "o/r6c#1" }),
|
|
736
|
+
SharedBaseError,
|
|
737
|
+
);
|
|
738
|
+
});
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
test("admitPlan rule 4: same custom base WITH allowSharedBase → admitted", async () => {
|
|
742
|
+
const state: AdmitRepo = { repo: "o/r7", defaultBranch: "main", branches: new Set(["main", "epic/shared"]), creates: [], metaCalls: 0 };
|
|
743
|
+
const planRows = [{ plan_key: "o/r7#1", repo: "o/r7", base_branch: "epic/shared", status: "planning" }];
|
|
744
|
+
const base = await withAdmit(state, () =>
|
|
745
|
+
admitPlan(admitData(planRows), state.repo, "epic/shared", "tok", { allowSharedBase: true }),
|
|
746
|
+
);
|
|
747
|
+
assertEquals(base, "epic/shared");
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
test("admitPlan rule 4: two plans sharing the DEFAULT branch → always admitted (exempt)", async () => {
|
|
751
|
+
const state: AdmitRepo = { repo: "o/r8", defaultBranch: "main", branches: new Set(["main"]), creates: [], metaCalls: 0 };
|
|
752
|
+
// Another active plan already targets the default branch — the shared-base guard exempts it.
|
|
753
|
+
const planRows = [{ plan_key: "o/r8#1", repo: "o/r8", base_branch: "main", status: "planning" }];
|
|
754
|
+
const base = await withAdmit(state, () =>
|
|
755
|
+
admitPlan(admitData(planRows), state.repo, "main", "tok", { confirmDefaultBase: true }),
|
|
756
|
+
);
|
|
757
|
+
assertEquals(base, "main");
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
test("admitPlan rule 4: a TERMINAL-status plan on the same base does NOT trip the guard", async () => {
|
|
761
|
+
const state: AdmitRepo = { repo: "o/r9", defaultBranch: "main", branches: new Set(["main", "epic/done-base"]), creates: [], metaCalls: 0 };
|
|
762
|
+
const planRows = [{ plan_key: "o/r9#1", repo: "o/r9", base_branch: "epic/done-base", status: "done" }];
|
|
763
|
+
const base = await withAdmit(state, () => admitPlan(admitData(planRows), state.repo, "epic/done-base", "tok"));
|
|
764
|
+
assertEquals(base, "epic/done-base");
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
test("admitPlan ORDER: a blank base is rejected before confirm-default / shared-base run", async () => {
|
|
768
|
+
// Even with an active shared plan present AND the base being the default, rule 1 must fire first.
|
|
769
|
+
const state: AdmitRepo = { repo: "o/r10", defaultBranch: "main", branches: new Set(["main"]), creates: [], metaCalls: 0 };
|
|
770
|
+
const planRows = [{ plan_key: "o/r10#1", repo: "o/r10", base_branch: "main", status: "planning" }];
|
|
771
|
+
await withAdmit(state, async () => {
|
|
772
|
+
await assertRejects(() => admitPlan(admitData(planRows), state.repo, " ", "tok"), MissingBaseBranchError);
|
|
773
|
+
});
|
|
774
|
+
assertEquals(state.metaCalls, 0); // never reached rule 3
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
test("admitPlan ORDER: a typo'd non-epic base is rejected (rule 2) before confirm-default (rule 3)", async () => {
|
|
778
|
+
const state: AdmitRepo = { repo: "o/r11", defaultBranch: "main", branches: new Set(["main"]), creates: [], metaCalls: 0 };
|
|
779
|
+
await withAdmit(state, async () => {
|
|
780
|
+
await assertRejects(() => admitPlan(admitData(), state.repo, "mian", "tok"), BaseBranchMustExistError);
|
|
781
|
+
});
|
|
782
|
+
// ensureBaseBranch (rule 2) throws for the missing non-epic/* branch before fetchDefaultBranch (rule 3).
|
|
783
|
+
assertEquals(state.metaCalls, 0);
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
test("findActivePlansByBase returns only non-terminal plans on the matching repo + base", async () => {
|
|
787
|
+
const rows = [
|
|
788
|
+
{ plan_key: "o/x#1", repo: "o/x", base_branch: "epic/b", status: "planning" },
|
|
789
|
+
{ plan_key: "o/x#2", repo: "o/x", base_branch: "epic/b", status: "done" },
|
|
790
|
+
{ plan_key: "o/x#3", repo: "o/x", base_branch: "epic/other", status: "planning" },
|
|
791
|
+
];
|
|
792
|
+
const active = await findActivePlansByBase(admitData(rows), "o/x", "epic/b");
|
|
793
|
+
assertEquals(active.length, 1);
|
|
794
|
+
assertEquals(active[0].plan_key, "o/x#1");
|
|
547
795
|
});
|
package/app/plan.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// hand-written SQL — matching app/service.ts.
|
|
12
12
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
13
13
|
import { blackboardUrl, mintBlackboardToken, renderCoordinationBrief } from "./blackboard.ts";
|
|
14
|
+
import { ensureBaseBranch, fetchDefaultBranch } from "./github.ts";
|
|
14
15
|
import { clearExclusions } from "./mergeExclusion.ts";
|
|
15
16
|
import { clearTaskDeltas } from "./taskDelta.ts";
|
|
16
17
|
import { resolveTrialMergeAttention, trialMergeWaveFromTaskId } from "./trialMerge.ts";
|
|
@@ -71,9 +72,11 @@ export interface Plan {
|
|
|
71
72
|
// Minted at plan start; baked into the blackboard URL handed to implementer agents. NULL for
|
|
72
73
|
// plans created before the blackboard shipped.
|
|
73
74
|
blackboard_token: string | null;
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
75
|
+
// Target base branch (019_plan_base_branch.sql; ADR 0003): the fleet branches off this branch and
|
|
76
|
+
// opens every task PR against it instead of the repository's default branch, landing the whole
|
|
77
|
+
// epic on a long-lived integration branch. New launches always set it (base is required at
|
|
78
|
+
// admission); the column stays NULLABLE ONLY to grandfather pre-ADR-0003 / in-flight rows that
|
|
79
|
+
// carry NULL — those must remain readable, so do NOT add a NOT NULL migration.
|
|
77
80
|
base_branch: string | null;
|
|
78
81
|
created_at: string;
|
|
79
82
|
updated_at: string;
|
|
@@ -258,6 +261,16 @@ export class InvalidBaseBranchError extends Error {
|
|
|
258
261
|
}
|
|
259
262
|
}
|
|
260
263
|
|
|
264
|
+
/** Raised when a caller supplies a blank/absent `baseBranch`. Every epic launch must name its base
|
|
265
|
+
* branch explicitly (ADR 0003): "land on the default branch" is a conscious, named, confirmed choice
|
|
266
|
+
* (the confirm-default gate), never a silent fallback. The operation edge maps this to a 400. */
|
|
267
|
+
export class MissingBaseBranchError extends Error {
|
|
268
|
+
constructor() {
|
|
269
|
+
super("base branch is required (blank/absent base branches are rejected)");
|
|
270
|
+
this.name = "MissingBaseBranchError";
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
261
274
|
/** Conservative allowlist gate for a base-branch name. Stricter than `git check-ref-format` on
|
|
262
275
|
* purpose: only `[A-Za-z0-9._/-]`, no leading `/`/`.`/`-` (a leading dash reads as a CLI flag),
|
|
263
276
|
* no trailing `/`/`.`, no `..`/`//`, no empty or `.lock`-suffixed path component, bounded length.
|
|
@@ -270,13 +283,14 @@ function isPlausibleBranchName(s: string): boolean {
|
|
|
270
283
|
return s.split("/").every((seg) => seg.length > 0 && !seg.startsWith(".") && !seg.endsWith(".lock"));
|
|
271
284
|
}
|
|
272
285
|
|
|
273
|
-
/** Normalise a caller-supplied base branch: trim,
|
|
274
|
-
*
|
|
275
|
-
* not a plausible git branch name is
|
|
276
|
-
*
|
|
277
|
-
|
|
286
|
+
/** Normalise a caller-supplied base branch: trim, then require it. A blank/absent value is rejected
|
|
287
|
+
* (`MissingBaseBranchError`) — ADR 0003 removed the implicit default-branch fallback, so every epic
|
|
288
|
+
* launch must name its base explicitly. A non-blank value that is not a plausible git branch name is
|
|
289
|
+
* rejected (`InvalidBaseBranchError`) rather than persisted or rendered into the agent prompt. The
|
|
290
|
+
* operation edge maps both to a 400. Always returns a non-null branch on success. */
|
|
291
|
+
export function normalizeBaseBranch(input: string | null | undefined): string {
|
|
278
292
|
const s = (input ?? "").trim();
|
|
279
|
-
if (s.length === 0)
|
|
293
|
+
if (s.length === 0) throw new MissingBaseBranchError();
|
|
280
294
|
if (!isPlausibleBranchName(s)) throw new InvalidBaseBranchError(s);
|
|
281
295
|
return s;
|
|
282
296
|
}
|
|
@@ -304,13 +318,131 @@ export function renderBaseBranchBrief(baseBranch: string): string {
|
|
|
304
318
|
].join("\n");
|
|
305
319
|
}
|
|
306
320
|
|
|
321
|
+
/** Raised when the explicit base branch IS the repository default branch but the caller did not
|
|
322
|
+
* acknowledge the consequence with `confirmDefaultBase: true` (ADR 0003 rule 3). Naming the default
|
|
323
|
+
* is the one dangerous explicit value: every task lands directly on it with no integration buffer,
|
|
324
|
+
* and any merge-to-default side effect fires per task. The operation edge maps this to a 400. */
|
|
325
|
+
export class DefaultBaseNotConfirmedError extends Error {
|
|
326
|
+
readonly branch: string;
|
|
327
|
+
constructor(branch: string) {
|
|
328
|
+
super(
|
|
329
|
+
`base branch "${branch}" is the repository default branch: every task would land directly ` +
|
|
330
|
+
`on "${branch}" with NO integration branch, and any merge-to-default side effect (e.g. ` +
|
|
331
|
+
`auto-publish) would fire per task. Re-submit with confirmDefaultBase: true to acknowledge ` +
|
|
332
|
+
`and proceed, or name an epic/* integration branch instead.`,
|
|
333
|
+
);
|
|
334
|
+
this.name = "DefaultBaseNotConfirmedError";
|
|
335
|
+
this.branch = branch;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Raised when another ACTIVE plan (status ∉ PLAN_TERMINAL_STATUSES) already targets the same repo
|
|
340
|
+
* + same custom base branch, and the caller did not pass `allowSharedBase: true` (ADR 0003 rule 4).
|
|
341
|
+
* Two in-flight epics sharing one integration branch interleave commits and poison each other's
|
|
342
|
+
* base. The default branch is EXEMPT (many epics target it concurrently without colliding — each
|
|
343
|
+
* task PR is independent). The operation edge maps this to a 409. */
|
|
344
|
+
export class SharedBaseError extends Error {
|
|
345
|
+
readonly repo: string;
|
|
346
|
+
readonly branch: string;
|
|
347
|
+
constructor(repo: string, branch: string) {
|
|
348
|
+
super(
|
|
349
|
+
`base branch "${branch}" on ${repo} is already in use by another active epic. Sharing one ` +
|
|
350
|
+
`integration branch across epics interleaves their commits and poisons the base. Re-submit ` +
|
|
351
|
+
`with allowSharedBase: true only if you intend to stack on it, or name a distinct epic/* ` +
|
|
352
|
+
`branch.`,
|
|
353
|
+
);
|
|
354
|
+
this.name = "SharedBaseError";
|
|
355
|
+
this.repo = repo;
|
|
356
|
+
this.branch = branch;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Find plans on `repo` targeting `base` whose status is NOT terminal (i.e. still active). Used by
|
|
361
|
+
* the shared-base admission guard to detect a second epic reaching for the same integration branch.
|
|
362
|
+
* Grandfathered `base_branch = null` rows never match a non-null `base`, so they are ignored. */
|
|
363
|
+
export async function findActivePlansByBase(
|
|
364
|
+
data: DataLayer,
|
|
365
|
+
repo: string,
|
|
366
|
+
base: string,
|
|
367
|
+
): Promise<Plan[]> {
|
|
368
|
+
const rows = await plans(data).find({ repo, base_branch: base });
|
|
369
|
+
return rows.filter((p) => !PLAN_TERMINAL_STATUSES.includes(p.status));
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Options gating the confirm-default (rule 3) and shared-base (rule 4) admission rules. Both
|
|
373
|
+
* default to `false` — a "warn you can't skip": the operator must consciously opt in. */
|
|
374
|
+
export interface AdmitPlanOptions {
|
|
375
|
+
allowSharedBase?: boolean;
|
|
376
|
+
confirmDefaultBase?: boolean;
|
|
377
|
+
/** The `plan_key` of the launch being admitted. When set, the shared-base guard (rule 4)
|
|
378
|
+
* EXCLUDES this plan's own active row, so an idempotent re-submit of the same issue does not
|
|
379
|
+
* trip `SharedBaseError` against itself — `startPlan` is idempotent on `plan_key` and returns
|
|
380
|
+
* `alreadyRunning` for an active plan, so the retry must reach it, not 409 on rule 4. */
|
|
381
|
+
selfPlanKey?: string;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Fail-fast admission gate for an epic launch (ADR 0003 §Decision). Composes the four ordered
|
|
385
|
+
* admission rules BEFORE any task fans out and returns the normalized base branch on success. The
|
|
386
|
+
* ORDER is load-bearing — the cheapest / most fundamental reject (missing or typo'd base) fires
|
|
387
|
+
* first, so it is NOT reordered:
|
|
388
|
+
*
|
|
389
|
+
* 1. Required + explicit — `normalizeBaseBranch` (blank/absent → `MissingBaseBranchError`;
|
|
390
|
+
* implausible → `InvalidBaseBranchError`).
|
|
391
|
+
* 2. Create-if-missing (epic/* guard), synchronously — `ensureBaseBranch`: a missing non-`epic/*`
|
|
392
|
+
* base throws `BaseBranchMustExistError` HERE (so a typo is a clean edge 400, not a late
|
|
393
|
+
* per-task failure); a missing `epic/*` base is created off default HEAD before fan-out; an
|
|
394
|
+
* existing base is a no-op. It is idempotent, so the durable `ensure-base-branch` head task
|
|
395
|
+
* re-runs it as belt-and-suspenders.
|
|
396
|
+
* 3. Confirm-default — if the base equals the repo default branch and `confirmDefaultBase` is not
|
|
397
|
+
* `true`, throw `DefaultBaseNotConfirmedError`. The default branch is then EXEMPT from rule 4.
|
|
398
|
+
* 4. Shared-base — if a DIFFERENT active plan already targets this same custom base and
|
|
399
|
+
* `allowSharedBase` is not `true`, throw `SharedBaseError`. The launch's own active row is
|
|
400
|
+
* excluded (via `options.selfPlanKey`) so an idempotent same-issue re-submit is not a 409.
|
|
401
|
+
*/
|
|
402
|
+
export async function admitPlan(
|
|
403
|
+
data: DataLayer,
|
|
404
|
+
repo: string,
|
|
405
|
+
baseBranch: string | null | undefined,
|
|
406
|
+
token: string,
|
|
407
|
+
options: AdmitPlanOptions = {},
|
|
408
|
+
): Promise<string> {
|
|
409
|
+
// Rule 1 — required + explicit.
|
|
410
|
+
const base = normalizeBaseBranch(baseBranch);
|
|
411
|
+
|
|
412
|
+
// Rule 2 — create-if-missing (epic/* guard), synchronously at admission. A missing non-epic/*
|
|
413
|
+
// base throws BaseBranchMustExistError → clean edge 400; a missing epic/* base is created off
|
|
414
|
+
// default HEAD; an existing base is a no-op. Idempotent, so the head task safely re-runs it.
|
|
415
|
+
await ensureBaseBranch(repo, base, token);
|
|
416
|
+
|
|
417
|
+
// Rule 3 — confirm-default. Naming the repo default branch is deliberate and requires an explicit
|
|
418
|
+
// acknowledgement. When the base IS the default, it is exempt from the shared-base guard (rule 4),
|
|
419
|
+
// so return here on a confirmed default.
|
|
420
|
+
const defaultBranch = await fetchDefaultBranch(repo, token);
|
|
421
|
+
if (defaultBranch !== null && base === defaultBranch) {
|
|
422
|
+
if (options.confirmDefaultBase !== true) throw new DefaultBaseNotConfirmedError(base);
|
|
423
|
+
return base;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Rule 4 — shared-base guard on a custom integration branch. Exclude this launch's OWN active row
|
|
427
|
+
// (when `selfPlanKey` is given) so an idempotent same-issue re-submit reaches `startPlan`'s
|
|
428
|
+
// `alreadyRunning` short-circuit instead of tripping a 409 against itself.
|
|
429
|
+
if (options.allowSharedBase !== true) {
|
|
430
|
+
const active = (await findActivePlansByBase(data, repo, base)).filter(
|
|
431
|
+
(p) => p.plan_key !== options.selfPlanKey,
|
|
432
|
+
);
|
|
433
|
+
if (active.length > 0) throw new SharedBaseError(repo, base);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return base;
|
|
437
|
+
}
|
|
438
|
+
|
|
307
439
|
/** Register a plan row (if new) and start the plan-fanout process. Idempotent on
|
|
308
440
|
* planKey: a plan already in flight is not restarted. */
|
|
309
441
|
export async function startPlan(
|
|
310
442
|
data: DataLayer,
|
|
311
443
|
engine: EngineClient,
|
|
312
444
|
parsed: ParsedIssue,
|
|
313
|
-
baseBranch: string
|
|
445
|
+
baseBranch: string,
|
|
314
446
|
) {
|
|
315
447
|
const table = plans(data);
|
|
316
448
|
const existing = await table.get(parsed.planKey);
|
|
@@ -399,12 +531,12 @@ export async function startPlan(
|
|
|
399
531
|
// out-of-band.
|
|
400
532
|
blackboardUrl: bbUrl,
|
|
401
533
|
blackboardBrief: renderCoordinationBrief(bbUrl),
|
|
402
|
-
//
|
|
403
|
-
// opens every PR against instead of the repo default. `baseBranchBrief` rides
|
|
404
|
-
// in the implement-task (like `blackboardBrief`)
|
|
405
|
-
//
|
|
534
|
+
// Epic base branch (019_plan_base_branch.sql; ADR 0003): the branch the fleet branches off
|
|
535
|
+
// and opens every PR against instead of the repo default. `baseBranchBrief` rides
|
|
536
|
+
// `appendPrompt` in the implement-task (like `blackboardBrief`). Base is now always explicit
|
|
537
|
+
// (normalizeBaseBranch rejects blank), so the brief is always rendered.
|
|
406
538
|
baseBranch: base,
|
|
407
|
-
baseBranchBrief:
|
|
539
|
+
baseBranchBrief: renderBaseBranchBrief(base),
|
|
408
540
|
},
|
|
409
541
|
});
|
|
410
542
|
const processKey = processInstanceKey == null ? null : String(processInstanceKey);
|