@cueloop/daemon 0.1.0-alpha.0
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 +29 -0
- package/src/api.test.ts +156 -0
- package/src/api.ts +200 -0
- package/src/client.ts +183 -0
- package/src/errors.ts +10 -0
- package/src/index.ts +8 -0
- package/src/main.ts +22 -0
- package/src/paths.ts +27 -0
- package/src/protocol.ts +40 -0
- package/src/server.test.ts +103 -0
- package/src/server.ts +250 -0
- package/src/single-instance.test.ts +101 -0
- package/src/store.ts +68 -0
- package/src/validate.test.ts +98 -0
- package/src/validate.ts +133 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { afterEach, beforeEach, 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 { DaemonServer } from "./server";
|
|
6
|
+
import { DaemonClient, DaemonClientError } from "./client";
|
|
7
|
+
import type { Artifact, WorkspaceKey } from "@cueloop/schema";
|
|
8
|
+
|
|
9
|
+
const WS: WorkspaceKey = { repoRoot: "/repo", branch: "main" };
|
|
10
|
+
const PLAN: Artifact = { type: "plan", content: "# P\n\nBody text.\n", meta: {} };
|
|
11
|
+
|
|
12
|
+
let home: string;
|
|
13
|
+
let server: DaemonServer;
|
|
14
|
+
let client: DaemonClient;
|
|
15
|
+
|
|
16
|
+
beforeEach(async () => {
|
|
17
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-srv-"));
|
|
18
|
+
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
19
|
+
server.start();
|
|
20
|
+
client = await DaemonClient.connect({ home });
|
|
21
|
+
});
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
client.close();
|
|
24
|
+
server.stop();
|
|
25
|
+
rmSync(home, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("socket round-trip", () => {
|
|
29
|
+
test("ping and full session flow over the wire", async () => {
|
|
30
|
+
expect((await client.ping()).pid).toBe(process.pid);
|
|
31
|
+
const s = await client.sessionCreate(WS, PLAN);
|
|
32
|
+
expect(s.status).toBe("pending");
|
|
33
|
+
await client.sessionAnnotate(s.id, {
|
|
34
|
+
id: "a1",
|
|
35
|
+
kind: "comment",
|
|
36
|
+
anchor: { quote: "Body text", prefix: "", suffix: "." },
|
|
37
|
+
body: "More detail please.",
|
|
38
|
+
});
|
|
39
|
+
const wait = client.sessionWait(s.id, 5_000);
|
|
40
|
+
await client.sessionResolve(s.id, "request_changes", "Expand it.");
|
|
41
|
+
const resolved = (await wait)!;
|
|
42
|
+
expect(resolved.verdict!.kind).toBe("request_changes");
|
|
43
|
+
expect(resolved.verdict!.feedback).toContain("More detail please.");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("errors carry codes across the wire", async () => {
|
|
47
|
+
expect(client.sessionGet("nope")).rejects.toBeInstanceOf(DaemonClientError);
|
|
48
|
+
try {
|
|
49
|
+
await client.sessionGet("nope");
|
|
50
|
+
} catch (e) {
|
|
51
|
+
expect((e as DaemonClientError).code).toBe("not_found");
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("events push to subscribed connections only", async () => {
|
|
56
|
+
const observer = await DaemonClient.connect({ home });
|
|
57
|
+
const seen: string[] = [];
|
|
58
|
+
observer.onEvent((e) => seen.push(e.event));
|
|
59
|
+
await observer.subscribe();
|
|
60
|
+
const s = await client.sessionCreate(WS, PLAN);
|
|
61
|
+
await client.sessionResolve(s.id, "approve", "");
|
|
62
|
+
// events are pushed async over the socket; give the loop a beat
|
|
63
|
+
await Bun.sleep(50);
|
|
64
|
+
expect(seen).toContain("session.created");
|
|
65
|
+
expect(seen).toContain("session.resolved");
|
|
66
|
+
observer.close();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("malformed requests get structured errors and never wedge the daemon", async () => {
|
|
70
|
+
// missing required params
|
|
71
|
+
try {
|
|
72
|
+
await client.request("session.create", { artifact: { type: "plan", content: "x" } });
|
|
73
|
+
throw new Error("should have thrown");
|
|
74
|
+
} catch (e) {
|
|
75
|
+
expect((e as DaemonClientError).code).toBe("invalid_params");
|
|
76
|
+
}
|
|
77
|
+
// wrong types
|
|
78
|
+
try {
|
|
79
|
+
await client.request("session.wait", { id: 42 });
|
|
80
|
+
throw new Error("should have thrown");
|
|
81
|
+
} catch (e) {
|
|
82
|
+
expect((e as DaemonClientError).code).toBe("invalid_params");
|
|
83
|
+
}
|
|
84
|
+
// unknown method
|
|
85
|
+
try {
|
|
86
|
+
await client.request("session.nuke", {});
|
|
87
|
+
throw new Error("should have thrown");
|
|
88
|
+
} catch (e) {
|
|
89
|
+
expect((e as DaemonClientError).code).toBe("unknown_method");
|
|
90
|
+
}
|
|
91
|
+
// the daemon is still fully alive afterwards
|
|
92
|
+
const s = await client.sessionCreate(WS, PLAN);
|
|
93
|
+
expect((await client.sessionGet(s.id)).id).toBe(s.id);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("two clients see the same state (thin-renderer model)", async () => {
|
|
97
|
+
const second = await DaemonClient.connect({ home });
|
|
98
|
+
const s = await client.sessionCreate(WS, PLAN);
|
|
99
|
+
const fromSecond = await second.sessionGet(s.id);
|
|
100
|
+
expect(fromSecond.artifact.content).toBe(PLAN.content);
|
|
101
|
+
second.close();
|
|
102
|
+
});
|
|
103
|
+
});
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The socket server: binds DaemonCore's methods to the unix socket, owns the
|
|
3
|
+
* pidfile, stale-socket cleanup, and the idle-exit timer (#14). File
|
|
4
|
+
* permissions are the local auth: the socket and state dir are 0700/0600.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { DaemonCore, type DaemonEvent } from "./api";
|
|
9
|
+
import { DaemonError } from "./errors";
|
|
10
|
+
import { isKnownMethod, parseParams } from "./validate";
|
|
11
|
+
import { LineBuffer, type Request } from "./protocol";
|
|
12
|
+
import { cueloopHome, lockPath, pidPath, socketPath } from "./paths";
|
|
13
|
+
|
|
14
|
+
interface Conn {
|
|
15
|
+
write(data: string): void;
|
|
16
|
+
subscribed: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface DaemonOptions {
|
|
20
|
+
home?: string;
|
|
21
|
+
/** Idle-exit delay; 0 disables (tests, foreground runs). */
|
|
22
|
+
idleExitMs?: number;
|
|
23
|
+
onIdleExit?: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Homes locked by a DaemonServer in THIS process. The on-disk lock's pid check
|
|
28
|
+
* cannot distinguish two instances inside one process (tests do exactly that),
|
|
29
|
+
* so in-process ownership is tracked separately.
|
|
30
|
+
*/
|
|
31
|
+
const HELD_HOMES = new Set<string>();
|
|
32
|
+
|
|
33
|
+
export class DaemonServer {
|
|
34
|
+
readonly core: DaemonCore;
|
|
35
|
+
readonly home: string;
|
|
36
|
+
private conns = new Set<Conn>();
|
|
37
|
+
private server: ReturnType<typeof Bun.listen> | null = null;
|
|
38
|
+
private idleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
39
|
+
private lockFd: number | null = null;
|
|
40
|
+
private readonly idleExitMs: number;
|
|
41
|
+
private readonly onIdleExit: () => void;
|
|
42
|
+
|
|
43
|
+
constructor(opts: DaemonOptions = {}) {
|
|
44
|
+
this.home = opts.home ?? cueloopHome();
|
|
45
|
+
this.idleExitMs = opts.idleExitMs ?? 15 * 60 * 1000;
|
|
46
|
+
this.onIdleExit = opts.onIdleExit ?? (() => process.exit(0));
|
|
47
|
+
mkdirSync(this.home, { recursive: true, mode: 0o700 });
|
|
48
|
+
this.core = new DaemonCore(this.home);
|
|
49
|
+
this.core.onEvent((e) => this.broadcast(e));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Exclusive single-instance lock over a cueloop home. Without it, two
|
|
54
|
+
* concurrent autostarts both bind: the second unlinks the first's socket and
|
|
55
|
+
* binds a fresh inode, leaving two daemons with divergent in-memory state
|
|
56
|
+
* over the same session files - clients then see whichever half won the race.
|
|
57
|
+
* Returns false when a live daemon already owns this home.
|
|
58
|
+
*/
|
|
59
|
+
private acquireLock(): boolean {
|
|
60
|
+
if (HELD_HOMES.has(this.home)) return false; // another instance here owns it
|
|
61
|
+
const path = lockPath(this.home);
|
|
62
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
63
|
+
try {
|
|
64
|
+
// "wx" fails when the file exists - the atomic part of the handshake
|
|
65
|
+
const fd = openSync(path, "wx");
|
|
66
|
+
writeFileSync(fd, String(process.pid));
|
|
67
|
+
this.lockFd = fd;
|
|
68
|
+
HELD_HOMES.add(this.home);
|
|
69
|
+
return true;
|
|
70
|
+
} catch {
|
|
71
|
+
let ownerPid = 0;
|
|
72
|
+
try {
|
|
73
|
+
ownerPid = Number(readFileSync(path, "utf8").trim());
|
|
74
|
+
} catch {
|
|
75
|
+
// the owner vanished between open and read; retry
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (ownerPid && ownerPid !== process.pid) {
|
|
79
|
+
try {
|
|
80
|
+
process.kill(ownerPid, 0); // throws when the pid is gone
|
|
81
|
+
return false; // a live daemon owns this home
|
|
82
|
+
} catch {
|
|
83
|
+
// stale lock from a crashed daemon
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// ownerPid === our pid but HELD_HOMES says we do not own it: a stale
|
|
87
|
+
// file from an earlier instance in this process - reclaim it.
|
|
88
|
+
try {
|
|
89
|
+
rmSync(path, { force: true });
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private releaseLock(): void {
|
|
99
|
+
HELD_HOMES.delete(this.home);
|
|
100
|
+
if (this.lockFd !== null) {
|
|
101
|
+
try {
|
|
102
|
+
closeSync(this.lockFd);
|
|
103
|
+
} catch {
|
|
104
|
+
// already closed
|
|
105
|
+
}
|
|
106
|
+
this.lockFd = null;
|
|
107
|
+
}
|
|
108
|
+
rmSync(lockPath(this.home), { force: true });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Bind the socket and serve. Returns null when another live daemon already
|
|
113
|
+
* owns this home - the caller should attach to it instead of competing.
|
|
114
|
+
*/
|
|
115
|
+
start(): string | null {
|
|
116
|
+
if (!this.acquireLock()) return null;
|
|
117
|
+
const path = socketPath(this.home);
|
|
118
|
+
// safe now: holding the lock means no live daemon owns this home, so any
|
|
119
|
+
// socket file left behind is stale
|
|
120
|
+
if (existsSync(path)) rmSync(path, { force: true });
|
|
121
|
+
const self = this;
|
|
122
|
+
this.server = Bun.listen<{ buffer: LineBuffer; conn: Conn }>({
|
|
123
|
+
unix: path,
|
|
124
|
+
socket: {
|
|
125
|
+
open(socket) {
|
|
126
|
+
const conn: Conn = { write: (d) => socket.write(d), subscribed: false };
|
|
127
|
+
socket.data = { buffer: new LineBuffer(), conn };
|
|
128
|
+
self.conns.add(conn);
|
|
129
|
+
self.scheduleIdleCheck();
|
|
130
|
+
},
|
|
131
|
+
data(socket, data) {
|
|
132
|
+
socket.data.buffer.push(data.toString(), (line) => {
|
|
133
|
+
void self.handleLine(socket.data.conn, line);
|
|
134
|
+
});
|
|
135
|
+
},
|
|
136
|
+
close(socket) {
|
|
137
|
+
self.conns.delete(socket.data.conn);
|
|
138
|
+
self.scheduleIdleCheck();
|
|
139
|
+
},
|
|
140
|
+
error() {},
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
chmodSync(path, 0o600);
|
|
144
|
+
writeFileSync(pidPath(this.home), String(process.pid));
|
|
145
|
+
this.scheduleIdleCheck();
|
|
146
|
+
return path;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
stop(): void {
|
|
150
|
+
this.server?.stop(true);
|
|
151
|
+
this.server = null;
|
|
152
|
+
rmSync(socketPath(this.home), { force: true });
|
|
153
|
+
rmSync(pidPath(this.home), { force: true });
|
|
154
|
+
if (this.idleTimer) clearTimeout(this.idleTimer);
|
|
155
|
+
this.releaseLock();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private broadcast(e: DaemonEvent): void {
|
|
159
|
+
const frame = JSON.stringify(e) + "\n";
|
|
160
|
+
for (const c of this.conns) if (c.subscribed) c.write(frame);
|
|
161
|
+
this.scheduleIdleCheck();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Idle = no pending session and no attached client. */
|
|
165
|
+
private scheduleIdleCheck(): void {
|
|
166
|
+
if (this.idleExitMs <= 0) return;
|
|
167
|
+
if (this.idleTimer) clearTimeout(this.idleTimer);
|
|
168
|
+
this.idleTimer = setTimeout(() => {
|
|
169
|
+
if (this.conns.size === 0 && !this.core.hasPendingSessions()) {
|
|
170
|
+
this.stop();
|
|
171
|
+
this.onIdleExit();
|
|
172
|
+
} else {
|
|
173
|
+
this.scheduleIdleCheck();
|
|
174
|
+
}
|
|
175
|
+
}, this.idleExitMs);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private async handleLine(conn: Conn, line: string): Promise<void> {
|
|
179
|
+
let req: Request;
|
|
180
|
+
try {
|
|
181
|
+
req = JSON.parse(line) as Request;
|
|
182
|
+
} catch {
|
|
183
|
+
conn.write(JSON.stringify({ id: -1, error: { code: "bad_json", message: "unparseable request" } }) + "\n");
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
const result = await this.dispatch(conn, req);
|
|
188
|
+
conn.write(JSON.stringify({ id: req.id, result }) + "\n");
|
|
189
|
+
} catch (err) {
|
|
190
|
+
const code = err instanceof DaemonError ? err.code : "internal";
|
|
191
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
192
|
+
conn.write(JSON.stringify({ id: req.id, error: { code, message } }) + "\n");
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private async dispatch(conn: Conn, req: Request): Promise<unknown> {
|
|
197
|
+
// The wire is untrusted JSON: validate before DaemonCore sees anything.
|
|
198
|
+
if (typeof req.method !== "string" || !isKnownMethod(req.method)) {
|
|
199
|
+
throw new DaemonError("unknown_method", `unknown method ${String(req.method)}`);
|
|
200
|
+
}
|
|
201
|
+
const core = this.core;
|
|
202
|
+
switch (req.method) {
|
|
203
|
+
case "daemon.ping":
|
|
204
|
+
return { pid: process.pid };
|
|
205
|
+
case "daemon.shutdown":
|
|
206
|
+
setTimeout(() => {
|
|
207
|
+
this.stop();
|
|
208
|
+
this.onIdleExit();
|
|
209
|
+
}, 10);
|
|
210
|
+
return {};
|
|
211
|
+
case "events.subscribe":
|
|
212
|
+
conn.subscribed = true;
|
|
213
|
+
return {};
|
|
214
|
+
case "session.create": {
|
|
215
|
+
const p = parseParams("session.create", req.params);
|
|
216
|
+
return core.sessionCreate({ workspace: p.workspace, artifact: p.artifact });
|
|
217
|
+
}
|
|
218
|
+
case "session.get":
|
|
219
|
+
return core.sessionGet(parseParams("session.get", req.params).id);
|
|
220
|
+
case "session.list":
|
|
221
|
+
return core.sessionList(parseParams("session.list", req.params).filter);
|
|
222
|
+
case "session.wait": {
|
|
223
|
+
const p = parseParams("session.wait", req.params);
|
|
224
|
+
return core.sessionWait(p.id, p.timeoutMs);
|
|
225
|
+
}
|
|
226
|
+
case "session.annotate": {
|
|
227
|
+
const p = parseParams("session.annotate", req.params);
|
|
228
|
+
return core.sessionAnnotate(p.id, p.annotation);
|
|
229
|
+
}
|
|
230
|
+
case "session.removeAnnotation": {
|
|
231
|
+
const p = parseParams("session.removeAnnotation", req.params);
|
|
232
|
+
return core.sessionRemoveAnnotation(p.id, p.annotationId);
|
|
233
|
+
}
|
|
234
|
+
case "session.setWorkingCopy": {
|
|
235
|
+
const p = parseParams("session.setWorkingCopy", req.params);
|
|
236
|
+
return core.sessionSetWorkingCopy(p.id, p.workingCopy);
|
|
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}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-instance guarantee (#14): one daemon per home. Two daemons over one
|
|
3
|
+
* state directory means divergent in-memory sessions and clients seeing
|
|
4
|
+
* whichever half they connect to.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
8
|
+
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { DaemonServer } from "./server";
|
|
12
|
+
import { DaemonClient } from "./client";
|
|
13
|
+
import { lockPath, socketPath } from "./paths";
|
|
14
|
+
import type { Artifact, WorkspaceKey } from "@cueloop/schema";
|
|
15
|
+
|
|
16
|
+
const WS: WorkspaceKey = { repoRoot: "/repo", branch: "main" };
|
|
17
|
+
const PLAN: Artifact = { type: "plan", content: "# P\n\nBody.\n", meta: {} };
|
|
18
|
+
|
|
19
|
+
let home: string;
|
|
20
|
+
const servers: DaemonServer[] = [];
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-lock-"));
|
|
24
|
+
});
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
for (const s of servers.splice(0)) s.stop();
|
|
27
|
+
rmSync(home, { recursive: true, force: true });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function server(): DaemonServer {
|
|
31
|
+
const s = new DaemonServer({ home, idleExitMs: 0 });
|
|
32
|
+
servers.push(s);
|
|
33
|
+
return s;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("one daemon per home", () => {
|
|
37
|
+
test("the second start is refused instead of stealing the socket", () => {
|
|
38
|
+
const first = server();
|
|
39
|
+
const path = first.start();
|
|
40
|
+
expect(path).toBe(socketPath(home));
|
|
41
|
+
const second = server();
|
|
42
|
+
expect(second.start()).toBeNull();
|
|
43
|
+
// the winner's socket survives untouched
|
|
44
|
+
expect(existsSync(path!)).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("clients keep talking to the one live daemon after a refused start", async () => {
|
|
48
|
+
const first = server();
|
|
49
|
+
first.start();
|
|
50
|
+
const created = first.core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
51
|
+
server().start(); // refused
|
|
52
|
+
const client = await DaemonClient.connect({ home });
|
|
53
|
+
try {
|
|
54
|
+
// no split brain: the session created on the winner is visible
|
|
55
|
+
expect((await client.sessionGet(created.id)).id).toBe(created.id);
|
|
56
|
+
expect((await client.sessionList()).length).toBe(1);
|
|
57
|
+
} finally {
|
|
58
|
+
client.close();
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("a stale lock from a crashed daemon is reclaimed", () => {
|
|
63
|
+
// a pid that cannot be running (init is never a cueloop daemon, and this
|
|
64
|
+
// simulates the record a crashed process leaves behind)
|
|
65
|
+
writeFileSync(lockPath(home), "999999");
|
|
66
|
+
const s = server();
|
|
67
|
+
expect(s.start()).toBe(socketPath(home));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("stopping releases the lock so a restart works", () => {
|
|
71
|
+
const first = server();
|
|
72
|
+
first.start();
|
|
73
|
+
first.stop();
|
|
74
|
+
expect(existsSync(lockPath(home))).toBe(false);
|
|
75
|
+
const second = server();
|
|
76
|
+
expect(second.start()).toBe(socketPath(home));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("concurrent autostarts converge on a single daemon", async () => {
|
|
80
|
+
// several clients race to autostart; all must end up on the same daemon
|
|
81
|
+
const clients = await Promise.all([
|
|
82
|
+
DaemonClient.connect({ home, autostart: true }),
|
|
83
|
+
DaemonClient.connect({ home, autostart: true }),
|
|
84
|
+
DaemonClient.connect({ home, autostart: true }),
|
|
85
|
+
]);
|
|
86
|
+
try {
|
|
87
|
+
const pids = await Promise.all(clients.map((c) => c.ping()));
|
|
88
|
+
const unique = new Set(pids.map((p) => p.pid));
|
|
89
|
+
expect(unique.size).toBe(1);
|
|
90
|
+
// and state is shared: one client's session is visible to the others
|
|
91
|
+
const s = await clients[0]!.sessionCreate(WS, PLAN);
|
|
92
|
+
expect((await clients[2]!.sessionGet(s.id)).id).toBe(s.id);
|
|
93
|
+
} finally {
|
|
94
|
+
for (const c of clients) c.close();
|
|
95
|
+
// the autostarted daemon is not one of `servers` - shut it down
|
|
96
|
+
const admin = await DaemonClient.connect({ home });
|
|
97
|
+
await admin.shutdown();
|
|
98
|
+
admin.close();
|
|
99
|
+
}
|
|
100
|
+
}, 60_000);
|
|
101
|
+
});
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session persistence (#14): one JSON document per session, every write
|
|
3
|
+
* through a temp file + atomic rename so a crash mid-write can never leave
|
|
4
|
+
* a corrupt record. Recovery is a read-only scan; records that fail to
|
|
5
|
+
* parse are skipped and reported, never deleted.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import type { ReviewSession } from "@cueloop/schema";
|
|
11
|
+
import { validateSessionRecord } from "./validate";
|
|
12
|
+
import { sessionsDir } from "./paths";
|
|
13
|
+
|
|
14
|
+
export interface RecoveryReport {
|
|
15
|
+
recovered: string[];
|
|
16
|
+
skipped: { file: string; error: string }[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class SessionStore {
|
|
20
|
+
private sessions = new Map<string, ReviewSession>();
|
|
21
|
+
private readonly dir: string;
|
|
22
|
+
|
|
23
|
+
constructor(home: string) {
|
|
24
|
+
this.dir = sessionsDir(home);
|
|
25
|
+
mkdirSync(this.dir, { recursive: true });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Read-only scan of the state directory; called once on boot. */
|
|
29
|
+
recover(): RecoveryReport {
|
|
30
|
+
const report: RecoveryReport = { recovered: [], skipped: [] };
|
|
31
|
+
for (const file of readdirSync(this.dir)) {
|
|
32
|
+
if (!file.endsWith(".json")) continue;
|
|
33
|
+
try {
|
|
34
|
+
const raw = readFileSync(join(this.dir, file), "utf8");
|
|
35
|
+
const parsed = validateSessionRecord(JSON.parse(raw));
|
|
36
|
+
if (!parsed.ok) throw new Error(`invalid record - ${parsed.error}`);
|
|
37
|
+
const session = parsed.value as ReviewSession;
|
|
38
|
+
this.sessions.set(session.id, session);
|
|
39
|
+
report.recovered.push(session.id);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
report.skipped.push({ file, error: err instanceof Error ? err.message : String(err) });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return report;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get(id: string): ReviewSession | undefined {
|
|
48
|
+
return this.sessions.get(id);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
list(): ReviewSession[] {
|
|
52
|
+
return [...this.sessions.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
upsert(session: ReviewSession): void {
|
|
56
|
+
this.sessions.set(session.id, session);
|
|
57
|
+
const path = join(this.dir, `${session.id}.json`);
|
|
58
|
+
const tmp = path + ".tmp";
|
|
59
|
+
writeFileSync(tmp, JSON.stringify(session, null, 2));
|
|
60
|
+
renameSync(tmp, path);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
delete(id: string): boolean {
|
|
64
|
+
if (!this.sessions.delete(id)) return false;
|
|
65
|
+
rmSync(join(this.dir, `${id}.json`), { force: true });
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { DaemonError } from "./errors";
|
|
3
|
+
import { isKnownMethod, parseParams, validateSessionRecord } from "./validate";
|
|
4
|
+
import { SCHEMA_VERSION } from "@cueloop/schema";
|
|
5
|
+
|
|
6
|
+
describe("method allowlist", () => {
|
|
7
|
+
test("known and unknown methods", () => {
|
|
8
|
+
expect(isKnownMethod("session.create")).toBe(true);
|
|
9
|
+
expect(isKnownMethod("session.destroyEverything")).toBe(false);
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe("parseParams", () => {
|
|
14
|
+
test("accepts a well-formed create and defaults meta", () => {
|
|
15
|
+
const p = parseParams("session.create", {
|
|
16
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
17
|
+
artifact: { type: "plan", content: "# P" },
|
|
18
|
+
});
|
|
19
|
+
expect(p.artifact.meta).toEqual({});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("rejects a missing workspace with a pathed message", () => {
|
|
23
|
+
try {
|
|
24
|
+
parseParams("session.create", { artifact: { type: "plan", content: "" } });
|
|
25
|
+
throw new Error("should have thrown");
|
|
26
|
+
} catch (err) {
|
|
27
|
+
expect(err).toBeInstanceOf(DaemonError);
|
|
28
|
+
expect((err as DaemonError).code).toBe("invalid_params");
|
|
29
|
+
expect((err as DaemonError).message).toContain("session.create: workspace");
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("rejects an unknown artifact type", () => {
|
|
34
|
+
expect(() =>
|
|
35
|
+
parseParams("session.create", {
|
|
36
|
+
workspace: { repoRoot: "/r", branch: "b" },
|
|
37
|
+
artifact: { type: "spreadsheet", content: "" },
|
|
38
|
+
}),
|
|
39
|
+
).toThrow(/artifact.type/);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("rejects an empty session id", () => {
|
|
43
|
+
expect(() => parseParams("session.get", { id: "" })).toThrow(/invalid|id/i);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("defaults and clamps the wait timeout", () => {
|
|
47
|
+
expect(parseParams("session.wait", { id: "ses_1" }).timeoutMs).toBe(60_000);
|
|
48
|
+
expect(() => parseParams("session.wait", { id: "ses_1", timeoutMs: -5 })).toThrow();
|
|
49
|
+
expect(() => parseParams("session.wait", { id: "ses_1", timeoutMs: 99 * 60 * 60 * 1000 })).toThrow();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("verdict kinds are closed", () => {
|
|
53
|
+
expect(parseParams("session.resolve", { id: "s", verdictKind: "approve" }).summary).toBe("");
|
|
54
|
+
expect(() => parseParams("session.resolve", { id: "s", verdictKind: "lgtm" })).toThrow(/verdictKind/);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("annotation kinds stay open (extension kinds are allowed)", () => {
|
|
58
|
+
const p = parseParams("session.annotate", {
|
|
59
|
+
id: "s",
|
|
60
|
+
annotation: { id: "a1", kind: "praise", anchor: { quote: "x" }, body: "nice" },
|
|
61
|
+
});
|
|
62
|
+
expect(p.annotation.kind).toBe("praise");
|
|
63
|
+
expect(p.annotation.anchor.prefix).toBe("");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("null params are treated as empty", () => {
|
|
67
|
+
expect(parseParams("daemon.ping", null)).toEqual({});
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("validateSessionRecord", () => {
|
|
72
|
+
const record = {
|
|
73
|
+
schemaVersion: SCHEMA_VERSION,
|
|
74
|
+
id: "ses_1",
|
|
75
|
+
workspace: { repoRoot: "/r", branch: "main" },
|
|
76
|
+
artifact: { type: "plan", content: "# P", meta: {} },
|
|
77
|
+
revisions: [{ revision: 1, content: "# P", submittedAt: "now" }],
|
|
78
|
+
annotations: [],
|
|
79
|
+
verdict: null,
|
|
80
|
+
status: "pending",
|
|
81
|
+
createdAt: "now",
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
test("accepts a valid record", () => {
|
|
85
|
+
expect(validateSessionRecord(record).ok).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("rejects a foreign schema version with a readable reason", () => {
|
|
89
|
+
const r = validateSessionRecord({ ...record, schemaVersion: "99" });
|
|
90
|
+
expect(r.ok).toBe(false);
|
|
91
|
+
if (!r.ok) expect(r.error).toContain("schemaVersion");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("rejects a structurally broken record", () => {
|
|
95
|
+
const r = validateSessionRecord({ ...record, revisions: "nope" });
|
|
96
|
+
expect(r.ok).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
});
|