@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
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Watches a diff session's repository for working-tree changes and drives the
|
|
3
|
+
* hot-reload: on any change under a repo root that a live diff session tracks,
|
|
4
|
+
* the daemon re-captures that session's diff so the open review updates in place.
|
|
5
|
+
* One watcher per non-ignored directory (not a recursive watch), so churn under
|
|
6
|
+
* `.git`, `node_modules`, or a .gitignored path never registers a watch at all -
|
|
7
|
+
* that keeps idle cost near zero on Linux, where a recursive watch would open an
|
|
8
|
+
* inotify descriptor per directory. A debounce collapses a burst into one
|
|
9
|
+
* re-capture. Many diff sessions can share a repo.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { watch, readdirSync, statSync, type Dirent, type FSWatcher } from "node:fs";
|
|
13
|
+
import { basename, isAbsolute, join } from "node:path";
|
|
14
|
+
|
|
15
|
+
/** Debounce window: a save or a checkout writes many files in a burst; collapse them into one re-capture. */
|
|
16
|
+
const DIFF_REFRESH_DEBOUNCE_MS = 300;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Directory names never worth a watch, whatever .gitignore says. `.git` is
|
|
20
|
+
* watched narrowly elsewhere (HEAD, refs, packed-refs) so its own read churn (the
|
|
21
|
+
* index, lock files) cannot loop; `node_modules` churns on installs and is never
|
|
22
|
+
* review content.
|
|
23
|
+
*/
|
|
24
|
+
const ALWAYS_IGNORED_DIRS = new Set([".git", ".jj", "node_modules"]);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Absolute paths of the directories git ignores under a repo root, resolved once
|
|
28
|
+
* at watch time from `git ls-files` so a .gitignored tree (dist, build output)
|
|
29
|
+
* never gets a watcher. Empty when git is unavailable - the name check still
|
|
30
|
+
* skips the churny defaults.
|
|
31
|
+
*/
|
|
32
|
+
function ignoredDirectories(repoRoot: string): Set<string> {
|
|
33
|
+
const ignored = new Set<string>();
|
|
34
|
+
const result = spawnGit(
|
|
35
|
+
["ls-files", "--others", "--ignored", "--exclude-standard", "--directory"],
|
|
36
|
+
repoRoot,
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
if (!result || result.exitCode !== 0) return ignored;
|
|
40
|
+
for (const line of result.stdout.toString().split("\n")) {
|
|
41
|
+
const relative = line.trim().replace(/\/$/, "");
|
|
42
|
+
|
|
43
|
+
if (relative.length > 0) ignored.add(join(repoRoot, relative));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return ignored;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Whether git ignores a single path; used for a directory created after the initial walk. */
|
|
50
|
+
function isGitIgnored(repoRoot: string, path: string): boolean {
|
|
51
|
+
return spawnGit(["check-ignore", "-q", path], repoRoot)?.exitCode === 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Run a git command in `cwd`, or null when git is missing or the cwd is gone (spawn throws ENOENT). */
|
|
55
|
+
function spawnGit(args: string[], cwd: string): { exitCode: number; stdout: Buffer } | null {
|
|
56
|
+
try {
|
|
57
|
+
const result = Bun.spawnSync(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore" });
|
|
58
|
+
|
|
59
|
+
return { exitCode: result.exitCode ?? 1, stdout: result.stdout };
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The git-metadata filenames whose change means `git diff HEAD` moved: a checkout, a commit, or a reset. */
|
|
66
|
+
function isRefChange(filename: string): boolean {
|
|
67
|
+
return filename === "HEAD" || filename === "packed-refs";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A repo's git dir (HEAD and the index live here) and common dir (refs and
|
|
72
|
+
* packed-refs live here). They differ inside a linked worktree. Null when the
|
|
73
|
+
* root is not a git repo or git is unavailable.
|
|
74
|
+
*/
|
|
75
|
+
function resolveGitDirs(repoRoot: string): { gitDir: string; commonDir: string } | null {
|
|
76
|
+
const result = spawnGit(["rev-parse", "--absolute-git-dir", "--git-common-dir"], repoRoot);
|
|
77
|
+
|
|
78
|
+
if (!result || result.exitCode !== 0) return null;
|
|
79
|
+
const [gitDir, commonRaw] = result.stdout.toString().trim().split("\n");
|
|
80
|
+
|
|
81
|
+
if (!gitDir) return null;
|
|
82
|
+
const commonDir = commonRaw
|
|
83
|
+
? isAbsolute(commonRaw)
|
|
84
|
+
? commonRaw
|
|
85
|
+
: join(repoRoot, commonRaw)
|
|
86
|
+
: gitDir;
|
|
87
|
+
|
|
88
|
+
return { gitDir, commonDir };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface RepoWatch {
|
|
92
|
+
/** One watcher per watched working-tree directory plus the narrow git-metadata watches; all close together. */
|
|
93
|
+
handles: FSWatcher[];
|
|
94
|
+
/** Live diff session ids sharing this repo root; the watch closes when the last one leaves. */
|
|
95
|
+
sessionIds: Set<string>;
|
|
96
|
+
jjSessionIds: Set<string>;
|
|
97
|
+
jjPoll: ReturnType<typeof setInterval> | null;
|
|
98
|
+
/** Absolute paths git ignores under the root, so the walk skips them. */
|
|
99
|
+
ignored: Set<string>;
|
|
100
|
+
/** Directories already watched, so a runtime-created dir is not watched twice. */
|
|
101
|
+
watchedDirs: Set<string>;
|
|
102
|
+
debounce: ReturnType<typeof setTimeout> | null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Owns the fs watchers behind diff hot-reload. `onRepoChange` fires once per
|
|
107
|
+
* debounced burst with the repo root that changed; the caller re-captures every
|
|
108
|
+
* live diff session on that root. A watcher error or an unwatchable root is
|
|
109
|
+
* swallowed - hot-reload is best-effort and must never crash the daemon.
|
|
110
|
+
*/
|
|
111
|
+
export class DiffWatcher {
|
|
112
|
+
private readonly repoWatches = new Map<string, RepoWatch>();
|
|
113
|
+
|
|
114
|
+
constructor(private readonly onRepoChange: (repoRoot: string) => void) {}
|
|
115
|
+
|
|
116
|
+
/** Start (or join) watching a repo root for one diff session. Idempotent per (root, session). */
|
|
117
|
+
trackDiffRepo(repoRoot: string, sessionId: string, vcs = "git"): void {
|
|
118
|
+
const existing = this.repoWatches.get(repoRoot);
|
|
119
|
+
|
|
120
|
+
if (existing) {
|
|
121
|
+
existing.sessionIds.add(sessionId);
|
|
122
|
+
if (vcs === "jj") this.startJjPoll(existing, repoRoot, sessionId);
|
|
123
|
+
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const repoWatch: RepoWatch = {
|
|
127
|
+
handles: [],
|
|
128
|
+
sessionIds: new Set([sessionId]),
|
|
129
|
+
jjSessionIds: new Set(),
|
|
130
|
+
jjPoll: null,
|
|
131
|
+
ignored: ignoredDirectories(repoRoot),
|
|
132
|
+
watchedDirs: new Set(),
|
|
133
|
+
debounce: null,
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
this.repoWatches.set(repoRoot, repoWatch);
|
|
137
|
+
if (vcs === "jj") this.startJjPoll(repoWatch, repoRoot, sessionId);
|
|
138
|
+
|
|
139
|
+
// one watcher per non-ignored working-tree directory; a tracked-file change re-captures
|
|
140
|
+
if (!this.watchTree(repoRoot, repoRoot)) {
|
|
141
|
+
// repo root gone or not watchable on this platform: skip, no hot-reload here
|
|
142
|
+
this.untrackDiffRepo(repoRoot, sessionId);
|
|
143
|
+
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// git metadata: a commit, checkout, or reset moves `git diff HEAD` without
|
|
148
|
+
// touching any working-tree file, so those never reach the tree watch above.
|
|
149
|
+
const gitDirs = resolveGitDirs(repoRoot);
|
|
150
|
+
|
|
151
|
+
if (gitDirs) {
|
|
152
|
+
// HEAD and packed-refs sit beside the index; the index rewrites on our own
|
|
153
|
+
// reads, so only HEAD/packed-refs here - watching the index would loop
|
|
154
|
+
this.watchInto(repoWatch.handles, gitDirs.gitDir, { recursive: false }, (filename) => {
|
|
155
|
+
if (isRefChange(filename)) this.scheduleRepoRefresh(repoRoot);
|
|
156
|
+
});
|
|
157
|
+
// a commit or reset updates refs/heads/<branch>; any ref move re-captures
|
|
158
|
+
this.watchInto(repoWatch.handles, join(gitDirs.commonDir, "refs"), { recursive: true }, () =>
|
|
159
|
+
this.scheduleRepoRefresh(repoRoot),
|
|
160
|
+
);
|
|
161
|
+
if (gitDirs.commonDir !== gitDirs.gitDir)
|
|
162
|
+
this.watchInto(repoWatch.handles, gitDirs.commonDir, { recursive: false }, (filename) => {
|
|
163
|
+
if (isRefChange(filename)) this.scheduleRepoRefresh(repoRoot);
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
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
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Watch `dir` (non-recursive) and every non-ignored directory under it, adding
|
|
176
|
+
* each to the repo's handles. Returns whether `dir` itself got a watcher, so
|
|
177
|
+
* the caller can tell a dead root from a merely-skipped subtree. A skipped
|
|
178
|
+
* (ignored or already-watched) directory counts as watched, not a failure.
|
|
179
|
+
*/
|
|
180
|
+
private watchTree(repoRoot: string, dir: string): boolean {
|
|
181
|
+
const repoWatch = this.repoWatches.get(repoRoot);
|
|
182
|
+
|
|
183
|
+
if (!repoWatch) return false;
|
|
184
|
+
if (repoWatch.watchedDirs.has(dir)) return true;
|
|
185
|
+
if (ALWAYS_IGNORED_DIRS.has(basename(dir)) || repoWatch.ignored.has(dir)) return true;
|
|
186
|
+
|
|
187
|
+
const opened = this.watchInto(repoWatch.handles, dir, { recursive: false }, (filename) => {
|
|
188
|
+
this.scheduleRepoRefresh(repoRoot);
|
|
189
|
+
// a newly created subdirectory needs its own watch (a recursive watch got this for free)
|
|
190
|
+
this.extendWatch(repoRoot, join(dir, filename));
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
if (!opened) return false;
|
|
194
|
+
repoWatch.watchedDirs.add(dir);
|
|
195
|
+
|
|
196
|
+
let entries: Dirent[];
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
200
|
+
} catch {
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
for (const entry of entries) {
|
|
204
|
+
// a symlinked directory is skipped so the walk cannot loop
|
|
205
|
+
if (entry.isDirectory() && !entry.isSymbolicLink())
|
|
206
|
+
this.watchTree(repoRoot, join(dir, entry.name));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* After a change names a new path, watch it (and its subtree) when it is a
|
|
214
|
+
* directory git does not ignore. The point-in-time ignored set cannot know a
|
|
215
|
+
* directory created after the walk, so a fresh dir is checked against git live.
|
|
216
|
+
*/
|
|
217
|
+
private extendWatch(repoRoot: string, path: string): void {
|
|
218
|
+
try {
|
|
219
|
+
if (!statSync(path).isDirectory()) return;
|
|
220
|
+
} catch {
|
|
221
|
+
// the path is already gone (a transient temp dir); nothing to watch
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (ALWAYS_IGNORED_DIRS.has(basename(path))) return;
|
|
225
|
+
if (isGitIgnored(repoRoot, path)) return;
|
|
226
|
+
this.watchTree(repoRoot, path);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Open one watcher and add it to `handles`; returns whether it opened. A
|
|
231
|
+
* watcher error (the path is deleted mid-review) is swallowed - hot-reload is
|
|
232
|
+
* best-effort and must never take down the daemon. The inherited EventEmitter
|
|
233
|
+
* `on` is absent from Bun's FSWatcher type.
|
|
234
|
+
*/
|
|
235
|
+
private watchInto(
|
|
236
|
+
handles: FSWatcher[],
|
|
237
|
+
path: string,
|
|
238
|
+
options: { recursive: boolean },
|
|
239
|
+
onFile: (filename: string) => void,
|
|
240
|
+
): boolean {
|
|
241
|
+
try {
|
|
242
|
+
const handle = watch(path, options, (_event, filename) => {
|
|
243
|
+
if (filename !== null) onFile(filename.toString());
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
handle.on("error", () => {});
|
|
247
|
+
handles.push(handle);
|
|
248
|
+
|
|
249
|
+
return true;
|
|
250
|
+
} catch {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Drop one diff session; close the repo watch once no diff session tracks it. */
|
|
256
|
+
untrackDiffRepo(repoRoot: string, sessionId: string): void {
|
|
257
|
+
const repoWatch = this.repoWatches.get(repoRoot);
|
|
258
|
+
|
|
259
|
+
if (!repoWatch) return;
|
|
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
|
+
}
|
|
266
|
+
if (repoWatch.sessionIds.size > 0) return;
|
|
267
|
+
if (repoWatch.debounce !== null) clearTimeout(repoWatch.debounce);
|
|
268
|
+
if (repoWatch.jjPoll !== null) clearInterval(repoWatch.jjPoll);
|
|
269
|
+
for (const handle of repoWatch.handles) handle.close();
|
|
270
|
+
this.repoWatches.delete(repoRoot);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private scheduleRepoRefresh(repoRoot: string): void {
|
|
274
|
+
const repoWatch = this.repoWatches.get(repoRoot);
|
|
275
|
+
|
|
276
|
+
if (!repoWatch) return;
|
|
277
|
+
if (repoWatch.debounce !== null) clearTimeout(repoWatch.debounce);
|
|
278
|
+
repoWatch.debounce = setTimeout(() => {
|
|
279
|
+
repoWatch.debounce = null;
|
|
280
|
+
this.onRepoChange(repoRoot);
|
|
281
|
+
}, DIFF_REFRESH_DEBOUNCE_MS);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Close every watcher and cancel pending debounces (daemon shutdown). */
|
|
285
|
+
close(): void {
|
|
286
|
+
for (const repoWatch of this.repoWatches.values()) {
|
|
287
|
+
if (repoWatch.debounce !== null) clearTimeout(repoWatch.debounce);
|
|
288
|
+
if (repoWatch.jjPoll !== null) clearInterval(repoWatch.jjPoll);
|
|
289
|
+
for (const handle of repoWatch.handles) handle.close();
|
|
290
|
+
}
|
|
291
|
+
this.repoWatches.clear();
|
|
292
|
+
}
|
|
293
|
+
}
|
package/src/gh.test.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { prSnapshot } from "./gh";
|
|
3
|
+
|
|
4
|
+
describe("prSnapshot", () => {
|
|
5
|
+
test("retries until the diff and head are one stable snapshot", async () => {
|
|
6
|
+
const refs = [
|
|
7
|
+
{ baseSha: "base-1", headSha: "head-1" },
|
|
8
|
+
{ baseSha: "base-1", headSha: "head-2" },
|
|
9
|
+
{ baseSha: "base-1", headSha: "head-2" },
|
|
10
|
+
{ baseSha: "base-1", headSha: "head-2" },
|
|
11
|
+
];
|
|
12
|
+
const patches = ["patch-1", "patch-2"];
|
|
13
|
+
|
|
14
|
+
const snapshot = await prSnapshot(
|
|
15
|
+
"https://github.com/org/repo/pull/1",
|
|
16
|
+
3,
|
|
17
|
+
async () => refs.shift() ?? null,
|
|
18
|
+
async () => patches.shift() ?? null,
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
expect(snapshot).toEqual({ patch: "patch-2", baseSha: "base-1", headSha: "head-2" });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("returns no snapshot when the head keeps moving", async () => {
|
|
25
|
+
let head = 0;
|
|
26
|
+
|
|
27
|
+
const snapshot = await prSnapshot(
|
|
28
|
+
"https://github.com/org/repo/pull/1",
|
|
29
|
+
2,
|
|
30
|
+
async () => ({ baseSha: "base", headSha: `head-${head++}` }),
|
|
31
|
+
async () => "patch",
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
expect(snapshot).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("retries when only the base moves", async () => {
|
|
38
|
+
const refs = [
|
|
39
|
+
{ baseSha: "base-1", headSha: "head" },
|
|
40
|
+
{ baseSha: "base-2", headSha: "head" },
|
|
41
|
+
{ baseSha: "base-2", headSha: "head" },
|
|
42
|
+
{ baseSha: "base-2", headSha: "head" },
|
|
43
|
+
];
|
|
44
|
+
const patches = ["old-base-patch", "new-base-patch"];
|
|
45
|
+
|
|
46
|
+
const snapshot = await prSnapshot(
|
|
47
|
+
"https://github.com/org/repo/pull/1",
|
|
48
|
+
3,
|
|
49
|
+
async () => refs.shift() ?? null,
|
|
50
|
+
async () => patches.shift() ?? null,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
expect(snapshot).toEqual({
|
|
54
|
+
patch: "new-base-patch",
|
|
55
|
+
baseSha: "base-2",
|
|
56
|
+
headSha: "head",
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
});
|
package/src/gh.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// gh CLI reads for PR reviews; auth is delegated to the user's gh. Reads only.
|
|
2
|
+
|
|
3
|
+
import * as v from "valibot";
|
|
4
|
+
|
|
5
|
+
export interface PullRequestRefs {
|
|
6
|
+
baseSha: string;
|
|
7
|
+
headSha: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const PullRequestRefsSchema = v.object({
|
|
11
|
+
baseRefOid: v.pipe(v.string(), v.nonEmpty()),
|
|
12
|
+
headRefOid: v.pipe(v.string(), v.nonEmpty()),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const ghBinary = (): string => process.env.CUELOOP_GH || "gh";
|
|
16
|
+
|
|
17
|
+
async function gh(args: string[]): Promise<{ code: number; stdout: string }> {
|
|
18
|
+
const process = Bun.spawn([ghBinary(), ...args], { stdout: "pipe", stderr: "ignore" });
|
|
19
|
+
const stdout = await new Response(process.stdout).text();
|
|
20
|
+
|
|
21
|
+
return { code: await process.exited, stdout };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The PR base and head commits, or null when gh is absent, unauthenticated, or the call fails. */
|
|
25
|
+
export async function prRefs(pr: string): Promise<PullRequestRefs | null> {
|
|
26
|
+
const { code, stdout } = await gh(["pr", "view", pr, "--json", "baseRefOid,headRefOid"]);
|
|
27
|
+
|
|
28
|
+
if (code !== 0) return null;
|
|
29
|
+
let input: unknown;
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
input = JSON.parse(stdout);
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const parsed = v.safeParse(PullRequestRefsSchema, input);
|
|
37
|
+
|
|
38
|
+
if (!parsed.success) return null;
|
|
39
|
+
|
|
40
|
+
return { baseSha: parsed.output.baseRefOid, headSha: parsed.output.headRefOid };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The PR unified diff as gh prints it, or null when the call fails. */
|
|
44
|
+
export async function prDiff(pr: string): Promise<string | null> {
|
|
45
|
+
const { code, stdout } = await gh(["pr", "diff", pr]);
|
|
46
|
+
|
|
47
|
+
if (code !== 0) return null;
|
|
48
|
+
|
|
49
|
+
return stdout;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A stable PR diff and the exact base/head pair it represents. */
|
|
53
|
+
export async function prSnapshot(
|
|
54
|
+
pr: string,
|
|
55
|
+
attempts = 3,
|
|
56
|
+
readRefs: (reference: string) => Promise<PullRequestRefs | null> = prRefs,
|
|
57
|
+
readDiff: (reference: string) => Promise<string | null> = prDiff,
|
|
58
|
+
): Promise<{ patch: string; baseSha: string; headSha: string } | null> {
|
|
59
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
60
|
+
const before = await readRefs(pr);
|
|
61
|
+
|
|
62
|
+
if (before === null) return null;
|
|
63
|
+
const patch = await readDiff(pr);
|
|
64
|
+
|
|
65
|
+
if (patch === null) return null;
|
|
66
|
+
const after = await readRefs(pr);
|
|
67
|
+
|
|
68
|
+
if (after === null) return null;
|
|
69
|
+
if (before.baseSha === after.baseSha && before.headSha === after.headSha)
|
|
70
|
+
return { patch, ...after };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { GhosttyThreadSurfaceStore } from "./ghostty-thread-surface-store";
|
|
6
|
+
import { ghosttyThreadSurfacesPath } from "./paths";
|
|
7
|
+
|
|
8
|
+
let home: string;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-ghostty-store-"));
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
rmSync(home, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("GhosttyThreadSurfaceStore", () => {
|
|
19
|
+
test("persists terminal IDs outside Thread records and reloads them", () => {
|
|
20
|
+
const store = new GhosttyThreadSurfaceStore(home);
|
|
21
|
+
|
|
22
|
+
store.set("ses_one", { terminalId: "term-1" });
|
|
23
|
+
expect(new GhosttyThreadSurfaceStore(home).get("ses_one")).toEqual({ terminalId: "term-1" });
|
|
24
|
+
expect(JSON.parse(readFileSync(ghosttyThreadSurfacesPath(home), "utf8"))).toEqual({
|
|
25
|
+
ses_one: { terminalId: "term-1" },
|
|
26
|
+
});
|
|
27
|
+
store.delete("ses_one");
|
|
28
|
+
expect(new GhosttyThreadSurfaceStore(home).get("ses_one")).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("ignores malformed scratch state", () => {
|
|
32
|
+
writeFileSync(ghosttyThreadSurfacesPath(home), '{"ses_one":{"terminalId":3}}');
|
|
33
|
+
|
|
34
|
+
expect(new GhosttyThreadSurfaceStore(home).get("ses_one")).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("reserves a Thread before launch and fails closed across daemon restart", () => {
|
|
38
|
+
const store = new GhosttyThreadSurfaceStore(home);
|
|
39
|
+
|
|
40
|
+
expect(store.claim("ses_one")).toBe(true);
|
|
41
|
+
expect(store.claim("ses_one")).toBe(false);
|
|
42
|
+
expect(new GhosttyThreadSurfaceStore(home).claim("ses_one")).toBe(false);
|
|
43
|
+
store.set("ses_one", { terminalId: "term-1" });
|
|
44
|
+
expect(new GhosttyThreadSurfaceStore(home).claim("ses_one")).toBe(true);
|
|
45
|
+
store.release("ses_one");
|
|
46
|
+
expect(new GhosttyThreadSurfaceStore(home).get("ses_one")).toEqual({ terminalId: "term-1" });
|
|
47
|
+
expect(new GhosttyThreadSurfaceStore(home).claim("ses_one")).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import * as v from "valibot";
|
|
3
|
+
import { ghosttyThreadSurfacesPath } from "./paths";
|
|
4
|
+
|
|
5
|
+
export interface GhosttyThreadSurfaceHandle {
|
|
6
|
+
terminalId: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface GhosttyThreadSurfaceRecord {
|
|
10
|
+
terminalId?: string;
|
|
11
|
+
opening?: true;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const RecordSchema = v.object({
|
|
15
|
+
terminalId: v.optional(v.string()),
|
|
16
|
+
opening: v.optional(v.literal(true)),
|
|
17
|
+
});
|
|
18
|
+
const MapSchema = v.record(v.string(), v.unknown());
|
|
19
|
+
|
|
20
|
+
export class GhosttyThreadSurfaceStore {
|
|
21
|
+
private surfaces = new Map<string, GhosttyThreadSurfaceRecord>();
|
|
22
|
+
private readonly path: string;
|
|
23
|
+
|
|
24
|
+
constructor(home: string) {
|
|
25
|
+
this.path = ghosttyThreadSurfacesPath(home);
|
|
26
|
+
this.load();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
private load(): void {
|
|
30
|
+
try {
|
|
31
|
+
const parsed = v.parse(MapSchema, JSON.parse(readFileSync(this.path, "utf8")));
|
|
32
|
+
|
|
33
|
+
for (const [threadId, value] of Object.entries(parsed)) {
|
|
34
|
+
const handle = v.safeParse(RecordSchema, value);
|
|
35
|
+
|
|
36
|
+
if (handle.success) {
|
|
37
|
+
const record = handle.output;
|
|
38
|
+
|
|
39
|
+
this.surfaces.set(
|
|
40
|
+
threadId,
|
|
41
|
+
record.opening && record.terminalId ? { terminalId: record.terminalId } : record,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
// Scratch corruption cannot change a pending Thread.
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private persist(): void {
|
|
51
|
+
const tempPath = this.path + ".tmp";
|
|
52
|
+
|
|
53
|
+
writeFileSync(tempPath, JSON.stringify(Object.fromEntries(this.surfaces), null, 2));
|
|
54
|
+
renameSync(tempPath, this.path);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
get(threadId: string): GhosttyThreadSurfaceHandle | null {
|
|
58
|
+
const terminalId = this.surfaces.get(threadId)?.terminalId;
|
|
59
|
+
|
|
60
|
+
return terminalId ? { terminalId } : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Persist a launch reservation before any AppleScript surface can be created. */
|
|
64
|
+
claim(threadId: string): boolean {
|
|
65
|
+
const previous = this.surfaces.get(threadId);
|
|
66
|
+
|
|
67
|
+
if (previous?.opening) return false;
|
|
68
|
+
this.surfaces.set(threadId, { ...previous, opening: true });
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
this.persist();
|
|
72
|
+
|
|
73
|
+
return true;
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (previous) this.surfaces.set(threadId, previous);
|
|
76
|
+
else this.surfaces.delete(threadId);
|
|
77
|
+
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
release(threadId: string): void {
|
|
83
|
+
const record = this.surfaces.get(threadId);
|
|
84
|
+
|
|
85
|
+
if (!record?.opening) return;
|
|
86
|
+
if (record.terminalId) this.surfaces.set(threadId, { terminalId: record.terminalId });
|
|
87
|
+
else this.surfaces.delete(threadId);
|
|
88
|
+
this.persist();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
set(threadId: string, handle: GhosttyThreadSurfaceHandle): void {
|
|
92
|
+
const opening = this.surfaces.get(threadId)?.opening;
|
|
93
|
+
|
|
94
|
+
this.surfaces.set(threadId, opening ? { ...handle, opening } : handle);
|
|
95
|
+
this.persist();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
delete(threadId: string): void {
|
|
99
|
+
if (this.surfaces.delete(threadId)) this.persist();
|
|
100
|
+
}
|
|
101
|
+
}
|