@cueloop/daemon 0.1.0-alpha.3 → 0.1.0-alpha.30
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 +13 -5
- package/src/api.test.ts +223 -44
- package/src/api.ts +128 -50
- package/src/client.ts +76 -29
- package/src/herdr-pane.test.ts +146 -0
- package/src/herdr-pane.ts +95 -0
- package/src/index.ts +8 -8
- package/src/protocol.ts +44 -7
- package/src/review.test.ts +243 -0
- package/src/review.ts +208 -0
- package/src/server.test.ts +135 -20
- package/src/server.ts +70 -48
- package/src/share-blob.test.ts +62 -0
- package/src/share-blob.ts +48 -0
- package/src/single-instance.test.ts +28 -11
- package/src/store.ts +5 -5
- package/src/validate.test.ts +152 -12
- package/src/validate.ts +93 -32
package/src/server.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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
|
|
|
@@ -8,10 +8,10 @@ import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, rm
|
|
|
8
8
|
import { DaemonCore, type DaemonEvent } from "./api";
|
|
9
9
|
import { DaemonError } from "./errors";
|
|
10
10
|
import { isKnownMethod, parseParams } from "./validate";
|
|
11
|
-
import { LineBuffer, type Request } from "./protocol";
|
|
11
|
+
import { BackpressureWriter, LineBuffer, type Request } from "./protocol";
|
|
12
12
|
import { cueloopHome, lockPath, pidPath, socketPath } from "./paths";
|
|
13
13
|
|
|
14
|
-
interface
|
|
14
|
+
interface Connection {
|
|
15
15
|
write(data: string): void;
|
|
16
16
|
subscribed: boolean;
|
|
17
17
|
}
|
|
@@ -33,20 +33,20 @@ const HELD_HOMES = new Set<string>();
|
|
|
33
33
|
export class DaemonServer {
|
|
34
34
|
readonly core: DaemonCore;
|
|
35
35
|
readonly home: string;
|
|
36
|
-
private
|
|
36
|
+
private connections = new Set<Connection>();
|
|
37
37
|
private server: ReturnType<typeof Bun.listen> | null = null;
|
|
38
38
|
private idleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
39
39
|
private lockFd: number | null = null;
|
|
40
40
|
private readonly idleExitMs: number;
|
|
41
41
|
private readonly onIdleExit: () => void;
|
|
42
42
|
|
|
43
|
-
constructor(
|
|
44
|
-
this.home =
|
|
45
|
-
this.idleExitMs =
|
|
46
|
-
this.onIdleExit =
|
|
43
|
+
constructor(options: DaemonOptions = {}) {
|
|
44
|
+
this.home = options.home ?? cueloopHome();
|
|
45
|
+
this.idleExitMs = options.idleExitMs ?? 15 * 60 * 1000;
|
|
46
|
+
this.onIdleExit = options.onIdleExit ?? (() => process.exit(0));
|
|
47
47
|
mkdirSync(this.home, { recursive: true, mode: 0o700 });
|
|
48
48
|
this.core = new DaemonCore(this.home);
|
|
49
|
-
this.core.onEvent((
|
|
49
|
+
this.core.onEvent((event) => this.broadcast(event));
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
/**
|
|
@@ -119,22 +119,28 @@ export class DaemonServer {
|
|
|
119
119
|
// socket file left behind is stale
|
|
120
120
|
if (existsSync(path)) rmSync(path, { force: true });
|
|
121
121
|
const self = this;
|
|
122
|
-
this.server = Bun.listen<{ buffer: LineBuffer;
|
|
122
|
+
this.server = Bun.listen<{ buffer: LineBuffer; connection: Connection; writer: BackpressureWriter }>({
|
|
123
123
|
unix: path,
|
|
124
124
|
socket: {
|
|
125
125
|
open(socket) {
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
126
|
+
const writer = new BackpressureWriter(socket);
|
|
127
|
+
const connection: Connection = { write: (data) => writer.write(data), subscribed: false };
|
|
128
|
+
socket.data = { buffer: new LineBuffer(), connection, writer };
|
|
129
|
+
self.connections.add(connection);
|
|
129
130
|
self.scheduleIdleCheck();
|
|
130
131
|
},
|
|
131
132
|
data(socket, data) {
|
|
132
133
|
socket.data.buffer.push(data.toString(), (line) => {
|
|
133
|
-
void self.
|
|
134
|
+
void self.respondToRequestLine(socket.data.connection, line);
|
|
134
135
|
});
|
|
135
136
|
},
|
|
137
|
+
drain(socket) {
|
|
138
|
+
// drain can fire before open has attached data on some platforms;
|
|
139
|
+
// a throw here would take the whole daemon down with the socket
|
|
140
|
+
socket.data?.writer.drain();
|
|
141
|
+
},
|
|
136
142
|
close(socket) {
|
|
137
|
-
self.
|
|
143
|
+
self.connections.delete(socket.data.connection);
|
|
138
144
|
self.scheduleIdleCheck();
|
|
139
145
|
},
|
|
140
146
|
error() {},
|
|
@@ -155,9 +161,9 @@ export class DaemonServer {
|
|
|
155
161
|
this.releaseLock();
|
|
156
162
|
}
|
|
157
163
|
|
|
158
|
-
private broadcast(
|
|
159
|
-
const frame = JSON.stringify(
|
|
160
|
-
for (const
|
|
164
|
+
private broadcast(event: DaemonEvent): void {
|
|
165
|
+
const frame = JSON.stringify(event) + "\n";
|
|
166
|
+
for (const connection of this.connections) if (connection.subscribed) connection.write(frame);
|
|
161
167
|
this.scheduleIdleCheck();
|
|
162
168
|
}
|
|
163
169
|
|
|
@@ -166,7 +172,7 @@ export class DaemonServer {
|
|
|
166
172
|
if (this.idleExitMs <= 0) return;
|
|
167
173
|
if (this.idleTimer) clearTimeout(this.idleTimer);
|
|
168
174
|
this.idleTimer = setTimeout(() => {
|
|
169
|
-
if (this.
|
|
175
|
+
if (this.connections.size === 0 && !this.core.hasPendingSessions()) {
|
|
170
176
|
this.stop();
|
|
171
177
|
this.onIdleExit();
|
|
172
178
|
} else {
|
|
@@ -175,31 +181,31 @@ export class DaemonServer {
|
|
|
175
181
|
}, this.idleExitMs);
|
|
176
182
|
}
|
|
177
183
|
|
|
178
|
-
private async
|
|
179
|
-
let
|
|
184
|
+
private async respondToRequestLine(connection: Connection, line: string): Promise<void> {
|
|
185
|
+
let request: Request;
|
|
180
186
|
try {
|
|
181
|
-
|
|
187
|
+
request = JSON.parse(line) as Request;
|
|
182
188
|
} catch {
|
|
183
|
-
|
|
189
|
+
connection.write(JSON.stringify({ id: -1, error: { code: "bad_json", message: "unparseable request" } }) + "\n");
|
|
184
190
|
return;
|
|
185
191
|
}
|
|
186
192
|
try {
|
|
187
|
-
const result = await this.dispatch(
|
|
188
|
-
|
|
193
|
+
const result = await this.dispatch(connection, request);
|
|
194
|
+
connection.write(JSON.stringify({ id: request.id, result }) + "\n");
|
|
189
195
|
} catch (err) {
|
|
190
196
|
const code = err instanceof DaemonError ? err.code : "internal";
|
|
191
197
|
const message = err instanceof Error ? err.message : String(err);
|
|
192
|
-
|
|
198
|
+
connection.write(JSON.stringify({ id: request.id, error: { code, message } }) + "\n");
|
|
193
199
|
}
|
|
194
200
|
}
|
|
195
201
|
|
|
196
|
-
private async dispatch(
|
|
202
|
+
private async dispatch(connection: Connection, request: Request): Promise<unknown> {
|
|
197
203
|
// The wire is untrusted JSON: validate before DaemonCore sees anything.
|
|
198
|
-
if (typeof
|
|
199
|
-
throw new DaemonError("unknown_method", `unknown method ${String(
|
|
204
|
+
if (typeof request.method !== "string" || !isKnownMethod(request.method)) {
|
|
205
|
+
throw new DaemonError("unknown_method", `unknown method ${String(request.method)}`);
|
|
200
206
|
}
|
|
201
207
|
const core = this.core;
|
|
202
|
-
switch (
|
|
208
|
+
switch (request.method) {
|
|
203
209
|
case "daemon.ping":
|
|
204
210
|
return { pid: process.pid };
|
|
205
211
|
case "daemon.shutdown":
|
|
@@ -209,42 +215,58 @@ export class DaemonServer {
|
|
|
209
215
|
}, 10);
|
|
210
216
|
return {};
|
|
211
217
|
case "events.subscribe":
|
|
212
|
-
|
|
218
|
+
connection.subscribed = true;
|
|
213
219
|
return {};
|
|
214
220
|
case "session.create": {
|
|
215
|
-
const
|
|
216
|
-
return core.sessionCreate({ workspace:
|
|
221
|
+
const params = parseParams("session.create", request.params);
|
|
222
|
+
return core.sessionCreate({ workspace: params.workspace, artifact: params.artifact });
|
|
217
223
|
}
|
|
218
224
|
case "session.get":
|
|
219
|
-
return core.sessionGet(parseParams("session.get",
|
|
225
|
+
return core.sessionGet(parseParams("session.get", request.params).id);
|
|
220
226
|
case "session.list":
|
|
221
|
-
return core.sessionList(parseParams("session.list",
|
|
227
|
+
return core.sessionList(parseParams("session.list", request.params).filter);
|
|
222
228
|
case "session.wait": {
|
|
223
|
-
const
|
|
224
|
-
return core.sessionWait(
|
|
229
|
+
const params = parseParams("session.wait", request.params);
|
|
230
|
+
return core.sessionWait(params.id, params.timeoutMs);
|
|
225
231
|
}
|
|
226
232
|
case "session.annotate": {
|
|
227
|
-
const
|
|
228
|
-
return core.sessionAnnotate(
|
|
233
|
+
const params = parseParams("session.annotate", request.params);
|
|
234
|
+
return core.sessionAnnotate(params.id, params.annotation);
|
|
229
235
|
}
|
|
230
236
|
case "session.removeAnnotation": {
|
|
231
|
-
const
|
|
232
|
-
return core.sessionRemoveAnnotation(
|
|
237
|
+
const params = parseParams("session.removeAnnotation", request.params);
|
|
238
|
+
return core.sessionRemoveAnnotation(params.id, params.annotationId);
|
|
233
239
|
}
|
|
234
240
|
case "session.setWorkingCopy": {
|
|
235
|
-
const
|
|
236
|
-
return core.sessionSetWorkingCopy(
|
|
241
|
+
const params = parseParams("session.setWorkingCopy", request.params);
|
|
242
|
+
return core.sessionSetWorkingCopy(params.id, params.workingCopy);
|
|
243
|
+
}
|
|
244
|
+
case "session.setViewed": {
|
|
245
|
+
const params = parseParams("session.setViewed", request.params);
|
|
246
|
+
return core.sessionSetViewed(params.id, params.viewedPaths);
|
|
247
|
+
}
|
|
248
|
+
case "session.setShareId": {
|
|
249
|
+
const params = parseParams("session.setShareId", request.params);
|
|
250
|
+
return core.sessionSetShareId(params.id, params.shareId);
|
|
251
|
+
}
|
|
252
|
+
case "session.delete": {
|
|
253
|
+
core.sessionDelete(parseParams("session.delete", request.params).id);
|
|
254
|
+
return {};
|
|
255
|
+
}
|
|
256
|
+
case "session.mergeShared": {
|
|
257
|
+
const params = parseParams("session.mergeShared", request.params);
|
|
258
|
+
return core.sessionMergeShared(params.id, { annotations: params.annotations, participants: params.participants });
|
|
237
259
|
}
|
|
238
260
|
case "session.resolve": {
|
|
239
|
-
const
|
|
240
|
-
return core.sessionResolve(
|
|
261
|
+
const params = parseParams("session.resolve", request.params);
|
|
262
|
+
return core.sessionResolve(params.id, params.verdictKind, params.summary);
|
|
241
263
|
}
|
|
242
264
|
case "session.submitRevision": {
|
|
243
|
-
const
|
|
244
|
-
return core.sessionSubmitRevision(
|
|
265
|
+
const params = parseParams("session.submitRevision", request.params);
|
|
266
|
+
return core.sessionSubmitRevision(params.id, params.content, params.addressedAnnotationIds);
|
|
245
267
|
}
|
|
246
268
|
default:
|
|
247
|
-
throw new DaemonError("unknown_method", `unknown method ${
|
|
269
|
+
throw new DaemonError("unknown_method", `unknown method ${request.method}`);
|
|
248
270
|
}
|
|
249
271
|
}
|
|
250
272
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
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 type { Artifact, 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("unpack rejects bad input", () => {
|
|
42
|
+
test("caps the decompressed size, so a compression bomb throws", () => {
|
|
43
|
+
// Arrange: tiny gzip, huge inflation - the decompression-bomb shape
|
|
44
|
+
const bomb = gzipSync(Buffer.alloc(MAX_BLOB_BYTES + 1, 0x61));
|
|
45
|
+
|
|
46
|
+
// Act / Assert
|
|
47
|
+
expect(() => unpackSessionBlob(bomb)).toThrow(/exceeds|maxOutputLength/i);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("throws on bytes that are not gzip", () => {
|
|
51
|
+
// Act / Assert
|
|
52
|
+
expect(() => unpackSessionBlob(Buffer.from("not gzip at all"))).toThrow(/gzip/i);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("throws on a shape that is not a session", () => {
|
|
56
|
+
// Arrange
|
|
57
|
+
const notASession = gzipSync(Buffer.from(JSON.stringify({ hello: "world" })));
|
|
58
|
+
|
|
59
|
+
// Act / Assert
|
|
60
|
+
expect(() => unpackSessionBlob(notASession)).toThrow(/not a valid session/i);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The share-blob wire format: how a ReviewSession travels from `cueloop share`
|
|
3
|
+
* to the gateway. The planner's CLI packs (this is the only thing it can do -
|
|
4
|
+
* it holds no encryption key); the gateway unpacks, then encrypts for R2.
|
|
5
|
+
*
|
|
6
|
+
* Format is gzip(JSON). Unpack is the untrusted boundary - the bytes come off
|
|
7
|
+
* an SSH channel from anyone who can reach the `share` user - so it caps the
|
|
8
|
+
* decompressed size (a decompression-bomb guard) and runs the same record
|
|
9
|
+
* validator the daemon store uses, never trusting the shape.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { gzipSync, gunzipSync } from "node:zlib";
|
|
13
|
+
import type { ReviewSession } from "@cueloop/schema";
|
|
14
|
+
import { validateSessionRecord } from "./validate";
|
|
15
|
+
|
|
16
|
+
/** Decompressed ceiling for one shared session (ADR 0004's payload cap). */
|
|
17
|
+
export const MAX_BLOB_BYTES = 1024 * 1024;
|
|
18
|
+
|
|
19
|
+
/** The SSH username `cueloop share` connects as to upload; the gateway routes on it. */
|
|
20
|
+
export const SHARE_UPLOAD_USER = "share";
|
|
21
|
+
|
|
22
|
+
/** Where shares live, in one place: the client uploads here, the gateway prints it. */
|
|
23
|
+
export const DEFAULT_SHARE_HOST = "cueloop.dev";
|
|
24
|
+
export const DEFAULT_SHARE_PORT = 22;
|
|
25
|
+
|
|
26
|
+
/** Serialise + compress a session for upload. */
|
|
27
|
+
export function packSessionBlob(session: ReviewSession): Buffer {
|
|
28
|
+
return gzipSync(Buffer.from(JSON.stringify(session), "utf8"));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Decompress + validate an uploaded blob, or throw a precise reason. */
|
|
32
|
+
export function unpackSessionBlob(bytes: Uint8Array): ReviewSession {
|
|
33
|
+
let json: string;
|
|
34
|
+
try {
|
|
35
|
+
json = gunzipSync(bytes, { maxOutputLength: MAX_BLOB_BYTES }).toString("utf8");
|
|
36
|
+
} catch (err) {
|
|
37
|
+
throw new Error(`blob is not valid gzip or exceeds ${MAX_BLOB_BYTES} bytes: ${err instanceof Error ? err.message : String(err)}`);
|
|
38
|
+
}
|
|
39
|
+
let raw: unknown;
|
|
40
|
+
try {
|
|
41
|
+
raw = JSON.parse(json);
|
|
42
|
+
} catch {
|
|
43
|
+
throw new Error("blob is not valid JSON");
|
|
44
|
+
}
|
|
45
|
+
const parsed = validateSessionRecord(raw);
|
|
46
|
+
if (!parsed.ok) throw new Error(`blob is not a valid session: ${parsed.error}`);
|
|
47
|
+
return parsed.value as ReviewSession;
|
|
48
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Single-instance guarantee
|
|
2
|
+
* Single-instance guarantee: one daemon per home. Two daemons over one
|
|
3
3
|
* state directory means divergent in-memory sessions and clients seeing
|
|
4
4
|
* whichever half they connect to.
|
|
5
5
|
*/
|
|
@@ -23,33 +23,39 @@ beforeEach(() => {
|
|
|
23
23
|
home = mkdtempSync(join(tmpdir(), "cueloop-lock-"));
|
|
24
24
|
});
|
|
25
25
|
afterEach(() => {
|
|
26
|
-
for (const
|
|
26
|
+
for (const runningServer of servers.splice(0)) runningServer.stop();
|
|
27
27
|
rmSync(home, { recursive: true, force: true });
|
|
28
28
|
});
|
|
29
29
|
|
|
30
30
|
function server(): DaemonServer {
|
|
31
|
-
const
|
|
32
|
-
servers.push(
|
|
33
|
-
return
|
|
31
|
+
const daemonServer = new DaemonServer({ home, idleExitMs: 0 });
|
|
32
|
+
servers.push(daemonServer);
|
|
33
|
+
return daemonServer;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
describe("one daemon per home", () => {
|
|
37
37
|
test("the second start is refused instead of stealing the socket", () => {
|
|
38
|
+
// Arrange
|
|
38
39
|
const first = server();
|
|
39
40
|
const path = first.start();
|
|
40
41
|
expect(path).toBe(socketPath(home));
|
|
41
42
|
const second = server();
|
|
43
|
+
|
|
44
|
+
// Assert
|
|
42
45
|
expect(second.start()).toBeNull();
|
|
43
46
|
// the winner's socket survives untouched
|
|
44
47
|
expect(existsSync(path!)).toBe(true);
|
|
45
48
|
});
|
|
46
49
|
|
|
47
50
|
test("clients keep talking to the one live daemon after a refused start", async () => {
|
|
51
|
+
// Arrange
|
|
48
52
|
const first = server();
|
|
49
53
|
first.start();
|
|
50
54
|
const created = first.core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
51
55
|
server().start(); // refused
|
|
52
56
|
const client = await DaemonClient.connect({ home });
|
|
57
|
+
|
|
58
|
+
// Assert
|
|
53
59
|
try {
|
|
54
60
|
// no split brain: the session created on the winner is visible
|
|
55
61
|
expect((await client.sessionGet(created.id)).id).toBe(created.id);
|
|
@@ -60,36 +66,47 @@ describe("one daemon per home", () => {
|
|
|
60
66
|
});
|
|
61
67
|
|
|
62
68
|
test("a stale lock from a crashed daemon is reclaimed", () => {
|
|
69
|
+
// Arrange
|
|
63
70
|
// a pid that cannot be running (init is never a cueloop daemon, and this
|
|
64
71
|
// simulates the record a crashed process leaves behind)
|
|
65
72
|
writeFileSync(lockPath(home), "999999");
|
|
66
|
-
const
|
|
67
|
-
|
|
73
|
+
const daemonServer = server();
|
|
74
|
+
|
|
75
|
+
// Assert
|
|
76
|
+
expect(daemonServer.start()).toBe(socketPath(home));
|
|
68
77
|
});
|
|
69
78
|
|
|
70
79
|
test("stopping releases the lock so a restart works", () => {
|
|
80
|
+
// Arrange
|
|
71
81
|
const first = server();
|
|
72
82
|
first.start();
|
|
83
|
+
|
|
84
|
+
// Act
|
|
73
85
|
first.stop();
|
|
86
|
+
|
|
87
|
+
// Assert
|
|
74
88
|
expect(existsSync(lockPath(home))).toBe(false);
|
|
75
89
|
const second = server();
|
|
76
90
|
expect(second.start()).toBe(socketPath(home));
|
|
77
91
|
});
|
|
78
92
|
|
|
79
93
|
test("concurrent autostarts converge on a single daemon", async () => {
|
|
94
|
+
// Act
|
|
80
95
|
// several clients race to autostart; all must end up on the same daemon
|
|
81
96
|
const clients = await Promise.all([
|
|
82
97
|
DaemonClient.connect({ home, autostart: true }),
|
|
83
98
|
DaemonClient.connect({ home, autostart: true }),
|
|
84
99
|
DaemonClient.connect({ home, autostart: true }),
|
|
85
100
|
]);
|
|
101
|
+
|
|
102
|
+
// Assert
|
|
86
103
|
try {
|
|
87
|
-
const pids = await Promise.all(clients.map((
|
|
88
|
-
const unique = new Set(pids.map((
|
|
104
|
+
const pids = await Promise.all(clients.map((client) => client.ping()));
|
|
105
|
+
const unique = new Set(pids.map((ping) => ping.pid));
|
|
89
106
|
expect(unique.size).toBe(1);
|
|
90
107
|
// and state is shared: one client's session is visible to the others
|
|
91
|
-
const
|
|
92
|
-
expect((await clients[2]!.sessionGet(
|
|
108
|
+
const session = await clients[0]!.sessionCreate(WS, PLAN);
|
|
109
|
+
expect((await clients[2]!.sessionGet(session.id)).id).toBe(session.id);
|
|
93
110
|
} finally {
|
|
94
111
|
for (const c of clients) c.close();
|
|
95
112
|
// the autostarted daemon is not one of `servers` - shut it down
|
package/src/store.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Session persistence
|
|
2
|
+
* Session persistence: one JSON document per session, every write
|
|
3
3
|
* through a temp file + atomic rename so a crash mid-write can never leave
|
|
4
4
|
* a corrupt record. Recovery is a read-only scan; records that fail to
|
|
5
5
|
* parse are skipped and reported, never deleted.
|
|
@@ -49,15 +49,15 @@ export class SessionStore {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
list(): ReviewSession[] {
|
|
52
|
-
return [...this.sessions.values()].sort((
|
|
52
|
+
return [...this.sessions.values()].sort((left, right) => left.createdAt.localeCompare(right.createdAt));
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
upsert(session: ReviewSession): void {
|
|
56
56
|
this.sessions.set(session.id, session);
|
|
57
57
|
const path = join(this.dir, `${session.id}.json`);
|
|
58
|
-
const
|
|
59
|
-
writeFileSync(
|
|
60
|
-
renameSync(
|
|
58
|
+
const tempPath = path + ".tmp";
|
|
59
|
+
writeFileSync(tempPath, JSON.stringify(session, null, 2));
|
|
60
|
+
renameSync(tempPath, path);
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
delete(id: string): boolean {
|
package/src/validate.test.ts
CHANGED
|
@@ -1,7 +1,35 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { DaemonCore } from "./api";
|
|
2
6
|
import { DaemonError } from "./errors";
|
|
3
|
-
import {
|
|
4
|
-
|
|
7
|
+
import {
|
|
8
|
+
AnchorSchema,
|
|
9
|
+
AnnotationSchema,
|
|
10
|
+
ArtifactMetaSchema,
|
|
11
|
+
ArtifactSchema,
|
|
12
|
+
IdentitySchema,
|
|
13
|
+
RevisionSchema,
|
|
14
|
+
SessionRecordSchema,
|
|
15
|
+
VerdictSchema,
|
|
16
|
+
WorkspaceSchema,
|
|
17
|
+
isKnownMethod,
|
|
18
|
+
parseParams,
|
|
19
|
+
validateSessionRecord,
|
|
20
|
+
} from "./validate";
|
|
21
|
+
import {
|
|
22
|
+
SCHEMA_VERSION,
|
|
23
|
+
type Anchor,
|
|
24
|
+
type Annotation,
|
|
25
|
+
type Artifact,
|
|
26
|
+
type ArtifactMeta,
|
|
27
|
+
type Identity,
|
|
28
|
+
type ReviewSession,
|
|
29
|
+
type Revision,
|
|
30
|
+
type Verdict,
|
|
31
|
+
type WorkspaceKey,
|
|
32
|
+
} from "@cueloop/schema";
|
|
5
33
|
|
|
6
34
|
describe("method allowlist", () => {
|
|
7
35
|
test("known and unknown methods", () => {
|
|
@@ -12,11 +40,14 @@ describe("method allowlist", () => {
|
|
|
12
40
|
|
|
13
41
|
describe("parseParams", () => {
|
|
14
42
|
test("accepts a well-formed create and defaults meta", () => {
|
|
15
|
-
|
|
43
|
+
// Act
|
|
44
|
+
const params = parseParams("session.create", {
|
|
16
45
|
workspace: { repoRoot: "/repo", branch: "main" },
|
|
17
46
|
artifact: { type: "plan", content: "# P" },
|
|
18
47
|
});
|
|
19
|
-
|
|
48
|
+
|
|
49
|
+
// Assert
|
|
50
|
+
expect(params.artifact.meta).toEqual({});
|
|
20
51
|
});
|
|
21
52
|
|
|
22
53
|
test("rejects a missing workspace with a pathed message", () => {
|
|
@@ -55,12 +86,25 @@ describe("parseParams", () => {
|
|
|
55
86
|
});
|
|
56
87
|
|
|
57
88
|
test("annotation kinds stay open (extension kinds are allowed)", () => {
|
|
58
|
-
|
|
89
|
+
// Act
|
|
90
|
+
const params = parseParams("session.annotate", {
|
|
59
91
|
id: "s",
|
|
60
92
|
annotation: { id: "a1", kind: "praise", anchor: { quote: "x" }, body: "nice" },
|
|
61
93
|
});
|
|
62
|
-
|
|
63
|
-
|
|
94
|
+
|
|
95
|
+
// Assert
|
|
96
|
+
expect(params.annotation.kind).toBe("praise");
|
|
97
|
+
expect(params.annotation.anchor.prefix).toBe("");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("setViewed takes a full path list and rejects non-string entries", () => {
|
|
101
|
+
expect(parseParams("session.setViewed", { id: "s", viewedPaths: ["src/a.ts", "src/b.ts"] }).viewedPaths).toEqual([
|
|
102
|
+
"src/a.ts",
|
|
103
|
+
"src/b.ts",
|
|
104
|
+
]);
|
|
105
|
+
expect(parseParams("session.setViewed", { id: "s", viewedPaths: [] }).viewedPaths).toEqual([]);
|
|
106
|
+
expect(() => parseParams("session.setViewed", { id: "s", viewedPaths: [1] })).toThrow(/viewedPaths/);
|
|
107
|
+
expect(() => parseParams("session.setViewed", { id: "s" })).toThrow(/viewedPaths/);
|
|
64
108
|
});
|
|
65
109
|
|
|
66
110
|
test("null params are treated as empty", () => {
|
|
@@ -86,13 +130,109 @@ describe("validateSessionRecord", () => {
|
|
|
86
130
|
});
|
|
87
131
|
|
|
88
132
|
test("rejects a foreign schema version with a readable reason", () => {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
133
|
+
// Act
|
|
134
|
+
const result = validateSessionRecord({ ...record, schemaVersion: "99" });
|
|
135
|
+
|
|
136
|
+
// Assert
|
|
137
|
+
expect(result.ok).toBe(false);
|
|
138
|
+
if (!result.ok) expect(result.error).toContain("schemaVersion");
|
|
92
139
|
});
|
|
93
140
|
|
|
94
141
|
test("rejects a structurally broken record", () => {
|
|
95
|
-
|
|
96
|
-
|
|
142
|
+
// Act
|
|
143
|
+
const result = validateSessionRecord({ ...record, revisions: "nope" });
|
|
144
|
+
|
|
145
|
+
// Assert
|
|
146
|
+
expect(result.ok).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Wire pins: every schema's entries must cover exactly the keys of the
|
|
152
|
+
* schema type it mirrors. Runtime complement to the compile-time EntriesOf
|
|
153
|
+
* check - the samples are fully populated and typed, so a new field in
|
|
154
|
+
* @cueloop/schema shows up here too.
|
|
155
|
+
*/
|
|
156
|
+
describe("wire pins", () => {
|
|
157
|
+
const keys = (subject: object) => Object.keys(subject).sort();
|
|
158
|
+
const entryKeys = (schema: { entries: object }) => Object.keys(schema.entries).sort();
|
|
159
|
+
|
|
160
|
+
const fullMeta: Required<ArtifactMeta> = {
|
|
161
|
+
cwd: "/repo",
|
|
162
|
+
agent: "claude-code",
|
|
163
|
+
agentSessionId: "sess-1",
|
|
164
|
+
planPath: "/repo/plan.md",
|
|
165
|
+
pr: "org/repo#1",
|
|
166
|
+
herdrPane: "%7",
|
|
167
|
+
title: "Plan",
|
|
168
|
+
};
|
|
169
|
+
const fullAnchor: Required<Anchor> = { quote: "q", prefix: "p", suffix: "s", blockIndex: 0, start: 0, end: 1 };
|
|
170
|
+
const fullAnnotation: Required<Annotation> = {
|
|
171
|
+
id: "a1",
|
|
172
|
+
kind: "comment",
|
|
173
|
+
anchor: fullAnchor,
|
|
174
|
+
body: "b",
|
|
175
|
+
orphan: false,
|
|
176
|
+
author: "SHA256:abc",
|
|
177
|
+
resolution: { revision: 2, source: "agent" },
|
|
178
|
+
createdAt: "now",
|
|
179
|
+
};
|
|
180
|
+
const fullArtifact: Required<Artifact> = { type: "plan", content: "# P", meta: fullMeta };
|
|
181
|
+
const fullVerdict: Required<Verdict> = { kind: "approve", summary: "", feedback: "", resolvedAt: "now" };
|
|
182
|
+
const fullRevision: Required<Revision> = { revision: 1, content: "# P", submittedAt: "now" };
|
|
183
|
+
const fullWorkspace: Required<WorkspaceKey> = { repoRoot: "/repo", branch: "main" };
|
|
184
|
+
const fullIdentity: Required<Identity> = { id: "SHA256:abc", provider: "ssh", name: "Al", handle: "abc" };
|
|
185
|
+
const fullSession: Required<ReviewSession> = {
|
|
186
|
+
schemaVersion: SCHEMA_VERSION,
|
|
187
|
+
id: "ses_1",
|
|
188
|
+
workspace: fullWorkspace,
|
|
189
|
+
artifact: fullArtifact,
|
|
190
|
+
revisions: [fullRevision],
|
|
191
|
+
annotations: [fullAnnotation],
|
|
192
|
+
workingCopy: "# P edited",
|
|
193
|
+
viewedPaths: ["src/a.ts"],
|
|
194
|
+
verdict: fullVerdict,
|
|
195
|
+
status: "pending",
|
|
196
|
+
createdAt: "now",
|
|
197
|
+
shareId: "p_abc123xy",
|
|
198
|
+
owner: "SHA256:owner",
|
|
199
|
+
participants: [fullIdentity],
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
test("schema key sets match the schema types", () => {
|
|
203
|
+
expect(entryKeys(WorkspaceSchema)).toEqual(keys(fullWorkspace));
|
|
204
|
+
expect(entryKeys(ArtifactMetaSchema)).toEqual(keys(fullMeta));
|
|
205
|
+
expect(entryKeys(ArtifactSchema)).toEqual(keys(fullArtifact));
|
|
206
|
+
expect(entryKeys(AnchorSchema)).toEqual(keys(fullAnchor));
|
|
207
|
+
// wire annotations arrive without createdAt; the daemon stamps it
|
|
208
|
+
const { createdAt: _stamped, ...wireAnnotation } = fullAnnotation;
|
|
209
|
+
expect(entryKeys(AnnotationSchema)).toEqual(keys(wireAnnotation));
|
|
210
|
+
expect(entryKeys(RevisionSchema)).toEqual(keys(fullRevision));
|
|
211
|
+
expect(entryKeys(VerdictSchema)).toEqual(keys(fullVerdict));
|
|
212
|
+
expect(entryKeys(IdentitySchema)).toEqual(keys(fullIdentity));
|
|
213
|
+
expect(entryKeys(SessionRecordSchema)).toEqual(keys(fullSession));
|
|
214
|
+
// persisted annotations carry the stamped createdAt
|
|
215
|
+
const stored = SessionRecordSchema.entries.annotations.item;
|
|
216
|
+
expect(entryKeys(stored)).toEqual(keys(fullAnnotation));
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("a fully-populated meta survives validation and DaemonCore unchanged", () => {
|
|
220
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-val-"));
|
|
221
|
+
try {
|
|
222
|
+
// Arrange
|
|
223
|
+
const params = parseParams("session.create", {
|
|
224
|
+
workspace: fullWorkspace,
|
|
225
|
+
artifact: { type: "plan", content: "# P", meta: fullMeta },
|
|
226
|
+
});
|
|
227
|
+
const core = new DaemonCore(home);
|
|
228
|
+
|
|
229
|
+
// Act
|
|
230
|
+
const created = core.sessionCreate(params);
|
|
231
|
+
|
|
232
|
+
// Assert
|
|
233
|
+
expect(core.sessionGet(created.id).artifact.meta).toEqual(fullMeta);
|
|
234
|
+
} finally {
|
|
235
|
+
rmSync(home, { recursive: true, force: true });
|
|
236
|
+
}
|
|
97
237
|
});
|
|
98
238
|
});
|