@cueloop/daemon 0.1.0-alpha.9 → 0.1.0-alpha.90

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/package.json +39 -22
  2. package/src/api.test.ts +1060 -65
  3. package/src/api.ts +1398 -89
  4. package/src/capabilities.test.ts +57 -0
  5. package/src/capabilities.ts +89 -0
  6. package/src/client.test.ts +24 -0
  7. package/src/client.ts +669 -86
  8. package/src/curate.ts +109 -0
  9. package/src/daemon-version.test.ts +142 -0
  10. package/src/diff-hot-reload.test.ts +419 -0
  11. package/src/diff-watcher.ts +293 -0
  12. package/src/gh.test.ts +59 -0
  13. package/src/gh.ts +74 -0
  14. package/src/ghostty-thread-surface-store.test.ts +49 -0
  15. package/src/ghostty-thread-surface-store.ts +101 -0
  16. package/src/ghostty-thread-surface.test.ts +398 -0
  17. package/src/ghostty-thread-surface.ts +267 -0
  18. package/src/harness-delivery.test.ts +268 -0
  19. package/src/harness-state-store.test.ts +93 -0
  20. package/src/harness-state-store.ts +213 -0
  21. package/src/herdr-split.test.ts +106 -0
  22. package/src/herdr-split.ts +92 -0
  23. package/src/herdr-thread-surface-store.test.ts +81 -0
  24. package/src/herdr-thread-surface-store.ts +64 -0
  25. package/src/herdr-thread-surface.test.ts +411 -0
  26. package/src/herdr-thread-surface.ts +355 -0
  27. package/src/index.ts +8 -9
  28. package/src/interruptible-wait.test.ts +127 -0
  29. package/src/interruptible-wait.ts +66 -0
  30. package/src/jj-working-tree.ts +131 -0
  31. package/src/main.ts +4 -1
  32. package/src/paths.ts +49 -0
  33. package/src/pr-poller.test.ts +102 -0
  34. package/src/pr-poller.ts +73 -0
  35. package/src/project-files.test.ts +124 -0
  36. package/src/project-files.ts +56 -0
  37. package/src/protocol.ts +79 -7
  38. package/src/retention.test.ts +191 -0
  39. package/src/retention.ts +123 -0
  40. package/src/server.test.ts +453 -42
  41. package/src/server.ts +433 -88
  42. package/src/share-blob.test.ts +112 -0
  43. package/src/share-blob.ts +55 -0
  44. package/src/single-instance.test.ts +162 -13
  45. package/src/store.conformance.test.ts +41 -0
  46. package/src/store.jsonl.test.ts +159 -0
  47. package/src/store.ts +264 -34
  48. package/src/testing/store-conformance.ts +194 -0
  49. package/src/thread-review.test.ts +483 -0
  50. package/src/thread-review.ts +398 -0
  51. package/src/thread-surface-config.test.ts +63 -0
  52. package/src/thread-surface-config.ts +61 -0
  53. package/src/validate.test.ts +282 -37
  54. package/src/validate.ts +398 -33
  55. package/src/vcs-source.test.ts +180 -0
  56. package/src/vcs-source.ts +228 -0
  57. package/src/version.ts +6 -0
  58. package/src/working-tree.ts +149 -0
  59. package/src/review.test.ts +0 -167
  60. package/src/review.ts +0 -174
