@cueloop/daemon 0.1.0-alpha.64 → 0.1.0-alpha.66
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 +5 -3
- package/src/api.test.ts +479 -1
- package/src/api.ts +452 -27
- package/src/capabilities.test.ts +28 -9
- package/src/capabilities.ts +52 -19
- package/src/client.ts +126 -18
- package/src/curate.ts +109 -0
- package/src/diff-hot-reload.test.ts +16 -0
- package/src/herdr-pane.test.ts +1 -1
- package/src/paths.ts +5 -0
- package/src/protocol.ts +3 -0
- package/src/review.ts +25 -4
- package/src/server.test.ts +229 -2
- package/src/server.ts +90 -7
- package/src/share-blob.test.ts +51 -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 +61 -1
- package/src/validate.ts +103 -1
package/src/server.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import * as v from "valibot";
|
|
@@ -80,6 +80,80 @@ describe("socket round-trip", () => {
|
|
|
80
80
|
observer.close();
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
+
test("an event names the history entry its change appended", async () => {
|
|
84
|
+
// Arrange
|
|
85
|
+
const observer = await DaemonClient.connect({ home });
|
|
86
|
+
const frames: Array<{ event: string; entryId?: string }> = [];
|
|
87
|
+
|
|
88
|
+
observer.onEvent((event) => frames.push(event));
|
|
89
|
+
await observer.subscribe();
|
|
90
|
+
const session = await client.sessionCreate(WS, PLAN);
|
|
91
|
+
|
|
92
|
+
// Act
|
|
93
|
+
const annotated = await client.sessionAnnotate(session.id, {
|
|
94
|
+
id: "a_evt",
|
|
95
|
+
kind: "comment",
|
|
96
|
+
anchor: { quote: "Plan", prefix: "", suffix: "" },
|
|
97
|
+
body: "hello",
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
await Bun.sleep(50);
|
|
101
|
+
|
|
102
|
+
// Assert: the update frame carries the comment entry's id
|
|
103
|
+
const updated = frames.find((frame) => frame.event === "session.updated");
|
|
104
|
+
|
|
105
|
+
expect(updated?.entryId).toBe(annotated.history!.entries.at(-1)!.id);
|
|
106
|
+
observer.close();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("a non-owner removes only the comments of the author it acts as, and names itself", async () => {
|
|
110
|
+
// Arrange: the owner's and Ana's comments; an agent connection acting as Ana
|
|
111
|
+
const session = await client.sessionCreate(WS, PLAN);
|
|
112
|
+
|
|
113
|
+
await client.sessionAnnotate(session.id, {
|
|
114
|
+
id: "own",
|
|
115
|
+
kind: "comment",
|
|
116
|
+
anchor: { quote: "Plan", prefix: "", suffix: "" },
|
|
117
|
+
body: "mine",
|
|
118
|
+
});
|
|
119
|
+
await client.sessionAnnotate(session.id, {
|
|
120
|
+
id: "anas",
|
|
121
|
+
kind: "comment",
|
|
122
|
+
anchor: { quote: "Plan", prefix: "", suffix: "" },
|
|
123
|
+
body: "hers",
|
|
124
|
+
author: "SHA256:ana",
|
|
125
|
+
});
|
|
126
|
+
const anonymous = await DaemonClient.connect({ home, role: "agent" });
|
|
127
|
+
const agent = await DaemonClient.connect({ home, role: "agent", author: "SHA256:ana" });
|
|
128
|
+
|
|
129
|
+
// Act + Assert: an unbound connection removes nothing; a bound one never another author's
|
|
130
|
+
await expect(anonymous.sessionRemoveAnnotation(session.id, "own")).rejects.toHaveProperty(
|
|
131
|
+
"code",
|
|
132
|
+
"forbidden",
|
|
133
|
+
);
|
|
134
|
+
await expect(agent.sessionRemoveAnnotation(session.id, "own")).rejects.toHaveProperty(
|
|
135
|
+
"code",
|
|
136
|
+
"forbidden",
|
|
137
|
+
);
|
|
138
|
+
await expect(
|
|
139
|
+
agent.sessionSetParticipantName(session.id, "SHA256:bob", "Not Bob"),
|
|
140
|
+
).rejects.toHaveProperty("code", "forbidden");
|
|
141
|
+
|
|
142
|
+
// Act: her own comment goes, and she names herself
|
|
143
|
+
const removed = await agent.sessionRemoveAnnotation(session.id, "anas");
|
|
144
|
+
const named = await agent.sessionSetParticipantName(session.id, "SHA256:ana", "Ana");
|
|
145
|
+
|
|
146
|
+
// Assert
|
|
147
|
+
expect(removed.annotations.map((annotation) => annotation.id)).toEqual(["own"]);
|
|
148
|
+
expect(removed.history!.entries.at(-1)).toMatchObject({
|
|
149
|
+
type: "comment-removed",
|
|
150
|
+
annotationId: "anas",
|
|
151
|
+
});
|
|
152
|
+
expect(named.participants).toContainEqual({ id: "SHA256:ana", provider: "ssh", name: "Ana" });
|
|
153
|
+
anonymous.close();
|
|
154
|
+
agent.close();
|
|
155
|
+
});
|
|
156
|
+
|
|
83
157
|
test("malformed requests get structured errors and never wedge the daemon", async () => {
|
|
84
158
|
// missing required params
|
|
85
159
|
await expect(
|
|
@@ -123,7 +197,7 @@ describe("socket round-trip", () => {
|
|
|
123
197
|
expect(got.artifact.meta.agent).toBe("claude-code");
|
|
124
198
|
});
|
|
125
199
|
|
|
126
|
-
test("share-sync
|
|
200
|
+
test("share-sync primitives round-trip: setShareId persists, mergeShared unions notes and identities by id", async () => {
|
|
127
201
|
// Given a shared session with one local note
|
|
128
202
|
const session = await client.sessionCreate(WS, PLAN);
|
|
129
203
|
const anchor = { quote: "Body text", prefix: "", suffix: "." };
|
|
@@ -200,6 +274,159 @@ describe("socket round-trip", () => {
|
|
|
200
274
|
});
|
|
201
275
|
});
|
|
202
276
|
|
|
277
|
+
describe("ownership is proven, never declared", () => {
|
|
278
|
+
/** A wire frame as the daemon answers it: a result or a coded error. */
|
|
279
|
+
interface WireReply {
|
|
280
|
+
result?: unknown;
|
|
281
|
+
error?: { code: string };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** The request body a bare socket sends: the primitive's params as the wire carries them. */
|
|
285
|
+
type WireParams = Parameters<typeof JSON.stringify>[0];
|
|
286
|
+
|
|
287
|
+
interface RawConnection {
|
|
288
|
+
call: (method: string, params: WireParams) => Promise<WireReply>;
|
|
289
|
+
close: () => void;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** A bare socket that speaks the wire protocol without the client's handshake. */
|
|
293
|
+
async function rawConnection(): Promise<RawConnection> {
|
|
294
|
+
const pending = new Map<number, (frame: WireReply) => void>();
|
|
295
|
+
let buffered = "";
|
|
296
|
+
let nextId = 1;
|
|
297
|
+
const socket = await Bun.connect({
|
|
298
|
+
unix: join(home, "cueloop.sock"),
|
|
299
|
+
socket: {
|
|
300
|
+
data: (_socket, data) => {
|
|
301
|
+
buffered += data.toString();
|
|
302
|
+
const lines = buffered.split("\n");
|
|
303
|
+
|
|
304
|
+
buffered = lines.pop() ?? "";
|
|
305
|
+
for (const line of lines) {
|
|
306
|
+
const frame = JSON.parse(line);
|
|
307
|
+
|
|
308
|
+
if ("id" in frame) pending.get(frame.id)?.(frame);
|
|
309
|
+
}
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
call: (method, params) =>
|
|
316
|
+
new Promise((resolve) => {
|
|
317
|
+
const id = nextId++;
|
|
318
|
+
|
|
319
|
+
pending.set(id, resolve);
|
|
320
|
+
socket.write(JSON.stringify({ id, method, params }) + "\n");
|
|
321
|
+
}),
|
|
322
|
+
close: () => socket.end(),
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
test("a connection that never proves ownership is a collaborator: it reads and comments, nothing more", async () => {
|
|
327
|
+
// Arrange
|
|
328
|
+
const session = await client.sessionCreate(WS, PLAN);
|
|
329
|
+
const raw = await rawConnection();
|
|
330
|
+
|
|
331
|
+
// Act + Assert
|
|
332
|
+
expect((await raw.call("session.get", { id: session.id })).error).toBeUndefined();
|
|
333
|
+
expect(
|
|
334
|
+
(
|
|
335
|
+
await raw.call("session.annotate", {
|
|
336
|
+
id: session.id,
|
|
337
|
+
annotation: {
|
|
338
|
+
id: "a_raw",
|
|
339
|
+
kind: "comment",
|
|
340
|
+
anchor: { quote: "Plan", prefix: "", suffix: "" },
|
|
341
|
+
body: "from a bare socket",
|
|
342
|
+
},
|
|
343
|
+
})
|
|
344
|
+
).error,
|
|
345
|
+
).toBeUndefined();
|
|
346
|
+
expect(
|
|
347
|
+
(await raw.call("session.resolve", { id: session.id, verdictKind: "approve", summary: "" }))
|
|
348
|
+
.error?.code,
|
|
349
|
+
).toBe("forbidden");
|
|
350
|
+
expect((await raw.call("session.delete", { id: session.id })).error?.code).toBe("forbidden");
|
|
351
|
+
raw.close();
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test("claiming ownership without the token is refused; the token grants it", async () => {
|
|
355
|
+
// Arrange
|
|
356
|
+
const raw = await rawConnection();
|
|
357
|
+
|
|
358
|
+
// Act + Assert: a bare claim, then a wrong token, both refused
|
|
359
|
+
expect((await raw.call("daemon.hello", { role: "owner" })).error?.code).toBe("forbidden");
|
|
360
|
+
expect((await raw.call("daemon.hello", { role: "owner", token: "nope" })).error?.code).toBe(
|
|
361
|
+
"forbidden",
|
|
362
|
+
);
|
|
363
|
+
expect((await raw.call("daemon.shutdown", {})).error?.code).toBe("forbidden");
|
|
364
|
+
|
|
365
|
+
// Act: the token the daemon wrote into its home
|
|
366
|
+
const token = readFileSync(join(home, "owner.token"), "utf8").trim();
|
|
367
|
+
|
|
368
|
+
expect((await raw.call("daemon.hello", { role: "owner", token })).error).toBeUndefined();
|
|
369
|
+
|
|
370
|
+
// Assert: owner primitives open up
|
|
371
|
+
const created = await raw.call("session.create", { workspace: WS, artifact: PLAN });
|
|
372
|
+
|
|
373
|
+
expect(created.error).toBeUndefined();
|
|
374
|
+
raw.close();
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test("a refused handshake reaches the caller and leaves the live daemon's socket alone", async () => {
|
|
378
|
+
// Arrange: a token file that is not what the daemon minted
|
|
379
|
+
const tokenPath = join(home, "owner.token");
|
|
380
|
+
const minted = readFileSync(tokenPath, "utf8");
|
|
381
|
+
|
|
382
|
+
writeFileSync(tokenPath, "0".repeat(64));
|
|
383
|
+
|
|
384
|
+
// Act + Assert: the client hears the refusal; autostart does not replace the daemon
|
|
385
|
+
await expect(DaemonClient.connect({ home, autostart: true })).rejects.toHaveProperty(
|
|
386
|
+
"code",
|
|
387
|
+
"forbidden",
|
|
388
|
+
);
|
|
389
|
+
expect(existsSync(join(home, "cueloop.sock"))).toBe(true);
|
|
390
|
+
expect((await client.sessionList()).length).toBe(0);
|
|
391
|
+
|
|
392
|
+
// Arrange: a token file that is not a token at all
|
|
393
|
+
writeFileSync(tokenPath, "not-a-token");
|
|
394
|
+
|
|
395
|
+
// Act + Assert
|
|
396
|
+
await expect(DaemonClient.connect({ home })).rejects.toHaveProperty(
|
|
397
|
+
"code",
|
|
398
|
+
"invalid_owner_token",
|
|
399
|
+
);
|
|
400
|
+
writeFileSync(tokenPath, minted);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test("the token exists before the socket does", () => {
|
|
404
|
+
// Arrange: a second daemon in another home
|
|
405
|
+
const other = mkdtempSync(join(tmpdir(), "cueloop-token-"));
|
|
406
|
+
const daemon = new DaemonServer({ home: other, idleExitMs: 0 });
|
|
407
|
+
|
|
408
|
+
try {
|
|
409
|
+
// Act
|
|
410
|
+
daemon.start();
|
|
411
|
+
|
|
412
|
+
// Assert: the token is on disk the moment the socket is
|
|
413
|
+
expect(existsSync(join(other, "owner.token"))).toBe(true);
|
|
414
|
+
expect(existsSync(join(other, "cueloop.sock"))).toBe(true);
|
|
415
|
+
} finally {
|
|
416
|
+
daemon.stop();
|
|
417
|
+
rmSync(other, { recursive: true, force: true });
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
test("the daemon client proves ownership on connect and the token is private to the home", async () => {
|
|
422
|
+
// Assert: the client in beforeEach connected as the owner through the token
|
|
423
|
+
const session = await client.sessionCreate(WS, PLAN);
|
|
424
|
+
|
|
425
|
+
expect((await client.sessionResolve(session.id, "approve", "")).status).toBe("resolved");
|
|
426
|
+
expect(statSync(join(home, "owner.token")).mode & 0o777).toBe(0o600);
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
|
|
203
430
|
describe("large payloads survive socket backpressure", () => {
|
|
204
431
|
test("a session bigger than the kernel socket buffer round-trips", async () => {
|
|
205
432
|
// Regression: responses larger than one socket write used to truncate
|
package/src/server.ts
CHANGED
|
@@ -16,7 +16,7 @@ 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
21
|
import {
|
|
22
22
|
BackpressureWriter,
|
|
@@ -25,9 +25,12 @@ import {
|
|
|
25
25
|
type Request,
|
|
26
26
|
type Response,
|
|
27
27
|
} from "./protocol";
|
|
28
|
-
import { cueloopHome, lockPath, pidPath, socketPath } from "./paths";
|
|
28
|
+
import { cueloopHome, lockPath, ownerTokenPath, pidPath, socketPath } from "./paths";
|
|
29
|
+
import { randomBytes } from "node:crypto";
|
|
29
30
|
|
|
30
31
|
interface Connection {
|
|
32
|
+
/** The author a non-owner connection acts as, bound in the handshake. */
|
|
33
|
+
author?: string;
|
|
31
34
|
write(data: string): void;
|
|
32
35
|
subscribed: boolean;
|
|
33
36
|
/** Capability role for this connection; the owner until a daemon.hello caps it. */
|
|
@@ -57,6 +60,8 @@ export class DaemonServer {
|
|
|
57
60
|
private server: ReturnType<typeof Bun.listen> | null = null;
|
|
58
61
|
private idleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
59
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 = "";
|
|
60
65
|
private readonly idleExitMs: number;
|
|
61
66
|
private readonly onIdleExit: () => void;
|
|
62
67
|
|
|
@@ -145,6 +150,11 @@ export class DaemonServer {
|
|
|
145
150
|
// safe now: holding the lock means no live daemon owns this home, so any
|
|
146
151
|
// socket file left behind is stale
|
|
147
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);
|
|
148
158
|
this.server = Bun.listen<{
|
|
149
159
|
buffer: LineBuffer;
|
|
150
160
|
connection: Connection;
|
|
@@ -157,7 +167,7 @@ export class DaemonServer {
|
|
|
157
167
|
const connection: Connection = {
|
|
158
168
|
write: (data) => writer.write(data),
|
|
159
169
|
subscribed: false,
|
|
160
|
-
role:
|
|
170
|
+
role: DEFAULT_ROLE,
|
|
161
171
|
};
|
|
162
172
|
|
|
163
173
|
socket.data = { buffer: new LineBuffer(), connection, writer };
|
|
@@ -194,6 +204,7 @@ export class DaemonServer {
|
|
|
194
204
|
this.server = null;
|
|
195
205
|
rmSync(socketPath(this.home), { force: true });
|
|
196
206
|
rmSync(pidPath(this.home), { force: true });
|
|
207
|
+
rmSync(ownerTokenPath(this.home), { force: true });
|
|
197
208
|
if (this.idleTimer) clearTimeout(this.idleTimer);
|
|
198
209
|
this.releaseLock();
|
|
199
210
|
}
|
|
@@ -247,7 +258,16 @@ export class DaemonServer {
|
|
|
247
258
|
private readonly handlers: Record<MethodName, MethodHandler> = {
|
|
248
259
|
"daemon.ping": () => ({ pid: process.pid }),
|
|
249
260
|
"daemon.hello": (connection, request) => {
|
|
250
|
-
|
|
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;
|
|
251
271
|
|
|
252
272
|
return {};
|
|
253
273
|
},
|
|
@@ -283,16 +303,78 @@ export class DaemonServer {
|
|
|
283
303
|
|
|
284
304
|
return this.core.sessionAnnotate(params.id, params.annotation, params.authorName);
|
|
285
305
|
},
|
|
286
|
-
"session.removeAnnotation": (
|
|
306
|
+
"session.removeAnnotation": (connection, request) => {
|
|
287
307
|
const params = parseParams("session.removeAnnotation", request.params);
|
|
288
308
|
|
|
289
|
-
|
|
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);
|
|
290
332
|
},
|
|
291
333
|
"session.setWorkingCopy": (_connection, request) => {
|
|
292
334
|
const params = parseParams("session.setWorkingCopy", request.params);
|
|
293
335
|
|
|
294
336
|
return this.core.sessionSetWorkingCopy(params.id, params.workingCopy);
|
|
295
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
|
+
},
|
|
296
378
|
"session.setViewed": (_connection, request) => {
|
|
297
379
|
const params = parseParams("session.setViewed", request.params);
|
|
298
380
|
|
|
@@ -319,6 +401,7 @@ export class DaemonServer {
|
|
|
319
401
|
return this.core.sessionMergeShared(params.id, {
|
|
320
402
|
annotations: params.annotations,
|
|
321
403
|
participants: params.participants,
|
|
404
|
+
removals: params.removals,
|
|
322
405
|
});
|
|
323
406
|
},
|
|
324
407
|
"session.resolve": (_connection, request) => {
|
|
@@ -352,7 +435,7 @@ export class DaemonServer {
|
|
|
352
435
|
throw new DaemonError("unknown_method", `unknown method ${request.method}`);
|
|
353
436
|
}
|
|
354
437
|
// Capability gate: a capped role (a review-side agent) cannot escalate past
|
|
355
|
-
// read + annotate, whatever
|
|
438
|
+
// read + annotate, whatever primitive it sends.
|
|
356
439
|
if (!roleAllowsMethod(connection.role, request.method)) {
|
|
357
440
|
throw new DaemonError("forbidden", `role ${connection.role} cannot call ${request.method}`);
|
|
358
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
|
|
@@ -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
|
|
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
|
+
}
|