@cueloop/daemon 0.1.0-alpha.8 → 0.1.0-alpha.80

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.
Files changed (50) hide show
  1. package/package.json +34 -22
  2. package/src/api.test.ts +925 -65
  3. package/src/api.ts +1060 -88
  4. package/src/capabilities.test.ts +57 -0
  5. package/src/capabilities.ts +84 -0
  6. package/src/client.test.ts +24 -0
  7. package/src/client.ts +526 -70
  8. package/src/curate.ts +109 -0
  9. package/src/daemon-version.test.ts +93 -0
  10. package/src/diff-hot-reload.test.ts +272 -0
  11. package/src/diff-watcher.ts +274 -0
  12. package/src/gh.ts +37 -0
  13. package/src/harness-delivery.test.ts +193 -0
  14. package/src/harness-state-store.test.ts +65 -0
  15. package/src/harness-state-store.ts +182 -0
  16. package/src/herdr-pane.test.ts +240 -0
  17. package/src/herdr-pane.ts +167 -0
  18. package/src/herdr-split.test.ts +106 -0
  19. package/src/herdr-split.ts +92 -0
  20. package/src/herdr-tab-store.test.ts +96 -0
  21. package/src/herdr-tab-store.ts +66 -0
  22. package/src/index.ts +8 -9
  23. package/src/interruptible-wait.test.ts +127 -0
  24. package/src/interruptible-wait.ts +66 -0
  25. package/src/main.ts +4 -1
  26. package/src/paths.ts +44 -0
  27. package/src/pr-poller.test.ts +76 -0
  28. package/src/pr-poller.ts +74 -0
  29. package/src/project-files.test.ts +124 -0
  30. package/src/project-files.ts +56 -0
  31. package/src/protocol.ts +79 -7
  32. package/src/retention.test.ts +191 -0
  33. package/src/retention.ts +123 -0
  34. package/src/server.test.ts +430 -42
  35. package/src/server.ts +362 -84
  36. package/src/share-blob.test.ts +112 -0
  37. package/src/share-blob.ts +55 -0
  38. package/src/single-instance.test.ts +72 -11
  39. package/src/store.conformance.test.ts +41 -0
  40. package/src/store.jsonl.test.ts +159 -0
  41. package/src/store.ts +264 -34
  42. package/src/testing/store-conformance.ts +194 -0
  43. package/src/thread-review.test.ts +394 -0
  44. package/src/thread-review.ts +324 -0
  45. package/src/validate.test.ts +214 -35
  46. package/src/validate.ts +296 -25
  47. package/src/version.ts +6 -0
  48. package/src/working-tree.ts +149 -0
  49. package/src/review.test.ts +0 -167
  50. package/src/review.ts +0 -174
package/src/server.ts CHANGED
@@ -1,26 +1,52 @@
1
1
  /**
2
2
  * The socket server: binds DaemonCore's methods to the unix socket, owns the
3
- * pidfile, stale-socket cleanup, and the idle-exit timer (#14). File
3
+ * pidfile, stale-socket cleanup, and the idle-exit timer. File
4
4
  * permissions are the local auth: the socket and state dir are 0700/0600.
5
5
  */
6
6
 
7
- import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
7
+ import {
8
+ chmodSync,
9
+ closeSync,
10
+ existsSync,
11
+ mkdirSync,
12
+ openSync,
13
+ readFileSync,
14
+ rmSync,
15
+ writeFileSync,
16
+ } from "node:fs";
8
17
  import { DaemonCore, type DaemonEvent } from "./api";
9
18
  import { DaemonError } from "./errors";
10
- import { isKnownMethod, parseParams } from "./validate";
11
- import { LineBuffer, type Request } from "./protocol";
12
- import { cueloopHome, lockPath, pidPath, socketPath } from "./paths";
19
+ import { DEFAULT_ROLE, roleAllowsMethod, type DaemonRole } from "./capabilities";
20
+ import { isKnownMethod, parseParams, type MethodName } from "./validate";
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 { DAEMON_VERSION } from "./version";
30
+ import { randomBytes } from "node:crypto";
13
31
 
