@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.
- 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,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
|
|
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
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
67
|
-
|
|
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((
|
|
88
|
-
const unique = new Set(pids.map((
|
|
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
|
|
92
|
-
|
|
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
|
+
});
|