@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,355 @@
|
|
|
1
|
+
import {
|
|
2
|
+
detectHerdr,
|
|
3
|
+
type HerdrEnv,
|
|
4
|
+
type Thread,
|
|
5
|
+
type ThreadSurfaceOpenStatus,
|
|
6
|
+
} from "@cueloop/schema";
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import * as v from "valibot";
|
|
9
|
+
import type { HerdrThreadSurfaceHandle } from "./herdr-thread-surface-store";
|
|
10
|
+
import { loadHerdrThreadSurface, type HerdrThreadSurface } from "./thread-surface-config";
|
|
11
|
+
|
|
12
|
+
const HERDR_SPAWN_TIMEOUT_MS = 2000;
|
|
13
|
+
|
|
14
|
+
interface HerdrCommandResult {
|
|
15
|
+
exitCode: number;
|
|
16
|
+
stdout: Buffer;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function runHerdrCommand(
|
|
20
|
+
command: string[],
|
|
21
|
+
options: { stdout: "pipe" | "ignore"; stderr: "ignore"; timeout: number },
|
|
22
|
+
): HerdrCommandResult {
|
|
23
|
+
const result = spawnSync(command[0]!, command.slice(1), {
|
|
24
|
+
stdio: ["ignore", options.stdout, options.stderr],
|
|
25
|
+
timeout: options.timeout,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
return { exitCode: result.status ?? 1, stdout: result.stdout ?? Buffer.alloc(0) };
|
|
29
|
+
}
|
|
30
|
+
const CreatedTabSchema = v.object({
|
|
31
|
+
result: v.optional(
|
|
32
|
+
v.object({
|
|
33
|
+
root_pane: v.optional(
|
|
34
|
+
v.object({ pane_id: v.optional(v.string()), tab_id: v.optional(v.string()) }),
|
|
35
|
+
),
|
|
36
|
+
}),
|
|
37
|
+
),
|
|
38
|
+
});
|
|
39
|
+
const PaneResultSchema = v.object({
|
|
40
|
+
result: v.optional(v.object({ pane: v.optional(v.unknown()) })),
|
|
41
|
+
});
|
|
42
|
+
const CreatedPaneSchema = v.object({
|
|
43
|
+
result: v.optional(v.object({ pane: v.optional(v.object({ pane_id: v.string() })) })),
|
|
44
|
+
});
|
|
45
|
+
const PaneNeighborSchema = v.object({
|
|
46
|
+
result: v.object({
|
|
47
|
+
neighbor: v.object({ neighbor_pane_id: v.optional(v.nullable(v.string())) }),
|
|
48
|
+
}),
|
|
49
|
+
});
|
|
50
|
+
const PaneFocusSchema = v.object({
|
|
51
|
+
result: v.optional(
|
|
52
|
+
v.object({ focus: v.optional(v.object({ focused_pane_id: v.nullable(v.string()) })) }),
|
|
53
|
+
),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/** Inputs for a new Herdr Thread tab. */
|
|
57
|
+
export interface OpenHerdrThreadTabOptions {
|
|
58
|
+
sessionId: string;
|
|
59
|
+
cwd: string;
|
|
60
|
+
binPath: string;
|
|
61
|
+
label: string;
|
|
62
|
+
workspaceId?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Daemon-owned native handles, separate from canonical Thread records. */
|
|
66
|
+
export interface HerdrThreadSurfacePersistence {
|
|
67
|
+
herdrGetThreadSurface(sessionId: string): Promise<HerdrThreadSurfaceHandle | null>;
|
|
68
|
+
herdrSetThreadSurface(sessionId: string, handle: HerdrThreadSurfaceHandle): Promise<void>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Open a focused Herdr tab; keep both native IDs for later liveness checks. */
|
|
72
|
+
export function openHerdrThreadTab(
|
|
73
|
+
options: OpenHerdrThreadTabOptions,
|
|
74
|
+
): HerdrThreadSurfaceHandle | null {
|
|
75
|
+
const { sessionId, cwd, binPath, label, workspaceId } = options;
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const created = runHerdrCommand(
|
|
79
|
+
[
|
|
80
|
+
binPath,
|
|
81
|
+
"tab",
|
|
82
|
+
"create",
|
|
83
|
+
...(workspaceId ? ["--workspace", workspaceId] : []),
|
|
84
|
+
"--cwd",
|
|
85
|
+
cwd,
|
|
86
|
+
"--label",
|
|
87
|
+
label,
|
|
88
|
+
"--focus",
|
|
89
|
+
],
|
|
90
|
+
{ stdout: "pipe", stderr: "ignore", timeout: HERDR_SPAWN_TIMEOUT_MS },
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
if (created.exitCode !== 0) return null;
|
|
94
|
+
const parsed = v.safeParse(CreatedTabSchema, JSON.parse(created.stdout.toString()));
|
|
95
|
+
|
|
96
|
+
if (!parsed.success) return null;
|
|
97
|
+
const paneId = parsed.output.result?.root_pane?.pane_id;
|
|
98
|
+
const tabId = parsed.output.result?.root_pane?.tab_id;
|
|
99
|
+
|
|
100
|
+
if (!paneId || !tabId) return null;
|
|
101
|
+
|
|
102
|
+
if (!sendCueloopThreadCommand(binPath, paneId, sessionId)) {
|
|
103
|
+
closeUnlaunchedHerdrSurface(binPath, "tab", tabId);
|
|
104
|
+
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { tabId, paneId };
|
|
109
|
+
} catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Inputs for a focused right-hand Herdr Thread pane. */
|
|
115
|
+
export interface OpenHerdrThreadPaneOptions {
|
|
116
|
+
sessionId: string;
|
|
117
|
+
cwd: string;
|
|
118
|
+
binPath: string;
|
|
119
|
+
sourcePaneId: string;
|
|
120
|
+
tabId: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Split the calling Herdr pane right at 50 percent and launch cueloop in it. */
|
|
124
|
+
export function openHerdrThreadPane(
|
|
125
|
+
options: OpenHerdrThreadPaneOptions,
|
|
126
|
+
): HerdrThreadSurfaceHandle | null {
|
|
127
|
+
const { sessionId, cwd, binPath, sourcePaneId, tabId } = options;
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
const created = runHerdrCommand(
|
|
131
|
+
[
|
|
132
|
+
binPath,
|
|
133
|
+
"pane",
|
|
134
|
+
"split",
|
|
135
|
+
sourcePaneId,
|
|
136
|
+
"--direction",
|
|
137
|
+
"right",
|
|
138
|
+
"--ratio",
|
|
139
|
+
"0.5",
|
|
140
|
+
"--cwd",
|
|
141
|
+
cwd,
|
|
142
|
+
"--focus",
|
|
143
|
+
],
|
|
144
|
+
{ stdout: "pipe", stderr: "ignore", timeout: HERDR_SPAWN_TIMEOUT_MS },
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
if (created.exitCode !== 0) return null;
|
|
148
|
+
const parsed = v.safeParse(CreatedPaneSchema, JSON.parse(created.stdout.toString()));
|
|
149
|
+
const paneId = parsed.success ? parsed.output.result?.pane?.pane_id : undefined;
|
|
150
|
+
|
|
151
|
+
if (!paneId) return null;
|
|
152
|
+
if (!sendCueloopThreadCommand(binPath, paneId, sessionId)) {
|
|
153
|
+
closeUnlaunchedHerdrSurface(binPath, "pane", paneId);
|
|
154
|
+
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return { mode: "pane", tabId, paneId };
|
|
159
|
+
} catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function closeUnlaunchedHerdrSurface(binPath: string, kind: "tab" | "pane", id: string): void {
|
|
165
|
+
try {
|
|
166
|
+
runHerdrCommand([binPath, kind, "close", id], {
|
|
167
|
+
stdout: "ignore",
|
|
168
|
+
stderr: "ignore",
|
|
169
|
+
timeout: HERDR_SPAWN_TIMEOUT_MS,
|
|
170
|
+
});
|
|
171
|
+
} catch {
|
|
172
|
+
// The pending Thread remains available through the manual command.
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function sendCueloopThreadCommand(binPath: string, paneId: string, sessionId: string): boolean {
|
|
177
|
+
const typed = runHerdrCommand([binPath, "pane", "send-text", paneId, `cueloop ${sessionId}`], {
|
|
178
|
+
stdout: "ignore",
|
|
179
|
+
stderr: "ignore",
|
|
180
|
+
timeout: HERDR_SPAWN_TIMEOUT_MS,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
if (typed.exitCode !== 0) return false;
|
|
184
|
+
const entered = runHerdrCommand([binPath, "pane", "send-keys", paneId, "enter"], {
|
|
185
|
+
stdout: "ignore",
|
|
186
|
+
stderr: "ignore",
|
|
187
|
+
timeout: HERDR_SPAWN_TIMEOUT_MS,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
return entered.exitCode === 0;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Check the recorded pane ID before deciding whether to reopen. */
|
|
194
|
+
function herdrPaneAlive(binPath: string, paneId: string): boolean {
|
|
195
|
+
try {
|
|
196
|
+
const got = runHerdrCommand([binPath, "pane", "get", paneId], {
|
|
197
|
+
stdout: "pipe",
|
|
198
|
+
stderr: "ignore",
|
|
199
|
+
timeout: HERDR_SPAWN_TIMEOUT_MS,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
if (got.exitCode !== 0) return false;
|
|
203
|
+
const parsed = v.safeParse(PaneResultSchema, JSON.parse(got.stdout.toString()));
|
|
204
|
+
|
|
205
|
+
return parsed.success && parsed.output.result?.pane != null;
|
|
206
|
+
} catch {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Focus a recorded Herdr tab. */
|
|
212
|
+
function focusHerdrTab(binPath: string, tabId: string): boolean {
|
|
213
|
+
try {
|
|
214
|
+
const focused = runHerdrCommand([binPath, "tab", "focus", tabId], {
|
|
215
|
+
stdout: "ignore",
|
|
216
|
+
stderr: "ignore",
|
|
217
|
+
timeout: HERDR_SPAWN_TIMEOUT_MS,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
return focused.exitCode === 0;
|
|
221
|
+
} catch {
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function focusHerdrThreadSurface(binPath: string, handle: HerdrThreadSurfaceHandle): boolean {
|
|
227
|
+
if (!focusHerdrTab(binPath, handle.tabId)) return false;
|
|
228
|
+
|
|
229
|
+
if (handle.mode !== "pane") return true;
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
const directions = [
|
|
233
|
+
["left", "right"],
|
|
234
|
+
["right", "left"],
|
|
235
|
+
["up", "down"],
|
|
236
|
+
["down", "up"],
|
|
237
|
+
] as const;
|
|
238
|
+
let hasNeighbor = false;
|
|
239
|
+
|
|
240
|
+
for (const [direction, opposite] of directions) {
|
|
241
|
+
const neighbor = herdrPaneNeighbor(binPath, handle.paneId, direction);
|
|
242
|
+
|
|
243
|
+
if (neighbor === undefined) return false;
|
|
244
|
+
if (neighbor === null) continue;
|
|
245
|
+
hasNeighbor = true;
|
|
246
|
+
|
|
247
|
+
if (herdrPaneNeighbor(binPath, neighbor, opposite) !== handle.paneId) continue;
|
|
248
|
+
const focused = runHerdrCommand(
|
|
249
|
+
[binPath, "pane", "focus", "--pane", neighbor, "--direction", opposite],
|
|
250
|
+
{ stdout: "pipe", stderr: "ignore", timeout: HERDR_SPAWN_TIMEOUT_MS },
|
|
251
|
+
);
|
|
252
|
+
const parsed = v.safeParse(PaneFocusSchema, JSON.parse(focused.stdout.toString()));
|
|
253
|
+
|
|
254
|
+
if (
|
|
255
|
+
focused.exitCode === 0 &&
|
|
256
|
+
parsed.success &&
|
|
257
|
+
parsed.output.result?.focus?.focused_pane_id === handle.paneId
|
|
258
|
+
) {
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return !hasNeighbor;
|
|
264
|
+
} catch {
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function herdrPaneNeighbor(
|
|
270
|
+
binPath: string,
|
|
271
|
+
paneId: string,
|
|
272
|
+
direction: string,
|
|
273
|
+
): string | null | undefined {
|
|
274
|
+
try {
|
|
275
|
+
const neighbor = runHerdrCommand(
|
|
276
|
+
[binPath, "pane", "neighbor", "--pane", paneId, "--direction", direction],
|
|
277
|
+
{ stdout: "pipe", stderr: "ignore", timeout: HERDR_SPAWN_TIMEOUT_MS },
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
if (neighbor.exitCode !== 0) return undefined;
|
|
281
|
+
const parsed = v.safeParse(PaneNeighborSchema, JSON.parse(neighbor.stdout.toString()));
|
|
282
|
+
|
|
283
|
+
return parsed.success ? (parsed.output.result.neighbor.neighbor_pane_id ?? null) : undefined;
|
|
284
|
+
} catch {
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Reuse a live Thread surface, reopen a closed one, or report a manual fallback. */
|
|
290
|
+
export async function openHerdrThreadSurface(
|
|
291
|
+
session: Thread,
|
|
292
|
+
persistence: HerdrThreadSurfacePersistence,
|
|
293
|
+
env: HerdrEnv = process.env,
|
|
294
|
+
mode: HerdrThreadSurface = loadHerdrThreadSurface(),
|
|
295
|
+
): Promise<ThreadSurfaceOpenStatus> {
|
|
296
|
+
const herdr = detectHerdr(env);
|
|
297
|
+
|
|
298
|
+
if (!herdr) return "unavailable";
|
|
299
|
+
|
|
300
|
+
if (mode === "none") return "disabled";
|
|
301
|
+
const recorded = await recallHerdrThreadSurface(persistence, session.id);
|
|
302
|
+
|
|
303
|
+
if (recorded && herdrPaneAlive(herdr.binPath, recorded.paneId)) {
|
|
304
|
+
return focusHerdrThreadSurface(herdr.binPath, recorded) ? "focused" : "failed";
|
|
305
|
+
}
|
|
306
|
+
const cwd = session.artifact.meta.cwd ?? session.workspace.repoRoot;
|
|
307
|
+
const opened =
|
|
308
|
+
mode === "pane"
|
|
309
|
+
? env.HERDR_TAB_ID
|
|
310
|
+
? openHerdrThreadPane({
|
|
311
|
+
sessionId: session.id,
|
|
312
|
+
cwd,
|
|
313
|
+
binPath: herdr.binPath,
|
|
314
|
+
sourcePaneId: herdr.paneId,
|
|
315
|
+
tabId: env.HERDR_TAB_ID,
|
|
316
|
+
})
|
|
317
|
+
: null
|
|
318
|
+
: openHerdrThreadTab({
|
|
319
|
+
sessionId: session.id,
|
|
320
|
+
cwd,
|
|
321
|
+
binPath: herdr.binPath,
|
|
322
|
+
label: session.artifact.meta.title ?? session.id,
|
|
323
|
+
workspaceId: env.HERDR_WORKSPACE_ID,
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
if (!opened) return "failed";
|
|
327
|
+
await rememberHerdrThreadSurface(persistence, session.id, opened);
|
|
328
|
+
|
|
329
|
+
return "opened";
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** A stale daemon cannot prevent the reviewer from opening the pending Thread. */
|
|
333
|
+
async function recallHerdrThreadSurface(
|
|
334
|
+
persistence: HerdrThreadSurfacePersistence,
|
|
335
|
+
sessionId: string,
|
|
336
|
+
): Promise<HerdrThreadSurfaceHandle | null> {
|
|
337
|
+
try {
|
|
338
|
+
return await persistence.herdrGetThreadSurface(sessionId);
|
|
339
|
+
} catch {
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Handle persistence is best-effort after the new terminal has opened. */
|
|
345
|
+
async function rememberHerdrThreadSurface(
|
|
346
|
+
persistence: HerdrThreadSurfacePersistence,
|
|
347
|
+
sessionId: string,
|
|
348
|
+
handle: HerdrThreadSurfaceHandle,
|
|
349
|
+
): Promise<void> {
|
|
350
|
+
try {
|
|
351
|
+
await persistence.herdrSetThreadSurface(sessionId, handle);
|
|
352
|
+
} catch {
|
|
353
|
+
// The Thread remains pending even if the old daemon cannot store this handle.
|
|
354
|
+
}
|
|
355
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
export
|
|
8
|
-
export
|
|
9
|
-
export { DaemonClient, DaemonClientError, type ConnectOptions } from "./client";
|
|
1
|
+
/**
|
|
2
|
+
* The daemon's public surface. Consumers reach the client and the review
|
|
3
|
+
* helpers through the ./client and ./thread-review subpaths; this barrel carries
|
|
4
|
+
* only the two names imported bare: the server and the home-dir resolver.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { DaemonServer, type DaemonOptions } from "./server";
|
|
8
|
+
export { cueloopHome } from "./paths";
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { ABORTED, pollUntilResolved, raceAbort } from "./interruptible-wait";
|
|
3
|
+
|
|
4
|
+
function deferred<T>() {
|
|
5
|
+
let resolve!: (value: T) => void;
|
|
6
|
+
let reject!: (cause: unknown) => void;
|
|
7
|
+
const promise = new Promise<T>((res, rej) => {
|
|
8
|
+
resolve = res;
|
|
9
|
+
reject = rej;
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
return { promise, resolve, reject };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("raceAbort", () => {
|
|
16
|
+
test("without a signal the promise passes straight through", async () => {
|
|
17
|
+
expect(await raceAbort(Promise.resolve("value"), undefined)).toBe("value");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("resolves to the value when the promise settles before any abort", async () => {
|
|
21
|
+
const controller = new AbortController();
|
|
22
|
+
|
|
23
|
+
expect(await raceAbort(Promise.resolve(7), controller.signal)).toBe(7);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("an already-aborted signal returns ABORTED without waiting", async () => {
|
|
27
|
+
const controller = new AbortController();
|
|
28
|
+
|
|
29
|
+
controller.abort();
|
|
30
|
+
|
|
31
|
+
expect(await raceAbort(new Promise<never>(() => {}), controller.signal)).toBe(ABORTED);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("a signal that fires during the wait returns ABORTED", async () => {
|
|
35
|
+
const controller = new AbortController();
|
|
36
|
+
const pending = deferred<number>();
|
|
37
|
+
const raced = raceAbort(pending.promise, controller.signal);
|
|
38
|
+
|
|
39
|
+
controller.abort();
|
|
40
|
+
|
|
41
|
+
expect(await raced).toBe(ABORTED);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("a rejection propagates the original error unchanged", async () => {
|
|
45
|
+
const controller = new AbortController();
|
|
46
|
+
const original = new Error("daemon socket closed");
|
|
47
|
+
let caught: unknown;
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
await raceAbort(Promise.reject(original), controller.signal);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
caught = error;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
expect(caught).toBe(original);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("a rejection that lands after an abort is swallowed, not surfaced", async () => {
|
|
59
|
+
const controller = new AbortController();
|
|
60
|
+
const pending = deferred<number>();
|
|
61
|
+
const raced = raceAbort(pending.promise, controller.signal);
|
|
62
|
+
|
|
63
|
+
controller.abort();
|
|
64
|
+
const result = await raced;
|
|
65
|
+
|
|
66
|
+
pending.reject(new Error("late daemon error"));
|
|
67
|
+
await Bun.sleep(10);
|
|
68
|
+
|
|
69
|
+
expect(result).toBe(ABORTED);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("pollUntilResolved", () => {
|
|
74
|
+
test("returns the first non-null attempt", async () => {
|
|
75
|
+
expect(await pollUntilResolved(() => Promise.resolve("done"), undefined)).toBe("done");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("keeps polling past null attempts until one resolves", async () => {
|
|
79
|
+
let calls = 0;
|
|
80
|
+
const attempt = () => {
|
|
81
|
+
calls++;
|
|
82
|
+
|
|
83
|
+
return Promise.resolve(calls < 3 ? null : "third");
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
expect(await pollUntilResolved(attempt, undefined)).toBe("third");
|
|
87
|
+
expect(calls).toBe(3);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("an already-aborted signal returns null before any attempt", async () => {
|
|
91
|
+
const controller = new AbortController();
|
|
92
|
+
let calls = 0;
|
|
93
|
+
|
|
94
|
+
controller.abort();
|
|
95
|
+
const result = await pollUntilResolved(() => {
|
|
96
|
+
calls++;
|
|
97
|
+
|
|
98
|
+
return Promise.resolve("value");
|
|
99
|
+
}, controller.signal);
|
|
100
|
+
|
|
101
|
+
expect(result).toBeNull();
|
|
102
|
+
expect(calls).toBe(0);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("a signal that fires during a chunk returns null", async () => {
|
|
106
|
+
const controller = new AbortController();
|
|
107
|
+
const pending = deferred<string | null>();
|
|
108
|
+
const polling = pollUntilResolved(() => pending.promise, controller.signal);
|
|
109
|
+
|
|
110
|
+
controller.abort();
|
|
111
|
+
|
|
112
|
+
expect(await polling).toBeNull();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("a rejected attempt propagates the original error", async () => {
|
|
116
|
+
const original = new Error("attempt failed");
|
|
117
|
+
let caught: unknown;
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
await pollUntilResolved(() => Promise.reject(original), undefined);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
caught = error;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
expect(caught).toBe(original);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Cause, Effect, Exit } from "effect";
|
|
2
|
+
|
|
3
|
+
export const ABORTED: unique symbol = Symbol("aborted");
|
|
4
|
+
|
|
5
|
+
const whenSignalAborts = (signal: AbortSignal): Effect.Effect<void> =>
|
|
6
|
+
Effect.callback<void>((resume) => {
|
|
7
|
+
if (signal.aborted) {
|
|
8
|
+
resume(Effect.void);
|
|
9
|
+
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
const onAbort = () => resume(Effect.void);
|
|
13
|
+
|
|
14
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
15
|
+
|
|
16
|
+
return Effect.sync(() => signal.removeEventListener("abort", onAbort));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const fromSettledPromise = <T>(start: () => Promise<T>): Effect.Effect<T, unknown> =>
|
|
20
|
+
Effect.callback<T, unknown>((resume) => {
|
|
21
|
+
start().then(
|
|
22
|
+
(value) => resume(Effect.succeed(value)),
|
|
23
|
+
(cause: unknown) => resume(Effect.fail(cause)),
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
async function runToPromise<T>(effect: Effect.Effect<T, unknown>): Promise<T> {
|
|
28
|
+
const exit = await Effect.runPromiseExit(effect);
|
|
29
|
+
|
|
30
|
+
if (Exit.isSuccess(exit)) return exit.value;
|
|
31
|
+
|
|
32
|
+
throw Cause.squash(exit.cause);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function raceAbort<T>(
|
|
36
|
+
promise: Promise<T>,
|
|
37
|
+
signal: AbortSignal | undefined,
|
|
38
|
+
): Promise<T | typeof ABORTED> {
|
|
39
|
+
if (!signal) return promise;
|
|
40
|
+
if (signal.aborted) {
|
|
41
|
+
promise.catch(() => {});
|
|
42
|
+
|
|
43
|
+
return Promise.resolve(ABORTED);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return runToPromise(
|
|
47
|
+
Effect.raceFirst(
|
|
48
|
+
fromSettledPromise(() => promise),
|
|
49
|
+
whenSignalAborts(signal).pipe(Effect.as<T | typeof ABORTED>(ABORTED)),
|
|
50
|
+
),
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function pollUntilResolved<T>(
|
|
55
|
+
attempt: () => Promise<T | null>,
|
|
56
|
+
signal: AbortSignal | undefined,
|
|
57
|
+
): Promise<T | null> {
|
|
58
|
+
if (signal?.aborted) return Promise.resolve(null);
|
|
59
|
+
const loop = Effect.repeat(fromSettledPromise(attempt), {
|
|
60
|
+
until: (result): result is T => result !== null,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
if (!signal) return runToPromise(loop);
|
|
64
|
+
|
|
65
|
+
return runToPromise(Effect.raceFirst(loop, whenSignalAborts(signal).pipe(Effect.as(null))));
|
|
66
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/** Native Jujutsu capture pinned to one commit after its working-copy snapshot. */
|
|
2
|
+
|
|
3
|
+
import type { DiffFileContents, DiffFileStatus } from "@cueloop/schema";
|
|
4
|
+
import type { VcsAdapter, VcsDiffSnapshot } from "@cueloop/extension-api";
|
|
5
|
+
|
|
6
|
+
async function jj(args: string[], cwd: string): Promise<string | null> {
|
|
7
|
+
try {
|
|
8
|
+
const child = Bun.spawn(["jj", ...args], { cwd, stdout: "pipe", stderr: "ignore" });
|
|
9
|
+
const output = await new Response(child.stdout).text();
|
|
10
|
+
|
|
11
|
+
return (await child.exited) === 0 ? output : null;
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const JJ_DIFF_FILES_TEMPLATE = 'status_char ++ "\\x00" ++ path.display() ++ "\\x00"';
|
|
18
|
+
const JJ_FILE_LIST_TEMPLATE = 'path.display() ++ "\\x00"';
|
|
19
|
+
|
|
20
|
+
function jjSummary(text: string): { path: string; status: DiffFileStatus; curatable: boolean }[] {
|
|
21
|
+
const tokens = text.split("\0");
|
|
22
|
+
const changes: { path: string; status: DiffFileStatus; curatable: boolean }[] = [];
|
|
23
|
+
|
|
24
|
+
for (let index = 0; index + 1 < tokens.length; index += 2) {
|
|
25
|
+
const code = tokens[index];
|
|
26
|
+
const path = tokens[index + 1];
|
|
27
|
+
|
|
28
|
+
if (!code || !path) continue;
|
|
29
|
+
changes.push({
|
|
30
|
+
path,
|
|
31
|
+
status: code === "A" ? "added" : code === "D" ? "deleted" : "modified",
|
|
32
|
+
curatable: code === "A" || code === "M" || code === "D",
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return changes;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isBinary(contents: string): boolean {
|
|
40
|
+
return contents.includes("\0");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function jjFileContents(
|
|
44
|
+
root: string,
|
|
45
|
+
revision: string,
|
|
46
|
+
path: string,
|
|
47
|
+
): Promise<string | null> {
|
|
48
|
+
return jj(["--ignore-working-copy", "file", "show", "-r", revision, "--", path], root);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function captureJjRevision(repoRoot: string, revset: string): Promise<VcsDiffSnapshot> {
|
|
52
|
+
const identity = await jj(
|
|
53
|
+
[
|
|
54
|
+
"log",
|
|
55
|
+
"-r",
|
|
56
|
+
revset,
|
|
57
|
+
"-T",
|
|
58
|
+
'change_id ++ "\\n" ++ commit_id ++ "\\n" ++ parents.map(|c| c.commit_id()).join(",") ++ "\\n"',
|
|
59
|
+
"--no-graph",
|
|
60
|
+
],
|
|
61
|
+
repoRoot,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
if (!identity) throw new Error(`Jujutsu diff capture failed: could not resolve ${revset}`);
|
|
65
|
+
const lines = identity.trim().split("\n");
|
|
66
|
+
|
|
67
|
+
if (lines.length !== 3) throw new Error(`Jujutsu change is ambiguous: ${revset}`);
|
|
68
|
+
const [changeId, revisionId, parents] = lines;
|
|
69
|
+
|
|
70
|
+
if (!changeId || !revisionId) throw new Error("Jujutsu diff capture failed: invalid identity");
|
|
71
|
+
const [patch, summary] = await Promise.all([
|
|
72
|
+
jj(["--ignore-working-copy", "diff", "--git", "-r", revisionId], repoRoot),
|
|
73
|
+
jj(["--ignore-working-copy", "diff", "-T", JJ_DIFF_FILES_TEMPLATE, "-r", revisionId], repoRoot),
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
if (patch === null || summary === null)
|
|
77
|
+
throw new Error("Jujutsu diff capture failed: could not read pinned revision");
|
|
78
|
+
const files: DiffFileContents[] = [];
|
|
79
|
+
const parent = parents?.split(",")[0];
|
|
80
|
+
|
|
81
|
+
if (parent && !parents?.includes(",")) {
|
|
82
|
+
for (const change of jjSummary(summary)) {
|
|
83
|
+
if (!change.curatable) continue;
|
|
84
|
+
const oldContents =
|
|
85
|
+
change.status === "added" ? "" : await jjFileContents(repoRoot, parent, change.path);
|
|
86
|
+
const newContents =
|
|
87
|
+
change.status === "deleted" ? "" : await jjFileContents(repoRoot, revisionId, change.path);
|
|
88
|
+
|
|
89
|
+
if (oldContents === null || newContents === null) continue;
|
|
90
|
+
if (isBinary(oldContents) || isBinary(newContents)) continue;
|
|
91
|
+
files.push({ path: change.path, oldContents, newContents, status: change.status });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { patch: patch.replace(/\n+$/, ""), files, source: { changeId, revisionId } };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Built-in jj adapter uses native change and commit IDs in a colocated checkout. */
|
|
99
|
+
export const jjVcsAdapter: VcsAdapter = {
|
|
100
|
+
apiVersion: 1,
|
|
101
|
+
id: "jj",
|
|
102
|
+
async detect(cwd) {
|
|
103
|
+
const root = await jj(["--ignore-working-copy", "root"], cwd);
|
|
104
|
+
|
|
105
|
+
return root?.trim() ?? null;
|
|
106
|
+
},
|
|
107
|
+
captureWorkingDiff(repoRoot) {
|
|
108
|
+
return captureJjRevision(repoRoot, "@");
|
|
109
|
+
},
|
|
110
|
+
async captureChange(repoRoot, changeId) {
|
|
111
|
+
if (!/^[a-z]{32}$/.test(changeId)) throw new Error(`Jujutsu change ID is invalid: ${changeId}`);
|
|
112
|
+
if ((await jj(["status"], repoRoot)) === null)
|
|
113
|
+
throw new Error("Jujutsu diff capture failed: could not snapshot the working copy");
|
|
114
|
+
|
|
115
|
+
return captureJjRevision(repoRoot, changeId);
|
|
116
|
+
},
|
|
117
|
+
async listChanges(repoRoot) {
|
|
118
|
+
const summary = await jj(["diff", "-T", JJ_DIFF_FILES_TEMPLATE, "-r", "@"], repoRoot);
|
|
119
|
+
|
|
120
|
+
if (summary === null) throw new Error("Jujutsu changes list failed");
|
|
121
|
+
|
|
122
|
+
return jjSummary(summary).map(({ path, status }) => ({ path, status }));
|
|
123
|
+
},
|
|
124
|
+
async listFiles(repoRoot) {
|
|
125
|
+
const files = await jj(["file", "list", "-T", JJ_FILE_LIST_TEMPLATE, "-r", "@"], repoRoot);
|
|
126
|
+
|
|
127
|
+
if (files === null) throw new Error("Jujutsu project files failed");
|
|
128
|
+
|
|
129
|
+
return files.split("\0").filter(Boolean).toSorted().slice(0, 5000);
|
|
130
|
+
},
|
|
131
|
+
};
|