@cueloop/daemon 0.1.0-alpha.10 → 0.1.0-alpha.101

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.
Files changed (83) hide show
  1. package/README.md +7 -0
  2. package/SDK.md +102 -0
  3. package/package.json +44 -22
  4. package/src/agent-tools.ts +98 -0
  5. package/src/agent-turn.test.ts +24 -0
  6. package/src/agent-turn.ts +34 -0
  7. package/src/api.test.ts +1081 -65
  8. package/src/api.ts +1425 -89
  9. package/src/capabilities.test.ts +57 -0
  10. package/src/capabilities.ts +95 -0
  11. package/src/client-errors.ts +24 -0
  12. package/src/client-reliability.test.ts +189 -0
  13. package/src/client.test.ts +37 -0
  14. package/src/client.ts +820 -94
  15. package/src/curate.ts +109 -0
  16. package/src/daemon-sdk-effect.ts +113 -0
  17. package/src/daemon-sdk.test.ts +306 -0
  18. package/src/daemon-sdk.ts +397 -0
  19. package/src/daemon-version.test.ts +142 -0
  20. package/src/diff-hot-reload.test.ts +419 -0
  21. package/src/diff-watcher.test.ts +117 -0
  22. package/src/diff-watcher.ts +326 -0
  23. package/src/examples/daemon-sdk.ts +55 -0
  24. package/src/gh.test.ts +59 -0
  25. package/src/gh.ts +74 -0
  26. package/src/ghostty-thread-surface-store.test.ts +49 -0
  27. package/src/ghostty-thread-surface-store.ts +101 -0
  28. package/src/ghostty-thread-surface.test.ts +398 -0
  29. package/src/ghostty-thread-surface.ts +267 -0
  30. package/src/harness-delivery.test.ts +269 -0
  31. package/src/harness-diagnostics.test.ts +26 -0
  32. package/src/harness-diagnostics.ts +30 -0
  33. package/src/harness-state-store.test.ts +93 -0
  34. package/src/harness-state-store.ts +213 -0
  35. package/src/herdr-split.test.ts +106 -0
  36. package/src/herdr-split.ts +85 -0
  37. package/src/herdr-thread-surface-store.test.ts +81 -0
  38. package/src/herdr-thread-surface-store.ts +66 -0
  39. package/src/herdr-thread-surface.test.ts +465 -0
  40. package/src/herdr-thread-surface.ts +378 -0
  41. package/src/index.ts +8 -9
  42. package/src/interruptible-wait.test.ts +127 -0
  43. package/src/interruptible-wait.ts +66 -0
  44. package/src/jj-working-tree.ts +131 -0
  45. package/src/main.ts +4 -1
  46. package/src/operation-receipts.test.ts +111 -0
  47. package/src/operation-receipts.ts +98 -0
  48. package/src/paths.ts +49 -0
  49. package/src/pr-poller.test.ts +102 -0
  50. package/src/pr-poller.ts +73 -0
  51. package/src/project-files.test.ts +134 -0
  52. package/src/project-files.ts +68 -0
  53. package/src/protocol.ts +78 -7
  54. package/src/retention.test.ts +191 -0
  55. package/src/retention.ts +123 -0
  56. package/src/sdk-identities.ts +35 -0
  57. package/src/server.test.ts +502 -42
  58. package/src/server.ts +631 -88
  59. package/src/share-blob.test.ts +119 -0
  60. package/src/share-blob.ts +57 -0
  61. package/src/shared-agent-protocol.ts +30 -0
  62. package/src/single-instance.test.ts +162 -13
  63. package/src/store.conformance.test.ts +41 -0
  64. package/src/store.jsonl.test.ts +173 -0
  65. package/src/store.ts +267 -34
  66. package/src/testing/store-conformance.ts +192 -0
  67. package/src/thread-agent-validation.ts +121 -0
  68. package/src/thread-agent.test.ts +719 -0
  69. package/src/thread-agent.ts +835 -0
  70. package/src/thread-review.test.ts +483 -0
  71. package/src/thread-review.ts +382 -0
  72. package/src/thread-subscription.test.ts +174 -0
  73. package/src/thread-subscription.ts +129 -0
  74. package/src/thread-surface-config.test.ts +63 -0
  75. package/src/thread-surface-config.ts +61 -0
  76. package/src/validate.test.ts +279 -37
  77. package/src/validate.ts +422 -33
  78. package/src/vcs-source.test.ts +180 -0
  79. package/src/vcs-source.ts +229 -0
  80. package/src/version.ts +6 -0
  81. package/src/working-tree.ts +150 -0
  82. package/src/review.test.ts +0 -167
  83. package/src/review.ts +0 -174