14
- interface Conn {
32
+ interface Connection {
33
+ /** The author a non-owner connection acts as, bound in the handshake. */
34
+ author?: string;
15
35
  write(data: string): void;
16
36
  subscribed: boolean;
37
+ /** Capability role for this connection; the owner until a daemon.hello caps it. */
38
+ role: DaemonRole;
17
39
  }
18
40
 
41
+ type MethodHandler = (connection: Connection, request: Request) => Response["result"];
42
+
19
43
  export interface DaemonOptions {
20
44
  home?: string;
21
45
  /** Idle-exit delay; 0 disables (tests, foreground runs). */
22
46
  idleExitMs?: number;
23
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;
24
50
  }
25
51
 
26
52
  /**
@@ -33,20 +59,24 @@ const HELD_HOMES = new Set<string>();
33
59
  export class DaemonServer {
34
60
  readonly core: DaemonCore;
35
61
  readonly home: string;
36
- private conns = new Set<Conn>();
62
+ private connections = new Set<Connection>();
37
63
  private server: ReturnType<typeof Bun.listen> | null = null;
38
64
  private idleTimer: ReturnType<typeof setTimeout> | null = null;
39
65
  private lockFd: number | null = null;
66
+ /** The secret that proves ownership; minted per daemon run, readable by the home's user only. */
67
+ private ownerToken = "";
40
68
  private readonly idleExitMs: number;
41
69
  private readonly onIdleExit: () => void;
70
+ private readonly version: string;
42
71
 
43
- constructor(opts: DaemonOptions = {}) {
44
- this.home = opts.home ?? cueloopHome();
45
- this.idleExitMs = opts.idleExitMs ?? 15 * 60 * 1000;
46
- this.onIdleExit = opts.onIdleExit ?? (() => process.exit(0));
72
+ constructor(options: DaemonOptions = {}) {
73
+ this.home = options.home ?? cueloopHome();
74
+ this.idleExitMs = options.idleExitMs ?? 15 * 60 * 1000;
75
+ this.onIdleExit = options.onIdleExit ?? (() => process.exit(0));
76
+ this.version = options.version ?? DAEMON_VERSION;
47
77
  mkdirSync(this.home, { recursive: true, mode: 0o700 });
48
78
  this.core = new DaemonCore(this.home);
49
- this.core.onEvent((e) => this.broadcast(e));
79
+ this.core.onEvent((event) => this.broadcast(event));
50
80
  }
51
81
 
52
82
  /**
@@ -59,16 +89,20 @@ export class DaemonServer {
59
89
  private acquireLock(): boolean {
60
90
  if (HELD_HOMES.has(this.home)) return false; // another instance here owns it
61
91
  const path = lockPath(this.home);
92
+
62
93
  for (let attempt = 0; attempt < 3; attempt++) {
63
94
  try {
64
95
  // "wx" fails when the file exists - the atomic part of the handshake
65
96
  const fd = openSync(path, "wx");
97
+
66
98
  writeFileSync(fd, String(process.pid));
67
99
  this.lockFd = fd;
68
100
  HELD_HOMES.add(this.home);
101
+
69
102
  return true;
70
103
  } catch {
71
104
  let ownerPid = 0;
105
+
72
106
  try {
73
107
  ownerPid = Number(readFileSync(path, "utf8").trim());
74
108
  } catch {
@@ -78,6 +112,7 @@ export class DaemonServer {
78
112
  if (ownerPid && ownerPid !== process.pid) {
79
113
  try {
80
114
  process.kill(ownerPid, 0); // throws when the pid is gone
115
+
81
116
  return false; // a live daemon owns this home
82
117
  } catch {
83
118
  // stale lock from a crashed daemon
@@ -92,6 +127,7 @@ export class DaemonServer {
92
127
  }
93
128
  }
94
129
  }
130
+
95
131
  return false;
96
132
  }
97
133
 
@@ -115,27 +151,47 @@ export class DaemonServer {
115
151
  start(): string | null {
116
152
  if (!this.acquireLock()) return null;
117
153
  const path = socketPath(this.home);
154
+
118
155
  // safe now: holding the lock means no live daemon owns this home, so any
119
156
  // socket file left behind is stale
120
157
  if (existsSync(path)) rmSync(path, { force: true });
121
- const self = this;
122
- this.server = Bun.listen<{ buffer: LineBuffer; conn: Conn }>({
158
+ // the token exists before the socket does: the first client to connect
159
+ // must be able to prove ownership
160
+ this.ownerToken = randomBytes(32).toString("hex");
161
+ writeFileSync(ownerTokenPath(this.home), this.ownerToken, { mode: 0o600 });
162
+ chmodSync(ownerTokenPath(this.home), 0o600);
163
+ this.server = Bun.listen<{
164
+ buffer: LineBuffer;
165
+ connection: Connection;
166
+ writer: BackpressureWriter;
167
+ }>({
123
168
  unix: path,
124
169
  socket: {
125
- open(socket) {
126
- const conn: Conn = { write: (d) => socket.write(d), subscribed: false };
127
- socket.data = { buffer: new LineBuffer(), conn };
128
- self.conns.add(conn);
129
- self.scheduleIdleCheck();
170
+ open: (socket) => {
171
+ const writer = new BackpressureWriter(socket);
172
+ const connection: Connection = {
173
+ write: (data) => writer.write(data),
174
+ subscribed: false,
175
+ role: DEFAULT_ROLE,
176
+ };
177
+
178
+ socket.data = { buffer: new LineBuffer(), connection, writer };
179
+ this.connections.add(connection);
180
+ this.scheduleIdleCheck();
130
181
  },
131
- data(socket, data) {
182
+ data: (socket, data) => {
132
183
  socket.data.buffer.push(data.toString(), (line) => {
133
- void self.handleLine(socket.data.conn, line);
184
+ void this.respondToRequestLine(socket.data.connection, line);
134
185
  });
135
186
  },
136
- close(socket) {
137
- self.conns.delete(socket.data.conn);
138
- self.scheduleIdleCheck();
187
+ drain(socket) {
188
+ // drain can fire before open has attached data on some platforms;
189
+ // a throw here would take the whole daemon down with the socket
190
+ socket.data?.writer.drain();
191
+ },
192
+ close: (socket) => {
193
+ this.connections.delete(socket.data.connection);
194
+ this.scheduleIdleCheck();
139
195
  },
140
196
  error() {},
141
197
  },
@@ -143,21 +199,25 @@ export class DaemonServer {
143
199
  chmodSync(path, 0o600);
144
200
  writeFileSync(pidPath(this.home), String(process.pid));
145
201
  this.scheduleIdleCheck();
202
+
146
203
  return path;
147
204
  }
148
205
 
149
206
  stop(): void {
207
+ this.core.dispose();
150
208
  this.server?.stop(true);
151
209
  this.server = null;
152
210
  rmSync(socketPath(this.home), { force: true });
153
211
  rmSync(pidPath(this.home), { force: true });
212
+ rmSync(ownerTokenPath(this.home), { force: true });
154
213
  if (this.idleTimer) clearTimeout(this.idleTimer);
155
214
  this.releaseLock();
156
215
  }
157
216
 
158
- private broadcast(e: DaemonEvent): void {
159
- const frame = JSON.stringify(e) + "\n";
160
- for (const c of this.conns) if (c.subscribed) c.write(frame);
217
+ private broadcast(event: DaemonEvent): void {
218
+ const frame = JSON.stringify(event) + "\n";
219
+
220
+ for (const connection of this.connections) if (connection.subscribed) connection.write(frame);
161
221
  this.scheduleIdleCheck();
162
222
  }
163
223
 
@@ -166,7 +226,7 @@ export class DaemonServer {
166
226
  if (this.idleExitMs <= 0) return;
167
227
  if (this.idleTimer) clearTimeout(this.idleTimer);
168
228
  this.idleTimer = setTimeout(() => {
169
- if (this.conns.size === 0 && !this.core.hasPendingSessions()) {
229
+ if (this.connections.size === 0 && !this.core.hasPendingSessions()) {
170
230
  this.stop();
171
231
  this.onIdleExit();
172
232
  } else {
@@ -175,76 +235,294 @@ export class DaemonServer {
175
235
  }, this.idleExitMs);
176
236
  }
177
237
 
178
- private async handleLine(conn: Conn, line: string): Promise<void> {
179
- let req: Request;
238
+ private async respondToRequestLine(connection: Connection, line: string): Promise<void> {
239
+ let request: Request;
240
+
180
241
  try {
181
- req = JSON.parse(line) as Request;
242
+ request = parseRequestFrame(line);
182
243
  } catch {
183
- conn.write(JSON.stringify({ id: -1, error: { code: "bad_json", message: "unparseable request" } }) + "\n");
244
+ connection.write(
245
+ JSON.stringify({ id: -1, error: { code: "bad_json", message: "unparseable request" } }) +
246
+ "\n",
247
+ );
248
+
184
249
  return;
185
250
  }
186
251
  try {
187
- const result = await this.dispatch(conn, req);
188
- conn.write(JSON.stringify({ id: req.id, result }) + "\n");
252
+ const result = await this.dispatch(connection, request);
253
+
254
+ connection.write(JSON.stringify({ id: request.id, result }) + "\n");
189
255
  } catch (err) {
190
256
  const code = err instanceof DaemonError ? err.code : "internal";
191
257
  const message = err instanceof Error ? err.message : String(err);
192
- conn.write(JSON.stringify({ id: req.id, error: { code, message } }) + "\n");
258
+
259
+ connection.write(JSON.stringify({ id: request.id, error: { code, message } }) + "\n");
193
260
  }
194
261
  }
195
262
 
196
- private async dispatch(conn: Conn, req: Request): Promise<unknown> {
197
- // The wire is untrusted JSON: validate before DaemonCore sees anything.
198
- if (typeof req.method !== "string" || !isKnownMethod(req.method)) {
199
- throw new DaemonError("unknown_method", `unknown method ${String(req.method)}`);
200
- }
201
- const core = this.core;
202
- switch (req.method) {
203
- case "daemon.ping":
204
- return { pid: process.pid };
205
- case "daemon.shutdown":
206
- setTimeout(() => {
207
- this.stop();
208
- this.onIdleExit();
209
- }, 10);
210
- return {};
211
- case "events.subscribe":
212
- conn.subscribed = true;
213
- return {};
214
- case "session.create": {
215
- const p = parseParams("session.create", req.params);
216
- return core.sessionCreate({ workspace: p.workspace, artifact: p.artifact });
217
- }
218
- case "session.get":
219
- return core.sessionGet(parseParams("session.get", req.params).id);
220
- case "session.list":
221
- return core.sessionList(parseParams("session.list", req.params).filter);
222
- case "session.wait": {
223
- const p = parseParams("session.wait", req.params);
224
- return core.sessionWait(p.id, p.timeoutMs);
225
- }
226
- case "session.annotate": {
227
- const p = parseParams("session.annotate", req.params);
228
- return core.sessionAnnotate(p.id, p.annotation);
229
- }
230
- case "session.removeAnnotation": {
231
- const p = parseParams("session.removeAnnotation", req.params);
232
- return core.sessionRemoveAnnotation(p.id, p.annotationId);
233
- }
234
- case "session.setWorkingCopy": {
235
- const p = parseParams("session.setWorkingCopy", req.params);
236
- return core.sessionSetWorkingCopy(p.id, p.workingCopy);
263
+ private readonly handlers: Record<MethodName, MethodHandler> = {
264
+ "daemon.ping": () => ({ pid: process.pid, version: this.version }),
265
+ "daemon.hello": (connection, request) => {
266
+ const params = parseParams("daemon.hello", request.params);
267
+
268
+ // ownership is proven, never declared: the token lives in the home
269
+ // directory the daemon serves, readable by its user alone
270
+ if (params.role === "owner" && params.token !== this.ownerToken) {
271
+ throw new DaemonError("forbidden", "owner token required");
237
272
  }
238
- case "session.resolve": {
239
- const p = parseParams("session.resolve", req.params);
240
- return core.sessionResolve(p.id, p.verdictKind, p.summary);
273
+ connection.role = params.role;
274
+ // identity is bound once, here; a non-owner never names it per call
275
+ if (params.role !== "owner" && params.author !== undefined) connection.author = params.author;
276
+
277
+ return {};
278
+ },
279
+ "daemon.shutdown": () => {
280
+ setTimeout(() => {
281
+ this.stop();
282
+ this.onIdleExit();
283
+ }, 10);
284
+
285
+ return {};
286
+ },
287
+ "events.subscribe": (connection) => {
288
+ connection.subscribed = true;
289
+
290
+ return {};
291
+ },
292
+ "session.create": (_connection, request) => {
293
+ const params = parseParams("session.create", request.params);
294
+
295
+ return this.core.sessionCreate({ workspace: params.workspace, artifact: params.artifact });
296
+ },
297
+ "session.get": (_connection, request) =>
298
+ this.core.sessionGet(parseParams("session.get", request.params).id),
299
+ "session.list": (_connection, request) =>
300
+ this.core.sessionList(parseParams("session.list", request.params).filter),
301
+ "session.wait": (_connection, request) => {
302
+ const params = parseParams("session.wait", request.params);
303
+
304
+ return this.core.sessionWait(params.id, params.timeoutMs);
305
+ },
306
+ "session.annotate": (_connection, request) => {
307
+ const params = parseParams("session.annotate", request.params);
308
+
309
+ return this.core.sessionAnnotate(params.id, params.annotation, params.authorName);
310
+ },
311
+ "session.comment": (_connection, request) => {
312
+ const params = parseParams("session.comment", request.params);
313
+
314
+ return this.core.sessionAnnotate(params.id, params.annotation, params.authorName);
315
+ },
316
+ "session.removeAnnotation": (connection, request) => {
317
+ const params = parseParams("session.removeAnnotation", request.params);
318
+
319
+ // the owner removes any comment; a non-owner only the ones of the author it is bound to
320
+ if (connection.role !== "owner" && connection.author === undefined) {
321
+ throw new DaemonError(
322
+ "forbidden",
323
+ "a non-owner connection removes comments as its bound author",
324
+ );
241
325
  }
242
- case "session.submitRevision": {
243
- const p = parseParams("session.submitRevision", req.params);
244
- return core.sessionSubmitRevision(p.id, p.content);
326
+
327
+ return this.core.sessionRemoveAnnotation(
328
+ params.id,
329
+ params.annotationId,
330
+ connection.role === "owner" ? undefined : connection.author,
331
+ );
332
+ },
333
+ "session.setParticipantName": (connection, request) => {
334
+ const params = parseParams("session.setParticipantName", request.params);
335
+
336
+ // the owner names anyone; a non-owner names only the author it is bound to
337
+ if (connection.role !== "owner" && params.author !== connection.author) {
338
+ throw new DaemonError("forbidden", "a non-owner connection names only its bound author");
245
339
  }
246
- default:
247
- throw new DaemonError("unknown_method", `unknown method ${req.method}`);
340
+
341
+ return this.core.sessionSetParticipantName(params.id, params.author, params.name);
342
+ },
343
+ "session.setWorkingCopy": (_connection, request) => {
344
+ const params = parseParams("session.setWorkingCopy", request.params);
345
+
346
+ return this.core.sessionSetWorkingCopy(params.id, params.workingCopy);
347
+ },
348
+ "session.cutBlock": (_connection, request) => {
349
+ const params = parseParams("session.cutBlock", request.params);
350
+
351
+ return this.core.sessionCutBlock(params.id, params.blockIndex);
352
+ },
353
+ "session.navigate": (_connection, request) => {
354
+ const params = parseParams("session.navigate", request.params);
355
+
356
+ return this.core.sessionNavigate(params.id, params.entryId, params.summary, params.branch);
357
+ },
358
+ "session.branch": (_connection, request) => {
359
+ const params = parseParams("session.branch", request.params);
360
+
361
+ return this.core.sessionBranch(params.id, params.name);
362
+ },
363
+ "session.switch": (_connection, request) => {
364
+ const params = parseParams("session.switch", request.params);
365
+
366
+ return this.core.sessionSwitch(params.id, params.branch);
367
+ },
368
+ "session.label": (_connection, request) => {
369
+ const params = parseParams("session.label", request.params);
370
+
371
+ return this.core.sessionLabel(params.id, params.label);
372
+ },
373
+ "session.fork": (_connection, request) => {
374
+ const params = parseParams("session.fork", request.params);
375
+
376
+ return this.core.sessionFork(params.id);
377
+ },
378
+ "session.restoreBlock": (_connection, request) => {
379
+ const params = parseParams("session.restoreBlock", request.params);
380
+
381
+ return this.core.sessionRestoreBlock(params.id, params.baseBlockIndex, params.line);
382
+ },
383
+ "session.curate": (_connection, request) => {
384
+ const params = parseParams("session.curate", request.params);
385
+
386
+ return this.core.sessionCurate(params.id, params.rejections);
387
+ },
388
+ "session.setAccess": (_connection, request) => {
389
+ const params = parseParams("session.setAccess", request.params);
390
+
391
+ return this.core.sessionSetAccess(params.id, params.githubLogins);
392
+ },
393
+ "session.setViewed": (_connection, request) => {
394
+ const params = parseParams("session.setViewed", request.params);
395
+
396
+ return this.core.sessionSetViewed(params.id, params.viewedPaths);
397
+ },
398
+ "session.setTitle": (_connection, request) => {
399
+ const params = parseParams("session.setTitle", request.params);
400
+
401
+ return this.core.sessionSetTitle(params.id, params.title);
402
+ },
403
+ "session.projectFiles": (_connection, request) =>
404
+ this.core.projectFiles(parseParams("session.projectFiles", request.params).id),
405
+ "session.fileContents": (_connection, request) => {
406
+ const params = parseParams("session.fileContents", request.params);
407
+
408
+ return this.core.fileContents(params.id, params.path);
409
+ },
410
+ "repo.files": (_connection, request) =>
411
+ this.core.repoFiles(parseParams("repo.files", request.params).cwd),
412
+ "repo.fileContents": (_connection, request) => {
413
+ const params = parseParams("repo.fileContents", request.params);
414
+
415
+ return this.core.repoFileContents(params.cwd, params.path);
416
+ },
417
+ "repo.changes": (_connection, request) =>
418
+ this.core.repoChanges(parseParams("repo.changes", request.params).cwd),
419
+ "repo.diff": (_connection, request) =>
420
+ this.core.repoDiff(parseParams("repo.diff", request.params).cwd),
421
+ "session.workbench": (_connection, request) =>
422
+ this.core.workbenchSession(parseParams("session.workbench", request.params).cwd),
423
+ "session.refreshDiff": (_connection, request) => {
424
+ const params = parseParams("session.refreshDiff", request.params);
425
+
426
+ return this.core.sessionRefreshDiff(params.id);
427
+ },
428
+ "session.setShareId": (_connection, request) => {
429
+ const params = parseParams("session.setShareId", request.params);
430
+
431
+ return this.core.sessionSetShareId(params.id, params.shareId);
432
+ },
433
+ "session.setShares": (_connection, request) => {
434
+ const params = parseParams("session.setShares", request.params);
435
+
436
+ return this.core.sessionSetShares(params.id, params.shares);
437
+ },
438
+ "session.delete": (_connection, request) => {
439
+ this.core.sessionDelete(parseParams("session.delete", request.params).id);
440
+
441
+ return {};
442
+ },
443
+ "session.mergeShared": (_connection, request) => {
444
+ const params = parseParams("session.mergeShared", request.params);
445
+
446
+ return this.core.sessionMergeShared(params.id, {
447
+ annotations: params.annotations,
448
+ participants: params.participants,
449
+ removals: params.removals,
450
+ });
451
+ },
452
+ "session.sendMessage": (_connection, request) => {
453
+ const params = parseParams("session.sendMessage", request.params);
454
+
455
+ return this.core.sessionSendMessage(
456
+ params.id,
457
+ params.outcome,
458
+ params.summary,
459
+ params.actionBodies,
460
+ );
461
+ },
462
+ "harness.bind": (_connection, request) => {
463
+ const params = parseParams("harness.bind", request.params);
464
+
465
+ return this.core.harnessBind(params);
466
+ },
467
+ "harness.getBinding": (_connection, request) => {
468
+ const params = parseParams("harness.getBinding", request.params);
469
+
470
+ return this.core.harnessGetBinding(params.bindingId);
471
+ },
472
+ "harness.consumeApprovedRetry": (_connection, request) => {
473
+ const params = parseParams("harness.consumeApprovedRetry", request.params);
474
+
475
+ return this.core.harnessConsumeApprovedRetry(
476
+ params.bindingId,
477
+ params.messageId,
478
+ params.content,
479
+ );
480
+ },
481
+ "delivery.pending": (_connection, request) => {
482
+ const params = parseParams("delivery.pending", request.params);
483
+
484
+ return this.core.deliveryPending(params.bindingId);
485
+ },
486
+ "delivery.acknowledge": (_connection, request) => {
487
+ const params = parseParams("delivery.acknowledge", request.params);
488
+
489
+ return this.core.deliveryAcknowledge(params.deliveryId);
490
+ },
491
+ "session.submitRevision": (_connection, request) => {
492
+ const params = parseParams("session.submitRevision", request.params);
493
+
494
+ return this.core.sessionSubmitRevision(
495
+ params.id,
496
+ params.content,
497
+ params.addressedAnnotationIds,
498
+ params.files,
499
+ );
500
+ },
501
+ "herdr.getTab": (_connection, request) =>
502
+ this.core.herdrGetTab(parseParams("herdr.getTab", request.params).id),
503
+ "herdr.setTab": (_connection, request) => {
504
+ const params = parseParams("herdr.setTab", request.params);
505
+
506
+ this.core.herdrSetTab(params.id, { tabId: params.tabId, paneId: params.paneId });
507
+
508
+ return {};
509
+ },
510
+ };
511
+
512
+ private async dispatch(connection: Connection, request: Request): Promise<Response["result"]> {
513
+ // The wire is untrusted JSON: validate before DaemonCore sees anything.
514
+ if (!isKnownMethod(request.method)) {
515
+ throw new DaemonError("unknown_method", `unknown method ${request.method}`);
516
+ }
517
+ // Capability gate: a capped role (a review-side agent) cannot escalate past
518
+ // read + annotate, whatever primitive it sends.
519
+ if (!roleAllowsMethod(connection.role, request.method)) {
520
+ throw new DaemonError("forbidden", `role ${connection.role} cannot call ${request.method}`);
248
521
  }
522
+ if (!Object.hasOwn(this.handlers, request.method)) {
523
+ throw new DaemonError("unknown_method", `unknown method ${request.method}`);
524
+ }
525
+
526
+ return this.handlers[request.method](connection, request);
249
527
  }
250
528
  }
@@ -0,0 +1,112 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { gzipSync } from "node:zlib";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { viewFollowing, type Artifact, type WorkspaceKey } from "@cueloop/schema";
7
+ import { DaemonCore } from "./api";
8
+ import { MAX_BLOB_BYTES, packSessionBlob, unpackSessionBlob } from "./share-blob";
9
+
10
+ const WS: WorkspaceKey = { repoRoot: "/repo", branch: "main" };
11
+ const PLAN: Artifact = { type: "plan", content: "# Plan\n\nDo the thing.\n", meta: {} };
12
+
13
+ let home: string;
14
+ let core: DaemonCore;
15
+
16
+ beforeEach(() => {
17
+ home = mkdtempSync(join(tmpdir(), "cueloop-blob-"));
18
+ core = new DaemonCore(home);
19
+ });
20
+ afterEach(() => rmSync(home, { recursive: true, force: true }));
21
+
22
+ describe("share blob roundtrip", () => {
23
+ test("pack then unpack restores the session with its annotations", () => {
24
+ // Arrange
25
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
26
+ const annotated = core.sessionAnnotate(session.id, {
27
+ id: "a_1",
28
+ kind: "comment",
29
+ anchor: { quote: "the thing", prefix: "", suffix: "" },
30
+ body: "why this?",
31
+ });
32
+
33
+ // Act
34
+ const restored = unpackSessionBlob(packSessionBlob(annotated));
35
+
36
+ // Assert
37
+ expect(restored).toEqual(annotated);
38
+ });
39
+ });
40
+
41
+ describe("the payload cap against a long review", () => {
42
+ test("a plan the size of the examples with a long discussion and its history stays far under the cap", () => {
43
+ // Arrange: the largest example plan, 200 rounds of comment + reply, 20 reviewer edits, 10 removals
44
+ const paragraph =
45
+ "Review sessions currently live only in daemon memory. If the daemon crashes mid-review, every pending annotation is lost. This plan makes sessions durable without changing the daemon's socket protocol.\n\n";
46
+ const content = `# Implementation Plan: Session Persistence\n\n## Context\n\n${paragraph.repeat(8)}`;
47
+ const session = core.sessionCreate({
48
+ workspace: WS,
49
+ artifact: { type: "plan", content, meta: {} },
50
+ });
51
+
52
+ for (let round = 0; round < 200; round++) {
53
+ core.sessionAnnotate(session.id, {
54
+ id: `a_${round}`,
55
+ kind: "comment",
56
+ anchor: { quote: "daemon memory", prefix: "live only in ", suffix: ". If the" },
57
+ body: `Round ${round}: what happens when the store directory is read-only at startup?`,
58
+ author: "SHA256:collaborator-fingerprint-0123456789abcdef",
59
+ });
60
+ core.sessionAnnotate(session.id, {
61
+ id: `r_${round}`,
62
+ kind: "comment",
63
+ anchor: { quote: "daemon memory", prefix: "live only in ", suffix: ". If the" },
64
+ body: "Fail fast with a readable error; the review never opens on a store that cannot write.",
65
+ replyTo: `a_${round}`,
66
+ });
67
+ if (round % 10 === 0)
68
+ core.sessionSetWorkingCopy(session.id, `${content}\n\nEdit ${round}.\n`);
69
+ if (round % 20 === 0) core.sessionRemoveAnnotation(session.id, `r_${round}`);
70
+ }
71
+ const shared = viewFollowing(core.sessionGet(session.id));
72
+
73
+ // Act
74
+ const decompressed = Buffer.byteLength(JSON.stringify(shared), "utf8");
75
+ const packed = packSessionBlob(shared).byteLength;
76
+
77
+ // Assert: a quarter of the cap decompressed, and the packed blob a small
78
+ // fraction of it; the round-trip holds
79
+ expect(shared.history!.entries.length).toBeGreaterThan(400);
80
+ expect(decompressed).toBeLessThan(MAX_BLOB_BYTES / 4);
81
+ expect(packed).toBeLessThan(decompressed);
82
+ expect(unpackSessionBlob(packSessionBlob(shared)).history!.entries.length).toBe(
83
+ shared.history!.entries.length,
84
+ );
85
+ console.info(
86
+ `share payload: ${shared.history!.entries.length} entries, ${decompressed} bytes decompressed, ${packed} bytes packed, cap ${MAX_BLOB_BYTES}`,
87
+ );
88
+ });
89
+ });
90
+
91
+ describe("unpack rejects bad input", () => {
92
+ test("caps the decompressed size, so a compression bomb throws", () => {
93
+ // Arrange: tiny gzip, huge inflation - the decompression-bomb shape
94
+ const bomb = gzipSync(Buffer.alloc(MAX_BLOB_BYTES + 1, 0x61));
95
+
96
+ // Act / Assert
97
+ expect(() => unpackSessionBlob(bomb)).toThrow(/exceeds|maxOutputLength/i);
98
+ });
99
+
100
+ test("throws on bytes that are not gzip", () => {
101
+ // Act / Assert
102
+ expect(() => unpackSessionBlob(Buffer.from("not gzip at all"))).toThrow(/gzip/i);
103
+ });
104
+
105
+ test("throws on a shape that is not a session", () => {
106
+ // Arrange
107
+ const notASession = gzipSync(Buffer.from(JSON.stringify({ hello: "world" })));
108
+
109
+ // Act / Assert
110
+ expect(() => unpackSessionBlob(notASession)).toThrow(/not a valid session/i);
111
+ });
112
+ });