claude-code-swarm 0.3.25 → 0.4.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CLAUDE.md +95 -0
- package/LICENSE +21 -0
- package/hooks/hooks.json +9 -0
- package/package.json +15 -2
- package/renovate.json5 +6 -0
- package/scripts/map-hook.mjs +88 -7
- package/scripts/map-sidecar.mjs +210 -1
- package/src/__tests__/cascade-client.test.mjs +217 -0
- package/src/__tests__/cascade-diff-server.test.mjs +375 -0
- package/src/__tests__/cascade-watcher.test.mjs +475 -0
- package/src/__tests__/config.test.mjs +23 -0
- package/src/__tests__/loadout-schema-bridge.test.mjs +1 -2
- package/src/__tests__/sidecar-nudge.test.mjs +137 -0
- package/src/bootstrap.mjs +20 -9
- package/src/cascade-client.mjs +334 -0
- package/src/cascade-diff-server.mjs +326 -0
- package/src/cascade-events.mjs +285 -0
- package/src/cascade-watcher.mjs +694 -0
- package/src/config.mjs +7 -0
- package/src/map-connection.mjs +18 -1
- package/src/map-events.mjs +8 -1
- package/src/paths.mjs +12 -0
- package/src/sidecar-server.mjs +62 -0
- package/src/skilltree-client.mjs +1 -1
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cascade-diff-server.test.mjs — cascade diff server (Phase 3)
|
|
3
|
+
*
|
|
4
|
+
* Exercises setupCascadeDiffServer against a real temp git repo with a mocked
|
|
5
|
+
* MAP connection that captures sendNotification calls. Covers single-commit
|
|
6
|
+
* diffs, range diffs, files_only, large-diff chunking (seq/final/sha256), and
|
|
7
|
+
* the typed error reply for a bad request.
|
|
8
|
+
*
|
|
9
|
+
* Also includes the Phase 3 stacking verification: a branch forked from a
|
|
10
|
+
* tracked branch emits `x-cascade/stream.opened` with `parent_stream` set, and
|
|
11
|
+
* the tracker DB records the parent edge.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
15
|
+
import { execSync } from "child_process";
|
|
16
|
+
import { createHash } from "crypto";
|
|
17
|
+
import fs from "fs";
|
|
18
|
+
import path from "path";
|
|
19
|
+
import { setupCascadeDiffServer } from "../cascade-diff-server.mjs";
|
|
20
|
+
import { openCascadeTracker, ensureStream, findStreamByBranch, closeCascadeTracker } from "../cascade-client.mjs";
|
|
21
|
+
import { startCascadeWatcher } from "../cascade-watcher.mjs";
|
|
22
|
+
import { makeTmpDir, cleanupTmpDir } from "./helpers.mjs";
|
|
23
|
+
|
|
24
|
+
/** Run a git command in `dir`, returning trimmed stdout. */
|
|
25
|
+
function g(dir, args) {
|
|
26
|
+
return execSync(`git ${args}`, {
|
|
27
|
+
cwd: dir, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"],
|
|
28
|
+
}).trim();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Create a real git repo with an initial commit on `main`. */
|
|
32
|
+
function makeGitRepo(dir) {
|
|
33
|
+
g(dir, "init -b main");
|
|
34
|
+
g(dir, 'config user.email "test@test.com"');
|
|
35
|
+
g(dir, 'config user.name "Test"');
|
|
36
|
+
g(dir, "config commit.gpgsign false");
|
|
37
|
+
fs.writeFileSync(path.join(dir, "README.md"), "# test\n");
|
|
38
|
+
g(dir, "add .");
|
|
39
|
+
g(dir, 'commit -m "initial"');
|
|
40
|
+
return g(dir, "rev-parse HEAD");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Write a file and commit it; returns the new HEAD SHA. */
|
|
44
|
+
function commitFile(dir, relPath, content, message) {
|
|
45
|
+
const full = path.join(dir, relPath);
|
|
46
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
47
|
+
fs.writeFileSync(full, content);
|
|
48
|
+
g(dir, "add -A");
|
|
49
|
+
g(dir, `commit -m "${message}"`);
|
|
50
|
+
return g(dir, "rev-parse HEAD");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Mock MAP connection — captures every sendNotification(method, params) call
|
|
55
|
+
* and lets the test fire a registered onNotification handler.
|
|
56
|
+
*/
|
|
57
|
+
function makeMockConnection() {
|
|
58
|
+
const sent = [];
|
|
59
|
+
const handlers = new Map();
|
|
60
|
+
return {
|
|
61
|
+
sent,
|
|
62
|
+
handlers,
|
|
63
|
+
onNotification(method, handler) {
|
|
64
|
+
handlers.set(method, handler);
|
|
65
|
+
},
|
|
66
|
+
offNotification(method) {
|
|
67
|
+
handlers.delete(method);
|
|
68
|
+
},
|
|
69
|
+
sendNotification(method, params) {
|
|
70
|
+
sent.push({ method, params });
|
|
71
|
+
return Promise.resolve();
|
|
72
|
+
},
|
|
73
|
+
/** Sent notifications for a given cascade method. */
|
|
74
|
+
sentFor(method) {
|
|
75
|
+
return sent.filter((s) => s.method === method);
|
|
76
|
+
},
|
|
77
|
+
/** Fire the registered handler for `method` and await it. */
|
|
78
|
+
async fire(method, params) {
|
|
79
|
+
const handler = handlers.get(method);
|
|
80
|
+
if (!handler) throw new Error(`no handler for ${method}`);
|
|
81
|
+
await handler(params);
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe("cascade-diff-server", () => {
|
|
87
|
+
let repoDir;
|
|
88
|
+
let dbDir;
|
|
89
|
+
let dbPath;
|
|
90
|
+
let tracker;
|
|
91
|
+
let dispose;
|
|
92
|
+
|
|
93
|
+
beforeEach(async () => {
|
|
94
|
+
repoDir = makeTmpDir("cascade-diff-");
|
|
95
|
+
// Keep the tracker DB outside the repo so `git add -A` never stages it
|
|
96
|
+
// into a diff under test.
|
|
97
|
+
dbDir = makeTmpDir("cascade-diff-db-");
|
|
98
|
+
dbPath = path.join(dbDir, "tracker.db");
|
|
99
|
+
makeGitRepo(repoDir);
|
|
100
|
+
tracker = await openCascadeTracker({ repoPath: repoDir, dbPath });
|
|
101
|
+
ensureStream(tracker, { branch: "main", agentId: "team-sidecar" });
|
|
102
|
+
dispose = null;
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
afterEach(() => {
|
|
106
|
+
if (dispose) {
|
|
107
|
+
try { dispose(); } catch { /* ignore */ }
|
|
108
|
+
dispose = null;
|
|
109
|
+
}
|
|
110
|
+
if (tracker) {
|
|
111
|
+
closeCascadeTracker(tracker);
|
|
112
|
+
tracker = null;
|
|
113
|
+
}
|
|
114
|
+
cleanupTmpDir(repoDir);
|
|
115
|
+
cleanupTmpDir(dbDir);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("serves a single-commit diff inline", async () => {
|
|
119
|
+
const conn = makeMockConnection();
|
|
120
|
+
dispose = setupCascadeDiffServer(conn, { repoPath: repoDir, tracker });
|
|
121
|
+
|
|
122
|
+
const sha = commitFile(repoDir, "feature.txt", "hello world\n", "add feature");
|
|
123
|
+
const streamId = findStreamByBranch(tracker, "main");
|
|
124
|
+
|
|
125
|
+
await conn.fire("cascade/diff.request", {
|
|
126
|
+
request_id: "req-1",
|
|
127
|
+
stream_id: streamId,
|
|
128
|
+
head: sha,
|
|
129
|
+
format: "unified",
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const responses = conn.sentFor("cascade/diff.response");
|
|
133
|
+
expect(responses.length).toBe(1);
|
|
134
|
+
const p = responses[0].params;
|
|
135
|
+
expect(p.request_id).toBe("req-1");
|
|
136
|
+
expect(p.streaming).toBe(false);
|
|
137
|
+
expect(p.diff).toContain("feature.txt");
|
|
138
|
+
expect(p.diff).toContain("+hello world");
|
|
139
|
+
expect(p.files_touched).toContain("feature.txt");
|
|
140
|
+
expect(p.truncated).toBe(false);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("serves a range diff for base..head", async () => {
|
|
144
|
+
const conn = makeMockConnection();
|
|
145
|
+
dispose = setupCascadeDiffServer(conn, { repoPath: repoDir, tracker });
|
|
146
|
+
|
|
147
|
+
const base = g(repoDir, "rev-parse HEAD");
|
|
148
|
+
commitFile(repoDir, "a.txt", "alpha\n", "add a");
|
|
149
|
+
const head = commitFile(repoDir, "b.txt", "beta\n", "add b");
|
|
150
|
+
const streamId = findStreamByBranch(tracker, "main");
|
|
151
|
+
|
|
152
|
+
await conn.fire("cascade/diff.request", {
|
|
153
|
+
request_id: "req-range",
|
|
154
|
+
stream_id: streamId,
|
|
155
|
+
head,
|
|
156
|
+
base,
|
|
157
|
+
format: "unified",
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const responses = conn.sentFor("cascade/diff.response");
|
|
161
|
+
expect(responses.length).toBe(1);
|
|
162
|
+
const p = responses[0].params;
|
|
163
|
+
expect(p.streaming).toBe(false);
|
|
164
|
+
// Both commits in the range are present.
|
|
165
|
+
expect(p.diff).toContain("a.txt");
|
|
166
|
+
expect(p.diff).toContain("b.txt");
|
|
167
|
+
expect(p.files_touched.sort()).toEqual(["a.txt", "b.txt"]);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("returns only file names when files_only is set", async () => {
|
|
171
|
+
const conn = makeMockConnection();
|
|
172
|
+
dispose = setupCascadeDiffServer(conn, { repoPath: repoDir, tracker });
|
|
173
|
+
|
|
174
|
+
const sha = commitFile(repoDir, "src/x.txt", "x\n", "add x");
|
|
175
|
+
const streamId = findStreamByBranch(tracker, "main");
|
|
176
|
+
|
|
177
|
+
await conn.fire("cascade/diff.request", {
|
|
178
|
+
request_id: "req-files",
|
|
179
|
+
stream_id: streamId,
|
|
180
|
+
head: sha,
|
|
181
|
+
files_only: true,
|
|
182
|
+
format: "unified",
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const responses = conn.sentFor("cascade/diff.response");
|
|
186
|
+
expect(responses.length).toBe(1);
|
|
187
|
+
const p = responses[0].params;
|
|
188
|
+
expect(p.streaming).toBe(false);
|
|
189
|
+
// files_only: no diff body, just the file list.
|
|
190
|
+
expect(p.diff).toBe("");
|
|
191
|
+
expect(p.files_touched).toEqual(["src/x.txt"]);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("streams a large diff in seq-ordered chunks with a final sha256", async () => {
|
|
195
|
+
const conn = makeMockConnection();
|
|
196
|
+
dispose = setupCascadeDiffServer(conn, { repoPath: repoDir, tracker });
|
|
197
|
+
|
|
198
|
+
// A >512 KB file forces the streaming path (inline threshold is 512 KB).
|
|
199
|
+
const bigContent = "lorem ipsum dolor sit amet\n".repeat(40_000); // ~1.05 MB
|
|
200
|
+
const sha = commitFile(repoDir, "big.txt", bigContent, "add big file");
|
|
201
|
+
const streamId = findStreamByBranch(tracker, "main");
|
|
202
|
+
|
|
203
|
+
await conn.fire("cascade/diff.request", {
|
|
204
|
+
request_id: "req-big",
|
|
205
|
+
stream_id: streamId,
|
|
206
|
+
head: sha,
|
|
207
|
+
format: "unified",
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// Announcement.
|
|
211
|
+
const responses = conn.sentFor("cascade/diff.response");
|
|
212
|
+
expect(responses.length).toBe(1);
|
|
213
|
+
const ann = responses[0].params;
|
|
214
|
+
expect(ann.streaming).toBe(true);
|
|
215
|
+
expect(typeof ann.chunk_stream_id).toBe("string");
|
|
216
|
+
expect(ann.total_size).toBeGreaterThan(512 * 1024);
|
|
217
|
+
expect(ann.files_touched).toContain("big.txt");
|
|
218
|
+
|
|
219
|
+
// Chunks: contiguous seq from 0, exactly one final.
|
|
220
|
+
const chunks = conn.sentFor("cascade/diff.chunk");
|
|
221
|
+
expect(chunks.length).toBeGreaterThan(1);
|
|
222
|
+
chunks.forEach((c, i) => {
|
|
223
|
+
expect(c.params.chunk_stream_id).toBe(ann.chunk_stream_id);
|
|
224
|
+
expect(c.params.seq).toBe(i);
|
|
225
|
+
});
|
|
226
|
+
const finalChunks = chunks.filter((c) => c.params.final === true);
|
|
227
|
+
expect(finalChunks.length).toBe(1);
|
|
228
|
+
expect(finalChunks[0]).toBe(chunks[chunks.length - 1]);
|
|
229
|
+
|
|
230
|
+
// Reassemble in seq order and verify sha256 over the full payload.
|
|
231
|
+
const assembled = Buffer.concat(
|
|
232
|
+
chunks.map((c) => Buffer.from(c.params.data, "base64")),
|
|
233
|
+
);
|
|
234
|
+
expect(assembled.length).toBe(ann.total_size);
|
|
235
|
+
const got = createHash("sha256").update(assembled).digest("hex");
|
|
236
|
+
expect(got).toBe(finalChunks[0].params.sha256);
|
|
237
|
+
expect(assembled.toString("utf-8")).toContain("big.txt");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("replies with the typed error variant for a bad request", async () => {
|
|
241
|
+
const conn = makeMockConnection();
|
|
242
|
+
dispose = setupCascadeDiffServer(conn, { repoPath: repoDir, tracker });
|
|
243
|
+
|
|
244
|
+
// Missing head — a malformed request.
|
|
245
|
+
await conn.fire("cascade/diff.request", {
|
|
246
|
+
request_id: "req-bad",
|
|
247
|
+
stream_id: "stream-x",
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const responses = conn.sentFor("cascade/diff.response");
|
|
251
|
+
expect(responses.length).toBe(1);
|
|
252
|
+
expect(responses[0].params.request_id).toBe("req-bad");
|
|
253
|
+
expect(responses[0].params.error).toBeTruthy();
|
|
254
|
+
expect(responses[0].params.error.code).toBe("bad_request");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("replies with the error variant when git fails on a bad commit", async () => {
|
|
258
|
+
const conn = makeMockConnection();
|
|
259
|
+
dispose = setupCascadeDiffServer(conn, { repoPath: repoDir, tracker });
|
|
260
|
+
|
|
261
|
+
const streamId = findStreamByBranch(tracker, "main");
|
|
262
|
+
await conn.fire("cascade/diff.request", {
|
|
263
|
+
request_id: "req-nogit",
|
|
264
|
+
stream_id: streamId,
|
|
265
|
+
head: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
|
|
266
|
+
format: "unified",
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const responses = conn.sentFor("cascade/diff.response");
|
|
270
|
+
expect(responses.length).toBe(1);
|
|
271
|
+
expect(responses[0].params.request_id).toBe("req-nogit");
|
|
272
|
+
expect(responses[0].params.error).toBeTruthy();
|
|
273
|
+
expect(responses[0].params.error.code).toBe("internal");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("never throws — ignores a request with no request_id", async () => {
|
|
277
|
+
const conn = makeMockConnection();
|
|
278
|
+
dispose = setupCascadeDiffServer(conn, { repoPath: repoDir, tracker });
|
|
279
|
+
await expect(conn.fire("cascade/diff.request", {})).resolves.not.toThrow();
|
|
280
|
+
expect(conn.sentFor("cascade/diff.response").length).toBe(0);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("dispose() unregisters the handler and is idempotent", () => {
|
|
284
|
+
const conn = makeMockConnection();
|
|
285
|
+
const d = setupCascadeDiffServer(conn, { repoPath: repoDir, tracker });
|
|
286
|
+
expect(conn.handlers.has("cascade/diff.request")).toBe(true);
|
|
287
|
+
d();
|
|
288
|
+
expect(conn.handlers.has("cascade/diff.request")).toBe(false);
|
|
289
|
+
expect(() => d()).not.toThrow();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("returns a no-op dispose when the connection cannot register handlers", () => {
|
|
293
|
+
const d = setupCascadeDiffServer({}, { repoPath: repoDir, tracker });
|
|
294
|
+
expect(typeof d).toBe("function");
|
|
295
|
+
expect(() => d()).not.toThrow();
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
describe("cascade stacking — forked branch carries parent_stream", () => {
|
|
300
|
+
let repoDir;
|
|
301
|
+
let dbDir;
|
|
302
|
+
let dbPath;
|
|
303
|
+
let tracker;
|
|
304
|
+
let watcher;
|
|
305
|
+
|
|
306
|
+
/** Mock MAP connection capturing callExtension calls (watcher emit path). */
|
|
307
|
+
function makeEmitConnection() {
|
|
308
|
+
const calls = [];
|
|
309
|
+
return {
|
|
310
|
+
calls,
|
|
311
|
+
callExtension(method, params) {
|
|
312
|
+
calls.push({ method, params });
|
|
313
|
+
return Promise.resolve({ ok: true });
|
|
314
|
+
},
|
|
315
|
+
callsFor(suffix) {
|
|
316
|
+
return calls.filter((c) => c.method.endsWith(suffix));
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
beforeEach(async () => {
|
|
322
|
+
repoDir = makeTmpDir("cascade-stack-");
|
|
323
|
+
dbDir = makeTmpDir("cascade-stack-db-");
|
|
324
|
+
dbPath = path.join(dbDir, "tracker.db");
|
|
325
|
+
makeGitRepo(repoDir);
|
|
326
|
+
tracker = await openCascadeTracker({ repoPath: repoDir, dbPath });
|
|
327
|
+
// Track `main` so the watcher can resolve it as the fork parent.
|
|
328
|
+
ensureStream(tracker, { branch: "main", agentId: "team-sidecar" });
|
|
329
|
+
watcher = null;
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
afterEach(() => {
|
|
333
|
+
if (watcher) {
|
|
334
|
+
try { watcher.stop(); } catch { /* ignore */ }
|
|
335
|
+
watcher = null;
|
|
336
|
+
}
|
|
337
|
+
if (tracker) {
|
|
338
|
+
closeCascadeTracker(tracker);
|
|
339
|
+
tracker = null;
|
|
340
|
+
}
|
|
341
|
+
cleanupTmpDir(repoDir);
|
|
342
|
+
cleanupTmpDir(dbDir);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("emits stream.opened with parent_stream and records the parent edge in the tracker DB", async () => {
|
|
346
|
+
const conn = makeEmitConnection();
|
|
347
|
+
watcher = startCascadeWatcher({ tracker, connection: conn, repoPath: repoDir });
|
|
348
|
+
await watcher._ready;
|
|
349
|
+
|
|
350
|
+
const mainStreamId = findStreamByBranch(tracker, "main");
|
|
351
|
+
expect(mainStreamId).toBeTruthy();
|
|
352
|
+
|
|
353
|
+
// Fork a new branch off main's HEAD (a clean fork — main's HEAD is the
|
|
354
|
+
// fork point), then detect it with a tick.
|
|
355
|
+
g(repoDir, "branch feature-stack");
|
|
356
|
+
await watcher._tickNow();
|
|
357
|
+
|
|
358
|
+
// The stream.opened event for the forked branch carries parent_stream.
|
|
359
|
+
const opened = conn.callsFor("stream.opened");
|
|
360
|
+
const forkEvent = opened.find((c) => c.params.branch_name === "feature-stack");
|
|
361
|
+
expect(forkEvent).toBeTruthy();
|
|
362
|
+
expect(forkEvent.params.parent_stream).toBe(mainStreamId);
|
|
363
|
+
|
|
364
|
+
// The tracker DB recorded the parent edge — listStreams exposes it so the
|
|
365
|
+
// hub's PR-stack walker can traverse parent → child.
|
|
366
|
+
const forkStreamId = findStreamByBranch(tracker, "feature-stack");
|
|
367
|
+
expect(forkStreamId).toBeTruthy();
|
|
368
|
+
const streams = tracker.listStreams();
|
|
369
|
+
const forkRow = streams.find((s) => s.id === forkStreamId);
|
|
370
|
+
expect(forkRow).toBeTruthy();
|
|
371
|
+
// git-cascade records the parent edge on the stream row.
|
|
372
|
+
const recordedParent = forkRow.parent_stream ?? forkRow.parentStream ?? forkRow.parent_stream_id;
|
|
373
|
+
expect(recordedParent).toBe(mainStreamId);
|
|
374
|
+
});
|
|
375
|
+
});
|