@nanobpm/nano-workforce 0.55.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 +14 -0
- package/README.md +9 -1
- package/SPEC.md +6 -2
- package/app/agentGuide.ts +1 -1
- package/app/agentic/cockpit/supply-render.test.ts +36 -0
- package/app/agentic/cockpit/supply-render.ts +22 -1
- package/app/agentic/cockpit/supply-view.test.ts +40 -0
- package/app/agentic/cockpit/supply-view.ts +81 -3
- package/app/agentic/correlation.test.ts +132 -0
- package/app/agentic/correlation.ts +193 -0
- package/app/agentic/families/correlation.family.test.ts +47 -0
- package/app/agentic/families/correlation.family.ts +39 -0
- 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/docs/agentic-cockpit.md +135 -0
- package/nano.app.json +4 -0
- package/openapi.yaml +89 -12
- package/operations/getAgenticSupply.test.ts +40 -0
- package/operations/getAgenticSupply.ts +32 -9
- 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/cockpit/cockpit.css +17 -0
- package/pages/cockpit/mount.js +35 -4
- package/pages/epic.page.json +4 -1
- package/resources/agent-guide.md +38 -2
- package/resources/processes/plan-fanout.bpmn +168 -149
- package/test/agentic-e2e.test.ts +258 -0
- 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,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",
|
|
@@ -131,6 +131,23 @@
|
|
|
131
131
|
|
|
132
132
|
.cockpit-worker:hover { color: #58a6ff; }
|
|
133
133
|
|
|
134
|
+
.cockpit-supply-process { color: var(--cockpit-muted); }
|
|
135
|
+
|
|
136
|
+
.cockpit-correlation {
|
|
137
|
+
background: none;
|
|
138
|
+
border: none;
|
|
139
|
+
color: var(--cockpit-text);
|
|
140
|
+
cursor: pointer;
|
|
141
|
+
display: block;
|
|
142
|
+
font: inherit;
|
|
143
|
+
padding: 0;
|
|
144
|
+
text-align: left;
|
|
145
|
+
text-decoration: underline;
|
|
146
|
+
text-underline-offset: 2px;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.cockpit-correlation:hover { color: #58a6ff; }
|
|
150
|
+
|
|
134
151
|
.cockpit-supply-liveness { color: var(--cockpit-muted); }
|
|
135
152
|
|
|
136
153
|
.cockpit-supply-empty {
|
package/pages/cockpit/mount.js
CHANGED
|
@@ -33,8 +33,21 @@ function liveness(worker, staleAfterMs) {
|
|
|
33
33
|
return worker.staleMs >= staleAfterMs ? "stale" : "live";
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
function
|
|
36
|
+
function correlationLabel(c) {
|
|
37
|
+
const parts = [];
|
|
38
|
+
if (c.bpmnProcessId != null) parts.push(c.bpmnProcessId);
|
|
39
|
+
if (c.elementId != null) parts.push(c.elementId);
|
|
40
|
+
if (c.processInstanceKey != null) parts.push(`inst ${c.processInstanceKey}`);
|
|
41
|
+
if (c.planKey != null) parts.push(c.planKey);
|
|
42
|
+
return parts.length > 0 ? parts.join(" \u00b7 ") : `job ${c.jobKey}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function workerView(worker, staleAfterMs, byJobKey) {
|
|
37
46
|
const jobKeys = [...(worker.jobKeys ?? [])].sort((a, b) => a.localeCompare(b));
|
|
47
|
+
const correlations = jobKeys
|
|
48
|
+
.map((jobKey) => byJobKey.get(jobKey))
|
|
49
|
+
.filter((c) => c != null)
|
|
50
|
+
.map((c) => ({ jobKey: c.jobKey, stream: c.stream, label: correlationLabel(c) }));
|
|
38
51
|
return {
|
|
39
52
|
instance: worker.instance,
|
|
40
53
|
identity: worker.identity,
|
|
@@ -43,6 +56,7 @@ function workerView(worker, staleAfterMs) {
|
|
|
43
56
|
host: worker.host ?? "\u2014",
|
|
44
57
|
jobKeys,
|
|
45
58
|
jobs: jobKeys.length,
|
|
59
|
+
correlations,
|
|
46
60
|
liveness: liveness(worker, staleAfterMs),
|
|
47
61
|
staleMs: worker.staleMs,
|
|
48
62
|
};
|
|
@@ -50,9 +64,11 @@ function workerView(worker, staleAfterMs) {
|
|
|
50
64
|
|
|
51
65
|
function supplyView(report, staleAfterMs) {
|
|
52
66
|
const byInstance = (a, b) => a.instance.localeCompare(b.instance);
|
|
67
|
+
const byJobKey = new Map();
|
|
68
|
+
for (const c of report.correlations ?? []) byJobKey.set(c.jobKey, c);
|
|
53
69
|
const leaves = (report.leaves ?? [])
|
|
54
70
|
.map((leaf) => {
|
|
55
|
-
const workers = leaf.workers.map((w) => workerView(w, staleAfterMs)).sort(byInstance);
|
|
71
|
+
const workers = leaf.workers.map((w) => workerView(w, staleAfterMs, byJobKey)).sort(byInstance);
|
|
56
72
|
return {
|
|
57
73
|
token: leaf.token,
|
|
58
74
|
workers,
|
|
@@ -61,7 +77,7 @@ function supplyView(report, staleAfterMs) {
|
|
|
61
77
|
};
|
|
62
78
|
})
|
|
63
79
|
.sort((a, b) => a.token.localeCompare(b.token));
|
|
64
|
-
const workers = (report.workers ?? []).map((w) => workerView(w, staleAfterMs)).sort(byInstance);
|
|
80
|
+
const workers = (report.workers ?? []).map((w) => workerView(w, staleAfterMs, byJobKey)).sort(byInstance);
|
|
65
81
|
return { leaves, workers, count: workers.length, live: workers.filter((w) => w.liveness === "live").length };
|
|
66
82
|
}
|
|
67
83
|
|
|
@@ -100,6 +116,21 @@ function workerRow(doc, worker, onDrill) {
|
|
|
100
116
|
const jobsCell = el(doc, "td", "cockpit-td cockpit-supply-jobs", worker.jobs === 0 ? "\u2014" : worker.jobKeys.join(", "));
|
|
101
117
|
jobsCell.setAttribute("data-jobs", String(worker.jobs));
|
|
102
118
|
row.appendChild(jobsCell);
|
|
119
|
+
const processCell = el(doc, "td", "cockpit-td cockpit-supply-process");
|
|
120
|
+
processCell.setAttribute("data-correlations", String(worker.correlations.length));
|
|
121
|
+
if (worker.correlations.length === 0) {
|
|
122
|
+
processCell.textContent = "\u2014";
|
|
123
|
+
} else {
|
|
124
|
+
for (const correlation of worker.correlations) {
|
|
125
|
+
const link = el(doc, "button", "cockpit-correlation", correlation.label);
|
|
126
|
+
link.setAttribute("type", "button");
|
|
127
|
+
link.setAttribute("data-job-key", correlation.jobKey);
|
|
128
|
+
link.setAttribute("data-stream", correlation.stream);
|
|
129
|
+
if (onDrill) link.addEventListener("click", () => onDrill(correlation.stream));
|
|
130
|
+
processCell.appendChild(link);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
row.appendChild(processCell);
|
|
103
134
|
const livenessCell = el(doc, "td", "cockpit-td cockpit-supply-liveness", worker.liveness);
|
|
104
135
|
livenessCell.setAttribute("data-liveness", worker.liveness);
|
|
105
136
|
row.appendChild(livenessCell);
|
|
@@ -116,7 +147,7 @@ function leafSection(doc, leaf, onDrill) {
|
|
|
116
147
|
const table = el(doc, "table", "cockpit-supply-table");
|
|
117
148
|
const thead = el(doc, "thead", "cockpit-supply-thead");
|
|
118
149
|
const head = el(doc, "tr", "cockpit-supply-head");
|
|
119
|
-
for (const label of ["worker", "family", "host", "jobs", "liveness"]) head.appendChild(el(doc, "th", "cockpit-th", label));
|
|
150
|
+
for (const label of ["worker", "family", "host", "jobs", "process / plan", "liveness"]) head.appendChild(el(doc, "th", "cockpit-th", label));
|
|
120
151
|
thead.appendChild(head);
|
|
121
152
|
table.appendChild(thead);
|
|
122
153
|
const tbody = el(doc, "tbody", "cockpit-supply-tbody");
|
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": "!" } },
|
package/resources/agent-guide.md
CHANGED
|
@@ -97,13 +97,49 @@ PR is then enrolled into its own convergence loop (§1).
|
|
|
97
97
|
```bash
|
|
98
98
|
curl -sS -X POST __BASE__/actions/start/plan-fanout \
|
|
99
99
|
-H 'content-type: application/json' \
|
|
100
|
-
-d '{ "issue": "owner/repo#123" }'
|
|
100
|
+
-d '{ "issue": "owner/repo#123", "baseBranch": "epic/agent-protocol" }'
|
|
101
101
|
```
|
|
102
102
|
|
|
103
|
-
The body is flat: `issue` (or `url`) — `owner/repo#123` or an issue URL
|
|
103
|
+
The body is flat: `issue` (or `url`) — `owner/repo#123` or an issue URL — plus a REQUIRED
|
|
104
|
+
`baseBranch` (ADR 0003), the branch the fleet branches off and opens every PR against; a
|
|
105
|
+
blank/absent base is rejected with a 400. Starting a
|
|
104
106
|
plan is idempotent on the plan key; an already-running plan short-circuits. The
|
|
105
107
|
response (202) echoes the `planKey` and engine `processKey`.
|
|
106
108
|
|
|
109
|
+
### Base-branch admission (ADR 0003)
|
|
110
|
+
|
|
111
|
+
`startPlanFanout` admits the base through one fail-fast gate before any task fans out.
|
|
112
|
+
Four ordered rules govern which base is accepted:
|
|
113
|
+
|
|
114
|
+
1. **Required + explicit.** `baseBranch` is mandatory — a blank/absent value is a `400`
|
|
115
|
+
(`MissingBaseBranchError`), and an implausible name is a `400` (`InvalidBaseBranchError`).
|
|
116
|
+
There is no silent "land on the default branch" fallback.
|
|
117
|
+
2. **Create-if-missing, `epic/*` only.** A missing `epic/*` base is **auto-created** off the
|
|
118
|
+
repository default branch's HEAD (idempotently — an existing branch is never reset). A
|
|
119
|
+
missing base that is **not** `epic/*` is a clean `400` (`BaseBranchMustExistError`): a typo
|
|
120
|
+
can't silently spawn a wrong-rooted branch, so any non-`epic/*` base must already exist.
|
|
121
|
+
3. **Confirm-default.** Naming the repository **default branch** as the base requires
|
|
122
|
+
`confirmDefaultBase: true`, else `400` (`DefaultBaseNotConfirmedError`). This is a
|
|
123
|
+
deliberate acknowledgement that every task lands directly on the default branch with no
|
|
124
|
+
integration buffer, and any merge-to-default side effect fires per task.
|
|
125
|
+
4. **Shared-base guard.** If another **active** epic (status not `done`/`failed`/`abandoned`)
|
|
126
|
+
already targets the **same repo + same custom base**, admission is a `409` (`SharedBaseError`)
|
|
127
|
+
unless you pass `allowSharedBase: true`. The default branch is exempt — many epics target it
|
|
128
|
+
concurrently without colliding.
|
|
129
|
+
|
|
130
|
+
So the body may also carry two optional booleans — `confirmDefaultBase` and `allowSharedBase` —
|
|
131
|
+
each a "warn you can't skip" for its rule:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
curl -sS -X POST __BASE__/actions/start/plan-fanout \
|
|
135
|
+
-H 'content-type: application/json' \
|
|
136
|
+
-d '{ "issue": "owner/repo#123", "baseBranch": "main", "confirmDefaultBase": true }'
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Grandfathered: in-flight plans launched before this admission gate (with a `null` base branch)
|
|
140
|
+
keep running unchanged — the requirement is enforced at admission of **new** launches, not by a
|
|
141
|
+
database constraint.
|
|
142
|
+
|
|
107
143
|
Track a plan the same way you track PRs — its `process_key` is an engine instance you
|
|
108
144
|
can inspect in §5, and the PRs it opens show up in `/status` as ordinary convergence
|
|
109
145
|
loops.
|