@cueloop/daemon 0.1.0-alpha.15 → 0.1.0-alpha.17
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 +2 -2
- package/src/api.test.ts +58 -44
- package/src/api.ts +62 -48
- package/src/client.ts +26 -23
- package/src/protocol.ts +7 -7
- package/src/review.test.ts +29 -12
- package/src/review.ts +62 -28
- package/src/server.test.ts +22 -22
- package/src/server.ts +51 -47
- package/src/single-instance.test.ts +11 -11
- package/src/store.ts +5 -5
- package/src/validate.test.ts +23 -12
- package/src/validate.ts +6 -4
package/src/review.ts
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
* The shared review core: one open/wait/verdict path for every consumer -
|
|
3
3
|
* CLI commands and agent adapters. openReview resolves the workspace, shapes
|
|
4
4
|
* the artifact, and opens-or-revises by agentSessionId; awaitVerdict maps the
|
|
5
|
-
* wait contract
|
|
5
|
+
* wait contract onto the agent contract through verdictResponse. An
|
|
6
6
|
* adapter keeps only two bespoke parts: parsing its host's event shape and
|
|
7
7
|
* serializing the decision in its host's contract.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import type
|
|
10
|
+
import { newAnnotationId, type ArtifactType, type ReviewSession, type WorkspaceKey } from "@cueloop/schema";
|
|
11
11
|
import { verdictResponse } from "./api";
|
|
12
12
|
import type { DaemonClient } from "./client";
|
|
13
13
|
|
|
@@ -26,16 +26,16 @@ async function git(args: string[], cwd: string): Promise<string | null> {
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
/** Workspace key resolution
|
|
29
|
+
/** Workspace key resolution: repo root + branch from the cwd. */
|
|
30
30
|
export async function resolveWorkspace(cwd = process.cwd()): Promise<WorkspaceKey> {
|
|
31
31
|
const repoRoot = (await git(["rev-parse", "--show-toplevel"], cwd)) ?? cwd;
|
|
32
32
|
const branch = (await git(["rev-parse", "--abbrev-ref", "HEAD"], cwd)) ?? "detached";
|
|
33
33
|
return { repoRoot, branch };
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
function firstHeading(
|
|
37
|
-
const
|
|
38
|
-
return
|
|
36
|
+
function firstHeading(markdown: string): string | undefined {
|
|
37
|
+
const headingMatch = markdown.match(/^#\s+(.+)$/m);
|
|
38
|
+
return headingMatch?.[1]?.trim();
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
export interface OpenReviewOptions {
|
|
@@ -53,6 +53,31 @@ export interface OpenReviewOptions {
|
|
|
53
53
|
herdrPane?: string;
|
|
54
54
|
/** Defaults to the plan's first markdown heading; diffs get no derived title. */
|
|
55
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
|
+
}
|
|
56
81
|
}
|
|
57
82
|
|
|
58
83
|
export interface AwaitVerdictOptions {
|
|
@@ -90,13 +115,13 @@ function raceAbort<T>(promise: Promise<T>, signal: AbortSignal | undefined): Pro
|
|
|
90
115
|
};
|
|
91
116
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
92
117
|
promise.then(
|
|
93
|
-
(
|
|
118
|
+
(value) => {
|
|
94
119
|
signal.removeEventListener("abort", onAbort);
|
|
95
|
-
resolve(
|
|
120
|
+
resolve(value);
|
|
96
121
|
},
|
|
97
|
-
(
|
|
122
|
+
(error) => {
|
|
98
123
|
signal.removeEventListener("abort", onAbort);
|
|
99
|
-
reject(
|
|
124
|
+
reject(error);
|
|
100
125
|
},
|
|
101
126
|
);
|
|
102
127
|
});
|
|
@@ -115,11 +140,11 @@ export class ReviewHandle {
|
|
|
115
140
|
/**
|
|
116
141
|
* Block on the verdict. "pending" means the budget ran out or the signal
|
|
117
142
|
* aborted - the session stays open and the verdict is collectable later
|
|
118
|
-
* (verdicts outlive waits
|
|
143
|
+
* (verdicts outlive waits). With only timeoutMs this is one long-poll;
|
|
119
144
|
* pollMs/onProgress/signal switch to the chunked loop.
|
|
120
145
|
*/
|
|
121
|
-
async awaitVerdict(
|
|
122
|
-
const { timeoutMs, pollMs, onProgress, signal } =
|
|
146
|
+
async awaitVerdict(options: AwaitVerdictOptions): Promise<VerdictOutcome | "pending"> {
|
|
147
|
+
const { timeoutMs, pollMs, onProgress, signal } = options;
|
|
123
148
|
if (pollMs === undefined && onProgress === undefined && signal === undefined) {
|
|
124
149
|
const resolved = await this.client.sessionWait(this.session.id, timeoutMs);
|
|
125
150
|
return resolved === null ? "pending" : outcome(resolved);
|
|
@@ -145,30 +170,39 @@ function outcome(session: ReviewSession): VerdictOutcome {
|
|
|
145
170
|
}
|
|
146
171
|
|
|
147
172
|
/** Open a review session (or revise the agent session's existing one) and hand back the wait surface. */
|
|
148
|
-
export async function openReview(client: DaemonClient,
|
|
149
|
-
const cwd =
|
|
150
|
-
const workspace =
|
|
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));
|
|
151
176
|
// Resubmits from the same agent session become revisions, not new sessions.
|
|
152
|
-
if (
|
|
177
|
+
if (options.agentSessionId !== undefined) {
|
|
153
178
|
const existing = (await client.sessionList()).find(
|
|
154
|
-
(
|
|
179
|
+
(candidate) => candidate.artifact.meta.agentSessionId === options.agentSessionId,
|
|
155
180
|
);
|
|
156
181
|
if (existing !== undefined) {
|
|
157
|
-
|
|
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);
|
|
158
188
|
}
|
|
159
189
|
}
|
|
160
|
-
|
|
161
|
-
type:
|
|
162
|
-
content:
|
|
190
|
+
let session = await client.sessionCreate(workspace, {
|
|
191
|
+
type: options.type,
|
|
192
|
+
content: options.content,
|
|
163
193
|
meta: {
|
|
164
|
-
agent:
|
|
165
|
-
agentSessionId:
|
|
166
|
-
planPath:
|
|
167
|
-
pr:
|
|
168
|
-
herdrPane:
|
|
169
|
-
title:
|
|
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),
|
|
170
200
|
cwd,
|
|
171
201
|
},
|
|
172
202
|
});
|
|
203
|
+
if (options.notes?.length) {
|
|
204
|
+
await attachNotes(client, session.id, options.notes);
|
|
205
|
+
session = await client.sessionGet(session.id);
|
|
206
|
+
}
|
|
173
207
|
return new ReviewHandle(client, session);
|
|
174
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,43 +71,43 @@ 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
94
|
});
|
|
95
95
|
|
|
96
96
|
test("meta fields survive the wire: herdrPane set on create comes back from get", async () => {
|
|
97
|
-
const
|
|
97
|
+
const session = await client.sessionCreate(WS, {
|
|
98
98
|
type: "plan",
|
|
99
99
|
content: "# P",
|
|
100
100
|
meta: { agent: "claude-code", herdrPane: "%7" },
|
|
101
101
|
});
|
|
102
|
-
const got = await client.sessionGet(
|
|
102
|
+
const got = await client.sessionGet(session.id);
|
|
103
103
|
expect(got.artifact.meta.herdrPane).toBe("%7");
|
|
104
104
|
expect(got.artifact.meta.agent).toBe("claude-code");
|
|
105
105
|
});
|
|
106
106
|
|
|
107
107
|
test("two clients see the same state (thin-renderer model)", async () => {
|
|
108
108
|
const second = await DaemonClient.connect({ home });
|
|
109
|
-
const
|
|
110
|
-
const fromSecond = await second.sessionGet(
|
|
109
|
+
const session = await client.sessionCreate(WS, PLAN);
|
|
110
|
+
const fromSecond = await second.sessionGet(session.id);
|
|
111
111
|
expect(fromSecond.artifact.content).toBe(PLAN.content);
|
|
112
112
|
second.close();
|
|
113
113
|
});
|
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
|
|
|
@@ -11,7 +11,7 @@ import { isKnownMethod, parseParams } from "./validate";
|
|
|
11
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,19 +119,19 @@ 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
126
|
const writer = new BackpressureWriter(socket);
|
|
127
|
-
const
|
|
128
|
-
socket.data = { buffer: new LineBuffer(),
|
|
129
|
-
self.
|
|
127
|
+
const connection: Connection = { write: (data) => writer.write(data), subscribed: false };
|
|
128
|
+
socket.data = { buffer: new LineBuffer(), connection, writer };
|
|
129
|
+
self.connections.add(connection);
|
|
130
130
|
self.scheduleIdleCheck();
|
|
131
131
|
},
|
|
132
132
|
data(socket, data) {
|
|
133
133
|
socket.data.buffer.push(data.toString(), (line) => {
|
|
134
|
-
void self.
|
|
134
|
+
void self.respondToRequestLine(socket.data.connection, line);
|
|
135
135
|
});
|
|
136
136
|
},
|
|
137
137
|
drain(socket) {
|
|
@@ -140,7 +140,7 @@ export class DaemonServer {
|
|
|
140
140
|
socket.data?.writer.drain();
|
|
141
141
|
},
|
|
142
142
|
close(socket) {
|
|
143
|
-
self.
|
|
143
|
+
self.connections.delete(socket.data.connection);
|
|
144
144
|
self.scheduleIdleCheck();
|
|
145
145
|
},
|
|
146
146
|
error() {},
|
|
@@ -161,9 +161,9 @@ export class DaemonServer {
|
|
|
161
161
|
this.releaseLock();
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
-
private broadcast(
|
|
165
|
-
const frame = JSON.stringify(
|
|
166
|
-
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);
|
|
167
167
|
this.scheduleIdleCheck();
|
|
168
168
|
}
|
|
169
169
|
|
|
@@ -172,7 +172,7 @@ export class DaemonServer {
|
|
|
172
172
|
if (this.idleExitMs <= 0) return;
|
|
173
173
|
if (this.idleTimer) clearTimeout(this.idleTimer);
|
|
174
174
|
this.idleTimer = setTimeout(() => {
|
|
175
|
-
if (this.
|
|
175
|
+
if (this.connections.size === 0 && !this.core.hasPendingSessions()) {
|
|
176
176
|
this.stop();
|
|
177
177
|
this.onIdleExit();
|
|
178
178
|
} else {
|
|
@@ -181,31 +181,31 @@ export class DaemonServer {
|
|
|
181
181
|
}, this.idleExitMs);
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
-
private async
|
|
185
|
-
let
|
|
184
|
+
private async respondToRequestLine(connection: Connection, line: string): Promise<void> {
|
|
185
|
+
let request: Request;
|
|
186
186
|
try {
|
|
187
|
-
|
|
187
|
+
request = JSON.parse(line) as Request;
|
|
188
188
|
} catch {
|
|
189
|
-
|
|
189
|
+
connection.write(JSON.stringify({ id: -1, error: { code: "bad_json", message: "unparseable request" } }) + "\n");
|
|
190
190
|
return;
|
|
191
191
|
}
|
|
192
192
|
try {
|
|
193
|
-
const result = await this.dispatch(
|
|
194
|
-
|
|
193
|
+
const result = await this.dispatch(connection, request);
|
|
194
|
+
connection.write(JSON.stringify({ id: request.id, result }) + "\n");
|
|
195
195
|
} catch (err) {
|
|
196
196
|
const code = err instanceof DaemonError ? err.code : "internal";
|
|
197
197
|
const message = err instanceof Error ? err.message : String(err);
|
|
198
|
-
|
|
198
|
+
connection.write(JSON.stringify({ id: request.id, error: { code, message } }) + "\n");
|
|
199
199
|
}
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
-
private async dispatch(
|
|
202
|
+
private async dispatch(connection: Connection, request: Request): Promise<unknown> {
|
|
203
203
|
// The wire is untrusted JSON: validate before DaemonCore sees anything.
|
|
204
|
-
if (typeof
|
|
205
|
-
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)}`);
|
|
206
206
|
}
|
|
207
207
|
const core = this.core;
|
|
208
|
-
switch (
|
|
208
|
+
switch (request.method) {
|
|
209
209
|
case "daemon.ping":
|
|
210
210
|
return { pid: process.pid };
|
|
211
211
|
case "daemon.shutdown":
|
|
@@ -215,42 +215,46 @@ export class DaemonServer {
|
|
|
215
215
|
}, 10);
|
|
216
216
|
return {};
|
|
217
217
|
case "events.subscribe":
|
|
218
|
-
|
|
218
|
+
connection.subscribed = true;
|
|
219
219
|
return {};
|
|
220
220
|
case "session.create": {
|
|
221
|
-
const
|
|
222
|
-
return core.sessionCreate({ workspace:
|
|
221
|
+
const params = parseParams("session.create", request.params);
|
|
222
|
+
return core.sessionCreate({ workspace: params.workspace, artifact: params.artifact });
|
|
223
223
|
}
|
|
224
224
|
case "session.get":
|
|
225
|
-
return core.sessionGet(parseParams("session.get",
|
|
225
|
+
return core.sessionGet(parseParams("session.get", request.params).id);
|
|
226
226
|
case "session.list":
|
|
227
|
-
return core.sessionList(parseParams("session.list",
|
|
227
|
+
return core.sessionList(parseParams("session.list", request.params).filter);
|
|
228
228
|
case "session.wait": {
|
|
229
|
-
const
|
|
230
|
-
return core.sessionWait(
|
|
229
|
+
const params = parseParams("session.wait", request.params);
|
|
230
|
+
return core.sessionWait(params.id, params.timeoutMs);
|
|
231
231
|
}
|
|
232
232
|
case "session.annotate": {
|
|
233
|
-
const
|
|
234
|
-
return core.sessionAnnotate(
|
|
233
|
+
const params = parseParams("session.annotate", request.params);
|
|
234
|
+
return core.sessionAnnotate(params.id, params.annotation);
|
|
235
235
|
}
|
|
236
236
|
case "session.removeAnnotation": {
|
|
237
|
-
const
|
|
238
|
-
return core.sessionRemoveAnnotation(
|
|
237
|
+
const params = parseParams("session.removeAnnotation", request.params);
|
|
238
|
+
return core.sessionRemoveAnnotation(params.id, params.annotationId);
|
|
239
239
|
}
|
|
240
240
|
case "session.setWorkingCopy": {
|
|
241
|
-
const
|
|
242
|
-
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);
|
|
243
247
|
}
|
|
244
248
|
case "session.resolve": {
|
|
245
|
-
const
|
|
246
|
-
return core.sessionResolve(
|
|
249
|
+
const params = parseParams("session.resolve", request.params);
|
|
250
|
+
return core.sessionResolve(params.id, params.verdictKind, params.summary);
|
|
247
251
|
}
|
|
248
252
|
case "session.submitRevision": {
|
|
249
|
-
const
|
|
250
|
-
return core.sessionSubmitRevision(
|
|
253
|
+
const params = parseParams("session.submitRevision", request.params);
|
|
254
|
+
return core.sessionSubmitRevision(params.id, params.content);
|
|
251
255
|
}
|
|
252
256
|
default:
|
|
253
|
-
throw new DaemonError("unknown_method", `unknown method ${
|
|
257
|
+
throw new DaemonError("unknown_method", `unknown method ${request.method}`);
|
|
254
258
|
}
|
|
255
259
|
}
|
|
256
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 {
|
package/src/validate.test.ts
CHANGED
|
@@ -38,11 +38,11 @@ describe("method allowlist", () => {
|
|
|
38
38
|
|
|
39
39
|
describe("parseParams", () => {
|
|
40
40
|
test("accepts a well-formed create and defaults meta", () => {
|
|
41
|
-
const
|
|
41
|
+
const params = parseParams("session.create", {
|
|
42
42
|
workspace: { repoRoot: "/repo", branch: "main" },
|
|
43
43
|
artifact: { type: "plan", content: "# P" },
|
|
44
44
|
});
|
|
45
|
-
expect(
|
|
45
|
+
expect(params.artifact.meta).toEqual({});
|
|
46
46
|
});
|
|
47
47
|
|
|
48
48
|
test("rejects a missing workspace with a pathed message", () => {
|
|
@@ -81,12 +81,22 @@ describe("parseParams", () => {
|
|
|
81
81
|
});
|
|
82
82
|
|
|
83
83
|
test("annotation kinds stay open (extension kinds are allowed)", () => {
|
|
84
|
-
const
|
|
84
|
+
const params = parseParams("session.annotate", {
|
|
85
85
|
id: "s",
|
|
86
86
|
annotation: { id: "a1", kind: "praise", anchor: { quote: "x" }, body: "nice" },
|
|
87
87
|
});
|
|
88
|
-
expect(
|
|
89
|
-
expect(
|
|
88
|
+
expect(params.annotation.kind).toBe("praise");
|
|
89
|
+
expect(params.annotation.anchor.prefix).toBe("");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("setViewed takes a full path list and rejects non-string entries", () => {
|
|
93
|
+
expect(parseParams("session.setViewed", { id: "s", viewedPaths: ["src/a.ts", "src/b.ts"] }).viewedPaths).toEqual([
|
|
94
|
+
"src/a.ts",
|
|
95
|
+
"src/b.ts",
|
|
96
|
+
]);
|
|
97
|
+
expect(parseParams("session.setViewed", { id: "s", viewedPaths: [] }).viewedPaths).toEqual([]);
|
|
98
|
+
expect(() => parseParams("session.setViewed", { id: "s", viewedPaths: [1] })).toThrow(/viewedPaths/);
|
|
99
|
+
expect(() => parseParams("session.setViewed", { id: "s" })).toThrow(/viewedPaths/);
|
|
90
100
|
});
|
|
91
101
|
|
|
92
102
|
test("null params are treated as empty", () => {
|
|
@@ -112,25 +122,25 @@ describe("validateSessionRecord", () => {
|
|
|
112
122
|
});
|
|
113
123
|
|
|
114
124
|
test("rejects a foreign schema version with a readable reason", () => {
|
|
115
|
-
const
|
|
116
|
-
expect(
|
|
117
|
-
if (!
|
|
125
|
+
const result = validateSessionRecord({ ...record, schemaVersion: "99" });
|
|
126
|
+
expect(result.ok).toBe(false);
|
|
127
|
+
if (!result.ok) expect(result.error).toContain("schemaVersion");
|
|
118
128
|
});
|
|
119
129
|
|
|
120
130
|
test("rejects a structurally broken record", () => {
|
|
121
|
-
const
|
|
122
|
-
expect(
|
|
131
|
+
const result = validateSessionRecord({ ...record, revisions: "nope" });
|
|
132
|
+
expect(result.ok).toBe(false);
|
|
123
133
|
});
|
|
124
134
|
});
|
|
125
135
|
|
|
126
136
|
/**
|
|
127
|
-
* Wire pins
|
|
137
|
+
* Wire pins: every schema's entries must cover exactly the keys of the
|
|
128
138
|
* schema type it mirrors. Runtime complement to the compile-time EntriesOf
|
|
129
139
|
* check - the samples are fully populated and typed, so a new field in
|
|
130
140
|
* @cueloop/schema shows up here too.
|
|
131
141
|
*/
|
|
132
142
|
describe("wire pins", () => {
|
|
133
|
-
const keys = (
|
|
143
|
+
const keys = (subject: object) => Object.keys(subject).sort();
|
|
134
144
|
const entryKeys = (schema: { entries: object }) => Object.keys(schema.entries).sort();
|
|
135
145
|
|
|
136
146
|
const fullMeta: Required<ArtifactMeta> = {
|
|
@@ -163,6 +173,7 @@ describe("wire pins", () => {
|
|
|
163
173
|
revisions: [fullRevision],
|
|
164
174
|
annotations: [fullAnnotation],
|
|
165
175
|
workingCopy: "# P edited",
|
|
176
|
+
viewedPaths: ["src/a.ts"],
|
|
166
177
|
verdict: fullVerdict,
|
|
167
178
|
status: "pending",
|
|
168
179
|
createdAt: "now",
|