@cueloop/daemon 0.1.0-alpha.63 → 0.1.0-alpha.65

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/src/server.ts CHANGED
@@ -16,19 +16,28 @@ import {
16
16
  } from "node:fs";
17
17
  import { DaemonCore, type DaemonEvent } from "./api";
18
18
  import { DaemonError } from "./errors";
19
- import { roleAllowsMethod, type DaemonRole } from "./capabilities";
19
+ import { DEFAULT_ROLE, roleAllowsMethod, type DaemonRole } from "./capabilities";
20
20
  import { isKnownMethod, parseParams, type MethodName } from "./validate";
21
- import { BackpressureWriter, LineBuffer, type Request } from "./protocol";
22
- import { cueloopHome, lockPath, pidPath, socketPath } from "./paths";
21
+ import {
22
+ BackpressureWriter,
23
+ LineBuffer,
24
+ parseRequestFrame,
25
+ type Request,
26
+ type Response,
27
+ } from "./protocol";
28
+ import { cueloopHome, lockPath, ownerTokenPath, pidPath, socketPath } from "./paths";
29
+ import { randomBytes } from "node:crypto";
23
30
 
24
31
  interface Connection {
32
+ /** The author a non-owner connection acts as, bound in the handshake. */
33
+ author?: string;
25
34
  write(data: string): void;
26
35
  subscribed: boolean;
27
36
  /** Capability role for this connection; the owner until a daemon.hello caps it. */
28
37
  role: DaemonRole;
29
38
  }
30
39
 
31
- type MethodHandler = (connection: Connection, request: Request) => unknown;
40
+ type MethodHandler = (connection: Connection, request: Request) => Response["result"];
32
41
 
