@cueloop/daemon 0.1.0-alpha.7 → 0.1.0-alpha.71

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 (42) hide show
  1. package/package.json +33 -22
  2. package/src/api.test.ts +891 -50
  3. package/src/api.ts +849 -72
  4. package/src/capabilities.test.ts +57 -0
  5. package/src/capabilities.ts +77 -0
  6. package/src/client.test.ts +24 -0
  7. package/src/client.ts +459 -69
  8. package/src/curate.ts +109 -0
  9. package/src/daemon-version.test.ts +93 -0
  10. package/src/diff-hot-reload.test.ts +186 -0
  11. package/src/diff-watcher.ts +102 -0
  12. package/src/herdr-pane.test.ts +240 -0
  13. package/src/herdr-pane.ts +167 -0
  14. package/src/herdr-split.test.ts +106 -0
  15. package/src/herdr-split.ts +92 -0
  16. package/src/herdr-tab-store.test.ts +96 -0
  17. package/src/herdr-tab-store.ts +66 -0
  18. package/src/index.ts +8 -9
  19. package/src/interruptible-wait.test.ts +127 -0
  20. package/src/interruptible-wait.ts +66 -0
  21. package/src/main.ts +4 -1
  22. package/src/paths.ts +39 -0
  23. package/src/project-files.test.ts +124 -0
  24. package/src/project-files.ts +56 -0
  25. package/src/protocol.ts +79 -7
  26. package/src/retention.test.ts +182 -0
  27. package/src/retention.ts +122 -0
  28. package/src/review.test.ts +255 -28
  29. package/src/review.ts +164 -62
  30. package/src/server.test.ts +381 -39
  31. package/src/server.ts +322 -84
  32. package/src/share-blob.test.ts +112 -0
  33. package/src/share-blob.ts +55 -0
  34. package/src/single-instance.test.ts +38 -11
  35. package/src/store.conformance.test.ts +41 -0
  36. package/src/store.jsonl.test.ts +159 -0
  37. package/src/store.ts +261 -34
  38. package/src/testing/store-conformance.ts +194 -0
  39. package/src/validate.test.ts +174 -28
  40. package/src/validate.ts +226 -16
  41. package/src/version.ts +6 -0
  42. package/src/working-tree.ts +149 -0
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,186 @@
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 { 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 "./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.sessionResolve(session.id, "approve", "");
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("the fs watcher drives hot-reload", () => {
168
+ test("a working-tree change under a live diff session refreshes it in place", async () => {
169
+ // Given a live diff session whose repo the daemon is watching
170
+ const session = await openDiffSession();
171
+
172
+ // When a tracked file changes on disk
173
+ writeFileSync(join(repo, "a.ts"), "export const a = 42;\n");
174
+
175
+ // Then the watcher debounces and re-captures the diff without any manual call.
176
+ // Watchers can be slow under CI contention, so poll generously.
177
+ const deadline = Date.now() + 8_000;
178
+ let content = core.sessionGet(session.id).artifact.content;
179
+
180
+ while (!content.includes("+export const a = 42;") && Date.now() < deadline) {
181
+ await Bun.sleep(100);
182
+ content = core.sessionGet(session.id).artifact.content;
183
+ }
184
+ expect(content).toContain("+export const a = 42;");
185
+ }, 12_000);
186
+ });
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Watches a diff session's repository for working-tree changes and drives the
3
+ * hot-reload: on any change under a repo root that a live diff session tracks,
4
+ * the daemon re-captures that session's diff so the open review updates in place.
5
+ * One recursive fs watcher per repo root - many diff sessions can share a repo -
6
+ * with a debounce so a burst of file writes triggers a single re-capture.
7
+ */
8
+
9
+ import { watch, type FSWatcher } from "node:fs";
10
+ import { sep } from "node:path";
11
+
12
+ /** Debounce window: a save or a checkout writes many files in a burst; collapse them into one re-capture. */
13
+ const DIFF_REFRESH_DEBOUNCE_MS = 300;
14
+
15
+ /**
16
+ * Paths whose churn must never trigger a diff refresh. git rewrites `.git`
17
+ * metadata (the index, lock files) on every read `cueloop diff` itself runs, so
18
+ * watching it would loop; `node_modules` churns on installs and is never review
19
+ * content.
20
+ */
21
+ function isIgnoredWatchPath(relativePath: string): boolean {
22
+ const segments = relativePath.split(sep);
23
+
24
+ return segments.includes(".git") || segments.includes("node_modules");
25
+ }
26
+
27
+ interface RepoWatch {
28
+ handle: FSWatcher;
29
+ /** Live diff session ids sharing this repo root; the watch closes when the last one leaves. */
30
+ sessionIds: Set<string>;
31
+ debounce: ReturnType<typeof setTimeout> | null;
32
+ }
33
+
34
+ /**
35
+ * Owns the fs watchers behind diff hot-reload. `onRepoChange` fires once per
36
+ * debounced burst with the repo root that changed; the caller re-captures every
37
+ * live diff session on that root. A watcher error or an unwatchable root is
38
+ * swallowed - hot-reload is best-effort and must never crash the daemon.
39
+ */
40
+ export class DiffWatcher {
41
+ private readonly repoWatches = new Map<string, RepoWatch>();
42
+
43
+ constructor(private readonly onRepoChange: (repoRoot: string) => void) {}
44
+
45
+ /** Start (or join) watching a repo root for one diff session. Idempotent per (root, session). */
46
+ trackDiffRepo(repoRoot: string, sessionId: string): void {
47
+ const existing = this.repoWatches.get(repoRoot);
48
+
49
+ if (existing) {
50
+ existing.sessionIds.add(sessionId);
51
+
52
+ return;
53
+ }
54
+ let handle: FSWatcher;
55
+
56
+ try {
57
+ handle = watch(repoRoot, { recursive: true }, (_event, filename) => {
58
+ if (filename !== null && isIgnoredWatchPath(filename.toString())) return;
59
+ this.scheduleRepoRefresh(repoRoot);
60
+ });
61
+ } catch {
62
+ // repo root gone or not watchable on this platform: skip, no hot-reload here
63
+ return;
64
+ }
65
+ // a watcher error (the root is deleted mid-review) must not take down the
66
+ // daemon; the inherited EventEmitter `on` is absent from Bun's FSWatcher type
67
+ handle.on("error", () => {});
68
+ this.repoWatches.set(repoRoot, { handle, sessionIds: new Set([sessionId]), debounce: null });
69
+ }
70
+
71
+ /** Drop one diff session; close the repo watch once no diff session tracks it. */
72
+ untrackDiffRepo(repoRoot: string, sessionId: string): void {
73
+ const repoWatch = this.repoWatches.get(repoRoot);
74
+
75
+ if (!repoWatch) return;
76
+ repoWatch.sessionIds.delete(sessionId);
77
+ if (repoWatch.sessionIds.size > 0) return;
78
+ if (repoWatch.debounce !== null) clearTimeout(repoWatch.debounce);
79
+ repoWatch.handle.close();
80
+ this.repoWatches.delete(repoRoot);
81
+ }
82
+
83
+ private scheduleRepoRefresh(repoRoot: string): void {
84
+ const repoWatch = this.repoWatches.get(repoRoot);
85
+
86
+ if (!repoWatch) return;
87
+ if (repoWatch.debounce !== null) clearTimeout(repoWatch.debounce);
88
+ repoWatch.debounce = setTimeout(() => {
89
+ repoWatch.debounce = null;
90
+ this.onRepoChange(repoRoot);
91
+ }, DIFF_REFRESH_DEBOUNCE_MS);
92
+ }
93
+
94
+ /** Close every watcher and cancel pending debounces (daemon shutdown). */
95
+ close(): void {
96
+ for (const repoWatch of this.repoWatches.values()) {
97
+ if (repoWatch.debounce !== null) clearTimeout(repoWatch.debounce);
98
+ repoWatch.handle.close();
99
+ }
100
+ this.repoWatches.clear();
101
+ }
102
+ }