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