@cueloop/daemon 0.1.0-alpha.84 → 0.1.0-alpha.86

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.84",
3
+ "version": "0.1.0-alpha.86",
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": {
@@ -38,7 +38,7 @@
38
38
  "access": "public"
39
39
  },
40
40
  "dependencies": {
41
- "@cueloop/schema": "0.1.0-alpha.84",
41
+ "@cueloop/schema": "0.1.0-alpha.86",
42
42
  "@pierre/diffs": "^1.4.1",
43
43
  "effect": "^4.0.0-rc.115",
44
44
  "smol-toml": "^1.8.0",
package/src/api.ts CHANGED
@@ -65,7 +65,7 @@ import {
65
65
  import { HarnessStateStore } from "./harness-state-store";
66
66
  import { DiffWatcher } from "./diff-watcher";
67
67
  import { PrReviewPoller } from "./pr-poller";
68
- import { prDiff } from "./gh";
68
+ import { prSnapshot } from "./gh";
69
69
  import { workingTreeDiff, workingChangeList, type WorkingTreeDiff } from "./working-tree";
70
70
  import { listProjectFiles, readProjectFile } from "./project-files";
71
71
  import { resolveWorkspace } from "./thread-review";
@@ -90,6 +90,7 @@ function annotationDeliveryFingerprint(annotation: Annotation | undefined): stri
90
90
  target: annotation.target,
91
91
  body: annotation.body,
92
92
  author: annotation.author,
93
+ reviewComment: annotation.reviewComment,
93
94
  replyTo: annotation.replyTo,
94
95
  });
95
96
  }
@@ -158,7 +159,9 @@ export class DaemonCore {
158
159
  this.ghosttyThreadSurfaces.delete(threadId);
159
160
  }
160
161
  this.diffWatcher = new DiffWatcher((repoRoot) => void this.refreshDiffsForRepo(repoRoot));
161
- this.prPoller = new PrReviewPoller((sessionId) => void this.sessionRefreshPrDiff(sessionId));
162
+ this.prPoller = new PrReviewPoller((sessionId, refs) =>
163
+ this.markPrRefreshAvailable(sessionId, refs),
164
+ );
162
165
  // resume hot-reload for diff sessions that survived a daemon restart
