@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/package.json +6 -3
- package/src/api.test.ts +479 -1
- package/src/api.ts +453 -28
- package/src/capabilities.test.ts +28 -9
- package/src/capabilities.ts +58 -22
- package/src/client.ts +183 -43
- package/src/curate.ts +109 -0
- package/src/diff-hot-reload.test.ts +16 -0
- package/src/diff-watcher.ts +1 -4
- package/src/herdr-pane.test.ts +4 -7
- package/src/herdr-pane.ts +20 -7
- package/src/herdr-split.test.ts +1 -1
- package/src/herdr-split.ts +8 -4
- package/src/herdr-tab-store.ts +10 -13
- package/src/paths.ts +5 -0
- package/src/protocol.ts +28 -0
- package/src/retention.test.ts +3 -3
- package/src/retention.ts +1 -1
- package/src/review.ts +1 -1
- package/src/server.test.ts +258 -54
- package/src/server.ts +102 -13
- package/src/share-blob.test.ts +51 -1
- package/src/share-blob.ts +1 -1
- package/src/store.conformance.test.ts +41 -0
- package/src/store.ts +89 -12
- package/src/testing/store-conformance.ts +194 -0
- package/src/validate.test.ts +60 -3
- package/src/validate.ts +104 -3
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 {
|
|
22
|
-
|
|
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) =>
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
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": (
|
|
306
|
+
"session.removeAnnotation": (connection, request) => {
|
|
281
307
|
const params = parseParams("session.removeAnnotation", request.params);
|
|
282
308
|
|
|
283
|
-
|
|
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<
|
|
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 (
|
|
346
|
-
throw new DaemonError("unknown_method", `unknown 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
|
|
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
|
}
|
package/src/share-blob.test.ts
CHANGED
|
@@ -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
|
|
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
|
@@ -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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|