@cueloop/daemon 0.1.0-alpha.5 → 0.1.0-alpha.51
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 +30 -22
- package/src/api.test.ts +278 -46
- package/src/api.ts +260 -52
- package/src/capabilities.test.ts +38 -0
- package/src/capabilities.ts +30 -0
- package/src/client.ts +128 -36
- package/src/diff-hot-reload.test.ts +159 -0
- package/src/diff-watcher.ts +99 -0
- package/src/herdr-pane.test.ts +236 -0
- package/src/herdr-pane.ts +143 -0
- package/src/herdr-split.test.ts +103 -0
- package/src/herdr-split.ts +84 -0
- package/src/herdr-tab-store.test.ts +95 -0
- package/src/herdr-tab-store.ts +67 -0
- package/src/index.ts +8 -8
- package/src/main.ts +3 -1
- package/src/paths.ts +9 -0
- package/src/protocol.ts +44 -7
- package/src/retention.test.ts +174 -0
- package/src/retention.ts +106 -0
- package/src/review.test.ts +362 -0
- package/src/review.ts +265 -0
- package/src/server.test.ts +151 -20
- package/src/server.ts +122 -55
- package/src/share-blob.test.ts +62 -0
- package/src/share-blob.ts +51 -0
- package/src/single-instance.test.ts +28 -11
- package/src/store.ts +7 -5
- package/src/validate.test.ts +184 -14
- package/src/validate.ts +133 -38
- package/src/working-tree.ts +100 -0
package/src/server.test.ts
CHANGED
|
@@ -27,18 +27,27 @@ afterEach(() => {
|
|
|
27
27
|
|
|
28
28
|
describe("socket round-trip", () => {
|
|
29
29
|
test("ping and full session flow over the wire", async () => {
|
|
30
|
+
// Assert
|
|
30
31
|
expect((await client.ping()).pid).toBe(process.pid);
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
await client.
|
|
32
|
+
|
|
33
|
+
// Act
|
|
34
|
+
const session = await client.sessionCreate(WS, PLAN);
|
|
35
|
+
|
|
36
|
+
// Assert
|
|
37
|
+
expect(session.status).toBe("pending");
|
|
38
|
+
|
|
39
|
+
// Act
|
|
40
|
+
await client.sessionAnnotate(session.id, {
|
|
34
41
|
id: "a1",
|
|
35
42
|
kind: "comment",
|
|
36
43
|
anchor: { quote: "Body text", prefix: "", suffix: "." },
|
|
37
44
|
body: "More detail please.",
|
|
38
45
|
});
|
|
39
|
-
const wait = client.sessionWait(
|
|
40
|
-
await client.sessionResolve(
|
|
46
|
+
const wait = client.sessionWait(session.id, 5_000);
|
|
47
|
+
await client.sessionResolve(session.id, "request_changes", "Expand it.");
|
|
41
48
|
const resolved = (await wait)!;
|
|
49
|
+
|
|
50
|
+
// Assert
|
|
42
51
|
expect(resolved.verdict!.kind).toBe("request_changes");
|
|
43
52
|
expect(resolved.verdict!.feedback).toContain("More detail please.");
|
|
44
53
|
});
|
|
@@ -47,18 +56,23 @@ describe("socket round-trip", () => {
|
|
|
47
56
|
expect(client.sessionGet("nope")).rejects.toBeInstanceOf(DaemonClientError);
|
|
48
57
|
try {
|
|
49
58
|
await client.sessionGet("nope");
|
|
50
|
-
} catch (
|
|
51
|
-
expect((
|
|
59
|
+
} catch (error) {
|
|
60
|
+
expect((error as DaemonClientError).code).toBe("not_found");
|
|
52
61
|
}
|
|
53
62
|
});
|
|
54
63
|
|
|
55
64
|
test("events push to subscribed connections only", async () => {
|
|
65
|
+
// Arrange
|
|
56
66
|
const observer = await DaemonClient.connect({ home });
|
|
57
67
|
const seen: string[] = [];
|
|
58
|
-
observer.onEvent((
|
|
68
|
+
observer.onEvent((event) => seen.push(event.event));
|
|
59
69
|
await observer.subscribe();
|
|
60
|
-
|
|
61
|
-
|
|
70
|
+
|
|
71
|
+
// Act
|
|
72
|
+
const session = await client.sessionCreate(WS, PLAN);
|
|
73
|
+
await client.sessionResolve(session.id, "approve", "");
|
|
74
|
+
|
|
75
|
+
// Assert
|
|
62
76
|
// events are pushed async over the socket; give the loop a beat
|
|
63
77
|
await Bun.sleep(50);
|
|
64
78
|
expect(seen).toContain("session.created");
|
|
@@ -71,33 +85,150 @@ describe("socket round-trip", () => {
|
|
|
71
85
|
try {
|
|
72
86
|
await client.request("session.create", { artifact: { type: "plan", content: "x" } });
|
|
73
87
|
throw new Error("should have thrown");
|
|
74
|
-
} catch (
|
|
75
|
-
expect((
|
|
88
|
+
} catch (error) {
|
|
89
|
+
expect((error as DaemonClientError).code).toBe("invalid_params");
|
|
76
90
|
}
|
|
77
91
|
// wrong types
|
|
78
92
|
try {
|
|
79
93
|
await client.request("session.wait", { id: 42 });
|
|
80
94
|
throw new Error("should have thrown");
|
|
81
|
-
} catch (
|
|
82
|
-
expect((
|
|
95
|
+
} catch (error) {
|
|
96
|
+
expect((error as DaemonClientError).code).toBe("invalid_params");
|
|
83
97
|
}
|
|
84
98
|
// unknown method
|
|
85
99
|
try {
|
|
86
100
|
await client.request("session.nuke", {});
|
|
87
101
|
throw new Error("should have thrown");
|
|
88
|
-
} catch (
|
|
89
|
-
expect((
|
|
102
|
+
} catch (error) {
|
|
103
|
+
expect((error as DaemonClientError).code).toBe("unknown_method");
|
|
90
104
|
}
|
|
91
105
|
// the daemon is still fully alive afterwards
|
|
92
|
-
const
|
|
93
|
-
expect((await client.sessionGet(
|
|
106
|
+
const session = await client.sessionCreate(WS, PLAN);
|
|
107
|
+
expect((await client.sessionGet(session.id)).id).toBe(session.id);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("meta fields survive the wire: herdrPane set on create comes back from get", async () => {
|
|
111
|
+
// Act
|
|
112
|
+
const session = await client.sessionCreate(WS, {
|
|
113
|
+
type: "plan",
|
|
114
|
+
content: "# P",
|
|
115
|
+
meta: { agent: "claude-code", herdrPane: "%7" },
|
|
116
|
+
});
|
|
117
|
+
const got = await client.sessionGet(session.id);
|
|
118
|
+
|
|
119
|
+
// Assert
|
|
120
|
+
expect(got.artifact.meta.herdrPane).toBe("%7");
|
|
121
|
+
expect(got.artifact.meta.agent).toBe("claude-code");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("share-sync verbs round-trip: setShareId persists, mergeShared unions notes and identities by id", async () => {
|
|
125
|
+
// Given a shared session with one local note
|
|
126
|
+
const session = await client.sessionCreate(WS, PLAN);
|
|
127
|
+
const anchor = { quote: "Body text", prefix: "", suffix: "." };
|
|
128
|
+
await client.sessionSetShareId(session.id, "p_abc123xy");
|
|
129
|
+
await client.sessionAnnotate(session.id, { id: "a1", kind: "comment", anchor, body: "mine" });
|
|
130
|
+
expect((await client.sessionGet(session.id)).shareId).toBe("p_abc123xy");
|
|
131
|
+
|
|
132
|
+
// When a pull merges an update to the known id, a new collaborator note, and the collaborator's identity
|
|
133
|
+
const merged = await client.sessionMergeShared(session.id, {
|
|
134
|
+
annotations: [
|
|
135
|
+
{
|
|
136
|
+
id: "a1",
|
|
137
|
+
kind: "comment",
|
|
138
|
+
anchor,
|
|
139
|
+
body: "should not overwrite",
|
|
140
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
id: "a2",
|
|
144
|
+
kind: "comment",
|
|
145
|
+
anchor,
|
|
146
|
+
body: "theirs",
|
|
147
|
+
author: "SHA256:mate",
|
|
148
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
participants: [{ id: "SHA256:mate", provider: "ssh", name: "Sam" }],
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Then notes union by id (existing a1 kept), and the participant registry carries the collaborator's name
|
|
155
|
+
expect(merged.annotations.map((annotation) => annotation.id).sort()).toEqual(["a1", "a2"]);
|
|
156
|
+
expect(merged.annotations.find((annotation) => annotation.id === "a1")!.body).toBe("mine");
|
|
157
|
+
expect(merged.annotations.find((annotation) => annotation.id === "a2")!.author).toBe(
|
|
158
|
+
"SHA256:mate",
|
|
159
|
+
);
|
|
160
|
+
expect(merged.participants).toEqual([{ id: "SHA256:mate", provider: "ssh", name: "Sam" }]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("mergeShared validates each annotation at the socket boundary", async () => {
|
|
164
|
+
// Given a session
|
|
165
|
+
const session = await client.sessionCreate(WS, PLAN);
|
|
166
|
+
try {
|
|
167
|
+
// When a malformed annotation is merged
|
|
168
|
+
// @ts-expect-error intentionally malformed annotation (empty id, missing fields)
|
|
169
|
+
await client.sessionMergeShared(session.id, { annotations: [{ id: "" }] });
|
|
170
|
+
throw new Error("should have thrown");
|
|
171
|
+
} catch (error) {
|
|
172
|
+
// Then it is rejected at the wire boundary
|
|
173
|
+
expect((error as DaemonClientError).code).toBe("invalid_params");
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("session.delete removes a session over the wire", async () => {
|
|
178
|
+
// Given a created session
|
|
179
|
+
const session = await client.sessionCreate(WS, PLAN);
|
|
180
|
+
|
|
181
|
+
// When it is deleted
|
|
182
|
+
await client.sessionDelete(session.id);
|
|
183
|
+
|
|
184
|
+
// Then it is gone, and deleting an unknown id is a not_found error
|
|
185
|
+
expect(client.sessionGet(session.id)).rejects.toBeInstanceOf(DaemonClientError);
|
|
186
|
+
try {
|
|
187
|
+
await client.sessionDelete("ses_missing");
|
|
188
|
+
throw new Error("should have thrown");
|
|
189
|
+
} catch (error) {
|
|
190
|
+
expect((error as DaemonClientError).code).toBe("not_found");
|
|
191
|
+
}
|
|
94
192
|
});
|
|
95
193
|
|
|
96
194
|
test("two clients see the same state (thin-renderer model)", async () => {
|
|
195
|
+
// Arrange
|
|
97
196
|
const second = await DaemonClient.connect({ home });
|
|
98
|
-
|
|
99
|
-
|
|
197
|
+
|
|
198
|
+
// Act
|
|
199
|
+
const session = await client.sessionCreate(WS, PLAN);
|
|
200
|
+
const fromSecond = await second.sessionGet(session.id);
|
|
201
|
+
|
|
202
|
+
// Assert
|
|
100
203
|
expect(fromSecond.artifact.content).toBe(PLAN.content);
|
|
101
204
|
second.close();
|
|
102
205
|
});
|
|
103
206
|
});
|
|
207
|
+
|
|
208
|
+
describe("large payloads survive socket backpressure", () => {
|
|
209
|
+
test("a session bigger than the kernel socket buffer round-trips", async () => {
|
|
210
|
+
// Regression: responses larger than one socket write used to truncate
|
|
211
|
+
// mid-line because the write return value was ignored, so the client
|
|
212
|
+
// never saw the frame's newline and every later request wedged too.
|
|
213
|
+
// Arrange
|
|
214
|
+
const bigContent =
|
|
215
|
+
"# Big plan\n" + "lorem ipsum dolor sit amet, consectetur adipiscing elit\n".repeat(4000);
|
|
216
|
+
|
|
217
|
+
// Act
|
|
218
|
+
const created = await client.sessionCreate(
|
|
219
|
+
{ repoRoot: "/repo", branch: "main" },
|
|
220
|
+
{ type: "plan", content: bigContent, meta: { title: "big" } },
|
|
221
|
+
);
|
|
222
|
+
const fetched = await client.sessionGet(created.id);
|
|
223
|
+
|
|
224
|
+
// Assert
|
|
225
|
+
expect(fetched.artifact.content).toBe(bigContent);
|
|
226
|
+
|
|
227
|
+
// Act
|
|
228
|
+
// the connection must still be usable for the next frame
|
|
229
|
+
const listed = await client.sessionList();
|
|
230
|
+
|
|
231
|
+
// Assert
|
|
232
|
+
expect(listed.some((session) => session.id === created.id)).toBe(true);
|
|
233
|
+
}, 15_000);
|
|
234
|
+
});
|
package/src/server.ts
CHANGED
|
@@ -1,19 +1,31 @@
|
|
|
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";
|
|
19
|
+
import { roleAllowsMethod, type DaemonRole } from "./capabilities";
|
|
10
20
|
import { isKnownMethod, parseParams } from "./validate";
|
|
11
|
-
import { LineBuffer, type Request } from "./protocol";
|
|
21
|
+
import { BackpressureWriter, LineBuffer, type Request } from "./protocol";
|
|
12
22
|
import { cueloopHome, lockPath, pidPath, socketPath } from "./paths";
|
|
13
23
|
|
|
14
|
-
interface
|
|
24
|
+
interface Connection {
|
|
15
25
|
write(data: string): void;
|
|
16
26
|
subscribed: boolean;
|
|
27
|
+
/** Capability role for this connection; the owner until a daemon.hello caps it. */
|
|
28
|
+
role: DaemonRole;
|
|
17
29
|
}
|
|
18
30
|
|
|
19
31
|
export interface DaemonOptions {
|
|
@@ -33,20 +45,20 @@ const HELD_HOMES = new Set<string>();
|
|
|
33
45
|
export class DaemonServer {
|
|
34
46
|
readonly core: DaemonCore;
|
|
35
47
|
readonly home: string;
|
|
36
|
-
private
|
|
48
|
+
private connections = new Set<Connection>();
|
|
37
49
|
private server: ReturnType<typeof Bun.listen> | null = null;
|
|
38
50
|
private idleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
39
51
|
private lockFd: number | null = null;
|
|
40
52
|
private readonly idleExitMs: number;
|
|
41
53
|
private readonly onIdleExit: () => void;
|
|
42
54
|
|
|
43
|
-
constructor(
|
|
44
|
-
this.home =
|
|
45
|
-
this.idleExitMs =
|
|
46
|
-
this.onIdleExit =
|
|
55
|
+
constructor(options: DaemonOptions = {}) {
|
|
56
|
+
this.home = options.home ?? cueloopHome();
|
|
57
|
+
this.idleExitMs = options.idleExitMs ?? 15 * 60 * 1000;
|
|
58
|
+
this.onIdleExit = options.onIdleExit ?? (() => process.exit(0));
|
|
47
59
|
mkdirSync(this.home, { recursive: true, mode: 0o700 });
|
|
48
60
|
this.core = new DaemonCore(this.home);
|
|
49
|
-
this.core.onEvent((
|
|
61
|
+
this.core.onEvent((event) => this.broadcast(event));
|
|
50
62
|
}
|
|
51
63
|
|
|
52
64
|
/**
|
|
@@ -118,24 +130,37 @@ export class DaemonServer {
|
|
|
118
130
|
// safe now: holding the lock means no live daemon owns this home, so any
|
|
119
131
|
// socket file left behind is stale
|
|
120
132
|
if (existsSync(path)) rmSync(path, { force: true });
|
|
121
|
-
|
|
122
|
-
|
|
133
|
+
this.server = Bun.listen<{
|
|
134
|
+
buffer: LineBuffer;
|
|
135
|
+
connection: Connection;
|
|
136
|
+
writer: BackpressureWriter;
|
|
137
|
+
}>({
|
|
123
138
|
unix: path,
|
|
124
139
|
socket: {
|
|
125
|
-
open(socket) {
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
140
|
+
open: (socket) => {
|
|
141
|
+
const writer = new BackpressureWriter(socket);
|
|
142
|
+
const connection: Connection = {
|
|
143
|
+
write: (data) => writer.write(data),
|
|
144
|
+
subscribed: false,
|
|
145
|
+
role: "owner",
|
|
146
|
+
};
|
|
147
|
+
socket.data = { buffer: new LineBuffer(), connection, writer };
|
|
148
|
+
this.connections.add(connection);
|
|
149
|
+
this.scheduleIdleCheck();
|
|
130
150
|
},
|
|
131
|
-
data(socket, data) {
|
|
151
|
+
data: (socket, data) => {
|
|
132
152
|
socket.data.buffer.push(data.toString(), (line) => {
|
|
133
|
-
void
|
|
153
|
+
void this.respondToRequestLine(socket.data.connection, line);
|
|
134
154
|
});
|
|
135
155
|
},
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
156
|
+
drain(socket) {
|
|
157
|
+
// drain can fire before open has attached data on some platforms;
|
|
158
|
+
// a throw here would take the whole daemon down with the socket
|
|
159
|
+
socket.data?.writer.drain();
|
|
160
|
+
},
|
|
161
|
+
close: (socket) => {
|
|
162
|
+
this.connections.delete(socket.data.connection);
|
|
163
|
+
this.scheduleIdleCheck();
|
|
139
164
|
},
|
|
140
165
|
error() {},
|
|
141
166
|
},
|
|
@@ -147,6 +172,7 @@ export class DaemonServer {
|
|
|
147
172
|
}
|
|
148
173
|
|
|
149
174
|
stop(): void {
|
|
175
|
+
this.core.dispose();
|
|
150
176
|
this.server?.stop(true);
|
|
151
177
|
this.server = null;
|
|
152
178
|
rmSync(socketPath(this.home), { force: true });
|
|
@@ -155,9 +181,9 @@ export class DaemonServer {
|
|
|
155
181
|
this.releaseLock();
|
|
156
182
|
}
|
|
157
183
|
|
|
158
|
-
private broadcast(
|
|
159
|
-
const frame = JSON.stringify(
|
|
160
|
-
for (const
|
|
184
|
+
private broadcast(event: DaemonEvent): void {
|
|
185
|
+
const frame = JSON.stringify(event) + "\n";
|
|
186
|
+
for (const connection of this.connections) if (connection.subscribed) connection.write(frame);
|
|
161
187
|
this.scheduleIdleCheck();
|
|
162
188
|
}
|
|
163
189
|
|
|
@@ -166,7 +192,7 @@ export class DaemonServer {
|
|
|
166
192
|
if (this.idleExitMs <= 0) return;
|
|
167
193
|
if (this.idleTimer) clearTimeout(this.idleTimer);
|
|
168
194
|
this.idleTimer = setTimeout(() => {
|
|
169
|
-
if (this.
|
|
195
|
+
if (this.connections.size === 0 && !this.core.hasPendingSessions()) {
|
|
170
196
|
this.stop();
|
|
171
197
|
this.onIdleExit();
|
|
172
198
|
} else {
|
|
@@ -175,33 +201,44 @@ export class DaemonServer {
|
|
|
175
201
|
}, this.idleExitMs);
|
|
176
202
|
}
|
|
177
203
|
|
|
178
|
-
private async
|
|
179
|
-
let
|
|
204
|
+
private async respondToRequestLine(connection: Connection, line: string): Promise<void> {
|
|
205
|
+
let request: Request;
|
|
180
206
|
try {
|
|
181
|
-
|
|
207
|
+
request = JSON.parse(line) as Request;
|
|
182
208
|
} catch {
|
|
183
|
-
|
|
209
|
+
connection.write(
|
|
210
|
+
JSON.stringify({ id: -1, error: { code: "bad_json", message: "unparseable request" } }) +
|
|
211
|
+
"\n",
|
|
212
|
+
);
|
|
184
213
|
return;
|
|
185
214
|
}
|
|
186
215
|
try {
|
|
187
|
-
const result = await this.dispatch(
|
|
188
|
-
|
|
216
|
+
const result = await this.dispatch(connection, request);
|
|
217
|
+
connection.write(JSON.stringify({ id: request.id, result }) + "\n");
|
|
189
218
|
} catch (err) {
|
|
190
219
|
const code = err instanceof DaemonError ? err.code : "internal";
|
|
191
220
|
const message = err instanceof Error ? err.message : String(err);
|
|
192
|
-
|
|
221
|
+
connection.write(JSON.stringify({ id: request.id, error: { code, message } }) + "\n");
|
|
193
222
|
}
|
|
194
223
|
}
|
|
195
224
|
|
|
196
|
-
private async dispatch(
|
|
225
|
+
private async dispatch(connection: Connection, request: Request): Promise<unknown> {
|
|
197
226
|
// The wire is untrusted JSON: validate before DaemonCore sees anything.
|
|
198
|
-
if (typeof
|
|
199
|
-
throw new DaemonError("unknown_method", `unknown method ${String(
|
|
227
|
+
if (typeof request.method !== "string" || !isKnownMethod(request.method)) {
|
|
228
|
+
throw new DaemonError("unknown_method", `unknown method ${String(request.method)}`);
|
|
229
|
+
}
|
|
230
|
+
// Capability gate: a capped role (a review-side agent) cannot escalate past
|
|
231
|
+
// read + annotate, whatever verb it sends.
|
|
232
|
+
if (!roleAllowsMethod(connection.role, request.method)) {
|
|
233
|
+
throw new DaemonError("forbidden", `role ${connection.role} cannot call ${request.method}`);
|
|
200
234
|
}
|
|
201
235
|
const core = this.core;
|
|
202
|
-
switch (
|
|
236
|
+
switch (request.method) {
|
|
203
237
|
case "daemon.ping":
|
|
204
238
|
return { pid: process.pid };
|
|
239
|
+
case "daemon.hello":
|
|
240
|
+
connection.role = parseParams("daemon.hello", request.params).role;
|
|
241
|
+
return {};
|
|
205
242
|
case "daemon.shutdown":
|
|
206
243
|
setTimeout(() => {
|
|
207
244
|
this.stop();
|
|
@@ -209,42 +246,72 @@ export class DaemonServer {
|
|
|
209
246
|
}, 10);
|
|
210
247
|
return {};
|
|
211
248
|
case "events.subscribe":
|
|
212
|
-
|
|
249
|
+
connection.subscribed = true;
|
|
213
250
|
return {};
|
|
214
251
|
case "session.create": {
|
|
215
|
-
const
|
|
216
|
-
return core.sessionCreate({ workspace:
|
|
252
|
+
const params = parseParams("session.create", request.params);
|
|
253
|
+
return core.sessionCreate({ workspace: params.workspace, artifact: params.artifact });
|
|
217
254
|
}
|
|
218
255
|
case "session.get":
|
|
219
|
-
return core.sessionGet(parseParams("session.get",
|
|
256
|
+
return core.sessionGet(parseParams("session.get", request.params).id);
|
|
220
257
|
case "session.list":
|
|
221
|
-
return core.sessionList(parseParams("session.list",
|
|
258
|
+
return core.sessionList(parseParams("session.list", request.params).filter);
|
|
222
259
|
case "session.wait": {
|
|
223
|
-
const
|
|
224
|
-
return core.sessionWait(
|
|
260
|
+
const params = parseParams("session.wait", request.params);
|
|
261
|
+
return core.sessionWait(params.id, params.timeoutMs);
|
|
225
262
|
}
|
|
226
263
|
case "session.annotate": {
|
|
227
|
-
const
|
|
228
|
-
return core.sessionAnnotate(
|
|
264
|
+
const params = parseParams("session.annotate", request.params);
|
|
265
|
+
return core.sessionAnnotate(params.id, params.annotation, params.authorName);
|
|
229
266
|
}
|
|
230
267
|
case "session.removeAnnotation": {
|
|
231
|
-
const
|
|
232
|
-
return core.sessionRemoveAnnotation(
|
|
268
|
+
const params = parseParams("session.removeAnnotation", request.params);
|
|
269
|
+
return core.sessionRemoveAnnotation(params.id, params.annotationId);
|
|
233
270
|
}
|
|
234
271
|
case "session.setWorkingCopy": {
|
|
235
|
-
const
|
|
236
|
-
return core.sessionSetWorkingCopy(
|
|
272
|
+
const params = parseParams("session.setWorkingCopy", request.params);
|
|
273
|
+
return core.sessionSetWorkingCopy(params.id, params.workingCopy);
|
|
274
|
+
}
|
|
275
|
+
case "session.setViewed": {
|
|
276
|
+
const params = parseParams("session.setViewed", request.params);
|
|
277
|
+
return core.sessionSetViewed(params.id, params.viewedPaths);
|
|
278
|
+
}
|
|
279
|
+
case "session.refreshDiff": {
|
|
280
|
+
const params = parseParams("session.refreshDiff", request.params);
|
|
281
|
+
return core.sessionRefreshDiff(params.id);
|
|
282
|
+
}
|
|
283
|
+
case "session.setShareId": {
|
|
284
|
+
const params = parseParams("session.setShareId", request.params);
|
|
285
|
+
return core.sessionSetShareId(params.id, params.shareId);
|
|
286
|
+
}
|
|
287
|
+
case "session.delete": {
|
|
288
|
+
core.sessionDelete(parseParams("session.delete", request.params).id);
|
|
289
|
+
return {};
|
|
290
|
+
}
|
|
291
|
+
case "session.mergeShared": {
|
|
292
|
+
const params = parseParams("session.mergeShared", request.params);
|
|
293
|
+
return core.sessionMergeShared(params.id, {
|
|
294
|
+
annotations: params.annotations,
|
|
295
|
+
participants: params.participants,
|
|
296
|
+
});
|
|
237
297
|
}
|
|
238
298
|
case "session.resolve": {
|
|
239
|
-
const
|
|
240
|
-
return core.sessionResolve(
|
|
299
|
+
const params = parseParams("session.resolve", request.params);
|
|
300
|
+
return core.sessionResolve(params.id, params.verdictKind, params.summary);
|
|
241
301
|
}
|
|
242
302
|
case "session.submitRevision": {
|
|
243
|
-
const
|
|
244
|
-
return core.sessionSubmitRevision(
|
|
303
|
+
const params = parseParams("session.submitRevision", request.params);
|
|
304
|
+
return core.sessionSubmitRevision(params.id, params.content, params.addressedAnnotationIds);
|
|
305
|
+
}
|
|
306
|
+
case "herdr.getTab":
|
|
307
|
+
return core.herdrGetTab(parseParams("herdr.getTab", request.params).id);
|
|
308
|
+
case "herdr.setTab": {
|
|
309
|
+
const params = parseParams("herdr.setTab", request.params);
|
|
310
|
+
core.herdrSetTab(params.id, { tabId: params.tabId, paneId: params.paneId });
|
|
311
|
+
return {};
|
|
245
312
|
}
|
|
246
313
|
default:
|
|
247
|
-
throw new DaemonError("unknown_method", `unknown method ${
|
|
314
|
+
throw new DaemonError("unknown_method", `unknown method ${request.method}`);
|
|
248
315
|
}
|
|
249
316
|
}
|
|
250
317
|
}
|
|
@@ -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,51 @@
|
|
|
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(
|
|
38
|
+
`blob is not valid gzip or exceeds ${MAX_BLOB_BYTES} bytes: ${err instanceof Error ? err.message : String(err)}`,
|
|
39
|
+
{ cause: err },
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
let raw: unknown;
|
|
43
|
+
try {
|
|
44
|
+
raw = JSON.parse(json);
|
|
45
|
+
} catch {
|
|
46
|
+
throw new Error("blob is not valid JSON");
|
|
47
|
+
}
|
|
48
|
+
const parsed = validateSessionRecord(raw);
|
|
49
|
+
if (!parsed.ok) throw new Error(`blob is not a valid session: ${parsed.error}`);
|
|
50
|
+
return parsed.value as ReviewSession;
|
|
51
|
+
}
|