@cueloop/daemon 0.1.0-alpha.78 → 0.1.0-alpha.79

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/daemon",
3
- "version": "0.1.0-alpha.78",
3
+ "version": "0.1.0-alpha.79",
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": {
@@ -35,8 +35,8 @@
35
35
  "access": "public"
36
36
  },
37
37
  "dependencies": {
38
- "@cueloop/schema": "0.1.0-alpha.78",
39
- "@pierre/diffs": "^1.3.6",
38
+ "@cueloop/schema": "0.1.0-alpha.79",
39
+ "@pierre/diffs": "^1.4.1",
40
40
  "effect": "^4.0.0-rc.115",
41
41
  "valibot": "^1.4.2"
42
42
  },
package/src/api.ts CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  type HunkRejection,
37
37
  type NewEntry,
38
38
  type Artifact,
39
+ type ShareLink,
39
40
  type Identity,
40
41
  type Thread,
41
42
  type SessionHistory,
@@ -48,6 +49,8 @@ import { ThreadStore, withHistory } from "./store";
48
49
  import { pruneExpiredSessions, resolveCleanupPeriodDays } from "./retention";
49
50
  import { HerdrTabStore, type HerdrTabHandle } from "./herdr-tab-store";
50
51
  import { DiffWatcher } from "./diff-watcher";
52
+ import { PrReviewPoller } from "./pr-poller";
53
+ import { prDiff } from "./gh";
51
54
  import { workingTreeDiff, workingChangeList, type WorkingTreeDiff } from "./working-tree";
52
55
  import { listProjectFiles, readProjectFile } from "./project-files";
53
56
  import { resolveWorkspace } from "./review";
