@cueloop/daemon 0.1.0-alpha.68 → 0.1.0-alpha.69
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 +3 -2
- package/src/api.test.ts +100 -1
- package/src/api.ts +71 -3
- package/src/capabilities.ts +2 -0
- package/src/client.ts +116 -17
- package/src/daemon-version.test.ts +93 -0
- package/src/interruptible-wait.test.ts +127 -0
- package/src/interruptible-wait.ts +66 -0
- package/src/paths.ts +25 -0
- package/src/review.ts +6 -44
- package/src/server.ts +16 -2
- package/src/store.jsonl.test.ts +159 -0
- package/src/store.ts +158 -27
- package/src/validate.test.ts +2 -0
- package/src/validate.ts +13 -0
- package/src/version.ts +6 -0
|
@@ -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
|
+
}
|
package/src/paths.ts
CHANGED
|
@@ -18,6 +18,31 @@ export function sessionsDir(home = cueloopHome()): string {
|
|
|
18
18
|
return join(home, "sessions");
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/** Thread files live here, one JSONL per thread, under a per-project bucket. */
|
|
22
|
+
export function threadsDir(home = cueloopHome()): string {
|
|
23
|
+
return join(home, "threads");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A git commit is lowercase hex; anything else is not a real root commit. */
|
|
27
|
+
const COMMIT_SHA = /^[0-9a-f]{7,64}$/;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The per-project bucket for a workspace: its durable root-commit SHA, or a shared standalone bucket.
|
|
31
|
+
* A value that is not a commit SHA (including path-traversal segments an IPC caller could smuggle in)
|
|
32
|
+
* is refused to the standalone bucket, so a bucket name can never escape the threads directory.
|
|
33
|
+
*/
|
|
34
|
+
export function threadBucket(rootCommit: string | undefined, home = cueloopHome()): string {
|
|
35
|
+
const bucket =
|
|
36
|
+
rootCommit && COMMIT_SHA.test(rootCommit) ? rootCommit.slice(0, 12) : "_standalone";
|
|
37
|
+
|
|
38
|
+
return join(threadsDir(home), bucket);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Where the one-time migration parks the old whole-record JSON files, so a downgrade can still read them. */
|
|
42
|
+
export function migratedSessionsDir(home = cueloopHome()): string {
|
|
43
|
+
return join(home, "sessions.migrated");
|
|
44
|
+
}
|
|
45
|
+
|
|
21
46
|
export function reportsDir(home = cueloopHome()): string {
|
|
22
47
|
return join(home, "reports");
|
|
23
48
|
}
|
package/src/review.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from "@cueloop/schema";
|
|
18
18
|
import { verdictResponse } from "./api";
|
|
19
19
|
import type { DaemonClient } from "./client";
|
|
20
|
+
import { ABORTED, pollUntilResolved, raceAbort } from "./interruptible-wait";
|
|
20
21
|
|
|
21
22
|
// Adapters and CLI primitives reach the verdict mapping through this module too,
|
|
22
23
|
// so a session obtained outside a ReviewHandle maps the same way.
|
|
@@ -139,42 +140,6 @@ export interface VerdictOutcome {
|
|
|
139
140
|
session: ReviewSession;
|
|
140
141
|
}
|
|
141
142
|
|
|
142
|
-
/** Sentinel distinguishing an abort from any daemon response. */
|
|
143
|
-
const ABORTED = Symbol("aborted");
|
|
144
|
-
|
|
145
|
-
function raceAbort<T>(
|
|
146
|
-
promise: Promise<T>,
|
|
147
|
-
signal: AbortSignal | undefined,
|
|
148
|
-
): Promise<T | typeof ABORTED> {
|
|
149
|
-
if (!signal) return promise;
|
|
150
|
-
if (signal.aborted) {
|
|
151
|
-
promise.catch(() => {});
|
|
152
|
-
|
|
153
|
-
return Promise.resolve(ABORTED);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
return new Promise<T | typeof ABORTED>((resolve, reject) => {
|
|
157
|
-
const onAbort = () => {
|
|
158
|
-
// The daemon request keeps running until the client closes; swallow its
|
|
159
|
-
// eventual rejection so the abort path never leaks an unhandled error.
|
|
160
|
-
promise.catch(() => {});
|
|
161
|
-
resolve(ABORTED);
|
|
162
|
-
};
|
|
163
|
-
|
|
164
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
165
|
-
promise.then(
|
|
166
|
-
(value) => {
|
|
167
|
-
signal.removeEventListener("abort", onAbort);
|
|
168
|
-
resolve(value);
|
|
169
|
-
},
|
|
170
|
-
(error) => {
|
|
171
|
-
signal.removeEventListener("abort", onAbort);
|
|
172
|
-
reject(error);
|
|
173
|
-
},
|
|
174
|
-
);
|
|
175
|
-
});
|
|
176
|
-
}
|
|
177
|
-
|
|
178
143
|
export class ReviewHandle {
|
|
179
144
|
constructor(
|
|
180
145
|
private readonly client: DaemonClient,
|
|
@@ -247,15 +212,12 @@ export async function awaitResolve(
|
|
|
247
212
|
options: AwaitResolveOptions = {},
|
|
248
213
|
): Promise<VerdictOutcome | null> {
|
|
249
214
|
const chunkMs = options.pollMs ?? 30_000;
|
|
250
|
-
const
|
|
215
|
+
const resolved = await pollUntilResolved(
|
|
216
|
+
() => client.sessionWait(sessionId, chunkMs),
|
|
217
|
+
options.signal,
|
|
218
|
+
);
|
|
251
219
|
|
|
252
|
-
|
|
253
|
-
if (signal?.aborted) return null;
|
|
254
|
-
const resolved = await raceAbort(client.sessionWait(sessionId, chunkMs), signal);
|
|
255
|
-
|
|
256
|
-
if (resolved === ABORTED) return null;
|
|
257
|
-
if (resolved !== null) return outcome(resolved);
|
|
258
|
-
}
|
|
220
|
+
return resolved === null ? null : outcome(resolved);
|
|
259
221
|
}
|
|
260
222
|
|
|
261
223
|
/** Open a review session (or revise the agent session's existing one) and hand back the wait surface. */
|
package/src/server.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
type Response,
|
|
27
27
|
} from "./protocol";
|
|
28
28
|
import { cueloopHome, lockPath, ownerTokenPath, pidPath, socketPath } from "./paths";
|
|
29
|
+
import { DAEMON_VERSION } from "./version";
|
|
29
30
|
import { randomBytes } from "node:crypto";
|
|
30
31
|
|
|
31
32
|
interface Connection {
|
|
@@ -44,6 +45,8 @@ export interface DaemonOptions {
|
|
|
44
45
|
/** Idle-exit delay; 0 disables (tests, foreground runs). */
|
|
45
46
|
idleExitMs?: number;
|
|
46
47
|
onIdleExit?: () => void;
|
|
48
|
+
/** The version the ping handshake reports; defaults to this build. Tests override it to pose as a stale daemon. */
|
|
49
|
+
version?: string;
|
|
47
50
|
}
|
|
48
51
|
|
|
49
52
|
/**
|
|
@@ -64,11 +67,13 @@ export class DaemonServer {
|
|
|
64
67
|
private ownerToken = "";
|
|
65
68
|
private readonly idleExitMs: number;
|
|
66
69
|
private readonly onIdleExit: () => void;
|
|
70
|
+
private readonly version: string;
|
|
67
71
|
|
|
68
72
|
constructor(options: DaemonOptions = {}) {
|
|
69
73
|
this.home = options.home ?? cueloopHome();
|
|
70
74
|
this.idleExitMs = options.idleExitMs ?? 15 * 60 * 1000;
|
|
71
75
|
this.onIdleExit = options.onIdleExit ?? (() => process.exit(0));
|
|
76
|
+
this.version = options.version ?? DAEMON_VERSION;
|
|
72
77
|
mkdirSync(this.home, { recursive: true, mode: 0o700 });
|
|
73
78
|
this.core = new DaemonCore(this.home);
|
|
74
79
|
this.core.onEvent((event) => this.broadcast(event));
|
|
@@ -256,7 +261,7 @@ export class DaemonServer {
|
|
|
256
261
|
}
|
|
257
262
|
|
|
258
263
|
private readonly handlers: Record<MethodName, MethodHandler> = {
|
|
259
|
-
"daemon.ping": () => ({ pid: process.pid }),
|
|
264
|
+
"daemon.ping": () => ({ pid: process.pid, version: this.version }),
|
|
260
265
|
"daemon.hello": (connection, request) => {
|
|
261
266
|
const params = parseParams("daemon.hello", request.params);
|
|
262
267
|
|
|
@@ -401,6 +406,10 @@ export class DaemonServer {
|
|
|
401
406
|
},
|
|
402
407
|
"repo.changes": (_connection, request) =>
|
|
403
408
|
this.core.repoChanges(parseParams("repo.changes", request.params).cwd),
|
|
409
|
+
"repo.diff": (_connection, request) =>
|
|
410
|
+
this.core.repoDiff(parseParams("repo.diff", request.params).cwd),
|
|
411
|
+
"session.workbench": (_connection, request) =>
|
|
412
|
+
this.core.workbenchSession(parseParams("session.workbench", request.params).cwd),
|
|
404
413
|
"session.refreshDiff": (_connection, request) => {
|
|
405
414
|
const params = parseParams("session.refreshDiff", request.params);
|
|
406
415
|
|
|
@@ -428,7 +437,12 @@ export class DaemonServer {
|
|
|
428
437
|
"session.resolve": (_connection, request) => {
|
|
429
438
|
const params = parseParams("session.resolve", request.params);
|
|
430
439
|
|
|
431
|
-
return this.core.sessionResolve(
|
|
440
|
+
return this.core.sessionResolve(
|
|
441
|
+
params.id,
|
|
442
|
+
params.verdictKind,
|
|
443
|
+
params.summary,
|
|
444
|
+
params.actionBodies,
|
|
445
|
+
);
|
|
432
446
|
},
|
|
433
447
|
"session.submitRevision": (_connection, request) => {
|
|
434
448
|
const params = parseParams("session.submitRevision", request.params);
|
|
@@ -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 { SessionStore } from "./store";
|
|
20
|
+
import { migratedSessionsDir, sessionsDir, threadBucket } from "./paths";
|
|
21
|
+
import { createTestSessionRecord } 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 SessionStore(home);
|
|
31
|
+
store.recover();
|
|
32
|
+
store.upsert(
|
|
33
|
+
createTestSessionRecord("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 SessionStore(home);
|
|
55
|
+
store.recover();
|
|
56
|
+
store.upsert(createTestSessionRecord("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 = createTestSessionRecord("ses_old", "2026-09-01T00:00:00.000Z");
|
|
65
|
+
writeFileSync(join(sessionsDir(home), "ses_old.json"), JSON.stringify(legacy));
|
|
66
|
+
|
|
67
|
+
const store = new SessionStore(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 SessionStore(home);
|
|
83
|
+
store.recover();
|
|
84
|
+
store.upsert(
|
|
85
|
+
createTestSessionRecord("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
|
+
createTestSessionRecord("ses_x", "2026-09-01T00:00:00.000Z", {
|
|
96
|
+
workspace: { repoRoot: "/repo", branch: "older" },
|
|
97
|
+
}),
|
|
98
|
+
),
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const reopened = new SessionStore(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 SessionStore(home);
|
|
111
|
+
store.recover();
|
|
112
|
+
// more upserts than the compaction threshold
|
|
113
|
+
for (let iteration = 0; iteration < 60; iteration++) {
|
|
114
|
+
store.upsert(
|
|
115
|
+
createTestSessionRecord("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 SessionStore(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 SessionStore(home);
|
|
136
|
+
store.recover();
|
|
137
|
+
store.upsert(createTestSessionRecord("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 SessionStore(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
|
+
createTestSessionRecord("ses_a", "2026-09-01T00:00:00.000Z", {
|
|
151
|
+
status: "pending",
|
|
152
|
+
workspace: { repoRoot: "/repo", branch: "after-tear" },
|
|
153
|
+
}),
|
|
154
|
+
);
|
|
155
|
+
const restarted = new SessionStore(home);
|
|
156
|
+
restarted.recover();
|
|
157
|
+
expect(restarted.get("ses_a")?.workspace.branch).toBe("after-tear");
|
|
158
|
+
});
|
|
159
|
+
});
|