@cueloop/daemon 0.1.0-alpha.8 → 0.1.0-alpha.80

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 (50) hide show
  1. package/package.json +34 -22
  2. package/src/api.test.ts +925 -65
  3. package/src/api.ts +1060 -88
  4. package/src/capabilities.test.ts +57 -0
  5. package/src/capabilities.ts +84 -0
  6. package/src/client.test.ts +24 -0
  7. package/src/client.ts +526 -70
  8. package/src/curate.ts +109 -0
  9. package/src/daemon-version.test.ts +93 -0
  10. package/src/diff-hot-reload.test.ts +272 -0
  11. package/src/diff-watcher.ts +274 -0
  12. package/src/gh.ts +37 -0
  13. package/src/harness-delivery.test.ts +193 -0
  14. package/src/harness-state-store.test.ts +65 -0
  15. package/src/harness-state-store.ts +182 -0
  16. package/src/herdr-pane.test.ts +240 -0
  17. package/src/herdr-pane.ts +167 -0
  18. package/src/herdr-split.test.ts +106 -0
  19. package/src/herdr-split.ts +92 -0
  20. package/src/herdr-tab-store.test.ts +96 -0
  21. package/src/herdr-tab-store.ts +66 -0
  22. package/src/index.ts +8 -9
  23. package/src/interruptible-wait.test.ts +127 -0
  24. package/src/interruptible-wait.ts +66 -0
  25. package/src/main.ts +4 -1
  26. package/src/paths.ts +44 -0
  27. package/src/pr-poller.test.ts +76 -0
  28. package/src/pr-poller.ts +74 -0
  29. package/src/project-files.test.ts +124 -0
  30. package/src/project-files.ts +56 -0
  31. package/src/protocol.ts +79 -7
  32. package/src/retention.test.ts +191 -0
  33. package/src/retention.ts +123 -0
  34. package/src/server.test.ts +430 -42
  35. package/src/server.ts +362 -84
  36. package/src/share-blob.test.ts +112 -0
  37. package/src/share-blob.ts +55 -0
  38. package/src/single-instance.test.ts +72 -11
  39. package/src/store.conformance.test.ts +41 -0
  40. package/src/store.jsonl.test.ts +159 -0
  41. package/src/store.ts +264 -34
  42. package/src/testing/store-conformance.ts +194 -0
  43. package/src/thread-review.test.ts +394 -0
  44. package/src/thread-review.ts +324 -0
  45. package/src/validate.test.ts +214 -35
  46. package/src/validate.ts +296 -25
  47. package/src/version.ts +6 -0
  48. package/src/working-tree.ts +149 -0
  49. package/src/review.test.ts +0 -167
  50. 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,93 @@
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, 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 type { Artifact, WorkspaceKey } from "@cueloop/schema";
16
+
17
+ const WS: WorkspaceKey = { repoRoot: "/repo", branch: "main" };
18
+ const PLAN: Artifact = { type: "plan", content: "# P\n\nBody.\n", meta: {} };
19
+
20
+ let home: string;
21
+ const servers: DaemonServer[] = [];
22
+
23
+ beforeEach(() => {
24
+ home = mkdtempSync(join(tmpdir(), "cueloop-version-"));
25
+ });
26
+ afterEach(() => {
27
+ for (const runningServer of servers.splice(0)) runningServer.stop();
28
+ rmSync(home, { recursive: true, force: true });
29
+ });
30
+
31
+ function server(version?: string): DaemonServer {
32
+ const daemonServer = new DaemonServer({ home, idleExitMs: 0, version });
33
+
34
+ servers.push(daemonServer);
35
+
36
+ return daemonServer;
37
+ }
38
+
39
+ describe("daemon version handshake", () => {
40
+ test("a stale daemon is rejected when there is nothing to replace it", async () => {
41
+ // Arrange - a daemon posing as an earlier build
42
+ server("0.0.0-stale").start();
43
+
44
+ // Act + Assert - without autostart the client cannot restart it, so it says why
45
+ await expect(DaemonClient.connect({ home, autostart: false })).rejects.toMatchObject({
46
+ code: "version_mismatch",
47
+ });
48
+ await expect(DaemonClient.connect({ home, autostart: false })).rejects.toBeInstanceOf(
49
+ DaemonClientError,
50
+ );
51
+ });
52
+
53
+ test("a current daemon connects and serves normally", async () => {
54
+ // Arrange - a daemon on this build
55
+ server(DAEMON_VERSION).start();
56
+ const client = await DaemonClient.connect({ home, autostart: false });
57
+
58
+ // Act - a round-trip proves the connection is live, not replaced
59
+ const created = await client.sessionCreate(WS, PLAN);
60
+ const listed = await client.sessionList();
61
+
62
+ // Assert
63
+ expect(listed.map((session) => session.id)).toContain(created.id);
64
+ client.close();
65
+ });
66
+ });
67
+
68
+ describe("daemon spawn command", () => {
69
+ const SOURCE_URL = "file:///repo/packages/daemon/src/client.ts";
70
+
71
+ test("from source it bun-runs main.ts", () => {
72
+ expect(daemonSpawnCommand("/bin/bun", SOURCE_URL, false)).toEqual([
73
+ "/bin/bun",
74
+ "run",
75
+ "/repo/packages/daemon/src/main.ts",
76
+ ]);
77
+ });
78
+
79
+ test("dev-watch reloads the daemon on source edits", () => {
80
+ expect(daemonSpawnCommand("/bin/bun", SOURCE_URL, true)).toEqual([
81
+ "/bin/bun",
82
+ "--watch",
83
+ "run",
84
+ "/repo/packages/daemon/src/main.ts",
85
+ ]);
86
+ });
87
+
88
+ test("a compiled binary re-execs its own daemon command, never --watch", () => {
89
+ expect(
90
+ daemonSpawnCommand("/usr/local/bin/cueloop", "file:///$bunfs/root/cueloop", true),
91
+ ).toEqual(["/usr/local/bin/cueloop", "daemon", "--autostart"]);
92
+ });
93
+ });
@@ -0,0 +1,272 @@
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, 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("re-captures the working tree and updates the artifact when the patch changed", async () => {
55
+ // Given an open diff session whose working tree then gains a new change
56
+ const session = await openDiffSession();
57
+
58
+ writeFileSync(join(repo, "a.ts"), "export const a = 2;\n");
59
+
60
+ // When the diff is refreshed
61
+ const result = await core.sessionRefreshDiff(session.id);
62
+
63
+ // Then the change is reported and the stored artifact carries the fresh patch and files
64
+ expect(result.changed).toBe(true);
65
+ const refreshed = core.sessionGet(session.id);
66
+
67
+ expect(refreshed.artifact.content).toContain("+export const a = 2;");
68
+ const modified = refreshed.artifact.files!.find((file) => file.path === "a.ts")!;
69
+
70
+ expect(modified.newContents).toBe("export const a = 2;\n");
71
+ });
72
+
73
+ test("reports no change and emits nothing when the working tree is unchanged", async () => {
74
+ // Given an open diff session and a listener for session events
75
+ const session = await openDiffSession();
76
+ const events: string[] = [];
77
+
78
+ core.onEvent((event) => events.push(event.event));
79
+
80
+ // When the diff is refreshed without any working-tree change
81
+ const result = await core.sessionRefreshDiff(session.id);
82
+
83
+ // Then nothing changed and no session.updated event fired
84
+ expect(result.changed).toBe(false);
85
+ expect(events).not.toContain("session.updated");
86
+ });
87
+
88
+ test("a changed refresh emits session.updated so attached clients re-fetch", async () => {
89
+ // Given an open diff session, a listener, and a fresh working-tree change
90
+ const session = await openDiffSession();
91
+ const events: string[] = [];
92
+
93
+ core.onEvent((event) => events.push(event.event));
94
+ writeFileSync(join(repo, "a.ts"), "export const a = 3;\n");
95
+
96
+ // When the diff is refreshed
97
+ await core.sessionRefreshDiff(session.id);
98
+
99
+ // Then a session.updated event fired for this session
100
+ expect(events).toContain("session.updated");
101
+ });
102
+
103
+ test("a tree move after a refresh keeps the fresh patch", async () => {
104
+ // Given a refreshed diff session
105
+ const session = await openDiffSession();
106
+
107
+ writeFileSync(join(repo, "a.ts"), "export const a = 4;\n");
108
+ await core.sessionRefreshDiff(session.id);
109
+
110
+ // When the owner branches off and switches back
111
+ core.sessionBranch(session.id, "alt");
112
+ const shown = core.sessionSwitch(session.id, "main");
113
+
114
+ // Then the artifact still shows the re-captured working tree, and the history has one revision
115
+ expect(shown.artifact.content).toContain("+export const a = 4;");
116
+ expect(shown.history!.entries.filter((entry) => entry.type === "revision")).toHaveLength(1);
117
+ });
118
+
119
+ test("is a no-op for a non-diff (plan) session", async () => {
120
+ // Given a plan session in the same workspace
121
+ const workspace = await resolveWorkspace(repo);
122
+ const plan: Artifact = { type: "plan", content: "# Plan\n\nBody.\n", meta: {} };
123
+ const session = core.sessionCreate({ workspace, artifact: plan });
124
+
125
+ // When a refresh is attempted
126
+ const result = await core.sessionRefreshDiff(session.id);
127
+
128
+ // Then it reports no change and leaves the plan content untouched
129
+ expect(result.changed).toBe(false);
130
+ expect(core.sessionGet(session.id).artifact.content).toBe("# Plan\n\nBody.\n");
131
+ });
132
+
133
+ test("refusing a resolved diff session never mutates it (no revive of a closed review)", async () => {
134
+ // Given a diff session that has been resolved
135
+ const session = await openDiffSession();
136
+
137
+ core.sessionSendMessage(session.id, "approved", "");
138
+ const resolvedContent = core.sessionGet(session.id).artifact.content;
139
+
140
+ writeFileSync(join(repo, "a.ts"), "export const a = 99;\n");
141
+
142
+ // When a refresh is attempted on the resolved session
143
+ // Then it is rejected and the frozen artifact is untouched
144
+ expect(core.sessionRefreshDiff(session.id)).rejects.toThrow(/resolved/);
145
+ expect(core.sessionGet(session.id).artifact.content).toBe(resolvedContent);
146
+ });
147
+
148
+ test("overlapping refreshes settle to one coherent artifact without error", async () => {
149
+ // Given a live diff session with a fresh working-tree change
150
+ const session = await openDiffSession();
151
+
152
+ writeFileSync(join(repo, "a.ts"), "export const a = 7;\n");
153
+
154
+ // When two refreshes run concurrently over the same session
155
+ const [first, second] = await Promise.all([
156
+ core.sessionRefreshDiff(session.id),
157
+ core.sessionRefreshDiff(session.id),
158
+ ]);
159
+
160
+ // Then both settle and the stored artifact reflects the current tree exactly
161
+ // once - the generation guard keeps the older capture from regressing it
162
+ expect(first.changed || second.changed).toBe(true);
163
+ expect(core.sessionGet(session.id).artifact.content).toContain("+export const a = 7;");
164
+ });
165
+ });
166
+
167
+ describe("PR review sessions", () => {
168
+ test("refresh re-pulls the PR diff via gh and never clobbers it with the working tree", async () => {
169
+ // Given a stub gh that reports a PR diff distinct from the local working tree
170
+ const ghStub = join(repo, "gh-stub.sh");
171
+
172
+ writeFileSync(
173
+ ghStub,
174
+ '#!/bin/sh\ncase "$2" in\n diff) printf "STUB PR DIFF\\n" ;;\n view) printf "sha-2\\n" ;;\nesac\n',
175
+ );
176
+ chmodSync(ghStub, 0o755);
177
+ const previousGh = process.env.CUELOOP_GH;
178
+
179
+ process.env.CUELOOP_GH = ghStub;
180
+
181
+ try {
182
+ // Given a PR review over the same repo root, and a local working-tree change present
183
+ const workspace = await resolveWorkspace(repo);
184
+ const artifact: Artifact = {
185
+ type: "diff",
186
+ content: "OLD PR DIFF\n",
187
+ files: [],
188
+ meta: { pr: "org/repo#1" },
189
+ };
190
+ const session = core.sessionCreate({ workspace, artifact });
191
+
192
+ writeFileSync(join(repo, "a.ts"), "export const a = 123;\n");
193
+
194
+ // When the diff is refreshed
195
+ const result = await core.sessionRefreshDiff(session.id);
196
+
197
+ // Then it carries the PR diff from gh, not the local working-tree change
198
+ expect(result.changed).toBe(true);
199
+ const refreshed = core.sessionGet(session.id);
200
+
201
+ expect(refreshed.artifact.content).toContain("STUB PR DIFF");
202
+ expect(refreshed.artifact.content).not.toContain("export const a = 123;");
203
+ } finally {
204
+ if (previousGh === undefined) delete process.env.CUELOOP_GH;
205
+ else process.env.CUELOOP_GH = previousGh;
206
+ }
207
+ });
208
+ });
209
+
210
+ describe("the fs watcher drives hot-reload", () => {
211
+ test("a working-tree change under a live diff session refreshes it in place", async () => {
212
+ // Given a live diff session whose repo the daemon is watching
213
+ const session = await openDiffSession();
214
+
215
+ // When a tracked file changes on disk
216
+ writeFileSync(join(repo, "a.ts"), "export const a = 42;\n");
217
+
218
+ // Then the watcher debounces and re-captures the diff without any manual call.
219
+ // Watchers can be slow under CI contention, so poll generously.
220
+ const deadline = Date.now() + 8_000;
221
+ let content = core.sessionGet(session.id).artifact.content;
222
+
223
+ while (!content.includes("+export const a = 42;") && Date.now() < deadline) {
224
+ await Bun.sleep(100);
225
+ content = core.sessionGet(session.id).artifact.content;
226
+ }
227
+ expect(content).toContain("+export const a = 42;");
228
+ }, 12_000);
229
+
230
+ test("watches a directory created after open, so a change inside it refreshes", async () => {
231
+ // Given an open diff session over a repo that had no such directory
232
+ const session = await openDiffSession();
233
+
234
+ // When a new directory appears, the watcher extends into it (a recursive watch got this free)
235
+ mkdirSync(join(repo, "pkg"));
236
+ await Bun.sleep(500);
237
+ writeFileSync(join(repo, "pkg", "inside.ts"), "export const inside = 1;\n");
238
+
239
+ // Then a change inside the new directory drives a re-capture
240
+ const deadline = Date.now() + 8_000;
241
+ let content = core.sessionGet(session.id).artifact.content;
242
+
243
+ while (!content.includes("+export const inside = 1;") && Date.now() < deadline) {
244
+ await Bun.sleep(100);
245
+ content = core.sessionGet(session.id).artifact.content;
246
+ }
247
+ expect(content).toContain("+export const inside = 1;");
248
+ }, 12_000);
249
+
250
+ test("a commit refreshes the diff even though no working-tree file changed", async () => {
251
+ // Given an open diff session showing an uncommitted change to a.ts
252
+ writeFileSync(join(repo, "a.ts"), "export const a = 5;\n");
253
+ const session = await openDiffSession();
254
+
255
+ expect(core.sessionGet(session.id).artifact.content).toContain("+export const a = 5;");
256
+
257
+ // When the change is committed - moving refs/HEAD, touching no working-tree file -
258
+ // the working tree now equals HEAD, so `git diff HEAD` is empty
259
+ git(["add", "a.ts"], repo);
260
+ git(["commit", "-qm", "land a"], repo);
261
+
262
+ // Then the git-metadata watch drives a re-capture and the diff empties out
263
+ const deadline = Date.now() + 8_000;
264
+ let content = core.sessionGet(session.id).artifact.content;
265
+
266
+ while (content.includes("+export const a = 5;") && Date.now() < deadline) {
267
+ await Bun.sleep(100);
268
+ content = core.sessionGet(session.id).artifact.content;
269
+ }
270
+ expect(content).not.toContain("+export const a = 5;");
271
+ }, 12_000);
272
+ });