@@ -0,0 +1,112 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { gzipSync } from "node:zlib";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { viewFollowing, type Artifact, type WorkspaceKey } from "@cueloop/schema";
7
+ import { DaemonCore } from "./api";
8
+ import { MAX_BLOB_BYTES, packSessionBlob, unpackSessionBlob } from "./share-blob";
9
+
10
+ const WS: WorkspaceKey = { repoRoot: "/repo", branch: "main" };
11
+ const PLAN: Artifact = { type: "plan", content: "# Plan\n\nDo the thing.\n", meta: {} };
12
+
13
+ let home: string;
14
+ let core: DaemonCore;
15
+
16
+ beforeEach(() => {
17
+ home = mkdtempSync(join(tmpdir(), "cueloop-blob-"));
18
+ core = new DaemonCore(home);
19
+ });
20
+ afterEach(() => rmSync(home, { recursive: true, force: true }));
21
+
22
+ describe("share blob roundtrip", () => {
23
+ test("pack then unpack restores the session with its annotations", () => {
24
+ // Arrange
25
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
26
+ const annotated = core.sessionAnnotate(session.id, {
27
+ id: "a_1",
28
+ kind: "comment",
29
+ anchor: { quote: "the thing", prefix: "", suffix: "" },
30
+ body: "why this?",
31
+ });
32
+
33
+ // Act
34
+ const restored = unpackSessionBlob(packSessionBlob(annotated));
35
+
36
+ // Assert
37
+ expect(restored).toEqual(annotated);
38
+ });
39
+ });
40
+
41
+ describe("the payload cap against a long review", () => {
42
+ test("a plan the size of the examples with a long discussion and its history stays far under the cap", () => {
43
+ // Arrange: the largest example plan, 200 rounds of comment + reply, 20 reviewer edits, 10 removals
44
+ const paragraph =
45
+ "Review sessions currently live only in daemon memory. If the daemon crashes mid-review, every pending annotation is lost. This plan makes sessions durable without changing the daemon's socket protocol.\n\n";
46
+ const content = `# Implementation Plan: Session Persistence\n\n## Context\n\n${paragraph.repeat(8)}`;
47
+ const session = core.sessionCreate({
48
+ workspace: WS,
49
+ artifact: { type: "plan", content, meta: {} },
50
+ });
51
+
52
+ for (let round = 0; round < 200; round++) {
53
+ core.sessionAnnotate(session.id, {
54
+ id: `a_${round}`,
55
+ kind: "comment",
56
+ anchor: { quote: "daemon memory", prefix: "live only in ", suffix: ". If the" },
57
+ body: `Round ${round}: what happens when the store directory is read-only at startup?`,
58
+ author: "SHA256:collaborator-fingerprint-0123456789abcdef",
59
+ });
60
+ core.sessionAnnotate(session.id, {
61
+ id: `r_${round}`,
62
+ kind: "comment",
63
+ anchor: { quote: "daemon memory", prefix: "live only in ", suffix: ". If the" },
64
+ body: "Fail fast with a readable error; the review never opens on a store that cannot write.",
65
+ replyTo: `a_${round}`,
66
+ });
67
+ if (round % 10 === 0)
68
+ core.sessionSetWorkingCopy(session.id, `${content}\n\nEdit ${round}.\n`);
69
+ if (round % 20 === 0) core.sessionRemoveAnnotation(session.id, `r_${round}`);
70
+ }
71
+ const shared = viewFollowing(core.sessionGet(session.id));
72
+
73
+ // Act
74
+ const decompressed = Buffer.byteLength(JSON.stringify(shared), "utf8");
75
+ const packed = packSessionBlob(shared).byteLength;
76
+
77
+ // Assert: a quarter of the cap decompressed, and the packed blob a small
78
+ // fraction of it; the round-trip holds
79
+ expect(shared.history!.entries.length).toBeGreaterThan(400);
80
+ expect(decompressed).toBeLessThan(MAX_BLOB_BYTES / 4);
81
+ expect(packed).toBeLessThan(decompressed);
82
+ expect(unpackSessionBlob(packSessionBlob(shared)).history!.entries.length).toBe(
83
+ shared.history!.entries.length,
84
+ );
85
+ console.info(
86
+ `share payload: ${shared.history!.entries.length} entries, ${decompressed} bytes decompressed, ${packed} bytes packed, cap ${MAX_BLOB_BYTES}`,
87
+ );
88
+ });
89
+ });
90
+
91
+ describe("unpack rejects bad input", () => {
92
+ test("caps the decompressed size, so a compression bomb throws", () => {
93
+ // Arrange: tiny gzip, huge inflation - the decompression-bomb shape
94
+ const bomb = gzipSync(Buffer.alloc(MAX_BLOB_BYTES + 1, 0x61));
95
+
96
+ // Act / Assert
97
+ expect(() => unpackSessionBlob(bomb)).toThrow(/exceeds|maxOutputLength/i);
98
+ });
99
+
100
+ test("throws on bytes that are not gzip", () => {
101
+ // Act / Assert
102
+ expect(() => unpackSessionBlob(Buffer.from("not gzip at all"))).toThrow(/gzip/i);
103
+ });
104
+
105
+ test("throws on a shape that is not a session", () => {
106
+ // Arrange
107
+ const notASession = gzipSync(Buffer.from(JSON.stringify({ hello: "world" })));
108
+
109
+ // Act / Assert
110
+ expect(() => unpackSessionBlob(notASession)).toThrow(/not a valid session/i);
111
+ });
112
+ });
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The share-blob wire format: how a Thread travels from `cueloop share`
3
+ * to the gateway. The planner's CLI packs (this is the only thing it can do -
4
+ * it holds no encryption key); the gateway unpacks, then encrypts for R2.
5
+ *
6
+ * Format is gzip(JSON). Unpack is the untrusted boundary - the bytes come off
7
+ * an SSH channel from anyone who can reach the `share` user - so it caps the
8
+ * decompressed size (a decompression-bomb guard) and runs the same record
9
+ * validator the daemon store uses, never trusting the shape.
10
+ */
11
+
12
+ import { gzipSync, gunzipSync } from "node:zlib";
13
+ import type { Thread } from "@cueloop/schema";
14
+ import { validateThreadRecord } from "./validate";
15
+
16
+ /** Decompressed ceiling for one shared session (ADR 0004's payload cap). */
17
+ export const MAX_BLOB_BYTES = 1024 * 1024;
18
+
19
+ /** The SSH username `cueloop share` connects as to upload; the gateway routes on it. */
20
+ export const SHARE_UPLOAD_USER = "share";
21
+
22
+ /** Where shares live, in one place: the client uploads here, the gateway prints it. */
23
+ export const DEFAULT_SHARE_HOST = "cueloop.dev";
24
+ export const DEFAULT_SHARE_PORT = 22;
25
+
26
+ /** Serialise + compress a session for upload. */
27
+ export function packSessionBlob(session: Thread): Buffer {
28
+ return gzipSync(Buffer.from(JSON.stringify(session), "utf8"));
29
+ }
30
+
31
+ /** Decompress + validate an uploaded blob, or throw a precise reason. */
32
+ export function unpackSessionBlob(bytes: Uint8Array): Thread {
33
+ let json: string;
34
+
35
+ try {
36
+ json = gunzipSync(bytes, { maxOutputLength: MAX_BLOB_BYTES }).toString("utf8");
37
+ } catch (err) {
38
+ throw new Error(
39
+ `blob is not valid gzip or exceeds ${MAX_BLOB_BYTES} bytes: ${err instanceof Error ? err.message : String(err)}`,
40
+ { cause: err },
41
+ );
42
+ }
43
+ let raw: unknown;
44
+
45
+ try {
46
+ raw = JSON.parse(json);
47
+ } catch {
48
+ throw new Error("blob is not valid JSON");
49
+ }
50
+ const parsed = validateThreadRecord(raw);
51
+
52
+ if (!parsed.ok) throw new Error(`blob is not a valid session: ${parsed.error}`);
53
+
54
+ return parsed.value;
55
+ }
@@ -1,16 +1,17 @@
1
1
  /**
2
- * Single-instance guarantee (#14): one daemon per home. Two daemons over one
2
+ * Single-instance guarantee: one daemon per home. Two daemons over one
3
3
  * state directory means divergent in-memory sessions and clients seeing
4
4
  * whichever half they connect to.
5
5
  */