163
166
  for (const session of this.store.list()) {
164
167
  this.trackLiveDiffSession(session);
@@ -1073,31 +1076,72 @@ export class DaemonCore {
1073
1076
 
1074
1077
  if (!session || session.status !== "pending" || session.artifact.type !== "diff")
1075
1078
  return { changed: false };
1076
- const pr = session.artifact.meta.pr;
1079
+ const pr = session.artifact.meta.prUrl ?? session.artifact.meta.pr;
1077
1080
 
1078
1081
  if (pr === undefined) return { changed: false };
1079
1082
  const generation = (this.diffRefreshGenerations.get(id) ?? 0) + 1;
1080
1083
 
1081
1084
  this.diffRefreshGenerations.set(id, generation);
1082
- const patch = await prDiff(pr);
1085
+ const snapshot = await prSnapshot(pr);
1083
1086
 
1084
1087
  // gh failed (offline, unauthenticated): keep the diff we have, try again next poll
1085
- if (patch === null) return { changed: false };
1088
+ if (snapshot === null) return { changed: false };
1086
1089
  // the pull yields the event loop: discard a stale capture or a closed session
1087
1090
  if (this.diffRefreshGenerations.get(id) !== generation) return { changed: false };
1088
1091
  const current = this.store.get(id);
1089
1092
 
1090
1093
  if (!current || current.status !== "pending" || current.artifact.type !== "diff")
1091
1094
  return { changed: false };
1092
- if (patch === current.artifact.content) return { changed: false };
1093
- current.artifact = { ...current.artifact, content: patch };
1095
+ const contentChanged = snapshot.patch !== current.artifact.content;
1096
+
1097
+ if (
1098
+ !contentChanged &&
1099
+ snapshot.baseSha === current.artifact.meta.prBaseSha &&
1100
+ snapshot.headSha === current.artifact.meta.prHeadSha &&
1101
+ current.artifact.meta.prRefreshBaseSha === undefined &&
1102
+ current.artifact.meta.prRefreshHeadSha === undefined
1103
+ )
1104
+ return { changed: false };
1105
+ current.artifact = {
1106
+ ...current.artifact,
1107
+ content: snapshot.patch,
1108
+ meta: {
1109
+ ...current.artifact.meta,
1110
+ prBaseSha: snapshot.baseSha,
1111
+ prHeadSha: snapshot.headSha,
1112
+ prRefreshBaseSha: undefined,
1113
+ prRefreshHeadSha: undefined,
1114
+ },
1115
+ };
1094
1116
  const history = withHistory(current).history;
1095
1117
 
1096
- if (history) current.history = recaptureMainHead(history, patch);
1118
+ if (history && contentChanged) current.history = recaptureMainHead(history, snapshot.patch);
1097
1119
  this.store.upsert(current);
1098
1120
  this.emit("session.updated", id);
1099
1121
 
1100
- return { changed: true };
1122
+ return { changed: contentChanged };
1123
+ }
1124
+
1125
+ /** Record moved PR refs without replacing the review until the reviewer refreshes it. */
1126
+ private markPrRefreshAvailable(id: string, refs: { baseSha: string; headSha: string }): void {
1127
+ const session = this.store.get(id);
1128
+
1129
+ if (!session || session.status !== "pending" || session.artifact.meta.pr === undefined) return;
1130
+ if (
1131
+ session.artifact.meta.prBaseSha === refs.baseSha &&
1132
+ session.artifact.meta.prHeadSha === refs.headSha
1133
+ )
1134
+ return;
1135
+ session.artifact = {
1136
+ ...session.artifact,
1137
+ meta: {
1138
+ ...session.artifact.meta,
1139
+ prRefreshBaseSha: refs.baseSha,
1140
+ prRefreshHeadSha: refs.headSha,
1141
+ },
1142
+ };
1143
+ this.store.upsert(session);
1144
+ this.emit("session.updated", id);
1101
1145
  }
1102
1146
 
1103
1147
  /** Re-capture every working-tree diff session sharing a repo root (one debounced fs change). */
@@ -1122,7 +1166,16 @@ export class DaemonCore {
1122
1166
  if (isWorkingTreeDiffSession(session))
1123
1167
  this.diffWatcher.trackDiffRepo(session.workspace.repoRoot, session.id);
1124
1168
  else if (isPrReviewSession(session))
1125
- this.prPoller.trackPr(session.id, session.artifact.meta.pr!);
1169
+ this.prPoller.trackPr(
1170
+ session.id,
1171
+ session.artifact.meta.prUrl ?? session.artifact.meta.pr!,
1172
+ session.artifact.meta.prBaseSha && session.artifact.meta.prHeadSha
1173
+ ? {
1174
+ baseSha: session.artifact.meta.prBaseSha,
1175
+ headSha: session.artifact.meta.prHeadSha,
1176
+ }
1177
+ : null,
1178
+ );
1126
1179
  }
1127
1180
 
1128
1181
  private untrackLiveDiffSession(session: Thread): void {
package/src/client.ts CHANGED
@@ -142,6 +142,8 @@ export interface ThreadClient {
142
142
  repoChanges?(cwd: string): Promise<{ path: string; status: DiffFileStatus }[]>;
143
143
  /** The live working-tree diff (patch plus per-file contents) at `cwd`. */
144
144
  repoDiff?(cwd: string): Promise<WorkingTreeDiff>;
145
+ /** Refresh a local diff or explicitly pull a moved PR head. */
146
+ sessionRefreshDiff?(id: string): Promise<{ changed: boolean }>;
145
147
  /** Find-or-create the per-repo workbench thread for `cwd`, so a bare launch's first comment persists. */
146
148
  sessionWorkbench?(cwd: string): Promise<Thread>;
147
149
  /** Move a branch's tip (the current one, or `branch` after switching to it) back to an entry on its path; a summary records the abandoned segment. */
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
8
- import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
8
+ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, 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";
@@ -168,10 +168,17 @@ describe("PR review sessions", () => {
168
168
  test("refresh re-pulls the PR diff via gh and never clobbers it with the working tree", async () => {
169
169
  // Given a stub gh that reports a PR diff distinct from the local working tree
170
170
  const ghStub = join(repo, "gh-stub.sh");
171
+ const ghLog = join(repo, "gh-stub.log");
171
172
 
172
173
  writeFileSync(
173
174
  ghStub,
174
- '#!/bin/sh\ncase "$2" in\n diff) printf "STUB PR DIFF\\n" ;;\n view) printf "sha-2\\n" ;;\nesac\n',
175
+ `#!/bin/sh
176
+ printf '%s\n' "$*" >> '${ghLog}'
177
+ case "$2" in
178
+ diff) printf "STUB PR DIFF\\n" ;;
179
+ view) printf '{"baseRefOid":"base-2","headRefOid":"sha-2"}\\n' ;;
180
+ esac
181
+ `,
175
182
  );
176
183
  chmodSync(ghStub, 0o755);
177
184
  const previousGh = process.env.CUELOOP_GH;
@@ -185,7 +192,14 @@ describe("PR review sessions", () => {
185
192
  type: "diff",
186
193
  content: "OLD PR DIFF\n",
187
194
  files: [],
188
- meta: { pr: "org/repo#1" },
195
+ meta: {
196
+ pr: "1",
197
+ prUrl: "https://github.com/org/repo/pull/1",
198
+ prBaseSha: "base-1",
199
+ prHeadSha: "sha-1",
200
+ prRefreshBaseSha: "base-2",
201
+ prRefreshHeadSha: "sha-2",
202
+ },
189
203
  };
190
204
  const session = core.sessionCreate({ workspace, artifact });
191
205
 
@@ -200,6 +214,11 @@ describe("PR review sessions", () => {
200
214
 
201
215
  expect(refreshed.artifact.content).toContain("STUB PR DIFF");
202
216
  expect(refreshed.artifact.content).not.toContain("export const a = 123;");
217
+ expect(refreshed.artifact.meta.prBaseSha).toBe("base-2");
218
+ expect(refreshed.artifact.meta.prHeadSha).toBe("sha-2");
219
+ expect(refreshed.artifact.meta.prRefreshBaseSha).toBeUndefined();
220
+ expect(refreshed.artifact.meta.prRefreshHeadSha).toBeUndefined();
221
+ expect(readFileSync(ghLog, "utf8")).toContain("https://github.com/org/repo/pull/1");
203
222
  } finally {
204
223
  if (previousGh === undefined) delete process.env.CUELOOP_GH;
205
224
  else process.env.CUELOOP_GH = previousGh;
package/src/gh.test.ts ADDED
@@ -0,0 +1,59 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { prSnapshot } from "./gh";
3
+
4
+ describe("prSnapshot", () => {
5
+ test("retries until the diff and head are one stable snapshot", async () => {
6
+ const refs = [
7
+ { baseSha: "base-1", headSha: "head-1" },
8
+ { baseSha: "base-1", headSha: "head-2" },
9
+ { baseSha: "base-1", headSha: "head-2" },
10
+ { baseSha: "base-1", headSha: "head-2" },
11
+ ];
12
+ const patches = ["patch-1", "patch-2"];
13
+
14
+ const snapshot = await prSnapshot(
15
+ "https://github.com/org/repo/pull/1",
16
+ 3,
17
+ async () => refs.shift() ?? null,
18
+ async () => patches.shift() ?? null,
19
+ );
20
+
21
+ expect(snapshot).toEqual({ patch: "patch-2", baseSha: "base-1", headSha: "head-2" });
22
+ });
23
+
24
+ test("returns no snapshot when the head keeps moving", async () => {
25
+ let head = 0;
26
+
27
+ const snapshot = await prSnapshot(
28
+ "https://github.com/org/repo/pull/1",
29
+ 2,
30
+ async () => ({ baseSha: "base", headSha: `head-${head++}` }),
31
+ async () => "patch",
32
+ );
33
+
34
+ expect(snapshot).toBeNull();
35
+ });
36
+
37
+ test("retries when only the base moves", async () => {
38
+ const refs = [
39
+ { baseSha: "base-1", headSha: "head" },
40
+ { baseSha: "base-2", headSha: "head" },
41
+ { baseSha: "base-2", headSha: "head" },
42
+ { baseSha: "base-2", headSha: "head" },
43
+ ];
44
+ const patches = ["old-base-patch", "new-base-patch"];
45
+
46
+ const snapshot = await prSnapshot(
47
+ "https://github.com/org/repo/pull/1",
48
+ 3,
49
+ async () => refs.shift() ?? null,
50
+ async () => patches.shift() ?? null,
51
+ );
52
+
53
+ expect(snapshot).toEqual({
54
+ patch: "new-base-patch",
55
+ baseSha: "base-2",
56
+ headSha: "head",
57
+ });
58
+ });
59
+ });
package/src/gh.ts CHANGED
@@ -1,5 +1,17 @@
1
1
  // gh CLI reads for PR reviews; auth is delegated to the user's gh. Reads only.
2
2
 
3
+ import * as v from "valibot";
4
+
5
+ export interface PullRequestRefs {
6
+ baseSha: string;
7
+ headSha: string;
8
+ }
9
+
10
+ const PullRequestRefsSchema = v.object({
11
+ baseRefOid: v.pipe(v.string(), v.nonEmpty()),
12
+ headRefOid: v.pipe(v.string(), v.nonEmpty()),
13
+ });
14
+
3
15
  const ghBinary = (): string => process.env.CUELOOP_GH || "gh";
4
16
 
5
17
  async function gh(args: string[]): Promise<{ code: number; stdout: string }> {
@@ -9,22 +21,23 @@ async function gh(args: string[]): Promise<{ code: number; stdout: string }> {
9
21
  return { code: await process.exited, stdout };
10
22
  }
11
23
 
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
- ]);
24
+ /** The PR base and head commits, or null when gh is absent, unauthenticated, or the call fails. */
25
+ export async function prRefs(pr: string): Promise<PullRequestRefs | null> {
26
+ const { code, stdout } = await gh(["pr", "view", pr, "--json", "baseRefOid,headRefOid"]);
23
27
 
24
28
  if (code !== 0) return null;
25
- const sha = stdout.trim();
29
+ let input: unknown;
30
+
31
+ try {
32
+ input = JSON.parse(stdout);
33
+ } catch {
34
+ return null;
35
+ }
36
+ const parsed = v.safeParse(PullRequestRefsSchema, input);
37
+
38
+ if (!parsed.success) return null;
26
39
 
27
- return sha.length > 0 ? sha : null;
40
+ return { baseSha: parsed.output.baseRefOid, headSha: parsed.output.headRefOid };
28
41
  }
29
42
 
30
43
  /** The PR unified diff as gh prints it, or null when the call fails. */
@@ -35,3 +48,27 @@ export async function prDiff(pr: string): Promise<string | null> {
35
48
 
36
49
  return stdout;
37
50
  }
51
+
52
+ /** A stable PR diff and the exact base/head pair it represents. */
53
+ export async function prSnapshot(
54
+ pr: string,
55
+ attempts = 3,
56
+ readRefs: (reference: string) => Promise<PullRequestRefs | null> = prRefs,
57
+ readDiff: (reference: string) => Promise<string | null> = prDiff,
58
+ ): Promise<{ patch: string; baseSha: string; headSha: string } | null> {
59
+ for (let attempt = 0; attempt < attempts; attempt++) {
60
+ const before = await readRefs(pr);
61
+
62
+ if (before === null) return null;
63
+ const patch = await readDiff(pr);
64
+
65
+ if (patch === null) return null;
66
+ const after = await readRefs(pr);
67
+
68
+ if (after === null) return null;
69
+ if (before.baseSha === after.baseSha && before.headSha === after.headSha)
70
+ return { patch, ...after };
71
+ }
72
+
73
+ return null;
74
+ }
@@ -2,27 +2,31 @@ import { describe, expect, test } from "bun:test";
2
2
  import { PrReviewPoller } from "./pr-poller";
3
3
 
4
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[] = [];
5
+ function refs(baseSha: string, headSha: string) {
6
+ return { baseSha, headSha };
7
+ }
8
+
9
+ function makePoller(initialRefs: ReturnType<typeof refs> | null) {
10
+ let currentRefs = initialRefs;
11
+ const advanced: Array<{ sessionId: string; refs: ReturnType<typeof refs> }> = [];
8
12
  const poller = new PrReviewPoller(
9
- (sessionId) => advanced.push(sessionId),
10
- async () => sha,
13
+ (sessionId, nextRefs) => advanced.push({ sessionId, refs: nextRefs }),
14
+ async () => currentRefs,
11
15
  1_000_000,
12
16
  );
13
17
 
14
18
  return {
15
19
  poller,
16
20
  advanced,
17
- setSha: (next: string | null) => {
18
- sha = next;
21
+ setRefs: (next: ReturnType<typeof refs> | null) => {
22
+ currentRefs = next;
19
23
  },
20
24
  };
21
25
  }
22
26
 
23
27
  describe("PrReviewPoller", () => {
24
28
  test("the first observation is a silent baseline", async () => {
25
- const { poller, advanced } = makePoller("sha1");
29
+ const { poller, advanced } = makePoller(refs("base1", "sha1"));
26
30
 
27
31
  poller.trackPr("s1", "org/repo#1");
28
32
  await poller.refreshHead("s1");
@@ -32,45 +36,67 @@ describe("PrReviewPoller", () => {
32
36
  });
33
37
 
34
38
  test("a moved head fires once; an unchanged head stays quiet", async () => {
35
- const { poller, advanced, setSha } = makePoller("sha1");
39
+ const { poller, advanced, setRefs } = makePoller(refs("base1", "sha1"));
36
40
 
37
41
  poller.trackPr("s1", "org/repo#1");
38
42
  await poller.refreshHead("s1");
39
43
 
40
- setSha("sha2");
44
+ setRefs(refs("base1", "sha2"));
41
45
  await poller.refreshHead("s1");
42
46
  await poller.refreshHead("s1");
43
47
 
44
- expect(advanced).toEqual(["s1"]);
48
+ expect(advanced).toEqual([{ sessionId: "s1", refs: refs("base1", "sha2") }]);
49
+ poller.close();
50
+ });
51
+
52
+ test("a persisted reviewed head exposes a move on the first check after restart", async () => {
53
+ const { poller, advanced } = makePoller(refs("base1", "sha2"));
54
+
55
+ poller.trackPr("s1", "https://github.com/org/repo/pull/1", refs("base1", "sha1"));
56
+ await poller.refreshHead("s1");
57
+
58
+ expect(advanced).toEqual([{ sessionId: "s1", refs: refs("base1", "sha2") }]);
45
59
  poller.close();
46
60
  });
47
61
 
48
62
  test("a failed head read never fires and keeps the last baseline", async () => {
49
- const { poller, advanced, setSha } = makePoller("sha1");
63
+ const { poller, advanced, setRefs } = makePoller(refs("base1", "sha1"));
50
64
 
51
65
  poller.trackPr("s1", "org/repo#1");
52
66
  await poller.refreshHead("s1");
53
67
 
54
- setSha(null);
68
+ setRefs(null);
55
69
  await poller.refreshHead("s1");
56
- setSha("sha2");
70
+ setRefs(refs("base1", "sha2"));
57
71
  await poller.refreshHead("s1");
58
72
 
59
- expect(advanced).toEqual(["s1"]);
73
+ expect(advanced).toEqual([{ sessionId: "s1", refs: refs("base1", "sha2") }]);
60
74
  poller.close();
61
75
  });
62
76
 
63
77
  test("untrack stops further checks", async () => {
64
- const { poller, advanced, setSha } = makePoller("sha1");
78
+ const { poller, advanced, setRefs } = makePoller(refs("base1", "sha1"));
65
79
 
66
80
  poller.trackPr("s1", "org/repo#1");
67
81
  await poller.refreshHead("s1");
68
82
  poller.untrackPr("s1");
69
83
 
70
- setSha("sha2");
84
+ setRefs(refs("base1", "sha2"));
71
85
  await poller.refreshHead("s1");
72
86
 
73
87
  expect(advanced).toEqual([]);
74
88
  poller.close();
75
89
  });
90
+
91
+ test("a moved base fires while the head stays unchanged", async () => {
92
+ const { poller, advanced, setRefs } = makePoller(refs("base1", "sha1"));
93
+
94
+ poller.trackPr("s1", "org/repo#1");
95
+ await poller.refreshHead("s1");
96
+ setRefs(refs("base2", "sha1"));
97
+ await poller.refreshHead("s1");
98
+
99
+ expect(advanced).toEqual([{ sessionId: "s1", refs: refs("base2", "sha1") }]);
100
+ poller.close();
101
+ });
76
102
  });
package/src/pr-poller.ts CHANGED
@@ -1,20 +1,18 @@
1
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.
2
+ * Polls each open PR review for new commits. A remote PR has no push signal, so
3
+ * this checks its base/head pair on an interval and only exposes refresh when
4
+ * either side moved. Best-effort: a missing or failing gh keeps the last refs.
7
5
  */
8
6
 
9
- import { prHeadSha } from "./gh";
7
+ import { prRefs, type PullRequestRefs } from "./gh";
10
8
 
11
- /** One cheap head-sha check per interval; the expensive full-diff pull happens only on a move. */
9
+ /** One cheap refs check per interval; the expensive full-diff pull happens only on a move. */
12
10
  const PR_POLL_INTERVAL_MS = 30_000;
13
11
 
14
12
  interface PrPoll {
15
13
  pr: string;
16
- /** Last head sha seen; null until the baseline check lands, so the first observation never fires. */
17
- lastSha: string | null;
14
+ /** Last refs seen; null until the baseline check lands, so the first observation never fires. */
15
+ lastRefs: PullRequestRefs | null;
18
16
  timer: ReturnType<typeof setInterval>;
19
17
  }
20
18
 
@@ -22,41 +20,42 @@ export class PrReviewPoller {
22
20
  private readonly polls = new Map<string, PrPoll>();
23
21
 
24
22
  constructor(
25
- private readonly onPrAdvance: (sessionId: string) => void,
26
- private readonly headSha: (pr: string) => Promise<string | null> = prHeadSha,
23
+ private readonly onPrAdvance: (sessionId: string, refs: PullRequestRefs) => void,
24
+ private readonly refs: (pr: string) => Promise<PullRequestRefs | null> = prRefs,
27
25
  private readonly intervalMs: number = PR_POLL_INTERVAL_MS,
28
26
  ) {}
29
27
 
30
- /** Start polling a PR review. Idempotent per session; the baseline check runs at once. */
31
- trackPr(sessionId: string, pr: string): void {
28
+ /** Start polling a PR review. Idempotent per session; the persisted reviewed head is the baseline. */
29
+ trackPr(sessionId: string, pr: string, reviewedRefs: PullRequestRefs | null = null): void {
32
30
  if (this.polls.has(sessionId)) return;
33
31
  const timer = setInterval(() => void this.refreshHead(sessionId), this.intervalMs);
34
32
  // the poll timer must not by itself keep the daemon alive against idle-exit
35
33
  timer.unref?.();
36
- this.polls.set(sessionId, { pr, lastSha: null, timer });
34
+ this.polls.set(sessionId, { pr, lastRefs: reviewedRefs, timer });
37
35
  void this.refreshHead(sessionId);
38
36
  }
39
37
 
40
- /** Check the PR head once; fire onPrAdvance only when it moved off a known baseline. */
38
+ /** Check the PR refs once; fire onPrAdvance only when they moved off a known baseline. */
41
39
  async refreshHead(sessionId: string): Promise<void> {
42
40
  const poll = this.polls.get(sessionId);
43
41
 
44
42
  if (!poll) return;
45
- const sha = await this.headSha(poll.pr).catch(() => null);
43
+ const refs = await this.refs(poll.pr).catch(() => null);
46
44
 
47
- if (sha === null) return;
45
+ if (refs === null) return;
48
46
  // the await yields; re-read so an untrack during the call is honoured
49
47
  const current = this.polls.get(sessionId);
50
48
 
51
49
  if (!current) return;
52
- if (current.lastSha === null) {
53
- current.lastSha = sha;
50
+ if (current.lastRefs === null) {
51
+ current.lastRefs = refs;
54
52
 
55
53
  return;
56
54
  }
57
- if (sha === current.lastSha) return;
58
- current.lastSha = sha;
59
- this.onPrAdvance(sessionId);
55
+ if (refs.baseSha === current.lastRefs.baseSha && refs.headSha === current.lastRefs.headSha)
56
+ return;
57
+ current.lastRefs = refs;
58
+ this.onPrAdvance(sessionId, refs);
60
59
  }
61
60
 
62
61
  untrackPr(sessionId: string): void {
@@ -78,6 +78,10 @@ export interface OpenReviewOptions {
78
78
  planPath?: string;
79
79
  prototypePath?: string;
80
80
  pr?: string;
81
+ prBrief?: string;
82
+ prBaseSha?: string;
83
+ prHeadSha?: string;
84
+ prUrl?: string;
81
85
  herdrPane?: string;
82
86
  /**
83
87
  * Full file contents per changed file for a working-tree diff, carried onto
@@ -297,6 +301,10 @@ export async function openReview(
297
301
  planPath: options.planPath,
298
302
  prototypePath: options.prototypePath,
299
303
  pr: options.pr,
304
+ prBrief: options.prBrief,
305
+ prBaseSha: options.prBaseSha,
306
+ prHeadSha: options.prHeadSha,
307
+ prUrl: options.prUrl,
300
308
  herdrPane: options.herdrPane,
301
309
  title:
302
310
  options.title ??
@@ -210,6 +210,12 @@ describe("wire pins", () => {
210
210
  planPath: "/repo/plan.md",
211
211
  prototypePath: "/repo/proto.html",
212
212
  pr: "org/repo#1",
213
+ prBrief: "# PR",
214
+ prBaseSha: "base",
215
+ prHeadSha: "head",
216
+ prRefreshBaseSha: "next-base",
217
+ prRefreshHeadSha: "next",
218
+ prUrl: "https://github.com/org/repo/pull/1",
213
219
  herdrPane: "%7",
214
220
  title: "Plan",
215
221
  workbench: false,
@@ -233,6 +239,17 @@ describe("wire pins", () => {
233
239
  body: "b",
234
240
  orphan: false,
235
241
  author: "SHA256:abc",
242
+ reviewComment: {
243
+ severity: "p1",
244
+ title: "Finding",
245
+ path: "src/x.ts",
246
+ line: 1,
247
+ startLine: 1,
248
+ startAnchor: fullAnchor,
249
+ side: "RIGHT",
250
+ suggestion: "replacement",
251
+ prompt: "fix it",
252
+ },
236
253
  replyTo: "a0",
237
254
  resolution: { revision: 2, source: "agent" },
238
255
  createdAt: "now",
package/src/validate.ts CHANGED
@@ -29,6 +29,8 @@ import {
29
29
  type Delivery,
30
30
  type PendingDelivery,
31
31
  type Revision,
32
+ REVIEW_SEVERITIES,
33
+ type ReviewComment,
32
34
  type SessionHistory,
33
35
  type ShareAccess,
34
36
  type ShareLink,
@@ -85,6 +87,12 @@ export const ArtifactMetaSchema = v.object({
85
87
  planPath: v.optional(v.string()),
86
88
  prototypePath: v.optional(v.string()),
87
89
  pr: v.optional(v.string()),
90
+ prBrief: v.optional(v.string()),
91
+ prBaseSha: v.optional(NonEmpty),
92
+ prHeadSha: v.optional(NonEmpty),
93
+ prRefreshBaseSha: v.optional(NonEmpty),
94
+ prRefreshHeadSha: v.optional(NonEmpty),
95
+ prUrl: v.optional(NonEmpty),
88
96
  herdrPane: v.optional(v.string()),
89
97
  title: v.optional(v.string()),
90
98
  workbench: v.optional(v.boolean()),
@@ -135,6 +143,19 @@ export const AnnotationSchema = v.object({
135
143
  body: v.string(),
136
144
  orphan: v.optional(v.boolean()),
137
145
  author: v.optional(v.string()),
146
+ reviewComment: v.optional(
147
+ v.object({
148
+ severity: v.picklist(REVIEW_SEVERITIES),
149
+ title: NonEmpty,
150
+ path: NonEmpty,
151
+ line: v.pipe(v.number(), v.integer(), v.minValue(1)),
152
+ startLine: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1))),
153
+ startAnchor: v.optional(AnchorSchema),
154
+ side: v.picklist(["LEFT", "RIGHT"]),
155
+ suggestion: v.optional(v.string()),
156
+ prompt: v.optional(v.string()),
157
+ } satisfies EntriesOf<ReviewComment>),
158
+ ),
138
159
  replyTo: v.optional(NonEmpty),
139
160
  resolution: v.optional(
140
161
  v.object({