@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
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Integration coverage for the durable HEAD arm of ADR 0003 rule 2 — the `ensure-base-branch`
|
|
2
|
+
// service task (taskType `pr.ensure-base-branch`). The unit tests in workers/ensure-base-branch/
|
|
3
|
+
// worker.test.ts prove create/no-op in isolation; this file proves the END-TO-END belt-and-suspenders
|
|
4
|
+
// property across a RE-PLAN: the head task CREATES a missing epic/* base off default HEAD on the first
|
|
5
|
+
// pass, then NO-OPS on a second pass (idempotent — it neither errors nor resets the ref). Driven
|
|
6
|
+
// through the real worker handler against a faked github transport — no network, deterministic.
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import { assertEquals } from "#test-assert";
|
|
9
|
+
import { resetDefaultBranchCache } from "../../app/github.ts";
|
|
10
|
+
import handler from "./worker.ts";
|
|
11
|
+
|
|
12
|
+
interface GithubState {
|
|
13
|
+
repo: string;
|
|
14
|
+
defaultBranch: string;
|
|
15
|
+
branches: Map<string, string>; // branch → head sha
|
|
16
|
+
creates: { ref: string; sha: string }[];
|
|
17
|
+
resets: string[]; // any PATCH/force-update on an existing ref (must stay empty)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function githubFetch(state: GithubState) {
|
|
21
|
+
return (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
|
22
|
+
const u = new URL(String(url));
|
|
23
|
+
const method = (init?.method ?? "GET").toUpperCase();
|
|
24
|
+
const path = u.pathname;
|
|
25
|
+
const json = (obj: unknown, status = 200) =>
|
|
26
|
+
new Response(JSON.stringify(obj), { status, headers: { "content-type": "application/json" } });
|
|
27
|
+
if (method === "GET" && path === `/repos/${state.repo}`) {
|
|
28
|
+
return Promise.resolve(json({ default_branch: state.defaultBranch }));
|
|
29
|
+
}
|
|
30
|
+
const refPrefix = `/repos/${state.repo}/git/ref/heads/`;
|
|
31
|
+
if (method === "GET" && path.startsWith(refPrefix)) {
|
|
32
|
+
const branch = decodeURIComponent(path.slice(refPrefix.length));
|
|
33
|
+
const sha = state.branches.get(branch);
|
|
34
|
+
if (sha === undefined) return Promise.resolve(new Response("Not Found", { status: 404 }));
|
|
35
|
+
return Promise.resolve(json({ ref: `refs/heads/${branch}`, object: { sha } }));
|
|
36
|
+
}
|
|
37
|
+
if (method === "POST" && path === `/repos/${state.repo}/git/refs`) {
|
|
38
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
39
|
+
const body = JSON.parse(String(init?.body ?? "{}")) as { ref?: string; sha?: string };
|
|
40
|
+
const ref = String(body.ref ?? "");
|
|
41
|
+
const sha = String(body.sha ?? "");
|
|
42
|
+
const branch = ref.replace(/^refs\/heads\//, "");
|
|
43
|
+
if (state.branches.has(branch)) return Promise.resolve(json({ message: "Reference already exists" }, 422));
|
|
44
|
+
state.creates.push({ ref, sha });
|
|
45
|
+
state.branches.set(branch, sha);
|
|
46
|
+
return Promise.resolve(json({ ref }, 201));
|
|
47
|
+
}
|
|
48
|
+
// A ref force-update (reset) would be a PATCH to .../git/refs/heads/<branch>. The idempotent head
|
|
49
|
+
// task must NEVER issue one; record it so the test can assert it stayed untouched.
|
|
50
|
+
if (method === "PATCH" && path.startsWith(`/repos/${state.repo}/git/refs/heads/`)) {
|
|
51
|
+
state.resets.push(decodeURIComponent(path.split("/git/refs/heads/")[1] ?? ""));
|
|
52
|
+
return Promise.resolve(json({ ok: true }));
|
|
53
|
+
}
|
|
54
|
+
return Promise.resolve(new Response(`unexpected ${method} ${path}`, { status: 500 }));
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function withGithub<T>(state: GithubState, fn: () => Promise<T>): Promise<T> {
|
|
59
|
+
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
60
|
+
const prevTok = process.env["GITHUB_TOKEN"];
|
|
61
|
+
const prevFetch = globalThis.fetch;
|
|
62
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
|
|
63
|
+
process.env["GITHUB_TOKEN"] = "tok";
|
|
64
|
+
resetDefaultBranchCache(); // isolate: don't inherit or leak another test's default-branch entry
|
|
65
|
+
globalThis.fetch = githubFetch(state) as typeof fetch;
|
|
66
|
+
try {
|
|
67
|
+
return await fn();
|
|
68
|
+
} finally {
|
|
69
|
+
resetDefaultBranchCache();
|
|
70
|
+
globalThis.fetch = prevFetch;
|
|
71
|
+
if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
72
|
+
else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
|
|
73
|
+
if (prevTok === undefined) delete process.env["GITHUB_TOKEN"];
|
|
74
|
+
else process.env["GITHUB_TOKEN"] = prevTok;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const fakeApp = { log: { info() {}, warn() {}, error() {} } } as any;
|
|
79
|
+
|
|
80
|
+
function runHead(state: GithubState, repo: string, baseBranch: string) {
|
|
81
|
+
return withGithub(state, () => handler({ variables: { repo, baseBranch } } as any, fakeApp)) as Promise<{
|
|
82
|
+
baseBranchResult: string;
|
|
83
|
+
}>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
test("head task: creates a missing epic/* base on first pass, then no-ops on re-plan (idempotent)", async () => {
|
|
87
|
+
const state: GithubState = {
|
|
88
|
+
repo: "owner/epic-repo",
|
|
89
|
+
defaultBranch: "main",
|
|
90
|
+
branches: new Map([["main", "mainhead"]]),
|
|
91
|
+
creates: [],
|
|
92
|
+
resets: [],
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// First pass (fresh plan): the epic/* base is missing → created off default HEAD.
|
|
96
|
+
const first = await runHead(state, state.repo, "epic/gate");
|
|
97
|
+
assertEquals(first.baseBranchResult, "created");
|
|
98
|
+
assertEquals(state.creates, [{ ref: "refs/heads/epic/gate", sha: "mainhead" }]);
|
|
99
|
+
assertEquals(state.branches.get("epic/gate"), "mainhead");
|
|
100
|
+
|
|
101
|
+
// Second pass (re-plan / crash-recovery): the branch now exists → clean no-op. No further create,
|
|
102
|
+
// and — critically — no reset of the existing ref (a re-plan must not clobber landed work).
|
|
103
|
+
const second = await runHead(state, state.repo, "epic/gate");
|
|
104
|
+
assertEquals(second.baseBranchResult, "exists");
|
|
105
|
+
assertEquals(state.creates.length, 1); // still just the first create
|
|
106
|
+
assertEquals(state.resets, []); // never reset the ref
|
|
107
|
+
assertEquals(state.branches.get("epic/gate"), "mainhead"); // ref untouched
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("head task: a pre-existing base is a pure no-op (no create, no reset)", async () => {
|
|
111
|
+
const state: GithubState = {
|
|
112
|
+
repo: "owner/epic-repo2",
|
|
113
|
+
defaultBranch: "main",
|
|
114
|
+
branches: new Map([
|
|
115
|
+
["main", "mainhead"],
|
|
116
|
+
["epic/landed", "landedsha"],
|
|
117
|
+
]),
|
|
118
|
+
creates: [],
|
|
119
|
+
resets: [],
|
|
120
|
+
};
|
|
121
|
+
const out = await runHead(state, state.repo, "epic/landed");
|
|
122
|
+
assertEquals(out.baseBranchResult, "exists");
|
|
123
|
+
assertEquals(state.creates, []);
|
|
124
|
+
assertEquals(state.resets, []);
|
|
125
|
+
assertEquals(state.branches.get("epic/landed"), "landedsha"); // untouched
|
|
126
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// pr.ensure-base-branch worker — the durable, retriable head arm of ADR 0003 rule 2.
|
|
2
|
+
//
|
|
3
|
+
// It re-runs the idempotent `ensureBaseBranch` primitive on the durable path, so it must CREATE a
|
|
4
|
+
// missing epic/* base off default HEAD and NO-OP when the branch already exists. Drive it through a
|
|
5
|
+
// faked github transport (token mode + stubbed `globalThis.fetch`) so no network is touched.
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import { assertEquals } from "#test-assert";
|
|
8
|
+
import handler from "./worker.ts";
|
|
9
|
+
|
|
10
|
+
interface FakeRepo {
|
|
11
|
+
repo: string;
|
|
12
|
+
defaultBranch: string;
|
|
13
|
+
branches: Map<string, string>; // branch name → head sha
|
|
14
|
+
creates: { ref: string; sha: string }[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function jsonResponse(obj: unknown, status = 200): Response {
|
|
18
|
+
return new Response(JSON.stringify(obj), { status, headers: { "content-type": "application/json" } });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function githubFetch(state: FakeRepo) {
|
|
22
|
+
return (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
|
23
|
+
const u = new URL(String(url));
|
|
24
|
+
const method = (init?.method ?? "GET").toUpperCase();
|
|
25
|
+
const path = u.pathname;
|
|
26
|
+
if (method === "GET" && path === `/repos/${state.repo}`) {
|
|
27
|
+
return Promise.resolve(jsonResponse({ default_branch: state.defaultBranch }));
|
|
28
|
+
}
|
|
29
|
+
const refPrefix = `/repos/${state.repo}/git/ref/heads/`;
|
|
30
|
+
if (method === "GET" && path.startsWith(refPrefix)) {
|
|
31
|
+
const branch = decodeURIComponent(path.slice(refPrefix.length));
|
|
32
|
+
const sha = state.branches.get(branch);
|
|
33
|
+
if (sha === undefined) return Promise.resolve(new Response("Not Found", { status: 404 }));
|
|
34
|
+
return Promise.resolve(jsonResponse({ ref: `refs/heads/${branch}`, object: { sha } }));
|
|
35
|
+
}
|
|
36
|
+
if (method === "POST" && path === `/repos/${state.repo}/git/refs`) {
|
|
37
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
38
|
+
const body = JSON.parse(String(init?.body ?? "{}")) as { ref?: string; sha?: string };
|
|
39
|
+
const ref = String(body.ref ?? "");
|
|
40
|
+
const sha = String(body.sha ?? "");
|
|
41
|
+
const branch = ref.replace(/^refs\/heads\//, "");
|
|
42
|
+
if (state.branches.has(branch)) return Promise.resolve(jsonResponse({ message: "Reference already exists" }, 422));
|
|
43
|
+
state.creates.push({ ref, sha });
|
|
44
|
+
state.branches.set(branch, sha);
|
|
45
|
+
return Promise.resolve(jsonResponse({ ref }, 201));
|
|
46
|
+
}
|
|
47
|
+
return Promise.resolve(new Response(`unexpected ${method} ${path}`, { status: 500 }));
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function withGithub<T>(state: FakeRepo, fn: () => Promise<T>): Promise<T> {
|
|
52
|
+
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
53
|
+
const prevTok = process.env["GITHUB_TOKEN"];
|
|
54
|
+
const prevFetch = globalThis.fetch;
|
|
55
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
|
|
56
|
+
process.env["GITHUB_TOKEN"] = "tok";
|
|
57
|
+
globalThis.fetch = githubFetch(state) as typeof fetch;
|
|
58
|
+
try {
|
|
59
|
+
return await fn();
|
|
60
|
+
} finally {
|
|
61
|
+
globalThis.fetch = prevFetch;
|
|
62
|
+
if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
63
|
+
else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
|
|
64
|
+
if (prevTok === undefined) delete process.env["GITHUB_TOKEN"];
|
|
65
|
+
else process.env["GITHUB_TOKEN"] = prevTok;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const fakeApp = { log: { info() {}, warn() {}, error() {} } } as any;
|
|
70
|
+
|
|
71
|
+
async function run(state: FakeRepo, repo: string, baseBranch: string) {
|
|
72
|
+
return withGithub(state, () => handler({ variables: { repo, baseBranch } } as any, fakeApp)) as Promise<{
|
|
73
|
+
baseBranchResult: string;
|
|
74
|
+
}>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
test("ensure-base-branch worker: creates a missing epic/* base off default HEAD", async () => {
|
|
78
|
+
const state: FakeRepo = {
|
|
79
|
+
repo: "o/w-create",
|
|
80
|
+
defaultBranch: "main",
|
|
81
|
+
branches: new Map([["main", "defaulthead"]]),
|
|
82
|
+
creates: [],
|
|
83
|
+
};
|
|
84
|
+
const out = await run(state, state.repo, "epic/new");
|
|
85
|
+
assertEquals(out.baseBranchResult, "created");
|
|
86
|
+
assertEquals(state.creates, [{ ref: "refs/heads/epic/new", sha: "defaulthead" }]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("ensure-base-branch worker: no-ops when the branch already exists (idempotent re-plan)", async () => {
|
|
90
|
+
const state: FakeRepo = {
|
|
91
|
+
repo: "o/w-exists",
|
|
92
|
+
defaultBranch: "main",
|
|
93
|
+
branches: new Map([
|
|
94
|
+
["main", "defaulthead"],
|
|
95
|
+
["epic/already", "existingsha"],
|
|
96
|
+
]),
|
|
97
|
+
creates: [],
|
|
98
|
+
};
|
|
99
|
+
const out = await run(state, state.repo, "epic/already");
|
|
100
|
+
assertEquals(out.baseBranchResult, "exists");
|
|
101
|
+
assertEquals(state.creates.length, 0);
|
|
102
|
+
// The existing ref must be left untouched.
|
|
103
|
+
assertEquals(state.branches.get("epic/already"), "existingsha");
|
|
104
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// pr.ensure-base-branch — the durable, retriable head arm of ADR 0003 rule 2.
|
|
2
|
+
//
|
|
3
|
+
// `admitPlan` already ran `ensureBaseBranch` synchronously at admission (fail fast, so a missing
|
|
4
|
+
// non-`epic/*` base is a clean edge 400 and a missing `epic/*` base is created before fan-out).
|
|
5
|
+
// This head service task RE-RUNS the same idempotent primitive on the durable path — so a re-plan
|
|
6
|
+
// or a crash between admission and fan-out still guarantees the base exists. Because
|
|
7
|
+
// `ensureBaseBranch` never resets an existing ref, this is a clean no-op when the branch is already
|
|
8
|
+
// there; a missing `epic/*` base is created off default HEAD, and a missing non-`epic/*` base
|
|
9
|
+
// throws `BaseBranchMustExistError` (which fails the durable task rather than fanning out onto a
|
|
10
|
+
// wrong-rooted branch).
|
|
11
|
+
import type { AppJobHandler } from "@nanobpm/urban";
|
|
12
|
+
import { type EnsureBaseBranchResult, ensureBaseBranch } from "../../app/github.ts";
|
|
13
|
+
|
|
14
|
+
interface In extends Record<string, unknown> {
|
|
15
|
+
repo: string;
|
|
16
|
+
baseBranch: string;
|
|
17
|
+
}
|
|
18
|
+
interface Out extends Record<string, unknown> {
|
|
19
|
+
baseBranchResult: EnsureBaseBranchResult;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
23
|
+
const repo = job.variables.repo;
|
|
24
|
+
const branch = job.variables.baseBranch;
|
|
25
|
+
const token = process.env.GITHUB_TOKEN ?? "";
|
|
26
|
+
const result = await ensureBaseBranch(repo, branch, token);
|
|
27
|
+
app.log.info("ensure-base-branch", { repo, branch, result });
|
|
28
|
+
return { baseBranchResult: result };
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export default handler;
|