6
6
 
7
7
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
8
- import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
8
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
9
9
  import { tmpdir } from "node:os";
10
10
  import { join } from "node:path";
11
+ import { createServer } from "node:net";
11
12
  import { DaemonServer } from "./server";
12
13
  import { DaemonClient } from "./client";
13
- import { lockPath, socketPath } from "./paths";
14
+ import { lockPath, ownerTokenPath, socketPath } from "./paths";
14
15
  import type { Artifact, WorkspaceKey } from "@cueloop/schema";
15
16
 
16
17
  const WS: WorkspaceKey = { repoRoot: "/repo", branch: "main" };
@@ -23,33 +24,61 @@ beforeEach(() => {
23
24
  home = mkdtempSync(join(tmpdir(), "cueloop-lock-"));
24
25
  });
25
26
  afterEach(() => {
26
- for (const s of servers.splice(0)) s.stop();
27
+ for (const runningServer of servers.splice(0)) runningServer.stop();
27
28
  rmSync(home, { recursive: true, force: true });
28
29
  });
29
30
 
30
31
  function server(): DaemonServer {
31
- const s = new DaemonServer({ home, idleExitMs: 0 });
32
- servers.push(s);
33
- return s;
32
+ const daemonServer = new DaemonServer({ home, idleExitMs: 0 });
33
+
34
+ servers.push(daemonServer);
35
+
36
+ return daemonServer;
34
37
  }
