@cueloop/daemon 0.1.0-alpha.89 → 0.1.0-alpha.90

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.89",
3
+ "version": "0.1.0-alpha.90",
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
  "./thread-review": "./src/thread-review.ts",
24
+ "./vcs-source": "./src/vcs-source.ts",
24
25
  "./api": "./src/api.ts",
25
26
  "./working-tree": "./src/working-tree.ts",
26
27
  "./store": "./src/store.ts",
@@ -38,7 +39,8 @@
38
39
  "access": "public"
39
40
  },
40
41
  "dependencies": {
41
- "@cueloop/schema": "0.1.0-alpha.89",
42
+ "@cueloop/extension-api": "0.1.0-alpha.90",
43
+ "@cueloop/schema": "0.1.0-alpha.90",
42
44
  "@pierre/diffs": "^1.4.1",
43
45
  "effect": "^4.0.0-rc.116",
44
46
  "smol-toml": "^1.8.0",
package/src/api.ts CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  type Annotation,
37
37
  type DiffFileStatus,
38
38
  type DiffFileContents,
39
+ type DiffSource,
39
40
  type HunkRejection,
40
41
  type NewEntry,
41
42
  type Artifact,
@@ -66,9 +67,10 @@ import { HarnessStateStore } from "./harness-state-store";
66
67
  import { DiffWatcher } from "./diff-watcher";
67
68
  import { PrReviewPoller } from "./pr-poller";
68
69
  import { prSnapshot } from "./gh";
69
- import { workingTreeDiff, workingChangeList, type WorkingTreeDiff } from "./working-tree";
70
+ import type { VcsDiffSnapshot } from "@cueloop/extension-api";
70
71
  import { listProjectFiles, readProjectFile } from "./project-files";
71
72
  import { resolveWorkspace } from "./thread-review";
73
+ import { VcsSourceManager } from "./vcs-source";
72
74
  import { DaemonError } from "./errors";
73
75
 
74
76
  /** Reviewer-controlled fields that make an annotation new or edited for delivery. */
@@ -132,6 +134,7 @@ export class DaemonCore {
132
134
  private readonly diffWatcher: DiffWatcher;
133
135
  /** Drives PR-review hot-reload: polls each open PR review for a moved head. */
134
136
  private readonly prPoller: PrReviewPoller;
137
+ private readonly vcsSources = new VcsSourceManager();
135
138
  /**
136
139
  * Per-diff-session capture generation. Bumped when a refresh begins; a
137
140
  * capture whose generation is stale by the time it finishes discards its
@@ -307,7 +310,27 @@ export class DaemonCore {
307
310
  id: this.newSessionId(now),
308
311
  workspace: params.workspace,
309
312
  artifact: params.artifact,
310
- revisions: [{ revision: 1, content: params.artifact.content, submittedAt: now }],
313
+ revisions: [
314
+ {
315
+ revision: 1,
316
+ content: params.artifact.content,
317
+ submittedAt: now,
318
+ files: params.artifact.type === "diff" ? params.artifact.files : undefined,
319
+ source: params.artifact.meta.prHeadSha
320
+ ? {
321
+ vcs: "git",
322
+ revisionId: params.artifact.meta.prHeadSha,
323
+ baseRevisionId: params.artifact.meta.prBaseSha,
324
+ }
325
+ : params.artifact.meta.vcsRevisionId && params.artifact.meta.vcs
326
+ ? {
327
+ vcs: params.artifact.meta.vcs,
328
+ changeId: params.artifact.meta.vcsChangeId,
329
+ revisionId: params.artifact.meta.vcsRevisionId,
330
+ }
331
+ : undefined,
332
+ },
333
+ ],
311
334
  annotations: [],
312
335
  message: null,
313
336
  status: "pending",
@@ -547,7 +570,12 @@ export class DaemonCore {
547
570
 
548
571
  /** Tracked, repo-relative file paths for the session's workspace; [] when it has no repo. */
549
572
  projectFiles(id: string): Promise<string[]> {
550
- return listProjectFiles(this.sessionGet(id).workspace.repoRoot);
573
+ const session = this.sessionGet(id);
574
+
575
+ return this.vcsSources
576
+ .select(session.workspace.repoRoot, session.artifact.meta.vcs ?? "git")
577
+ .then(({ adapter, repoRoot }) => adapter.listFiles(repoRoot))
578
+ .catch(() => listProjectFiles(session.workspace.repoRoot));
551
579
  }
552
580
 
553
581
  /** UTF-8 contents of a repo-relative file in the session's workspace, or null when it cannot be read safely. */
@@ -557,7 +585,9 @@ export class DaemonCore {
557
585
 
558
586
  /** Tracked, repo-relative file paths for the git repo containing `cwd`; [] when it is not a repo. Owner-only. */
559
587
  async repoFiles(cwd: string): Promise<string[]> {
560
- return listProjectFiles((await resolveWorkspace(cwd)).repoRoot);
588
+ const { adapter, repoRoot } = await this.vcsSources.select(cwd);
589
+
590
+ return adapter.listFiles(repoRoot);
561
591
  }
562
592
 
563
593
  /** UTF-8 contents of a repo-relative file in the repo containing `cwd`, or null when unreadable. Owner-only. */
@@ -567,7 +597,9 @@ export class DaemonCore {
567
597
 
568
598
  /** Changed files (repo-relative path plus git status) in the working tree at `cwd`. Owner-only. */
569
599
  repoChanges(cwd: string): Promise<{ path: string; status: DiffFileStatus }[]> {
570
- return workingChangeList(cwd);
600
+ return this.vcsSources
601
+ .select(cwd)
602
+ .then(({ adapter, repoRoot }) => adapter.listChanges(repoRoot));
571
603
  }
572
604
 
573
605
  /**
@@ -575,8 +607,8 @@ export class DaemonCore {
575
607
  * the same unified patch plus full per-file contents a `cueloop diff` captures,
576
608
  * so the Changes navigator renders a real diff for any thread. Owner-only.
577
609
  */
578
- repoDiff(cwd: string): Promise<WorkingTreeDiff> {
579
- return workingTreeDiff(cwd);
610
+ repoDiff(cwd: string, vcs?: string): Promise<VcsDiffSnapshot & { vcs: string }> {
611
+ return this.vcsSources.capture(cwd, vcs);
580
612
  }
581
613
 
582
614
  /**
@@ -586,8 +618,10 @@ export class DaemonCore {
586
618
  * first comment without an agent submission. Owner-only.
587
619
  */
588
620
  async workbenchSession(cwd: string): Promise<Thread> {
621
+ const selected = await this.vcsSources.select(cwd);
589
622
  const workspace = await resolveWorkspace(cwd);
590
- const key = workspace.rootCommit ?? "_standalone";
623
+ workspace.repoRoot = selected.repoRoot;
624
+ const key = `${workspace.rootCommit ?? selected.repoRoot}:${selected.adapter.id}`;
591
625
  // an open workbench is reused; a resolved one is immutable and would reject the note, so a fresh
592
626
  // one is created past it
593
627
  const open = this.store
@@ -596,7 +630,9 @@ export class DaemonCore {
596
630
  (session) =>
597
631
  session.artifact.meta.workbench === true &&
598
632
  session.status !== "resolved" &&
599
- (session.workspace.rootCommit ?? "_standalone") === key,
633
+ (session.workspace.rootCommit ?? session.workspace.repoRoot) ===
634
+ (workspace.rootCommit ?? selected.repoRoot) &&
635
+ (session.artifact.meta.vcs ?? "git") === selected.adapter.id,
600
636
  );
601
637
 
602
638
  if (open) return open;
@@ -604,14 +640,21 @@ export class DaemonCore {
604
640
  const inFlight = this.workbenchCreation.get(key);
605
641
 
606
642
  if (inFlight) return inFlight;
607
- const creation = workingTreeDiff(cwd).then((diff) =>
643
+ const creation = this.vcsSources.capture(cwd, selected.adapter.id).then((diff) =>
608
644
  this.sessionCreate({
609
645
  workspace,
610
646
  artifact: {
611
647
  type: "diff",
612
648
  content: diff.patch,
613
649
  files: diff.files,
614
- meta: { workbench: true, title: "Workbench", cwd },
650
+ meta: {
651
+ workbench: true,
652
+ title: "Workbench",
653
+ cwd,
654
+ vcs: diff.vcs,
655
+ vcsChangeId: diff.source?.changeId,
656
+ vcsRevisionId: diff.source?.revisionId,
657
+ },
615
658
  },
616
659
  }),
617
660
  );
@@ -883,13 +926,21 @@ export class DaemonCore {
883
926
  content: string,
884
927
  addressedAnnotationIds: string[] = [],
885
928
  files?: DiffFileContents[],
929
+ source?: DiffSource,
886
930
  ): Thread {
887
931
  const session = this.sessionGet(id);
888
932
  const now = new Date().toISOString();
889
933
  const revisionNumber = session.revisions.length + 1;
890
934
 
891
- session.revisions.push({ revision: revisionNumber, content, submittedAt: now });
935
+ session.revisions.push({
936
+ revision: revisionNumber,
937
+ content,
938
+ submittedAt: now,
939
+ source,
940
+ files: session.artifact.type === "diff" ? (files ?? session.artifact.files) : undefined,
941
+ });
892
942
  session.artifact = { ...session.artifact, content, files: files ?? session.artifact.files };
943
+ applyRevisionSource(session.artifact, source);
893
944
  // the agent's revision lands on main wherever its tip sits; the artifact
894
945
  // shows the head of the branch the reviewer is on
895
946
  const entryId = this.recordOnMain(session, {
@@ -1053,7 +1104,18 @@ export class DaemonCore {
1053
1104
  const generation = (this.diffRefreshGenerations.get(id) ?? 0) + 1;
1054
1105
 
1055
1106
  this.diffRefreshGenerations.set(id, generation);
1056
- const diff = await workingTreeDiff(session.workspace.repoRoot);
1107
+ const pinnedChange =
1108
+ session.artifact.meta.workbench !== true ? session.artifact.meta.vcsChangeId : undefined;
1109
+ const diff = pinnedChange
1110
+ ? await this.vcsSources.captureChange(
1111
+ session.workspace.repoRoot,
1112
+ session.artifact.meta.vcs ?? "git",
1113
+ pinnedChange,
1114
+ )
1115
+ : await this.vcsSources.capture(
1116
+ session.workspace.repoRoot,
1117
+ session.artifact.meta.vcs ?? "git",
1118
+ );
1057
1119
 
1058
1120
  // The capture yields the event loop: a newer refresh may have started, or a
1059
1121
  // concurrent resolve/delete may have closed the session. Discard a stale
@@ -1064,16 +1126,78 @@ export class DaemonCore {
1064
1126
 
1065
1127
  if (!current || current.status !== "pending" || current.artifact.type !== "diff")
1066
1128
  return { changed: false };
1067
- if (diff.patch === current.artifact.content) return { changed: false };
1068
- current.artifact = { ...current.artifact, content: diff.patch, files: diff.files };
1069
- // the history keeps showing the same revision, re-captured, so a later tree move derives this patch
1070
- const history = withHistory(current).history;
1129
+ const contentChanged = diff.patch !== current.artifact.content;
1130
+ const sourceChanged = diff.source?.revisionId !== current.artifact.meta.vcsRevisionId;
1071
1131
 
1072
- if (history) current.history = recaptureMainHead(history, diff.patch);
1132
+ if (!contentChanged && !sourceChanged) return { changed: false };
1133
+ current.artifact = {
1134
+ ...current.artifact,
1135
+ content: diff.patch,
1136
+ files: diff.files,
1137
+ meta: {
1138
+ ...current.artifact.meta,
1139
+ vcs: diff.vcs,
1140
+ vcsChangeId: diff.source?.changeId,
1141
+ vcsRevisionId: diff.source?.revisionId,
1142
+ },
1143
+ };
1144
+ this.recordCapturedDiffRevision(
1145
+ current,
1146
+ diff,
1147
+ Boolean(pinnedChange),
1148
+ contentChanged || sourceChanged,
1149
+ );
1073
1150
  this.store.upsert(current);
1074
1151
  this.emit("session.updated", id);
1075
1152
 
1076
- return { changed: true };
1153
+ return { changed: contentChanged };
1154
+ }
1155
+
1156
+ /** Live captures replace their revision; a submitted JJ change gets a new review revision. */
1157
+ private recordCapturedDiffRevision(
1158
+ session: Thread,
1159
+ diff: VcsDiffSnapshot & { vcs: string },
1160
+ pinned: boolean,
1161
+ changed: boolean,
1162
+ ): void {
1163
+ if (pinned && changed) {
1164
+ const now = new Date().toISOString();
1165
+
1166
+ session.revisions.push({
1167
+ revision: session.revisions.length + 1,
1168
+ content: diff.patch,
1169
+ submittedAt: now,
1170
+ files: diff.files,
1171
+ source: {
1172
+ vcs: diff.vcs,
1173
+ changeId: diff.source?.changeId,
1174
+ revisionId: diff.source?.revisionId,
1175
+ },
1176
+ });
1177
+ this.recordOnMain(session, {
1178
+ type: "revision",
1179
+ by: "agent",
1180
+ content: diff.patch,
1181
+ createdAt: now,
1182
+ });
1183
+
1184
+ return;
1185
+ }
1186
+ if (changed) {
1187
+ const history = withHistory(session).history;
1188
+
1189
+ if (history) session.history = recaptureMainHead(history, diff.patch);
1190
+ }
1191
+ if (pinned) return;
1192
+ const liveRevision = session.revisions.at(-1);
1193
+
1194
+ if (liveRevision) {
1195
+ liveRevision.content = diff.patch;
1196
+ liveRevision.files = diff.files;
1197
+ liveRevision.source = diff.source
1198
+ ? { vcs: diff.vcs, changeId: diff.source.changeId, revisionId: diff.source.revisionId }
1199
+ : undefined;
1200
+ }
1077
1201
  }
1078
1202
 
1079
1203
  /**
@@ -1103,6 +1227,9 @@ export class DaemonCore {
1103
1227
  if (!current || current.status !== "pending" || current.artifact.type !== "diff")
1104
1228
  return { changed: false };
1105
1229
  const contentChanged = snapshot.patch !== current.artifact.content;
1230
+ const sourceChanged =
1231
+ snapshot.baseSha !== current.artifact.meta.prBaseSha ||
1232
+ snapshot.headSha !== current.artifact.meta.prHeadSha;
1106
1233
 
1107
1234
  if (
1108
1235
  !contentChanged &&
@@ -1123,9 +1250,26 @@ export class DaemonCore {
1123
1250
  prRefreshHeadSha: undefined,
1124
1251
  },
1125
1252
  };
1126
- const history = withHistory(current).history;
1127
-
1128
- if (history && contentChanged) current.history = recaptureMainHead(history, snapshot.patch);
1253
+ if (contentChanged || sourceChanged) {
1254
+ const now = new Date().toISOString();
1255
+
1256
+ current.revisions.push({
1257
+ revision: current.revisions.length + 1,
1258
+ content: snapshot.patch,
1259
+ submittedAt: now,
1260
+ source: {
1261
+ vcs: "git",
1262
+ baseRevisionId: snapshot.baseSha,
1263
+ revisionId: snapshot.headSha,
1264
+ },
1265
+ });
1266
+ this.recordOnMain(current, {
1267
+ type: "revision",
1268
+ by: "agent",
1269
+ content: snapshot.patch,
1270
+ createdAt: now,
1271
+ });
1272
+ }
1129
1273
  this.store.upsert(current);
1130
1274
  this.emit("session.updated", id);
1131
1275
 
@@ -1174,7 +1318,11 @@ export class DaemonCore {
1174
1318
 
1175
1319
  private trackLiveDiffSession(session: Thread): void {
1176
1320
  if (isWorkingTreeDiffSession(session))
1177
- this.diffWatcher.trackDiffRepo(session.workspace.repoRoot, session.id);
1321
+ this.diffWatcher.trackDiffRepo(
1322
+ session.workspace.repoRoot,
1323
+ session.id,
1324
+ session.artifact.meta.vcs,
1325
+ );
1178
1326
  else if (isPrReviewSession(session))
1179
1327
  this.prPoller.trackPr(
1180
1328
  session.id,
@@ -1326,12 +1474,21 @@ function forkedAnnotation(annotation: Annotation, source: Thread, fork: Thread):
1326
1474
  : { ...annotation, resolution: { ...resolution, revision: inFork.revision } };
1327
1475
  }
1328
1476
 
1477
+ function applyRevisionSource(artifact: Artifact, source?: DiffSource): void {
1478
+ if (source) artifact.meta.vcs = source.vcs;
1479
+ if (artifact.type !== "diff") return;
1480
+
1481
+ artifact.meta.vcsChangeId = source?.changeId;
1482
+ artifact.meta.vcsRevisionId = source?.revisionId;
1483
+ }
1484
+
1329
1485
  /** A pending diff of the local working tree: hot-reloads by watching its repo. A PR review is excluded. */
1330
1486
  function isWorkingTreeDiffSession(session: Thread): boolean {
1331
1487
  return (
1332
1488
  session.status === "pending" &&
1333
1489
  session.artifact.type === "diff" &&
1334
- session.artifact.meta.pr === undefined
1490
+ session.artifact.meta.pr === undefined &&
1491
+ !(session.artifact.meta.vcsChangeId && session.artifact.meta.workbench !== true)
1335
1492
  );
1336
1493
  }
1337
1494
 
package/src/client.ts CHANGED
@@ -141,7 +141,7 @@ export interface ThreadClient {
141
141
  /** Changed files (path plus git status) in the working tree at `cwd`. */
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
- repoDiff?(cwd: string): Promise<WorkingTreeDiff>;
144
+ repoDiff?(cwd: string, vcs?: string): Promise<WorkingTreeDiff>;
145
145
  /** Refresh a local diff or explicitly pull a moved PR head. */
146
146
  sessionRefreshDiff?(id: string): Promise<{ changed: boolean }>;
147
147
  /** Find-or-create the per-repo workbench thread for `cwd`, so a bare launch's first comment persists. */
@@ -574,11 +574,21 @@ export class DaemonClient implements ThreadClient {
574
574
  v.array(v.object({ path: v.string(), status: v.picklist(["added", "modified", "deleted"]) })),
575
575
  );
576
576
  }
577
- repoDiff(cwd: string): Promise<WorkingTreeDiff> {
577
+ repoDiff(
578
+ cwd: string,
579
+ vcs?: string,
580
+ ): Promise<
581
+ WorkingTreeDiff & { vcs?: string; source?: { changeId?: string; revisionId: string } }
582
+ > {
578
583
  return this.request(
579
584
  "repo.diff",
580
- { cwd },
581
- v.object({ patch: v.string(), files: v.array(DiffFileContentsSchema) }),
585
+ { cwd, vcs },
586
+ v.object({
587
+ patch: v.string(),
588
+ files: v.array(DiffFileContentsSchema),
589
+ vcs: v.optional(v.string()),
590
+ source: v.optional(v.object({ changeId: v.optional(v.string()), revisionId: v.string() })),
591
+ }),
582
592
  );
583
593
  }
584
594
  sessionWorkbench(cwd: string): Promise<Thread> {
@@ -660,10 +670,11 @@ export class DaemonClient implements ThreadClient {
660
670
  content: string,
661
671
  addressedAnnotationIds: string[] = [],
662
672
  files?: DiffFileContents[],
673
+ source?: import("@cueloop/schema").DiffSource,
663
674
  ): Promise<Thread> {
664
675
  return this.request(
665
676
  "session.submitRevision",
666
- { id, content, addressedAnnotationIds, files },
677
+ { id, content, addressedAnnotationIds, files, source },
667
678
  ThreadRecordSchema,
668
679
  );
669
680
  }
@@ -51,6 +51,90 @@ async function openDiffSession() {
51
51
  }
52
52
 
53
53
  describe("session.refreshDiff", () => {
54
+ test("a JJ rewrite with the same patch records its new commit identity", async () => {
55
+ const init = Bun.spawnSync(["jj", "git", "init", "--colocate", repo], {
56
+ cwd: repo,
57
+ stdout: "ignore",
58
+ stderr: "pipe",
59
+ });
60
+
61
+ if (init.exitCode !== 0) throw new Error(init.stderr.toString());
62
+ writeFileSync(join(repo, "a.ts"), "export const a = 2;\n");
63
+ const first = await core.repoDiff(repo);
64
+ const workspace = await resolveWorkspace(repo);
65
+ const session = core.sessionCreate({
66
+ workspace,
67
+ artifact: {
68
+ type: "diff",
69
+ content: first.patch,
70
+ files: first.files,
71
+ meta: {
72
+ vcs: "jj",
73
+ vcsChangeId: first.source!.changeId,
74
+ vcsRevisionId: first.source!.revisionId,
75
+ },
76
+ },
77
+ });
78
+ const describe = Bun.spawnSync(["jj", "describe", "-m", "same patch, new commit"], {
79
+ cwd: repo,
80
+ stderr: "pipe",
81
+ });
82
+
83
+ if (describe.exitCode !== 0) throw new Error(describe.stderr.toString());
84
+ const result = await core.sessionRefreshDiff(session.id);
85
+ const current = core.sessionGet(session.id);
86
+
87
+ expect(result.changed).toBe(false);
88
+ expect(current.revisions).toHaveLength(2);
89
+ expect(current.revisions[0]?.content).toBe(current.revisions[1]?.content);
90
+ expect(current.revisions[0]?.source?.revisionId).toBe(first.source!.revisionId);
91
+ expect(current.revisions[1]?.source?.revisionId).toBe(current.artifact.meta.vcsRevisionId);
92
+ expect(current.revisions[1]?.source?.revisionId).not.toBe(first.source!.revisionId);
93
+ });
94
+
95
+ test("a submitted JJ change keeps its prior snapshot when refreshed after a rewrite", async () => {
96
+ const init = Bun.spawnSync(["jj", "git", "init", "--colocate", repo], {
97
+ cwd: repo,
98
+ stdout: "ignore",
99
+ stderr: "pipe",
100
+ });
101
+
102
+ if (init.exitCode !== 0) throw new Error(init.stderr.toString());
103
+ writeFileSync(join(repo, "a.ts"), "export const a = 2;\n");
104
+ const first = await core.repoDiff(repo);
105
+ const workspace = await resolveWorkspace(repo);
106
+ const session = core.sessionCreate({
107
+ workspace,
108
+ artifact: {
109
+ type: "diff",
110
+ content: first.patch,
111
+ files: first.files,
112
+ meta: {
113
+ vcs: "jj",
114
+ vcsChangeId: first.source!.changeId,
115
+ vcsRevisionId: first.source!.revisionId,
116
+ },
117
+ },
118
+ });
119
+
120
+ writeFileSync(join(repo, "a.ts"), "export const a = 3;\n");
121
+ const result = await core.sessionRefreshDiff(session.id);
122
+ const current = core.sessionGet(session.id);
123
+
124
+ expect(result.changed).toBe(true);
125
+ expect(current.revisions.map((revision) => revision.content)).toEqual([
126
+ first.patch,
127
+ current.artifact.content,
128
+ ]);
129
+ expect(current.revisions[0]?.source?.revisionId).toBe(first.source!.revisionId);
130
+ expect(current.revisions[0]?.files).toEqual(first.files);
131
+ expect(current.revisions[1]?.source?.revisionId).toBe(current.artifact.meta.vcsRevisionId);
132
+ expect(current.revisions[1]?.files).toEqual(current.artifact.files);
133
+ expect(current.artifact.meta.vcsChangeId).toBe(first.source!.changeId);
134
+ expect(current.artifact.meta.vcsRevisionId).not.toBe(first.source!.revisionId);
135
+ expect(current.artifact.content).toContain("+export const a = 3;");
136
+ });
137
+
54
138
  test("re-captures the working tree and updates the artifact when the patch changed", async () => {
55
139
  // Given an open diff session whose working tree then gains a new change
56
140
  const session = await openDiffSession();
@@ -218,7 +302,26 @@ esac
218
302
  expect(refreshed.artifact.meta.prHeadSha).toBe("sha-2");
219
303
  expect(refreshed.artifact.meta.prRefreshBaseSha).toBeUndefined();
220
304
  expect(refreshed.artifact.meta.prRefreshHeadSha).toBeUndefined();
305
+ expect(refreshed.revisions.map((revision) => revision.content)).toEqual([
306
+ "OLD PR DIFF\n",
307
+ refreshed.artifact.content,
308
+ ]);
309
+ expect(refreshed.revisions.map((revision) => revision.source?.revisionId)).toEqual([
310
+ "sha-1",
311
+ "sha-2",
312
+ ]);
313
+ expect(refreshed.history!.entries.filter((entry) => entry.type === "revision")).toHaveLength(
314
+ 2,
315
+ );
221
316
  expect(readFileSync(ghLog, "utf8")).toContain("https://github.com/org/repo/pull/1");
317
+ writeFileSync(ghStub, readFileSync(ghStub, "utf8").replace("sha-2", "sha-3"));
318
+ const samePatch = await core.sessionRefreshDiff(session.id);
319
+ const movedHead = core.sessionGet(session.id);
320
+
321
+ expect(samePatch.changed).toBe(false);
322
+ expect(movedHead.revisions).toHaveLength(3);
323
+ expect(movedHead.revisions[2]?.content).toBe(movedHead.revisions[1]?.content);
324
+ expect(movedHead.revisions[2]?.source?.revisionId).toBe("sha-3");
222
325
  } finally {
223
326
  if (previousGh === undefined) delete process.env.CUELOOP_GH;
224
327
  else process.env.CUELOOP_GH = previousGh;
@@ -227,6 +330,31 @@ esac
227
330
  });
228
331
 
229
332
  describe("the fs watcher drives hot-reload", () => {
333
+ test("a JJ workbench refreshes in place after the working copy changes", async () => {
334
+ git(["status"], repo);
335
+ const init = Bun.spawnSync(["jj", "git", "init", "--colocate", repo], {
336
+ cwd: repo,
337
+ stdout: "ignore",
338
+ stderr: "pipe",
339
+ });
340
+
341
+ if (init.exitCode !== 0) throw new Error(init.stderr.toString());
342
+ const session = await core.workbenchSession(repo);
343
+
344
+ expect(session.artifact.meta.vcs).toBe("jj");
345
+ writeFileSync(join(repo, "a.ts"), "export const a = 42;\n");
346
+ const deadline = Date.now() + 8_000;
347
+ let current = core.sessionGet(session.id);
348
+
349
+ while (!current.artifact.content.includes("+export const a = 42;") && Date.now() < deadline) {
350
+ await Bun.sleep(100);
351
+ current = core.sessionGet(session.id);
352
+ }
353
+ expect(current.artifact.content).toContain("+export const a = 42;");
354
+ expect(current.revisions).toHaveLength(1);
355
+ expect(current.artifact.meta.vcsChangeId).toBeTruthy();
356
+ }, 12_000);
357
+
230
358
  test("a working-tree change under a live diff session refreshes it in place", async () => {
231
359
  // Given a live diff session whose repo the daemon is watching
232
360
  const session = await openDiffSession();
@@ -21,7 +21,7 @@ const DIFF_REFRESH_DEBOUNCE_MS = 300;
21
21
  * index, lock files) cannot loop; `node_modules` churns on installs and is never
22
22
  * review content.
23
23
  */
24
- const ALWAYS_IGNORED_DIRS = new Set([".git", "node_modules"]);
24
+ const ALWAYS_IGNORED_DIRS = new Set([".git", ".jj", "node_modules"]);
25
25
 
26
26
  /**
27
27
  * Absolute paths of the directories git ignores under a repo root, resolved once
@@ -93,6 +93,8 @@ interface RepoWatch {
93
93
  handles: FSWatcher[];
94
94
  /** Live diff session ids sharing this repo root; the watch closes when the last one leaves. */
95
95
  sessionIds: Set<string>;
96
+ jjSessionIds: Set<string>;
97
+ jjPoll: ReturnType<typeof setInterval> | null;
96
98
  /** Absolute paths git ignores under the root, so the walk skips them. */
97
99
  ignored: Set<string>;
98
100
  /** Directories already watched, so a runtime-created dir is not watched twice. */
@@ -112,28 +114,32 @@ export class DiffWatcher {
112
114
  constructor(private readonly onRepoChange: (repoRoot: string) => void) {}
113
115
 
114
116
  /** Start (or join) watching a repo root for one diff session. Idempotent per (root, session). */
115
- trackDiffRepo(repoRoot: string, sessionId: string): void {
117
+ trackDiffRepo(repoRoot: string, sessionId: string, vcs = "git"): void {
116
118
  const existing = this.repoWatches.get(repoRoot);
117
119
 
118
120
  if (existing) {
119
121
  existing.sessionIds.add(sessionId);
122
+ if (vcs === "jj") this.startJjPoll(existing, repoRoot, sessionId);
120
123
 
121
124
  return;
122
125
  }
123
126
  const repoWatch: RepoWatch = {
124
127
  handles: [],
125
128
  sessionIds: new Set([sessionId]),
129
+ jjSessionIds: new Set(),
130
+ jjPoll: null,
126
131
  ignored: ignoredDirectories(repoRoot),
127
132
  watchedDirs: new Set(),
128
133
  debounce: null,
129
134
  };
130
135
 
131
136
  this.repoWatches.set(repoRoot, repoWatch);
137
+ if (vcs === "jj") this.startJjPoll(repoWatch, repoRoot, sessionId);
132
138
 
133
139
  // one watcher per non-ignored working-tree directory; a tracked-file change re-captures
134
140
  if (!this.watchTree(repoRoot, repoRoot)) {
135
141
  // repo root gone or not watchable on this platform: skip, no hot-reload here
136
- this.repoWatches.delete(repoRoot);
142
+ this.untrackDiffRepo(repoRoot, sessionId);
137
143
 
138
144
  return;
139
145
  }
@@ -159,6 +165,12 @@ export class DiffWatcher {
159
165
  }
160
166
  }
161
167
 
168
+ private startJjPoll(repoWatch: RepoWatch, repoRoot: string, sessionId: string): void {
169
+ repoWatch.jjSessionIds.add(sessionId);
170
+ if (repoWatch.jjPoll !== null) return;
171
+ repoWatch.jjPoll = setInterval(() => this.scheduleRepoRefresh(repoRoot), 2000);
172
+ }
173
+
162
174
  /**
163
175
  * Watch `dir` (non-recursive) and every non-ignored directory under it, adding
164
176
  * each to the repo's handles. Returns whether `dir` itself got a watcher, so
@@ -246,8 +258,14 @@ export class DiffWatcher {
246
258
 
247
259
  if (!repoWatch) return;
248
260
  repoWatch.sessionIds.delete(sessionId);
261
+ repoWatch.jjSessionIds.delete(sessionId);
262
+ if (repoWatch.jjSessionIds.size === 0 && repoWatch.jjPoll !== null) {
263
+ clearInterval(repoWatch.jjPoll);
264
+ repoWatch.jjPoll = null;
265
+ }
249
266
  if (repoWatch.sessionIds.size > 0) return;
250
267
  if (repoWatch.debounce !== null) clearTimeout(repoWatch.debounce);
268
+ if (repoWatch.jjPoll !== null) clearInterval(repoWatch.jjPoll);
251
269
  for (const handle of repoWatch.handles) handle.close();
252
270
  this.repoWatches.delete(repoRoot);
253
271
  }
@@ -267,6 +285,7 @@ export class DiffWatcher {
267
285
  close(): void {
268
286
  for (const repoWatch of this.repoWatches.values()) {
269
287
  if (repoWatch.debounce !== null) clearTimeout(repoWatch.debounce);
288
+ if (repoWatch.jjPoll !== null) clearInterval(repoWatch.jjPoll);
270
289
  for (const handle of repoWatch.handles) handle.close();
271
290
  }
272
291
  this.repoWatches.clear();