@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 CHANGED
@@ -1,3 +1,10 @@
1
+ # [0.57.0](https://github.com/nanobpm/nano-workforce/compare/v0.56.0...v0.57.0) (2026-08-13)
2
+
3
+
4
+ ### Features
5
+
6
+ * **app-view:** show epic base branch in epics overview grid ([#159](https://github.com/nanobpm/nano-workforce/issues/159)) ([#164](https://github.com/nanobpm/nano-workforce/issues/164)) ([216cb0f](https://github.com/nanobpm/nano-workforce/commit/216cb0f515561fbaa646725593707ae6a2418246))
7
+
1
8
  # [0.56.0](https://github.com/nanobpm/nano-workforce/compare/v0.55.0...v0.56.0) (2026-08-13)
2
9
 
3
10
 
package/README.md CHANGED
@@ -231,9 +231,17 @@ fleet"** form, or POST the same operation the form does:
231
231
  ```bash
232
232
  curl -sS -X POST http://localhost:3000/app/api/actions/start/plan-fanout \
233
233
  -H 'content-type: application/json' \
234
- -d '{ "issue": "owner/repo#123" }'
234
+ -d '{ "issue": "owner/repo#123", "baseBranch": "epic/agent-protocol" }'
235
235
  ```
236
236
 
237
+ `baseBranch` is **required** (ADR 0003) — it's the integration branch the whole fleet
238
+ branches off and opens every PR against. A missing `epic/*` base is auto-created off the
239
+ default branch's HEAD; a missing non-`epic/*` base is a `400` (must already exist). Two
240
+ optional flags gate the dangerous cases: `confirmDefaultBase: true` is required to name the
241
+ repository default branch as the base, and `allowSharedBase: true` is required when another
242
+ active epic already targets the same custom base. See
243
+ [ADR 0003](docs/adr/0003-epic-base-branch-admission.md) for the full admission model.
244
+
237
245
  ---
238
246
 
239
247
  ## Configuration
package/SPEC.md CHANGED
@@ -466,8 +466,12 @@ scalar-only and cannot express the `tasks`/`results` lists, so the workers self-
466
466
  `plan_tasks` (one row per slice, tracking its `status`/`pr_key`/`summary`).
467
467
 
468
468
  **Entry points**: the epic page's "Hand an issue to the fleet" form or
469
- `POST /app/api/actions/start/plan-fanout` (`{ issue | url }`) — the same flat
470
- operation the form posts.
469
+ `POST /app/api/actions/start/plan-fanout` (either `{ issue, baseBranch }` or
470
+ `{ url, baseBranch }` — a `oneOf` naming the target by **exactly one** of `issue`
471
+ (`owner/repo#123`) or `url`, plus optional `confirmDefaultBase`/`allowSharedBase`) —
472
+ the same flat operation the form posts. `baseBranch`
473
+ is required and admitted through the ADR 0003 gate (auto-create `epic/*`, confirm-default,
474
+ shared-base guard).
471
475
 
472
476
  **Visibility**: the home page adds a **Plans** grid (Active: planning/dispatched;
473
477
  History: done/failed/abandoned) with a `plan_tasks` child grid showing each task's
package/app/agentGuide.ts CHANGED
@@ -31,7 +31,7 @@ const RAW_GUIDE: string = (() => {
31
31
  "- `GET /status` — every PR in flight, with its engine `processKey` and any open escalation.",
32
32
  "- `GET /version` — which code is live.",
33
33
  "- `POST /actions/start/convergence-loop` — submit a PR (`{ pr, convergeOnly?, maxRounds?, dependsOn? }`).",
34
- "- `POST /actions/start/plan-fanout` — submit an epic (`{ issue }`).",
34
+ "- `POST /actions/start/plan-fanout` — submit an epic (`{ issue, baseBranch }` or `{ url, baseBranch }`; base is required — a missing `epic/*` base is auto-created, and `confirmDefaultBase`/`allowSharedBase` gate the default-branch and shared-base cases — see ADR 0003).",
35
35
  "- `POST /actions/message` — answer an escalation (`escalation-answered`, correlate by PR key).",
36
36
  "",
37
37
  "Engine (Camunda-8 v2 REST) base for debugging: `__ENGINE__`.",
@@ -3,7 +3,7 @@
3
3
  // the merge-exclusion graph. Force the token transport and stub `globalThis.fetch`.
4
4
  import { test } from "node:test";
5
5
  import { assertEquals, assertRejects } from "#test-assert";
6
- import { fetchPrFiles } from "./github.ts";
6
+ import { BaseBranchMustExistError, ensureBaseBranch, fetchPrFiles } from "./github.ts";
7
7
 
8
8
  // A fake `fetch` that serves `pages` of file batches; each page N (1-based) returns `pages[N-1]`
9
9
  // files (named `f{index}`), setting a `Link: rel="next"` header whenever a later page exists.
@@ -58,3 +58,181 @@ test("fetchPrFiles: throws when the cap genuinely truncates (full last page + ne
58
58
  "truncated",
59
59
  );
60
60
  });
61
+
62
+ // ── ensureBaseBranch (ADR 0003 rule 2) ──────────────────────────────────────
63
+ // Force the token transport and stub `globalThis.fetch` so the create-if-missing primitive is
64
+ // exercised end-to-end without touching the network: git-ref lookups, default-branch resolution,
65
+ // and ref creation are all served from an in-memory repo model that records every create call.
66
+ interface FakeRepo {
67
+ repo: string;
68
+ defaultBranch: string;
69
+ branches: Map<string, string>; // branch name → head sha (includes the default branch)
70
+ creates: { ref: string; sha: string }[];
71
+ }
72
+
73
+ function jsonResponse(obj: unknown, status = 200): Response {
74
+ return new Response(JSON.stringify(obj), {
75
+ status,
76
+ headers: { "content-type": "application/json" },
77
+ });
78
+ }
79
+
80
+ function githubFetch(state: FakeRepo) {
81
+ return (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
82
+ const u = new URL(String(url));
83
+ const method = (init?.method ?? "GET").toUpperCase();
84
+ const path = u.pathname;
85
+ // Repo metadata → default branch (fetchDefaultBranch token mode).
86
+ if (method === "GET" && path === `/repos/${state.repo}`) {
87
+ return Promise.resolve(jsonResponse({ default_branch: state.defaultBranch }));
88
+ }
89
+ // Git ref lookup → head sha or 404.
90
+ const refPrefix = `/repos/${state.repo}/git/ref/heads/`;
91
+ if (method === "GET" && path.startsWith(refPrefix)) {
92
+ const branch = decodeURIComponent(path.slice(refPrefix.length));
93
+ const sha = state.branches.get(branch);
94
+ if (sha === undefined) return Promise.resolve(new Response("Not Found", { status: 404 }));
95
+ return Promise.resolve(jsonResponse({ ref: `refs/heads/${branch}`, object: { sha } }));
96
+ }
97
+ // Create ref.
98
+ if (method === "POST" && path === `/repos/${state.repo}/git/refs`) {
99
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
100
+ const body = JSON.parse(String(init?.body ?? "{}")) as { ref?: string; sha?: string };
101
+ const ref = String(body.ref ?? "");
102
+ const sha = String(body.sha ?? "");
103
+ const branch = ref.replace(/^refs\/heads\//, "");
104
+ if (state.branches.has(branch)) {
105
+ return Promise.resolve(jsonResponse({ message: "Reference already exists" }, 422));
106
+ }
107
+ state.creates.push({ ref, sha });
108
+ state.branches.set(branch, sha);
109
+ return Promise.resolve(jsonResponse({ ref }, 201));
110
+ }
111
+ return Promise.resolve(new Response(`unexpected ${method} ${path}`, { status: 500 }));
112
+ };
113
+ }
114
+
115
+ async function withGithub<T>(state: FakeRepo, fn: () => Promise<T>): Promise<T> {
116
+ const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
117
+ const prevFetch = globalThis.fetch;
118
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
119
+ globalThis.fetch = githubFetch(state) as typeof fetch;
120
+ try {
121
+ return await fn();
122
+ } finally {
123
+ globalThis.fetch = prevFetch;
124
+ if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
125
+ else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
126
+ }
127
+ }
128
+
129
+ test("ensureBaseBranch: existing branch is a no-op (never creates/resets the ref)", async () => {
130
+ const state: FakeRepo = {
131
+ repo: "o/exists",
132
+ defaultBranch: "main",
133
+ branches: new Map([
134
+ ["main", "mainsha"],
135
+ ["epic/already-there", "existingsha"],
136
+ ]),
137
+ creates: [],
138
+ };
139
+ const result = await withGithub(state, () =>
140
+ ensureBaseBranch(state.repo, "epic/already-there", "tok"),
141
+ );
142
+ assertEquals(result, "exists");
143
+ assertEquals(state.creates.length, 0);
144
+ // The ref must be left untouched.
145
+ assertEquals(state.branches.get("epic/already-there"), "existingsha");
146
+ });
147
+
148
+ test("ensureBaseBranch: missing epic/* branch is created off the default branch HEAD", async () => {
149
+ const state: FakeRepo = {
150
+ repo: "o/create-epic",
151
+ defaultBranch: "main",
152
+ branches: new Map([["main", "defaulthead"]]),
153
+ creates: [],
154
+ };
155
+ const result = await withGithub(state, () =>
156
+ ensureBaseBranch(state.repo, "epic/new-feature", "tok"),
157
+ );
158
+ assertEquals(result, "created");
159
+ assertEquals(state.creates, [{ ref: "refs/heads/epic/new-feature", sha: "defaulthead" }]);
160
+ });
161
+
162
+ test("ensureBaseBranch: idempotent — a second call once the branch exists is a no-op", async () => {
163
+ const state: FakeRepo = {
164
+ repo: "o/idempotent",
165
+ defaultBranch: "main",
166
+ branches: new Map([["main", "defaulthead"]]),
167
+ creates: [],
168
+ };
169
+ const first = await withGithub(state, () => ensureBaseBranch(state.repo, "epic/twice", "tok"));
170
+ assertEquals(first, "created");
171
+ assertEquals(state.creates.length, 1);
172
+ // Re-plan / durable head-task re-run: the branch now exists → clean no-op, no second create.
173
+ const second = await withGithub(state, () => ensureBaseBranch(state.repo, "epic/twice", "tok"));
174
+ assertEquals(second, "exists");
175
+ assertEquals(state.creates.length, 1);
176
+ });
177
+
178
+ test(
179
+ 'ensureBaseBranch: concurrent create race (GET 404 then POST 422) reports "exists", not "created"',
180
+ async () => {
181
+ // Simulate losing the create race: the ref lookup 404s (so we attempt a create), but by the
182
+ // time our POST lands another actor has already created the ref → GitHub answers 422. The
183
+ // 422 is idempotent, and the outcome must be the honest "exists" (we did not create it), not
184
+ // a misleading "created". This locks in the retriable semantics for a concurrent create.
185
+ const state: FakeRepo = {
186
+ repo: "o/race",
187
+ defaultBranch: "main",
188
+ branches: new Map([["main", "defaulthead"]]),
189
+ creates: [],
190
+ };
191
+ // The git-ref lookup for the epic branch always 404s (as if it does not exist yet), while a
192
+ // concurrent actor has "already created" it so our POST sees a 422.
193
+ const base = githubFetch(state);
194
+ const racingFetch = (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
195
+ const u = new URL(String(url));
196
+ const method = (init?.method ?? "GET").toUpperCase();
197
+ const path = u.pathname;
198
+ if (method === "GET" && path === `/repos/${state.repo}/git/ref/heads/epic/raced`) {
199
+ return Promise.resolve(new Response("Not Found", { status: 404 }));
200
+ }
201
+ if (method === "POST" && path === `/repos/${state.repo}/git/refs`) {
202
+ return Promise.resolve(jsonResponse({ message: "Reference already exists" }, 422));
203
+ }
204
+ return base(url, init);
205
+ };
206
+ const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
207
+ const prevFetch = globalThis.fetch;
208
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
209
+ globalThis.fetch = racingFetch as typeof fetch;
210
+ try {
211
+ const result = await ensureBaseBranch(state.repo, "epic/raced", "tok");
212
+ assertEquals(result, "exists");
213
+ } finally {
214
+ globalThis.fetch = prevFetch;
215
+ if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
216
+ else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
217
+ }
218
+ },
219
+ );
220
+
221
+ test("ensureBaseBranch: missing non-epic/* branch throws BaseBranchMustExistError", async () => {
222
+ const state: FakeRepo = {
223
+ repo: "o/typo",
224
+ defaultBranch: "main",
225
+ branches: new Map([["main", "defaulthead"]]),
226
+ creates: [],
227
+ };
228
+ const err = await withGithub(state, () =>
229
+ assertRejects(
230
+ () => ensureBaseBranch(state.repo, "feature/typo", "tok"),
231
+ BaseBranchMustExistError,
232
+ "feature/typo",
233
+ ),
234
+ );
235
+ assertEquals(err instanceof BaseBranchMustExistError, true);
236
+ // A rejected non-epic/* base must never spawn a wrong-rooted branch.
237
+ assertEquals(state.creates.length, 0);
238
+ });
package/app/github.ts CHANGED
@@ -454,6 +454,12 @@ export async function fetchDefaultBranch(repo: string, token: string): Promise<s
454
454
  return name;
455
455
  }
456
456
 
457
+ /** Test-only: drop the memoized default-branch entries so a suite can't leak a warmed cache
458
+ * (which ignores transport/token state on a hit) into a test that expects a cold lookup. */
459
+ export function resetDefaultBranchCache(): void {
460
+ defaultBranchCache.clear();
461
+ }
462
+
457
463
  /** Whether a branch has already *landed* — i.e. it is the head of a `MERGED` PR. Returns:
458
464
  * • `landed` — a merged PR exists from this branch → the branch is a dead-end target
459
465
  * • `open` — an open PR exists from it (still alive)
@@ -672,3 +678,129 @@ export async function enqueueViaComment(
672
678
  });
673
679
  return r.ok;
674
680
  }
681
+
682
+ // ── Epic base-branch admission (ADR 0003, rule 2) ───────────────────────────
683
+ // `ensureBaseBranch` is the create-if-missing primitive that guarantees an epic's integration
684
+ // branch exists BEFORE any task fans out, with an `epic/*` guard so a typo can't silently spawn a
685
+ // wrong-rooted branch. It is idempotent — an existing branch is a NO-OP (the ref is never reset,
686
+ // which would nuke in-flight task PRs stacked on it) — so it is safe to call repeatedly: at
687
+ // admission (fail fast), from the durable `ensure-base-branch` head task, and again on a re-plan.
688
+
689
+ /** Thrown when a base branch that does NOT match the `epic/*` convention is missing. A
690
+ * non-`epic/*` base must already exist — a mistyped name is an operator error, not something to
691
+ * auto-create off the default branch (that would silently produce a wrong-rooted branch). */
692
+ export class BaseBranchMustExistError extends Error {
693
+ readonly branch: string;
694
+ constructor(branch: string) {
695
+ super(
696
+ `base branch "${branch}" does not exist and is not an epic/* branch, so it will not be ` +
697
+ `auto-created — create it first, or use the epic/* convention for an auto-created ` +
698
+ `integration branch`,
699
+ );
700
+ this.name = "BaseBranchMustExistError";
701
+ this.branch = branch;
702
+ }
703
+ }
704
+
705
+ /** Whether `branch` matches the auto-creatable `epic/*` convention (migration 019). */
706
+ function isEpicBranch(branch: string): boolean {
707
+ return branch.startsWith("epic/");
708
+ }
709
+
710
+ /** Resolve the head commit SHA of `branch` on `repo`, or `null` when the branch does not exist
711
+ * (a 404 from the git-ref endpoint). Throws only on a genuine transport failure. */
712
+ async function branchHeadSha(repo: string, branch: string, token: string): Promise<string | null> {
713
+ const apiPath = `repos/${repo}/git/ref/heads/${branch}`;
714
+ if (await useGh()) {
715
+ try {
716
+ const out = await runGh(["api", apiPath]);
717
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
718
+ const j = JSON.parse(out) as { object?: { sha?: string } };
719
+ return j.object?.sha ?? null;
720
+ } catch (err) {
721
+ const msg = err instanceof Error ? err.message : String(err);
722
+ if (/\b404\b|not found|no such/i.test(msg)) return null;
723
+ throw err;
724
+ }
725
+ }
726
+ if (!token) throw new Error(`no GitHub transport available to read ${apiPath}`);
727
+ const r = await fetch(`https://api.github.com/${apiPath}`, {
728
+ headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
729
+ });
730
+ if (r.status === 404) return null;
731
+ if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
732
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
733
+ const j = (await r.json()) as { object?: { sha?: string } };
734
+ return j.object?.sha ?? null;
735
+ }
736
+
737
+ /** Create `refs/heads/<branch>` pointing at `sha`. Idempotent: a concurrent create / re-plan
738
+ * that already made the ref (GitHub `422 Reference already exists`) is treated as a no-op.
739
+ * Returns `true` when this call actually created the ref, `false` when it lost the race and the
740
+ * ref already existed (the 422 case) — so the caller can report an honest exists/created outcome. */
741
+ async function createBranchRef(
742
+ repo: string,
743
+ branch: string,
744
+ sha: string,
745
+ token: string,
746
+ ): Promise<boolean> {
747
+ const ref = `refs/heads/${branch}`;
748
+ if (await useGh()) {
749
+ try {
750
+ await runGh(["api", `repos/${repo}/git/refs`, "-X", "POST", "-f", `ref=${ref}`, "-f", `sha=${sha}`]);
751
+ } catch (err) {
752
+ const msg = err instanceof Error ? err.message : String(err);
753
+ if (/\b422\b|already exists/i.test(msg)) return false; // idempotent — someone else created it
754
+ throw err;
755
+ }
756
+ return true;
757
+ }
758
+ if (!token) throw new Error(`no GitHub transport available to create ${ref}`);
759
+ const r = await fetch(`https://api.github.com/repos/${repo}/git/refs`, {
760
+ method: "POST",
761
+ headers: {
762
+ authorization: `Bearer ${token}`,
763
+ accept: "application/vnd.github+json",
764
+ "content-type": "application/json",
765
+ },
766
+ body: JSON.stringify({ ref, sha }),
767
+ });
768
+ if (r.ok) return true;
769
+ if (r.status === 422) return false; // reference already exists — idempotent
770
+ throw new Error(`github ${r.status} ${r.statusText}: ${(await r.text()).slice(0, 300)}`.trim());
771
+ }
772
+
773
+ /** The outcome of `ensureBaseBranch`: the branch was already present (`exists`, a no-op) or was
774
+ * just created off the default branch HEAD (`created`). */
775
+ export type EnsureBaseBranchResult = "exists" | "created";
776
+
777
+ /** Guarantee the epic base `branch` exists on `repo` (ADR 0003 rule 2), idempotently:
778
+ * • already exists → `"exists"` — NO-OP; the ref is never moved/reset.
779
+ * • missing and matches `epic/*` → create `refs/heads/<branch>` off the default branch HEAD,
780
+ * return `"created"`.
781
+ * • missing and not `epic/*` → throw `BaseBranchMustExistError` (a non-`epic/*` base must
782
+ * pre-exist; a typo must fail fast, not silently spawn a wrong-rooted branch).
783
+ * Safe to call repeatedly (at admission AND as the durable head task, and on a re-plan). */
784
+ export async function ensureBaseBranch(
785
+ repo: string,
786
+ branch: string,
787
+ token: string,
788
+ ): Promise<EnsureBaseBranchResult> {
789
+ const existing = await branchHeadSha(repo, branch, token);
790
+ if (existing !== null) return "exists"; // never reset an existing ref
791
+
792
+ if (!isEpicBranch(branch)) throw new BaseBranchMustExistError(branch);
793
+
794
+ const defaultBranch = await fetchDefaultBranch(repo, token);
795
+ if (!defaultBranch) {
796
+ throw new Error(`cannot resolve the default branch of ${repo} to create ${branch}`);
797
+ }
798
+ const defaultSha = await branchHeadSha(repo, defaultBranch, token);
799
+ if (!defaultSha) {
800
+ throw new Error(`cannot resolve HEAD of default branch ${defaultBranch} on ${repo} to create ${branch}`);
801
+ }
802
+ // A concurrent create / re-plan may have raced us to the ref (GitHub 422); in that case it
803
+ // already exists and we did not create it, so report "exists" rather than misleading "created".
804
+ const created = await createBranchRef(repo, branch, defaultSha, token);
805
+ return created ? "created" : "exists";
806
+ }