35
38
 
36
39
  describe("one daemon per home", () => {
37
40
  test("the second start is refused instead of stealing the socket", () => {
41
+ // Arrange
38
42
  const first = server();
39
43
  const path = first.start();
44
+
40
45
  expect(path).toBe(socketPath(home));
41
46
  const second = server();
47
+
48
+ // Assert
42
49
  expect(second.start()).toBeNull();
43
50
  // the winner's socket survives untouched
44
51
  expect(existsSync(path!)).toBe(true);
45
52
  });
46
53
 
54
+ test("stopping a refused server leaves the lock owner's socket intact", async () => {
55
+ const first = server();
56
+ const path = first.start();
57
+ const refused = server();
58
+
59
+ expect(refused.start()).toBeNull();
60
+ refused.stop();
61
+ expect(existsSync(path!)).toBe(true);
62
+ const client = await DaemonClient.connect({ home });
63
+
64
+ try {
65
+ expect((await client.ping()).pid).toBe(process.pid);
66
+ } finally {
67
+ client.close();
68
+ }
69
+ });
70
+
47
71
  test("clients keep talking to the one live daemon after a refused start", async () => {
72
+ // Arrange
48
73
  const first = server();
74
+
49
75
  first.start();
50
76
  const created = first.core.sessionCreate({ workspace: WS, artifact: PLAN });
77
+
51
78
  server().start(); // refused
52
79
  const client = await DaemonClient.connect({ home });
80
+
81
+ // Assert
53
82
  try {
54
83
  // no split brain: the session created on the winner is visible
55
84
  expect((await client.sessionGet(created.id)).id).toBe(created.id);
@@ -60,42 +89,162 @@ describe("one daemon per home", () => {
60
89
  });
61
90
 
62
91
  test("a stale lock from a crashed daemon is reclaimed", () => {
92
+ // Arrange
63
93
  // a pid that cannot be running (init is never a cueloop daemon, and this
64
94
  // simulates the record a crashed process leaves behind)
65
95
  writeFileSync(lockPath(home), "999999");
66
- const s = server();
67
- expect(s.start()).toBe(socketPath(home));
96
+ const daemonServer = server();
97
+
98
+ // Assert
99
+ expect(daemonServer.start()).toBe(socketPath(home));
100
+ });
101
+
102
+ test("a daemon does not steal a lock while its owner writes the pid", async () => {
103
+ const path = lockPath(home);
104
+ const script = `
105
+ import { openSync, writeFileSync } from "node:fs";
106
+ const fd = openSync(${JSON.stringify(path)}, "wx");
107
+ console.log("lock created");
108
+ setTimeout(() => writeFileSync(fd, String(process.pid)), 100);
109
+ setTimeout(() => {}, 2_000);
110
+ `;
111
+ const owner = Bun.spawn([process.execPath, "-e", script], {
112
+ stdout: "pipe",
113
+ stderr: "pipe",
114
+ });
115
+
116
+ try {
117
+ const firstOutput = await owner.stdout.getReader().read();
118
+
119
+ expect(new TextDecoder().decode(firstOutput.value)).toContain("lock created");
120
+ expect(server().start()).toBeNull();
121
+ expect(existsSync(socketPath(home))).toBe(false);
122
+ } finally {
123
+ owner.kill();
124
+ await owner.exited;
125
+ }
68
126
  });
69
127
 
128
+ test("an autostarted daemon reclaims a stale socket after taking the lock", async () => {
129
+ writeFileSync(socketPath(home), "stale socket");
130
+ const client = await DaemonClient.connect({ home, autostart: true });
131
+
132
+ try {
133
+ expect((await client.ping()).pid).toBeGreaterThan(0);
134
+ } finally {
135
+ await client.shutdown();
136
+ client.close();
137
+ }
138
+ });
139
+
140
+ test("autostart reports the daemon error when the child cannot start", async () => {
141
+ mkdirSync(ownerTokenPath(home));
142
+ const previousTimeout = process.env.CUELOOP_START_TIMEOUT_MS;
143
+
144
+ // This checks error reporting, not startup speed; allow the child to start on a loaded runner.
145
+ process.env.CUELOOP_START_TIMEOUT_MS = "5000";
146
+ try {
147
+ await expect(DaemonClient.connect({ home, autostart: true })).rejects.toThrow("EISDIR");
148
+ } finally {
149
+ if (previousTimeout === undefined) delete process.env.CUELOOP_START_TIMEOUT_MS;
150
+ else process.env.CUELOOP_START_TIMEOUT_MS = previousTimeout;
151
+ }
152
+ }, 10_000);
153
+
70
154
  test("stopping releases the lock so a restart works", () => {
155
+ // Arrange
71
156
  const first = server();
157
+
72
158
  first.start();
159
+
160
+ // Act
73
161
  first.stop();
162
+
163
+ // Assert
74
164
  expect(existsSync(lockPath(home))).toBe(false);
75
165
  const second = server();
166
+
76
167
  expect(second.start()).toBe(socketPath(home));
77
168
  });
78
169
 
79
170
  test("concurrent autostarts converge on a single daemon", async () => {
171
+ // Act
80
172
  // several clients race to autostart; all must end up on the same daemon
81
173
  const clients = await Promise.all([
82
174
  DaemonClient.connect({ home, autostart: true }),
83
175
  DaemonClient.connect({ home, autostart: true }),
84
176
  DaemonClient.connect({ home, autostart: true }),
85
177
  ]);
178
+
179
+ // Assert
86
180
  try {
87
- const pids = await Promise.all(clients.map((c) => c.ping()));
88
- const unique = new Set(pids.map((p) => p.pid));
181
+ const pids = await Promise.all(clients.map((client) => client.ping()));
182
+ const unique = new Set(pids.map((ping) => ping.pid));
183
+
89
184
  expect(unique.size).toBe(1);
90
185
  // and state is shared: one client's session is visible to the others
91
- const s = await clients[0]!.sessionCreate(WS, PLAN);
92
- expect((await clients[2]!.sessionGet(s.id)).id).toBe(s.id);
186
+ const session = await clients[0]!.sessionCreate(WS, PLAN);
187
+
188
+ expect((await clients[2]!.sessionGet(session.id)).id).toBe(session.id);
93
189
  } finally {
94
190
  for (const c of clients) c.close();
95
191
  // the autostarted daemon is not one of `servers` - shut it down
96
192
  const admin = await DaemonClient.connect({ home });
193
+
97
194
  await admin.shutdown();
98
195
  admin.close();
99
196
  }
100
197
  }, 60_000);
198
+
199
+ test("autostart joins a daemon that bound its socket after the first dial", async () => {
200
+ const running = server();
201
+ const path = running.start();
202
+ const created = running.core.sessionCreate({ workspace: WS, artifact: PLAN });
203
+ const client = new DaemonClient();
204
+ const previousTimeout = process.env.CUELOOP_START_TIMEOUT_MS;
205
+
206
+ client["home"] = home;
207
+ process.env.CUELOOP_START_TIMEOUT_MS = "250";
208
+
209
+ try {
210
+ await client["attachFreshDaemon"](home, path!);
211
+
212
+ expect((await client.sessionGet(created.id)).id).toBe(created.id);
213
+ expect(existsSync(path!)).toBe(true);
214
+ } finally {
215
+ client.close();
216
+ if (previousTimeout === undefined) delete process.env.CUELOOP_START_TIMEOUT_MS;
217
+ else process.env.CUELOOP_START_TIMEOUT_MS = previousTimeout;
218
+ }
219
+ });
220
+
221
+ test("autostart retries after a socket closes during the handshake", async () => {
222
+ const path = socketPath(home);
223
+ const replacement = server();
224
+ const transient = createServer((socket) => {
225
+ socket.destroy();
226
+ transient.close(() => {
227
+ rmSync(path, { force: true });
228
+ rmSync(lockPath(home), { force: true });
229
+ replacement.start();
230
+ });
231
+ });
232
+
233
+ writeFileSync(lockPath(home), String(process.pid));
234
+ await new Promise<void>((resolve) => transient.listen(path, resolve));
235
+ const client = new DaemonClient();
236
+ const previousTimeout = process.env.CUELOOP_START_TIMEOUT_MS;
237
+
238
+ client["home"] = home;
239
+ process.env.CUELOOP_START_TIMEOUT_MS = "1000";
240
+ try {
241
+ await client["attachFreshDaemon"](home, path);
242
+ expect((await client.ping()).pid).toBe(process.pid);
243
+ } finally {
244
+ client.close();
245
+ if (transient.listening) transient.close();
246
+ if (previousTimeout === undefined) delete process.env.CUELOOP_START_TIMEOUT_MS;
247
+ else process.env.CUELOOP_START_TIMEOUT_MS = previousTimeout;
248
+ }
249
+ });
101
250
  });
@@ -0,0 +1,41 @@
1
+ import { mkdtempSync, writeFileSync } from "node:fs";
2
+ import * as v from "valibot";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { MemoryThreadStore, ThreadStore, type ThreadRepository } from "./store";
6
+ import { sessionsDir } from "./paths";
7
+ import { runThreadStoreConformance } from "./testing/store-conformance";
8
+
9
+ const homes = new WeakMap<ThreadRepository, string>();
10
+
11
+ runThreadStoreConformance("file store", {
12
+ open: (records) => {
13
+ const home = mkdtempSync(join(tmpdir(), "cueloop-store-"));
14
+ const store = new ThreadStore(home);
15
+
16
+ // records land on disk the way an earlier daemon would have written them;
17
+ // the file name comes from the record's id when it has one
18
+ records.forEach((record, index) => {
19
+ const named = v.safeParse(v.object({ id: v.string() }), record);
20
+ const id = named.success ? named.output.id : `bad_${index}`;
21
+
22
+ writeFileSync(join(sessionsDir(home), `${id}.json`), JSON.stringify(record));
23
+ });
24
+ homes.set(store, home);
25
+
26
+ return store;
27
+ },
28
+ restart: (store) => {
29
+ const reopened = new ThreadStore(homes.get(store)!);
30
+
31
+ homes.set(reopened, homes.get(store)!);
32
+
33
+ return reopened;
34
+ },
35
+ });
36
+
37
+ runThreadStoreConformance("memory store", {
38
+ open: (records) => new MemoryThreadStore(records),
39
+ // nothing persists past the instance: a restart recovers what it was seeded with
40
+ restart: (store) => new MemoryThreadStore(store.list()),
41
+ });
@@ -0,0 +1,159 @@
1
+ /**
2
+ * The JSONL thread store's own behaviors, beyond the shared repository contract: per-project bucket
3
+ * layout keyed by root commit, one-time migration of the old whole-record files, compaction of a
4
+ * long log, and a crash's torn trailing line being skipped on read.
5
+ */
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import {
9
+ mkdtempSync,
10
+ mkdirSync,
11
+ readdirSync,
12
+ readFileSync,
13
+ writeFileSync,
14
+ appendFileSync,
15
+ existsSync,
16
+ } from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import { join } from "node:path";
19
+ import { ThreadStore } from "./store";
20
+ import { migratedSessionsDir, sessionsDir, threadBucket } from "./paths";
21
+ import { createTestThreadRecord } from "./testing/store-conformance";
22
+
23
+ function tmpHome(): string {
24
+ return mkdtempSync(join(tmpdir(), "cueloop-jsonl-"));
25
+ }
26
+
27
+ describe("JSONL thread store", () => {
28
+ test("a thread is written under a per-project bucket keyed by root commit", () => {
29
+ const home = tmpHome();
30
+ const store = new ThreadStore(home);
31
+ store.recover();
32
+ store.upsert(
33
+ createTestThreadRecord("ses_a", "2026-09-01T00:00:00.000Z", {
34
+ workspace: { repoRoot: "/repo", branch: "main", rootCommit: "abcdef0123456789" },
35
+ }),
36
+ );
37
+
38
+ const bucket = threadBucket("abcdef0123456789", home);
39
+ expect(readdirSync(bucket)).toContain("ses_a.jsonl");
40
+ });
41
+
42
+ test("a non-commit rootCommit (path traversal) cannot escape the threads directory", () => {
43
+ const home = tmpHome();
44
+ const escape = threadBucket("../../../etc/cueloop", home);
45
+ const standalone = threadBucket(undefined, home);
46
+
47
+ // a value that is not a real commit SHA is refused to the standalone bucket, never a parent path
48
+ expect(escape).toBe(standalone);
49
+ expect(escape.startsWith(join(home, "threads"))).toBe(true);
50
+ });
51
+
52
+ test("a repo-less thread lands in the standalone bucket", () => {
53
+ const home = tmpHome();
54
+ const store = new ThreadStore(home);
55
+ store.recover();
56
+ store.upsert(createTestThreadRecord("ses_s", "2026-09-01T00:00:00.000Z"));
57
+
58
+ expect(readdirSync(threadBucket(undefined, home))).toContain("ses_s.jsonl");
59
+ });
60
+
61
+ test("boot migrates the old whole-record file and parks the original", () => {
62
+ const home = tmpHome();
63
+ mkdirSync(sessionsDir(home), { recursive: true });
64
+ const legacy = createTestThreadRecord("ses_old", "2026-09-01T00:00:00.000Z");
65
+ writeFileSync(join(sessionsDir(home), "ses_old.json"), JSON.stringify(legacy));
66
+
67
+ const store = new ThreadStore(home);
68
+ const report = store.recover();
69
+
70
+ expect(report.recovered).toEqual(["ses_old"]);
71
+ // the record migrates intact (a history is derived on read, as the old store did)
72
+ expect(store.get("ses_old")?.id).toBe("ses_old");
73
+ expect(store.get("ses_old")?.artifact).toEqual(legacy.artifact);
74
+ // the original is parked, not left in the live legacy dir
75
+ expect(existsSync(join(sessionsDir(home), "ses_old.json"))).toBe(false);
76
+ expect(readdirSync(migratedSessionsDir(home))).toContain("ses_old.json");
77
+ });
78
+
79
+ test("migration never overwrites a newer JSONL with an older legacy snapshot", () => {
80
+ const home = tmpHome();
81
+ // a newer JSONL already holds this thread (e.g. written after a rollback)
82
+ const store = new ThreadStore(home);
83
+ store.recover();
84
+ store.upsert(
85
+ createTestThreadRecord("ses_x", "2026-09-01T00:00:00.000Z", {
86
+ status: "pending",
87
+ workspace: { repoRoot: "/repo", branch: "newer" },
88
+ }),
89
+ );
90
+ // an older legacy JSON for the same thread is restored alongside it
91
+ mkdirSync(sessionsDir(home), { recursive: true });
92
+ writeFileSync(
93
+ join(sessionsDir(home), "ses_x.json"),
94
+ JSON.stringify(
95
+ createTestThreadRecord("ses_x", "2026-09-01T00:00:00.000Z", {
96
+ workspace: { repoRoot: "/repo", branch: "older" },
97
+ }),
98
+ ),
99
+ );
100
+
101
+ const reopened = new ThreadStore(home);
102
+ reopened.recover();
103
+ // the newer JSONL wins; the legacy snapshot is parked, not applied
104
+ expect(reopened.get("ses_x")?.workspace.branch).toBe("newer");
105
+ expect(readdirSync(migratedSessionsDir(home))).toContain("ses_x.json");
106
+ });
107
+
108
+ test("a long log compacts to a single line, and the record survives it", () => {
109
+ const home = tmpHome();
110
+ const store = new ThreadStore(home);
111
+ store.recover();
112
+ // more upserts than the compaction threshold
113
+ for (let iteration = 0; iteration < 60; iteration++) {
114
+ store.upsert(
115
+ createTestThreadRecord("ses_a", "2026-09-01T00:00:00.000Z", {
116
+ status: "pending",
117
+ workspace: { repoRoot: "/repo", branch: `b${iteration}` },
118
+ }),
119
+ );
120
+ }
121
+
122
+ const file = join(threadBucket(undefined, home), "ses_a.jsonl");
123
+ const lines = readFileSync(file, "utf8")
124
+ .split("\n")
125
+ .filter((l) => l.trim());
126
+ expect(lines.length).toBeLessThan(60);
127
+ // the latest snapshot survives the compaction and a restart
128
+ const reopened = new ThreadStore(home);
129
+ reopened.recover();
130
+ expect(reopened.get("ses_a")?.workspace.branch).toBe("b59");
131
+ });
132
+
133
+ test("a crash's torn trailing line is skipped; the last valid snapshot wins", () => {
134
+ const home = tmpHome();
135
+ const store = new ThreadStore(home);
136
+ store.recover();
137
+ store.upsert(createTestThreadRecord("ses_a", "2026-09-01T00:00:00.000Z"));
138
+
139
+ // simulate a crash mid-append: a half-written line at the end
140
+ const file = join(threadBucket(undefined, home), "ses_a.jsonl");
141
+ appendFileSync(file, '{"id":"ses_a","half');
142
+
143
+ const reopened = new ThreadStore(home);
144
+ const report = reopened.recover();
145
+ expect(report.recovered).toEqual(["ses_a"]);
146
+ expect(reopened.get("ses_a")?.id).toBe("ses_a");
147
+
148
+ // an update after a torn recovery must not fuse onto the torn fragment and vanish on restart
149
+ reopened.upsert(
150
+ createTestThreadRecord("ses_a", "2026-09-01T00:00:00.000Z", {
151
+ status: "pending",
152
+ workspace: { repoRoot: "/repo", branch: "after-tear" },
153
+ }),
154
+ );
155
+ const restarted = new ThreadStore(home);
156
+ restarted.recover();
157
+ expect(restarted.get("ses_a")?.workspace.branch).toBe("after-tear");
158
+ });
159
+ });