33
42
  export interface DaemonOptions {
34
43
  home?: string;
@@ -51,6 +60,8 @@ export class DaemonServer {
51
60
  private server: ReturnType<typeof Bun.listen> | null = null;
52
61
  private idleTimer: ReturnType<typeof setTimeout> | null = null;
53
62
  private lockFd: number | null = null;
63
+ /** The secret that proves ownership; minted per daemon run, readable by the home's user only. */
64
+ private ownerToken = "";
54
65
  private readonly idleExitMs: number;
55
66
  private readonly onIdleExit: () => void;
56
67
 
@@ -139,6 +150,11 @@ export class DaemonServer {
139
150
  // safe now: holding the lock means no live daemon owns this home, so any
140
151
  // socket file left behind is stale
141
152
  if (existsSync(path)) rmSync(path, { force: true });
153
+ // the token exists before the socket does: the first client to connect
154
+ // must be able to prove ownership
155
+ this.ownerToken = randomBytes(32).toString("hex");
156
+ writeFileSync(ownerTokenPath(this.home), this.ownerToken, { mode: 0o600 });
157
+ chmodSync(ownerTokenPath(this.home), 0o600);
142
158
  this.server = Bun.listen<{
143
159
  buffer: LineBuffer;
144
160
  connection: Connection;
@@ -151,7 +167,7 @@ export class DaemonServer {
151
167
  const connection: Connection = {
152
168
  write: (data) => writer.write(data),
153
169
  subscribed: false,
154
- role: "owner",
170
+ role: DEFAULT_ROLE,
155
171
  };
156
172
 
157
173
  socket.data = { buffer: new LineBuffer(), connection, writer };
@@ -188,6 +204,7 @@ export class DaemonServer {
188
204
  this.server = null;
189
205
  rmSync(socketPath(this.home), { force: true });
190
206
  rmSync(pidPath(this.home), { force: true });
207
+ rmSync(ownerTokenPath(this.home), { force: true });
191
208
  if (this.idleTimer) clearTimeout(this.idleTimer);
192
209
  this.releaseLock();
193
210
  }
@@ -217,7 +234,7 @@ export class DaemonServer {
217
234
  let request: Request;
218
235
 
219
236
  try {
220
- request = JSON.parse(line) as Request;
237
+ request = parseRequestFrame(line);
221
238
  } catch {
222
239
  connection.write(
223
240
  JSON.stringify({ id: -1, error: { code: "bad_json", message: "unparseable request" } }) +
@@ -241,7 +258,16 @@ export class DaemonServer {
241
258
  private readonly handlers: Record<MethodName, MethodHandler> = {
242
259
  "daemon.ping": () => ({ pid: process.pid }),
243
260
  "daemon.hello": (connection, request) => {
244
- connection.role = parseParams("daemon.hello", request.params).role;
261
+ const params = parseParams("daemon.hello", request.params);
262
+
263
+ // ownership is proven, never declared: the token lives in the home
264
+ // directory the daemon serves, readable by its user alone
265
+ if (params.role === "owner" && params.token !== this.ownerToken) {
266
+ throw new DaemonError("forbidden", "owner token required");
267
+ }
268
+ connection.role = params.role;
269
+ // identity is bound once, here; a non-owner never names it per call
270
+ if (params.role !== "owner" && params.author !== undefined) connection.author = params.author;
245
271
 
246
272
  return {};
247
273
  },
@@ -277,16 +303,78 @@ export class DaemonServer {
277
303
 
278
304
  return this.core.sessionAnnotate(params.id, params.annotation, params.authorName);
279
305
  },
280
- "session.removeAnnotation": (_connection, request) => {
306
+ "session.removeAnnotation": (connection, request) => {
281
307
  const params = parseParams("session.removeAnnotation", request.params);
282
308
 
283
- return this.core.sessionRemoveAnnotation(params.id, params.annotationId);
309
+ // the owner removes any comment; a non-owner only the ones of the author it is bound to
310
+ if (connection.role !== "owner" && connection.author === undefined) {
311
+ throw new DaemonError(
312
+ "forbidden",
313
+ "a non-owner connection removes comments as its bound author",
314
+ );
315
+ }
316
+
317
+ return this.core.sessionRemoveAnnotation(
318
+ params.id,
319
+ params.annotationId,
320
+ connection.role === "owner" ? undefined : connection.author,
321
+ );
322
+ },
323
+ "session.setParticipantName": (connection, request) => {
324
+ const params = parseParams("session.setParticipantName", request.params);
325
+
326
+ // the owner names anyone; a non-owner names only the author it is bound to
327
+ if (connection.role !== "owner" && params.author !== connection.author) {
328
+ throw new DaemonError("forbidden", "a non-owner connection names only its bound author");
329
+ }
330
+
331
+ return this.core.sessionSetParticipantName(params.id, params.author, params.name);
284
332
  },
285
333
  "session.setWorkingCopy": (_connection, request) => {
286
334
  const params = parseParams("session.setWorkingCopy", request.params);
287
335
 
288
336
  return this.core.sessionSetWorkingCopy(params.id, params.workingCopy);
289
337
  },
338
+ "session.cutBlock": (_connection, request) => {
339
+ const params = parseParams("session.cutBlock", request.params);
340
+
341
+ return this.core.sessionCutBlock(params.id, params.blockIndex);
342
+ },
343
+ "session.navigate": (_connection, request) => {
344
+ const params = parseParams("session.navigate", request.params);
345
+
346
+ return this.core.sessionNavigate(params.id, params.entryId, params.summary, params.branch);
347
+ },
348
+ "session.branch": (_connection, request) => {
349
+ const params = parseParams("session.branch", request.params);
350
+
351
+ return this.core.sessionBranch(params.id, params.name);
352
+ },
353
+ "session.switch": (_connection, request) => {
354
+ const params = parseParams("session.switch", request.params);
355
+
356
+ return this.core.sessionSwitch(params.id, params.branch);
357
+ },
358
+ "session.label": (_connection, request) => {
359
+ const params = parseParams("session.label", request.params);
360
+
361
+ return this.core.sessionLabel(params.id, params.label);
362
+ },
363
+ "session.fork": (_connection, request) => {
364
+ const params = parseParams("session.fork", request.params);
365
+
366
+ return this.core.sessionFork(params.id);
367
+ },
368
+ "session.restoreBlock": (_connection, request) => {
369
+ const params = parseParams("session.restoreBlock", request.params);
370
+
371
+ return this.core.sessionRestoreBlock(params.id, params.baseBlockIndex, params.line);
372
+ },
373
+ "session.curate": (_connection, request) => {
374
+ const params = parseParams("session.curate", request.params);
375
+
376
+ return this.core.sessionCurate(params.id, params.rejections);
377
+ },
290
378
  "session.setViewed": (_connection, request) => {
291
379
  const params = parseParams("session.setViewed", request.params);
292
380
 
@@ -313,6 +401,7 @@ export class DaemonServer {
313
401
  return this.core.sessionMergeShared(params.id, {
314
402
  annotations: params.annotations,
315
403
  participants: params.participants,
404
+ removals: params.removals,
316
405
  });
317
406
  },
318
407
  "session.resolve": (_connection, request) => {
@@ -340,13 +429,13 @@ export class DaemonServer {
340
429
  },
341
430
  };
342
431
 
343
- private async dispatch(connection: Connection, request: Request): Promise<unknown> {
432
+ private async dispatch(connection: Connection, request: Request): Promise<Response["result"]> {
344
433
  // The wire is untrusted JSON: validate before DaemonCore sees anything.
345
- if (typeof request.method !== "string" || !isKnownMethod(request.method)) {
346
- throw new DaemonError("unknown_method", `unknown method ${String(request.method)}`);
434
+ if (!isKnownMethod(request.method)) {
435
+ throw new DaemonError("unknown_method", `unknown method ${request.method}`);
347
436
  }
348
437
  // Capability gate: a capped role (a review-side agent) cannot escalate past
349
- // read + annotate, whatever verb it sends.
438
+ // read + annotate, whatever primitive it sends.
350
439
  if (!roleAllowsMethod(connection.role, request.method)) {
351
440
  throw new DaemonError("forbidden", `role ${connection.role} cannot call ${request.method}`);
352
441
  }
@@ -3,7 +3,7 @@ import { gzipSync } from "node:zlib";
3
3
  import { mkdtempSync, rmSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
- import type { Artifact, WorkspaceKey } from "@cueloop/schema";
6
+ import { viewFollowing, type Artifact, type WorkspaceKey } from "@cueloop/schema";
7
7
  import { DaemonCore } from "./api";
8
8
  import { MAX_BLOB_BYTES, packSessionBlob, unpackSessionBlob } from "./share-blob";
9
9
 
@@ -38,6 +38,56 @@ describe("share blob roundtrip", () => {
38
38
  });
39
39
  });
40
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
+
41
91
  describe("unpack rejects bad input", () => {
42
92
  test("caps the decompressed size, so a compression bomb throws", () => {
43
93
  // Arrange: tiny gzip, huge inflation - the decompression-bomb shape
package/src/share-blob.ts CHANGED
@@ -51,5 +51,5 @@ export function unpackSessionBlob(bytes: Uint8Array): ReviewSession {
51
51
 
52
52
  if (!parsed.ok) throw new Error(`blob is not a valid session: ${parsed.error}`);
53
53
 
54
- return parsed.value as ReviewSession;
54
+ return parsed.value;
55
55
  }
@@ -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 { MemorySessionStore, SessionStore, type SessionRepository } from "./store";
6
+ import { sessionsDir } from "./paths";
7
+ import { runSessionStoreConformance } from "./testing/store-conformance";
8
+
9
+ const homes = new WeakMap<SessionRepository, string>();
10
+
11
+ runSessionStoreConformance("file store", {
12
+ open: (records) => {
13
+ const home = mkdtempSync(join(tmpdir(), "cueloop-store-"));
14
+ const store = new SessionStore(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 SessionStore(homes.get(store)!);
30
+
31
+ homes.set(reopened, homes.get(store)!);
32
+
33
+ return reopened;
34
+ },
35
+ });
36
+
37
+ runSessionStoreConformance("memory store", {
38
+ open: (records) => new MemorySessionStore(records),
39
+ // nothing persists past the instance: a restart recovers what it was seeded with
40
+ restart: (store) => new MemorySessionStore(store.list()),
41
+ });
package/src/store.ts CHANGED
@@ -1,22 +1,55 @@
1
1
  /**
2
- * Session persistence: one JSON document per session, every write
3
- * through a temp file + atomic rename so a crash mid-write can never leave
4
- * a corrupt record. Recovery is a read-only scan; records that fail to
5
- * parse are skipped and reported, never deleted.
2
+ * Session persistence: one JSON record per session, written whole through a
3
+ * temp file and an atomic rename so a crash never leaves a torn file. Recovery
4
+ * is a read-only scan of the state directory; records that fail to parse are
5
+ * skipped and reported, never deleted, and records from before histories
6
+ * existed are given one on read.
7
+ *
8
+ * `SessionRepository` is the contract every adapter satisfies; the conformance
9
+ * suite in ./testing/store-conformance.ts pins it for the file store and the
10
+ * in-memory store alike.
6
11
  */
7
12
 
8
13
  import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
9
14
  import { join } from "node:path";
10
- import type { ReviewSession } from "@cueloop/schema";
11
- import { validateSessionRecord } from "./validate";
15
+ import { historyFromLinear, type ReviewSession } from "@cueloop/schema";
12
16
  import { sessionsDir } from "./paths";
17
+ import { validateSessionRecord } from "./validate";
13
18
 
14
19
  export interface RecoveryReport {
15
20
  recovered: string[];
16
21
  skipped: { file: string; error: string }[];
17
22
  }
18
23
 
19
- export class SessionStore {
24
+ /** What the daemon needs from session storage. */
25
+ export interface SessionRepository {
26
+ /** Load what is stored; called once on boot. */
27
+ recover(): RecoveryReport;
28
+ get(id: string): ReviewSession | undefined;
29
+ /** Every session, oldest first. */
30
+ list(): ReviewSession[];
31
+ upsert(session: ReviewSession): void;
32
+ /** True when a session was removed. */
33
+ delete(id: string): boolean;
34
+ }
35
+
36
+ /**
37
+ * A record as it is read: a history is derived for records written without
38
+ * one. A record with no revision has no head to derive from and keeps
39
+ * reading without a history - migration never loses a record.
40
+ */
41
+ export function withHistory(session: ReviewSession): ReviewSession {
42
+ if (session.history || session.revisions.length === 0) return session;
43
+
44
+ return { ...session, history: historyFromLinear(session) };
45
+ }
46
+
47
+ /** Records in the order `list()` promises: oldest first. */
48
+ function oldestFirst(sessions: Iterable<ReviewSession>): ReviewSession[] {
49
+ return [...sessions].sort((left, right) => left.createdAt.localeCompare(right.createdAt));
50
+ }
51
+
52
+ export class SessionStore implements SessionRepository {
20
53
  private sessions = new Map<string, ReviewSession>();
21
54
  private readonly dir: string;
22
55
 
@@ -25,7 +58,6 @@ export class SessionStore {
25
58
  mkdirSync(this.dir, { recursive: true });
26
59
  }
27
60
 
28
- /** Read-only scan of the state directory; called once on boot. */
29
61
  recover(): RecoveryReport {
30
62
  const report: RecoveryReport = { recovered: [], skipped: [] };
31
63
 
@@ -36,7 +68,7 @@ export class SessionStore {
36
68
  const parsed = validateSessionRecord(JSON.parse(raw));
37
69
 
38
70
  if (!parsed.ok) throw new Error(`invalid record - ${parsed.error}`);
39
- const session = parsed.value as ReviewSession;
71
+ const session = withHistory(parsed.value);
40
72
 
41
73
  this.sessions.set(session.id, session);
42
74
  report.recovered.push(session.id);
@@ -53,9 +85,7 @@ export class SessionStore {
53
85
  }
54
86
 
55
87
  list(): ReviewSession[] {
56
- return [...this.sessions.values()].sort((left, right) =>
57
- left.createdAt.localeCompare(right.createdAt),
58
- );
88
+ return oldestFirst(this.sessions.values());
59
89
  }
60
90
 
61
91
  upsert(session: ReviewSession): void {
@@ -74,3 +104,50 @@ export class SessionStore {
74
104
  return true;
75
105
  }
76
106
  }
107
+
108
+ /**
109
+ * The in-memory adapter: the same contract with nothing on disk. `seed` stands
110
+ * in for what a file store finds on recovery, so validation and migration are
111
+ * exercised the same way.
112
+ */
113
+ export class MemorySessionStore implements SessionRepository {
114
+ private sessions = new Map<string, ReviewSession>();
115
+
116
+ constructor(private readonly seed: unknown[] = []) {}
117
+
118
+ recover(): RecoveryReport {
119
+ const report: RecoveryReport = { recovered: [], skipped: [] };
120
+
121
+ this.seed.forEach((record, index) => {
122
+ const parsed = validateSessionRecord(record);
123
+
124
+ if (!parsed.ok) {
125
+ report.skipped.push({ file: `seed[${index}]`, error: `invalid record - ${parsed.error}` });
126
+
127
+ return;
128
+ }
129
+ const session = withHistory(parsed.value);
130
+
131
+ this.sessions.set(session.id, session);
132
+ report.recovered.push(session.id);
133
+ });
134
+
135
+ return report;
136
+ }
137
+
138
+ get(id: string): ReviewSession | undefined {
139
+ return this.sessions.get(id);
140
+ }
141
+
142
+ list(): ReviewSession[] {
143
+ return oldestFirst(this.sessions.values());
144
+ }
145
+
146
+ upsert(session: ReviewSession): void {
147
+ this.sessions.set(session.id, session);
148
+ }
149
+
150
+ delete(id: string): boolean {
151
+ return this.sessions.delete(id);
152
+ }
153
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * The session repository contract as executable cases. Every adapter runs the
3
+ * same suite: what a store returns, in which order, what survives a restart,
4
+ * what a bad record does, and how a record without a history reads.
5
+ *
6
+ * `restart` gives back a fresh adapter over the same persisted state (or the
7
+ * same seed) so recovery is tested through the contract, never past it.
8
+ */
9
+
10
+ import { describe, expect, test } from "bun:test";
11
+ import { SCHEMA_VERSION, type ReviewSession } from "@cueloop/schema";
12
+ import type { SessionRepository } from "../store";
13
+
14
+ export interface StoreHarness {
15
+ /** A fresh, empty adapter that will recover `records` on `recover()`. */
16
+ open: (records: unknown[]) => SessionRepository;
17
+ /** A new adapter over whatever `store` persisted, as after a daemon restart. */
18
+ restart: (store: SessionRepository) => SessionRepository;
19
+ }
20
+
21
+ export function createTestSessionRecord(
22
+ id: string,
23
+ createdAt: string,
24
+ overrides: Partial<ReviewSession> = {},
25
+ ): ReviewSession {
26
+ return {
27
+ schemaVersion: SCHEMA_VERSION,
28
+ id,
29
+ workspace: { repoRoot: "/repo", branch: "main" },
30
+ artifact: { type: "plan", content: `# ${id}`, meta: {} },
31
+ revisions: [{ revision: 1, content: `# ${id}`, submittedAt: createdAt }],
32
+ annotations: [],
33
+ verdict: null,
34
+ status: "pending",
35
+ createdAt,
36
+ ...overrides,
37
+ };
38
+ }
39
+
40
+ export function runSessionStoreConformance(name: string, harness: StoreHarness): void {
41
+ describe(`${name} - session repository contract`, () => {
42
+ test("a stored session reads back whole and lists oldest first", () => {
43
+ // Arrange
44
+ const store = harness.open([]);
45
+
46
+ store.recover();
47
+ const newer = createTestSessionRecord("ses_b", "2026-09-02T00:00:00.000Z");
48
+ const older = createTestSessionRecord("ses_a", "2026-09-01T00:00:00.000Z");
49
+
50
+ // Act
51
+ store.upsert(newer);
52
+ store.upsert(older);
53
+
54
+ // Assert
55
+ expect(store.get("ses_a")).toEqual(older);
56
+ expect(store.list().map((session) => session.id)).toEqual(["ses_a", "ses_b"]);
57
+ expect(store.get("ses_zzz")).toBeUndefined();
58
+ });
59
+
60
+ test("upsert replaces by id and delete reports whether anything was removed", () => {
61
+ // Arrange
62
+ const store = harness.open([]);
63
+
64
+ store.recover();
65
+ store.upsert(createTestSessionRecord("ses_a", "2026-09-01T00:00:00.000Z"));
66
+
67
+ // Act
68
+ store.upsert(
69
+ createTestSessionRecord("ses_a", "2026-09-01T00:00:00.000Z", { status: "resolved" }),
70
+ );
71
+
72
+ // Assert
73
+ expect(store.list()).toHaveLength(1);
74
+ expect(store.get("ses_a")?.status).toBe("resolved");
75
+ expect(store.delete("ses_a")).toBe(true);
76
+ expect(store.delete("ses_a")).toBe(false);
77
+ expect(store.list()).toEqual([]);
78
+ });
79
+
80
+ test("what was upserted survives a restart, deletions included", () => {
81
+ // Arrange
82
+ const store = harness.open([]);
83
+
84
+ store.recover();
85
+ store.upsert(createTestSessionRecord("ses_keep", "2026-09-01T00:00:00.000Z"));
86
+ store.upsert(createTestSessionRecord("ses_gone", "2026-09-02T00:00:00.000Z"));
87
+ store.delete("ses_gone");
88
+
89
+ // Act
90
+ const reopened = harness.restart(store);
91
+ const report = reopened.recover();
92
+
93
+ // Assert
94
+ expect(report.recovered).toEqual(["ses_keep"]);
95
+ expect(reopened.get("ses_keep")?.artifact.content).toBe("# ses_keep");
96
+ expect(reopened.get("ses_gone")).toBeUndefined();
97
+ });
98
+
99
+ test("a record without a history reads as a one-branch tree whose head is the artifact", () => {
100
+ // Arrange: written before histories existed, with two revisions and a comment
101
+ const legacy = createTestSessionRecord("ses_old", "2026-09-01T00:00:00.000Z", {
102
+ artifact: { type: "plan", content: "v2", meta: {} },
103
+ revisions: [
104
+ { revision: 1, content: "v1", submittedAt: "2026-09-01T00:00:00.000Z" },
105
+ { revision: 2, content: "v2", submittedAt: "2026-09-01T02:00:00.000Z" },
106
+ ],
107
+ annotations: [
108
+ {
109
+ id: "a1",
110
+ kind: "comment",
111
+ anchor: { quote: "v1", prefix: "", suffix: "" },
112
+ body: "why",
113
+ createdAt: "2026-09-01T01:00:00.000Z",
114
+ },
115
+ ],
116
+ });
117
+ const store = harness.open([legacy]);
118
+
119
+ // Act
120
+ const report = store.recover();
121
+ const history = store.get("ses_old")?.history;
122
+
123
+ // Assert
124
+ expect(report.recovered).toEqual(["ses_old"]);
125
+ expect(history?.branch).toBe("main");
126
+ expect(history?.entries.map((entry) => entry.type)).toEqual([
127
+ "revision",
128
+ "comment",
129
+ "revision",
130
+ ]);
131
+ expect(history?.entries.at(-1)).toMatchObject({ type: "revision", content: "v2" });
132
+ expect(history?.tips.main).toBe(history?.entries.at(-1)?.id);
133
+ });
134
+
135
+ test("a record with no revision still recovers, without a history", () => {
136
+ // Arrange
137
+ const store = harness.open([
138
+ createTestSessionRecord("ses_empty", "2026-09-01T00:00:00.000Z", { revisions: [] }),
139
+ ]);
140
+
141
+ // Act
142
+ const report = store.recover();
143
+
144
+ // Assert
145
+ expect(report.recovered).toEqual(["ses_empty"]);
146
+ expect(store.get("ses_empty")?.history).toBeUndefined();
147
+ });
148
+
149
+ test("a record that already carries a history keeps it untouched", () => {
150
+ // Arrange
151
+ const withTree = createTestSessionRecord("ses_tree", "2026-09-01T00:00:00.000Z", {
152
+ history: {
153
+ entries: [
154
+ {
155
+ id: "root",
156
+ parentId: null,
157
+ type: "revision",
158
+ by: "agent",
159
+ content: "# ses_tree",
160
+ createdAt: "2026-09-01T00:00:00.000Z",
161
+ },
162
+ ],
163
+ tips: { main: "root", ramble: "root" },
164
+ branch: "ramble",
165
+ labels: { root: "start" },
166
+ },
167
+ });
168
+ const store = harness.open([withTree]);
169
+
170
+ // Act
171
+ store.recover();
172
+
173
+ // Assert
174
+ expect(store.get("ses_tree")?.history).toEqual(withTree.history);
175
+ });
176
+
177
+ test("a record that fails validation is skipped and reported, never dropped from the report", () => {
178
+ // Arrange
179
+ const store = harness.open([
180
+ createTestSessionRecord("ses_good", "2026-09-01T00:00:00.000Z"),
181
+ { schemaVersion: SCHEMA_VERSION, id: "ses_bad" },
182
+ ]);
183
+
184
+ // Act
185
+ const report = store.recover();
186
+
187
+ // Assert
188
+ expect(report.recovered).toEqual(["ses_good"]);
189
+ expect(report.skipped).toHaveLength(1);
190
+ expect(report.skipped[0]!.error).toContain("invalid record");
191
+ expect(store.get("ses_bad")).toBeUndefined();
192
+ });
193
+ });
194
+ }