@@ -0,0 +1,419 @@
1
+ /**
2
+ * Diff hot-reload: session.refreshDiff re-captures a diff session's working
3
+ * tree, and the fs watcher drives it on a real change. Exercised against a
4
+ * real temp git repo so the capture path is the production one.
5
+ */
6
+
7
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
8
+ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { DaemonCore } from "./api";
12
+ import { workingTreeDiff } from "./working-tree";
13
+ import { resolveWorkspace } from "./thread-review";
14
+ import type { Artifact } from "@cueloop/schema";
15
+
16
+ function git(args: string[], cwd: string): void {
17
+ const result = Bun.spawnSync(["git", ...args], { cwd, stdout: "ignore", stderr: "ignore" });
18
+
19
+ if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")} failed in ${cwd}`);
20
+ }
21
+
22
+ let home: string;
23
+ let repo: string;
24
+ let core: DaemonCore;
25
+
26
+ beforeEach(() => {
27
+ home = mkdtempSync(join(tmpdir(), "cueloop-hotreload-home-"));
28
+ repo = mkdtempSync(join(tmpdir(), "cueloop-hotreload-repo-"));
29
+ git(["init", "-q", "-b", "main"], repo);
30
+ git(["config", "user.email", "t@t"], repo);
31
+ git(["config", "user.name", "t"], repo);
32
+ writeFileSync(join(repo, "a.ts"), "export const a = 1;\n");
33
+ git(["add", "."], repo);
34
+ git(["commit", "-qm", "init"], repo);
35
+ core = new DaemonCore(home);
36
+ });
37
+
38
+ afterEach(() => {
39
+ core.dispose();
40
+ rmSync(home, { recursive: true, force: true });
41
+ rmSync(repo, { recursive: true, force: true });
42
+ });
43
+
44
+ /** Open a diff session over the temp repo's current working tree. */
45
+ async function openDiffSession() {
46
+ const workspace = await resolveWorkspace(repo);
47
+ const diff = await workingTreeDiff(repo);
48
+ const artifact: Artifact = { type: "diff", content: diff.patch, files: diff.files, meta: {} };
49
+
50
+ return core.sessionCreate({ workspace, artifact });
51
+ }
52
+
53
+ describe("session.refreshDiff", () => {
54
+ test("a JJ rewrite with the same patch records its new commit identity", async () => {
55
+ const init = Bun.spawnSync(["jj", "git", "init", "--colocate", repo], {
56
+ cwd: repo,
57
+ stdout: "ignore",
58
+ stderr: "pipe",
59
+ });
60
+
61
+ if (init.exitCode !== 0) throw new Error(init.stderr.toString());
62
+ writeFileSync(join(repo, "a.ts"), "export const a = 2;\n");
63
+ const first = await core.repoDiff(repo);
64
+ const workspace = await resolveWorkspace(repo);
65
+ const session = core.sessionCreate({
66
+ workspace,
67
+ artifact: {
68
+ type: "diff",
69
+ content: first.patch,
70
+ files: first.files,
71
+ meta: {
72
+ vcs: "jj",
73
+ vcsChangeId: first.source!.changeId,
74
+ vcsRevisionId: first.source!.revisionId,
75
+ },
76
+ },
77
+ });
78
+ const describe = Bun.spawnSync(["jj", "describe", "-m", "same patch, new commit"], {
79
+ cwd: repo,
80
+ stderr: "pipe",
81
+ });
82
+
83
+ if (describe.exitCode !== 0) throw new Error(describe.stderr.toString());
84
+ const result = await core.sessionRefreshDiff(session.id);
85
+ const current = core.sessionGet(session.id);
86
+
87
+ expect(result.changed).toBe(false);
88
+ expect(current.revisions).toHaveLength(2);
89
+ expect(current.revisions[0]?.content).toBe(current.revisions[1]?.content);
90
+ expect(current.revisions[0]?.source?.revisionId).toBe(first.source!.revisionId);
91
+ expect(current.revisions[1]?.source?.revisionId).toBe(current.artifact.meta.vcsRevisionId);
92
+ expect(current.revisions[1]?.source?.revisionId).not.toBe(first.source!.revisionId);
93
+ });
94
+
95
+ test("a submitted JJ change keeps its prior snapshot when refreshed after a rewrite", async () => {
96
+ const init = Bun.spawnSync(["jj", "git", "init", "--colocate", repo], {
97
+ cwd: repo,
98
+ stdout: "ignore",
99
+ stderr: "pipe",
100
+ });
101
+
102
+ if (init.exitCode !== 0) throw new Error(init.stderr.toString());
103
+ writeFileSync(join(repo, "a.ts"), "export const a = 2;\n");
104
+ const first = await core.repoDiff(repo);
105
+ const workspace = await resolveWorkspace(repo);
106
+ const session = core.sessionCreate({
107
+ workspace,
108
+ artifact: {
109
+ type: "diff",
110
+ content: first.patch,
111
+ files: first.files,
112
+ meta: {
113
+ vcs: "jj",
114
+ vcsChangeId: first.source!.changeId,
115
+ vcsRevisionId: first.source!.revisionId,
116
+ },
117
+ },
118
+ });
119
+
120
+ writeFileSync(join(repo, "a.ts"), "export const a = 3;\n");
121
+ const result = await core.sessionRefreshDiff(session.id);
122
+ const current = core.sessionGet(session.id);
123
+
124
+ expect(result.changed).toBe(true);
125
+ expect(current.revisions.map((revision) => revision.content)).toEqual([
126
+ first.patch,
127
+ current.artifact.content,
128
+ ]);
129
+ expect(current.revisions[0]?.source?.revisionId).toBe(first.source!.revisionId);
130
+ expect(current.revisions[0]?.files).toEqual(first.files);
131
+ expect(current.revisions[1]?.source?.revisionId).toBe(current.artifact.meta.vcsRevisionId);
132
+ expect(current.revisions[1]?.files).toEqual(current.artifact.files);
133
+ expect(current.artifact.meta.vcsChangeId).toBe(first.source!.changeId);
134
+ expect(current.artifact.meta.vcsRevisionId).not.toBe(first.source!.revisionId);
135
+ expect(current.artifact.content).toContain("+export const a = 3;");
136
+ });
137
+
138
+ test("re-captures the working tree and updates the artifact when the patch changed", async () => {
139
+ // Given an open diff session whose working tree then gains a new change
140
+ const session = await openDiffSession();
141
+
142
+ writeFileSync(join(repo, "a.ts"), "export const a = 2;\n");
143
+
144
+ // When the diff is refreshed
145
+ const result = await core.sessionRefreshDiff(session.id);
146
+
147
+ // Then the change is reported and the stored artifact carries the fresh patch and files
148
+ expect(result.changed).toBe(true);
149
+ const refreshed = core.sessionGet(session.id);
150
+
151
+ expect(refreshed.artifact.content).toContain("+export const a = 2;");
152
+ const modified = refreshed.artifact.files!.find((file) => file.path === "a.ts")!;
153
+
154
+ expect(modified.newContents).toBe("export const a = 2;\n");
155
+ });
156
+
157
+ test("reports no change and emits nothing when the working tree is unchanged", async () => {
158
+ // Given an open diff session and a listener for session events
159
+ const session = await openDiffSession();
160
+ const events: string[] = [];
161
+
162
+ core.onEvent((event) => events.push(event.event));
163
+
164
+ // When the diff is refreshed without any working-tree change
165
+ const result = await core.sessionRefreshDiff(session.id);
166
+
167
+ // Then nothing changed and no session.updated event fired
168
+ expect(result.changed).toBe(false);
169
+ expect(events).not.toContain("session.updated");
170
+ });
171
+
172
+ test("a changed refresh emits session.updated so attached clients re-fetch", async () => {
173
+ // Given an open diff session, a listener, and a fresh working-tree change
174
+ const session = await openDiffSession();
175
+ const events: string[] = [];
176
+
177
+ core.onEvent((event) => events.push(event.event));
178
+ writeFileSync(join(repo, "a.ts"), "export const a = 3;\n");
179
+
180
+ // When the diff is refreshed
181
+ await core.sessionRefreshDiff(session.id);
182
+
183
+ // Then a session.updated event fired for this session
184
+ expect(events).toContain("session.updated");
185
+ });
186
+
187
+ test("a tree move after a refresh keeps the fresh patch", async () => {
188
+ // Given a refreshed diff session
189
+ const session = await openDiffSession();
190
+
191
+ writeFileSync(join(repo, "a.ts"), "export const a = 4;\n");
192
+ await core.sessionRefreshDiff(session.id);
193
+
194
+ // When the owner branches off and switches back
195
+ core.sessionBranch(session.id, "alt");
196
+ const shown = core.sessionSwitch(session.id, "main");
197
+
198
+ // Then the artifact still shows the re-captured working tree, and the history has one revision
199
+ expect(shown.artifact.content).toContain("+export const a = 4;");
200
+ expect(shown.history!.entries.filter((entry) => entry.type === "revision")).toHaveLength(1);
201
+ });
202
+
203
+ test("is a no-op for a non-diff (plan) session", async () => {
204
+ // Given a plan session in the same workspace
205
+ const workspace = await resolveWorkspace(repo);
206
+ const plan: Artifact = { type: "plan", content: "# Plan\n\nBody.\n", meta: {} };
207
+ const session = core.sessionCreate({ workspace, artifact: plan });
208
+
209
+ // When a refresh is attempted
210
+ const result = await core.sessionRefreshDiff(session.id);
211
+
212
+ // Then it reports no change and leaves the plan content untouched
213
+ expect(result.changed).toBe(false);
214
+ expect(core.sessionGet(session.id).artifact.content).toBe("# Plan\n\nBody.\n");
215
+ });
216
+
217
+ test("refusing a resolved diff session never mutates it (no revive of a closed review)", async () => {
218
+ // Given a diff session that has been resolved
219
+ const session = await openDiffSession();
220
+
221
+ core.sessionSendMessage(session.id, "approved", "");
222
+ const resolvedContent = core.sessionGet(session.id).artifact.content;
223
+
224
+ writeFileSync(join(repo, "a.ts"), "export const a = 99;\n");
225
+
226
+ // When a refresh is attempted on the resolved session
227
+ // Then it is rejected and the frozen artifact is untouched
228
+ expect(core.sessionRefreshDiff(session.id)).rejects.toThrow(/resolved/);
229
+ expect(core.sessionGet(session.id).artifact.content).toBe(resolvedContent);
230
+ });
231
+
232
+ test("overlapping refreshes settle to one coherent artifact without error", async () => {
233
+ // Given a live diff session with a fresh working-tree change
234
+ const session = await openDiffSession();
235
+
236
+ writeFileSync(join(repo, "a.ts"), "export const a = 7;\n");
237
+
238
+ // When two refreshes run concurrently over the same session
239
+ const [first, second] = await Promise.all([
240
+ core.sessionRefreshDiff(session.id),
241
+ core.sessionRefreshDiff(session.id),
242
+ ]);
243
+
244
+ // Then both settle and the stored artifact reflects the current tree exactly
245
+ // once - the generation guard keeps the older capture from regressing it
246
+ expect(first.changed || second.changed).toBe(true);
247
+ expect(core.sessionGet(session.id).artifact.content).toContain("+export const a = 7;");
248
+ });
249
+ });
250
+
251
+ describe("PR review sessions", () => {
252
+ test("refresh re-pulls the PR diff via gh and never clobbers it with the working tree", async () => {
253
+ // Given a stub gh that reports a PR diff distinct from the local working tree
254
+ const ghStub = join(repo, "gh-stub.sh");
255
+ const ghLog = join(repo, "gh-stub.log");
256
+
257
+ writeFileSync(
258
+ ghStub,
259
+ `#!/bin/sh
260
+ printf '%s\n' "$*" >> '${ghLog}'
261
+ case "$2" in
262
+ diff) printf "STUB PR DIFF\\n" ;;
263
+ view) printf '{"baseRefOid":"base-2","headRefOid":"sha-2"}\\n' ;;
264
+ esac
265
+ `,
266
+ );
267
+ chmodSync(ghStub, 0o755);
268
+ const previousGh = process.env.CUELOOP_GH;
269
+
270
+ process.env.CUELOOP_GH = ghStub;
271
+
272
+ try {
273
+ // Given a PR review over the same repo root, and a local working-tree change present
274
+ const workspace = await resolveWorkspace(repo);
275
+ const artifact: Artifact = {
276
+ type: "diff",
277
+ content: "OLD PR DIFF\n",
278
+ files: [],
279
+ meta: {
280
+ pr: "1",
281
+ prUrl: "https://github.com/org/repo/pull/1",
282
+ prBaseSha: "base-1",
283
+ prHeadSha: "sha-1",
284
+ prRefreshBaseSha: "base-2",
285
+ prRefreshHeadSha: "sha-2",
286
+ },
287
+ };
288
+ const session = core.sessionCreate({ workspace, artifact });
289
+
290
+ writeFileSync(join(repo, "a.ts"), "export const a = 123;\n");
291
+
292
+ // When the diff is refreshed
293
+ const result = await core.sessionRefreshDiff(session.id);
294
+
295
+ // Then it carries the PR diff from gh, not the local working-tree change
296
+ expect(result.changed).toBe(true);
297
+ const refreshed = core.sessionGet(session.id);
298
+
299
+ expect(refreshed.artifact.content).toContain("STUB PR DIFF");
300
+ expect(refreshed.artifact.content).not.toContain("export const a = 123;");
301
+ expect(refreshed.artifact.meta.prBaseSha).toBe("base-2");
302
+ expect(refreshed.artifact.meta.prHeadSha).toBe("sha-2");
303
+ expect(refreshed.artifact.meta.prRefreshBaseSha).toBeUndefined();
304
+ expect(refreshed.artifact.meta.prRefreshHeadSha).toBeUndefined();
305
+ expect(refreshed.revisions.map((revision) => revision.content)).toEqual([
306
+ "OLD PR DIFF\n",
307
+ refreshed.artifact.content,
308
+ ]);
309
+ expect(refreshed.revisions.map((revision) => revision.source?.revisionId)).toEqual([
310
+ "sha-1",
311
+ "sha-2",
312
+ ]);
313
+ expect(refreshed.history!.entries.filter((entry) => entry.type === "revision")).toHaveLength(
314
+ 2,
315
+ );
316
+ expect(readFileSync(ghLog, "utf8")).toContain("https://github.com/org/repo/pull/1");
317
+ writeFileSync(ghStub, readFileSync(ghStub, "utf8").replace("sha-2", "sha-3"));
318
+ const samePatch = await core.sessionRefreshDiff(session.id);
319
+ const movedHead = core.sessionGet(session.id);
320
+
321
+ expect(samePatch.changed).toBe(false);
322
+ expect(movedHead.revisions).toHaveLength(3);
323
+ expect(movedHead.revisions[2]?.content).toBe(movedHead.revisions[1]?.content);
324
+ expect(movedHead.revisions[2]?.source?.revisionId).toBe("sha-3");
325
+ } finally {
326
+ if (previousGh === undefined) delete process.env.CUELOOP_GH;
327
+ else process.env.CUELOOP_GH = previousGh;
328
+ }
329
+ });
330
+ });
331
+
332
+ describe("the fs watcher drives hot-reload", () => {
333
+ test("a JJ workbench refreshes in place after the working copy changes", async () => {
334
+ git(["status"], repo);
335
+ const init = Bun.spawnSync(["jj", "git", "init", "--colocate", repo], {
336
+ cwd: repo,
337
+ stdout: "ignore",
338
+ stderr: "pipe",
339
+ });
340
+
341
+ if (init.exitCode !== 0) throw new Error(init.stderr.toString());
342
+ const session = await core.workbenchSession(repo);
343
+
344
+ expect(session.artifact.meta.vcs).toBe("jj");
345
+ writeFileSync(join(repo, "a.ts"), "export const a = 42;\n");
346
+ const deadline = Date.now() + 8_000;
347
+ let current = core.sessionGet(session.id);
348
+
349
+ while (!current.artifact.content.includes("+export const a = 42;") && Date.now() < deadline) {
350
+ await Bun.sleep(100);
351
+ current = core.sessionGet(session.id);
352
+ }
353
+ expect(current.artifact.content).toContain("+export const a = 42;");
354
+ expect(current.revisions).toHaveLength(1);
355
+ expect(current.artifact.meta.vcsChangeId).toBeTruthy();
356
+ }, 12_000);
357
+
358
+ test("a working-tree change under a live diff session refreshes it in place", async () => {
359
+ // Given a live diff session whose repo the daemon is watching
360
+ const session = await openDiffSession();
361
+
362
+ // When a tracked file changes on disk
363
+ writeFileSync(join(repo, "a.ts"), "export const a = 42;\n");
364
+
365
+ // Then the watcher debounces and re-captures the diff without any manual call.
366
+ // Watchers can be slow under CI contention, so poll generously.
367
+ const deadline = Date.now() + 8_000;
368
+ let content = core.sessionGet(session.id).artifact.content;
369
+
370
+ while (!content.includes("+export const a = 42;") && Date.now() < deadline) {
371
+ await Bun.sleep(100);
372
+ content = core.sessionGet(session.id).artifact.content;
373
+ }
374
+ expect(content).toContain("+export const a = 42;");
375
+ }, 12_000);
376
+
377
+ test("watches a directory created after open, so a change inside it refreshes", async () => {
378
+ // Given an open diff session over a repo that had no such directory
379
+ const session = await openDiffSession();
380
+
381
+ // When a new directory appears, the watcher extends into it (a recursive watch got this free)
382
+ mkdirSync(join(repo, "pkg"));
383
+ await Bun.sleep(500);
384
+ writeFileSync(join(repo, "pkg", "inside.ts"), "export const inside = 1;\n");
385
+
386
+ // Then a change inside the new directory drives a re-capture
387
+ const deadline = Date.now() + 8_000;
388
+ let content = core.sessionGet(session.id).artifact.content;
389
+
390
+ while (!content.includes("+export const inside = 1;") && Date.now() < deadline) {
391
+ await Bun.sleep(100);
392
+ content = core.sessionGet(session.id).artifact.content;
393
+ }
394
+ expect(content).toContain("+export const inside = 1;");
395
+ }, 12_000);
396
+
397
+ test("a commit refreshes the diff even though no working-tree file changed", async () => {
398
+ // Given an open diff session showing an uncommitted change to a.ts
399
+ writeFileSync(join(repo, "a.ts"), "export const a = 5;\n");
400
+ const session = await openDiffSession();
401
+
402
+ expect(core.sessionGet(session.id).artifact.content).toContain("+export const a = 5;");
403
+
404
+ // When the change is committed - moving refs/HEAD, touching no working-tree file -
405
+ // the working tree now equals HEAD, so `git diff HEAD` is empty
406
+ git(["add", "a.ts"], repo);
407
+ git(["commit", "-qm", "land a"], repo);
408
+
409
+ // Then the git-metadata watch drives a re-capture and the diff empties out
410
+ const deadline = Date.now() + 8_000;
411
+ let content = core.sessionGet(session.id).artifact.content;
412
+
413
+ while (content.includes("+export const a = 5;") && Date.now() < deadline) {
414
+ await Bun.sleep(100);
415
+ content = core.sessionGet(session.id).artifact.content;
416
+ }
417
+ expect(content).not.toContain("+export const a = 5;");
418
+ }, 12_000);
419
+ });
@@ -0,0 +1,117 @@
1
+ import { expect, mock, spyOn, test } from "bun:test";
2
+ import * as fs from "node:fs";
3
+ import * as promises from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { DiffWatcher } from "./diff-watcher";
7
+
8
+ const directoryReads: {
9
+ readdir: (path: fs.PathLike, options: { withFileTypes: true }) => Promise<fs.Dirent[]>;
10
+ } = promises;
11
+
12
+ test("startup reconciliation watches a directory whose creation event was missed", async () => {
13
+ const repo = fs.mkdtempSync(join(tmpdir(), "cueloop-watch-startup-"));
14
+ const watch = fs.watch;
15
+ const listeners = new Map<string, fs.WatchListener<string>>();
16
+ let refreshes = 0;
17
+ const startup = Promise.withResolvers<void>();
18
+ const change = Promise.withResolvers<void>();
19
+ const watcher = new DiffWatcher(() => {
20
+ refreshes++;
21
+ if (refreshes === 1) startup.resolve();
22
+ else change.resolve();
23
+ });
24
+ // SAFETY: DiffWatcher always calls the string-filename, three-argument watch overload.
25
+ const spy = spyOn(fs, "watch").mockImplementation(((
26
+ path: fs.PathLike,
27
+ options: fs.WatchOptions,
28
+ listener: fs.WatchListener<string>,
29
+ ) => {
30
+ listeners.set(String(path), listener);
31
+
32
+ return watch(path, options, () => {});
33
+ }) as typeof fs.watch);
34
+
35
+ try {
36
+ watcher.trackDiffRepo(repo, "session");
37
+ fs.mkdirSync(join(repo, "nested"));
38
+ await startup.promise;
39
+ expect(refreshes).toBe(1);
40
+ const onDirectoryChange = listeners.get(join(repo, "nested"));
41
+
42
+ expect(onDirectoryChange).toBeDefined();
43
+ fs.writeFileSync(join(repo, "nested", "inside.ts"), "export const inside = 1;\n");
44
+ onDirectoryChange?.("change", "inside.ts");
45
+ await change.promise;
46
+ expect(refreshes).toBe(2);
47
+ } finally {
48
+ watcher.close();
49
+ spy.mockRestore();
50
+ fs.rmSync(repo, { recursive: true, force: true });
51
+ }
52
+ });
53
+
54
+ test("closing during startup reconciliation cancels the pending refresh", async () => {
55
+ const repo = fs.mkdtempSync(join(tmpdir(), "cueloop-watch-close-"));
56
+ const scanStarted = Promise.withResolvers<void>();
57
+ const scanFinished = Promise.withResolvers<fs.Dirent[]>();
58
+ const refresh = mock(() => {});
59
+ const watcher = new DiffWatcher(refresh);
60
+ const spy = spyOn(directoryReads, "readdir").mockImplementation(() => {
61
+ scanStarted.resolve();
62
+
63
+ return scanFinished.promise;
64
+ });
65
+
66
+ try {
67
+ watcher.trackDiffRepo(repo, "session");
68
+ await scanStarted.promise;
69
+ watcher.close();
70
+ scanFinished.resolve([]);
71
+ await new Promise((resolve) => setTimeout(resolve, 0));
72
+ expect(refresh).not.toHaveBeenCalled();
73
+ } finally {
74
+ scanFinished.resolve([]);
75
+ watcher.close();
76
+ spy.mockRestore();
77
+ fs.rmSync(repo, { recursive: true, force: true });
78
+ }
79
+ });
80
+
81
+ test("replacing a watch during reconciliation stops the old directory traversal", async () => {
82
+ const repo = fs.mkdtempSync(join(tmpdir(), "cueloop-watch-replace-"));
83
+ const first = join(repo, "a");
84
+ const next = join(repo, "b");
85
+
86
+ fs.mkdirSync(first);
87
+ fs.mkdirSync(next);
88
+ const scanStarted = Promise.withResolvers<void>();
89
+ const scanFinished = Promise.withResolvers<fs.Dirent[]>();
90
+ const scanned: string[] = [];
91
+ const watcher = new DiffWatcher(() => {});
92
+ const spy = spyOn(directoryReads, "readdir").mockImplementation((path) => {
93
+ scanned.push(String(path));
94
+ if (path === first) {
95
+ scanStarted.resolve();
96
+
97
+ return scanFinished.promise;
98
+ }
99
+
100
+ return Promise.resolve(fs.readdirSync(path, { withFileTypes: true }));
101
+ });
102
+
103
+ try {
104
+ watcher.trackDiffRepo(repo, "old");
105
+ await scanStarted.promise;
106
+ watcher.untrackDiffRepo(repo, "old");
107
+ watcher.trackDiffRepo(repo, "replacement");
108
+ scanFinished.resolve([]);
109
+ await new Promise((resolve) => setTimeout(resolve, 0));
110
+ expect(scanned).not.toContain(next);
111
+ } finally {
112
+ scanFinished.resolve([]);
113
+ watcher.close();
114
+ spy.mockRestore();
115
+ fs.rmSync(repo, { recursive: true, force: true });
116
+ }
117
+ });