@cueloop/daemon 0.1.0-alpha.9 → 0.1.0-alpha.91
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 +39 -22
- package/src/api.test.ts +1060 -65
- package/src/api.ts +1398 -89
- package/src/capabilities.test.ts +57 -0
- package/src/capabilities.ts +89 -0
- package/src/client.test.ts +24 -0
- package/src/client.ts +669 -86
- package/src/curate.ts +109 -0
- package/src/daemon-version.test.ts +142 -0
- package/src/diff-hot-reload.test.ts +419 -0
- package/src/diff-watcher.ts +293 -0
- package/src/gh.test.ts +59 -0
- package/src/gh.ts +74 -0
- package/src/ghostty-thread-surface-store.test.ts +49 -0
- package/src/ghostty-thread-surface-store.ts +101 -0
- package/src/ghostty-thread-surface.test.ts +398 -0
- package/src/ghostty-thread-surface.ts +267 -0
- package/src/harness-delivery.test.ts +268 -0
- package/src/harness-state-store.test.ts +93 -0
- package/src/harness-state-store.ts +213 -0
- package/src/herdr-split.test.ts +106 -0
- package/src/herdr-split.ts +92 -0
- package/src/herdr-thread-surface-store.test.ts +81 -0
- package/src/herdr-thread-surface-store.ts +64 -0
- package/src/herdr-thread-surface.test.ts +411 -0
- package/src/herdr-thread-surface.ts +355 -0
- package/src/index.ts +8 -9
- package/src/interruptible-wait.test.ts +127 -0
- package/src/interruptible-wait.ts +66 -0
- package/src/jj-working-tree.ts +131 -0
- package/src/main.ts +4 -1
- package/src/paths.ts +49 -0
- package/src/pr-poller.test.ts +102 -0
- package/src/pr-poller.ts +73 -0
- package/src/project-files.test.ts +124 -0
- package/src/project-files.ts +56 -0
- package/src/protocol.ts +79 -7
- package/src/retention.test.ts +191 -0
- package/src/retention.ts +123 -0
- package/src/server.test.ts +453 -42
- package/src/server.ts +433 -88
- package/src/share-blob.test.ts +112 -0
- package/src/share-blob.ts +55 -0
- package/src/single-instance.test.ts +162 -13
- package/src/store.conformance.test.ts +41 -0
- package/src/store.jsonl.test.ts +159 -0
- package/src/store.ts +264 -34
- package/src/testing/store-conformance.ts +194 -0
- package/src/thread-review.test.ts +483 -0
- package/src/thread-review.ts +398 -0
- package/src/thread-surface-config.test.ts +63 -0
- package/src/thread-surface-config.ts +61 -0
- package/src/validate.test.ts +282 -37
- package/src/validate.ts +398 -33
- package/src/vcs-source.test.ts +180 -0
- package/src/vcs-source.ts +228 -0
- package/src/version.ts +6 -0
- package/src/working-tree.ts +149 -0
- package/src/review.test.ts +0 -167
- package/src/review.ts +0 -174
package/src/curate.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The curated patch of a diff review: the submitted changes minus the hunks and
|
|
3
|
+
* change blocks the reviewer rejected. The daemon computes it from the
|
|
4
|
+
* session's reject decisions so the working copy every client sees is one and
|
|
5
|
+
* the same; the client only maps rows to decisions.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { diffAcceptRejectHunk, parseDiffFromFile, type FileDiffMetadata } from "@pierre/diffs";
|
|
9
|
+
import {
|
|
10
|
+
unifiedDiffText,
|
|
11
|
+
type DiffFileContents,
|
|
12
|
+
type DiffFileStatus,
|
|
13
|
+
type HunkRejection,
|
|
14
|
+
} from "@cueloop/schema";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Context lines around each change. Matching git's default (3) makes the model
|
|
18
|
+
* split hunks like the rendered working-tree patch does; a row is mapped by
|
|
19
|
+
* absolute line number, never by counting hunks, so a whole-hunk reject can
|
|
20
|
+
* only ever span the model hunk it lands in.
|
|
21
|
+
*/
|
|
22
|
+
export const DIFF_CONTEXT = 3;
|
|
23
|
+
|
|
24
|
+
/** Parse one file's full contents into a non-partial diff model. */
|
|
25
|
+
export function parseFileDiff(file: DiffFileContents): FileDiffMetadata {
|
|
26
|
+
return parseDiffFromFile(
|
|
27
|
+
{ name: file.path, contents: file.oldContents },
|
|
28
|
+
{ name: file.path, contents: file.newContents },
|
|
29
|
+
{ context: DIFF_CONTEXT },
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Apply one file's rejections and return its curated patch, or null when the
|
|
35
|
+
* curated content equals the old content (every change rejected - no patch).
|
|
36
|
+
* Whole-hunk rejections supersede change-level ones for the same hunk. Because
|
|
37
|
+
* `diffAcceptRejectHunk` returns a model with the same hunk/content shape, the
|
|
38
|
+
* indices stay valid across calls; they are applied high-to-low regardless, so
|
|
39
|
+
* the order is deterministic.
|
|
40
|
+
*/
|
|
41
|
+
function curateFilePatch(file: DiffFileContents, rejections: HunkRejection[]): string | null {
|
|
42
|
+
let model = parseFileDiff(file);
|
|
43
|
+
const wholeHunks = new Set(
|
|
44
|
+
rejections
|
|
45
|
+
.filter((rejection) => rejection.changeIndex === undefined)
|
|
46
|
+
.map((rejection) => rejection.hunkIndex),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
for (const hunkIndex of [...wholeHunks].sort((a, b) => b - a)) {
|
|
50
|
+
model = diffAcceptRejectHunk(model, hunkIndex, "reject");
|
|
51
|
+
}
|
|
52
|
+
const changeRejections = rejections
|
|
53
|
+
.filter(
|
|
54
|
+
(rejection) => rejection.changeIndex !== undefined && !wholeHunks.has(rejection.hunkIndex),
|
|
55
|
+
)
|
|
56
|
+
.sort((a, b) => b.hunkIndex - a.hunkIndex || b.changeIndex! - a.changeIndex!);
|
|
57
|
+
|
|
58
|
+
for (const rejection of changeRejections) {
|
|
59
|
+
model = diffAcceptRejectHunk(model, rejection.hunkIndex, {
|
|
60
|
+
type: "reject",
|
|
61
|
+
changeIndex: rejection.changeIndex!,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
const curatedNew = model.additionLines.join("");
|
|
65
|
+
|
|
66
|
+
if (curatedNew === file.oldContents) return null;
|
|
67
|
+
const patch = unifiedDiffText(file.oldContents, curatedNew, file.path);
|
|
68
|
+
|
|
69
|
+
if (patch === null) return null;
|
|
70
|
+
|
|
71
|
+
return withFileStateHeaders(patch, file.path, file.status, curatedNew);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Point the diff headers at /dev/null for a created or fully-deleted file so the
|
|
76
|
+
* curated patch applies. git's status - not empty contents - decides existence,
|
|
77
|
+
* so editing an existing empty file stays a modify; a deletion whose removals
|
|
78
|
+
* were partly restored also falls back to a modify.
|
|
79
|
+
*/
|
|
80
|
+
function withFileStateHeaders(
|
|
81
|
+
patch: string,
|
|
82
|
+
path: string,
|
|
83
|
+
status: DiffFileStatus,
|
|
84
|
+
curatedNew: string,
|
|
85
|
+
): string {
|
|
86
|
+
if (status === "added") return patch.replace(`--- a/${path}`, "--- /dev/null");
|
|
87
|
+
if (status === "deleted" && curatedNew === "") {
|
|
88
|
+
return patch.replace(`+++ b/${path}`, "+++ /dev/null");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return patch;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The curated unified-diff patch of the accepted changes across every file.
|
|
96
|
+
* Files with no net change (unchanged, or with all changes rejected) drop out.
|
|
97
|
+
*/
|
|
98
|
+
export function curateDiff(files: DiffFileContents[], rejections: HunkRejection[]): string {
|
|
99
|
+
const parts: string[] = [];
|
|
100
|
+
|
|
101
|
+
for (const file of files) {
|
|
102
|
+
const fileRejections = rejections.filter((rejection) => rejection.path === file.path);
|
|
103
|
+
const patch = curateFilePatch(file, fileRejections);
|
|
104
|
+
|
|
105
|
+
if (patch) parts.push(patch);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return parts.join("\n");
|
|
109
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Version handshake: a client must never keep talking to a daemon from an
|
|
3
|
+
* earlier build (the stale-daemon-after-upgrade bug). The ping carries the
|
|
4
|
+
* daemon's version; a mismatch without autostart is a clear error, and a
|
|
5
|
+
* matching version connects normally.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { DaemonServer } from "./server";
|
|
13
|
+
import { DaemonClient, DaemonClientError, daemonSpawnCommand } from "./client";
|
|
14
|
+
import { DAEMON_VERSION } from "./version";
|
|
15
|
+
import { pidPath } from "./paths";
|
|
16
|
+
import type { Artifact, WorkspaceKey } from "@cueloop/schema";
|
|
17
|
+
|
|
18
|
+
const WS: WorkspaceKey = { repoRoot: "/repo", branch: "main" };
|
|
19
|
+
const PLAN: Artifact = { type: "plan", content: "# P\n\nBody.\n", meta: {} };
|
|
20
|
+
|
|
21
|
+
let home: string;
|
|
22
|
+
const servers: DaemonServer[] = [];
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-version-"));
|
|
26
|
+
});
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
for (const runningServer of servers.splice(0)) runningServer.stop();
|
|
29
|
+
rmSync(home, { recursive: true, force: true });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function server(version?: string): DaemonServer {
|
|
33
|
+
const daemonServer = new DaemonServer({
|
|
34
|
+
home,
|
|
35
|
+
idleExitMs: 0,
|
|
36
|
+
version,
|
|
37
|
+
onIdleExit: () => {},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
servers.push(daemonServer);
|
|
41
|
+
|
|
42
|
+
return daemonServer;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe("daemon version handshake", () => {
|
|
46
|
+
test("a stale daemon is rejected when there is nothing to replace it", async () => {
|
|
47
|
+
// Arrange - a daemon posing as an earlier build
|
|
48
|
+
server("0.0.0-stale").start();
|
|
49
|
+
|
|
50
|
+
// Act + Assert - without autostart the client cannot restart it, so it says why
|
|
51
|
+
await expect(DaemonClient.connect({ home, autostart: false })).rejects.toMatchObject({
|
|
52
|
+
code: "version_mismatch",
|
|
53
|
+
});
|
|
54
|
+
await expect(DaemonClient.connect({ home, autostart: false })).rejects.toBeInstanceOf(
|
|
55
|
+
DaemonClientError,
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("autostart never replaces a live daemon from another version", async () => {
|
|
60
|
+
const liveServer = server("0.1.0-alpha.81");
|
|
61
|
+
|
|
62
|
+
liveServer.start();
|
|
63
|
+
const session = liveServer.core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
64
|
+
let replacement: DaemonClient | undefined;
|
|
65
|
+
let connectionError: unknown;
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
replacement = await DaemonClient.connect({ home, autostart: true });
|
|
69
|
+
} catch (error) {
|
|
70
|
+
connectionError = error;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
replacement?.close();
|
|
74
|
+
expect(connectionError).toBeInstanceOf(DaemonClientError);
|
|
75
|
+
expect(connectionError).toMatchObject({ code: "version_mismatch" });
|
|
76
|
+
expect(readFileSync(pidPath(home), "utf8")).toBe(String(process.pid));
|
|
77
|
+
expect(liveServer.core.sessionGet(session.id).id).toBe(session.id);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("a rejected client releases its connection so the daemon can become idle", async () => {
|
|
81
|
+
let idleExited = false;
|
|
82
|
+
const liveServer = new DaemonServer({
|
|
83
|
+
home,
|
|
84
|
+
idleExitMs: 50,
|
|
85
|
+
version: "0.1.0-alpha.81",
|
|
86
|
+
onIdleExit: () => {
|
|
87
|
+
idleExited = true;
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
servers.push(liveServer);
|
|
92
|
+
liveServer.start();
|
|
93
|
+
|
|
94
|
+
await expect(DaemonClient.connect({ home, autostart: true })).rejects.toMatchObject({
|
|
95
|
+
code: "version_mismatch",
|
|
96
|
+
});
|
|
97
|
+
await Bun.sleep(100);
|
|
98
|
+
|
|
99
|
+
expect(idleExited).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("a current daemon connects and serves normally", async () => {
|
|
103
|
+
// Arrange - a daemon on this build
|
|
104
|
+
server(DAEMON_VERSION).start();
|
|
105
|
+
const client = await DaemonClient.connect({ home, autostart: false });
|
|
106
|
+
|
|
107
|
+
// Act - a round-trip proves the connection is live, not replaced
|
|
108
|
+
const created = await client.sessionCreate(WS, PLAN);
|
|
109
|
+
const listed = await client.sessionList();
|
|
110
|
+
|
|
111
|
+
// Assert
|
|
112
|
+
expect(listed.map((session) => session.id)).toContain(created.id);
|
|
113
|
+
client.close();
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("daemon spawn command", () => {
|
|
118
|
+
const SOURCE_URL = "file:///repo/packages/daemon/src/client.ts";
|
|
119
|
+
|
|
120
|
+
test("from source it bun-runs main.ts", () => {
|
|
121
|
+
expect(daemonSpawnCommand("/bin/bun", SOURCE_URL, false)).toEqual([
|
|
122
|
+
"/bin/bun",
|
|
123
|
+
"run",
|
|
124
|
+
"/repo/packages/daemon/src/main.ts",
|
|
125
|
+
]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("dev-watch reloads the daemon on source edits", () => {
|
|
129
|
+
expect(daemonSpawnCommand("/bin/bun", SOURCE_URL, true)).toEqual([
|
|
130
|
+
"/bin/bun",
|
|
131
|
+
"--watch",
|
|
132
|
+
"run",
|
|
133
|
+
"/repo/packages/daemon/src/main.ts",
|
|
134
|
+
]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("a compiled binary re-execs its own daemon command, never --watch", () => {
|
|
138
|
+
expect(
|
|
139
|
+
daemonSpawnCommand("/usr/local/bin/cueloop", "file:///$bunfs/root/cueloop", true),
|
|
140
|
+
).toEqual(["/usr/local/bin/cueloop", "daemon", "--autostart"]);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diff hot-reload: session.refreshDiff re-captures a diff session's working
|
|
3
|
+
* tree, and the fs watcher drives it on a real change. Exercised against a
|
|
4
|
+
* real temp git repo so the capture path is the production one.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
8
|
+
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { DaemonCore } from "./api";
|
|
12
|
+
import { workingTreeDiff } from "./working-tree";
|
|
13
|
+
import { resolveWorkspace } from "./thread-review";
|
|
14
|
+
import type { Artifact } from "@cueloop/schema";
|
|
15
|
+
|
|
16
|
+
function git(args: string[], cwd: string): void {
|
|
17
|
+
const result = Bun.spawnSync(["git", ...args], { cwd, stdout: "ignore", stderr: "ignore" });
|
|
18
|
+
|
|
19
|
+
if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")} failed in ${cwd}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let home: string;
|
|
23
|
+
let repo: string;
|
|
24
|
+
let core: DaemonCore;
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-hotreload-home-"));
|
|
28
|
+
repo = mkdtempSync(join(tmpdir(), "cueloop-hotreload-repo-"));
|
|
29
|
+
git(["init", "-q", "-b", "main"], repo);
|
|
30
|
+
git(["config", "user.email", "t@t"], repo);
|
|
31
|
+
git(["config", "user.name", "t"], repo);
|
|
32
|
+
writeFileSync(join(repo, "a.ts"), "export const a = 1;\n");
|
|
33
|
+
git(["add", "."], repo);
|
|
34
|
+
git(["commit", "-qm", "init"], repo);
|
|
35
|
+
core = new DaemonCore(home);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
core.dispose();
|
|
40
|
+
rmSync(home, { recursive: true, force: true });
|
|
41
|
+
rmSync(repo, { recursive: true, force: true });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/** Open a diff session over the temp repo's current working tree. */
|
|
45
|
+
async function openDiffSession() {
|
|
46
|
+
const workspace = await resolveWorkspace(repo);
|
|
47
|
+
const diff = await workingTreeDiff(repo);
|
|
48
|
+
const artifact: Artifact = { type: "diff", content: diff.patch, files: diff.files, meta: {} };
|
|
49
|
+
|
|
50
|
+
return core.sessionCreate({ workspace, artifact });
|
|
51
|
+
}
|
|
52
|
+
|
|
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
|
+
|
|
138
|
+
test("re-captures the working tree and updates the artifact when the patch changed", async () => {
|
|
139
|
+
// Given an open diff session whose working tree then gains a new change
|
|
140
|
+
const session = await openDiffSession();
|
|
141
|
+
|
|
142
|
+
writeFileSync(join(repo, "a.ts"), "export const a = 2;\n");
|
|
143
|
+
|
|
144
|
+
// When the diff is refreshed
|
|
145
|
+
const result = await core.sessionRefreshDiff(session.id);
|
|
146
|
+
|
|
147
|
+
// Then the change is reported and the stored artifact carries the fresh patch and files
|
|
148
|
+
expect(result.changed).toBe(true);
|
|
149
|
+
const refreshed = core.sessionGet(session.id);
|
|
150
|
+
|
|
151
|
+
expect(refreshed.artifact.content).toContain("+export const a = 2;");
|
|
152
|
+
const modified = refreshed.artifact.files!.find((file) => file.path === "a.ts")!;
|
|
153
|
+
|
|
154
|
+
expect(modified.newContents).toBe("export const a = 2;\n");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("reports no change and emits nothing when the working tree is unchanged", async () => {
|
|
158
|
+
// Given an open diff session and a listener for session events
|
|
159
|
+
const session = await openDiffSession();
|
|
160
|
+
const events: string[] = [];
|
|
161
|
+
|
|
162
|
+
core.onEvent((event) => events.push(event.event));
|
|
163
|
+
|
|
164
|
+
// When the diff is refreshed without any working-tree change
|
|
165
|
+
const result = await core.sessionRefreshDiff(session.id);
|
|
166
|
+
|
|
167
|
+
// Then nothing changed and no session.updated event fired
|
|
168
|
+
expect(result.changed).toBe(false);
|
|
169
|
+
expect(events).not.toContain("session.updated");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("a changed refresh emits session.updated so attached clients re-fetch", async () => {
|
|
173
|
+
// Given an open diff session, a listener, and a fresh working-tree change
|
|
174
|
+
const session = await openDiffSession();
|
|
175
|
+
const events: string[] = [];
|
|
176
|
+
|
|
177
|
+
core.onEvent((event) => events.push(event.event));
|
|
178
|
+
writeFileSync(join(repo, "a.ts"), "export const a = 3;\n");
|
|
179
|
+
|
|
180
|
+
// When the diff is refreshed
|
|
181
|
+
await core.sessionRefreshDiff(session.id);
|
|
182
|
+
|
|
183
|
+
// Then a session.updated event fired for this session
|
|
184
|
+
expect(events).toContain("session.updated");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("a tree move after a refresh keeps the fresh patch", async () => {
|
|
188
|
+
// Given a refreshed diff session
|
|
189
|
+
const session = await openDiffSession();
|
|
190
|
+
|
|
191
|
+
writeFileSync(join(repo, "a.ts"), "export const a = 4;\n");
|
|
192
|
+
await core.sessionRefreshDiff(session.id);
|
|
193
|
+
|
|
194
|
+
// When the owner branches off and switches back
|
|
195
|
+
core.sessionBranch(session.id, "alt");
|
|
196
|
+
const shown = core.sessionSwitch(session.id, "main");
|
|
197
|
+
|
|
198
|
+
// Then the artifact still shows the re-captured working tree, and the history has one revision
|
|
199
|
+
expect(shown.artifact.content).toContain("+export const a = 4;");
|
|
200
|
+
expect(shown.history!.entries.filter((entry) => entry.type === "revision")).toHaveLength(1);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("is a no-op for a non-diff (plan) session", async () => {
|
|
204
|
+
// Given a plan session in the same workspace
|
|
205
|
+
const workspace = await resolveWorkspace(repo);
|
|
206
|
+
const plan: Artifact = { type: "plan", content: "# Plan\n\nBody.\n", meta: {} };
|
|
207
|
+
const session = core.sessionCreate({ workspace, artifact: plan });
|
|
208
|
+
|
|
209
|
+
// When a refresh is attempted
|
|
210
|
+
const result = await core.sessionRefreshDiff(session.id);
|
|
211
|
+
|
|
212
|
+
// Then it reports no change and leaves the plan content untouched
|
|
213
|
+
expect(result.changed).toBe(false);
|
|
214
|
+
expect(core.sessionGet(session.id).artifact.content).toBe("# Plan\n\nBody.\n");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("refusing a resolved diff session never mutates it (no revive of a closed review)", async () => {
|
|
218
|
+
// Given a diff session that has been resolved
|
|
219
|
+
const session = await openDiffSession();
|
|
220
|
+
|
|
221
|
+
core.sessionSendMessage(session.id, "approved", "");
|
|
222
|
+
const resolvedContent = core.sessionGet(session.id).artifact.content;
|
|
223
|
+
|
|
224
|
+
writeFileSync(join(repo, "a.ts"), "export const a = 99;\n");
|
|
225
|
+
|
|
226
|
+
// When a refresh is attempted on the resolved session
|
|
227
|
+
// Then it is rejected and the frozen artifact is untouched
|
|
228
|
+
expect(core.sessionRefreshDiff(session.id)).rejects.toThrow(/resolved/);
|
|
229
|
+
expect(core.sessionGet(session.id).artifact.content).toBe(resolvedContent);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("overlapping refreshes settle to one coherent artifact without error", async () => {
|
|
233
|
+
// Given a live diff session with a fresh working-tree change
|
|
234
|
+
const session = await openDiffSession();
|
|
235
|
+
|
|
236
|
+
writeFileSync(join(repo, "a.ts"), "export const a = 7;\n");
|
|
237
|
+
|
|
238
|
+
// When two refreshes run concurrently over the same session
|
|
239
|
+
const [first, second] = await Promise.all([
|
|
240
|
+
core.sessionRefreshDiff(session.id),
|
|
241
|
+
core.sessionRefreshDiff(session.id),
|
|
242
|
+
]);
|
|
243
|
+
|
|
244
|
+
// Then both settle and the stored artifact reflects the current tree exactly
|
|
245
|
+
// once - the generation guard keeps the older capture from regressing it
|
|
246
|
+
expect(first.changed || second.changed).toBe(true);
|
|
247
|
+
expect(core.sessionGet(session.id).artifact.content).toContain("+export const a = 7;");
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe("PR review sessions", () => {
|
|
252
|
+
test("refresh re-pulls the PR diff via gh and never clobbers it with the working tree", async () => {
|
|
253
|
+
// Given a stub gh that reports a PR diff distinct from the local working tree
|
|
254
|
+
const ghStub = join(repo, "gh-stub.sh");
|
|
255
|
+
const ghLog = join(repo, "gh-stub.log");
|
|
256
|
+
|
|
257
|
+
writeFileSync(
|
|
258
|
+
ghStub,
|
|
259
|
+
`#!/bin/sh
|
|
260
|
+
printf '%s\n' "$*" >> '${ghLog}'
|
|
261
|
+
case "$2" in
|
|
262
|
+
diff) printf "STUB PR DIFF\\n" ;;
|
|
263
|
+
view) printf '{"baseRefOid":"base-2","headRefOid":"sha-2"}\\n' ;;
|
|
264
|
+
esac
|
|
265
|
+
`,
|
|
266
|
+
);
|
|
267
|
+
chmodSync(ghStub, 0o755);
|
|
268
|
+
const previousGh = process.env.CUELOOP_GH;
|
|
269
|
+
|
|
270
|
+
process.env.CUELOOP_GH = ghStub;
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
// Given a PR review over the same repo root, and a local working-tree change present
|
|
274
|
+
const workspace = await resolveWorkspace(repo);
|
|
275
|
+
const artifact: Artifact = {
|
|
276
|
+
type: "diff",
|
|
277
|
+
content: "OLD PR DIFF\n",
|
|
278
|
+
files: [],
|
|
279
|
+
meta: {
|
|
280
|
+
pr: "1",
|
|
281
|
+
prUrl: "https://github.com/org/repo/pull/1",
|
|
282
|
+
prBaseSha: "base-1",
|
|
283
|
+
prHeadSha: "sha-1",
|
|
284
|
+
prRefreshBaseSha: "base-2",
|
|
285
|
+
prRefreshHeadSha: "sha-2",
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
const session = core.sessionCreate({ workspace, artifact });
|
|
289
|
+
|
|
290
|
+
writeFileSync(join(repo, "a.ts"), "export const a = 123;\n");
|
|
291
|
+
|
|
292
|
+
// When the diff is refreshed
|
|
293
|
+
const result = await core.sessionRefreshDiff(session.id);
|
|
294
|
+
|
|
295
|
+
// Then it carries the PR diff from gh, not the local working-tree change
|
|
296
|
+
expect(result.changed).toBe(true);
|
|
297
|
+
const refreshed = core.sessionGet(session.id);
|
|
298
|
+
|
|
299
|
+
expect(refreshed.artifact.content).toContain("STUB PR DIFF");
|
|
300
|
+
expect(refreshed.artifact.content).not.toContain("export const a = 123;");
|
|
301
|
+
expect(refreshed.artifact.meta.prBaseSha).toBe("base-2");
|
|
302
|
+
expect(refreshed.artifact.meta.prHeadSha).toBe("sha-2");
|
|
303
|
+
expect(refreshed.artifact.meta.prRefreshBaseSha).toBeUndefined();
|
|
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
|
+
);
|
|
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");
|
|
325
|
+
} finally {
|
|
326
|
+
if (previousGh === undefined) delete process.env.CUELOOP_GH;
|
|
327
|
+
else process.env.CUELOOP_GH = previousGh;
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
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
|
+
|
|
358
|
+
test("a working-tree change under a live diff session refreshes it in place", async () => {
|
|
359
|
+
// Given a live diff session whose repo the daemon is watching
|
|
360
|
+
const session = await openDiffSession();
|
|
361
|
+
|
|
362
|
+
// When a tracked file changes on disk
|
|
363
|
+
writeFileSync(join(repo, "a.ts"), "export const a = 42;\n");
|
|
364
|
+
|
|
365
|
+
// Then the watcher debounces and re-captures the diff without any manual call.
|
|
366
|
+
// Watchers can be slow under CI contention, so poll generously.
|
|
367
|
+
const deadline = Date.now() + 8_000;
|
|
368
|
+
let content = core.sessionGet(session.id).artifact.content;
|
|
369
|
+
|
|
370
|
+
while (!content.includes("+export const a = 42;") && Date.now() < deadline) {
|
|
371
|
+
await Bun.sleep(100);
|
|
372
|
+
content = core.sessionGet(session.id).artifact.content;
|
|
373
|
+
}
|
|
374
|
+
expect(content).toContain("+export const a = 42;");
|
|
375
|
+
}, 12_000);
|
|
376
|
+
|
|
377
|
+
test("watches a directory created after open, so a change inside it refreshes", async () => {
|
|
378
|
+
// Given an open diff session over a repo that had no such directory
|
|
379
|
+
const session = await openDiffSession();
|
|
380
|
+
|
|
381
|
+
// When a new directory appears, the watcher extends into it (a recursive watch got this free)
|
|
382
|
+
mkdirSync(join(repo, "pkg"));
|
|
383
|
+
await Bun.sleep(500);
|
|
384
|
+
writeFileSync(join(repo, "pkg", "inside.ts"), "export const inside = 1;\n");
|
|
385
|
+
|
|
386
|
+
// Then a change inside the new directory drives a re-capture
|
|
387
|
+
const deadline = Date.now() + 8_000;
|
|
388
|
+
let content = core.sessionGet(session.id).artifact.content;
|
|
389
|
+
|
|
390
|
+
while (!content.includes("+export const inside = 1;") && Date.now() < deadline) {
|
|
391
|
+
await Bun.sleep(100);
|
|
392
|
+
content = core.sessionGet(session.id).artifact.content;
|
|
393
|
+
}
|
|
394
|
+
expect(content).toContain("+export const inside = 1;");
|
|
395
|
+
}, 12_000);
|
|
396
|
+
|
|
397
|
+
test("a commit refreshes the diff even though no working-tree file changed", async () => {
|
|
398
|
+
// Given an open diff session showing an uncommitted change to a.ts
|
|
399
|
+
writeFileSync(join(repo, "a.ts"), "export const a = 5;\n");
|
|
400
|
+
const session = await openDiffSession();
|
|
401
|
+
|
|
402
|
+
expect(core.sessionGet(session.id).artifact.content).toContain("+export const a = 5;");
|
|
403
|
+
|
|
404
|
+
// When the change is committed - moving refs/HEAD, touching no working-tree file -
|
|
405
|
+
// the working tree now equals HEAD, so `git diff HEAD` is empty
|
|
406
|
+
git(["add", "a.ts"], repo);
|
|
407
|
+
git(["commit", "-qm", "land a"], repo);
|
|
408
|
+
|
|
409
|
+
// Then the git-metadata watch drives a re-capture and the diff empties out
|
|
410
|
+
const deadline = Date.now() + 8_000;
|
|
411
|
+
let content = core.sessionGet(session.id).artifact.content;
|
|
412
|
+
|
|
413
|
+
while (content.includes("+export const a = 5;") && Date.now() < deadline) {
|
|
414
|
+
await Bun.sleep(100);
|
|
415
|
+
content = core.sessionGet(session.id).artifact.content;
|
|
416
|
+
}
|
|
417
|
+
expect(content).not.toContain("+export const a = 5;");
|
|
418
|
+
}, 12_000);
|
|
419
|
+
});
|