@cueloop/daemon 0.1.0-alpha.43 → 0.1.0-alpha.44
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 +3 -2
- package/src/api.ts +76 -0
- package/src/capabilities.test.ts +1 -0
- package/src/client.ts +4 -0
- package/src/diff-hot-reload.test.ts +159 -0
- package/src/diff-watcher.ts +99 -0
- package/src/server.ts +5 -0
- package/src/validate.ts +1 -0
- package/src/working-tree.ts +100 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/daemon",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.44",
|
|
4
4
|
"description": "The cueloop session daemon and its client: socket protocol, atomic store, resumable waits",
|
|
5
5
|
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
".": "./src/index.ts",
|
|
22
22
|
"./client": "./src/client.ts",
|
|
23
23
|
"./review": "./src/review.ts",
|
|
24
|
+
"./working-tree": "./src/working-tree.ts",
|
|
24
25
|
"./herdr-pane": "./src/herdr-pane.ts",
|
|
25
26
|
"./herdr-split": "./src/herdr-split.ts",
|
|
26
27
|
"./share-blob": "./src/share-blob.ts"
|
|
@@ -29,7 +30,7 @@
|
|
|
29
30
|
"access": "public"
|
|
30
31
|
},
|
|
31
32
|
"dependencies": {
|
|
32
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
33
|
+
"@cueloop/schema": "0.1.0-alpha.44",
|
|
33
34
|
"valibot": "^1.4.2"
|
|
34
35
|
},
|
|
35
36
|
"engines": {
|
package/src/api.ts
CHANGED
|
@@ -24,6 +24,8 @@ import {
|
|
|
24
24
|
} from "@cueloop/schema";
|
|
25
25
|
import { SessionStore } from "./store";
|
|
26
26
|
import { HerdrTabStore, type HerdrTabHandle } from "./herdr-tab-store";
|
|
27
|
+
import { DiffWatcher } from "./diff-watcher";
|
|
28
|
+
import { workingTreeDiff } from "./working-tree";
|
|
27
29
|
import { DaemonError } from "./errors";
|
|
28
30
|
|
|
29
31
|
export type EventName =
|
|
@@ -46,11 +48,27 @@ export class DaemonCore {
|
|
|
46
48
|
private waiters = new Map<string, ((session: ReviewSession) => void)[]>();
|
|
47
49
|
private listeners = new Set<EventListener>();
|
|
48
50
|
private seq = 0;
|
|
51
|
+
/** Drives diff hot-reload: watches each live diff session's repo for working-tree changes. */
|
|
52
|
+
private readonly diffWatcher: DiffWatcher;
|
|
53
|
+
/**
|
|
54
|
+
* Per-diff-session capture generation. Bumped when a refresh begins; a
|
|
55
|
+
* capture whose generation is stale by the time it finishes discards its
|
|
56
|
+
* result, so overlapping captures never write an older patch over a newer one.
|
|
57
|
+
*/
|
|
58
|
+
private readonly diffRefreshGenerations = new Map<string, number>();
|
|
49
59
|
|
|
50
60
|
constructor(home: string) {
|
|
51
61
|
this.store = new SessionStore(home);
|
|
52
62
|
this.store.recover();
|
|
53
63
|
this.herdrTabs = new HerdrTabStore(home);
|
|
64
|
+
this.diffWatcher = new DiffWatcher((repoRoot) => void this.refreshDiffsForRepo(repoRoot));
|
|
65
|
+
// resume hot-reload for diff sessions that survived a daemon restart
|
|
66
|
+
for (const session of this.store.list()) this.watchIfDiffSession(session);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Release the fs watchers behind diff hot-reload; call on daemon shutdown. */
|
|
70
|
+
dispose(): void {
|
|
71
|
+
this.diffWatcher.close();
|
|
54
72
|
}
|
|
55
73
|
|
|
56
74
|
/** The herdr tab opened for a review, if any (adapter scratch, not on the session). */
|
|
@@ -90,6 +108,7 @@ export class DaemonCore {
|
|
|
90
108
|
createdAt: now,
|
|
91
109
|
};
|
|
92
110
|
this.store.upsert(session);
|
|
111
|
+
this.watchIfDiffSession(session);
|
|
93
112
|
this.emit("session.created", session.id);
|
|
94
113
|
this.emit("inbox.changed", session.id);
|
|
95
114
|
return session;
|
|
@@ -216,7 +235,10 @@ export class DaemonCore {
|
|
|
216
235
|
|
|
217
236
|
/** Remove a session for good (inbox delete); resolved or pending, both go. */
|
|
218
237
|
sessionDelete(id: string): void {
|
|
238
|
+
const session = this.store.get(id);
|
|
219
239
|
if (!this.store.delete(id)) throw new DaemonError("not_found", `no session ${id}`);
|
|
240
|
+
if (session) this.unwatchIfDiffSession(session);
|
|
241
|
+
this.diffRefreshGenerations.delete(id);
|
|
220
242
|
this.herdrTabs.delete(id);
|
|
221
243
|
this.emit("inbox.changed", id);
|
|
222
244
|
}
|
|
@@ -259,6 +281,8 @@ export class DaemonCore {
|
|
|
259
281
|
session.verdict = verdict;
|
|
260
282
|
session.status = "resolved";
|
|
261
283
|
this.store.upsert(session);
|
|
284
|
+
// a resolved diff review is frozen; stop hot-reloading its working tree
|
|
285
|
+
this.unwatchIfDiffSession(session);
|
|
262
286
|
const parked = this.waiters.get(id) ?? [];
|
|
263
287
|
this.waiters.delete(id);
|
|
264
288
|
for (const parkedWaiter of parked) parkedWaiter(session);
|
|
@@ -313,6 +337,53 @@ export class DaemonCore {
|
|
|
313
337
|
return session;
|
|
314
338
|
}
|
|
315
339
|
|
|
340
|
+
/** Re-capture a diff session's working tree; broadcasts session.updated only when the patch moved. */
|
|
341
|
+
async sessionRefreshDiff(id: string): Promise<{ changed: boolean }> {
|
|
342
|
+
const session = this.mutable(id);
|
|
343
|
+
if (session.artifact.type !== "diff") return { changed: false };
|
|
344
|
+
const generation = (this.diffRefreshGenerations.get(id) ?? 0) + 1;
|
|
345
|
+
this.diffRefreshGenerations.set(id, generation);
|
|
346
|
+
const diff = await workingTreeDiff(session.workspace.repoRoot);
|
|
347
|
+
// The capture yields the event loop: a newer refresh may have started, or a
|
|
348
|
+
// concurrent resolve/delete may have closed the session. Discard a stale
|
|
349
|
+
// capture, and re-read so a resolved review is never mutated and a deleted
|
|
350
|
+
// one is never revived by this upsert.
|
|
351
|
+
if (this.diffRefreshGenerations.get(id) !== generation) return { changed: false };
|
|
352
|
+
const current = this.store.get(id);
|
|
353
|
+
if (!current || current.status !== "pending" || current.artifact.type !== "diff")
|
|
354
|
+
return { changed: false };
|
|
355
|
+
if (diff.patch === current.artifact.content) return { changed: false };
|
|
356
|
+
current.artifact = { ...current.artifact, content: diff.patch, files: diff.files };
|
|
357
|
+
this.store.upsert(current);
|
|
358
|
+
this.emit("session.updated", id);
|
|
359
|
+
return { changed: true };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Re-capture every live diff session sharing a repo root (one debounced fs change). */
|
|
363
|
+
private async refreshDiffsForRepo(repoRoot: string): Promise<void> {
|
|
364
|
+
const live = this.store
|
|
365
|
+
.list()
|
|
366
|
+
.filter((session) => isLiveDiffSession(session) && session.workspace.repoRoot === repoRoot);
|
|
367
|
+
for (const session of live) {
|
|
368
|
+
// a session resolved or deleted between the change and this tick just skips
|
|
369
|
+
try {
|
|
370
|
+
await this.sessionRefreshDiff(session.id);
|
|
371
|
+
} catch {
|
|
372
|
+
// no-op: the session is gone or already resolved
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private watchIfDiffSession(session: ReviewSession): void {
|
|
378
|
+
if (isLiveDiffSession(session))
|
|
379
|
+
this.diffWatcher.trackDiffRepo(session.workspace.repoRoot, session.id);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
private unwatchIfDiffSession(session: ReviewSession): void {
|
|
383
|
+
if (session.artifact.type === "diff")
|
|
384
|
+
this.diffWatcher.untrackDiffRepo(session.workspace.repoRoot, session.id);
|
|
385
|
+
}
|
|
386
|
+
|
|
316
387
|
private mutable(id: string): ReviewSession {
|
|
317
388
|
const session = this.sessionGet(id);
|
|
318
389
|
if (session.status === "resolved")
|
|
@@ -323,6 +394,11 @@ export class DaemonCore {
|
|
|
323
394
|
|
|
324
395
|
export { DaemonError };
|
|
325
396
|
|
|
397
|
+
/** A pending diff session: the state that warrants hot-reload watching of its working tree. */
|
|
398
|
+
function isLiveDiffSession(session: ReviewSession): boolean {
|
|
399
|
+
return session.status === "pending" && session.artifact.type === "diff";
|
|
400
|
+
}
|
|
401
|
+
|
|
326
402
|
/** Convenience for adapters: map a resolved session to the agent contract. */
|
|
327
403
|
export function verdictResponse(session: ReviewSession): { allow: boolean; feedback: string } {
|
|
328
404
|
if (!session.verdict) throw new DaemonError("pending", "session has no verdict");
|
package/src/capabilities.test.ts
CHANGED
|
@@ -19,6 +19,7 @@ describe("roleAllowsMethod", () => {
|
|
|
19
19
|
"session.setWorkingCopy",
|
|
20
20
|
"session.delete",
|
|
21
21
|
"session.setShareId",
|
|
22
|
+
"session.refreshDiff",
|
|
22
23
|
])
|
|
23
24
|
expect(roleAllowsMethod("agent", method)).toBe(false);
|
|
24
25
|
expect(roleAllowsMethod("collaborator", "session.resolve")).toBe(false);
|
package/src/client.ts
CHANGED
|
@@ -215,6 +215,10 @@ export class DaemonClient implements SessionClient {
|
|
|
215
215
|
sessionSetViewed(id: string, viewedPaths: string[]): Promise<ReviewSession> {
|
|
216
216
|
return this.request("session.setViewed", { id, viewedPaths });
|
|
217
217
|
}
|
|
218
|
+
/** Re-capture a diff session's working tree; changed=true when the patch moved and an event fired. */
|
|
219
|
+
sessionRefreshDiff(id: string): Promise<{ changed: boolean }> {
|
|
220
|
+
return this.request("session.refreshDiff", { id });
|
|
221
|
+
}
|
|
218
222
|
sessionSetShareId(id: string, shareId: string): Promise<ReviewSession> {
|
|
219
223
|
return this.request("session.setShareId", { id, shareId });
|
|
220
224
|
}
|
|
@@ -0,0 +1,159 @@
|
|
|
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
|
+
if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")} failed in ${cwd}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let home: string;
|
|
22
|
+
let repo: string;
|
|
23
|
+
let core: DaemonCore;
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-hotreload-home-"));
|
|
27
|
+
repo = mkdtempSync(join(tmpdir(), "cueloop-hotreload-repo-"));
|
|
28
|
+
git(["init", "-q", "-b", "main"], repo);
|
|
29
|
+
git(["config", "user.email", "t@t"], repo);
|
|
30
|
+
git(["config", "user.name", "t"], repo);
|
|
31
|
+
writeFileSync(join(repo, "a.ts"), "export const a = 1;\n");
|
|
32
|
+
git(["add", "."], repo);
|
|
33
|
+
git(["commit", "-qm", "init"], repo);
|
|
34
|
+
core = new DaemonCore(home);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
core.dispose();
|
|
39
|
+
rmSync(home, { recursive: true, force: true });
|
|
40
|
+
rmSync(repo, { recursive: true, force: true });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/** Open a diff session over the temp repo's current working tree. */
|
|
44
|
+
async function openDiffSession() {
|
|
45
|
+
const workspace = await resolveWorkspace(repo);
|
|
46
|
+
const diff = await workingTreeDiff(repo);
|
|
47
|
+
const artifact: Artifact = { type: "diff", content: diff.patch, files: diff.files, meta: {} };
|
|
48
|
+
return core.sessionCreate({ workspace, artifact });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("session.refreshDiff", () => {
|
|
52
|
+
test("re-captures the working tree and updates the artifact when the patch changed", async () => {
|
|
53
|
+
// Given an open diff session whose working tree then gains a new change
|
|
54
|
+
const session = await openDiffSession();
|
|
55
|
+
writeFileSync(join(repo, "a.ts"), "export const a = 2;\n");
|
|
56
|
+
|
|
57
|
+
// When the diff is refreshed
|
|
58
|
+
const result = await core.sessionRefreshDiff(session.id);
|
|
59
|
+
|
|
60
|
+
// Then the change is reported and the stored artifact carries the fresh patch and files
|
|
61
|
+
expect(result.changed).toBe(true);
|
|
62
|
+
const refreshed = core.sessionGet(session.id);
|
|
63
|
+
expect(refreshed.artifact.content).toContain("+export const a = 2;");
|
|
64
|
+
const modified = refreshed.artifact.files!.find((file) => file.path === "a.ts")!;
|
|
65
|
+
expect(modified.newContents).toBe("export const a = 2;\n");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("reports no change and emits nothing when the working tree is unchanged", async () => {
|
|
69
|
+
// Given an open diff session and a listener for session events
|
|
70
|
+
const session = await openDiffSession();
|
|
71
|
+
const events: string[] = [];
|
|
72
|
+
core.onEvent((event) => events.push(event.event));
|
|
73
|
+
|
|
74
|
+
// When the diff is refreshed without any working-tree change
|
|
75
|
+
const result = await core.sessionRefreshDiff(session.id);
|
|
76
|
+
|
|
77
|
+
// Then nothing changed and no session.updated event fired
|
|
78
|
+
expect(result.changed).toBe(false);
|
|
79
|
+
expect(events).not.toContain("session.updated");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("a changed refresh emits session.updated so attached clients re-fetch", async () => {
|
|
83
|
+
// Given an open diff session, a listener, and a fresh working-tree change
|
|
84
|
+
const session = await openDiffSession();
|
|
85
|
+
const events: string[] = [];
|
|
86
|
+
core.onEvent((event) => events.push(event.event));
|
|
87
|
+
writeFileSync(join(repo, "a.ts"), "export const a = 3;\n");
|
|
88
|
+
|
|
89
|
+
// When the diff is refreshed
|
|
90
|
+
await core.sessionRefreshDiff(session.id);
|
|
91
|
+
|
|
92
|
+
// Then a session.updated event fired for this session
|
|
93
|
+
expect(events).toContain("session.updated");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("is a no-op for a non-diff (plan) session", async () => {
|
|
97
|
+
// Given a plan session in the same workspace
|
|
98
|
+
const workspace = await resolveWorkspace(repo);
|
|
99
|
+
const plan: Artifact = { type: "plan", content: "# Plan\n\nBody.\n", meta: {} };
|
|
100
|
+
const session = core.sessionCreate({ workspace, artifact: plan });
|
|
101
|
+
|
|
102
|
+
// When a refresh is attempted
|
|
103
|
+
const result = await core.sessionRefreshDiff(session.id);
|
|
104
|
+
|
|
105
|
+
// Then it reports no change and leaves the plan content untouched
|
|
106
|
+
expect(result.changed).toBe(false);
|
|
107
|
+
expect(core.sessionGet(session.id).artifact.content).toBe("# Plan\n\nBody.\n");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("refusing a resolved diff session never mutates it (no revive of a closed review)", async () => {
|
|
111
|
+
// Given a diff session that has been resolved
|
|
112
|
+
const session = await openDiffSession();
|
|
113
|
+
core.sessionResolve(session.id, "approve", "");
|
|
114
|
+
const resolvedContent = core.sessionGet(session.id).artifact.content;
|
|
115
|
+
writeFileSync(join(repo, "a.ts"), "export const a = 99;\n");
|
|
116
|
+
|
|
117
|
+
// When a refresh is attempted on the resolved session
|
|
118
|
+
// Then it is rejected and the frozen artifact is untouched
|
|
119
|
+
expect(core.sessionRefreshDiff(session.id)).rejects.toThrow(/resolved/);
|
|
120
|
+
expect(core.sessionGet(session.id).artifact.content).toBe(resolvedContent);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("overlapping refreshes settle to one coherent artifact without error", async () => {
|
|
124
|
+
// Given a live diff session with a fresh working-tree change
|
|
125
|
+
const session = await openDiffSession();
|
|
126
|
+
writeFileSync(join(repo, "a.ts"), "export const a = 7;\n");
|
|
127
|
+
|
|
128
|
+
// When two refreshes run concurrently over the same session
|
|
129
|
+
const [first, second] = await Promise.all([
|
|
130
|
+
core.sessionRefreshDiff(session.id),
|
|
131
|
+
core.sessionRefreshDiff(session.id),
|
|
132
|
+
]);
|
|
133
|
+
|
|
134
|
+
// Then both settle and the stored artifact reflects the current tree exactly
|
|
135
|
+
// once - the generation guard keeps the older capture from regressing it
|
|
136
|
+
expect(first.changed || second.changed).toBe(true);
|
|
137
|
+
expect(core.sessionGet(session.id).artifact.content).toContain("+export const a = 7;");
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("the fs watcher drives hot-reload", () => {
|
|
142
|
+
test("a working-tree change under a live diff session refreshes it in place", async () => {
|
|
143
|
+
// Given a live diff session whose repo the daemon is watching
|
|
144
|
+
const session = await openDiffSession();
|
|
145
|
+
|
|
146
|
+
// When a tracked file changes on disk
|
|
147
|
+
writeFileSync(join(repo, "a.ts"), "export const a = 42;\n");
|
|
148
|
+
|
|
149
|
+
// Then the watcher debounces and re-captures the diff without any manual call.
|
|
150
|
+
// Watchers can be slow under CI contention, so poll generously.
|
|
151
|
+
const deadline = Date.now() + 8_000;
|
|
152
|
+
let content = core.sessionGet(session.id).artifact.content;
|
|
153
|
+
while (!content.includes("+export const a = 42;") && Date.now() < deadline) {
|
|
154
|
+
await Bun.sleep(100);
|
|
155
|
+
content = core.sessionGet(session.id).artifact.content;
|
|
156
|
+
}
|
|
157
|
+
expect(content).toContain("+export const a = 42;");
|
|
158
|
+
}, 12_000);
|
|
159
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
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
|
+
return segments.includes(".git") || segments.includes("node_modules");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface RepoWatch {
|
|
27
|
+
handle: FSWatcher;
|
|
28
|
+
/** Live diff session ids sharing this repo root; the watch closes when the last one leaves. */
|
|
29
|
+
sessionIds: Set<string>;
|
|
30
|
+
debounce: ReturnType<typeof setTimeout> | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Owns the fs watchers behind diff hot-reload. `onRepoChange` fires once per
|
|
35
|
+
* debounced burst with the repo root that changed; the caller re-captures every
|
|
36
|
+
* live diff session on that root. A watcher error or an unwatchable root is
|
|
37
|
+
* swallowed - hot-reload is best-effort and must never crash the daemon.
|
|
38
|
+
*/
|
|
39
|
+
export class DiffWatcher {
|
|
40
|
+
private readonly repoWatches = new Map<string, RepoWatch>();
|
|
41
|
+
|
|
42
|
+
constructor(private readonly onRepoChange: (repoRoot: string) => void) {}
|
|
43
|
+
|
|
44
|
+
/** Start (or join) watching a repo root for one diff session. Idempotent per (root, session). */
|
|
45
|
+
trackDiffRepo(repoRoot: string, sessionId: string): void {
|
|
46
|
+
const existing = this.repoWatches.get(repoRoot);
|
|
47
|
+
if (existing) {
|
|
48
|
+
existing.sessionIds.add(sessionId);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
let handle: FSWatcher;
|
|
52
|
+
try {
|
|
53
|
+
handle = watch(repoRoot, { recursive: true }, (_event, filename) => {
|
|
54
|
+
if (filename !== null && isIgnoredWatchPath(filename.toString())) return;
|
|
55
|
+
this.scheduleRepoRefresh(repoRoot);
|
|
56
|
+
});
|
|
57
|
+
} catch {
|
|
58
|
+
// repo root gone or not watchable on this platform: skip, no hot-reload here
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
// a watcher error (the root is deleted mid-review) must not take down the
|
|
62
|
+
// daemon; the inherited EventEmitter `on` is absent from Bun's FSWatcher type
|
|
63
|
+
(handle as FSWatcher & { on(event: "error", listener: (error: Error) => void): void }).on(
|
|
64
|
+
"error",
|
|
65
|
+
() => {},
|
|
66
|
+
);
|
|
67
|
+
this.repoWatches.set(repoRoot, { handle, sessionIds: new Set([sessionId]), debounce: null });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Drop one diff session; close the repo watch once no diff session tracks it. */
|
|
71
|
+
untrackDiffRepo(repoRoot: string, sessionId: string): void {
|
|
72
|
+
const repoWatch = this.repoWatches.get(repoRoot);
|
|
73
|
+
if (!repoWatch) return;
|
|
74
|
+
repoWatch.sessionIds.delete(sessionId);
|
|
75
|
+
if (repoWatch.sessionIds.size > 0) return;
|
|
76
|
+
if (repoWatch.debounce !== null) clearTimeout(repoWatch.debounce);
|
|
77
|
+
repoWatch.handle.close();
|
|
78
|
+
this.repoWatches.delete(repoRoot);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private scheduleRepoRefresh(repoRoot: string): void {
|
|
82
|
+
const repoWatch = this.repoWatches.get(repoRoot);
|
|
83
|
+
if (!repoWatch) return;
|
|
84
|
+
if (repoWatch.debounce !== null) clearTimeout(repoWatch.debounce);
|
|
85
|
+
repoWatch.debounce = setTimeout(() => {
|
|
86
|
+
repoWatch.debounce = null;
|
|
87
|
+
this.onRepoChange(repoRoot);
|
|
88
|
+
}, DIFF_REFRESH_DEBOUNCE_MS);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Close every watcher and cancel pending debounces (daemon shutdown). */
|
|
92
|
+
close(): void {
|
|
93
|
+
for (const repoWatch of this.repoWatches.values()) {
|
|
94
|
+
if (repoWatch.debounce !== null) clearTimeout(repoWatch.debounce);
|
|
95
|
+
repoWatch.handle.close();
|
|
96
|
+
}
|
|
97
|
+
this.repoWatches.clear();
|
|
98
|
+
}
|
|
99
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -172,6 +172,7 @@ export class DaemonServer {
|
|
|
172
172
|
}
|
|
173
173
|
|
|
174
174
|
stop(): void {
|
|
175
|
+
this.core.dispose();
|
|
175
176
|
this.server?.stop(true);
|
|
176
177
|
this.server = null;
|
|
177
178
|
rmSync(socketPath(this.home), { force: true });
|
|
@@ -275,6 +276,10 @@ export class DaemonServer {
|
|
|
275
276
|
const params = parseParams("session.setViewed", request.params);
|
|
276
277
|
return core.sessionSetViewed(params.id, params.viewedPaths);
|
|
277
278
|
}
|
|
279
|
+
case "session.refreshDiff": {
|
|
280
|
+
const params = parseParams("session.refreshDiff", request.params);
|
|
281
|
+
return core.sessionRefreshDiff(params.id);
|
|
282
|
+
}
|
|
278
283
|
case "session.setShareId": {
|
|
279
284
|
const params = parseParams("session.setShareId", request.params);
|
|
280
285
|
return core.sessionSetShareId(params.id, params.shareId);
|
package/src/validate.ts
CHANGED
|
@@ -133,6 +133,7 @@ export const Params = {
|
|
|
133
133
|
"session.removeAnnotation": v.object({ id: SessionId, annotationId: NonEmpty }),
|
|
134
134
|
"session.setWorkingCopy": v.object({ id: SessionId, workingCopy: v.optional(v.string()) }),
|
|
135
135
|
"session.setViewed": v.object({ id: SessionId, viewedPaths: v.array(v.string()) }),
|
|
136
|
+
"session.refreshDiff": v.object({ id: SessionId }),
|
|
136
137
|
"session.setShareId": v.object({ id: SessionId, shareId: NonEmpty }),
|
|
137
138
|
"session.delete": v.object({ id: SessionId }),
|
|
138
139
|
"session.mergeShared": v.object({
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/** Working-tree diff capture for `cueloop diff`, untracked files included. */
|
|
2
|
+
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { DiffFileContents } from "@cueloop/schema";
|
|
5
|
+
|
|
6
|
+
async function git(args: string[], cwd: string): Promise<string | null> {
|
|
7
|
+
const gitProcess = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore" });
|
|
8
|
+
const stdout = await new Response(gitProcess.stdout).text();
|
|
9
|
+
if ((await gitProcess.exited) !== 0) return null;
|
|
10
|
+
return stdout.trim();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The blob at HEAD for a path, or "" when the path is not in HEAD (new file). */
|
|
14
|
+
async function headContents(path: string, cwd: string): Promise<string> {
|
|
15
|
+
const gitProcess = Bun.spawn(["git", "show", `HEAD:${path}`], {
|
|
16
|
+
cwd,
|
|
17
|
+
stdout: "pipe",
|
|
18
|
+
stderr: "ignore",
|
|
19
|
+
});
|
|
20
|
+
const stdout = await new Response(gitProcess.stdout).text();
|
|
21
|
+
return (await gitProcess.exited) === 0 ? stdout : "";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The working-tree file, or "" when it is gone (deleted). */
|
|
25
|
+
async function workingContents(path: string, cwd: string): Promise<string> {
|
|
26
|
+
try {
|
|
27
|
+
return await Bun.file(join(cwd, path)).text();
|
|
28
|
+
} catch {
|
|
29
|
+
return "";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A NUL byte marks content git would treat as binary - not text-curatable. */
|
|
34
|
+
function looksBinary(text: string): boolean {
|
|
35
|
+
for (let index = 0; index < text.length; index++) {
|
|
36
|
+
if (text.charCodeAt(index) === 0) return true;
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Full old/new contents for every tracked change against HEAD, classified by
|
|
43
|
+
* git's own status letter. Renames/copies and binary files are omitted: a
|
|
44
|
+
* path+text pair cannot express a rename header or a binary blob, so curation
|
|
45
|
+
* would emit a patch that drops the old path or corrupts the file - leaving them
|
|
46
|
+
* out keeps those changes in the patch but simply not curatable. The
|
|
47
|
+
* NUL-delimited stream survives paths with spaces or unicode.
|
|
48
|
+
*/
|
|
49
|
+
async function trackedFileContents(cwd: string): Promise<DiffFileContents[]> {
|
|
50
|
+
const nameStatus = (await git(["diff", "HEAD", "--name-status", "-z"], cwd)) ?? "";
|
|
51
|
+
const tokens = nameStatus.split("\0").filter((token) => token.length > 0);
|
|
52
|
+
const files: DiffFileContents[] = [];
|
|
53
|
+
let cursor = 0;
|
|
54
|
+
while (cursor < tokens.length) {
|
|
55
|
+
const code = tokens[cursor++]![0];
|
|
56
|
+
if (code === "R" || code === "C") {
|
|
57
|
+
cursor += 2;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const path = tokens[cursor++]!;
|
|
61
|
+
const oldContents = code === "A" ? "" : await headContents(path, cwd);
|
|
62
|
+
const newContents = code === "D" ? "" : await workingContents(path, cwd);
|
|
63
|
+
if (looksBinary(oldContents) || looksBinary(newContents)) continue;
|
|
64
|
+
const status = code === "A" ? "added" : code === "D" ? "deleted" : "modified";
|
|
65
|
+
files.push({ path, oldContents, newContents, status });
|
|
66
|
+
}
|
|
67
|
+
return files;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface WorkingTreeDiff {
|
|
71
|
+
patch: string;
|
|
72
|
+
/** Full file contents per curatable changed file, keeping curation applyable. */
|
|
73
|
+
files: DiffFileContents[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function workingTreeDiff(cwd = process.cwd()): Promise<WorkingTreeDiff> {
|
|
77
|
+
const tracked = (await git(["diff", "HEAD"], cwd)) ?? "";
|
|
78
|
+
const files = await trackedFileContents(cwd);
|
|
79
|
+
// untracked files included by default
|
|
80
|
+
const untrackedList = (await git(["ls-files", "--others", "--exclude-standard"], cwd)) ?? "";
|
|
81
|
+
let untracked = "";
|
|
82
|
+
for (const file of untrackedList.split("\n").filter(Boolean)) {
|
|
83
|
+
// `git diff --no-index` exits 1 when the files differ - that is success here
|
|
84
|
+
const gitProcess = Bun.spawn(["git", "diff", "--no-index", "--", "/dev/null", file], {
|
|
85
|
+
cwd,
|
|
86
|
+
stdout: "pipe",
|
|
87
|
+
stderr: "ignore",
|
|
88
|
+
});
|
|
89
|
+
const stdout = await new Response(gitProcess.stdout).text();
|
|
90
|
+
const code = await gitProcess.exited;
|
|
91
|
+
if ((code === 0 || code === 1) && stdout.trim()) {
|
|
92
|
+
untracked += stdout.trim() + "\n";
|
|
93
|
+
const newContents = await workingContents(file, cwd);
|
|
94
|
+
if (!looksBinary(newContents)) {
|
|
95
|
+
files.push({ path: file, oldContents: "", newContents, status: "added" });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { patch: [tracked, untracked].filter(Boolean).join("\n"), files };
|
|
100
|
+
}
|