@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,258 @@
|
|
|
1
|
+
// End-to-end WIRING test for the agentic visibility plane (ADR 0056) — the H6 (#149) closing slice.
|
|
2
|
+
//
|
|
3
|
+
// This is the deterministic integration test the epic asked for: it stands up a REAL AgenticHub over
|
|
4
|
+
// an in-memory transport, mounts the WHOLE family fleet through the H0 (#143) discovery SEAM
|
|
5
|
+
// (`loadAgenticFamilies` — so H1 presence, H3 relay, and H6 correlation all attach exactly as they do
|
|
6
|
+
// in production), and drives a worker + a cockpit consumer end to end:
|
|
7
|
+
//
|
|
8
|
+
// H0 the seam discovers + mounts every `*.family.ts` and tears them down in reverse;
|
|
9
|
+
// H1 a worker REGISTER creates a live presence row with its declared family + host;
|
|
10
|
+
// H6 the orchestrator links the worker's jobKey → process instance / plan;
|
|
11
|
+
// H5 GET /app/api/agentic/supply reports the worker with jobKeys populated, the drill stream
|
|
12
|
+
// repointed at `job:<jobKey>`, and the job's correlation (process instance / plan);
|
|
13
|
+
// H3 the worker relays terminal output on the jobKey-scoped stream and a cockpit TerminalSession
|
|
14
|
+
// drills in and reads it — then, across a HUB RESTART (ring lost, db shared), the same session
|
|
15
|
+
// resumes-from-offset: it re-attaches and receives only the un-applied tail, with no loss and
|
|
16
|
+
// no duplication.
|
|
17
|
+
//
|
|
18
|
+
// It is timer-free and deterministic: connections are in-memory, `flush` yields to the next
|
|
19
|
+
// event-loop turn via `setImmediate` (so all pending microtasks settle before it resolves), and
|
|
20
|
+
// every assertion is on settled state. There are no retries and no sleeps.
|
|
21
|
+
import { DatabaseSync } from "node:sqlite";
|
|
22
|
+
import { test } from "node:test";
|
|
23
|
+
import { AgenticHub } from "@nanobpm/agentic/channel";
|
|
24
|
+
import type { Authenticator, ChannelConnection, ChannelTransport } from "@nanobpm/agentic/channel";
|
|
25
|
+
import { TerminalSession } from "@nanobpm/agentic/cockpit";
|
|
26
|
+
import type { SqliteDb } from "@nanobpm/agentic/presence";
|
|
27
|
+
import { decodeFrame, encodeFrame, type Frame } from "@nanobpm/agentic/protocol";
|
|
28
|
+
import type { AppApi, DataLayer } from "@nanobpm/urban";
|
|
29
|
+
import { assert, assertEquals } from "#test-assert";
|
|
30
|
+
import { currentCorrelation } from "../app/agentic/correlation.ts";
|
|
31
|
+
import { loadAgenticFamilies } from "../app/agentic/loader.ts";
|
|
32
|
+
import { type AgenticContext, AgenticFamilyRegistry } from "../app/agentic/registry.ts";
|
|
33
|
+
import handler from "../operations/getAgenticSupply.ts";
|
|
34
|
+
import { noopLog } from "./log.ts";
|
|
35
|
+
|
|
36
|
+
// ── in-memory substrate ──────────────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
/** One shared in-memory SQLite db — the durable substrate that survives a hub restart. */
|
|
39
|
+
function memSqlite(): SqliteDb {
|
|
40
|
+
const db = new DatabaseSync(":memory:");
|
|
41
|
+
return {
|
|
42
|
+
exec: (sql) => db.exec(sql),
|
|
43
|
+
run: (sql, params = []) => {
|
|
44
|
+
const r = db.prepare(sql).run(...(params as never[]));
|
|
45
|
+
return { changes: Number(r.changes), lastInsertRowid: Number(r.lastInsertRowid) };
|
|
46
|
+
},
|
|
47
|
+
all: <T = Record<string, unknown>>(sql: string, params: unknown[] = []) => db.prepare(sql).all(...(params as never[])) as T[],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function memData(db: SqliteDb): DataLayer {
|
|
52
|
+
return { source: () => ({ db }) } as unknown as DataLayer;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** An in-memory transport whose `connect` hands the hub a fresh connection. */
|
|
56
|
+
function memTransport(): { transport: ChannelTransport; connect(conn: ChannelConnection): void } {
|
|
57
|
+
let onConnection: ((conn: ChannelConnection) => void) | undefined;
|
|
58
|
+
const transport: ChannelTransport = {
|
|
59
|
+
onConnection: (l) => {
|
|
60
|
+
onConnection = l;
|
|
61
|
+
},
|
|
62
|
+
address: { port: 0 },
|
|
63
|
+
close: async () => {},
|
|
64
|
+
};
|
|
65
|
+
return { transport, connect: (conn) => onConnection?.(conn) };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A live in-memory connection: `feed` delivers a frame TO the hub; `sent` collects frames FROM it.
|
|
70
|
+
* `close()` fires the hub's registered close listener, so teardown/presence-cleanup paths run as in
|
|
71
|
+
* production (rather than silently swallowing the disconnect).
|
|
72
|
+
*/
|
|
73
|
+
function conn(id: string, identity: string): { conn: ChannelConnection; feed(frame: Frame): void; sent: Frame[] } {
|
|
74
|
+
let onMessage: ((bytes: Uint8Array) => void) | undefined;
|
|
75
|
+
let onClose: ((code?: number, reason?: string) => void) | undefined;
|
|
76
|
+
const sent: Frame[] = [];
|
|
77
|
+
const channelConn: ChannelConnection = {
|
|
78
|
+
id,
|
|
79
|
+
handshake: { query: { identity }, token: "t", credential: "c" },
|
|
80
|
+
send: (bytes) => sent.push(decodeFrame(bytes)),
|
|
81
|
+
close: (code, reason) => onClose?.(code, reason),
|
|
82
|
+
onMessage: (l) => {
|
|
83
|
+
onMessage = l;
|
|
84
|
+
},
|
|
85
|
+
onClose: (l) => {
|
|
86
|
+
onClose = l;
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
return { conn: channelConn, feed: (frame) => onMessage?.(encodeFrame(frame)), sent };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const authenticator: Authenticator = (req) => ({ ok: true, grant: { identity: req.query?.identity ?? "anon" } });
|
|
93
|
+
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
|
94
|
+
|
|
95
|
+
// ── relay wire helpers (the S5 sub-protocol, mirrored from relay.family.test.ts) ──────────────────
|
|
96
|
+
|
|
97
|
+
const RELAY_FAMILY = "relay";
|
|
98
|
+
const produce = (stream: string, incarnation: number, chunk: string): Frame => ({
|
|
99
|
+
lane: "bulk",
|
|
100
|
+
family: RELAY_FAMILY,
|
|
101
|
+
seq: 0,
|
|
102
|
+
payload: { op: "produce", stream, incarnation, chunk },
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// ── the harness: a real hub with the whole family fleet mounted through the H0 seam ───────────────
|
|
106
|
+
|
|
107
|
+
interface MountedHub {
|
|
108
|
+
hub: AgenticHub;
|
|
109
|
+
transport: { transport: ChannelTransport; connect(c: ChannelConnection): void };
|
|
110
|
+
names: string[];
|
|
111
|
+
teardown(): Promise<void>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function mountFleet(db: SqliteDb): Promise<MountedHub> {
|
|
115
|
+
const transport = memTransport();
|
|
116
|
+
const hub = new AgenticHub({ transport: transport.transport, authenticator, sweepIntervalMs: 0 });
|
|
117
|
+
// H0: discover + mount the WHOLE fleet through the SAME registry seam production boot uses
|
|
118
|
+
// (`mountAgenticChannel`) — so idempotent mountAll, partial-mount cleanup, and reverse/isolated
|
|
119
|
+
// teardown are all exercised here exactly as in production rather than reimplemented by hand.
|
|
120
|
+
const registry = new AgenticFamilyRegistry();
|
|
121
|
+
registry.registerAll(await loadAgenticFamilies(undefined, noopLog()));
|
|
122
|
+
const ctx: AgenticContext = {
|
|
123
|
+
hub,
|
|
124
|
+
registry: hub.registry,
|
|
125
|
+
transport: transport.transport as never,
|
|
126
|
+
data: memData(db),
|
|
127
|
+
log: noopLog(),
|
|
128
|
+
};
|
|
129
|
+
await registry.mountAll(ctx);
|
|
130
|
+
return {
|
|
131
|
+
hub,
|
|
132
|
+
transport,
|
|
133
|
+
names: registry.names(),
|
|
134
|
+
teardown: async () => {
|
|
135
|
+
await registry.teardownAll(noopLog());
|
|
136
|
+
await hub.close();
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const app = { log: noopLog() } as unknown as AppApi;
|
|
142
|
+
function supplyInput() {
|
|
143
|
+
return { req: { method: "GET", path: "/app/api/agentic/supply", query: new URLSearchParams(), headers: new Headers(), text: async () => "" } as never, params: {}, query: {}, body: undefined };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
test("E2E: the whole visibility plane wires up — presence, correlation, supply report, relay drill, and resume across a hub restart", async () => {
|
|
147
|
+
const db = memSqlite();
|
|
148
|
+
const JOB = "6494";
|
|
149
|
+
const STREAM = `job:${JOB}`;
|
|
150
|
+
|
|
151
|
+
// Sanity: the fleet the seam discovers really includes presence, relay, and correlation.
|
|
152
|
+
const fleet = await mountFleet(db);
|
|
153
|
+
const names = fleet.names;
|
|
154
|
+
assert(names.includes("presence"), "H1 presence family is discovered by the H0 seam");
|
|
155
|
+
assert(names.includes("relay"), "H3 relay family is discovered by the H0 seam");
|
|
156
|
+
assert(names.includes("correlation"), "H6 correlation family is discovered by the H0 seam");
|
|
157
|
+
|
|
158
|
+
// ── H1: a worker connects and REGISTERs; a live presence row appears with its family/host. ──
|
|
159
|
+
const worker = conn("wk-conn-1", "leafA");
|
|
160
|
+
fleet.transport.connect(worker.conn);
|
|
161
|
+
await flush();
|
|
162
|
+
worker.feed({ lane: "control", family: "register", seq: 1, payload: { instance: "wk-a", capability: { family: "opus", host: "boxA" } } });
|
|
163
|
+
await flush();
|
|
164
|
+
|
|
165
|
+
// ── H6: the orchestrator links the worker's active jobKey to its process instance / plan. ──
|
|
166
|
+
const correlation = currentCorrelation();
|
|
167
|
+
assert(correlation !== undefined, "the correlation family installed the singleton");
|
|
168
|
+
correlation.link("wk-a", JOB, { processInstanceKey: "4612", bpmnProcessId: "plan-fanout", elementId: "implement-task", planKey: "nanobpm/nano-workforce#142" });
|
|
169
|
+
|
|
170
|
+
// ── H5: the supply report shows the worker with jobKeys, the repointed stream, and the correlation. ──
|
|
171
|
+
{
|
|
172
|
+
const res = (await handler(supplyInput(), app)) as {
|
|
173
|
+
status: number;
|
|
174
|
+
body: { count: number; workers: Array<Record<string, unknown>>; correlations: Array<Record<string, unknown>> };
|
|
175
|
+
};
|
|
176
|
+
assertEquals(res.status, 200);
|
|
177
|
+
assertEquals(res.body.count, 1);
|
|
178
|
+
const w = res.body.workers[0];
|
|
179
|
+
assertEquals(w.instance, "wk-a");
|
|
180
|
+
assertEquals(w.family, "opus");
|
|
181
|
+
assertEquals(w.host, "boxA");
|
|
182
|
+
assertEquals(w.jobKeys, [JOB], "H1×H6: the correlation registry feeds the presence jobKeys seam");
|
|
183
|
+
assertEquals(w.stream, STREAM, "H6: the drill stream repoints at the live job's stream");
|
|
184
|
+
assertEquals(res.body.correlations.length, 1);
|
|
185
|
+
const c = res.body.correlations[0];
|
|
186
|
+
assertEquals(c.jobKey, JOB);
|
|
187
|
+
assertEquals(c.stream, STREAM);
|
|
188
|
+
assertEquals(c.processInstanceKey, "4612");
|
|
189
|
+
assertEquals(c.bpmnProcessId, "plan-fanout");
|
|
190
|
+
assertEquals(c.planKey, "nanobpm/nano-workforce#142");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ── H3: the worker relays 3 chunks; a cockpit TerminalSession drills in and reads them all. ──
|
|
194
|
+
const written: string[] = [];
|
|
195
|
+
const sink = { write: (chunk: string) => written.push(chunk) };
|
|
196
|
+
|
|
197
|
+
// Wire a cockpit consumer connection through the hub. The TerminalSession speaks the S5 sub-protocol
|
|
198
|
+
// (RelayOutbound/RelayInbound); we wrap outbound as control frames to the hub and unwrap the frames
|
|
199
|
+
// the hub sends back to it into the session — exactly what RelayChannelClient does in the browser.
|
|
200
|
+
let cockpit = conn("cockpit-conn-1", "leafOps");
|
|
201
|
+
fleet.transport.connect(cockpit.conn);
|
|
202
|
+
await flush();
|
|
203
|
+
const session = new TerminalSession({
|
|
204
|
+
stream: STREAM,
|
|
205
|
+
sink,
|
|
206
|
+
send: (message) => cockpit.feed({ lane: "control", family: RELAY_FAMILY, seq: 0, payload: message }),
|
|
207
|
+
credit: 1024,
|
|
208
|
+
});
|
|
209
|
+
const drainToSession = async (c: { sent: Frame[] }) => {
|
|
210
|
+
await flush();
|
|
211
|
+
while (c.sent.length > 0) {
|
|
212
|
+
const frame = c.sent.shift();
|
|
213
|
+
if (frame) session.handle(frame.payload as never);
|
|
214
|
+
}
|
|
215
|
+
await flush();
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
worker.feed(produce(STREAM, 1, "c0"));
|
|
219
|
+
worker.feed(produce(STREAM, 1, "c1"));
|
|
220
|
+
worker.feed(produce(STREAM, 1, "c2"));
|
|
221
|
+
await flush();
|
|
222
|
+
session.attach();
|
|
223
|
+
await drainToSession(cockpit);
|
|
224
|
+
assertEquals(written, ["c0", "c1", "c2"], "the consumer receives every relayed chunk in order");
|
|
225
|
+
assertEquals(session.nextOffset, 3, "the session's resume point advanced past the applied tail");
|
|
226
|
+
|
|
227
|
+
// ── H3 resume-from-offset ACROSS A HUB RESTART: ring is lost, the shared db persists. ──
|
|
228
|
+
await fleet.teardown();
|
|
229
|
+
|
|
230
|
+
const fleet2 = await mountFleet(db);
|
|
231
|
+
// Re-link the correlation on the fresh process (the resumed orchestrator re-establishes it).
|
|
232
|
+
currentCorrelation()?.link("wk-a", JOB, { processInstanceKey: "4612", bpmnProcessId: "plan-fanout" });
|
|
233
|
+
|
|
234
|
+
// The worker reconnects and replays its transcript (c0..c2) plus TWO NEW chunks (c3, c4) on a bumped
|
|
235
|
+
// incarnation — a fresh ring, offsets restart at 0.
|
|
236
|
+
const worker2 = conn("wk-conn-2", "leafA");
|
|
237
|
+
fleet2.transport.connect(worker2.conn);
|
|
238
|
+
await flush();
|
|
239
|
+
worker2.feed({ lane: "control", family: "register", seq: 1, payload: { instance: "wk-a", capability: { family: "opus", host: "boxA" } } });
|
|
240
|
+
for (let i = 0; i < 5; i++) worker2.feed(produce(STREAM, 2, `c${i}`));
|
|
241
|
+
await flush();
|
|
242
|
+
|
|
243
|
+
// The SAME cockpit session reconnects (new hub connection) and re-attaches. Because it resumes from
|
|
244
|
+
// its own nextOffset (3), it receives ONLY the un-applied tail c3,c4 — no loss, no duplicate replay.
|
|
245
|
+
cockpit = conn("cockpit-conn-2", "leafOps");
|
|
246
|
+
fleet2.transport.connect(cockpit.conn);
|
|
247
|
+
await flush();
|
|
248
|
+
session.attach();
|
|
249
|
+
await drainToSession(cockpit);
|
|
250
|
+
|
|
251
|
+
assertEquals(written, ["c0", "c1", "c2", "c3", "c4"], "resume-from-offset delivers only the new tail — no loss, no duplication");
|
|
252
|
+
|
|
253
|
+
// Belt-and-braces: every delivered data frame after resume was at offset ≥ 3 (nothing below the
|
|
254
|
+
// resume point was re-applied).
|
|
255
|
+
assert(session.nextOffset >= 5, "the resume point advanced through the replayed tail");
|
|
256
|
+
|
|
257
|
+
await fleet2.teardown();
|
|
258
|
+
});
|
|
@@ -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;
|