@cueloop/daemon 0.1.0-alpha.2 → 0.1.0-alpha.21
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 +12 -5
- package/src/api.test.ts +58 -44
- package/src/api.ts +62 -48
- package/src/client.ts +33 -25
- package/src/herdr-pane.test.ts +122 -0
- package/src/herdr-pane.ts +94 -0
- package/src/index.ts +8 -8
- package/src/protocol.ts +44 -7
- package/src/review.test.ts +184 -0
- package/src/review.ts +208 -0
- package/src/server.test.ts +49 -20
- package/src/server.ts +58 -48
- package/src/single-instance.test.ts +11 -11
- package/src/store.ts +5 -5
- package/src/validate.test.ts +126 -12
- package/src/validate.ts +59 -31
package/src/review.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared review core: one open/wait/verdict path for every consumer -
|
|
3
|
+
* CLI commands and agent adapters. openReview resolves the workspace, shapes
|
|
4
|
+
* the artifact, and opens-or-revises by agentSessionId; awaitVerdict maps the
|
|
5
|
+
* wait contract onto the agent contract through verdictResponse. An
|
|
6
|
+
* adapter keeps only two bespoke parts: parsing its host's event shape and
|
|
7
|
+
* serializing the decision in its host's contract.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { newAnnotationId, type ArtifactType, type ReviewSession, type WorkspaceKey } from "@cueloop/schema";
|
|
11
|
+
import { verdictResponse } from "./api";
|
|
12
|
+
import type { DaemonClient } from "./client";
|
|
13
|
+
|
|
14
|
+
// Adapters and CLI verbs reach the verdict mapping through this module too,
|
|
15
|
+
// so a session obtained outside a ReviewHandle maps the same way.
|
|
16
|
+
export { verdictResponse };
|
|
17
|
+
|
|
18
|
+
async function git(args: string[], cwd: string): Promise<string | null> {
|
|
19
|
+
try {
|
|
20
|
+
const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore" });
|
|
21
|
+
const out = await new Response(proc.stdout).text();
|
|
22
|
+
if ((await proc.exited) !== 0) return null;
|
|
23
|
+
return out.trim();
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Workspace key resolution: repo root + branch from the cwd. */
|
|
30
|
+
export async function resolveWorkspace(cwd = process.cwd()): Promise<WorkspaceKey> {
|
|
31
|
+
const repoRoot = (await git(["rev-parse", "--show-toplevel"], cwd)) ?? cwd;
|
|
32
|
+
const branch = (await git(["rev-parse", "--abbrev-ref", "HEAD"], cwd)) ?? "detached";
|
|
33
|
+
return { repoRoot, branch };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function firstHeading(markdown: string): string | undefined {
|
|
37
|
+
const headingMatch = markdown.match(/^#\s+(.+)$/m);
|
|
38
|
+
return headingMatch?.[1]?.trim();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface OpenReviewOptions {
|
|
42
|
+
type: ArtifactType;
|
|
43
|
+
content: string;
|
|
44
|
+
/** Workspace resolution root and meta.cwd; defaults to process.cwd(). */
|
|
45
|
+
cwd?: string;
|
|
46
|
+
/** Pre-resolved workspace key; skips git resolution when the caller already has it. */
|
|
47
|
+
workspace?: WorkspaceKey;
|
|
48
|
+
agent?: string;
|
|
49
|
+
/** When set, a resubmit from the same agent session becomes a revision, not a new session. */
|
|
50
|
+
agentSessionId?: string;
|
|
51
|
+
planPath?: string;
|
|
52
|
+
pr?: string;
|
|
53
|
+
herdrPane?: string;
|
|
54
|
+
/** Defaults to the plan's first markdown heading; diffs get no derived title. */
|
|
55
|
+
title?: string;
|
|
56
|
+
/**
|
|
57
|
+
* Per-file agent notes for diff sessions: the submitting agent's own
|
|
58
|
+
* explanation of each file's change, in dead prose. Stored as annotations
|
|
59
|
+
* with kind "note" anchored at the file path, so they render as regular
|
|
60
|
+
* rail cards and feed the guided walk's agent-note block.
|
|
61
|
+
*/
|
|
62
|
+
notes?: ReviewNote[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The note contract: one note per changed file, anchored by the file path. */
|
|
66
|
+
export interface ReviewNote {
|
|
67
|
+
path: string;
|
|
68
|
+
body: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Anchor a note at its file: quote = the path, no context selectors. */
|
|
72
|
+
async function attachNotes(client: DaemonClient, sessionId: string, notes: ReviewNote[]): Promise<void> {
|
|
73
|
+
for (const note of notes) {
|
|
74
|
+
await client.sessionAnnotate(sessionId, {
|
|
75
|
+
id: newAnnotationId(),
|
|
76
|
+
kind: "note",
|
|
77
|
+
anchor: { quote: note.path, prefix: "", suffix: "" },
|
|
78
|
+
body: note.body,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface AwaitVerdictOptions {
|
|
84
|
+
/** Total wait budget; Infinity keeps polling until resolved or aborted. */
|
|
85
|
+
timeoutMs: number;
|
|
86
|
+
/** Chunk length for the poll loop; between chunks the session is re-read for progress. */
|
|
87
|
+
pollMs?: number;
|
|
88
|
+
/** Called with the fresh session after each chunk that is still pending. */
|
|
89
|
+
onProgress?: (session: ReviewSession) => void;
|
|
90
|
+
signal?: AbortSignal;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** A resolved review mapped onto the agent contract, plus the full session. */
|
|
94
|
+
export interface VerdictOutcome {
|
|
95
|
+
allow: boolean;
|
|
96
|
+
feedback: string;
|
|
97
|
+
session: ReviewSession;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Sentinel distinguishing an abort from any daemon response. */
|
|
101
|
+
const ABORTED = Symbol("aborted");
|
|
102
|
+
|
|
103
|
+
function raceAbort<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T | typeof ABORTED> {
|
|
104
|
+
if (!signal) return promise;
|
|
105
|
+
if (signal.aborted) {
|
|
106
|
+
promise.catch(() => {});
|
|
107
|
+
return Promise.resolve(ABORTED);
|
|
108
|
+
}
|
|
109
|
+
return new Promise<T | typeof ABORTED>((resolve, reject) => {
|
|
110
|
+
const onAbort = () => {
|
|
111
|
+
// The daemon request keeps running until the client closes; swallow its
|
|
112
|
+
// eventual rejection so the abort path never leaks an unhandled error.
|
|
113
|
+
promise.catch(() => {});
|
|
114
|
+
resolve(ABORTED);
|
|
115
|
+
};
|
|
116
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
117
|
+
promise.then(
|
|
118
|
+
(value) => {
|
|
119
|
+
signal.removeEventListener("abort", onAbort);
|
|
120
|
+
resolve(value);
|
|
121
|
+
},
|
|
122
|
+
(error) => {
|
|
123
|
+
signal.removeEventListener("abort", onAbort);
|
|
124
|
+
reject(error);
|
|
125
|
+
},
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export class ReviewHandle {
|
|
131
|
+
constructor(
|
|
132
|
+
private readonly client: DaemonClient,
|
|
133
|
+
readonly session: ReviewSession,
|
|
134
|
+
) {}
|
|
135
|
+
|
|
136
|
+
get id(): string {
|
|
137
|
+
return this.session.id;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Block on the verdict. "pending" means the budget ran out or the signal
|
|
142
|
+
* aborted - the session stays open and the verdict is collectable later
|
|
143
|
+
* (verdicts outlive waits). With only timeoutMs this is one long-poll;
|
|
144
|
+
* pollMs/onProgress/signal switch to the chunked loop.
|
|
145
|
+
*/
|
|
146
|
+
async awaitVerdict(options: AwaitVerdictOptions): Promise<VerdictOutcome | "pending"> {
|
|
147
|
+
const { timeoutMs, pollMs, onProgress, signal } = options;
|
|
148
|
+
if (pollMs === undefined && onProgress === undefined && signal === undefined) {
|
|
149
|
+
const resolved = await this.client.sessionWait(this.session.id, timeoutMs);
|
|
150
|
+
return resolved === null ? "pending" : outcome(resolved);
|
|
151
|
+
}
|
|
152
|
+
const chunkMs = pollMs ?? 10_000;
|
|
153
|
+
const deadline = Number.isFinite(timeoutMs) ? Date.now() + timeoutMs : undefined;
|
|
154
|
+
for (;;) {
|
|
155
|
+
const budget = deadline === undefined ? chunkMs : Math.min(chunkMs, deadline - Date.now());
|
|
156
|
+
if (budget <= 0 || signal?.aborted) return "pending";
|
|
157
|
+
const resolved = await raceAbort(this.client.sessionWait(this.session.id, budget), signal);
|
|
158
|
+
if (resolved === ABORTED) return "pending";
|
|
159
|
+
if (resolved !== null) return outcome(resolved);
|
|
160
|
+
// Still pending after this chunk: re-read to surface reviewer progress.
|
|
161
|
+
const current = await raceAbort(this.client.sessionGet(this.session.id), signal);
|
|
162
|
+
if (current === ABORTED) return "pending";
|
|
163
|
+
onProgress?.(current);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function outcome(session: ReviewSession): VerdictOutcome {
|
|
169
|
+
return { ...verdictResponse(session), session };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Open a review session (or revise the agent session's existing one) and hand back the wait surface. */
|
|
173
|
+
export async function openReview(client: DaemonClient, options: OpenReviewOptions): Promise<ReviewHandle> {
|
|
174
|
+
const cwd = options.cwd ?? process.cwd();
|
|
175
|
+
const workspace = options.workspace ?? (await resolveWorkspace(cwd));
|
|
176
|
+
// Resubmits from the same agent session become revisions, not new sessions.
|
|
177
|
+
if (options.agentSessionId !== undefined) {
|
|
178
|
+
const existing = (await client.sessionList()).find(
|
|
179
|
+
(candidate) => candidate.artifact.meta.agentSessionId === options.agentSessionId,
|
|
180
|
+
);
|
|
181
|
+
if (existing !== undefined) {
|
|
182
|
+
let revised = await client.sessionSubmitRevision(existing.id, options.content);
|
|
183
|
+
if (options.notes?.length) {
|
|
184
|
+
await attachNotes(client, revised.id, options.notes);
|
|
185
|
+
revised = await client.sessionGet(revised.id);
|
|
186
|
+
}
|
|
187
|
+
return new ReviewHandle(client, revised);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
let session = await client.sessionCreate(workspace, {
|
|
191
|
+
type: options.type,
|
|
192
|
+
content: options.content,
|
|
193
|
+
meta: {
|
|
194
|
+
agent: options.agent,
|
|
195
|
+
agentSessionId: options.agentSessionId,
|
|
196
|
+
planPath: options.planPath,
|
|
197
|
+
pr: options.pr,
|
|
198
|
+
herdrPane: options.herdrPane,
|
|
199
|
+
title: options.title ?? (options.type === "plan" ? firstHeading(options.content) : undefined),
|
|
200
|
+
cwd,
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
if (options.notes?.length) {
|
|
204
|
+
await attachNotes(client, session.id, options.notes);
|
|
205
|
+
session = await client.sessionGet(session.id);
|
|
206
|
+
}
|
|
207
|
+
return new ReviewHandle(client, session);
|
|
208
|
+
}
|
package/src/server.test.ts
CHANGED
|
@@ -28,16 +28,16 @@ afterEach(() => {
|
|
|
28
28
|
describe("socket round-trip", () => {
|
|
29
29
|
test("ping and full session flow over the wire", async () => {
|
|
30
30
|
expect((await client.ping()).pid).toBe(process.pid);
|
|
31
|
-
const
|
|
32
|
-
expect(
|
|
33
|
-
await client.sessionAnnotate(
|
|
31
|
+
const session = await client.sessionCreate(WS, PLAN);
|
|
32
|
+
expect(session.status).toBe("pending");
|
|
33
|
+
await client.sessionAnnotate(session.id, {
|
|
34
34
|
id: "a1",
|
|
35
35
|
kind: "comment",
|
|
36
36
|
anchor: { quote: "Body text", prefix: "", suffix: "." },
|
|
37
37
|
body: "More detail please.",
|
|
38
38
|
});
|
|
39
|
-
const wait = client.sessionWait(
|
|
40
|
-
await client.sessionResolve(
|
|
39
|
+
const wait = client.sessionWait(session.id, 5_000);
|
|
40
|
+
await client.sessionResolve(session.id, "request_changes", "Expand it.");
|
|
41
41
|
const resolved = (await wait)!;
|
|
42
42
|
expect(resolved.verdict!.kind).toBe("request_changes");
|
|
43
43
|
expect(resolved.verdict!.feedback).toContain("More detail please.");
|
|
@@ -47,18 +47,18 @@ describe("socket round-trip", () => {
|
|
|
47
47
|
expect(client.sessionGet("nope")).rejects.toBeInstanceOf(DaemonClientError);
|
|
48
48
|
try {
|
|
49
49
|
await client.sessionGet("nope");
|
|
50
|
-
} catch (
|
|
51
|
-
expect((
|
|
50
|
+
} catch (error) {
|
|
51
|
+
expect((error as DaemonClientError).code).toBe("not_found");
|
|
52
52
|
}
|
|
53
53
|
});
|
|
54
54
|
|
|
55
55
|
test("events push to subscribed connections only", async () => {
|
|
56
56
|
const observer = await DaemonClient.connect({ home });
|
|
57
57
|
const seen: string[] = [];
|
|
58
|
-
observer.onEvent((
|
|
58
|
+
observer.onEvent((event) => seen.push(event.event));
|
|
59
59
|
await observer.subscribe();
|
|
60
|
-
const
|
|
61
|
-
await client.sessionResolve(
|
|
60
|
+
const session = await client.sessionCreate(WS, PLAN);
|
|
61
|
+
await client.sessionResolve(session.id, "approve", "");
|
|
62
62
|
// events are pushed async over the socket; give the loop a beat
|
|
63
63
|
await Bun.sleep(50);
|
|
64
64
|
expect(seen).toContain("session.created");
|
|
@@ -71,33 +71,62 @@ describe("socket round-trip", () => {
|
|
|
71
71
|
try {
|
|
72
72
|
await client.request("session.create", { artifact: { type: "plan", content: "x" } });
|
|
73
73
|
throw new Error("should have thrown");
|
|
74
|
-
} catch (
|
|
75
|
-
expect((
|
|
74
|
+
} catch (error) {
|
|
75
|
+
expect((error as DaemonClientError).code).toBe("invalid_params");
|
|
76
76
|
}
|
|
77
77
|
// wrong types
|
|
78
78
|
try {
|
|
79
79
|
await client.request("session.wait", { id: 42 });
|
|
80
80
|
throw new Error("should have thrown");
|
|
81
|
-
} catch (
|
|
82
|
-
expect((
|
|
81
|
+
} catch (error) {
|
|
82
|
+
expect((error as DaemonClientError).code).toBe("invalid_params");
|
|
83
83
|
}
|
|
84
84
|
// unknown method
|
|
85
85
|
try {
|
|
86
86
|
await client.request("session.nuke", {});
|
|
87
87
|
throw new Error("should have thrown");
|
|
88
|
-
} catch (
|
|
89
|
-
expect((
|
|
88
|
+
} catch (error) {
|
|
89
|
+
expect((error as DaemonClientError).code).toBe("unknown_method");
|
|
90
90
|
}
|
|
91
91
|
// the daemon is still fully alive afterwards
|
|
92
|
-
const
|
|
93
|
-
expect((await client.sessionGet(
|
|
92
|
+
const session = await client.sessionCreate(WS, PLAN);
|
|
93
|
+
expect((await client.sessionGet(session.id)).id).toBe(session.id);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("meta fields survive the wire: herdrPane set on create comes back from get", async () => {
|
|
97
|
+
const session = await client.sessionCreate(WS, {
|
|
98
|
+
type: "plan",
|
|
99
|
+
content: "# P",
|
|
100
|
+
meta: { agent: "claude-code", herdrPane: "%7" },
|
|
101
|
+
});
|
|
102
|
+
const got = await client.sessionGet(session.id);
|
|
103
|
+
expect(got.artifact.meta.herdrPane).toBe("%7");
|
|
104
|
+
expect(got.artifact.meta.agent).toBe("claude-code");
|
|
94
105
|
});
|
|
95
106
|
|
|
96
107
|
test("two clients see the same state (thin-renderer model)", async () => {
|
|
97
108
|
const second = await DaemonClient.connect({ home });
|
|
98
|
-
const
|
|
99
|
-
const fromSecond = await second.sessionGet(
|
|
109
|
+
const session = await client.sessionCreate(WS, PLAN);
|
|
110
|
+
const fromSecond = await second.sessionGet(session.id);
|
|
100
111
|
expect(fromSecond.artifact.content).toBe(PLAN.content);
|
|
101
112
|
second.close();
|
|
102
113
|
});
|
|
103
114
|
});
|
|
115
|
+
|
|
116
|
+
describe("large payloads survive socket backpressure", () => {
|
|
117
|
+
test("a session bigger than the kernel socket buffer round-trips", async () => {
|
|
118
|
+
// Regression: responses larger than one socket write used to truncate
|
|
119
|
+
// mid-line because the write return value was ignored, so the client
|
|
120
|
+
// never saw the frame's newline and every later request wedged too.
|
|
121
|
+
const bigContent = "# Big plan\n" + "lorem ipsum dolor sit amet, consectetur adipiscing elit\n".repeat(4000);
|
|
122
|
+
const created = await client.sessionCreate(
|
|
123
|
+
{ repoRoot: "/repo", branch: "main" },
|
|
124
|
+
{ type: "plan", content: bigContent, meta: { title: "big" } },
|
|
125
|
+
);
|
|
126
|
+
const fetched = await client.sessionGet(created.id);
|
|
127
|
+
expect(fetched.artifact.content).toBe(bigContent);
|
|
128
|
+
// the connection must still be usable for the next frame
|
|
129
|
+
const listed = await client.sessionList();
|
|
130
|
+
expect(listed.some((session) => session.id === created.id)).toBe(true);
|
|
131
|
+
}, 15_000);
|
|
132
|
+
});
|
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,46 @@ 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);
|
|
237
247
|
}
|
|
238
248
|
case "session.resolve": {
|
|
239
|
-
const
|
|
240
|
-
return core.sessionResolve(
|
|
249
|
+
const params = parseParams("session.resolve", request.params);
|
|
250
|
+
return core.sessionResolve(params.id, params.verdictKind, params.summary);
|
|
241
251
|
}
|
|
242
252
|
case "session.submitRevision": {
|
|
243
|
-
const
|
|
244
|
-
return core.sessionSubmitRevision(
|
|
253
|
+
const params = parseParams("session.submitRevision", request.params);
|
|
254
|
+
return core.sessionSubmitRevision(params.id, params.content);
|
|
245
255
|
}
|
|
246
256
|
default:
|
|
247
|
-
throw new DaemonError("unknown_method", `unknown method ${
|
|
257
|
+
throw new DaemonError("unknown_method", `unknown method ${request.method}`);
|
|
248
258
|
}
|
|
249
259
|
}
|
|
250
260
|
}
|
|
@@ -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,14 +23,14 @@ 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", () => {
|
|
@@ -63,8 +63,8 @@ describe("one daemon per home", () => {
|
|
|
63
63
|
// a pid that cannot be running (init is never a cueloop daemon, and this
|
|
64
64
|
// simulates the record a crashed process leaves behind)
|
|
65
65
|
writeFileSync(lockPath(home), "999999");
|
|
66
|
-
const
|
|
67
|
-
expect(
|
|
66
|
+
const daemonServer = server();
|
|
67
|
+
expect(daemonServer.start()).toBe(socketPath(home));
|
|
68
68
|
});
|
|
69
69
|
|
|
70
70
|
test("stopping releases the lock so a restart works", () => {
|
|
@@ -84,12 +84,12 @@ describe("one daemon per home", () => {
|
|
|
84
84
|
DaemonClient.connect({ home, autostart: true }),
|
|
85
85
|
]);
|
|
86
86
|
try {
|
|
87
|
-
const pids = await Promise.all(clients.map((
|
|
88
|
-
const unique = new Set(pids.map((
|
|
87
|
+
const pids = await Promise.all(clients.map((client) => client.ping()));
|
|
88
|
+
const unique = new Set(pids.map((ping) => ping.pid));
|
|
89
89
|
expect(unique.size).toBe(1);
|
|
90
90
|
// and state is shared: one client's session is visible to the others
|
|
91
|
-
const
|
|
92
|
-
expect((await clients[2]!.sessionGet(
|
|
91
|
+
const session = await clients[0]!.sessionCreate(WS, PLAN);
|
|
92
|
+
expect((await clients[2]!.sessionGet(session.id)).id).toBe(session.id);
|
|
93
93
|
} finally {
|
|
94
94
|
for (const c of clients) c.close();
|
|
95
95
|
// 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 {
|