@cueloop/daemon 0.1.0-alpha.9 → 0.1.0-alpha.90

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 (60) hide show
  1. package/package.json +39 -22
  2. package/src/api.test.ts +1060 -65
  3. package/src/api.ts +1398 -89
  4. package/src/capabilities.test.ts +57 -0
  5. package/src/capabilities.ts +89 -0
  6. package/src/client.test.ts +24 -0
  7. package/src/client.ts +669 -86
  8. package/src/curate.ts +109 -0
  9. package/src/daemon-version.test.ts +142 -0
  10. package/src/diff-hot-reload.test.ts +419 -0
  11. package/src/diff-watcher.ts +293 -0
  12. package/src/gh.test.ts +59 -0
  13. package/src/gh.ts +74 -0
  14. package/src/ghostty-thread-surface-store.test.ts +49 -0
  15. package/src/ghostty-thread-surface-store.ts +101 -0
  16. package/src/ghostty-thread-surface.test.ts +398 -0
  17. package/src/ghostty-thread-surface.ts +267 -0
  18. package/src/harness-delivery.test.ts +268 -0
  19. package/src/harness-state-store.test.ts +93 -0
  20. package/src/harness-state-store.ts +213 -0
  21. package/src/herdr-split.test.ts +106 -0
  22. package/src/herdr-split.ts +92 -0
  23. package/src/herdr-thread-surface-store.test.ts +81 -0
  24. package/src/herdr-thread-surface-store.ts +64 -0
  25. package/src/herdr-thread-surface.test.ts +411 -0
  26. package/src/herdr-thread-surface.ts +355 -0
  27. package/src/index.ts +8 -9
  28. package/src/interruptible-wait.test.ts +127 -0
  29. package/src/interruptible-wait.ts +66 -0
  30. package/src/jj-working-tree.ts +131 -0
  31. package/src/main.ts +4 -1
  32. package/src/paths.ts +49 -0
  33. package/src/pr-poller.test.ts +102 -0
  34. package/src/pr-poller.ts +73 -0
  35. package/src/project-files.test.ts +124 -0
  36. package/src/project-files.ts +56 -0
  37. package/src/protocol.ts +79 -7
  38. package/src/retention.test.ts +191 -0
  39. package/src/retention.ts +123 -0
  40. package/src/server.test.ts +453 -42
  41. package/src/server.ts +433 -88
  42. package/src/share-blob.test.ts +112 -0
  43. package/src/share-blob.ts +55 -0
  44. package/src/single-instance.test.ts +162 -13
  45. package/src/store.conformance.test.ts +41 -0
  46. package/src/store.jsonl.test.ts +159 -0
  47. package/src/store.ts +264 -34
  48. package/src/testing/store-conformance.ts +194 -0
  49. package/src/thread-review.test.ts +483 -0
  50. package/src/thread-review.ts +398 -0
  51. package/src/thread-surface-config.test.ts +63 -0
  52. package/src/thread-surface-config.ts +61 -0
  53. package/src/validate.test.ts +282 -37
  54. package/src/validate.ts +398 -33
  55. package/src/vcs-source.test.ts +180 -0
  56. package/src/vcs-source.ts +228 -0
  57. package/src/version.ts +6 -0
  58. package/src/working-tree.ts +149 -0
  59. package/src/review.test.ts +0 -167
  60. package/src/review.ts +0 -174
