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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/package.json +34 -22
  2. package/src/api.test.ts +925 -65
  3. package/src/api.ts +1060 -88
  4. package/src/capabilities.test.ts +57 -0
  5. package/src/capabilities.ts +84 -0
  6. package/src/client.test.ts +24 -0
  7. package/src/client.ts +526 -70
  8. package/src/curate.ts +109 -0
  9. package/src/daemon-version.test.ts +93 -0
  10. package/src/diff-hot-reload.test.ts +272 -0
  11. package/src/diff-watcher.ts +274 -0
  12. package/src/gh.ts +37 -0
  13. package/src/harness-delivery.test.ts +193 -0
  14. package/src/harness-state-store.test.ts +65 -0
  15. package/src/harness-state-store.ts +182 -0
  16. package/src/herdr-pane.test.ts +240 -0
  17. package/src/herdr-pane.ts +167 -0
  18. package/src/herdr-split.test.ts +106 -0
  19. package/src/herdr-split.ts +92 -0
  20. package/src/herdr-tab-store.test.ts +96 -0
  21. package/src/herdr-tab-store.ts +66 -0
  22. package/src/index.ts +8 -9
  23. package/src/interruptible-wait.test.ts +127 -0
  24. package/src/interruptible-wait.ts +66 -0
  25. package/src/main.ts +4 -1
  26. package/src/paths.ts +44 -0
  27. package/src/pr-poller.test.ts +76 -0
  28. package/src/pr-poller.ts +74 -0
  29. package/src/project-files.test.ts +124 -0
  30. package/src/project-files.ts +56 -0
  31. package/src/protocol.ts +79 -7
  32. package/src/retention.test.ts +191 -0
  33. package/src/retention.ts +123 -0
  34. package/src/server.test.ts +430 -42
  35. package/src/server.ts +362 -84
  36. package/src/share-blob.test.ts +112 -0
  37. package/src/share-blob.ts +55 -0
  38. package/src/single-instance.test.ts +72 -11
  39. package/src/store.conformance.test.ts +41 -0
  40. package/src/store.jsonl.test.ts +159 -0
  41. package/src/store.ts +264 -34
  42. package/src/testing/store-conformance.ts +194 -0
  43. package/src/thread-review.test.ts +394 -0
  44. package/src/thread-review.ts +324 -0
  45. package/src/validate.test.ts +214 -35
  46. package/src/validate.ts +296 -25
  47. package/src/version.ts +6 -0
  48. package/src/working-tree.ts +149 -0
  49. package/src/review.test.ts +0 -167
  50. package/src/review.ts +0 -174
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Launches a bring-your-own review harness (claude code / pi / codex) in a herdr
3
+ * split beside the current pane - the Agent rail's "run it right here" spawn, so
4
+ * a reviewer chats with an agent about the plan without leaving the review tab.
5
+ * Best-effort and herdr-only: a no-op returning false outside herdr or on any
6
+ * spawn failure, each spawn bounded by a timeout.
7
+ */
8
+
9
+ import { detectHerdr, type HerdrEnv } from "@cueloop/schema";
10
+ import * as v from "valibot";
11
+
12
+ const HERDR_SPAWN_TIMEOUT_MS = 2000;
13
+ const SplitPaneSchema = v.object({
14
+ result: v.optional(v.object({ pane: v.optional(v.object({ pane_id: v.optional(v.string()) })) })),
15
+ });
16
+
17
+ /** How much of the current pane the launched harness split takes (herdr --ratio). */
18
+ const HARNESS_SPLIT_RATIO = "0.4";
19
+
20
+ export interface LaunchHarnessInSplitOptions {
21
+ /** The harness launch command run in the split, e.g. "cc", "pi", "codex". */
22
+ command: string;
23
+ /** Working directory for the split - the reviewed session's cwd. */
24
+ cwd: string;
25
+ /**
26
+ * A plan-context briefing typed into the split after the harness command, left
27
+ * unsubmitted for the reviewer to send. Best-effort: on a slow harness start it
28
+ * may reach the shell instead, so the launch never depends on it.
29
+ */
30
+ seedText?: string;
31
+ }
32
+
33
+ /**
34
+ * Split the current pane and run the harness in it. Returns true when the split
35
+ * opened, false on any best-effort bail-out. No-op outside herdr.
36
+ */
37
+ export function launchHarnessInSplit(
38
+ options: LaunchHarnessInSplitOptions,
39
+ env: HerdrEnv = process.env,
40
+ ): boolean {
41
+ const herdr = detectHerdr(env);
42
+
43
+ if (!herdr) return false;
44
+ try {
45
+ const split = Bun.spawnSync(
46
+ [
47
+ herdr.binPath,
48
+ "pane",
49
+ "split",
50
+ herdr.paneId,
51
+ "--direction",
52
+ "right",
53
+ "--ratio",
54
+ HARNESS_SPLIT_RATIO,
55
+ "--cwd",
56
+ options.cwd,
57
+ ],
58
+ { stdout: "pipe", stderr: "ignore", timeout: HERDR_SPAWN_TIMEOUT_MS },
59
+ );
60
+
61
+ if (split.exitCode !== 0) return false;
62
+ const parsed = v.safeParse(SplitPaneSchema, JSON.parse(split.stdout.toString()));
63
+
64
+ if (!parsed.success) return false;
65
+ const paneId = parsed.output.result?.pane?.pane_id;
66
+
67
+ if (!paneId) return false;
68
+ sendText(herdr.binPath, paneId, options.command);
69
+ sendKeys(herdr.binPath, paneId, "enter");
70
+ if (options.seedText) sendText(herdr.binPath, paneId, options.seedText);
71
+
72
+ return true;
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+
78
+ function sendText(binPath: string, paneId: string, text: string): void {
79
+ Bun.spawnSync([binPath, "pane", "send-text", paneId, text], {
80
+ stdout: "ignore",
81
+ stderr: "ignore",
82
+ timeout: HERDR_SPAWN_TIMEOUT_MS,
83
+ });
84
+ }
85
+
86
+ function sendKeys(binPath: string, paneId: string, keys: string): void {
87
+ Bun.spawnSync([binPath, "pane", "send-keys", paneId, keys], {
88
+ stdout: "ignore",
89
+ stderr: "ignore",
90
+ timeout: HERDR_SPAWN_TIMEOUT_MS,
91
+ });
92
+ }
@@ -0,0 +1,96 @@
1
+ /** herdr tab side-store: set/get/delete round-trips, survives a reload from disk, and ignores a corrupt file. */
2
+
3
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { HerdrTabStore } from "./herdr-tab-store";
8
+ import { herdrTabsPath } from "./paths";
9
+
10
+ let home: string;
11
+
12
+ beforeEach(() => {
13
+ home = mkdtempSync(join(tmpdir(), "cueloop-herdr-store-"));
14
+ });
15
+ afterEach(() => {
16
+ rmSync(home, { recursive: true, force: true });
17
+ });
18
+
19
+ describe("HerdrTabStore", () => {
20
+ test("set then get returns the handle; get for an unknown session is null", () => {
21
+ // Arrange
22
+ const store = new HerdrTabStore(home);
23
+
24
+ // Act
25
+ store.set("ses_1", { tabId: "w1:t2", paneId: "w1:p2" });
26
+
27
+ // Assert
28
+ expect(store.get("ses_1")).toEqual({ tabId: "w1:t2", paneId: "w1:p2" });
29
+ expect(store.get("ses_missing")).toBeNull();
30
+ });
31
+
32
+ test("handles survive a reload from disk", () => {
33
+ // Arrange
34
+ new HerdrTabStore(home).set("ses_1", { tabId: "w1:t2", paneId: "w1:p2" });
35
+
36
+ // Act - a fresh store over the same home loads the persisted file
37
+ const reloaded = new HerdrTabStore(home);
38
+
39
+ // Assert
40
+ expect(reloaded.get("ses_1")).toEqual({ tabId: "w1:t2", paneId: "w1:p2" });
41
+ });
42
+
43
+ test("delete removes the handle", () => {
44
+ // Arrange
45
+ const store = new HerdrTabStore(home);
46
+
47
+ store.set("ses_1", { tabId: "w1:t2", paneId: "w1:p2" });
48
+
49
+ // Act
50
+ store.delete("ses_1");
51
+
52
+ // Assert
53
+ expect(store.get("ses_1")).toBeNull();
54
+ });
55
+
56
+ test("a corrupt file starts empty instead of throwing", () => {
57
+ // Arrange
58
+ writeFileSync(herdrTabsPath(home), "not json");
59
+
60
+ // Act
61
+ const store = new HerdrTabStore(home);
62
+
63
+ // Assert
64
+ expect(store.get("ses_1")).toBeNull();
65
+ });
66
+
67
+ test("malformed entries are dropped, well-formed ones survive", () => {
68
+ // Arrange - valid JSON, but entries with the wrong shape
69
+ writeFileSync(
70
+ herdrTabsPath(home),
71
+ JSON.stringify({
72
+ ses_ok: { tabId: "w1:t2", paneId: "w1:p2" },
73
+ ses_partial: { tabId: "w1:t2" },
74
+ ses_typed: { tabId: 1, paneId: 2 },
75
+ ses_null: null,
76
+ }),
77
+ );
78
+
79
+ // Act
80
+ const store = new HerdrTabStore(home);
81
+
82
+ // Assert
83
+ expect(store.get("ses_ok")).toEqual({ tabId: "w1:t2", paneId: "w1:p2" });
84
+ expect(store.get("ses_partial")).toBeNull();
85
+ expect(store.get("ses_typed")).toBeNull();
86
+ expect(store.get("ses_null")).toBeNull();
87
+ });
88
+
89
+ test("a non-object top level starts empty", () => {
90
+ // Arrange
91
+ writeFileSync(herdrTabsPath(home), JSON.stringify(["not", "a", "map"]));
92
+
93
+ // Act & Assert
94
+ expect(new HerdrTabStore(home).get("ses_1")).toBeNull();
95
+ });
96
+ });
@@ -0,0 +1,66 @@
1
+ /**
2
+ * herdr adapter scratch, kept out of the core session record: the tab cueloop
3
+ * opened to render each review, keyed by session id. One JSON map, atomic write.
4
+ * The core `Thread` never mentions herdr; this side-store is the whole
5
+ * leak boundary.
6
+ */
7
+
8
+ import { readFileSync, renameSync, writeFileSync } from "node:fs";
9
+ import * as v from "valibot";
10
+ import { herdrTabsPath } from "./paths";
11
+
12
+ /** The tab and its root pane: pane id checks liveness, tab id focuses it. */
13
+ export interface HerdrTabHandle {
14
+ tabId: string;
15
+ paneId: string;
16
+ }
17
+
18
+ const HerdrTabHandleSchema = v.object({
19
+ tabId: v.string(),
20
+ paneId: v.string(),
21
+ });
22
+ const HerdrTabsSchema = v.record(v.string(), v.unknown());
23
+
24
+ export class HerdrTabStore {
25
+ private tabs = new Map<string, HerdrTabHandle>();
26
+ private readonly path: string;
27
+
28
+ constructor(home: string) {
29
+ this.path = herdrTabsPath(home);
30
+ this.load();
31
+ }
32
+
33
+ private load(): void {
34
+ try {
35
+ const parsed = v.parse(HerdrTabsSchema, JSON.parse(readFileSync(this.path, "utf8")));
36
+
37
+ for (const [sessionId, value] of Object.entries(parsed)) {
38
+ const handle = v.safeParse(HerdrTabHandleSchema, value);
39
+
40
+ if (handle.success) this.tabs.set(sessionId, handle.output);
41
+ }
42
+ } catch {
43
+ // absent, unreadable, or malformed: start empty - a stale tab just reopens on resubmit
44
+ }
45
+ }
46
+
47
+ private persist(): void {
48
+ const tempPath = this.path + ".tmp";
49
+
50
+ writeFileSync(tempPath, JSON.stringify(Object.fromEntries(this.tabs), null, 2));
51
+ renameSync(tempPath, this.path);
52
+ }
53
+
54
+ get(sessionId: string): HerdrTabHandle | null {
55
+ return this.tabs.get(sessionId) ?? null;
56
+ }
57
+
58
+ set(sessionId: string, handle: HerdrTabHandle): void {
59
+ this.tabs.set(sessionId, handle);
60
+ this.persist();
61
+ }
62
+
63
+ delete(sessionId: string): void {
64
+ if (this.tabs.delete(sessionId)) this.persist();
65
+ }
66
+ }
package/src/index.ts CHANGED
@@ -1,9 +1,8 @@
1
- export * from "./api";
2
- export * from "./review";
3
- export * from "./errors";
4
- export * from "./validate";
5
- export * from "./store";
6
- export * from "./server";
7
- export * from "./protocol";
8
- export * from "./paths";
9
- export { DaemonClient, DaemonClientError, type ConnectOptions } from "./client";
1
+ /**
2
+ * The daemon's public surface. Consumers reach the client and the review
3
+ * helpers through the ./client and ./thread-review subpaths; this barrel carries
4
+ * only the two names imported bare: the server and the home-dir resolver.
5
+ */
6
+
7
+ export { DaemonServer, type DaemonOptions } from "./server";
8
+ export { cueloopHome } from "./paths";
@@ -0,0 +1,127 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { ABORTED, pollUntilResolved, raceAbort } from "./interruptible-wait";
3
+
4
+ function deferred<T>() {
5
+ let resolve!: (value: T) => void;
6
+ let reject!: (cause: unknown) => void;
7
+ const promise = new Promise<T>((res, rej) => {
8
+ resolve = res;
9
+ reject = rej;
10
+ });
11
+
12
+ return { promise, resolve, reject };
13
+ }
14
+
15
+ describe("raceAbort", () => {
16
+ test("without a signal the promise passes straight through", async () => {
17
+ expect(await raceAbort(Promise.resolve("value"), undefined)).toBe("value");
18
+ });
19
+
20
+ test("resolves to the value when the promise settles before any abort", async () => {
21
+ const controller = new AbortController();
22
+
23
+ expect(await raceAbort(Promise.resolve(7), controller.signal)).toBe(7);
24
+ });
25
+
26
+ test("an already-aborted signal returns ABORTED without waiting", async () => {
27
+ const controller = new AbortController();
28
+
29
+ controller.abort();
30
+
31
+ expect(await raceAbort(new Promise<never>(() => {}), controller.signal)).toBe(ABORTED);
32
+ });
33
+
34
+ test("a signal that fires during the wait returns ABORTED", async () => {
35
+ const controller = new AbortController();
36
+ const pending = deferred<number>();
37
+ const raced = raceAbort(pending.promise, controller.signal);
38
+
39
+ controller.abort();
40
+
41
+ expect(await raced).toBe(ABORTED);
42
+ });
43
+
44
+ test("a rejection propagates the original error unchanged", async () => {
45
+ const controller = new AbortController();
46
+ const original = new Error("daemon socket closed");
47
+ let caught: unknown;
48
+
49
+ try {
50
+ await raceAbort(Promise.reject(original), controller.signal);
51
+ } catch (error) {
52
+ caught = error;
53
+ }
54
+
55
+ expect(caught).toBe(original);
56
+ });
57
+
58
+ test("a rejection that lands after an abort is swallowed, not surfaced", async () => {
59
+ const controller = new AbortController();
60
+ const pending = deferred<number>();
61
+ const raced = raceAbort(pending.promise, controller.signal);
62
+
63
+ controller.abort();
64
+ const result = await raced;
65
+
66
+ pending.reject(new Error("late daemon error"));
67
+ await Bun.sleep(10);
68
+
69
+ expect(result).toBe(ABORTED);
70
+ });
71
+ });
72
+
73
+ describe("pollUntilResolved", () => {
74
+ test("returns the first non-null attempt", async () => {
75
+ expect(await pollUntilResolved(() => Promise.resolve("done"), undefined)).toBe("done");
76
+ });
77
+
78
+ test("keeps polling past null attempts until one resolves", async () => {
79
+ let calls = 0;
80
+ const attempt = () => {
81
+ calls++;
82
+
83
+ return Promise.resolve(calls < 3 ? null : "third");
84
+ };
85
+
86
+ expect(await pollUntilResolved(attempt, undefined)).toBe("third");
87
+ expect(calls).toBe(3);
88
+ });
89
+
90
+ test("an already-aborted signal returns null before any attempt", async () => {
91
+ const controller = new AbortController();
92
+ let calls = 0;
93
+
94
+ controller.abort();
95
+ const result = await pollUntilResolved(() => {
96
+ calls++;
97
+
98
+ return Promise.resolve("value");
99
+ }, controller.signal);
100
+
101
+ expect(result).toBeNull();
102
+ expect(calls).toBe(0);
103
+ });
104
+
105
+ test("a signal that fires during a chunk returns null", async () => {
106
+ const controller = new AbortController();
107
+ const pending = deferred<string | null>();
108
+ const polling = pollUntilResolved(() => pending.promise, controller.signal);
109
+
110
+ controller.abort();
111
+
112
+ expect(await polling).toBeNull();
113
+ });
114
+
115
+ test("a rejected attempt propagates the original error", async () => {
116
+ const original = new Error("attempt failed");
117
+ let caught: unknown;
118
+
119
+ try {
120
+ await pollUntilResolved(() => Promise.reject(original), undefined);
121
+ } catch (error) {
122
+ caught = error;
123
+ }
124
+
125
+ expect(caught).toBe(original);
126
+ });
127
+ });
@@ -0,0 +1,66 @@
1
+ import { Cause, Effect, Exit } from "effect";
2
+
3
+ export const ABORTED: unique symbol = Symbol("aborted");
4
+
5
+ const whenSignalAborts = (signal: AbortSignal): Effect.Effect<void> =>
6
+ Effect.callback<void>((resume) => {
7
+ if (signal.aborted) {
8
+ resume(Effect.void);
9
+
10
+ return;
11
+ }
12
+ const onAbort = () => resume(Effect.void);
13
+
14
+ signal.addEventListener("abort", onAbort, { once: true });
15
+
16
+ return Effect.sync(() => signal.removeEventListener("abort", onAbort));
17
+ });
18
+
19
+ const fromSettledPromise = <T>(start: () => Promise<T>): Effect.Effect<T, unknown> =>
20
+ Effect.callback<T, unknown>((resume) => {
21
+ start().then(
22
+ (value) => resume(Effect.succeed(value)),
23
+ (cause: unknown) => resume(Effect.fail(cause)),
24
+ );
25
+ });
26
+
27
+ async function runToPromise<T>(effect: Effect.Effect<T, unknown>): Promise<T> {
28
+ const exit = await Effect.runPromiseExit(effect);
29
+
30
+ if (Exit.isSuccess(exit)) return exit.value;
31
+
32
+ throw Cause.squash(exit.cause);
33
+ }
34
+
35
+ export function raceAbort<T>(
36
+ promise: Promise<T>,
37
+ signal: AbortSignal | undefined,
38
+ ): Promise<T | typeof ABORTED> {
39
+ if (!signal) return promise;
40
+ if (signal.aborted) {
41
+ promise.catch(() => {});
42
+
43
+ return Promise.resolve(ABORTED);
44
+ }
45
+
46
+ return runToPromise(
47
+ Effect.raceFirst(
48
+ fromSettledPromise(() => promise),
49
+ whenSignalAborts(signal).pipe(Effect.as<T | typeof ABORTED>(ABORTED)),
50
+ ),
51
+ );
52
+ }
53
+
54
+ export function pollUntilResolved<T>(
55
+ attempt: () => Promise<T | null>,
56
+ signal: AbortSignal | undefined,
57
+ ): Promise<T | null> {
58
+ if (signal?.aborted) return Promise.resolve(null);
59
+ const loop = Effect.repeat(fromSettledPromise(attempt), {
60
+ until: (result): result is T => result !== null,
61
+ });
62
+
63
+ if (!signal) return runToPromise(loop);
64
+
65
+ return runToPromise(Effect.raceFirst(loop, whenSignalAborts(signal).pipe(Effect.as(null))));
66
+ }
package/src/main.ts CHANGED
@@ -2,9 +2,12 @@
2
2
 
3
3
  import { DaemonServer } from "./server";
4
4
 
5
- const idleExitMs = process.env.CUELOOP_IDLE_EXIT_MS ? Number(process.env.CUELOOP_IDLE_EXIT_MS) : undefined;
5
+ const idleExitMs = process.env.CUELOOP_IDLE_EXIT_MS
6
+ ? Number(process.env.CUELOOP_IDLE_EXIT_MS)
7
+ : undefined;
6
8
  const server = new DaemonServer({ idleExitMs });
7
9
  const path = server.start();
10
+
8
11
  if (path === null) {
9
12
  // another daemon already owns this home; the client will attach to it
10
13
  console.log("cueloop daemon already running for this home");
package/src/paths.ts CHANGED
@@ -18,6 +18,35 @@ export function sessionsDir(home = cueloopHome()): string {
18
18
  return join(home, "sessions");
19
19
  }
20
20
 
21
+ /** Thread files live here, one JSONL per thread, under a per-project bucket. */
22
+ export function threadsDir(home = cueloopHome()): string {
23
+ return join(home, "threads");
24
+ }
25
+
26
+ /** A git commit is lowercase hex; anything else is not a real root commit. */
27
+ const COMMIT_SHA = /^[0-9a-f]{7,64}$/;
28
+
29
+ /**
30
+ * The per-project bucket for a workspace: its durable root-commit SHA, or a shared standalone bucket.
31
+ * A value that is not a commit SHA (including path-traversal segments an IPC caller could smuggle in)
32
+ * is refused to the standalone bucket, so a bucket name can never escape the threads directory.
33
+ */
34
+ export function threadBucket(rootCommit: string | undefined, home = cueloopHome()): string {
35
+ const bucket =
36
+ rootCommit && COMMIT_SHA.test(rootCommit) ? rootCommit.slice(0, 12) : "_standalone";
37
+
38
+ return join(threadsDir(home), bucket);
39
+ }
40
+
41
+ /** Where the one-time migration parks the old whole-record JSON files, so a downgrade can still read them. */
42
+ export function migratedSessionsDir(home = cueloopHome()): string {
43
+ return join(home, "sessions.migrated");
44
+ }
45
+
46
+ export function reportsDir(home = cueloopHome()): string {
47
+ return join(home, "reports");
48
+ }
49
+
21
50
  export function pidPath(home = cueloopHome()): string {
22
51
  return join(home, "cueloop.pid");
23
52
  }
@@ -25,3 +54,18 @@ export function pidPath(home = cueloopHome()): string {
25
54
  export function lockPath(home = cueloopHome()): string {
26
55
  return join(home, "cueloop.lock");
27
56
  }
57
+
58
+ /** The secret a connection presents to be the owner; written by the daemon, mode 0600. */
59
+ export function ownerTokenPath(home = cueloopHome()): string {
60
+ return join(home, "owner.token");
61
+ }
62
+
63
+ /** Adapter scratch: herdr tab handles keyed by session id, kept out of the core session record. */
64
+ export function herdrTabsPath(home = cueloopHome()): string {
65
+ return join(home, "herdr-tabs.json");
66
+ }
67
+
68
+ /** Harness bindings and Message deliveries, stored independently of Thread records. */
69
+ export function harnessStatePath(home = cueloopHome()): string {
70
+ return join(home, "harness-state.json");
71
+ }
@@ -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
+ }