@@ -85,6 +88,8 @@ export class DaemonCore {
85
88
  private seq = 0;
86
89
  /** Drives diff hot-reload: watches each live diff session's repo for working-tree changes. */
87
90
  private readonly diffWatcher: DiffWatcher;
91
+ /** Drives PR-review hot-reload: polls each open PR review for a moved head. */
92
+ private readonly prPoller: PrReviewPoller;
88
93
  /**
89
94
  * Per-diff-session capture generation. Bumped when a refresh begins; a
90
95
  * capture whose generation is stale by the time it finishes discards its
@@ -100,13 +105,15 @@ export class DaemonCore {
100
105
  pruneExpiredSessions(this.store, resolveCleanupPeriodDays(), Date.now());
101
106
  this.herdrTabs = new HerdrTabStore(home);
102
107
  this.diffWatcher = new DiffWatcher((repoRoot) => void this.refreshDiffsForRepo(repoRoot));
108
+ this.prPoller = new PrReviewPoller((sessionId) => void this.sessionRefreshPrDiff(sessionId));
103
109
  // resume hot-reload for diff sessions that survived a daemon restart
104
- for (const session of this.store.list()) this.watchIfDiffSession(session);
110
+ for (const session of this.store.list()) this.trackLiveDiffSession(session);
105
111
  }
106
112
 
107
- /** Release the fs watchers behind diff hot-reload; call on daemon shutdown. */
113
+ /** Release the watchers and pollers behind diff hot-reload; call on daemon shutdown. */
108
114
  dispose(): void {
109
115
  this.diffWatcher.close();
116
+ this.prPoller.close();
110
117
  }
111
118
 
112
119
  /** The herdr tab opened for a review, if any (adapter scratch, not on the session). */
@@ -152,7 +159,7 @@ export class DaemonCore {
152
159
 
153
160
  session.history = historyFromLinear(session);
154
161
  this.store.upsert(session);
155
- this.watchIfDiffSession(session);
162
+ this.trackLiveDiffSession(session);
156
163
  this.emit("session.created", session.id);
157
164
  this.emit("inbox.changed", session.id);
158
165
 
@@ -432,6 +439,21 @@ export class DaemonCore {
432
439
  return session;
433
440
  }
434
441
 
442
+ /** Replace the thread's share links; the legacy single-share fields are dropped once shares[] is authoritative. */
443
+ sessionSetShares(id: string, shares: ShareLink[]): Thread {
444
+ const session = this.mutable(id);
445
+
446
+ session.shares = shares;
447
+ delete session.shareId;
448
+ delete session.access;
449
+ delete session.owner;
450
+ delete session.shareBranch;
451
+ this.store.upsert(session);
452
+ this.emit("session.updated", id);
453
+
454
+ return session;
455
+ }
456
+
435
457
  /** Set the private-share allowlist of GitHub logins; presence marks the share private. */
436
458
  sessionSetAccess(id: string, githubLogins: string[]): Thread {
437
459
  const session = this.mutable(id);
@@ -448,7 +470,7 @@ export class DaemonCore {
448
470
  const session = this.store.get(id);
449
471
 
450
472
  if (!this.store.delete(id)) throw new DaemonError("not_found", `no session ${id}`);
451
- if (session) this.unwatchIfDiffSession(session);
473
+ if (session) this.untrackLiveDiffSession(session);
452
474
  this.diffRefreshGenerations.delete(id);
453
475
  this.herdrTabs.delete(id);
454
476
  this.emit("inbox.changed", id);
@@ -612,7 +634,7 @@ export class DaemonCore {
612
634
  });
613
635
  this.store.upsert(session);
614
636
  // a resolved diff review is frozen; stop hot-reloading its working tree
615
- this.unwatchIfDiffSession(session);
637
+ this.untrackLiveDiffSession(session);
616
638
  const parked = this.waiters.get(id) ?? [];
617
639
 
618
640
  this.waiters.delete(id);
@@ -789,7 +811,7 @@ export class DaemonCore {
789
811
  if (source.participants)
790
812
  fork.participants = source.participants.map((identity) => ({ ...identity }));
791
813
  this.store.upsert(fork);
792
- this.watchIfDiffSession(fork);
814
+ this.trackLiveDiffSession(fork);
793
815
  this.emit("session.created", fork.id);
794
816
  this.emit("inbox.changed", fork.id);
795
817
 
@@ -801,6 +823,8 @@ export class DaemonCore {
801
823
  const session = this.mutable(id);
802
824
 
803
825
  if (session.artifact.type !== "diff") return { changed: false };
826
+ // a PR review has no local working tree; re-pull it from the PR instead of clobbering it
827
+ if (session.artifact.meta.pr !== undefined) return this.sessionRefreshPrDiff(id);
804
828
  const generation = (this.diffRefreshGenerations.get(id) ?? 0) + 1;
805
829
 
806
830
  this.diffRefreshGenerations.set(id, generation);
@@ -827,11 +851,50 @@ export class DaemonCore {
827
851
  return { changed: true };
828
852
  }
829
853
 
830
- /** Re-capture every live diff session sharing a repo root (one debounced fs change). */
854
+ /**
855
+ * Re-pull a PR review's diff when its head moved; broadcasts session.updated
856
+ * only when the patch text changed. A PR review has no local working tree, so
857
+ * it never runs workingTreeDiff - the diff comes from gh.
858
+ */
859
+ async sessionRefreshPrDiff(id: string): Promise<{ changed: boolean }> {
860
+ const session = this.store.get(id);
861
+
862
+ if (!session || session.status !== "pending" || session.artifact.type !== "diff")
863
+ return { changed: false };
864
+ const pr = session.artifact.meta.pr;
865
+
866
+ if (pr === undefined) return { changed: false };
867
+ const generation = (this.diffRefreshGenerations.get(id) ?? 0) + 1;
868
+
869
+ this.diffRefreshGenerations.set(id, generation);
870
+ const patch = await prDiff(pr);
871
+
872
+ // gh failed (offline, unauthenticated): keep the diff we have, try again next poll
873
+ if (patch === null) return { changed: false };
874
+ // the pull yields the event loop: discard a stale capture or a closed session
875
+ if (this.diffRefreshGenerations.get(id) !== generation) return { changed: false };
876
+ const current = this.store.get(id);
877
+
878
+ if (!current || current.status !== "pending" || current.artifact.type !== "diff")
879
+ return { changed: false };
880
+ if (patch === current.artifact.content) return { changed: false };
881
+ current.artifact = { ...current.artifact, content: patch };
882
+ const history = withHistory(current).history;
883
+
884
+ if (history) current.history = recaptureMainHead(history, patch);
885
+ this.store.upsert(current);
886
+ this.emit("session.updated", id);
887
+
888
+ return { changed: true };
889
+ }
890
+
891
+ /** Re-capture every working-tree diff session sharing a repo root (one debounced fs change). */
831
892
  private async refreshDiffsForRepo(repoRoot: string): Promise<void> {
832
893
  const live = this.store
833
894
  .list()
834
- .filter((session) => isLiveDiffSession(session) && session.workspace.repoRoot === repoRoot);
895
+ .filter(
896
+ (session) => isWorkingTreeDiffSession(session) && session.workspace.repoRoot === repoRoot,
897
+ );
835
898
 
836
899
  for (const session of live) {
837
900
  // a session resolved or deleted between the change and this tick just skips
@@ -843,14 +906,17 @@ export class DaemonCore {
843
906
  }
844
907
  }
845
908
 
846
- private watchIfDiffSession(session: Thread): void {
847
- if (isLiveDiffSession(session))
909
+ private trackLiveDiffSession(session: Thread): void {
910
+ if (isWorkingTreeDiffSession(session))
848
911
  this.diffWatcher.trackDiffRepo(session.workspace.repoRoot, session.id);
912
+ else if (isPrReviewSession(session))
913
+ this.prPoller.trackPr(session.id, session.artifact.meta.pr!);
849
914
  }
850
915
 
851
- private unwatchIfDiffSession(session: Thread): void {
852
- if (session.artifact.type === "diff")
853
- this.diffWatcher.untrackDiffRepo(session.workspace.repoRoot, session.id);
916
+ private untrackLiveDiffSession(session: Thread): void {
917
+ if (session.artifact.type !== "diff") return;
918
+ this.diffWatcher.untrackDiffRepo(session.workspace.repoRoot, session.id);
919
+ this.prPoller.untrackPr(session.id);
854
920
  }
855
921
 
856
922
  /**
@@ -975,9 +1041,22 @@ function forkedAnnotation(annotation: Annotation, source: Thread, fork: Thread):
975
1041
  : { ...annotation, resolution: { ...resolution, revision: inFork.revision } };
976
1042
  }
977
1043
 
978
- /** A pending diff session: the state that warrants hot-reload watching of its working tree. */
979
- function isLiveDiffSession(session: Thread): boolean {
980
- return session.status === "pending" && session.artifact.type === "diff";
1044
+ /** A pending diff of the local working tree: hot-reloads by watching its repo. A PR review is excluded. */
1045
+ function isWorkingTreeDiffSession(session: Thread): boolean {
1046
+ return (
1047
+ session.status === "pending" &&
1048
+ session.artifact.type === "diff" &&
1049
+ session.artifact.meta.pr === undefined
1050
+ );
1051
+ }
1052
+
1053
+ /** A pending review of a remote PR: hot-reloads by polling the PR head, never by watching a working tree. */
1054
+ function isPrReviewSession(session: Thread): boolean {
1055
+ return (
1056
+ session.status === "pending" &&
1057
+ session.artifact.type === "diff" &&
1058
+ session.artifact.meta.pr !== undefined
1059
+ );
981
1060
  }
982
1061
 
983
1062
  /** Convenience for adapters: map a resolved session to the agent contract. */
@@ -55,6 +55,7 @@ const PRIMITIVE_ROLES = {
55
55
  "session.fork": "owner",
56
56
  "session.refreshDiff": "owner",
57
57
  "session.setShareId": "owner",
58
+ "session.setShares": "owner",
58
59
  "session.delete": "owner",
59
60
  "session.mergeShared": "owner",
60
61
  "session.resolve": "owner",
package/src/client.ts CHANGED
@@ -11,6 +11,7 @@ import type {
11
11
  Artifact,
12
12
  DiffFileStatus,
13
13
  HunkRejection,
14
+ ShareLink,
14
15
  Thread,
15
16
  VerdictKind,
16
17
  WorkspaceKey,
@@ -117,6 +118,7 @@ export interface SessionClient {
117
118
  /** Copy the current path into a new session; returns the fork. */
118
119
  sessionFork(id: string): Promise<Thread>;
119
120
  sessionSetShareId(id: string, shareId: string): Promise<Thread>;
121
+ sessionSetShares(id: string, shares: ShareLink[]): Promise<Thread>;
120
122
  sessionMergeShared(id: string, incoming: SharedMerge): Promise<Thread>;
121
123
  sessionDelete(id: string): Promise<void>;
122
124
  /** Record the caller's own identity name (collaborator self-naming on a share). */
@@ -497,6 +499,10 @@ export class DaemonClient implements SessionClient {
497
499
  sessionSetShareId(id: string, shareId: string): Promise<Thread> {
498
500
  return this.request("session.setShareId", { id, shareId }, ThreadRecordSchema);
499
501
  }
502
+
503
+ sessionSetShares(id: string, shares: ShareLink[]): Promise<Thread> {
504
+ return this.request("session.setShares", { id, shares }, ThreadRecordSchema);
505
+ }
500
506
  sessionMergeShared(id: string, incoming: SharedMerge): Promise<Thread> {
501
507
  return this.request("session.mergeShared", { id, ...incoming }, ThreadRecordSchema);
502
508
  }
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
8
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
8
+ import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
9
9
  import { tmpdir } from "node:os";
10
10
  import { join } from "node:path";
11
11
  import { DaemonCore } from "./api";
@@ -164,6 +164,49 @@ describe("session.refreshDiff", () => {
164
164
  });
165
165
  });
166
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
+
167
210
  describe("the fs watcher drives hot-reload", () => {
168
211
  test("a working-tree change under a live diff session refreshes it in place", async () => {
169
212
  // Given a live diff session whose repo the daemon is watching
@@ -183,4 +226,47 @@ describe("the fs watcher drives hot-reload", () => {
183
226
  }
184
227
  expect(content).toContain("+export const a = 42;");
185
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);
186
272
  });
@@ -2,32 +2,101 @@
2
2
  * Watches a diff session's repository for working-tree changes and drives the
3
3
  * hot-reload: on any change under a repo root that a live diff session tracks,
4
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.
5
+ * One watcher per non-ignored directory (not a recursive watch), so churn under
6
+ * `.git`, `node_modules`, or a .gitignored path never registers a watch at all -
7
+ * that keeps idle cost near zero on Linux, where a recursive watch would open an
8
+ * inotify descriptor per directory. A debounce collapses a burst into one
9
+ * re-capture. Many diff sessions can share a repo.
7
10
  */
8
11
 
9
- import { watch, type FSWatcher } from "node:fs";
10
- import { sep } from "node:path";
12
+ import { watch, readdirSync, statSync, type Dirent, type FSWatcher } from "node:fs";
13
+ import { basename, isAbsolute, join } from "node:path";
11
14
 
12
15
  /** Debounce window: a save or a checkout writes many files in a burst; collapse them into one re-capture. */
13
16
  const DIFF_REFRESH_DEBOUNCE_MS = 300;
14
17
 
15
18
  /**
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.
19
+ * Directory names never worth a watch, whatever .gitignore says. `.git` is
20
+ * watched narrowly elsewhere (HEAD, refs, packed-refs) so its own read churn (the
21
+ * index, lock files) cannot loop; `node_modules` churns on installs and is never
22
+ * review content.
20
23
  */
21
- function isIgnoredWatchPath(relativePath: string): boolean {
22
- const segments = relativePath.split(sep);
24
+ const ALWAYS_IGNORED_DIRS = new Set([".git", "node_modules"]);
23
25
 
24
- return segments.includes(".git") || segments.includes("node_modules");
26
+ /**
27
+ * Absolute paths of the directories git ignores under a repo root, resolved once
28
+ * at watch time from `git ls-files` so a .gitignored tree (dist, build output)
29
+ * never gets a watcher. Empty when git is unavailable - the name check still
30
+ * skips the churny defaults.
31
+ */
32
+ function ignoredDirectories(repoRoot: string): Set<string> {
33
+ const ignored = new Set<string>();
34
+ const result = spawnGit(
35
+ ["ls-files", "--others", "--ignored", "--exclude-standard", "--directory"],
36
+ repoRoot,
37
+ );
38
+
39
+ if (!result || result.exitCode !== 0) return ignored;
40
+ for (const line of result.stdout.toString().split("\n")) {
41
+ const relative = line.trim().replace(/\/$/, "");
42
+
43
+ if (relative.length > 0) ignored.add(join(repoRoot, relative));
44
+ }
45
+
46
+ return ignored;
47
+ }
48
+
49
+ /** Whether git ignores a single path; used for a directory created after the initial walk. */
50
+ function isGitIgnored(repoRoot: string, path: string): boolean {
51
+ return spawnGit(["check-ignore", "-q", path], repoRoot)?.exitCode === 0;
52
+ }
53
+
54
+ /** Run a git command in `cwd`, or null when git is missing or the cwd is gone (spawn throws ENOENT). */
55
+ function spawnGit(args: string[], cwd: string): { exitCode: number; stdout: Buffer } | null {
56
+ try {
57
+ const result = Bun.spawnSync(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore" });
58
+
59
+ return { exitCode: result.exitCode ?? 1, stdout: result.stdout };
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ /** The git-metadata filenames whose change means `git diff HEAD` moved: a checkout, a commit, or a reset. */
66
+ function isRefChange(filename: string): boolean {
67
+ return filename === "HEAD" || filename === "packed-refs";
68
+ }
69
+
70
+ /**
71
+ * A repo's git dir (HEAD and the index live here) and common dir (refs and
72
+ * packed-refs live here). They differ inside a linked worktree. Null when the
73
+ * root is not a git repo or git is unavailable.
74
+ */
75
+ function resolveGitDirs(repoRoot: string): { gitDir: string; commonDir: string } | null {
76
+ const result = spawnGit(["rev-parse", "--absolute-git-dir", "--git-common-dir"], repoRoot);
77
+
78
+ if (!result || result.exitCode !== 0) return null;
79
+ const [gitDir, commonRaw] = result.stdout.toString().trim().split("\n");
80
+
81
+ if (!gitDir) return null;
82
+ const commonDir = commonRaw
83
+ ? isAbsolute(commonRaw)
84
+ ? commonRaw
85
+ : join(repoRoot, commonRaw)
86
+ : gitDir;
87
+
88
+ return { gitDir, commonDir };
25
89
  }
26
90
 
27
91
  interface RepoWatch {
28
- handle: FSWatcher;
92
+ /** One watcher per watched working-tree directory plus the narrow git-metadata watches; all close together. */
93
+ handles: FSWatcher[];
29
94
  /** Live diff session ids sharing this repo root; the watch closes when the last one leaves. */
30
95
  sessionIds: Set<string>;
96
+ /** Absolute paths git ignores under the root, so the walk skips them. */
97
+ ignored: Set<string>;
98
+ /** Directories already watched, so a runtime-created dir is not watched twice. */
99
+ watchedDirs: Set<string>;
31
100
  debounce: ReturnType<typeof setTimeout> | null;
32
101
  }
33
102
 
@@ -51,21 +120,124 @@ export class DiffWatcher {
51
120
 
52
121
  return;
53
122
  }
54
- let handle: FSWatcher;
123
+ const repoWatch: RepoWatch = {
124
+ handles: [],
125
+ sessionIds: new Set([sessionId]),
126
+ ignored: ignoredDirectories(repoRoot),
127
+ watchedDirs: new Set(),
128
+ debounce: null,
129
+ };
55
130
 
56
- try {
57
- handle = watch(repoRoot, { recursive: true }, (_event, filename) => {
58
- if (filename !== null && isIgnoredWatchPath(filename.toString())) return;
59
- this.scheduleRepoRefresh(repoRoot);
131
+ this.repoWatches.set(repoRoot, repoWatch);
132
+
133
+ // one watcher per non-ignored working-tree directory; a tracked-file change re-captures
134
+ if (!this.watchTree(repoRoot, repoRoot)) {
135
+ // repo root gone or not watchable on this platform: skip, no hot-reload here
136
+ this.repoWatches.delete(repoRoot);
137
+
138
+ return;
139
+ }
140
+
141
+ // git metadata: a commit, checkout, or reset moves `git diff HEAD` without
142
+ // touching any working-tree file, so those never reach the tree watch above.
143
+ const gitDirs = resolveGitDirs(repoRoot);
144
+
145
+ if (gitDirs) {
146
+ // HEAD and packed-refs sit beside the index; the index rewrites on our own
147
+ // reads, so only HEAD/packed-refs here - watching the index would loop
148
+ this.watchInto(repoWatch.handles, gitDirs.gitDir, { recursive: false }, (filename) => {
149
+ if (isRefChange(filename)) this.scheduleRepoRefresh(repoRoot);
60
150
  });
151
+ // a commit or reset updates refs/heads/<branch>; any ref move re-captures
152
+ this.watchInto(repoWatch.handles, join(gitDirs.commonDir, "refs"), { recursive: true }, () =>
153
+ this.scheduleRepoRefresh(repoRoot),
154
+ );
155
+ if (gitDirs.commonDir !== gitDirs.gitDir)
156
+ this.watchInto(repoWatch.handles, gitDirs.commonDir, { recursive: false }, (filename) => {
157
+ if (isRefChange(filename)) this.scheduleRepoRefresh(repoRoot);
158
+ });
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Watch `dir` (non-recursive) and every non-ignored directory under it, adding
164
+ * each to the repo's handles. Returns whether `dir` itself got a watcher, so
165
+ * the caller can tell a dead root from a merely-skipped subtree. A skipped
166
+ * (ignored or already-watched) directory counts as watched, not a failure.
167
+ */
168
+ private watchTree(repoRoot: string, dir: string): boolean {
169
+ const repoWatch = this.repoWatches.get(repoRoot);
170
+
171
+ if (!repoWatch) return false;
172
+ if (repoWatch.watchedDirs.has(dir)) return true;
173
+ if (ALWAYS_IGNORED_DIRS.has(basename(dir)) || repoWatch.ignored.has(dir)) return true;
174
+
175
+ const opened = this.watchInto(repoWatch.handles, dir, { recursive: false }, (filename) => {
176
+ this.scheduleRepoRefresh(repoRoot);
177
+ // a newly created subdirectory needs its own watch (a recursive watch got this for free)
178
+ this.extendWatch(repoRoot, join(dir, filename));
179
+ });
180
+
181
+ if (!opened) return false;
182
+ repoWatch.watchedDirs.add(dir);
183
+
184
+ let entries: Dirent[];
185
+
186
+ try {
187
+ entries = readdirSync(dir, { withFileTypes: true });
61
188
  } catch {
62
- // repo root gone or not watchable on this platform: skip, no hot-reload here
189
+ return true;
190
+ }
191
+ for (const entry of entries) {
192
+ // a symlinked directory is skipped so the walk cannot loop
193
+ if (entry.isDirectory() && !entry.isSymbolicLink())
194
+ this.watchTree(repoRoot, join(dir, entry.name));
195
+ }
196
+
197
+ return true;
198
+ }
199
+
200
+ /**
201
+ * After a change names a new path, watch it (and its subtree) when it is a
202
+ * directory git does not ignore. The point-in-time ignored set cannot know a
203
+ * directory created after the walk, so a fresh dir is checked against git live.
204
+ */
205
+ private extendWatch(repoRoot: string, path: string): void {
206
+ try {
207
+ if (!statSync(path).isDirectory()) return;
208
+ } catch {
209
+ // the path is already gone (a transient temp dir); nothing to watch
63
210
  return;
64
211
  }
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 });
212
+ if (ALWAYS_IGNORED_DIRS.has(basename(path))) return;
213
+ if (isGitIgnored(repoRoot, path)) return;
214
+ this.watchTree(repoRoot, path);
215
+ }
216
+
217
+ /**
218
+ * Open one watcher and add it to `handles`; returns whether it opened. A
219
+ * watcher error (the path is deleted mid-review) is swallowed - hot-reload is
220
+ * best-effort and must never take down the daemon. The inherited EventEmitter
221
+ * `on` is absent from Bun's FSWatcher type.
222
+ */
223
+ private watchInto(
224
+ handles: FSWatcher[],
225
+ path: string,
226
+ options: { recursive: boolean },
227
+ onFile: (filename: string) => void,
228
+ ): boolean {
229
+ try {
230
+ const handle = watch(path, options, (_event, filename) => {
231
+ if (filename !== null) onFile(filename.toString());
232
+ });
233
+
234
+ handle.on("error", () => {});
235
+ handles.push(handle);
236
+
237
+ return true;
238
+ } catch {
239
+ return false;
240
+ }
69
241
  }
70
242
 
71
243
  /** Drop one diff session; close the repo watch once no diff session tracks it. */
@@ -76,7 +248,7 @@ export class DiffWatcher {
76
248
  repoWatch.sessionIds.delete(sessionId);
77
249
  if (repoWatch.sessionIds.size > 0) return;
78
250
  if (repoWatch.debounce !== null) clearTimeout(repoWatch.debounce);
79
- repoWatch.handle.close();
251
+ for (const handle of repoWatch.handles) handle.close();
80
252
  this.repoWatches.delete(repoRoot);
81
253
  }
82
254
 
@@ -95,7 +267,7 @@ export class DiffWatcher {
95
267
  close(): void {
96
268
  for (const repoWatch of this.repoWatches.values()) {
97
269
  if (repoWatch.debounce !== null) clearTimeout(repoWatch.debounce);
98
- repoWatch.handle.close();
270
+ for (const handle of repoWatch.handles) handle.close();
99
271
  }
100
272
  this.repoWatches.clear();
101
273
  }
package/src/gh.ts ADDED
@@ -0,0 +1,37 @@
1
+ // gh CLI reads for PR reviews; auth is delegated to the user's gh. Reads only.
2
+
3
+ const ghBinary = (): string => process.env.CUELOOP_GH || "gh";
4
+
5
+ async function gh(args: string[]): Promise<{ code: number; stdout: string }> {
6
+ const process = Bun.spawn([ghBinary(), ...args], { stdout: "pipe", stderr: "ignore" });
7
+ const stdout = await new Response(process.stdout).text();
8
+
9
+ return { code: await process.exited, stdout };
10
+ }
11
+
12
+ /** The PR head commit sha, or null when gh is absent, unauthenticated, or the call fails. */
13
+ export async function prHeadSha(pr: string): Promise<string | null> {
14
+ const { code, stdout } = await gh([
15
+ "pr",
16
+ "view",
17
+ pr,
18
+ "--json",
19
+ "headRefOid",
20
+ "-q",
21
+ ".headRefOid",
22
+ ]);
23
+
24
+ if (code !== 0) return null;
25
+ const sha = stdout.trim();
26
+
27
+ return sha.length > 0 ? sha : null;
28
+ }
29
+
30
+ /** The PR unified diff as gh prints it, or null when the call fails. */
31
+ export async function prDiff(pr: string): Promise<string | null> {
32
+ const { code, stdout } = await gh(["pr", "diff", pr]);
33
+
34
+ if (code !== 0) return null;
35
+
36
+ return stdout;
37
+ }
@@ -0,0 +1,76 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { PrReviewPoller } from "./pr-poller";
3
+
4
+ /** Build a poller whose head sha is controllable, with the timer effectively disabled. */
5
+ function makePoller(initialSha: string | null) {
6
+ let sha = initialSha;
7
+ const advanced: string[] = [];
8
+ const poller = new PrReviewPoller(
9
+ (sessionId) => advanced.push(sessionId),
10
+ async () => sha,
11
+ 1_000_000,
12
+ );
13
+
14
+ return {
15
+ poller,
16
+ advanced,
17
+ setSha: (next: string | null) => {
18
+ sha = next;
19
+ },
20
+ };
21
+ }
22
+
23
+ describe("PrReviewPoller", () => {
24
+ test("the first observation is a silent baseline", async () => {
25
+ const { poller, advanced } = makePoller("sha1");
26
+
27
+ poller.trackPr("s1", "org/repo#1");
28
+ await poller.refreshHead("s1");
29
+
30
+ expect(advanced).toEqual([]);
31
+ poller.close();
32
+ });
33
+
34
+ test("a moved head fires once; an unchanged head stays quiet", async () => {
35
+ const { poller, advanced, setSha } = makePoller("sha1");
36
+
37
+ poller.trackPr("s1", "org/repo#1");
38
+ await poller.refreshHead("s1");
39
+
40
+ setSha("sha2");
41
+ await poller.refreshHead("s1");
42
+ await poller.refreshHead("s1");
43
+
44
+ expect(advanced).toEqual(["s1"]);
45
+ poller.close();
46
+ });
47
+
48
+ test("a failed head read never fires and keeps the last baseline", async () => {
49
+ const { poller, advanced, setSha } = makePoller("sha1");
50
+
51
+ poller.trackPr("s1", "org/repo#1");
52
+ await poller.refreshHead("s1");
53
+
54
+ setSha(null);
55
+ await poller.refreshHead("s1");
56
+ setSha("sha2");
57
+ await poller.refreshHead("s1");
58
+
59
+ expect(advanced).toEqual(["s1"]);
60
+ poller.close();
61
+ });
62
+
63
+ test("untrack stops further checks", async () => {
64
+ const { poller, advanced, setSha } = makePoller("sha1");
65
+
66
+ poller.trackPr("s1", "org/repo#1");
67
+ await poller.refreshHead("s1");
68
+ poller.untrackPr("s1");
69
+
70
+ setSha("sha2");
71
+ await poller.refreshHead("s1");
72
+
73
+ expect(advanced).toEqual([]);
74
+ poller.close();
75
+ });
76
+ });
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Polls each open PR review for new commits so the review hot-reloads when the
3
+ * PR head advances. A remote PR has no push signal, so this checks the head sha
4
+ * on an interval - one cheap gh call - and only asks the caller to re-pull the
5
+ * full diff when the head actually moved. Best-effort: a missing or failing gh
6
+ * leaves the last known head in place and never throws.
7
+ */
8
+
9
+ import { prHeadSha } from "./gh";
10
+
11
+ /** One cheap head-sha check per interval; the expensive full-diff pull happens only on a move. */
12
+ const PR_POLL_INTERVAL_MS = 30_000;
13
+
14
+ interface PrPoll {
15
+ pr: string;
16
+ /** Last head sha seen; null until the baseline check lands, so the first observation never fires. */
17
+ lastSha: string | null;
18
+ timer: ReturnType<typeof setInterval>;
19
+ }
20
+
21
+ export class PrReviewPoller {
22
+ private readonly polls = new Map<string, PrPoll>();
23
+
24
+ constructor(
25
+ private readonly onPrAdvance: (sessionId: string) => void,
26
+ private readonly headSha: (pr: string) => Promise<string | null> = prHeadSha,
27
+ private readonly intervalMs: number = PR_POLL_INTERVAL_MS,
28
+ ) {}
29
+
30
+ /** Start polling a PR review. Idempotent per session; the baseline check runs at once. */
31
+ trackPr(sessionId: string, pr: string): void {
32
+ if (this.polls.has(sessionId)) return;
33
+ const timer = setInterval(() => void this.refreshHead(sessionId), this.intervalMs);
34
+ // the poll timer must not by itself keep the daemon alive against idle-exit
35
+ timer.unref?.();
36
+ this.polls.set(sessionId, { pr, lastSha: null, timer });
37
+ void this.refreshHead(sessionId);
38
+ }
39
+
40
+ /** Check the PR head once; fire onPrAdvance only when it moved off a known baseline. */
41
+ async refreshHead(sessionId: string): Promise<void> {
42
+ const poll = this.polls.get(sessionId);
43
+
44
+ if (!poll) return;
45
+ const sha = await this.headSha(poll.pr).catch(() => null);
46
+
47
+ if (sha === null) return;
48
+ // the await yields; re-read so an untrack during the call is honoured
49
+ const current = this.polls.get(sessionId);
50
+
51
+ if (!current) return;
52
+ if (current.lastSha === null) {
53
+ current.lastSha = sha;
54
+
55
+ return;
56
+ }
57
+ if (sha === current.lastSha) return;
58
+ current.lastSha = sha;
59
+ this.onPrAdvance(sessionId);
60
+ }
61
+
62
+ untrackPr(sessionId: string): void {
63
+ const poll = this.polls.get(sessionId);
64
+
65
+ if (!poll) return;
66
+ clearInterval(poll.timer);
67
+ this.polls.delete(sessionId);
68
+ }
69
+
70
+ close(): void {
71
+ for (const poll of this.polls.values()) clearInterval(poll.timer);
72
+ this.polls.clear();
73
+ }
74
+ }
package/src/server.ts CHANGED
@@ -430,6 +430,11 @@ export class DaemonServer {
430
430
 
431
431
  return this.core.sessionSetShareId(params.id, params.shareId);
432
432
  },
433
+ "session.setShares": (_connection, request) => {
434
+ const params = parseParams("session.setShares", request.params);
435
+
436
+ return this.core.sessionSetShares(params.id, params.shares);
437
+ },
433
438
  "session.delete": (_connection, request) => {
434
439
  this.core.sessionDelete(parseParams("session.delete", request.params).id);
435
440
 
package/src/store.ts CHANGED
@@ -24,7 +24,7 @@ import {
24
24
  writeFileSync,
25
25
  } from "node:fs";
26
26
  import { dirname, join } from "node:path";
27
- import { historyFromLinear, type Thread } from "@cueloop/schema";
27
+ import { historyFromLinear, withShareLinks, type Thread } from "@cueloop/schema";
28
28
  import { migratedSessionsDir, sessionsDir, threadBucket, threadsDir } from "./paths";
29
29
  import { validateThreadRecord } from "./validate";
30
30
 
@@ -55,9 +55,12 @@ export interface ThreadRepository {
55
55
  * reading without a history - migration never loses a record.
56
56
  */
57
57
  export function withHistory(session: Thread): Thread {
58
- if (session.history || session.revisions.length === 0) return session;
58
+ // migrate a legacy single share into shares[] on the same read that fills history
59
+ const migrated = withShareLinks(session);
59
60
 
60
- return { ...session, history: historyFromLinear(session) };
61
+ if (migrated.history || migrated.revisions.length === 0) return migrated;
62
+
63
+ return { ...migrated, history: historyFromLinear(migrated) };
61
64
  }
62
65
 
63
66
  /** Records in the order `list()` promises: oldest first. */
@@ -279,6 +279,16 @@ describe("wire pins", () => {
279
279
  verdict: fullVerdict,
280
280
  status: "pending",
281
281
  createdAt: "now",
282
+ shares: [
283
+ {
284
+ id: "p_abc123xy",
285
+ name: "review link",
286
+ requireAuth: true,
287
+ allowlist: ["octocat"],
288
+ owner: "SHA256:owner",
289
+ shareBranch: "main",
290
+ },
291
+ ],
282
292
  shareId: "p_abc123xy",
283
293
  owner: "SHA256:owner",
284
294
  access: { githubLogins: ["octocat"] },
package/src/validate.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  type Revision,
26
26
  type SessionHistory,
27
27
  type ShareAccess,
28
+ type ShareLink,
28
29
  validateHistory,
29
30
  type Verdict,
30
31
  type WorkspaceKey,
@@ -131,6 +132,15 @@ export const IdentitySchema = v.object({
131
132
  handle: v.optional(v.string()),
132
133
  } satisfies EntriesOf<Identity>);
133
134
 
135
+ export const ShareLinkSchema = v.object({
136
+ id: NonEmpty,
137
+ name: v.optional(v.string()),
138
+ requireAuth: v.boolean(),
139
+ allowlist: v.array(NonEmpty),
140
+ owner: v.optional(v.string()),
141
+ shareBranch: v.optional(v.string()),
142
+ } satisfies EntriesOf<ShareLink>);
143
+
134
144
  export const Params = {
135
145
  "session.create": v.object({ workspace: WorkspaceSchema, artifact: ArtifactSchema }),
136
146
  "session.get": v.object({ id: SessionId }),
@@ -205,6 +215,7 @@ export const Params = {
205
215
  "session.fork": v.object({ id: SessionId }),
206
216
  "session.refreshDiff": v.object({ id: SessionId }),
207
217
  "session.setShareId": v.object({ id: SessionId, shareId: NonEmpty }),
218
+ "session.setShares": v.object({ id: SessionId, shares: v.array(ShareLinkSchema) }),
208
219
  "session.setAccess": v.object({ id: SessionId, githubLogins: v.array(NonEmpty) }),
209
220
  "session.delete": v.object({ id: SessionId }),
210
221
  "session.mergeShared": v.object({
@@ -348,6 +359,7 @@ export const ThreadRecordSchema = v.object({
348
359
  createdAt: v.string(),
349
360
  shelvedAnnotations: v.optional(v.array(FullAnnotationSchema)),
350
361
  parentSessionId: v.optional(v.string()),
362
+ shares: v.optional(v.array(ShareLinkSchema)),
351
363
  shareId: v.optional(v.string()),
352
364
  shareBranch: v.optional(v.string()),
353
365
  owner: v.optional(v.string()),