@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 +4 -2
- package/src/api.ts +181 -24
- package/src/client.ts +16 -5
- package/src/diff-hot-reload.test.ts +128 -0
- package/src/diff-watcher.ts +22 -3
- package/src/jj-working-tree.ts +131 -0
- package/src/server.ts +6 -2
- package/src/thread-review.test.ts +90 -1
- package/src/thread-review.ts +85 -9
- package/src/validate.test.ts +10 -1
- package/src/validate.ts +21 -1
- package/src/vcs-source.test.ts +180 -0
- package/src/vcs-source.ts +228 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/daemon",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.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/
|
|
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 {
|
|
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: [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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<
|
|
579
|
-
return
|
|
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
|
-
|
|
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 ??
|
|
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 =
|
|
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: {
|
|
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({
|
|
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
|
|
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
|
-
|
|
1068
|
-
|
|
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 (
|
|
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:
|
|
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
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
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(
|
|
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(
|
|
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({
|
|
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();
|
package/src/diff-watcher.ts
CHANGED
|
@@ -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.
|
|
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();
|