@cueloop/daemon 0.1.0-alpha.6 → 0.1.0-alpha.61
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.
- package/package.json +30 -22
- package/src/api.test.ts +292 -46
- package/src/api.ts +301 -52
- package/src/capabilities.test.ts +38 -0
- package/src/capabilities.ts +30 -0
- package/src/client.test.ts +24 -0
- package/src/client.ts +151 -38
- package/src/diff-hot-reload.test.ts +170 -0
- package/src/diff-watcher.ts +105 -0
- package/src/herdr-pane.test.ts +243 -0
- package/src/herdr-pane.ts +154 -0
- package/src/herdr-split.test.ts +106 -0
- package/src/herdr-split.ts +88 -0
- package/src/herdr-tab-store.test.ts +96 -0
- package/src/herdr-tab-store.ts +69 -0
- package/src/index.ts +8 -8
- package/src/main.ts +4 -1
- package/src/paths.ts +9 -0
- package/src/protocol.ts +51 -7
- package/src/retention.test.ts +182 -0
- package/src/retention.ts +122 -0
- package/src/review.test.ts +370 -0
- package/src/review.ts +289 -0
- package/src/server.test.ts +169 -20
- package/src/server.ts +193 -84
- package/src/share-blob.test.ts +62 -0
- package/src/share-blob.ts +55 -0
- package/src/single-instance.test.ts +38 -11
- package/src/store.ts +13 -5
- package/src/validate.test.ts +196 -14
- package/src/validate.ts +138 -38
- package/src/working-tree.ts +113 -0
package/src/client.ts
CHANGED
|
@@ -1,38 +1,87 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Daemon client: the one library every consumer shares - CLI verbs, the TUI,
|
|
3
|
-
* adapters, and tests. Also owns the lazy-launch story
|
|
3
|
+
* adapters, and tests. Also owns the lazy-launch story: connect() with
|
|
4
4
|
* autostart spawns a detached daemon when the socket is dead, then attaches.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { existsSync, rmSync } from "node:fs";
|
|
8
|
-
import type {
|
|
9
|
-
|
|
8
|
+
import type {
|
|
9
|
+
Annotation,
|
|
10
|
+
Artifact,
|
|
11
|
+
Identity,
|
|
12
|
+
ReviewSession,
|
|
13
|
+
VerdictKind,
|
|
14
|
+
WorkspaceKey,
|
|
15
|
+
} from "@cueloop/schema";
|
|
16
|
+
import { BackpressureWriter, LineBuffer, type EventFrame, type Response } from "./protocol";
|
|
17
|
+
import type { DaemonRole } from "./capabilities";
|
|
18
|
+
import type { HerdrTabHandle } from "./herdr-tab-store";
|
|
10
19
|
import { cueloopHome, socketPath } from "./paths";
|
|
11
20
|
|
|
21
|
+
export type { EventFrame } from "./protocol";
|
|
22
|
+
|
|
12
23
|
export interface ConnectOptions {
|
|
13
24
|
home?: string;
|
|
14
25
|
/** Spawn the daemon when the socket is not alive. */
|
|
15
26
|
autostart?: boolean;
|
|
27
|
+
/** Capability role for this connection; a review-side agent connects capped. Defaults to owner. */
|
|
28
|
+
role?: DaemonRole;
|
|
16
29
|
}
|
|
17
30
|
|
|
18
|
-
type
|
|
31
|
+
type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void };
|
|
19
32
|
|
|
20
|
-
|
|
33
|
+
/**
|
|
34
|
+
* The session verbs the review controller drives. DaemonClient is the local
|
|
35
|
+
* implementation (unix socket); the sharing gateway supplies an in-memory,
|
|
36
|
+
* blob-backed one. Depending on this interface - not DaemonClient - is what
|
|
37
|
+
* lets the same <App> render a local session or a decrypted share unchanged.
|
|
38
|
+
*/
|
|
39
|
+
export interface SessionClient {
|
|
40
|
+
onEvent(listener: (event: EventFrame) => void): () => void;
|
|
41
|
+
subscribe(): Promise<void>;
|
|
42
|
+
sessionGet(id: string): Promise<ReviewSession>;
|
|
43
|
+
sessionList(filter?: { status?: "pending" | "resolved" }): Promise<ReviewSession[]>;
|
|
44
|
+
sessionAnnotate(
|
|
45
|
+
id: string,
|
|
46
|
+
annotation: Omit<Annotation, "createdAt">,
|
|
47
|
+
authorName?: string,
|
|
48
|
+
): Promise<ReviewSession>;
|
|
49
|
+
sessionRemoveAnnotation(id: string, annotationId: string): Promise<ReviewSession>;
|
|
50
|
+
sessionSetWorkingCopy(id: string, workingCopy: string | undefined): Promise<ReviewSession>;
|
|
51
|
+
sessionSetViewed(id: string, viewedPaths: string[]): Promise<ReviewSession>;
|
|
52
|
+
sessionSetShareId(id: string, shareId: string): Promise<ReviewSession>;
|
|
53
|
+
sessionMergeShared(
|
|
54
|
+
id: string,
|
|
55
|
+
incoming: { annotations: Annotation[]; participants?: Identity[] },
|
|
56
|
+
): Promise<ReviewSession>;
|
|
57
|
+
sessionDelete(id: string): Promise<void>;
|
|
58
|
+
/** Record the caller's own identity name (collaborator self-naming on a share). */
|
|
59
|
+
sessionSetSelfName(id: string, name: string): Promise<ReviewSession>;
|
|
60
|
+
sessionResolve(id: string, verdictKind: VerdictKind, summary: string): Promise<ReviewSession>;
|
|
61
|
+
close(): void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class DaemonClient implements SessionClient {
|
|
21
65
|
private socket: Awaited<ReturnType<typeof Bun.connect>> | null = null;
|
|
22
|
-
private
|
|
66
|
+
private writer: BackpressureWriter | null = null;
|
|
67
|
+
private pending = new Map<number, PendingRequest>();
|
|
23
68
|
private nextId = 1;
|
|
24
|
-
private eventListeners = new Set<(
|
|
69
|
+
private eventListeners = new Set<(event: EventFrame) => void>();
|
|
25
70
|
private closed = false;
|
|
71
|
+
private role: DaemonRole = "owner";
|
|
26
72
|
|
|
27
|
-
static async connect(
|
|
28
|
-
const home =
|
|
73
|
+
static async connect(options: ConnectOptions = {}): Promise<DaemonClient> {
|
|
74
|
+
const home = options.home ?? cueloopHome();
|
|
29
75
|
const path = socketPath(home);
|
|
30
76
|
const client = new DaemonClient();
|
|
77
|
+
|
|
78
|
+
client.role = options.role ?? "owner";
|
|
31
79
|
try {
|
|
32
80
|
await client.dial(path);
|
|
81
|
+
|
|
33
82
|
return client;
|
|
34
83
|
} catch (err) {
|
|
35
|
-
if (!
|
|
84
|
+
if (!options.autostart) throw err;
|
|
36
85
|
}
|
|
37
86
|
// Socket dead or absent: clean a stale file and spawn the daemon detached.
|
|
38
87
|
if (existsSync(path)) rmSync(path, { force: true });
|
|
@@ -40,83 +89,99 @@ export class DaemonClient {
|
|
|
40
89
|
// Generous: a cold or loaded machine pays for a runtime start before the
|
|
41
90
|
// socket exists, and giving up early looks to callers like a broken daemon.
|
|
42
91
|
const deadline = Date.now() + Number(process.env.CUELOOP_START_TIMEOUT_MS ?? 30_000);
|
|
43
|
-
let
|
|
92
|
+
let lastError: unknown;
|
|
93
|
+
|
|
44
94
|
while (Date.now() < deadline) {
|
|
45
95
|
try {
|
|
46
96
|
await client.dial(path);
|
|
97
|
+
|
|
47
98
|
return client;
|
|
48
99
|
} catch (err) {
|
|
49
|
-
|
|
100
|
+
lastError = err;
|
|
50
101
|
await Bun.sleep(50);
|
|
51
102
|
}
|
|
52
103
|
}
|
|
53
|
-
throw new Error(`daemon did not come up at ${path}: ${String(
|
|
104
|
+
throw new Error(`daemon did not come up at ${path}: ${String(lastError)}`);
|
|
54
105
|
}
|
|
55
106
|
|
|
56
107
|
private async dial(path: string): Promise<void> {
|
|
57
108
|
const buffer = new LineBuffer();
|
|
58
|
-
|
|
109
|
+
|
|
59
110
|
this.socket = await Bun.connect({
|
|
60
111
|
unix: path,
|
|
61
112
|
socket: {
|
|
62
|
-
data(_socket, data) {
|
|
63
|
-
buffer.push(data.toString(), (line) =>
|
|
113
|
+
data: (_socket, data) => {
|
|
114
|
+
buffer.push(data.toString(), (line) => this.routeInboundFrame(line));
|
|
115
|
+
},
|
|
116
|
+
drain: () => {
|
|
117
|
+
this.writer?.drain();
|
|
64
118
|
},
|
|
65
|
-
close() {
|
|
66
|
-
|
|
67
|
-
for (const
|
|
68
|
-
|
|
119
|
+
close: () => {
|
|
120
|
+
this.closed = true;
|
|
121
|
+
for (const pendingRequest of this.pending.values())
|
|
122
|
+
pendingRequest.reject(new Error("daemon connection closed"));
|
|
123
|
+
this.pending.clear();
|
|
69
124
|
},
|
|
70
125
|
error() {},
|
|
71
126
|
},
|
|
72
127
|
});
|
|
128
|
+
this.writer = new BackpressureWriter(this.socket);
|
|
73
129
|
// Verify liveness: a dead socket file accepts connects on some platforms
|
|
74
130
|
// only to fail later, so a ping is the actual handshake.
|
|
75
131
|
await this.request("daemon.ping", {}, 2_000);
|
|
132
|
+
// Cap this connection's role for the daemon's capability gate (owner is the default).
|
|
133
|
+
if (this.role !== "owner") await this.request("daemon.hello", { role: this.role }, 2_000);
|
|
76
134
|
}
|
|
77
135
|
|
|
78
|
-
onEvent(listener: (
|
|
136
|
+
onEvent(listener: (event: EventFrame) => void): () => void {
|
|
79
137
|
this.eventListeners.add(listener);
|
|
138
|
+
|
|
80
139
|
return () => this.eventListeners.delete(listener);
|
|
81
140
|
}
|
|
82
141
|
|
|
83
|
-
private
|
|
142
|
+
private routeInboundFrame(line: string): void {
|
|
84
143
|
let frame: Response | EventFrame;
|
|
144
|
+
|
|
85
145
|
try {
|
|
86
146
|
frame = JSON.parse(line) as Response | EventFrame;
|
|
87
147
|
} catch {
|
|
88
148
|
return;
|
|
89
149
|
}
|
|
90
150
|
if ("event" in frame) {
|
|
91
|
-
for (const
|
|
151
|
+
for (const listener of this.eventListeners) listener(frame);
|
|
152
|
+
|
|
92
153
|
return;
|
|
93
154
|
}
|
|
94
|
-
const
|
|
95
|
-
|
|
155
|
+
const pendingRequest = this.pending.get(frame.id);
|
|
156
|
+
|
|
157
|
+
if (!pendingRequest) return;
|
|
96
158
|
this.pending.delete(frame.id);
|
|
97
|
-
if (frame.error)
|
|
98
|
-
|
|
159
|
+
if (frame.error)
|
|
160
|
+
pendingRequest.reject(new DaemonClientError(frame.error.code, frame.error.message));
|
|
161
|
+
else pendingRequest.resolve(frame.result);
|
|
99
162
|
}
|
|
100
163
|
|
|
101
164
|
request<T = unknown>(method: string, params: unknown, timeoutMs = 30_000): Promise<T> {
|
|
102
165
|
if (this.closed || !this.socket) return Promise.reject(new Error("not connected"));
|
|
103
166
|
const id = this.nextId++;
|
|
167
|
+
|
|
104
168
|
return new Promise<T>((resolve, reject) => {
|
|
105
169
|
const timer = setTimeout(() => {
|
|
106
170
|
this.pending.delete(id);
|
|
107
171
|
reject(new Error(`request ${method} timed out`));
|
|
108
172
|
}, timeoutMs);
|
|
173
|
+
|
|
109
174
|
this.pending.set(id, {
|
|
110
|
-
resolve: (
|
|
175
|
+
resolve: (value) => {
|
|
111
176
|
clearTimeout(timer);
|
|
112
|
-
resolve(
|
|
177
|
+
resolve(value as T);
|
|
113
178
|
},
|
|
114
|
-
reject: (
|
|
179
|
+
reject: (error) => {
|
|
115
180
|
clearTimeout(timer);
|
|
116
|
-
reject(
|
|
181
|
+
reject(error);
|
|
117
182
|
},
|
|
118
183
|
});
|
|
119
|
-
this.
|
|
184
|
+
this.writer!.write(JSON.stringify({ id, method, params }) + "\n");
|
|
120
185
|
});
|
|
121
186
|
}
|
|
122
187
|
|
|
@@ -145,8 +210,12 @@ export class DaemonClient {
|
|
|
145
210
|
sessionWait(id: string, timeoutMs: number): Promise<ReviewSession | null> {
|
|
146
211
|
return this.request("session.wait", { id, timeoutMs }, timeoutMs + 10_000);
|
|
147
212
|
}
|
|
148
|
-
sessionAnnotate(
|
|
149
|
-
|
|
213
|
+
sessionAnnotate(
|
|
214
|
+
id: string,
|
|
215
|
+
annotation: Omit<Annotation, "createdAt">,
|
|
216
|
+
authorName?: string,
|
|
217
|
+
): Promise<ReviewSession> {
|
|
218
|
+
return this.request("session.annotate", { id, annotation, authorName });
|
|
150
219
|
}
|
|
151
220
|
sessionRemoveAnnotation(id: string, annotationId: string): Promise<ReviewSession> {
|
|
152
221
|
return this.request("session.removeAnnotation", { id, annotationId });
|
|
@@ -154,11 +223,45 @@ export class DaemonClient {
|
|
|
154
223
|
sessionSetWorkingCopy(id: string, workingCopy: string | undefined): Promise<ReviewSession> {
|
|
155
224
|
return this.request("session.setWorkingCopy", { id, workingCopy });
|
|
156
225
|
}
|
|
226
|
+
sessionSetViewed(id: string, viewedPaths: string[]): Promise<ReviewSession> {
|
|
227
|
+
return this.request("session.setViewed", { id, viewedPaths });
|
|
228
|
+
}
|
|
229
|
+
/** Re-capture a diff session's working tree; changed=true when the patch moved and an event fired. */
|
|
230
|
+
sessionRefreshDiff(id: string): Promise<{ changed: boolean }> {
|
|
231
|
+
return this.request("session.refreshDiff", { id });
|
|
232
|
+
}
|
|
233
|
+
sessionSetShareId(id: string, shareId: string): Promise<ReviewSession> {
|
|
234
|
+
return this.request("session.setShareId", { id, shareId });
|
|
235
|
+
}
|
|
236
|
+
sessionMergeShared(
|
|
237
|
+
id: string,
|
|
238
|
+
incoming: { annotations: Annotation[]; participants?: Identity[] },
|
|
239
|
+
): Promise<ReviewSession> {
|
|
240
|
+
return this.request("session.mergeShared", { id, ...incoming });
|
|
241
|
+
}
|
|
242
|
+
sessionDelete(id: string): Promise<void> {
|
|
243
|
+
return this.request("session.delete", { id });
|
|
244
|
+
}
|
|
245
|
+
/** Local sessions have no collaborator self-name; the share client owns this. */
|
|
246
|
+
sessionSetSelfName(id: string, _name: string): Promise<ReviewSession> {
|
|
247
|
+
return this.sessionGet(id);
|
|
248
|
+
}
|
|
157
249
|
sessionResolve(id: string, verdictKind: VerdictKind, summary: string): Promise<ReviewSession> {
|
|
158
250
|
return this.request("session.resolve", { id, verdictKind, summary });
|
|
159
251
|
}
|
|
160
|
-
sessionSubmitRevision(
|
|
161
|
-
|
|
252
|
+
sessionSubmitRevision(
|
|
253
|
+
id: string,
|
|
254
|
+
content: string,
|
|
255
|
+
addressedAnnotationIds: string[] = [],
|
|
256
|
+
): Promise<ReviewSession> {
|
|
257
|
+
return this.request("session.submitRevision", { id, content, addressedAnnotationIds });
|
|
258
|
+
}
|
|
259
|
+
/** herdr adapter scratch: the tab opened for a review; local-only, off the SessionClient contract. */
|
|
260
|
+
herdrGetTab(id: string): Promise<HerdrTabHandle | null> {
|
|
261
|
+
return this.request("herdr.getTab", { id });
|
|
262
|
+
}
|
|
263
|
+
async herdrSetTab(id: string, handle: HerdrTabHandle): Promise<void> {
|
|
264
|
+
await this.request("herdr.setTab", { id, ...handle });
|
|
162
265
|
}
|
|
163
266
|
shutdown(): Promise<void> {
|
|
164
267
|
return this.request("daemon.shutdown", {});
|
|
@@ -174,9 +277,19 @@ export class DaemonClientError extends Error {
|
|
|
174
277
|
}
|
|
175
278
|
}
|
|
176
279
|
|
|
280
|
+
// A compiled binary re-execs `cueloop daemon --autostart` (idle-exits like main.ts,
|
|
281
|
+
// unlike the never-exiting foreground daemon); from source, bun runs main.ts.
|
|
282
|
+
export function daemonSpawnCommand(execPath: string, moduleUrl: string): string[] {
|
|
283
|
+
const compiled =
|
|
284
|
+
moduleUrl.includes("$bunfs") || moduleUrl.includes("~BUN") || moduleUrl.includes("%7EBUN");
|
|
285
|
+
|
|
286
|
+
return compiled
|
|
287
|
+
? [execPath, "daemon", "--autostart"]
|
|
288
|
+
: [execPath, "run", new URL("./main.ts", moduleUrl).pathname];
|
|
289
|
+
}
|
|
290
|
+
|
|
177
291
|
function spawnDaemon(home: string): void {
|
|
178
|
-
|
|
179
|
-
Bun.spawn([process.execPath, "run", entry], {
|
|
292
|
+
Bun.spawn(daemonSpawnCommand(process.execPath, import.meta.url), {
|
|
180
293
|
env: { ...process.env, CUELOOP_HOME: home },
|
|
181
294
|
stdio: ["ignore", "ignore", "ignore"],
|
|
182
295
|
}).unref();
|
|
@@ -0,0 +1,170 @@
|
|
|
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("is a no-op for a non-diff (plan) session", async () => {
|
|
104
|
+
// Given a plan session in the same workspace
|
|
105
|
+
const workspace = await resolveWorkspace(repo);
|
|
106
|
+
const plan: Artifact = { type: "plan", content: "# Plan\n\nBody.\n", meta: {} };
|
|
107
|
+
const session = core.sessionCreate({ workspace, artifact: plan });
|
|
108
|
+
|
|
109
|
+
// When a refresh is attempted
|
|
110
|
+
const result = await core.sessionRefreshDiff(session.id);
|
|
111
|
+
|
|
112
|
+
// Then it reports no change and leaves the plan content untouched
|
|
113
|
+
expect(result.changed).toBe(false);
|
|
114
|
+
expect(core.sessionGet(session.id).artifact.content).toBe("# Plan\n\nBody.\n");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("refusing a resolved diff session never mutates it (no revive of a closed review)", async () => {
|
|
118
|
+
// Given a diff session that has been resolved
|
|
119
|
+
const session = await openDiffSession();
|
|
120
|
+
|
|
121
|
+
core.sessionResolve(session.id, "approve", "");
|
|
122
|
+
const resolvedContent = core.sessionGet(session.id).artifact.content;
|
|
123
|
+
|
|
124
|
+
writeFileSync(join(repo, "a.ts"), "export const a = 99;\n");
|
|
125
|
+
|
|
126
|
+
// When a refresh is attempted on the resolved session
|
|
127
|
+
// Then it is rejected and the frozen artifact is untouched
|
|
128
|
+
expect(core.sessionRefreshDiff(session.id)).rejects.toThrow(/resolved/);
|
|
129
|
+
expect(core.sessionGet(session.id).artifact.content).toBe(resolvedContent);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("overlapping refreshes settle to one coherent artifact without error", async () => {
|
|
133
|
+
// Given a live diff session with a fresh working-tree change
|
|
134
|
+
const session = await openDiffSession();
|
|
135
|
+
|
|
136
|
+
writeFileSync(join(repo, "a.ts"), "export const a = 7;\n");
|
|
137
|
+
|
|
138
|
+
// When two refreshes run concurrently over the same session
|
|
139
|
+
const [first, second] = await Promise.all([
|
|
140
|
+
core.sessionRefreshDiff(session.id),
|
|
141
|
+
core.sessionRefreshDiff(session.id),
|
|
142
|
+
]);
|
|
143
|
+
|
|
144
|
+
// Then both settle and the stored artifact reflects the current tree exactly
|
|
145
|
+
// once - the generation guard keeps the older capture from regressing it
|
|
146
|
+
expect(first.changed || second.changed).toBe(true);
|
|
147
|
+
expect(core.sessionGet(session.id).artifact.content).toContain("+export const a = 7;");
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("the fs watcher drives hot-reload", () => {
|
|
152
|
+
test("a working-tree change under a live diff session refreshes it in place", async () => {
|
|
153
|
+
// Given a live diff session whose repo the daemon is watching
|
|
154
|
+
const session = await openDiffSession();
|
|
155
|
+
|
|
156
|
+
// When a tracked file changes on disk
|
|
157
|
+
writeFileSync(join(repo, "a.ts"), "export const a = 42;\n");
|
|
158
|
+
|
|
159
|
+
// Then the watcher debounces and re-captures the diff without any manual call.
|
|
160
|
+
// Watchers can be slow under CI contention, so poll generously.
|
|
161
|
+
const deadline = Date.now() + 8_000;
|
|
162
|
+
let content = core.sessionGet(session.id).artifact.content;
|
|
163
|
+
|
|
164
|
+
while (!content.includes("+export const a = 42;") && Date.now() < deadline) {
|
|
165
|
+
await Bun.sleep(100);
|
|
166
|
+
content = core.sessionGet(session.id).artifact.content;
|
|
167
|
+
}
|
|
168
|
+
expect(content).toContain("+export const a = 42;");
|
|
169
|
+
}, 12_000);
|
|
170
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
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 as FSWatcher & { on(event: "error", listener: (error: Error) => void): void }).on(
|
|
68
|
+
"error",
|
|
69
|
+
() => {},
|
|
70
|
+
);
|
|
71
|
+
this.repoWatches.set(repoRoot, { handle, sessionIds: new Set([sessionId]), debounce: null });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Drop one diff session; close the repo watch once no diff session tracks it. */
|
|
75
|
+
untrackDiffRepo(repoRoot: string, sessionId: string): void {
|
|
76
|
+
const repoWatch = this.repoWatches.get(repoRoot);
|
|
77
|
+
|
|
78
|
+
if (!repoWatch) return;
|
|
79
|
+
repoWatch.sessionIds.delete(sessionId);
|
|
80
|
+
if (repoWatch.sessionIds.size > 0) return;
|
|
81
|
+
if (repoWatch.debounce !== null) clearTimeout(repoWatch.debounce);
|
|
82
|
+
repoWatch.handle.close();
|
|
83
|
+
this.repoWatches.delete(repoRoot);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private scheduleRepoRefresh(repoRoot: string): void {
|
|
87
|
+
const repoWatch = this.repoWatches.get(repoRoot);
|
|
88
|
+
|
|
89
|
+
if (!repoWatch) return;
|
|
90
|
+
if (repoWatch.debounce !== null) clearTimeout(repoWatch.debounce);
|
|
91
|
+
repoWatch.debounce = setTimeout(() => {
|
|
92
|
+
repoWatch.debounce = null;
|
|
93
|
+
this.onRepoChange(repoRoot);
|
|
94
|
+
}, DIFF_REFRESH_DEBOUNCE_MS);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Close every watcher and cancel pending debounces (daemon shutdown). */
|
|
98
|
+
close(): void {
|
|
99
|
+
for (const repoWatch of this.repoWatches.values()) {
|
|
100
|
+
if (repoWatch.debounce !== null) clearTimeout(repoWatch.debounce);
|
|
101
|
+
repoWatch.handle.close();
|
|
102
|
+
}
|
|
103
|
+
this.repoWatches.clear();
|
|
104
|
+
}
|
|
105
|
+
}
|