@cueloop/daemon 0.1.0-alpha.33 → 0.1.0-alpha.35
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 +21 -21
- package/src/api.test.ts +37 -7
- package/src/api.ts +35 -11
- package/src/client.ts +32 -14
- package/src/herdr-pane.test.ts +10 -2
- package/src/herdr-pane.ts +11 -6
- package/src/main.ts +3 -1
- package/src/review.test.ts +128 -9
- package/src/review.ts +59 -4
- package/src/server.test.ts +20 -4
- package/src/server.ts +31 -13
- package/src/share-blob.ts +4 -1
- package/src/store.ts +3 -1
- package/src/validate.test.ts +39 -11
- package/src/validate.ts +29 -6
package/package.json
CHANGED
|
@@ -1,7 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/daemon",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.35",
|
|
4
|
+
"description": "The cueloop session daemon and its client: socket protocol, atomic store, resumable waits",
|
|
5
|
+
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/mmurakaru/cueloop/issues"
|
|
8
|
+
},
|
|
4
9
|
"license": "Apache-2.0",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/mmurakaru/cueloop.git",
|
|
13
|
+
"directory": "packages/daemon"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
5
19
|
"type": "module",
|
|
6
20
|
"exports": {
|
|
7
21
|
".": "./src/index.ts",
|
|
@@ -10,28 +24,14 @@
|
|
|
10
24
|
"./herdr-pane": "./src/herdr-pane.ts",
|
|
11
25
|
"./share-blob": "./src/share-blob.ts"
|
|
12
26
|
},
|
|
13
|
-
"dependencies": {
|
|
14
|
-
"@cueloop/schema": "0.1.0-alpha.33",
|
|
15
|
-
"valibot": "^1.4.2"
|
|
16
|
-
},
|
|
17
27
|
"publishConfig": {
|
|
18
28
|
"access": "public"
|
|
19
29
|
},
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@cueloop/schema": "0.1.0-alpha.35",
|
|
32
|
+
"valibot": "^1.4.2"
|
|
33
|
+
},
|
|
24
34
|
"engines": {
|
|
25
35
|
"bun": ">=1.3.0"
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
"type": "git",
|
|
29
|
-
"url": "git+https://github.com/mmurakaru/cueloop.git",
|
|
30
|
-
"directory": "packages/daemon"
|
|
31
|
-
},
|
|
32
|
-
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
33
|
-
"bugs": {
|
|
34
|
-
"url": "https://github.com/mmurakaru/cueloop/issues"
|
|
35
|
-
},
|
|
36
|
-
"description": "The cueloop session daemon and its client: socket protocol, atomic store, resumable waits"
|
|
37
|
-
}
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/api.test.ts
CHANGED
|
@@ -79,7 +79,10 @@ describe("session lifecycle", () => {
|
|
|
79
79
|
// Assert
|
|
80
80
|
expect(resolved.verdict!.feedback).toContain("# Review: request changes");
|
|
81
81
|
expect(resolved.verdict!.feedback).toContain("> carefully");
|
|
82
|
-
expect(verdictResponse(resolved)).toEqual({
|
|
82
|
+
expect(verdictResponse(resolved)).toEqual({
|
|
83
|
+
allow: false,
|
|
84
|
+
feedback: resolved.verdict!.feedback,
|
|
85
|
+
});
|
|
83
86
|
|
|
84
87
|
// Arrange
|
|
85
88
|
const approved = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
@@ -98,7 +101,12 @@ describe("session lifecycle", () => {
|
|
|
98
101
|
|
|
99
102
|
// Assert
|
|
100
103
|
expect(() =>
|
|
101
|
-
core.sessionAnnotate(session.id, {
|
|
104
|
+
core.sessionAnnotate(session.id, {
|
|
105
|
+
id: "a1",
|
|
106
|
+
kind: "comment",
|
|
107
|
+
anchor: { quote: "x", prefix: "", suffix: "" },
|
|
108
|
+
body: "b",
|
|
109
|
+
}),
|
|
102
110
|
).toThrow("already resolved");
|
|
103
111
|
});
|
|
104
112
|
|
|
@@ -166,7 +174,12 @@ describe("session lifecycle", () => {
|
|
|
166
174
|
|
|
167
175
|
describe("revision marks addressed annotations", () => {
|
|
168
176
|
const annotate = (sessionId: string, id: string, quote: string, body = "note") =>
|
|
169
|
-
core.sessionAnnotate(sessionId, {
|
|
177
|
+
core.sessionAnnotate(sessionId, {
|
|
178
|
+
id,
|
|
179
|
+
kind: "comment",
|
|
180
|
+
anchor: { quote, prefix: "", suffix: "" },
|
|
181
|
+
body,
|
|
182
|
+
});
|
|
170
183
|
|
|
171
184
|
test("ids the agent reports are marked addressed by that revision", () => {
|
|
172
185
|
// Arrange
|
|
@@ -190,10 +203,16 @@ describe("revision marks addressed annotations", () => {
|
|
|
190
203
|
annotate(session.id, "kept", "Context");
|
|
191
204
|
|
|
192
205
|
// Act: the revision rewrites the "carefully" sentence but keeps the Context heading
|
|
193
|
-
const revised = core.sessionSubmitRevision(
|
|
206
|
+
const revised = core.sessionSubmitRevision(
|
|
207
|
+
session.id,
|
|
208
|
+
"# Plan\n\n## Context\n\nDo the thing with tests.\n",
|
|
209
|
+
);
|
|
194
210
|
|
|
195
211
|
// Assert
|
|
196
|
-
expect(revised.annotations.find((a) => a.id === "gone")!.resolution).toEqual({
|
|
212
|
+
expect(revised.annotations.find((a) => a.id === "gone")!.resolution).toEqual({
|
|
213
|
+
revision: 2,
|
|
214
|
+
source: "drift",
|
|
215
|
+
});
|
|
197
216
|
expect(revised.annotations.find((a) => a.id === "kept")!.resolution).toBeUndefined();
|
|
198
217
|
});
|
|
199
218
|
|
|
@@ -326,10 +345,21 @@ describe("events", () => {
|
|
|
326
345
|
|
|
327
346
|
// Act
|
|
328
347
|
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
329
|
-
core.sessionAnnotate(session.id, {
|
|
348
|
+
core.sessionAnnotate(session.id, {
|
|
349
|
+
id: "a1",
|
|
350
|
+
kind: "comment",
|
|
351
|
+
anchor: { quote: "thing", prefix: "", suffix: "" },
|
|
352
|
+
body: "b",
|
|
353
|
+
});
|
|
330
354
|
core.sessionResolve(session.id, "approve", "");
|
|
331
355
|
|
|
332
356
|
// Assert
|
|
333
|
-
expect(seen).toEqual([
|
|
357
|
+
expect(seen).toEqual([
|
|
358
|
+
"session.created",
|
|
359
|
+
"inbox.changed",
|
|
360
|
+
"session.updated",
|
|
361
|
+
"session.resolved",
|
|
362
|
+
"inbox.changed",
|
|
363
|
+
]);
|
|
334
364
|
});
|
|
335
365
|
});
|
package/src/api.ts
CHANGED
|
@@ -88,11 +88,16 @@ export class DaemonCore {
|
|
|
88
88
|
return session;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
sessionList(filter?: {
|
|
91
|
+
sessionList(filter?: {
|
|
92
|
+
status?: "pending" | "resolved";
|
|
93
|
+
workspace?: Partial<WorkspaceKey>;
|
|
94
|
+
}): ReviewSession[] {
|
|
92
95
|
return this.store.list().filter((session) => {
|
|
93
96
|
if (filter?.status && session.status !== filter.status) return false;
|
|
94
|
-
if (filter?.workspace?.repoRoot && session.workspace.repoRoot !== filter.workspace.repoRoot)
|
|
95
|
-
|
|
97
|
+
if (filter?.workspace?.repoRoot && session.workspace.repoRoot !== filter.workspace.repoRoot)
|
|
98
|
+
return false;
|
|
99
|
+
if (filter?.workspace?.branch && session.workspace.branch !== filter.workspace.branch)
|
|
100
|
+
return false;
|
|
96
101
|
return true;
|
|
97
102
|
});
|
|
98
103
|
}
|
|
@@ -131,7 +136,11 @@ export class DaemonCore {
|
|
|
131
136
|
const existing = session.annotations.findIndex((candidate) => candidate.id === annotation.id);
|
|
132
137
|
const full: Annotation = { ...annotation, createdAt: new Date().toISOString() };
|
|
133
138
|
if (existing === -1) session.annotations.push(full);
|
|
134
|
-
else
|
|
139
|
+
else
|
|
140
|
+
session.annotations[existing] = {
|
|
141
|
+
...full,
|
|
142
|
+
createdAt: session.annotations[existing]!.createdAt,
|
|
143
|
+
};
|
|
135
144
|
this.store.upsert(session);
|
|
136
145
|
this.emit("session.updated", id);
|
|
137
146
|
return session;
|
|
@@ -148,7 +157,8 @@ export class DaemonCore {
|
|
|
148
157
|
/** The reviewer's working copy; undefined clears it (revert all edits). */
|
|
149
158
|
sessionSetWorkingCopy(id: string, workingCopy: string | undefined): ReviewSession {
|
|
150
159
|
const session = this.mutable(id);
|
|
151
|
-
if (workingCopy === undefined || workingCopy === session.artifact.content)
|
|
160
|
+
if (workingCopy === undefined || workingCopy === session.artifact.content)
|
|
161
|
+
delete session.workingCopy;
|
|
152
162
|
else session.workingCopy = workingCopy;
|
|
153
163
|
this.store.upsert(session);
|
|
154
164
|
this.emit("session.updated", id);
|
|
@@ -190,12 +200,18 @@ export class DaemonCore {
|
|
|
190
200
|
* the authority on their own name). This is how a teammate's name reaches the
|
|
191
201
|
* planner after a pull, alongside their notes.
|
|
192
202
|
*/
|
|
193
|
-
sessionMergeShared(
|
|
203
|
+
sessionMergeShared(
|
|
204
|
+
id: string,
|
|
205
|
+
incoming: { annotations: Annotation[]; participants?: Identity[] },
|
|
206
|
+
): ReviewSession {
|
|
194
207
|
const session = this.mutable(id);
|
|
195
208
|
const known = new Set(session.annotations.map((annotation) => annotation.id));
|
|
196
|
-
for (const annotation of incoming.annotations)
|
|
209
|
+
for (const annotation of incoming.annotations)
|
|
210
|
+
if (!known.has(annotation.id)) session.annotations.push(annotation);
|
|
197
211
|
if (incoming.participants?.length) {
|
|
198
|
-
const registry = new Map(
|
|
212
|
+
const registry = new Map(
|
|
213
|
+
(session.participants ?? []).map((participant) => [participant.id, participant]),
|
|
214
|
+
);
|
|
199
215
|
for (const participant of incoming.participants) registry.set(participant.id, participant);
|
|
200
216
|
session.participants = [...registry.values()];
|
|
201
217
|
}
|
|
@@ -233,7 +249,11 @@ export class DaemonCore {
|
|
|
233
249
|
* Unknown ids are ignored, so a stale id in the agent's list never fails
|
|
234
250
|
* the resubmit.
|
|
235
251
|
*/
|
|
236
|
-
sessionSubmitRevision(
|
|
252
|
+
sessionSubmitRevision(
|
|
253
|
+
id: string,
|
|
254
|
+
content: string,
|
|
255
|
+
addressedAnnotationIds: string[] = [],
|
|
256
|
+
): ReviewSession {
|
|
237
257
|
const session = this.sessionGet(id);
|
|
238
258
|
const now = new Date().toISOString();
|
|
239
259
|
const revisionNumber = session.revisions.length + 1;
|
|
@@ -251,7 +271,10 @@ export class DaemonCore {
|
|
|
251
271
|
if (isAddressed(annotation) || isAgentNote(annotation)) continue;
|
|
252
272
|
if (reportedIds.has(annotation.id)) {
|
|
253
273
|
annotation.resolution = { revision: revisionNumber, source: "agent" };
|
|
254
|
-
} else if (
|
|
274
|
+
} else if (
|
|
275
|
+
revisedBlocks !== null &&
|
|
276
|
+
resolveAnchor(annotation.anchor, revisedBlocks) === null
|
|
277
|
+
) {
|
|
255
278
|
annotation.resolution = { revision: revisionNumber, source: "drift" };
|
|
256
279
|
}
|
|
257
280
|
}
|
|
@@ -264,7 +287,8 @@ export class DaemonCore {
|
|
|
264
287
|
|
|
265
288
|
private mutable(id: string): ReviewSession {
|
|
266
289
|
const session = this.sessionGet(id);
|
|
267
|
-
if (session.status === "resolved")
|
|
290
|
+
if (session.status === "resolved")
|
|
291
|
+
throw new DaemonError("resolved", `session ${id} is already resolved`);
|
|
268
292
|
return session;
|
|
269
293
|
}
|
|
270
294
|
}
|
package/src/client.ts
CHANGED
|
@@ -5,7 +5,14 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { existsSync, rmSync } from "node:fs";
|
|
8
|
-
import type {
|
|
8
|
+
import type {
|
|
9
|
+
Annotation,
|
|
10
|
+
Artifact,
|
|
11
|
+
Identity,
|
|
12
|
+
ReviewSession,
|
|
13
|
+
VerdictKind,
|
|
14
|
+
WorkspaceKey,
|
|
15
|
+
} from "@cueloop/schema";
|
|
9
16
|
import { BackpressureWriter, LineBuffer, type EventFrame, type Response } from "./protocol";
|
|
10
17
|
import { cueloopHome, socketPath } from "./paths";
|
|
11
18
|
|
|
@@ -35,7 +42,10 @@ export interface SessionClient {
|
|
|
35
42
|
sessionSetWorkingCopy(id: string, workingCopy: string | undefined): Promise<ReviewSession>;
|
|
36
43
|
sessionSetViewed(id: string, viewedPaths: string[]): Promise<ReviewSession>;
|
|
37
44
|
sessionSetShareId(id: string, shareId: string): Promise<ReviewSession>;
|
|
38
|
-
sessionMergeShared(
|
|
45
|
+
sessionMergeShared(
|
|
46
|
+
id: string,
|
|
47
|
+
incoming: { annotations: Annotation[]; participants?: Identity[] },
|
|
48
|
+
): Promise<ReviewSession>;
|
|
39
49
|
sessionDelete(id: string): Promise<void>;
|
|
40
50
|
/** Record the caller's own identity name (collaborator self-naming on a share). */
|
|
41
51
|
sessionSetSelfName(id: string, name: string): Promise<ReviewSession>;
|
|
@@ -82,20 +92,20 @@ export class DaemonClient implements SessionClient {
|
|
|
82
92
|
|
|
83
93
|
private async dial(path: string): Promise<void> {
|
|
84
94
|
const buffer = new LineBuffer();
|
|
85
|
-
const self = this;
|
|
86
95
|
this.socket = await Bun.connect({
|
|
87
96
|
unix: path,
|
|
88
97
|
socket: {
|
|
89
|
-
data(_socket, data) {
|
|
90
|
-
buffer.push(data.toString(), (line) =>
|
|
98
|
+
data: (_socket, data) => {
|
|
99
|
+
buffer.push(data.toString(), (line) => this.routeInboundFrame(line));
|
|
91
100
|
},
|
|
92
|
-
drain() {
|
|
93
|
-
|
|
101
|
+
drain: () => {
|
|
102
|
+
this.writer?.drain();
|
|
94
103
|
},
|
|
95
|
-
close() {
|
|
96
|
-
|
|
97
|
-
for (const pendingRequest of
|
|
98
|
-
|
|
104
|
+
close: () => {
|
|
105
|
+
this.closed = true;
|
|
106
|
+
for (const pendingRequest of this.pending.values())
|
|
107
|
+
pendingRequest.reject(new Error("daemon connection closed"));
|
|
108
|
+
this.pending.clear();
|
|
99
109
|
},
|
|
100
110
|
error() {},
|
|
101
111
|
},
|
|
@@ -125,7 +135,8 @@ export class DaemonClient implements SessionClient {
|
|
|
125
135
|
const pendingRequest = this.pending.get(frame.id);
|
|
126
136
|
if (!pendingRequest) return;
|
|
127
137
|
this.pending.delete(frame.id);
|
|
128
|
-
if (frame.error)
|
|
138
|
+
if (frame.error)
|
|
139
|
+
pendingRequest.reject(new DaemonClientError(frame.error.code, frame.error.message));
|
|
129
140
|
else pendingRequest.resolve(frame.result);
|
|
130
141
|
}
|
|
131
142
|
|
|
@@ -191,7 +202,10 @@ export class DaemonClient implements SessionClient {
|
|
|
191
202
|
sessionSetShareId(id: string, shareId: string): Promise<ReviewSession> {
|
|
192
203
|
return this.request("session.setShareId", { id, shareId });
|
|
193
204
|
}
|
|
194
|
-
sessionMergeShared(
|
|
205
|
+
sessionMergeShared(
|
|
206
|
+
id: string,
|
|
207
|
+
incoming: { annotations: Annotation[]; participants?: Identity[] },
|
|
208
|
+
): Promise<ReviewSession> {
|
|
195
209
|
return this.request("session.mergeShared", { id, ...incoming });
|
|
196
210
|
}
|
|
197
211
|
sessionDelete(id: string): Promise<void> {
|
|
@@ -204,7 +218,11 @@ export class DaemonClient implements SessionClient {
|
|
|
204
218
|
sessionResolve(id: string, verdictKind: VerdictKind, summary: string): Promise<ReviewSession> {
|
|
205
219
|
return this.request("session.resolve", { id, verdictKind, summary });
|
|
206
220
|
}
|
|
207
|
-
sessionSubmitRevision(
|
|
221
|
+
sessionSubmitRevision(
|
|
222
|
+
id: string,
|
|
223
|
+
content: string,
|
|
224
|
+
addressedAnnotationIds: string[] = [],
|
|
225
|
+
): Promise<ReviewSession> {
|
|
208
226
|
return this.request("session.submitRevision", { id, content, addressedAnnotationIds });
|
|
209
227
|
}
|
|
210
228
|
shutdown(): Promise<void> {
|
package/src/herdr-pane.test.ts
CHANGED
|
@@ -54,7 +54,12 @@ describe("openHerdrPane", () => {
|
|
|
54
54
|
const stub = makeStub("open");
|
|
55
55
|
|
|
56
56
|
// Act
|
|
57
|
-
const ok = openHerdrPane({
|
|
57
|
+
const ok = openHerdrPane({
|
|
58
|
+
sessionId: "ses_abc",
|
|
59
|
+
cwd: "/repo/work",
|
|
60
|
+
binPath: stub.binPath,
|
|
61
|
+
label: "Rollout Plan",
|
|
62
|
+
});
|
|
58
63
|
|
|
59
64
|
// Assert
|
|
60
65
|
expect(ok).toBeTrue();
|
|
@@ -82,7 +87,10 @@ describe("openHerdrPane", () => {
|
|
|
82
87
|
// Arrange
|
|
83
88
|
const logPath = join(dir, "nopane.log");
|
|
84
89
|
const binPath = join(dir, "nopane.sh");
|
|
85
|
-
writeFileSync(
|
|
90
|
+
writeFileSync(
|
|
91
|
+
binPath,
|
|
92
|
+
`#!/bin/sh\nprintf '%s\\n' "$*" >> "${logPath}"\nprintf '{"result":{}}'\n`,
|
|
93
|
+
);
|
|
86
94
|
chmodSync(binPath, 0o755);
|
|
87
95
|
|
|
88
96
|
// Act
|
package/src/herdr-pane.ts
CHANGED
|
@@ -47,13 +47,18 @@ export interface OpenHerdrPaneOptions {
|
|
|
47
47
|
export function openHerdrPane(options: OpenHerdrPaneOptions): boolean {
|
|
48
48
|
const { sessionId, cwd, binPath, label } = options;
|
|
49
49
|
try {
|
|
50
|
-
const created = Bun.spawnSync(
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
50
|
+
const created = Bun.spawnSync(
|
|
51
|
+
[binPath, "tab", "create", "--cwd", cwd, "--label", label, "--focus"],
|
|
52
|
+
{
|
|
53
|
+
stdout: "pipe",
|
|
54
|
+
stderr: "ignore",
|
|
55
|
+
timeout: HERDR_SPAWN_TIMEOUT_MS,
|
|
56
|
+
},
|
|
57
|
+
);
|
|
55
58
|
if (created.exitCode !== 0) return false;
|
|
56
|
-
const parsed = JSON.parse(created.stdout.toString()) as {
|
|
59
|
+
const parsed = JSON.parse(created.stdout.toString()) as {
|
|
60
|
+
result?: { root_pane?: { pane_id?: string } };
|
|
61
|
+
};
|
|
57
62
|
const paneId = parsed.result?.root_pane?.pane_id;
|
|
58
63
|
if (!paneId) return false;
|
|
59
64
|
// A fresh tab hosts a plain shell, so the review is typed in like a human.
|
package/src/main.ts
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
import { DaemonServer } from "./server";
|
|
4
4
|
|
|
5
|
-
const idleExitMs = process.env.CUELOOP_IDLE_EXIT_MS
|
|
5
|
+
const idleExitMs = process.env.CUELOOP_IDLE_EXIT_MS
|
|
6
|
+
? Number(process.env.CUELOOP_IDLE_EXIT_MS)
|
|
7
|
+
: undefined;
|
|
6
8
|
const server = new DaemonServer({ idleExitMs });
|
|
7
9
|
const path = server.start();
|
|
8
10
|
if (path === null) {
|
package/src/review.test.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { join } from "node:path";
|
|
|
7
7
|
import type { ReviewSession } from "@cueloop/schema";
|
|
8
8
|
import { DaemonServer } from "./server";
|
|
9
9
|
import { DaemonClient } from "./client";
|
|
10
|
-
import { openReview, resolveWorkspace } from "./review";
|
|
10
|
+
import { awaitResolve, openReview, resolveWorkspace } from "./review";
|
|
11
11
|
|
|
12
12
|
const PLAN = "# Rollout Plan\n\nShip it in two stages.\n";
|
|
13
13
|
|
|
@@ -66,7 +66,12 @@ describe("resolveWorkspace", () => {
|
|
|
66
66
|
describe("openReview", () => {
|
|
67
67
|
test("shapes the artifact: agent, derived title, cwd", async () => {
|
|
68
68
|
// Act
|
|
69
|
-
const review = await openReview(client, {
|
|
69
|
+
const review = await openReview(client, {
|
|
70
|
+
type: "plan",
|
|
71
|
+
content: PLAN,
|
|
72
|
+
cwd: home,
|
|
73
|
+
agent: "test-agent",
|
|
74
|
+
});
|
|
70
75
|
|
|
71
76
|
// Assert
|
|
72
77
|
const session = review.session;
|
|
@@ -80,13 +85,22 @@ describe("openReview", () => {
|
|
|
80
85
|
|
|
81
86
|
test("an explicit title wins; diffs never derive one from content", async () => {
|
|
82
87
|
// Act
|
|
83
|
-
const titled = await openReview(client, {
|
|
88
|
+
const titled = await openReview(client, {
|
|
89
|
+
type: "plan",
|
|
90
|
+
content: PLAN,
|
|
91
|
+
cwd: home,
|
|
92
|
+
title: "Custom",
|
|
93
|
+
});
|
|
84
94
|
|
|
85
95
|
// Assert
|
|
86
96
|
expect(titled.session.artifact.meta.title).toBe("Custom");
|
|
87
97
|
|
|
88
98
|
// Act
|
|
89
|
-
const diff = await openReview(client, {
|
|
99
|
+
const diff = await openReview(client, {
|
|
100
|
+
type: "diff",
|
|
101
|
+
content: "# not a heading\n",
|
|
102
|
+
cwd: home,
|
|
103
|
+
});
|
|
90
104
|
|
|
91
105
|
// Assert
|
|
92
106
|
expect(diff.session.artifact.meta.title).toBeUndefined();
|
|
@@ -97,7 +111,12 @@ describe("openReview", () => {
|
|
|
97
111
|
const ws = { repoRoot: "/elsewhere", branch: "feature" };
|
|
98
112
|
|
|
99
113
|
// Act
|
|
100
|
-
const review = await openReview(client, {
|
|
114
|
+
const review = await openReview(client, {
|
|
115
|
+
type: "diff",
|
|
116
|
+
content: "d",
|
|
117
|
+
cwd: home,
|
|
118
|
+
workspace: ws,
|
|
119
|
+
});
|
|
101
120
|
|
|
102
121
|
// Assert
|
|
103
122
|
expect(review.session.workspace).toEqual(ws);
|
|
@@ -105,7 +124,12 @@ describe("openReview", () => {
|
|
|
105
124
|
|
|
106
125
|
test("the same agentSessionId revises instead of opening a new session", async () => {
|
|
107
126
|
// Arrange
|
|
108
|
-
const first = await openReview(client, {
|
|
127
|
+
const first = await openReview(client, {
|
|
128
|
+
type: "plan",
|
|
129
|
+
content: PLAN,
|
|
130
|
+
cwd: home,
|
|
131
|
+
agentSessionId: "agent-1",
|
|
132
|
+
});
|
|
109
133
|
|
|
110
134
|
// Act
|
|
111
135
|
const second = await openReview(client, {
|
|
@@ -121,7 +145,12 @@ describe("openReview", () => {
|
|
|
121
145
|
expect(second.session.status).toBe("pending");
|
|
122
146
|
|
|
123
147
|
// Act
|
|
124
|
-
const other = await openReview(client, {
|
|
148
|
+
const other = await openReview(client, {
|
|
149
|
+
type: "plan",
|
|
150
|
+
content: PLAN,
|
|
151
|
+
cwd: home,
|
|
152
|
+
agentSessionId: "agent-2",
|
|
153
|
+
});
|
|
125
154
|
|
|
126
155
|
// Assert
|
|
127
156
|
expect(other.id).not.toBe(first.id);
|
|
@@ -206,7 +235,8 @@ describe("awaitVerdict: chunked loop (the pi shape)", () => {
|
|
|
206
235
|
|
|
207
236
|
// Assert
|
|
208
237
|
// give the loop a chunk to observe the annotation before resolving
|
|
209
|
-
for (let i = 0; i < 100 && !seen.some((snapshot) => snapshot.annotations.length === 1); i++)
|
|
238
|
+
for (let i = 0; i < 100 && !seen.some((snapshot) => snapshot.annotations.length === 1); i++)
|
|
239
|
+
await Bun.sleep(20);
|
|
210
240
|
expect(seen.some((snapshot) => snapshot.annotations.length === 1)).toBe(true);
|
|
211
241
|
|
|
212
242
|
// Act
|
|
@@ -223,7 +253,11 @@ describe("awaitVerdict: chunked loop (the pi shape)", () => {
|
|
|
223
253
|
// Arrange
|
|
224
254
|
const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
|
|
225
255
|
const controller = new AbortController();
|
|
226
|
-
const waiting = review.awaitVerdict({
|
|
256
|
+
const waiting = review.awaitVerdict({
|
|
257
|
+
timeoutMs: Infinity,
|
|
258
|
+
pollMs: 100,
|
|
259
|
+
signal: controller.signal,
|
|
260
|
+
});
|
|
227
261
|
|
|
228
262
|
// Act
|
|
229
263
|
controller.abort();
|
|
@@ -241,3 +275,88 @@ describe("awaitVerdict: chunked loop (the pi shape)", () => {
|
|
|
241
275
|
expect(await review.awaitVerdict({ timeoutMs: 120, pollMs: 50 })).toBe("pending");
|
|
242
276
|
});
|
|
243
277
|
});
|
|
278
|
+
|
|
279
|
+
describe("awaitResolve: the adapter wake seam (session id only)", () => {
|
|
280
|
+
test("resolves to the outcome when the verdict lands during the wait", async () => {
|
|
281
|
+
// Arrange
|
|
282
|
+
const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
|
|
283
|
+
const waiting = awaitResolve(client, review.id, { pollMs: 100 });
|
|
284
|
+
|
|
285
|
+
// Act
|
|
286
|
+
await client.sessionResolve(review.id, "approve", "Ship it.");
|
|
287
|
+
const verdict = await waiting;
|
|
288
|
+
|
|
289
|
+
// Assert
|
|
290
|
+
expect(verdict).not.toBeNull();
|
|
291
|
+
expect(verdict!.allow).toBe(true);
|
|
292
|
+
expect(verdict!.feedback).toContain("Ship it.");
|
|
293
|
+
expect(verdict!.session.verdict!.kind).toBe("approve");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("returns the stored verdict immediately when the session already resolved", async () => {
|
|
297
|
+
// Arrange - a detached waiter that only attaches after the human decided
|
|
298
|
+
const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
|
|
299
|
+
await client.sessionResolve(review.id, "request_changes", "One stage only.");
|
|
300
|
+
|
|
301
|
+
// Act
|
|
302
|
+
const verdict = await awaitResolve(client, review.id);
|
|
303
|
+
|
|
304
|
+
// Assert
|
|
305
|
+
expect(verdict).not.toBeNull();
|
|
306
|
+
expect(verdict!.allow).toBe(false);
|
|
307
|
+
expect(verdict!.feedback).toContain("One stage only.");
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("returns null when the signal aborts first", async () => {
|
|
311
|
+
// Arrange
|
|
312
|
+
const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
|
|
313
|
+
const controller = new AbortController();
|
|
314
|
+
const waiting = awaitResolve(client, review.id, { pollMs: 100, signal: controller.signal });
|
|
315
|
+
|
|
316
|
+
// Act
|
|
317
|
+
controller.abort();
|
|
318
|
+
|
|
319
|
+
// Assert
|
|
320
|
+
expect(await waiting).toBeNull();
|
|
321
|
+
expect((await client.sessionGet(review.id)).status).toBe("pending");
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
describe("awaitResolve: the held wait keeps the daemon alive", () => {
|
|
326
|
+
test("a pending session does not idle-exit while a waiter is parked, then the verdict lands", async () => {
|
|
327
|
+
// Arrange - a daemon with an aggressive idle timer and its own home
|
|
328
|
+
const idleHome = mkdtempSync(join(tmpdir(), "cueloop-idle-"));
|
|
329
|
+
let idleExits = 0;
|
|
330
|
+
const idleServer = new DaemonServer({
|
|
331
|
+
home: idleHome,
|
|
332
|
+
idleExitMs: 30,
|
|
333
|
+
onIdleExit: () => idleExits++,
|
|
334
|
+
});
|
|
335
|
+
idleServer.start();
|
|
336
|
+
const waiterClient = await DaemonClient.connect({ home: idleHome });
|
|
337
|
+
try {
|
|
338
|
+
const review = await openReview(waiterClient, { type: "plan", content: PLAN, cwd: idleHome });
|
|
339
|
+
const waiting = awaitResolve(waiterClient, review.id, { pollMs: 40 });
|
|
340
|
+
|
|
341
|
+
// Act - wait well past the idle window while the review is still pending
|
|
342
|
+
await Bun.sleep(120);
|
|
343
|
+
|
|
344
|
+
// Assert - the parked waiter and the pending session both keep it alive
|
|
345
|
+
expect(idleExits).toBe(0);
|
|
346
|
+
expect((await waiterClient.ping()).pid).toBe(process.pid);
|
|
347
|
+
|
|
348
|
+
// Act - the human returns a verdict; the parked wait collects it
|
|
349
|
+
const resolver = await DaemonClient.connect({ home: idleHome });
|
|
350
|
+
await resolver.sessionResolve(review.id, "approve", "Good to go.");
|
|
351
|
+
resolver.close();
|
|
352
|
+
const verdict = await waiting;
|
|
353
|
+
|
|
354
|
+
// Assert
|
|
355
|
+
expect(verdict!.allow).toBe(true);
|
|
356
|
+
} finally {
|
|
357
|
+
waiterClient.close();
|
|
358
|
+
idleServer.stop();
|
|
359
|
+
rmSync(idleHome, { recursive: true, force: true });
|
|
360
|
+
}
|
|
361
|
+
}, 15_000);
|
|
362
|
+
});
|
package/src/review.ts
CHANGED
|
@@ -7,7 +7,13 @@
|
|
|
7
7
|
* serializing the decision in its host's contract.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
newAnnotationId,
|
|
12
|
+
type ArtifactType,
|
|
13
|
+
type DiffFileContents,
|
|
14
|
+
type ReviewSession,
|
|
15
|
+
type WorkspaceKey,
|
|
16
|
+
} from "@cueloop/schema";
|
|
11
17
|
import { verdictResponse } from "./api";
|
|
12
18
|
import type { DaemonClient } from "./client";
|
|
13
19
|
|
|
@@ -51,6 +57,11 @@ export interface OpenReviewOptions {
|
|
|
51
57
|
planPath?: string;
|
|
52
58
|
pr?: string;
|
|
53
59
|
herdrPane?: string;
|
|
60
|
+
/**
|
|
61
|
+
* Full file contents per changed file for a working-tree diff, carried onto
|
|
62
|
+
* the artifact so hunk curation produces an exactly applyable patch.
|
|
63
|
+
*/
|
|
64
|
+
files?: DiffFileContents[];
|
|
54
65
|
/** Defaults to the plan's first markdown heading; diffs get no derived title. */
|
|
55
66
|
title?: string;
|
|
56
67
|
/**
|
|
@@ -69,7 +80,11 @@ export interface ReviewNote {
|
|
|
69
80
|
}
|
|
70
81
|
|
|
71
82
|
/** Anchor a note at its file: quote = the path, no context selectors. */
|
|
72
|
-
async function attachNotes(
|
|
83
|
+
async function attachNotes(
|
|
84
|
+
client: DaemonClient,
|
|
85
|
+
sessionId: string,
|
|
86
|
+
notes: ReviewNote[],
|
|
87
|
+
): Promise<void> {
|
|
73
88
|
for (const note of notes) {
|
|
74
89
|
await client.sessionAnnotate(sessionId, {
|
|
75
90
|
id: newAnnotationId(),
|
|
@@ -100,7 +115,10 @@ export interface VerdictOutcome {
|
|
|
100
115
|
/** Sentinel distinguishing an abort from any daemon response. */
|
|
101
116
|
const ABORTED = Symbol("aborted");
|
|
102
117
|
|
|
103
|
-
function raceAbort<T>(
|
|
118
|
+
function raceAbort<T>(
|
|
119
|
+
promise: Promise<T>,
|
|
120
|
+
signal: AbortSignal | undefined,
|
|
121
|
+
): Promise<T | typeof ABORTED> {
|
|
104
122
|
if (!signal) return promise;
|
|
105
123
|
if (signal.aborted) {
|
|
106
124
|
promise.catch(() => {});
|
|
@@ -169,8 +187,44 @@ function outcome(session: ReviewSession): VerdictOutcome {
|
|
|
169
187
|
return { ...verdictResponse(session), session };
|
|
170
188
|
}
|
|
171
189
|
|
|
190
|
+
export interface AwaitResolveOptions {
|
|
191
|
+
/** Long-poll chunk length; the wait re-arms each chunk until resolved or aborted. Default 30s. */
|
|
192
|
+
pollMs?: number;
|
|
193
|
+
/** Abort the wait (the harness session shut down); resolves to null. */
|
|
194
|
+
signal?: AbortSignal;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Park until a review session resolves, then return the verdict outcome; null
|
|
199
|
+
* when the signal aborts first. Where ReviewHandle.awaitVerdict needs the handle
|
|
200
|
+
* that opened the review, this needs only a session id - so a background waiter
|
|
201
|
+
* that woke on a session it did not open (a detached Claude Code / Codex waiter,
|
|
202
|
+
* or pi's session_start listener) can collect the same verdict. This is the
|
|
203
|
+
* wake seam every non-blocking adapter builds on. Loops the daemon long-poll, so
|
|
204
|
+
* a verdict that lands between chunks is never missed and a session already
|
|
205
|
+
* resolved returns on the first chunk. The held connection also keeps the daemon
|
|
206
|
+
* off its idle-exit path for the whole wait.
|
|
207
|
+
*/
|
|
208
|
+
export async function awaitResolve(
|
|
209
|
+
client: DaemonClient,
|
|
210
|
+
sessionId: string,
|
|
211
|
+
options: AwaitResolveOptions = {},
|
|
212
|
+
): Promise<VerdictOutcome | null> {
|
|
213
|
+
const chunkMs = options.pollMs ?? 30_000;
|
|
214
|
+
const { signal } = options;
|
|
215
|
+
for (;;) {
|
|
216
|
+
if (signal?.aborted) return null;
|
|
217
|
+
const resolved = await raceAbort(client.sessionWait(sessionId, chunkMs), signal);
|
|
218
|
+
if (resolved === ABORTED) return null;
|
|
219
|
+
if (resolved !== null) return outcome(resolved);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
172
223
|
/** Open a review session (or revise the agent session's existing one) and hand back the wait surface. */
|
|
173
|
-
export async function openReview(
|
|
224
|
+
export async function openReview(
|
|
225
|
+
client: DaemonClient,
|
|
226
|
+
options: OpenReviewOptions,
|
|
227
|
+
): Promise<ReviewHandle> {
|
|
174
228
|
const cwd = options.cwd ?? process.cwd();
|
|
175
229
|
const workspace = options.workspace ?? (await resolveWorkspace(cwd));
|
|
176
230
|
// Resubmits from the same agent session become revisions, not new sessions.
|
|
@@ -190,6 +244,7 @@ export async function openReview(client: DaemonClient, options: OpenReviewOption
|
|
|
190
244
|
let session = await client.sessionCreate(workspace, {
|
|
191
245
|
type: options.type,
|
|
192
246
|
content: options.content,
|
|
247
|
+
files: options.files,
|
|
193
248
|
meta: {
|
|
194
249
|
agent: options.agent,
|
|
195
250
|
agentSessionId: options.agentSessionId,
|
package/src/server.test.ts
CHANGED
|
@@ -132,8 +132,21 @@ describe("socket round-trip", () => {
|
|
|
132
132
|
// When a pull merges an update to the known id, a new collaborator note, and the collaborator's identity
|
|
133
133
|
const merged = await client.sessionMergeShared(session.id, {
|
|
134
134
|
annotations: [
|
|
135
|
-
{
|
|
136
|
-
|
|
135
|
+
{
|
|
136
|
+
id: "a1",
|
|
137
|
+
kind: "comment",
|
|
138
|
+
anchor,
|
|
139
|
+
body: "should not overwrite",
|
|
140
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
id: "a2",
|
|
144
|
+
kind: "comment",
|
|
145
|
+
anchor,
|
|
146
|
+
body: "theirs",
|
|
147
|
+
author: "SHA256:mate",
|
|
148
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
149
|
+
},
|
|
137
150
|
],
|
|
138
151
|
participants: [{ id: "SHA256:mate", provider: "ssh", name: "Sam" }],
|
|
139
152
|
});
|
|
@@ -141,7 +154,9 @@ describe("socket round-trip", () => {
|
|
|
141
154
|
// Then notes union by id (existing a1 kept), and the participant registry carries the collaborator's name
|
|
142
155
|
expect(merged.annotations.map((annotation) => annotation.id).sort()).toEqual(["a1", "a2"]);
|
|
143
156
|
expect(merged.annotations.find((annotation) => annotation.id === "a1")!.body).toBe("mine");
|
|
144
|
-
expect(merged.annotations.find((annotation) => annotation.id === "a2")!.author).toBe(
|
|
157
|
+
expect(merged.annotations.find((annotation) => annotation.id === "a2")!.author).toBe(
|
|
158
|
+
"SHA256:mate",
|
|
159
|
+
);
|
|
145
160
|
expect(merged.participants).toEqual([{ id: "SHA256:mate", provider: "ssh", name: "Sam" }]);
|
|
146
161
|
});
|
|
147
162
|
|
|
@@ -196,7 +211,8 @@ describe("large payloads survive socket backpressure", () => {
|
|
|
196
211
|
// mid-line because the write return value was ignored, so the client
|
|
197
212
|
// never saw the frame's newline and every later request wedged too.
|
|
198
213
|
// Arrange
|
|
199
|
-
const bigContent =
|
|
214
|
+
const bigContent =
|
|
215
|
+
"# Big plan\n" + "lorem ipsum dolor sit amet, consectetur adipiscing elit\n".repeat(4000);
|
|
200
216
|
|
|
201
217
|
// Act
|
|
202
218
|
const created = await client.sessionCreate(
|
package/src/server.ts
CHANGED
|
@@ -4,7 +4,16 @@
|
|
|
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
19
|
import { isKnownMethod, parseParams } from "./validate";
|
|
@@ -118,20 +127,23 @@ export class DaemonServer {
|
|
|
118
127
|
// safe now: holding the lock means no live daemon owns this home, so any
|
|
119
128
|
// socket file left behind is stale
|
|
120
129
|
if (existsSync(path)) rmSync(path, { force: true });
|
|
121
|
-
|
|
122
|
-
|
|
130
|
+
this.server = Bun.listen<{
|
|
131
|
+
buffer: LineBuffer;
|
|
132
|
+
connection: Connection;
|
|
133
|
+
writer: BackpressureWriter;
|
|
134
|
+
}>({
|
|
123
135
|
unix: path,
|
|
124
136
|
socket: {
|
|
125
|
-
open(socket) {
|
|
137
|
+
open: (socket) => {
|
|
126
138
|
const writer = new BackpressureWriter(socket);
|
|
127
139
|
const connection: Connection = { write: (data) => writer.write(data), subscribed: false };
|
|
128
140
|
socket.data = { buffer: new LineBuffer(), connection, writer };
|
|
129
|
-
|
|
130
|
-
|
|
141
|
+
this.connections.add(connection);
|
|
142
|
+
this.scheduleIdleCheck();
|
|
131
143
|
},
|
|
132
|
-
data(socket, data) {
|
|
144
|
+
data: (socket, data) => {
|
|
133
145
|
socket.data.buffer.push(data.toString(), (line) => {
|
|
134
|
-
void
|
|
146
|
+
void this.respondToRequestLine(socket.data.connection, line);
|
|
135
147
|
});
|
|
136
148
|
},
|
|
137
149
|
drain(socket) {
|
|
@@ -139,9 +151,9 @@ export class DaemonServer {
|
|
|
139
151
|
// a throw here would take the whole daemon down with the socket
|
|
140
152
|
socket.data?.writer.drain();
|
|
141
153
|
},
|
|
142
|
-
close(socket) {
|
|
143
|
-
|
|
144
|
-
|
|
154
|
+
close: (socket) => {
|
|
155
|
+
this.connections.delete(socket.data.connection);
|
|
156
|
+
this.scheduleIdleCheck();
|
|
145
157
|
},
|
|
146
158
|
error() {},
|
|
147
159
|
},
|
|
@@ -186,7 +198,10 @@ export class DaemonServer {
|
|
|
186
198
|
try {
|
|
187
199
|
request = JSON.parse(line) as Request;
|
|
188
200
|
} catch {
|
|
189
|
-
connection.write(
|
|
201
|
+
connection.write(
|
|
202
|
+
JSON.stringify({ id: -1, error: { code: "bad_json", message: "unparseable request" } }) +
|
|
203
|
+
"\n",
|
|
204
|
+
);
|
|
190
205
|
return;
|
|
191
206
|
}
|
|
192
207
|
try {
|
|
@@ -255,7 +270,10 @@ export class DaemonServer {
|
|
|
255
270
|
}
|
|
256
271
|
case "session.mergeShared": {
|
|
257
272
|
const params = parseParams("session.mergeShared", request.params);
|
|
258
|
-
return core.sessionMergeShared(params.id, {
|
|
273
|
+
return core.sessionMergeShared(params.id, {
|
|
274
|
+
annotations: params.annotations,
|
|
275
|
+
participants: params.participants,
|
|
276
|
+
});
|
|
259
277
|
}
|
|
260
278
|
case "session.resolve": {
|
|
261
279
|
const params = parseParams("session.resolve", request.params);
|
package/src/share-blob.ts
CHANGED
|
@@ -34,7 +34,10 @@ export function unpackSessionBlob(bytes: Uint8Array): ReviewSession {
|
|
|
34
34
|
try {
|
|
35
35
|
json = gunzipSync(bytes, { maxOutputLength: MAX_BLOB_BYTES }).toString("utf8");
|
|
36
36
|
} catch (err) {
|
|
37
|
-
throw new Error(
|
|
37
|
+
throw new Error(
|
|
38
|
+
`blob is not valid gzip or exceeds ${MAX_BLOB_BYTES} bytes: ${err instanceof Error ? err.message : String(err)}`,
|
|
39
|
+
{ cause: err },
|
|
40
|
+
);
|
|
38
41
|
}
|
|
39
42
|
let raw: unknown;
|
|
40
43
|
try {
|
package/src/store.ts
CHANGED
|
@@ -49,7 +49,9 @@ export class SessionStore {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
list(): ReviewSession[] {
|
|
52
|
-
return [...this.sessions.values()].sort((left, right) =>
|
|
52
|
+
return [...this.sessions.values()].sort((left, right) =>
|
|
53
|
+
left.createdAt.localeCompare(right.createdAt),
|
|
54
|
+
);
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
upsert(session: ReviewSession): void {
|
package/src/validate.test.ts
CHANGED
|
@@ -77,12 +77,16 @@ describe("parseParams", () => {
|
|
|
77
77
|
test("defaults and clamps the wait timeout", () => {
|
|
78
78
|
expect(parseParams("session.wait", { id: "ses_1" }).timeoutMs).toBe(60_000);
|
|
79
79
|
expect(() => parseParams("session.wait", { id: "ses_1", timeoutMs: -5 })).toThrow();
|
|
80
|
-
expect(() =>
|
|
80
|
+
expect(() =>
|
|
81
|
+
parseParams("session.wait", { id: "ses_1", timeoutMs: 99 * 60 * 60 * 1000 }),
|
|
82
|
+
).toThrow();
|
|
81
83
|
});
|
|
82
84
|
|
|
83
85
|
test("verdict kinds are closed", () => {
|
|
84
86
|
expect(parseParams("session.resolve", { id: "s", verdictKind: "approve" }).summary).toBe("");
|
|
85
|
-
expect(() => parseParams("session.resolve", { id: "s", verdictKind: "lgtm" })).toThrow(
|
|
87
|
+
expect(() => parseParams("session.resolve", { id: "s", verdictKind: "lgtm" })).toThrow(
|
|
88
|
+
/verdictKind/,
|
|
89
|
+
);
|
|
86
90
|
});
|
|
87
91
|
|
|
88
92
|
test("annotation kinds stay open (extension kinds are allowed)", () => {
|
|
@@ -98,12 +102,14 @@ describe("parseParams", () => {
|
|
|
98
102
|
});
|
|
99
103
|
|
|
100
104
|
test("setViewed takes a full path list and rejects non-string entries", () => {
|
|
101
|
-
expect(
|
|
102
|
-
"src/a.ts",
|
|
103
|
-
|
|
104
|
-
]);
|
|
105
|
+
expect(
|
|
106
|
+
parseParams("session.setViewed", { id: "s", viewedPaths: ["src/a.ts", "src/b.ts"] })
|
|
107
|
+
.viewedPaths,
|
|
108
|
+
).toEqual(["src/a.ts", "src/b.ts"]);
|
|
105
109
|
expect(parseParams("session.setViewed", { id: "s", viewedPaths: [] }).viewedPaths).toEqual([]);
|
|
106
|
-
expect(() => parseParams("session.setViewed", { id: "s", viewedPaths: [1] })).toThrow(
|
|
110
|
+
expect(() => parseParams("session.setViewed", { id: "s", viewedPaths: [1] })).toThrow(
|
|
111
|
+
/viewedPaths/,
|
|
112
|
+
);
|
|
107
113
|
expect(() => parseParams("session.setViewed", { id: "s" })).toThrow(/viewedPaths/);
|
|
108
114
|
});
|
|
109
115
|
|
|
@@ -166,7 +172,14 @@ describe("wire pins", () => {
|
|
|
166
172
|
herdrPane: "%7",
|
|
167
173
|
title: "Plan",
|
|
168
174
|
};
|
|
169
|
-
const fullAnchor: Required<Anchor> = {
|
|
175
|
+
const fullAnchor: Required<Anchor> = {
|
|
176
|
+
quote: "q",
|
|
177
|
+
prefix: "p",
|
|
178
|
+
suffix: "s",
|
|
179
|
+
blockIndex: 0,
|
|
180
|
+
start: 0,
|
|
181
|
+
end: 1,
|
|
182
|
+
};
|
|
170
183
|
const fullAnnotation: Required<Annotation> = {
|
|
171
184
|
id: "a1",
|
|
172
185
|
kind: "comment",
|
|
@@ -177,11 +190,26 @@ describe("wire pins", () => {
|
|
|
177
190
|
resolution: { revision: 2, source: "agent" },
|
|
178
191
|
createdAt: "now",
|
|
179
192
|
};
|
|
180
|
-
const fullArtifact: Required<Artifact> = {
|
|
181
|
-
|
|
193
|
+
const fullArtifact: Required<Artifact> = {
|
|
194
|
+
type: "plan",
|
|
195
|
+
content: "# P",
|
|
196
|
+
meta: fullMeta,
|
|
197
|
+
files: [{ path: "src/a.ts", oldContents: "old\n", newContents: "new\n", status: "modified" }],
|
|
198
|
+
};
|
|
199
|
+
const fullVerdict: Required<Verdict> = {
|
|
200
|
+
kind: "approve",
|
|
201
|
+
summary: "",
|
|
202
|
+
feedback: "",
|
|
203
|
+
resolvedAt: "now",
|
|
204
|
+
};
|
|
182
205
|
const fullRevision: Required<Revision> = { revision: 1, content: "# P", submittedAt: "now" };
|
|
183
206
|
const fullWorkspace: Required<WorkspaceKey> = { repoRoot: "/repo", branch: "main" };
|
|
184
|
-
const fullIdentity: Required<Identity> = {
|
|
207
|
+
const fullIdentity: Required<Identity> = {
|
|
208
|
+
id: "SHA256:abc",
|
|
209
|
+
provider: "ssh",
|
|
210
|
+
name: "Al",
|
|
211
|
+
handle: "abc",
|
|
212
|
+
};
|
|
185
213
|
const fullSession: Required<ReviewSession> = {
|
|
186
214
|
schemaVersion: SCHEMA_VERSION,
|
|
187
215
|
id: "ses_1",
|
package/src/validate.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
type Annotation,
|
|
17
17
|
type Artifact,
|
|
18
18
|
type ArtifactMeta,
|
|
19
|
+
type DiffFileContents,
|
|
19
20
|
type Identity,
|
|
20
21
|
type ReviewSession,
|
|
21
22
|
type Revision,
|
|
@@ -50,10 +51,18 @@ export const ArtifactMetaSchema = v.object({
|
|
|
50
51
|
title: v.optional(v.string()),
|
|
51
52
|
} satisfies EntriesOf<ArtifactMeta>);
|
|
52
53
|
|
|
54
|
+
export const DiffFileContentsSchema = v.object({
|
|
55
|
+
path: NonEmpty,
|
|
56
|
+
oldContents: v.string(),
|
|
57
|
+
newContents: v.string(),
|
|
58
|
+
status: v.picklist(["added", "modified", "deleted"]),
|
|
59
|
+
} satisfies EntriesOf<DiffFileContents>);
|
|
60
|
+
|
|
53
61
|
export const ArtifactSchema = v.object({
|
|
54
62
|
type: v.picklist(["plan", "diff"]),
|
|
55
63
|
content: v.string(),
|
|
56
64
|
meta: v.optional(ArtifactMetaSchema, {}),
|
|
65
|
+
files: v.optional(v.array(DiffFileContentsSchema)),
|
|
57
66
|
} satisfies EntriesOf<Artifact>);
|
|
58
67
|
|
|
59
68
|
export const AnchorSchema = v.object({
|
|
@@ -68,7 +77,7 @@ export const AnchorSchema = v.object({
|
|
|
68
77
|
/** Wire annotations arrive without createdAt - the daemon stamps it. */
|
|
69
78
|
export const AnnotationSchema = v.object({
|
|
70
79
|
id: NonEmpty,
|
|
71
|
-
/** Open kind set: built-
|
|
80
|
+
/** Open kind set: the built-in is comment. */
|
|
72
81
|
kind: NonEmpty,
|
|
73
82
|
anchor: AnchorSchema,
|
|
74
83
|
body: v.string(),
|
|
@@ -85,7 +94,10 @@ export const AnnotationSchema = v.object({
|
|
|
85
94
|
const SessionId = NonEmpty;
|
|
86
95
|
|
|
87
96
|
/** A stored annotation: the wire shape plus the daemon-stamped createdAt. */
|
|
88
|
-
export const FullAnnotationSchema = v.object({
|
|
97
|
+
export const FullAnnotationSchema = v.object({
|
|
98
|
+
...AnnotationSchema.entries,
|
|
99
|
+
createdAt: v.string(),
|
|
100
|
+
} satisfies EntriesOf<Annotation>);
|
|
89
101
|
|
|
90
102
|
export const IdentitySchema = v.object({
|
|
91
103
|
id: NonEmpty,
|
|
@@ -108,7 +120,10 @@ export const Params = {
|
|
|
108
120
|
"session.wait": v.object({
|
|
109
121
|
id: SessionId,
|
|
110
122
|
// clamped: a negative or absurd timeout is a client bug, not a daemon one
|
|
111
|
-
timeoutMs: v.optional(
|
|
123
|
+
timeoutMs: v.optional(
|
|
124
|
+
v.pipe(v.number(), v.minValue(0), v.maxValue(24 * 60 * 60 * 1000)),
|
|
125
|
+
60_000,
|
|
126
|
+
),
|
|
112
127
|
}),
|
|
113
128
|
"session.annotate": v.object({ id: SessionId, annotation: AnnotationSchema }),
|
|
114
129
|
"session.removeAnnotation": v.object({ id: SessionId, annotationId: NonEmpty }),
|
|
@@ -144,12 +159,18 @@ export function isKnownMethod(method: string): method is MethodName {
|
|
|
144
159
|
}
|
|
145
160
|
|
|
146
161
|
/** Validate params for a method, or throw a DaemonError the client can read. */
|
|
147
|
-
export function parseParams<M extends MethodName>(
|
|
162
|
+
export function parseParams<M extends MethodName>(
|
|
163
|
+
method: M,
|
|
164
|
+
params: unknown,
|
|
165
|
+
): v.InferOutput<(typeof Params)[M]> {
|
|
148
166
|
const result = v.safeParse(Params[method], params ?? {});
|
|
149
167
|
if (!result.success) {
|
|
150
168
|
const issue = result.issues[0]!;
|
|
151
169
|
const path = issue.path?.map((pathSegment) => String(pathSegment.key)).join(".") ?? "";
|
|
152
|
-
throw new DaemonError(
|
|
170
|
+
throw new DaemonError(
|
|
171
|
+
"invalid_params",
|
|
172
|
+
`${method}: ${path ? path + " - " : ""}${issue.message}`,
|
|
173
|
+
);
|
|
153
174
|
}
|
|
154
175
|
return result.output;
|
|
155
176
|
}
|
|
@@ -185,7 +206,9 @@ export const SessionRecordSchema = v.object({
|
|
|
185
206
|
participants: v.optional(v.array(IdentitySchema)),
|
|
186
207
|
} satisfies EntriesOf<ReviewSession>);
|
|
187
208
|
|
|
188
|
-
export function validateSessionRecord(
|
|
209
|
+
export function validateSessionRecord(
|
|
210
|
+
raw: unknown,
|
|
211
|
+
): { ok: true; value: v.InferOutput<typeof SessionRecordSchema> } | { ok: false; error: string } {
|
|
189
212
|
const result = v.safeParse(SessionRecordSchema, raw);
|
|
190
213
|
if (result.success) return { ok: true, value: result.output };
|
|
191
214
|
const issue = result.issues[0]!;
|