@residue/cli 0.0.1
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/adapters/pi/extension.ts.txt +137 -0
- package/dist/index.js +4443 -0
- package/dist/residue +0 -0
- package/package.json +24 -0
- package/src/commands/capture.ts +49 -0
- package/src/commands/hook.ts +222 -0
- package/src/commands/init.ts +146 -0
- package/src/commands/login.ts +26 -0
- package/src/commands/push.ts +3 -0
- package/src/commands/session-end.ts +38 -0
- package/src/commands/session-start.ts +35 -0
- package/src/commands/setup.ts +148 -0
- package/src/commands/sync.ts +354 -0
- package/src/index.ts +99 -0
- package/src/lib/config.ts +61 -0
- package/src/lib/git.ts +95 -0
- package/src/lib/pending.ts +190 -0
- package/src/types/text-import.d.ts +4 -0
- package/src/utils/errors.ts +75 -0
- package/src/utils/logger.ts +51 -0
- package/test/commands/capture.test.ts +206 -0
- package/test/commands/hook.test.ts +224 -0
- package/test/commands/sync.test.ts +540 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdir, mkdtemp, rm, utimes, writeFile } from "fs/promises";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { readPending } from "@/lib/pending";
|
|
6
|
+
|
|
7
|
+
let tempDir: string;
|
|
8
|
+
let fakeHome: string;
|
|
9
|
+
|
|
10
|
+
const cliDir = join(import.meta.dir, "../..");
|
|
11
|
+
const entry = join(cliDir, "src/index.ts");
|
|
12
|
+
|
|
13
|
+
async function gitExec(args: string[]) {
|
|
14
|
+
const proc = Bun.spawn(["git", ...args], {
|
|
15
|
+
cwd: tempDir,
|
|
16
|
+
stdout: "pipe",
|
|
17
|
+
stderr: "pipe",
|
|
18
|
+
});
|
|
19
|
+
await proc.exited;
|
|
20
|
+
return (await new Response(proc.stdout).text()).trim();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
beforeEach(async () => {
|
|
24
|
+
tempDir = await mkdtemp(join(tmpdir(), "residue-sync-test-"));
|
|
25
|
+
fakeHome = await mkdtemp(join(tmpdir(), "residue-sync-home-"));
|
|
26
|
+
await gitExec(["init"]);
|
|
27
|
+
await gitExec(["config", "user.email", "test@test.com"]);
|
|
28
|
+
await gitExec(["config", "user.name", "Test"]);
|
|
29
|
+
await gitExec([
|
|
30
|
+
"remote",
|
|
31
|
+
"add",
|
|
32
|
+
"origin",
|
|
33
|
+
"git@github.com:my-org/my-repo.git",
|
|
34
|
+
]);
|
|
35
|
+
await writeFile(join(tempDir, "README.md"), "init");
|
|
36
|
+
await gitExec(["add", "."]);
|
|
37
|
+
await gitExec(["commit", "-m", "initial"]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterEach(async () => {
|
|
41
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
42
|
+
await rm(fakeHome, { recursive: true, force: true });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function cli(args: string[], env?: Record<string, string>) {
|
|
46
|
+
return Bun.spawn(["bun", entry, ...args], {
|
|
47
|
+
cwd: tempDir,
|
|
48
|
+
stdout: "pipe",
|
|
49
|
+
stderr: "pipe",
|
|
50
|
+
env: { ...process.env, HOME: fakeHome, DEBUG: "residue:*", ...env },
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function setupConfig(opts: { workerUrl: string; token: string }) {
|
|
55
|
+
const configDir = join(fakeHome, ".residue");
|
|
56
|
+
await mkdir(configDir, { recursive: true });
|
|
57
|
+
await writeFile(
|
|
58
|
+
join(configDir, "config"),
|
|
59
|
+
JSON.stringify({ worker_url: opts.workerUrl, token: opts.token }),
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type RequestLog = {
|
|
64
|
+
method: string;
|
|
65
|
+
url: string;
|
|
66
|
+
body: unknown;
|
|
67
|
+
auth: string | null;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
type R2Upload = {
|
|
71
|
+
key: string;
|
|
72
|
+
body: string;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
function createMockServer() {
|
|
76
|
+
const requests: RequestLog[] = [];
|
|
77
|
+
const r2Uploads: R2Upload[] = [];
|
|
78
|
+
|
|
79
|
+
// Mock R2 endpoint that receives PUT requests (simulates presigned URL target)
|
|
80
|
+
const r2Server = Bun.serve({
|
|
81
|
+
port: 0,
|
|
82
|
+
fetch(req) {
|
|
83
|
+
return (async () => {
|
|
84
|
+
if (req.method === "PUT") {
|
|
85
|
+
const url = new URL(req.url);
|
|
86
|
+
const body = await req.text();
|
|
87
|
+
r2Uploads.push({ key: url.pathname, body });
|
|
88
|
+
return new Response("", { status: 200 });
|
|
89
|
+
}
|
|
90
|
+
return new Response("method not allowed", { status: 405 });
|
|
91
|
+
})();
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const r2BaseUrl = `http://localhost:${r2Server.port}`;
|
|
96
|
+
|
|
97
|
+
// Worker mock that returns presigned URLs pointing to the mock R2
|
|
98
|
+
const workerServer = Bun.serve({
|
|
99
|
+
port: 0,
|
|
100
|
+
fetch(req) {
|
|
101
|
+
return (async () => {
|
|
102
|
+
const url = new URL(req.url);
|
|
103
|
+
|
|
104
|
+
if (url.pathname === "/api/sessions/upload-url") {
|
|
105
|
+
const body = (await req.json()) as { session_id: string };
|
|
106
|
+
const r2Key = `sessions/${body.session_id}.json`;
|
|
107
|
+
|
|
108
|
+
requests.push({
|
|
109
|
+
method: req.method,
|
|
110
|
+
url: url.pathname,
|
|
111
|
+
body,
|
|
112
|
+
auth: req.headers.get("authorization"),
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
return new Response(
|
|
116
|
+
JSON.stringify({
|
|
117
|
+
url: `${r2BaseUrl}/${r2Key}`,
|
|
118
|
+
r2_key: r2Key,
|
|
119
|
+
}),
|
|
120
|
+
{ status: 200 },
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (url.pathname === "/api/sessions") {
|
|
125
|
+
const body = await req.json();
|
|
126
|
+
requests.push({
|
|
127
|
+
method: req.method,
|
|
128
|
+
url: url.pathname,
|
|
129
|
+
body,
|
|
130
|
+
auth: req.headers.get("authorization"),
|
|
131
|
+
});
|
|
132
|
+
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return new Response("not found", { status: 404 });
|
|
136
|
+
})();
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
workerUrl: `http://localhost:${workerServer.port}`,
|
|
142
|
+
requests,
|
|
143
|
+
r2Uploads,
|
|
144
|
+
stop() {
|
|
145
|
+
workerServer.stop();
|
|
146
|
+
r2Server.stop();
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
describe("sync command", () => {
|
|
152
|
+
test("uploads data to R2 via presigned URL and posts metadata to worker", async () => {
|
|
153
|
+
const mock = createMockServer();
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
await setupConfig({ workerUrl: mock.workerUrl, token: "my-token" });
|
|
157
|
+
|
|
158
|
+
const dataPath = join(tempDir, "session-data.jsonl");
|
|
159
|
+
await writeFile(dataPath, '{"role":"user","content":"hello"}');
|
|
160
|
+
|
|
161
|
+
// Create a session, capture while open, then end it
|
|
162
|
+
const startProc = cli([
|
|
163
|
+
"session",
|
|
164
|
+
"start",
|
|
165
|
+
"--agent",
|
|
166
|
+
"claude-code",
|
|
167
|
+
"--data",
|
|
168
|
+
dataPath,
|
|
169
|
+
]);
|
|
170
|
+
await startProc.exited;
|
|
171
|
+
const sessionId = (await new Response(startProc.stdout).text()).trim();
|
|
172
|
+
|
|
173
|
+
const captureProc = cli(["capture"]);
|
|
174
|
+
await captureProc.exited;
|
|
175
|
+
|
|
176
|
+
const endProc = cli(["session", "end", "--id", sessionId]);
|
|
177
|
+
await endProc.exited;
|
|
178
|
+
|
|
179
|
+
// Sync
|
|
180
|
+
const syncProc = cli(["sync"]);
|
|
181
|
+
const exitCode = await syncProc.exited;
|
|
182
|
+
const stderr = await new Response(syncProc.stderr).text();
|
|
183
|
+
|
|
184
|
+
expect(exitCode).toBe(0);
|
|
185
|
+
expect(stderr).toContain("uploaded session");
|
|
186
|
+
expect(stderr).toContain("directly to R2");
|
|
187
|
+
expect(stderr).toContain("synced session");
|
|
188
|
+
expect(stderr).toContain(sessionId);
|
|
189
|
+
|
|
190
|
+
// Should have 2 worker requests: upload-url + POST metadata
|
|
191
|
+
expect(mock.requests).toHaveLength(2);
|
|
192
|
+
expect(mock.requests[0].url).toBe("/api/sessions/upload-url");
|
|
193
|
+
|
|
194
|
+
const req = mock.requests[1];
|
|
195
|
+
expect(req.url).toBe("/api/sessions");
|
|
196
|
+
expect(req.method).toBe("POST");
|
|
197
|
+
expect(req.auth).toBe("Bearer my-token");
|
|
198
|
+
|
|
199
|
+
const body = req.body as {
|
|
200
|
+
session: Record<string, unknown>;
|
|
201
|
+
commits: Array<Record<string, unknown>>;
|
|
202
|
+
};
|
|
203
|
+
expect(body.session.id).toBe(sessionId);
|
|
204
|
+
expect(body.session.agent).toBe("claude-code");
|
|
205
|
+
expect(body.session.status).toBe("ended");
|
|
206
|
+
// Metadata POST must NOT contain inline data
|
|
207
|
+
expect(body.session.data).toBeUndefined();
|
|
208
|
+
expect(body.commits).toHaveLength(1);
|
|
209
|
+
expect(body.commits[0].org).toBe("my-org");
|
|
210
|
+
expect(body.commits[0].repo).toBe("my-repo");
|
|
211
|
+
expect(typeof body.commits[0].branch).toBe("string");
|
|
212
|
+
expect((body.commits[0].branch as string).length).toBeGreaterThan(0);
|
|
213
|
+
|
|
214
|
+
// R2 should have received the data directly
|
|
215
|
+
expect(mock.r2Uploads).toHaveLength(1);
|
|
216
|
+
expect(mock.r2Uploads[0].body).toBe('{"role":"user","content":"hello"}');
|
|
217
|
+
|
|
218
|
+
// Ended session removed from pending
|
|
219
|
+
const pendingPath = join(tempDir, ".residue/pending.json");
|
|
220
|
+
const sessions = (await readPending(pendingPath))._unsafeUnwrap();
|
|
221
|
+
expect(sessions).toHaveLength(0);
|
|
222
|
+
} finally {
|
|
223
|
+
mock.stop();
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("keeps open sessions in pending after sync", async () => {
|
|
228
|
+
const mock = createMockServer();
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
await setupConfig({ workerUrl: mock.workerUrl, token: "t" });
|
|
232
|
+
|
|
233
|
+
const dataPath = join(tempDir, "session-data.jsonl");
|
|
234
|
+
await writeFile(dataPath, "data");
|
|
235
|
+
|
|
236
|
+
const startProc = cli([
|
|
237
|
+
"session",
|
|
238
|
+
"start",
|
|
239
|
+
"--agent",
|
|
240
|
+
"claude-code",
|
|
241
|
+
"--data",
|
|
242
|
+
dataPath,
|
|
243
|
+
]);
|
|
244
|
+
await startProc.exited;
|
|
245
|
+
|
|
246
|
+
const captureProc = cli(["capture"]);
|
|
247
|
+
await captureProc.exited;
|
|
248
|
+
|
|
249
|
+
const syncProc = cli(["sync"]);
|
|
250
|
+
await syncProc.exited;
|
|
251
|
+
|
|
252
|
+
const pendingPath = join(tempDir, ".residue/pending.json");
|
|
253
|
+
const sessions = (await readPending(pendingPath))._unsafeUnwrap();
|
|
254
|
+
expect(sessions).toHaveLength(1);
|
|
255
|
+
expect(sessions[0].status).toBe("open");
|
|
256
|
+
} finally {
|
|
257
|
+
mock.stop();
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("uses --remote-url for org/repo inference when provided", async () => {
|
|
262
|
+
const mock = createMockServer();
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
await setupConfig({ workerUrl: mock.workerUrl, token: "t" });
|
|
266
|
+
|
|
267
|
+
const dataPath = join(tempDir, "session-data.jsonl");
|
|
268
|
+
await writeFile(dataPath, "data");
|
|
269
|
+
|
|
270
|
+
const startProc = cli([
|
|
271
|
+
"session",
|
|
272
|
+
"start",
|
|
273
|
+
"--agent",
|
|
274
|
+
"claude-code",
|
|
275
|
+
"--data",
|
|
276
|
+
dataPath,
|
|
277
|
+
]);
|
|
278
|
+
await startProc.exited;
|
|
279
|
+
|
|
280
|
+
const captureProc = cli(["capture"]);
|
|
281
|
+
await captureProc.exited;
|
|
282
|
+
|
|
283
|
+
const endProc = cli([
|
|
284
|
+
"session",
|
|
285
|
+
"end",
|
|
286
|
+
"--id",
|
|
287
|
+
(await new Response(startProc.stdout).text()).trim(),
|
|
288
|
+
]);
|
|
289
|
+
await endProc.exited;
|
|
290
|
+
|
|
291
|
+
// Sync with a different remote URL (not origin)
|
|
292
|
+
const syncProc = cli([
|
|
293
|
+
"sync",
|
|
294
|
+
"--remote-url",
|
|
295
|
+
"git@github.com:other-org/other-repo.git",
|
|
296
|
+
]);
|
|
297
|
+
const exitCode = await syncProc.exited;
|
|
298
|
+
|
|
299
|
+
expect(exitCode).toBe(0);
|
|
300
|
+
expect(mock.requests).toHaveLength(2);
|
|
301
|
+
|
|
302
|
+
const body = mock.requests[1].body as {
|
|
303
|
+
commits: Array<{ org: string; repo: string; branch: string }>;
|
|
304
|
+
};
|
|
305
|
+
expect(body.commits[0].org).toBe("other-org");
|
|
306
|
+
expect(body.commits[0].repo).toBe("other-repo");
|
|
307
|
+
expect(typeof body.commits[0].branch).toBe("string");
|
|
308
|
+
} finally {
|
|
309
|
+
mock.stop();
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("falls back to origin when --remote-url is empty", async () => {
|
|
314
|
+
const mock = createMockServer();
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
await setupConfig({ workerUrl: mock.workerUrl, token: "t" });
|
|
318
|
+
|
|
319
|
+
const dataPath = join(tempDir, "session-data.jsonl");
|
|
320
|
+
await writeFile(dataPath, "data");
|
|
321
|
+
|
|
322
|
+
const startProc = cli([
|
|
323
|
+
"session",
|
|
324
|
+
"start",
|
|
325
|
+
"--agent",
|
|
326
|
+
"claude-code",
|
|
327
|
+
"--data",
|
|
328
|
+
dataPath,
|
|
329
|
+
]);
|
|
330
|
+
await startProc.exited;
|
|
331
|
+
|
|
332
|
+
const captureProc = cli(["capture"]);
|
|
333
|
+
await captureProc.exited;
|
|
334
|
+
|
|
335
|
+
const endProc = cli([
|
|
336
|
+
"session",
|
|
337
|
+
"end",
|
|
338
|
+
"--id",
|
|
339
|
+
(await new Response(startProc.stdout).text()).trim(),
|
|
340
|
+
]);
|
|
341
|
+
await endProc.exited;
|
|
342
|
+
|
|
343
|
+
// Sync with empty remote URL (should fall back to origin)
|
|
344
|
+
const syncProc = cli(["sync", "--remote-url", ""]);
|
|
345
|
+
const exitCode = await syncProc.exited;
|
|
346
|
+
|
|
347
|
+
expect(exitCode).toBe(0);
|
|
348
|
+
expect(mock.requests).toHaveLength(2);
|
|
349
|
+
|
|
350
|
+
const body = mock.requests[1].body as {
|
|
351
|
+
commits: Array<{ org: string; repo: string }>;
|
|
352
|
+
};
|
|
353
|
+
expect(body.commits[0].org).toBe("my-org");
|
|
354
|
+
expect(body.commits[0].repo).toBe("my-repo");
|
|
355
|
+
} finally {
|
|
356
|
+
mock.stop();
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("auto-closes stale open sessions before syncing", async () => {
|
|
361
|
+
const mock = createMockServer();
|
|
362
|
+
|
|
363
|
+
try {
|
|
364
|
+
await setupConfig({ workerUrl: mock.workerUrl, token: "t" });
|
|
365
|
+
|
|
366
|
+
const dataPath = join(tempDir, "session-data.jsonl");
|
|
367
|
+
await writeFile(dataPath, "stale data");
|
|
368
|
+
|
|
369
|
+
// Start a session and capture a commit
|
|
370
|
+
const startProc = cli([
|
|
371
|
+
"session",
|
|
372
|
+
"start",
|
|
373
|
+
"--agent",
|
|
374
|
+
"pi",
|
|
375
|
+
"--data",
|
|
376
|
+
dataPath,
|
|
377
|
+
]);
|
|
378
|
+
await startProc.exited;
|
|
379
|
+
const sessionId = (await new Response(startProc.stdout).text()).trim();
|
|
380
|
+
|
|
381
|
+
const captureProc = cli(["capture"]);
|
|
382
|
+
await captureProc.exited;
|
|
383
|
+
|
|
384
|
+
// Set the data file mtime to 2 hours ago so it looks stale
|
|
385
|
+
const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000);
|
|
386
|
+
await utimes(dataPath, twoHoursAgo, twoHoursAgo);
|
|
387
|
+
|
|
388
|
+
// Sync -- the open session should be auto-closed and removed from pending
|
|
389
|
+
const syncProc = cli(["sync"]);
|
|
390
|
+
const exitCode = await syncProc.exited;
|
|
391
|
+
const stderr = await new Response(syncProc.stderr).text();
|
|
392
|
+
|
|
393
|
+
expect(exitCode).toBe(0);
|
|
394
|
+
expect(stderr).toContain("auto-closed stale session");
|
|
395
|
+
expect(stderr).toContain(sessionId);
|
|
396
|
+
|
|
397
|
+
// Session was auto-closed to "ended", so after successful sync it should be removed
|
|
398
|
+
const pendingPath = join(tempDir, ".residue/pending.json");
|
|
399
|
+
const sessions = (await readPending(pendingPath))._unsafeUnwrap();
|
|
400
|
+
expect(sessions).toHaveLength(0);
|
|
401
|
+
|
|
402
|
+
// Verify the upload sent status "ended"
|
|
403
|
+
expect(mock.requests).toHaveLength(2);
|
|
404
|
+
const body = mock.requests[1].body as { session: { status: string } };
|
|
405
|
+
expect(body.session.status).toBe("ended");
|
|
406
|
+
} finally {
|
|
407
|
+
mock.stop();
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test("does not auto-close recently active open sessions", async () => {
|
|
412
|
+
const mock = createMockServer();
|
|
413
|
+
|
|
414
|
+
try {
|
|
415
|
+
await setupConfig({ workerUrl: mock.workerUrl, token: "t" });
|
|
416
|
+
|
|
417
|
+
const dataPath = join(tempDir, "session-data.jsonl");
|
|
418
|
+
await writeFile(dataPath, "fresh data");
|
|
419
|
+
|
|
420
|
+
// Start a session and capture a commit -- data file mtime is now (fresh)
|
|
421
|
+
const startProc = cli([
|
|
422
|
+
"session",
|
|
423
|
+
"start",
|
|
424
|
+
"--agent",
|
|
425
|
+
"pi",
|
|
426
|
+
"--data",
|
|
427
|
+
dataPath,
|
|
428
|
+
]);
|
|
429
|
+
await startProc.exited;
|
|
430
|
+
|
|
431
|
+
const captureProc = cli(["capture"]);
|
|
432
|
+
await captureProc.exited;
|
|
433
|
+
|
|
434
|
+
const syncProc = cli(["sync"]);
|
|
435
|
+
const exitCode = await syncProc.exited;
|
|
436
|
+
const stderr = await new Response(syncProc.stderr).text();
|
|
437
|
+
|
|
438
|
+
expect(exitCode).toBe(0);
|
|
439
|
+
expect(stderr).not.toContain("auto-closed");
|
|
440
|
+
|
|
441
|
+
// Open session stays in pending
|
|
442
|
+
const pendingPath = join(tempDir, ".residue/pending.json");
|
|
443
|
+
const sessions = (await readPending(pendingPath))._unsafeUnwrap();
|
|
444
|
+
expect(sessions).toHaveLength(1);
|
|
445
|
+
expect(sessions[0].status).toBe("open");
|
|
446
|
+
} finally {
|
|
447
|
+
mock.stop();
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test("auto-closes open session when data file is missing", async () => {
|
|
452
|
+
const mock = createMockServer();
|
|
453
|
+
|
|
454
|
+
try {
|
|
455
|
+
await setupConfig({ workerUrl: mock.workerUrl, token: "t" });
|
|
456
|
+
|
|
457
|
+
const dataPath = join(tempDir, "session-data.jsonl");
|
|
458
|
+
await writeFile(dataPath, "data");
|
|
459
|
+
|
|
460
|
+
const startProc = cli([
|
|
461
|
+
"session",
|
|
462
|
+
"start",
|
|
463
|
+
"--agent",
|
|
464
|
+
"pi",
|
|
465
|
+
"--data",
|
|
466
|
+
dataPath,
|
|
467
|
+
]);
|
|
468
|
+
await startProc.exited;
|
|
469
|
+
const sessionId = (await new Response(startProc.stdout).text()).trim();
|
|
470
|
+
|
|
471
|
+
const captureProc = cli(["capture"]);
|
|
472
|
+
await captureProc.exited;
|
|
473
|
+
|
|
474
|
+
// Delete the data file
|
|
475
|
+
await rm(dataPath);
|
|
476
|
+
|
|
477
|
+
const syncProc = cli(["sync"]);
|
|
478
|
+
const exitCode = await syncProc.exited;
|
|
479
|
+
const stderr = await new Response(syncProc.stderr).text();
|
|
480
|
+
|
|
481
|
+
expect(exitCode).toBe(0);
|
|
482
|
+
expect(stderr).toContain("auto-closed session");
|
|
483
|
+
expect(stderr).toContain("not accessible");
|
|
484
|
+
expect(stderr).toContain(sessionId);
|
|
485
|
+
} finally {
|
|
486
|
+
mock.stop();
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test("keeps session on upload failure", async () => {
|
|
491
|
+
// Worker that returns 500 for everything
|
|
492
|
+
const server = Bun.serve({
|
|
493
|
+
port: 0,
|
|
494
|
+
fetch() {
|
|
495
|
+
return new Response("error", { status: 500 });
|
|
496
|
+
},
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
try {
|
|
500
|
+
await setupConfig({
|
|
501
|
+
workerUrl: `http://localhost:${server.port}`,
|
|
502
|
+
token: "t",
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
const dataPath = join(tempDir, "session-data.jsonl");
|
|
506
|
+
await writeFile(dataPath, "data");
|
|
507
|
+
|
|
508
|
+
const startProc = cli([
|
|
509
|
+
"session",
|
|
510
|
+
"start",
|
|
511
|
+
"--agent",
|
|
512
|
+
"claude-code",
|
|
513
|
+
"--data",
|
|
514
|
+
dataPath,
|
|
515
|
+
]);
|
|
516
|
+
await startProc.exited;
|
|
517
|
+
const sessionId = (await new Response(startProc.stdout).text()).trim();
|
|
518
|
+
|
|
519
|
+
// Capture while open so it gets a commit SHA
|
|
520
|
+
const captureProc = cli(["capture"]);
|
|
521
|
+
await captureProc.exited;
|
|
522
|
+
|
|
523
|
+
const endProc = cli(["session", "end", "--id", sessionId]);
|
|
524
|
+
await endProc.exited;
|
|
525
|
+
|
|
526
|
+
const syncProc = cli(["sync"]);
|
|
527
|
+
const exitCode = await syncProc.exited;
|
|
528
|
+
const stderr = await new Response(syncProc.stderr).text();
|
|
529
|
+
|
|
530
|
+
expect(exitCode).toBe(0);
|
|
531
|
+
expect(stderr).toContain("failed to get upload URL");
|
|
532
|
+
|
|
533
|
+
const pendingPath = join(tempDir, ".residue/pending.json");
|
|
534
|
+
const sessions = (await readPending(pendingPath))._unsafeUnwrap();
|
|
535
|
+
expect(sessions).toHaveLength(1);
|
|
536
|
+
} finally {
|
|
537
|
+
server.stop();
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"outDir": "dist",
|
|
5
|
+
"rootDir": ".",
|
|
6
|
+
"types": ["@types/bun"],
|
|
7
|
+
"baseUrl": ".",
|
|
8
|
+
"paths": {
|
|
9
|
+
"@/*": ["src/*"]
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"include": ["src", "test"],
|
|
13
|
+
"exclude": ["node_modules", "dist", "adapters"]
|
|
14
|
+
}
|