claude-code-swarm 0.3.26 → 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.
@@ -0,0 +1,217 @@
1
+ /**
2
+ * cascade-client.test.mjs — git-cascade tracker ownership (Phase 1)
3
+ *
4
+ * Exercises openCascadeTracker / ensureStream / closeCascadeTracker against a
5
+ * real temp git repo with a couple of branches. git-cascade is resolved at
6
+ * runtime via swarmkit-resolver, so these tests require git-cascade to be
7
+ * importable from the workspace.
8
+ */
9
+
10
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
11
+ import { execSync } from "child_process";
12
+ import fs from "fs";
13
+ import path from "path";
14
+ import {
15
+ openCascadeTracker,
16
+ ensureStream,
17
+ closeCascadeTracker,
18
+ findStreamByBranch,
19
+ recordObservedCommit,
20
+ recordObservedMerge,
21
+ } from "../cascade-client.mjs";
22
+ import { makeTmpDir, cleanupTmpDir } from "./helpers.mjs";
23
+
24
+ /**
25
+ * Create a real git repo with an initial commit and a couple of branches.
26
+ * Leaves `main` (or the default branch) checked out.
27
+ */
28
+ function makeGitRepo(dir) {
29
+ execSync("git init", { cwd: dir, stdio: "pipe" });
30
+ execSync('git config user.email "test@test.com"', { cwd: dir, stdio: "pipe" });
31
+ execSync('git config user.name "Test"', { cwd: dir, stdio: "pipe" });
32
+ execSync("git config commit.gpgsign false", { cwd: dir, stdio: "pipe" });
33
+ fs.writeFileSync(path.join(dir, "README.md"), "# test\n");
34
+ execSync("git add .", { cwd: dir, stdio: "pipe" });
35
+ execSync('git commit -m "initial"', { cwd: dir, stdio: "pipe" });
36
+ // Create a couple of extra branches.
37
+ execSync("git branch feature-a", { cwd: dir, stdio: "pipe" });
38
+ execSync("git branch feature-b", { cwd: dir, stdio: "pipe" });
39
+ }
40
+
41
+ /** Current branch name of a repo. */
42
+ function currentBranch(dir) {
43
+ return execSync("git rev-parse --abbrev-ref HEAD", {
44
+ cwd: dir, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"],
45
+ }).trim();
46
+ }
47
+
48
+ /** HEAD commit SHA of a repo (optionally a specific rev). */
49
+ function headSha(dir, rev = "HEAD") {
50
+ return execSync(`git rev-parse ${rev}`, {
51
+ cwd: dir, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"],
52
+ }).trim();
53
+ }
54
+
55
+ describe("cascade-client", () => {
56
+ let repoDir;
57
+ let dbPath;
58
+ let tracker;
59
+
60
+ beforeEach(() => {
61
+ repoDir = makeTmpDir("cascade-test-");
62
+ dbPath = path.join(repoDir, "tracker.db");
63
+ makeGitRepo(repoDir);
64
+ tracker = null;
65
+ });
66
+
67
+ afterEach(() => {
68
+ if (tracker) {
69
+ closeCascadeTracker(tracker);
70
+ tracker = null;
71
+ }
72
+ cleanupTmpDir(repoDir);
73
+ });
74
+
75
+ it("openCascadeTracker returns a tracker for a git repo", async () => {
76
+ tracker = await openCascadeTracker({ repoPath: repoDir, dbPath });
77
+ expect(tracker).not.toBeNull();
78
+ expect(typeof tracker.trackExistingBranch).toBe("function");
79
+ });
80
+
81
+ it("openCascadeTracker returns null for a non-git directory", async () => {
82
+ const nonGit = makeTmpDir("cascade-nongit-");
83
+ try {
84
+ const t = await openCascadeTracker({
85
+ repoPath: nonGit,
86
+ dbPath: path.join(nonGit, "tracker.db"),
87
+ });
88
+ expect(t).toBeNull();
89
+ } finally {
90
+ cleanupTmpDir(nonGit);
91
+ }
92
+ });
93
+
94
+ it("ensureStream creates a stream for the working branch", async () => {
95
+ tracker = await openCascadeTracker({ repoPath: repoDir, dbPath });
96
+ expect(tracker).not.toBeNull();
97
+
98
+ const branch = currentBranch(repoDir);
99
+ const result = ensureStream(tracker, { branch, agentId: "team-sidecar" });
100
+
101
+ expect(result).not.toBeNull();
102
+ expect(result.created).toBe(true);
103
+ expect(typeof result.streamId).toBe("string");
104
+ expect(result.streamId.length).toBeGreaterThan(0);
105
+ });
106
+
107
+ it("ensureStream is idempotent on a second call for the same branch", async () => {
108
+ tracker = await openCascadeTracker({ repoPath: repoDir, dbPath });
109
+ expect(tracker).not.toBeNull();
110
+
111
+ const branch = currentBranch(repoDir);
112
+ const first = ensureStream(tracker, { branch, agentId: "team-sidecar" });
113
+ const second = ensureStream(tracker, { branch, agentId: "team-sidecar" });
114
+
115
+ expect(first).not.toBeNull();
116
+ expect(second).not.toBeNull();
117
+ expect(first.created).toBe(true);
118
+ expect(second.created).toBe(false);
119
+ expect(second.streamId).toBe(first.streamId);
120
+ });
121
+
122
+ it("ensureStream tracks distinct branches as separate streams", async () => {
123
+ tracker = await openCascadeTracker({ repoPath: repoDir, dbPath });
124
+ expect(tracker).not.toBeNull();
125
+
126
+ const a = ensureStream(tracker, { branch: "feature-a", agentId: "team-sidecar" });
127
+ const b = ensureStream(tracker, { branch: "feature-b", agentId: "team-sidecar" });
128
+
129
+ expect(a).not.toBeNull();
130
+ expect(b).not.toBeNull();
131
+ expect(a.created).toBe(true);
132
+ expect(b.created).toBe(true);
133
+ expect(a.streamId).not.toBe(b.streamId);
134
+ });
135
+
136
+ it("ensureStream returns null when tracker is missing", () => {
137
+ expect(ensureStream(null, { branch: "main", agentId: "x" })).toBeNull();
138
+ });
139
+
140
+ it("closeCascadeTracker is a safe no-op on null", () => {
141
+ expect(() => closeCascadeTracker(null)).not.toThrow();
142
+ });
143
+
144
+ it("closeCascadeTracker closes an open tracker", async () => {
145
+ const t = await openCascadeTracker({ repoPath: repoDir, dbPath });
146
+ expect(t).not.toBeNull();
147
+ expect(() => closeCascadeTracker(t)).not.toThrow();
148
+ });
149
+
150
+ it("findStreamByBranch resolves a tracked branch and returns null otherwise", async () => {
151
+ tracker = await openCascadeTracker({ repoPath: repoDir, dbPath });
152
+ expect(tracker).not.toBeNull();
153
+
154
+ const branch = currentBranch(repoDir);
155
+ const created = ensureStream(tracker, { branch, agentId: "team-sidecar" });
156
+ expect(created).not.toBeNull();
157
+
158
+ expect(findStreamByBranch(tracker, branch)).toBe(created.streamId);
159
+ expect(findStreamByBranch(tracker, "no-such-branch")).toBeNull();
160
+ expect(findStreamByBranch(null, branch)).toBeNull();
161
+ });
162
+
163
+ it("recordObservedCommit persists a change and returns its id", async () => {
164
+ tracker = await openCascadeTracker({ repoPath: repoDir, dbPath });
165
+ expect(tracker).not.toBeNull();
166
+
167
+ const branch = currentBranch(repoDir);
168
+ const stream = ensureStream(tracker, { branch, agentId: "team-sidecar" });
169
+ expect(stream).not.toBeNull();
170
+
171
+ const commit = headSha(repoDir);
172
+ const changeId = await recordObservedCommit(tracker, {
173
+ streamId: stream.streamId,
174
+ commit,
175
+ description: "initial",
176
+ });
177
+
178
+ expect(typeof changeId).toBe("string");
179
+ expect(changeId.length).toBeGreaterThan(0);
180
+ });
181
+
182
+ it("recordObservedCommit returns null when tracker is missing", async () => {
183
+ const result = await recordObservedCommit(null, {
184
+ streamId: "s1", commit: "abc", description: "x",
185
+ });
186
+ expect(result).toBeNull();
187
+ });
188
+
189
+ it("recordObservedMerge persists a merge edge and returns its id", async () => {
190
+ tracker = await openCascadeTracker({ repoPath: repoDir, dbPath });
191
+ expect(tracker).not.toBeNull();
192
+
193
+ const target = ensureStream(tracker, { branch: currentBranch(repoDir), agentId: "team-sidecar" });
194
+ const source = ensureStream(tracker, { branch: "feature-a", agentId: "team-sidecar" });
195
+ expect(target).not.toBeNull();
196
+ expect(source).not.toBeNull();
197
+
198
+ const mergeCommit = headSha(repoDir);
199
+ const mergeId = await recordObservedMerge(tracker, {
200
+ sourceStreamId: source.streamId,
201
+ sourceCommit: headSha(repoDir, "feature-a"),
202
+ targetStreamId: target.streamId,
203
+ mergeCommit,
204
+ metadata: { trigger: "test" },
205
+ });
206
+
207
+ expect(typeof mergeId).toBe("string");
208
+ expect(mergeId.length).toBeGreaterThan(0);
209
+ });
210
+
211
+ it("recordObservedMerge returns null when required fields are missing", async () => {
212
+ tracker = await openCascadeTracker({ repoPath: repoDir, dbPath });
213
+ expect(tracker).not.toBeNull();
214
+ const result = await recordObservedMerge(tracker, { sourceStreamId: "s1" });
215
+ expect(result).toBeNull();
216
+ });
217
+ });
@@ -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
+ });