package/src/curate.ts ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * The curated patch of a diff review: the submitted changes minus the hunks and
3
+ * change blocks the reviewer rejected. The daemon computes it from the
4
+ * session's reject decisions so the working copy every client sees is one and
5
+ * the same; the client only maps rows to decisions.
6
+ */
7
+
8
+ import { diffAcceptRejectHunk, parseDiffFromFile, type FileDiffMetadata } from "@pierre/diffs";
9
+ import {
10
+ unifiedDiffText,
11
+ type DiffFileContents,
12
+ type DiffFileStatus,
13
+ type HunkRejection,
14
+ } from "@cueloop/schema";
15
+
16
+ /**
17
+ * Context lines around each change. Matching git's default (3) makes the model
18
+ * split hunks like the rendered working-tree patch does; a row is mapped by
19
+ * absolute line number, never by counting hunks, so a whole-hunk reject can
20
+ * only ever span the model hunk it lands in.
21
+ */
22
+ export const DIFF_CONTEXT = 3;
23
+
24
+ /** Parse one file's full contents into a non-partial diff model. */
25
+ export function parseFileDiff(file: DiffFileContents): FileDiffMetadata {
26
+ return parseDiffFromFile(
27
+ { name: file.path, contents: file.oldContents },
28
+ { name: file.path, contents: file.newContents },
29
+ { context: DIFF_CONTEXT },
30
+ );
31
+ }
32
+
33
+ /**
34
+ * Apply one file's rejections and return its curated patch, or null when the
35
+ * curated content equals the old content (every change rejected - no patch).
36
+ * Whole-hunk rejections supersede change-level ones for the same hunk. Because
37
+ * `diffAcceptRejectHunk` returns a model with the same hunk/content shape, the
38
+ * indices stay valid across calls; they are applied high-to-low regardless, so
39
+ * the order is deterministic.
40
+ */
41
+ function curateFilePatch(file: DiffFileContents, rejections: HunkRejection[]): string | null {
42
+ let model = parseFileDiff(file);
43
+ const wholeHunks = new Set(
44
+ rejections
45
+ .filter((rejection) => rejection.changeIndex === undefined)
46
+ .map((rejection) => rejection.hunkIndex),
47
+ );
48
+
49
+ for (const hunkIndex of [...wholeHunks].sort((a, b) => b - a)) {
50
+ model = diffAcceptRejectHunk(model, hunkIndex, "reject");
51
+ }
52
+ const changeRejections = rejections
53
+ .filter(
54
+ (rejection) => rejection.changeIndex !== undefined && !wholeHunks.has(rejection.hunkIndex),
55
+ )
56
+ .sort((a, b) => b.hunkIndex - a.hunkIndex || b.changeIndex! - a.changeIndex!);
57
+
58
+ for (const rejection of changeRejections) {
59
+ model = diffAcceptRejectHunk(model, rejection.hunkIndex, {
60
+ type: "reject",
61
+ changeIndex: rejection.changeIndex!,
62
+ });
63
+ }
64
+ const curatedNew = model.additionLines.join("");
65
+
66
+ if (curatedNew === file.oldContents) return null;
67
+ const patch = unifiedDiffText(file.oldContents, curatedNew, file.path);
68
+
69
+ if (patch === null) return null;
70
+
71
+ return withFileStateHeaders(patch, file.path, file.status, curatedNew);
72
+ }
73
+
74
+ /**
75
+ * Point the diff headers at /dev/null for a created or fully-deleted file so the
76
+ * curated patch applies. git's status - not empty contents - decides existence,
77
+ * so editing an existing empty file stays a modify; a deletion whose removals
78
+ * were partly restored also falls back to a modify.
79
+ */
80
+ function withFileStateHeaders(
81
+ patch: string,
82
+ path: string,
83
+ status: DiffFileStatus,
84
+ curatedNew: string,
85
+ ): string {
86
+ if (status === "added") return patch.replace(`--- a/${path}`, "--- /dev/null");
87
+ if (status === "deleted" && curatedNew === "") {
88
+ return patch.replace(`+++ b/${path}`, "+++ /dev/null");
89
+ }
90
+
91
+ return patch;
92
+ }
93
+
94
+ /**
95
+ * The curated unified-diff patch of the accepted changes across every file.
96
+ * Files with no net change (unchanged, or with all changes rejected) drop out.
97
+ */
98
+ export function curateDiff(files: DiffFileContents[], rejections: HunkRejection[]): string {
99
+ const parts: string[] = [];
100
+
101
+ for (const file of files) {
102
+ const fileRejections = rejections.filter((rejection) => rejection.path === file.path);
103
+ const patch = curateFilePatch(file, fileRejections);
104
+
105
+ if (patch) parts.push(patch);
106
+ }
107
+
108
+ return parts.join("\n");
109
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Version handshake: a client must never keep talking to a daemon from an
3
+ * earlier build (the stale-daemon-after-upgrade bug). The ping carries the
4
+ * daemon's version; a mismatch without autostart is a clear error, and a
5
+ * matching version connects normally.
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
9
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { DaemonServer } from "./server";
13
+ import { DaemonClient, DaemonClientError, daemonSpawnCommand } from "./client";
14
+ import { DAEMON_VERSION } from "./version";
15
+ import { pidPath } from "./paths";
16
+ import type { Artifact, WorkspaceKey } from "@cueloop/schema";
17
+
18
+ const WS: WorkspaceKey = { repoRoot: "/repo", branch: "main" };
19
+ const PLAN: Artifact = { type: "plan", content: "# P\n\nBody.\n", meta: {} };
20
+
21
+ let home: string;
22
+ const servers: DaemonServer[] = [];
23
+
24
+ beforeEach(() => {
25
+ home = mkdtempSync(join(tmpdir(), "cueloop-version-"));
26
+ });
27
+ afterEach(() => {
28
+ for (const runningServer of servers.splice(0)) runningServer.stop();
29
+ rmSync(home, { recursive: true, force: true });
30
+ });
31
+
32
+ function server(version?: string): DaemonServer {
33
+ const daemonServer = new DaemonServer({
34
+ home,
35
+ idleExitMs: 0,
36
+ version,
37
+ onIdleExit: () => {},
38
+ });
39
+
40
+ servers.push(daemonServer);
41
+
42
+ return daemonServer;
43
+ }
44
+
45
+ describe("daemon version handshake", () => {
46
+ test("a stale daemon is rejected when there is nothing to replace it", async () => {
47
+ // Arrange - a daemon posing as an earlier build
48
+ server("0.0.0-stale").start();
49
+
50
+ // Act + Assert - without autostart the client cannot restart it, so it says why
51
+ await expect(DaemonClient.connect({ home, autostart: false })).rejects.toMatchObject({
52
+ code: "version_mismatch",
53
+ });
54
+ await expect(DaemonClient.connect({ home, autostart: false })).rejects.toBeInstanceOf(
55
+ DaemonClientError,
56
+ );
57
+ });
58
+
59
+ test("autostart never replaces a live daemon from another version", async () => {
60
+ const liveServer = server("0.1.0-alpha.81");
61
+
62
+ liveServer.start();
63
+ const session = liveServer.core.sessionCreate({ workspace: WS, artifact: PLAN });
64
+ let replacement: DaemonClient | undefined;
65
+ let connectionError: unknown;
66
+
67
+ try {
68
+ replacement = await DaemonClient.connect({ home, autostart: true });
69
+ } catch (error) {
70
+ connectionError = error;
71
+ }
72
+
73
+ replacement?.close();
74
+ expect(connectionError).toBeInstanceOf(DaemonClientError);
75
+ expect(connectionError).toMatchObject({ code: "version_mismatch" });
76
+ expect(readFileSync(pidPath(home), "utf8")).toBe(String(process.pid));
77
+ expect(liveServer.core.sessionGet(session.id).id).toBe(session.id);
78
+ });
79
+
80
+ test("a rejected client releases its connection so the daemon can become idle", async () => {
81
+ let idleExited = false;
82
+ const liveServer = new DaemonServer({
83
+ home,
84
+ idleExitMs: 50,
85
+ version: "0.1.0-alpha.81",
86
+ onIdleExit: () => {
87
+ idleExited = true;
88
+ },
89
+ });
90
+
91
+ servers.push(liveServer);
92
+ liveServer.start();
93
+
94
+ await expect(DaemonClient.connect({ home, autostart: true })).rejects.toMatchObject({
95
+ code: "version_mismatch",
96
+ });
97
+ await Bun.sleep(100);
98
+
99
+ expect(idleExited).toBe(true);
100
+ });
101
+
102
+ test("a current daemon connects and serves normally", async () => {
103
+ // Arrange - a daemon on this build
104
+ server(DAEMON_VERSION).start();
105
+ const client = await DaemonClient.connect({ home, autostart: false });
106
+
107
+ // Act - a round-trip proves the connection is live, not replaced
108
+ const created = await client.sessionCreate(WS, PLAN);
109
+ const listed = await client.sessionList();
110
+
111
+ // Assert
112
+ expect(listed.map((session) => session.id)).toContain(created.id);
113
+ client.close();
114
+ });
115
+ });
116
+
117
+ describe("daemon spawn command", () => {
118
+ const SOURCE_URL = "file:///repo/packages/daemon/src/client.ts";
119
+
120
+ test("from source it bun-runs main.ts", () => {
121
+ expect(daemonSpawnCommand("/bin/bun", SOURCE_URL, false)).toEqual([
122
+ "/bin/bun",
123
+ "run",
124
+ "/repo/packages/daemon/src/main.ts",
125
+ ]);
126
+ });
127
+
128
+ test("dev-watch reloads the daemon on source edits", () => {
129
+ expect(daemonSpawnCommand("/bin/bun", SOURCE_URL, true)).toEqual([
130
+ "/bin/bun",
131
+ "--watch",
132
+ "run",
133
+ "/repo/packages/daemon/src/main.ts",
134
+ ]);
135
+ });
136
+
137
+ test("a compiled binary re-execs its own daemon command, never --watch", () => {
138
+ expect(
139
+ daemonSpawnCommand("/usr/local/bin/cueloop", "file:///$bunfs/root/cueloop", true),
140
+ ).toEqual(["/usr/local/bin/cueloop", "daemon", "--autostart"]);
141
+ });
142
+ });
@@ -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
+ });