@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.
- package/package.json +34 -22
- package/src/api.test.ts +925 -65
- package/src/api.ts +1060 -88
- package/src/capabilities.test.ts +57 -0
- package/src/capabilities.ts +84 -0
- package/src/client.test.ts +24 -0
- package/src/client.ts +526 -70
- package/src/curate.ts +109 -0
- package/src/daemon-version.test.ts +93 -0
- package/src/diff-hot-reload.test.ts +272 -0
- package/src/diff-watcher.ts +274 -0
- package/src/gh.ts +37 -0
- package/src/harness-delivery.test.ts +193 -0
- package/src/harness-state-store.test.ts +65 -0
- package/src/harness-state-store.ts +182 -0
- package/src/herdr-pane.test.ts +240 -0
- package/src/herdr-pane.ts +167 -0
- package/src/herdr-split.test.ts +106 -0
- package/src/herdr-split.ts +92 -0
- package/src/herdr-tab-store.test.ts +96 -0
- package/src/herdr-tab-store.ts +66 -0
- package/src/index.ts +8 -9
- package/src/interruptible-wait.test.ts +127 -0
- package/src/interruptible-wait.ts +66 -0
- package/src/main.ts +4 -1
- package/src/paths.ts +44 -0
- package/src/pr-poller.test.ts +76 -0
- package/src/pr-poller.ts +74 -0
- package/src/project-files.test.ts +124 -0
- package/src/project-files.ts +56 -0
- package/src/protocol.ts +79 -7
- package/src/retention.test.ts +191 -0
- package/src/retention.ts +123 -0
- package/src/server.test.ts +430 -42
- package/src/server.ts +362 -84
- package/src/share-blob.test.ts +112 -0
- package/src/share-blob.ts +55 -0
- package/src/single-instance.test.ts +72 -11
- package/src/store.conformance.test.ts +41 -0
- package/src/store.jsonl.test.ts +159 -0
- package/src/store.ts +264 -34
- package/src/testing/store-conformance.ts +194 -0
- package/src/thread-review.test.ts +394 -0
- package/src/thread-review.ts +324 -0
- package/src/validate.test.ts +214 -35
- package/src/validate.ts +296 -25
- package/src/version.ts +6 -0
- package/src/working-tree.ts +149 -0
- package/src/review.test.ts +0 -167
- 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
|
|
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 {
|
|
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 {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
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
|
|
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
|
|
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(
|
|
44
|
-
this.home =
|
|
45
|
-
this.idleExitMs =
|
|
46
|
-
this.onIdleExit =
|
|
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((
|
|
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
|
-
|
|
122
|
-
|
|
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
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
184
|
+
void this.respondToRequestLine(socket.data.connection, line);
|
|
134
185
|
});
|
|
135
186
|
},
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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(
|
|
159
|
-
const frame = JSON.stringify(
|
|
160
|
-
|
|
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.
|
|
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
|
|
179
|
-
let
|
|
238
|
+
private async respondToRequestLine(connection: Connection, line: string): Promise<void> {
|
|
239
|
+
let request: Request;
|
|
240
|
+
|
|
180
241
|
try {
|
|
181
|
-
|
|
242
|
+
request = parseRequestFrame(line);
|
|
182
243
|
} catch {
|
|
183
|
-
|
|
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(
|
|
188
|
-
|
|
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
|
-
|
|
258
|
+
|
|
259
|
+
connection.write(JSON.stringify({ id: request.id, error: { code, message } }) + "\n");
|
|
193
260
|
}
|
|
194
261
|
}
|
|
195
262
|
|
|
196
|
-
private
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
247
|
-
|
|
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
|
+
});
|