@cueloop/daemon 0.1.0-alpha.21 → 0.1.0-alpha.23
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 +4 -3
- package/src/api.test.ts +165 -0
- package/src/api.ts +31 -3
- package/src/client.ts +24 -3
- package/src/herdr-pane.test.ts +35 -11
- package/src/herdr-pane.ts +5 -4
- package/src/review.test.ts +65 -6
- package/src/server.test.ts +31 -0
- package/src/server.ts +1 -1
- package/src/share-blob.test.ts +62 -0
- package/src/share-blob.ts +48 -0
- package/src/single-instance.test.ts +17 -0
- package/src/validate.test.ts +19 -0
- package/src/validate.ts +13 -1
package/package.json
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/daemon",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.23",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": "./src/index.ts",
|
|
8
8
|
"./client": "./src/client.ts",
|
|
9
9
|
"./review": "./src/review.ts",
|
|
10
|
-
"./herdr-pane": "./src/herdr-pane.ts"
|
|
10
|
+
"./herdr-pane": "./src/herdr-pane.ts",
|
|
11
|
+
"./share-blob": "./src/share-blob.ts"
|
|
11
12
|
},
|
|
12
13
|
"dependencies": {
|
|
13
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
14
|
+
"@cueloop/schema": "0.1.0-alpha.23",
|
|
14
15
|
"valibot": "^1.4.2"
|
|
15
16
|
},
|
|
16
17
|
"publishConfig": {
|
package/src/api.test.ts
CHANGED
|
@@ -24,7 +24,10 @@ afterEach(() => rmSync(home, { recursive: true, force: true }));
|
|
|
24
24
|
|
|
25
25
|
describe("session lifecycle", () => {
|
|
26
26
|
test("create → get → list", () => {
|
|
27
|
+
// Act
|
|
27
28
|
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
29
|
+
|
|
30
|
+
// Assert
|
|
28
31
|
expect(session.status).toBe("pending");
|
|
29
32
|
expect(session.revisions.length).toBe(1);
|
|
30
33
|
expect(core.sessionGet(session.id).id).toBe(session.id);
|
|
@@ -32,7 +35,10 @@ describe("session lifecycle", () => {
|
|
|
32
35
|
});
|
|
33
36
|
|
|
34
37
|
test("annotate upserts by id; remove deletes", () => {
|
|
38
|
+
// Arrange
|
|
35
39
|
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
40
|
+
|
|
41
|
+
// Act
|
|
36
42
|
core.sessionAnnotate(session.id, {
|
|
37
43
|
id: "a1",
|
|
38
44
|
kind: "comment",
|
|
@@ -45,13 +51,20 @@ describe("session lifecycle", () => {
|
|
|
45
51
|
anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
|
|
46
52
|
body: "Define carefully.",
|
|
47
53
|
});
|
|
54
|
+
|
|
55
|
+
// Assert
|
|
48
56
|
expect(core.sessionGet(session.id).annotations.length).toBe(1);
|
|
49
57
|
expect(core.sessionGet(session.id).annotations[0]!.body).toBe("Define carefully.");
|
|
58
|
+
|
|
59
|
+
// Act
|
|
50
60
|
core.sessionRemoveAnnotation(session.id, "a1");
|
|
61
|
+
|
|
62
|
+
// Assert
|
|
51
63
|
expect(core.sessionGet(session.id).annotations.length).toBe(0);
|
|
52
64
|
});
|
|
53
65
|
|
|
54
66
|
test("resolve produces feedback.md and maps to the agent contract", () => {
|
|
67
|
+
// Arrange
|
|
55
68
|
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
56
69
|
core.sessionAnnotate(session.id, {
|
|
57
70
|
id: "a1",
|
|
@@ -59,50 +72,90 @@ describe("session lifecycle", () => {
|
|
|
59
72
|
anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
|
|
60
73
|
body: "Spell out the steps.",
|
|
61
74
|
});
|
|
75
|
+
|
|
76
|
+
// Act
|
|
62
77
|
const resolved = core.sessionResolve(session.id, "request_changes", "Needs detail.");
|
|
78
|
+
|
|
79
|
+
// Assert
|
|
63
80
|
expect(resolved.verdict!.feedback).toContain("# Review: request changes");
|
|
64
81
|
expect(resolved.verdict!.feedback).toContain("> carefully");
|
|
65
82
|
expect(verdictResponse(resolved)).toEqual({ allow: false, feedback: resolved.verdict!.feedback });
|
|
83
|
+
|
|
84
|
+
// Arrange
|
|
66
85
|
const approved = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
86
|
+
|
|
87
|
+
// Act
|
|
67
88
|
core.sessionResolve(approved.id, "approve", "LGTM");
|
|
89
|
+
|
|
90
|
+
// Assert
|
|
68
91
|
expect(verdictResponse(core.sessionGet(approved.id)).allow).toBe(true);
|
|
69
92
|
});
|
|
70
93
|
|
|
71
94
|
test("mutating a resolved session throws", () => {
|
|
95
|
+
// Arrange
|
|
72
96
|
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
73
97
|
core.sessionResolve(session.id, "approve", "");
|
|
98
|
+
|
|
99
|
+
// Assert
|
|
74
100
|
expect(() =>
|
|
75
101
|
core.sessionAnnotate(session.id, { id: "a1", kind: "comment", anchor: { quote: "x", prefix: "", suffix: "" }, body: "b" }),
|
|
76
102
|
).toThrow("already resolved");
|
|
77
103
|
});
|
|
78
104
|
|
|
79
105
|
test("working copy stores edits and clears on revert or no-op", () => {
|
|
106
|
+
// Arrange
|
|
80
107
|
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
108
|
+
|
|
109
|
+
// Act
|
|
81
110
|
core.sessionSetWorkingCopy(session.id, PLAN.content.replace("carefully", "very carefully"));
|
|
111
|
+
|
|
112
|
+
// Assert
|
|
82
113
|
expect(core.sessionGet(session.id).workingCopy).toContain("very carefully");
|
|
114
|
+
|
|
115
|
+
// Act
|
|
83
116
|
core.sessionSetWorkingCopy(session.id, PLAN.content);
|
|
117
|
+
|
|
118
|
+
// Assert
|
|
84
119
|
expect(core.sessionGet(session.id).workingCopy).toBeUndefined();
|
|
85
120
|
});
|
|
86
121
|
|
|
87
122
|
test("viewed paths merge, dedupe, clear on empty, and survive a daemon restart", () => {
|
|
123
|
+
// Arrange
|
|
88
124
|
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
125
|
+
|
|
126
|
+
// Act
|
|
89
127
|
core.sessionSetViewed(session.id, ["src/a.ts", "src/b.ts", "src/a.ts"]);
|
|
128
|
+
|
|
129
|
+
// Assert
|
|
90
130
|
expect(core.sessionGet(session.id).viewedPaths).toEqual(["src/a.ts", "src/b.ts"]);
|
|
131
|
+
|
|
132
|
+
// Act
|
|
91
133
|
// merge-additive: a stale client sending only its own new mark loses nothing
|
|
92
134
|
core.sessionSetViewed(session.id, ["src/c.ts"]);
|
|
135
|
+
|
|
136
|
+
// Assert
|
|
93
137
|
expect(core.sessionGet(session.id).viewedPaths).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]);
|
|
94
138
|
// a resumed review reads its progress back after a daemon restart
|
|
95
139
|
const reborn = new DaemonCore(home);
|
|
96
140
|
expect(reborn.sessionGet(session.id).viewedPaths).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]);
|
|
141
|
+
|
|
142
|
+
// Act
|
|
97
143
|
core.sessionSetViewed(session.id, []);
|
|
144
|
+
|
|
145
|
+
// Assert
|
|
98
146
|
expect(core.sessionGet(session.id).viewedPaths).toBeUndefined();
|
|
99
147
|
});
|
|
100
148
|
|
|
101
149
|
test("revision reopens the session and resets working copy + verdict", () => {
|
|
150
|
+
// Arrange
|
|
102
151
|
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
103
152
|
core.sessionSetWorkingCopy(session.id, PLAN.content + "\nedit");
|
|
104
153
|
core.sessionResolve(session.id, "request_changes", "redo");
|
|
154
|
+
|
|
155
|
+
// Act
|
|
105
156
|
const revised = core.sessionSubmitRevision(session.id, "# Plan v2\n");
|
|
157
|
+
|
|
158
|
+
// Assert
|
|
106
159
|
expect(revised.status).toBe("pending");
|
|
107
160
|
expect(revised.verdict).toBeNull();
|
|
108
161
|
expect(revised.workingCopy).toBeUndefined();
|
|
@@ -111,47 +164,154 @@ describe("session lifecycle", () => {
|
|
|
111
164
|
});
|
|
112
165
|
});
|
|
113
166
|
|
|
167
|
+
describe("revision marks addressed annotations", () => {
|
|
168
|
+
const annotate = (sessionId: string, id: string, quote: string, body = "note") =>
|
|
169
|
+
core.sessionAnnotate(sessionId, { id, kind: "comment", anchor: { quote, prefix: "", suffix: "" }, body });
|
|
170
|
+
|
|
171
|
+
test("ids the agent reports are marked addressed by that revision", () => {
|
|
172
|
+
// Arrange
|
|
173
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
174
|
+
annotate(session.id, "a1", "carefully");
|
|
175
|
+
annotate(session.id, "a2", "Context");
|
|
176
|
+
|
|
177
|
+
// Act: the revision keeps both quoted texts, so only the reported id resolves
|
|
178
|
+
const revised = core.sessionSubmitRevision(session.id, PLAN.content + "\nMore.\n", ["a1"]);
|
|
179
|
+
|
|
180
|
+
// Assert
|
|
181
|
+
const [first, second] = revised.annotations;
|
|
182
|
+
expect(first!.resolution).toEqual({ revision: 2, source: "agent" });
|
|
183
|
+
expect(second!.resolution).toBeUndefined();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("a plan annotation whose quoted text vanished is drift-addressed; a surviving quote stays open", () => {
|
|
187
|
+
// Arrange
|
|
188
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
189
|
+
annotate(session.id, "gone", "carefully");
|
|
190
|
+
annotate(session.id, "kept", "Context");
|
|
191
|
+
|
|
192
|
+
// Act: the revision rewrites the "carefully" sentence but keeps the Context heading
|
|
193
|
+
const revised = core.sessionSubmitRevision(session.id, "# Plan\n\n## Context\n\nDo the thing with tests.\n");
|
|
194
|
+
|
|
195
|
+
// Assert
|
|
196
|
+
expect(revised.annotations.find((a) => a.id === "gone")!.resolution).toEqual({ revision: 2, source: "drift" });
|
|
197
|
+
expect(revised.annotations.find((a) => a.id === "kept")!.resolution).toBeUndefined();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("an unknown reported id is ignored, and an already-addressed annotation keeps its first resolution", () => {
|
|
201
|
+
// Arrange
|
|
202
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
203
|
+
annotate(session.id, "a1", "carefully");
|
|
204
|
+
core.sessionSubmitRevision(session.id, PLAN.content, ["a1"]);
|
|
205
|
+
|
|
206
|
+
// Act: a second revision reports a stale id and re-reports the settled one
|
|
207
|
+
const revised = core.sessionSubmitRevision(session.id, PLAN.content, ["a1", "a_never_existed"]);
|
|
208
|
+
|
|
209
|
+
// Assert
|
|
210
|
+
expect(revised.annotations[0]!.resolution).toEqual({ revision: 2, source: "agent" });
|
|
211
|
+
expect(revised.revisions.length).toBe(3);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("a diff revision never drift-addresses - a new patch says nothing about the feedback", () => {
|
|
215
|
+
// Arrange
|
|
216
|
+
const diffArtifact: Artifact = { type: "diff", content: "+++ b/a.ts\n+new line\n", meta: {} };
|
|
217
|
+
const session = core.sessionCreate({ workspace: WS, artifact: diffArtifact });
|
|
218
|
+
annotate(session.id, "d1", "new line");
|
|
219
|
+
|
|
220
|
+
// Act: the revised patch no longer contains the quoted text
|
|
221
|
+
const revised = core.sessionSubmitRevision(session.id, "+++ b/b.ts\n+other\n");
|
|
222
|
+
|
|
223
|
+
// Assert
|
|
224
|
+
expect(revised.annotations[0]!.resolution).toBeUndefined();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("the next feedback document omits addressed annotations and teaches the addressed-ids call", () => {
|
|
228
|
+
// Arrange
|
|
229
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
230
|
+
annotate(session.id, "settled", "carefully");
|
|
231
|
+
annotate(session.id, "open", "Context", "needs a diagram");
|
|
232
|
+
core.sessionSubmitRevision(session.id, PLAN.content, ["settled"]);
|
|
233
|
+
|
|
234
|
+
// Act
|
|
235
|
+
const resolved = core.sessionResolve(session.id, "request_changes", "one left");
|
|
236
|
+
|
|
237
|
+
// Assert
|
|
238
|
+
expect(resolved.verdict!.feedback).not.toContain("settled");
|
|
239
|
+
expect(resolved.verdict!.feedback).toContain("annotation id: `open`");
|
|
240
|
+
expect(resolved.verdict!.feedback).toContain(`submit-revision ${session.id}`);
|
|
241
|
+
expect(resolved.verdict!.feedback).toContain("--addressed");
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
114
245
|
describe("the wait contract: verdicts outlive waits", () => {
|
|
115
246
|
test("wait resolves when the verdict arrives", async () => {
|
|
247
|
+
// Arrange
|
|
116
248
|
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
117
249
|
const wait = core.sessionWait(session.id, 5_000);
|
|
250
|
+
|
|
251
|
+
// Act
|
|
118
252
|
core.sessionResolve(session.id, "approve", "");
|
|
119
253
|
const resolved = await wait;
|
|
254
|
+
|
|
255
|
+
// Assert
|
|
120
256
|
expect(resolved!.verdict!.kind).toBe("approve");
|
|
121
257
|
});
|
|
122
258
|
|
|
123
259
|
test("wait times out to null; a later wait collects the stored verdict", async () => {
|
|
260
|
+
// Arrange
|
|
124
261
|
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
262
|
+
|
|
263
|
+
// Act
|
|
125
264
|
const first = await core.sessionWait(session.id, 30);
|
|
265
|
+
|
|
266
|
+
// Assert
|
|
126
267
|
expect(first).toBeNull();
|
|
268
|
+
|
|
269
|
+
// Act
|
|
127
270
|
core.sessionResolve(session.id, "request_changes", "later");
|
|
128
271
|
const second = await core.sessionWait(session.id, 30);
|
|
272
|
+
|
|
273
|
+
// Assert
|
|
129
274
|
expect(second!.verdict!.summary).toBe("later");
|
|
130
275
|
});
|
|
131
276
|
|
|
132
277
|
test("multiple waiters all resolve", async () => {
|
|
278
|
+
// Arrange
|
|
133
279
|
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
134
280
|
const waits = [core.sessionWait(session.id, 5_000), core.sessionWait(session.id, 5_000)];
|
|
281
|
+
|
|
282
|
+
// Act
|
|
135
283
|
core.sessionResolve(session.id, "approve", "");
|
|
136
284
|
const results = await Promise.all(waits);
|
|
285
|
+
|
|
286
|
+
// Assert
|
|
137
287
|
expect(results.every((result) => result?.status === "resolved")).toBe(true);
|
|
138
288
|
});
|
|
139
289
|
});
|
|
140
290
|
|
|
141
291
|
describe("persistence and recovery", () => {
|
|
142
292
|
test("sessions survive a daemon restart", () => {
|
|
293
|
+
// Arrange
|
|
143
294
|
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
144
295
|
core.sessionResolve(session.id, "approve", "done");
|
|
296
|
+
|
|
297
|
+
// Act
|
|
145
298
|
const reborn = new DaemonCore(home);
|
|
146
299
|
const recovered = reborn.sessionGet(session.id);
|
|
300
|
+
|
|
301
|
+
// Assert
|
|
147
302
|
expect(recovered.verdict!.summary).toBe("done");
|
|
148
303
|
});
|
|
149
304
|
|
|
150
305
|
test("corrupt records are skipped, not fatal, and never deleted", async () => {
|
|
306
|
+
// Arrange
|
|
151
307
|
core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
152
308
|
await Bun.write(join(home, "sessions", "broken.json"), "{ not json");
|
|
153
309
|
const store = new SessionStore(home);
|
|
310
|
+
|
|
311
|
+
// Act
|
|
154
312
|
const report = store.recover();
|
|
313
|
+
|
|
314
|
+
// Assert
|
|
155
315
|
expect(report.recovered.length).toBe(1);
|
|
156
316
|
expect(report.skipped.length).toBe(1);
|
|
157
317
|
expect(await Bun.file(join(home, "sessions", "broken.json")).exists()).toBe(true);
|
|
@@ -160,11 +320,16 @@ describe("persistence and recovery", () => {
|
|
|
160
320
|
|
|
161
321
|
describe("events", () => {
|
|
162
322
|
test("lifecycle events fire in order", () => {
|
|
323
|
+
// Arrange
|
|
163
324
|
const seen: string[] = [];
|
|
164
325
|
core.onEvent((event) => seen.push(event.event));
|
|
326
|
+
|
|
327
|
+
// Act
|
|
165
328
|
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
166
329
|
core.sessionAnnotate(session.id, { id: "a1", kind: "comment", anchor: { quote: "thing", prefix: "", suffix: "" }, body: "b" });
|
|
167
330
|
core.sessionResolve(session.id, "approve", "");
|
|
331
|
+
|
|
332
|
+
// Assert
|
|
168
333
|
expect(seen).toEqual(["session.created", "inbox.changed", "session.updated", "session.resolved", "inbox.changed"]);
|
|
169
334
|
});
|
|
170
335
|
});
|
package/src/api.ts
CHANGED
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
import {
|
|
9
9
|
SCHEMA_VERSION,
|
|
10
10
|
feedbackForSession,
|
|
11
|
+
isAddressed,
|
|
12
|
+
isAgentNote,
|
|
13
|
+
parseBlocks,
|
|
14
|
+
resolveAnchor,
|
|
11
15
|
verdictAllows,
|
|
12
16
|
type Annotation,
|
|
13
17
|
type Artifact,
|
|
@@ -183,15 +187,39 @@ export class DaemonCore {
|
|
|
183
187
|
return session;
|
|
184
188
|
}
|
|
185
189
|
|
|
186
|
-
/**
|
|
187
|
-
|
|
190
|
+
/**
|
|
191
|
+
* Agent resubmits: new revision becomes the artifact, session reopens.
|
|
192
|
+
* Annotations the agent reports as acted on (by id) are marked addressed,
|
|
193
|
+
* and for plan revisions any still-open annotation whose quoted text no
|
|
194
|
+
* longer resolves is marked addressed too ("drift" - the line it pointed at
|
|
195
|
+
* was rewritten). Addressed is a marker, never a delete: the reviewer's
|
|
196
|
+
* rail hides them behind a count, and the next feedback document omits them.
|
|
197
|
+
* Unknown ids are ignored, so a stale id in the agent's list never fails
|
|
198
|
+
* the resubmit.
|
|
199
|
+
*/
|
|
200
|
+
sessionSubmitRevision(id: string, content: string, addressedAnnotationIds: string[] = []): ReviewSession {
|
|
188
201
|
const session = this.sessionGet(id);
|
|
189
202
|
const now = new Date().toISOString();
|
|
190
|
-
|
|
203
|
+
const revisionNumber = session.revisions.length + 1;
|
|
204
|
+
session.revisions.push({ revision: revisionNumber, content, submittedAt: now });
|
|
191
205
|
session.artifact = { ...session.artifact, content };
|
|
192
206
|
delete session.workingCopy;
|
|
193
207
|
session.verdict = null;
|
|
194
208
|
session.status = "pending";
|
|
209
|
+
|
|
210
|
+
const reportedIds = new Set(addressedAnnotationIds);
|
|
211
|
+
// drift assist applies to plans only: a diff revision is a whole new
|
|
212
|
+
// patch, where a vanished quote says nothing about the feedback
|
|
213
|
+
const revisedBlocks = session.artifact.type === "plan" ? parseBlocks(content) : null;
|
|
214
|
+
for (const annotation of session.annotations) {
|
|
215
|
+
if (isAddressed(annotation) || isAgentNote(annotation)) continue;
|
|
216
|
+
if (reportedIds.has(annotation.id)) {
|
|
217
|
+
annotation.resolution = { revision: revisionNumber, source: "agent" };
|
|
218
|
+
} else if (revisedBlocks !== null && resolveAnchor(annotation.anchor, revisedBlocks) === null) {
|
|
219
|
+
annotation.resolution = { revision: revisionNumber, source: "drift" };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
195
223
|
this.store.upsert(session);
|
|
196
224
|
this.emit("session.revised", id);
|
|
197
225
|
this.emit("inbox.changed", id);
|
package/src/client.ts
CHANGED
|
@@ -9,6 +9,8 @@ import type { Annotation, Artifact, ReviewSession, VerdictKind, WorkspaceKey } f
|
|
|
9
9
|
import { BackpressureWriter, LineBuffer, type EventFrame, type Response } from "./protocol";
|
|
10
10
|
import { cueloopHome, socketPath } from "./paths";
|
|
11
11
|
|
|
12
|
+
export type { EventFrame } from "./protocol";
|
|
13
|
+
|
|
12
14
|
export interface ConnectOptions {
|
|
13
15
|
home?: string;
|
|
14
16
|
/** Spawn the daemon when the socket is not alive. */
|
|
@@ -17,7 +19,26 @@ export interface ConnectOptions {
|
|
|
17
19
|
|
|
18
20
|
type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void };
|
|
19
21
|
|
|
20
|
-
|
|
22
|
+
/**
|
|
23
|
+
* The session verbs the review controller drives. DaemonClient is the local
|
|
24
|
+
* implementation (unix socket); the sharing gateway supplies an in-memory,
|
|
25
|
+
* blob-backed one. Depending on this interface - not DaemonClient - is what
|
|
26
|
+
* lets the same <App> render a local session or a decrypted share unchanged.
|
|
27
|
+
*/
|
|
28
|
+
export interface SessionClient {
|
|
29
|
+
onEvent(listener: (event: EventFrame) => void): () => void;
|
|
30
|
+
subscribe(): Promise<void>;
|
|
31
|
+
sessionGet(id: string): Promise<ReviewSession>;
|
|
32
|
+
sessionList(filter?: { status?: "pending" | "resolved" }): Promise<ReviewSession[]>;
|
|
33
|
+
sessionAnnotate(id: string, annotation: Omit<Annotation, "createdAt">): Promise<ReviewSession>;
|
|
34
|
+
sessionRemoveAnnotation(id: string, annotationId: string): Promise<ReviewSession>;
|
|
35
|
+
sessionSetWorkingCopy(id: string, workingCopy: string | undefined): Promise<ReviewSession>;
|
|
36
|
+
sessionSetViewed(id: string, viewedPaths: string[]): Promise<ReviewSession>;
|
|
37
|
+
sessionResolve(id: string, verdictKind: VerdictKind, summary: string): Promise<ReviewSession>;
|
|
38
|
+
close(): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class DaemonClient implements SessionClient {
|
|
21
42
|
private socket: Awaited<ReturnType<typeof Bun.connect>> | null = null;
|
|
22
43
|
private writer: BackpressureWriter | null = null;
|
|
23
44
|
private pending = new Map<number, PendingRequest>();
|
|
@@ -165,8 +186,8 @@ export class DaemonClient {
|
|
|
165
186
|
sessionResolve(id: string, verdictKind: VerdictKind, summary: string): Promise<ReviewSession> {
|
|
166
187
|
return this.request("session.resolve", { id, verdictKind, summary });
|
|
167
188
|
}
|
|
168
|
-
sessionSubmitRevision(id: string, content: string): Promise<ReviewSession> {
|
|
169
|
-
return this.request("session.submitRevision", { id, content });
|
|
189
|
+
sessionSubmitRevision(id: string, content: string, addressedAnnotationIds: string[] = []): Promise<ReviewSession> {
|
|
190
|
+
return this.request("session.submitRevision", { id, content, addressedAnnotationIds });
|
|
170
191
|
}
|
|
171
192
|
shutdown(): Promise<void> {
|
|
172
193
|
return this.request("daemon.shutdown", {});
|
package/src/herdr-pane.test.ts
CHANGED
|
@@ -1,11 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Creation-side herdr pane opener tests. The herdr binary is stubbed by a
|
|
3
|
-
* script that both records its argv to a log file and, for `tab create`,
|
|
4
|
-
* prints the JSON the helper reads to learn the new pane id. The gating
|
|
5
|
-
* wrapper is exercised for its three outcomes: no-op outside herdr, no-op for
|
|
6
|
-
* a revision (the open pane is reused), and a full open for a genuinely new
|
|
7
|
-
* session.
|
|
8
|
-
*/
|
|
1
|
+
/** Creation-side herdr pane opener: a stubbed herdr binary logs its argv, and the gating wrapper is checked for its three outcomes (no-op outside herdr, no-op on revision, full open for a new session). */
|
|
9
2
|
|
|
10
3
|
import { afterAll, describe, expect, test } from "bun:test";
|
|
11
4
|
import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
@@ -18,15 +11,18 @@ const dir = mkdtempSync(join(tmpdir(), "cueloop-herdr-pane-"));
|
|
|
18
11
|
|
|
19
12
|
/**
|
|
20
13
|
* A stub herdr binary. It appends its argv to logPath and, when the verb is
|
|
21
|
-
* `tab create`, prints a JSON result carrying
|
|
22
|
-
* can drive send-text and send-keys.
|
|
14
|
+
* `tab create`, prints a JSON result carrying the pane id on stdout so the
|
|
15
|
+
* helper can drive send-text and send-keys. The printed shape mirrors the
|
|
16
|
+
* REAL herdr `tab create` output ({ result: { root_pane: { pane_id } } },
|
|
17
|
+
* verified against herdr 0.8.0) - an earlier stub invented a different shape
|
|
18
|
+
* and let a parser that never matched real herdr pass the suite.
|
|
23
19
|
*/
|
|
24
20
|
function makeStub(name: string, paneId = "w1:p2"): { binPath: string; logPath: string } {
|
|
25
21
|
const logPath = join(dir, `${name}.log`);
|
|
26
22
|
const binPath = join(dir, `${name}.sh`);
|
|
27
23
|
writeFileSync(
|
|
28
24
|
binPath,
|
|
29
|
-
`#!/bin/sh\nprintf '%s\\n' "$*" >> "${logPath}"\nif [ "$1" = "tab" ] && [ "$2" = "create" ]; then\n printf '{"result":{"
|
|
25
|
+
`#!/bin/sh\nprintf '%s\\n' "$*" >> "${logPath}"\nif [ "$1" = "tab" ] && [ "$2" = "create" ]; then\n printf '{"result":{"root_pane":{"pane_id":"${paneId}"}}}'\nfi\n`,
|
|
30
26
|
);
|
|
31
27
|
chmodSync(binPath, 0o755);
|
|
32
28
|
return { binPath, logPath };
|
|
@@ -54,8 +50,13 @@ function newSession(overrides: Partial<ReviewSession> = {}): ReviewSession {
|
|
|
54
50
|
|
|
55
51
|
describe("openHerdrPane", () => {
|
|
56
52
|
test("creates a focused tab, types the cueloop command, and presses enter", () => {
|
|
53
|
+
// Arrange
|
|
57
54
|
const stub = makeStub("open");
|
|
55
|
+
|
|
56
|
+
// Act
|
|
58
57
|
const ok = openHerdrPane({ sessionId: "ses_abc", cwd: "/repo/work", binPath: stub.binPath, label: "Rollout Plan" });
|
|
58
|
+
|
|
59
|
+
// Assert
|
|
59
60
|
expect(ok).toBeTrue();
|
|
60
61
|
expect(readLines(stub.logPath)).toEqual([
|
|
61
62
|
"tab create --cwd /repo/work --label Rollout Plan --focus",
|
|
@@ -65,21 +66,29 @@ describe("openHerdrPane", () => {
|
|
|
65
66
|
});
|
|
66
67
|
|
|
67
68
|
test("returns false and never throws on a broken binary", () => {
|
|
69
|
+
// Act
|
|
68
70
|
const ok = openHerdrPane({
|
|
69
71
|
sessionId: "ses_abc",
|
|
70
72
|
cwd: "/repo/work",
|
|
71
73
|
binPath: join(dir, "missing-bin"),
|
|
72
74
|
label: "x",
|
|
73
75
|
});
|
|
76
|
+
|
|
77
|
+
// Assert
|
|
74
78
|
expect(ok).toBeFalse();
|
|
75
79
|
});
|
|
76
80
|
|
|
77
81
|
test("bails out when tab create yields no pane id - no send-text, no send-keys", () => {
|
|
82
|
+
// Arrange
|
|
78
83
|
const logPath = join(dir, "nopane.log");
|
|
79
84
|
const binPath = join(dir, "nopane.sh");
|
|
80
85
|
writeFileSync(binPath, `#!/bin/sh\nprintf '%s\\n' "$*" >> "${logPath}"\nprintf '{"result":{}}'\n`);
|
|
81
86
|
chmodSync(binPath, 0o755);
|
|
87
|
+
|
|
88
|
+
// Act
|
|
82
89
|
const ok = openHerdrPane({ sessionId: "ses_abc", cwd: "/repo/work", binPath, label: "x" });
|
|
90
|
+
|
|
91
|
+
// Assert
|
|
83
92
|
expect(ok).toBeFalse();
|
|
84
93
|
expect(readLines(logPath)).toEqual(["tab create --cwd /repo/work --label x --focus"]);
|
|
85
94
|
});
|
|
@@ -87,9 +96,14 @@ describe("openHerdrPane", () => {
|
|
|
87
96
|
|
|
88
97
|
describe("openHerdrPaneForReview", () => {
|
|
89
98
|
test("opens a pane for a genuinely new session inside herdr", () => {
|
|
99
|
+
// Arrange
|
|
90
100
|
const stub = makeStub("gated-new");
|
|
91
101
|
const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
|
|
102
|
+
|
|
103
|
+
// Act
|
|
92
104
|
openHerdrPaneForReview(newSession({ id: "ses_xyz" }), env);
|
|
105
|
+
|
|
106
|
+
// Assert
|
|
93
107
|
expect(readLines(stub.logPath)).toEqual([
|
|
94
108
|
"tab create --cwd /repo/work --label Rollout Plan --focus",
|
|
95
109
|
"pane send-text w1:p2 cueloop ses_xyz",
|
|
@@ -98,12 +112,18 @@ describe("openHerdrPaneForReview", () => {
|
|
|
98
112
|
});
|
|
99
113
|
|
|
100
114
|
test("no-op outside herdr - no herdr process is spawned", () => {
|
|
115
|
+
// Arrange
|
|
101
116
|
const stub = makeStub("gated-outside");
|
|
117
|
+
|
|
118
|
+
// Act
|
|
102
119
|
openHerdrPaneForReview(newSession(), { HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath });
|
|
120
|
+
|
|
121
|
+
// Assert
|
|
103
122
|
expect(existsSync(stub.logPath)).toBeFalse();
|
|
104
123
|
});
|
|
105
124
|
|
|
106
125
|
test("no-op for a revision - the already open pane is reused, not spammed", () => {
|
|
126
|
+
// Arrange
|
|
107
127
|
const stub = makeStub("gated-revision");
|
|
108
128
|
const env = { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1", HERDR_BIN_PATH: stub.binPath };
|
|
109
129
|
const revised = newSession({
|
|
@@ -112,7 +132,11 @@ describe("openHerdrPaneForReview", () => {
|
|
|
112
132
|
{ revision: 2, content: "# P2", submittedAt: "now" },
|
|
113
133
|
],
|
|
114
134
|
});
|
|
135
|
+
|
|
136
|
+
// Act
|
|
115
137
|
openHerdrPaneForReview(revised, env);
|
|
138
|
+
|
|
139
|
+
// Assert
|
|
116
140
|
expect(existsSync(stub.logPath)).toBeFalse();
|
|
117
141
|
});
|
|
118
142
|
});
|
package/src/herdr-pane.ts
CHANGED
|
@@ -38,8 +38,9 @@ export interface OpenHerdrPaneOptions {
|
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
40
|
* Open a herdr tab that renders the review. `tab create` prints a JSON result
|
|
41
|
-
* shaped like `{ result: {
|
|
42
|
-
*
|
|
41
|
+
* shaped like `{ result: { root_pane: { pane_id } } }` (verified against
|
|
42
|
+
* herdr 0.8.0; `pane get` uses a different `result.pane` nesting, which is
|
|
43
|
+
* what focusHerdrPane reads). The pane id drives the two follow-up steps.
|
|
43
44
|
* Returns true when the full open-and-launch sequence completed, false on any
|
|
44
45
|
* best-effort bail-out.
|
|
45
46
|
*/
|
|
@@ -52,8 +53,8 @@ export function openHerdrPane(options: OpenHerdrPaneOptions): boolean {
|
|
|
52
53
|
timeout: HERDR_SPAWN_TIMEOUT_MS,
|
|
53
54
|
});
|
|
54
55
|
if (created.exitCode !== 0) return false;
|
|
55
|
-
const parsed = JSON.parse(created.stdout.toString()) as { result?: {
|
|
56
|
-
const paneId = parsed.result?.
|
|
56
|
+
const parsed = JSON.parse(created.stdout.toString()) as { result?: { root_pane?: { pane_id?: string } } };
|
|
57
|
+
const paneId = parsed.result?.root_pane?.pane_id;
|
|
57
58
|
if (!paneId) return false;
|
|
58
59
|
// A fresh tab hosts a plain shell, so the review is typed in like a human.
|
|
59
60
|
const typed = Bun.spawnSync([binPath, "pane", "send-text", paneId, `cueloop ${sessionId}`], {
|
package/src/review.test.ts
CHANGED
|
@@ -1,9 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The shared review core against a real DaemonServer in a temp home:
|
|
3
|
-
* workspace resolution, title derivation, open-or-revise by agentSessionId,
|
|
4
|
-
* and both awaitVerdict shapes (one long-poll and the chunked loop with
|
|
5
|
-
* progress and abort).
|
|
6
|
-
*/
|
|
1
|
+
/** The shared review core against a real DaemonServer in a temp home: workspace resolution, title derivation, open-or-revise by agentSessionId, and both awaitVerdict shapes (one long-poll and the chunked loop with progress and abort). */
|
|
7
2
|
|
|
8
3
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
9
4
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
@@ -41,11 +36,16 @@ describe("resolveWorkspace", () => {
|
|
|
41
36
|
test("a git repo resolves to its root and branch", async () => {
|
|
42
37
|
const repo = mkdtempSync(join(tmpdir(), "cueloop-ws-"));
|
|
43
38
|
try {
|
|
39
|
+
// Arrange
|
|
44
40
|
sh(["git", "init", "-q", "-b", "main"], repo);
|
|
45
41
|
sh(["git", "config", "user.email", "t@t"], repo);
|
|
46
42
|
sh(["git", "config", "user.name", "t"], repo);
|
|
47
43
|
sh(["git", "commit", "-q", "--allow-empty", "-m", "init"], repo);
|
|
44
|
+
|
|
45
|
+
// Act
|
|
48
46
|
const ws = await resolveWorkspace(repo);
|
|
47
|
+
|
|
48
|
+
// Assert
|
|
49
49
|
expect(ws.branch).toBe("main");
|
|
50
50
|
// macOS tmpdirs can resolve through /private; compare the tail
|
|
51
51
|
expect(ws.repoRoot.endsWith(repo.split("/").at(-1)!)).toBe(true);
|
|
@@ -55,14 +55,20 @@ describe("resolveWorkspace", () => {
|
|
|
55
55
|
});
|
|
56
56
|
|
|
57
57
|
test("outside a repo the cwd itself is the workspace, branch detached", async () => {
|
|
58
|
+
// Act
|
|
58
59
|
const ws = await resolveWorkspace(home);
|
|
60
|
+
|
|
61
|
+
// Assert
|
|
59
62
|
expect(ws).toEqual({ repoRoot: home, branch: "detached" });
|
|
60
63
|
});
|
|
61
64
|
});
|
|
62
65
|
|
|
63
66
|
describe("openReview", () => {
|
|
64
67
|
test("shapes the artifact: agent, derived title, cwd", async () => {
|
|
68
|
+
// Act
|
|
65
69
|
const review = await openReview(client, { type: "plan", content: PLAN, cwd: home, agent: "test-agent" });
|
|
70
|
+
|
|
71
|
+
// Assert
|
|
66
72
|
const session = review.session;
|
|
67
73
|
expect(session.status).toBe("pending");
|
|
68
74
|
expect(session.artifact.type).toBe("plan");
|
|
@@ -73,34 +79,56 @@ describe("openReview", () => {
|
|
|
73
79
|
});
|
|
74
80
|
|
|
75
81
|
test("an explicit title wins; diffs never derive one from content", async () => {
|
|
82
|
+
// Act
|
|
76
83
|
const titled = await openReview(client, { type: "plan", content: PLAN, cwd: home, title: "Custom" });
|
|
84
|
+
|
|
85
|
+
// Assert
|
|
77
86
|
expect(titled.session.artifact.meta.title).toBe("Custom");
|
|
87
|
+
|
|
88
|
+
// Act
|
|
78
89
|
const diff = await openReview(client, { type: "diff", content: "# not a heading\n", cwd: home });
|
|
90
|
+
|
|
91
|
+
// Assert
|
|
79
92
|
expect(diff.session.artifact.meta.title).toBeUndefined();
|
|
80
93
|
});
|
|
81
94
|
|
|
82
95
|
test("a pre-resolved workspace skips git resolution", async () => {
|
|
96
|
+
// Arrange
|
|
83
97
|
const ws = { repoRoot: "/elsewhere", branch: "feature" };
|
|
98
|
+
|
|
99
|
+
// Act
|
|
84
100
|
const review = await openReview(client, { type: "diff", content: "d", cwd: home, workspace: ws });
|
|
101
|
+
|
|
102
|
+
// Assert
|
|
85
103
|
expect(review.session.workspace).toEqual(ws);
|
|
86
104
|
});
|
|
87
105
|
|
|
88
106
|
test("the same agentSessionId revises instead of opening a new session", async () => {
|
|
107
|
+
// Arrange
|
|
89
108
|
const first = await openReview(client, { type: "plan", content: PLAN, cwd: home, agentSessionId: "agent-1" });
|
|
109
|
+
|
|
110
|
+
// Act
|
|
90
111
|
const second = await openReview(client, {
|
|
91
112
|
type: "plan",
|
|
92
113
|
content: PLAN + "\nStage three.\n",
|
|
93
114
|
cwd: home,
|
|
94
115
|
agentSessionId: "agent-1",
|
|
95
116
|
});
|
|
117
|
+
|
|
118
|
+
// Assert
|
|
96
119
|
expect(second.id).toBe(first.id);
|
|
97
120
|
expect(second.session.revisions.length).toBe(2);
|
|
98
121
|
expect(second.session.status).toBe("pending");
|
|
122
|
+
|
|
123
|
+
// Act
|
|
99
124
|
const other = await openReview(client, { type: "plan", content: PLAN, cwd: home, agentSessionId: "agent-2" });
|
|
125
|
+
|
|
126
|
+
// Assert
|
|
100
127
|
expect(other.id).not.toBe(first.id);
|
|
101
128
|
});
|
|
102
129
|
|
|
103
130
|
test("per-file notes land as note annotations anchored at the file path", async () => {
|
|
131
|
+
// Act
|
|
104
132
|
const review = await openReview(client, {
|
|
105
133
|
type: "diff",
|
|
106
134
|
content: "d",
|
|
@@ -110,6 +138,8 @@ describe("openReview", () => {
|
|
|
110
138
|
{ path: "src/b.ts", body: "Swaps the line." },
|
|
111
139
|
],
|
|
112
140
|
});
|
|
141
|
+
|
|
142
|
+
// Assert
|
|
113
143
|
const notes = review.session.annotations;
|
|
114
144
|
expect(notes.length).toBe(2);
|
|
115
145
|
expect(notes.every((annotation) => annotation.kind === "note")).toBe(true);
|
|
@@ -120,10 +150,17 @@ describe("openReview", () => {
|
|
|
120
150
|
|
|
121
151
|
describe("awaitVerdict: one long-poll (the hook shape)", () => {
|
|
122
152
|
test("times out to pending; a later wait collects the stored verdict", async () => {
|
|
153
|
+
// Arrange
|
|
123
154
|
const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
|
|
155
|
+
|
|
156
|
+
// Assert
|
|
124
157
|
expect(await review.awaitVerdict({ timeoutMs: 50 })).toBe("pending");
|
|
158
|
+
|
|
159
|
+
// Act
|
|
125
160
|
await client.sessionResolve(review.id, "approve", "Fine.");
|
|
126
161
|
const verdict = await review.awaitVerdict({ timeoutMs: 1_000 });
|
|
162
|
+
|
|
163
|
+
// Assert
|
|
127
164
|
expect(verdict).not.toBe("pending");
|
|
128
165
|
if (verdict === "pending") throw new Error("unreachable");
|
|
129
166
|
expect(verdict.allow).toBe(true);
|
|
@@ -132,10 +169,15 @@ describe("awaitVerdict: one long-poll (the hook shape)", () => {
|
|
|
132
169
|
});
|
|
133
170
|
|
|
134
171
|
test("request_changes maps to allow=false with feedback.md attached", async () => {
|
|
172
|
+
// Arrange
|
|
135
173
|
const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
|
|
136
174
|
const waiting = review.awaitVerdict({ timeoutMs: 5_000 });
|
|
175
|
+
|
|
176
|
+
// Act
|
|
137
177
|
await client.sessionResolve(review.id, "request_changes", "One stage only.");
|
|
138
178
|
const verdict = await waiting;
|
|
179
|
+
|
|
180
|
+
// Assert
|
|
139
181
|
if (verdict === "pending") throw new Error("expected a verdict");
|
|
140
182
|
expect(verdict.allow).toBe(false);
|
|
141
183
|
expect(verdict.feedback).toContain("# Review: request changes");
|
|
@@ -145,6 +187,7 @@ describe("awaitVerdict: one long-poll (the hook shape)", () => {
|
|
|
145
187
|
|
|
146
188
|
describe("awaitVerdict: chunked loop (the pi shape)", () => {
|
|
147
189
|
test("onProgress sees fresh sessions between chunks; the verdict ends the loop", async () => {
|
|
190
|
+
// Arrange
|
|
148
191
|
const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
|
|
149
192
|
const seen: ReviewSession[] = [];
|
|
150
193
|
const waiting = review.awaitVerdict({
|
|
@@ -152,33 +195,49 @@ describe("awaitVerdict: chunked loop (the pi shape)", () => {
|
|
|
152
195
|
pollMs: 100,
|
|
153
196
|
onProgress: (progress) => seen.push(progress),
|
|
154
197
|
});
|
|
198
|
+
|
|
199
|
+
// Act
|
|
155
200
|
await client.sessionAnnotate(review.id, {
|
|
156
201
|
id: "a1",
|
|
157
202
|
kind: "comment",
|
|
158
203
|
anchor: { quote: "two stages", prefix: "in ", suffix: "." },
|
|
159
204
|
body: "Name them.",
|
|
160
205
|
});
|
|
206
|
+
|
|
207
|
+
// Assert
|
|
161
208
|
// give the loop a chunk to observe the annotation before resolving
|
|
162
209
|
for (let i = 0; i < 100 && !seen.some((snapshot) => snapshot.annotations.length === 1); i++) await Bun.sleep(20);
|
|
163
210
|
expect(seen.some((snapshot) => snapshot.annotations.length === 1)).toBe(true);
|
|
211
|
+
|
|
212
|
+
// Act
|
|
164
213
|
await client.sessionResolve(review.id, "request_changes", "Too vague.");
|
|
165
214
|
const verdict = await waiting;
|
|
215
|
+
|
|
216
|
+
// Assert
|
|
166
217
|
if (verdict === "pending") throw new Error("expected a verdict");
|
|
167
218
|
expect(verdict.allow).toBe(false);
|
|
168
219
|
expect(verdict.session.annotations.length).toBe(1);
|
|
169
220
|
}, 15_000);
|
|
170
221
|
|
|
171
222
|
test("abort surfaces as pending and leaves the session collectable", async () => {
|
|
223
|
+
// Arrange
|
|
172
224
|
const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
|
|
173
225
|
const controller = new AbortController();
|
|
174
226
|
const waiting = review.awaitVerdict({ timeoutMs: Infinity, pollMs: 100, signal: controller.signal });
|
|
227
|
+
|
|
228
|
+
// Act
|
|
175
229
|
controller.abort();
|
|
230
|
+
|
|
231
|
+
// Assert
|
|
176
232
|
expect(await waiting).toBe("pending");
|
|
177
233
|
expect((await client.sessionGet(review.id)).status).toBe("pending");
|
|
178
234
|
});
|
|
179
235
|
|
|
180
236
|
test("a finite budget runs out to pending", async () => {
|
|
237
|
+
// Arrange
|
|
181
238
|
const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
|
|
239
|
+
|
|
240
|
+
// Assert
|
|
182
241
|
expect(await review.awaitVerdict({ timeoutMs: 120, pollMs: 50 })).toBe("pending");
|
|
183
242
|
});
|
|
184
243
|
});
|
package/src/server.test.ts
CHANGED
|
@@ -27,9 +27,16 @@ afterEach(() => {
|
|
|
27
27
|
|
|
28
28
|
describe("socket round-trip", () => {
|
|
29
29
|
test("ping and full session flow over the wire", async () => {
|
|
30
|
+
// Assert
|
|
30
31
|
expect((await client.ping()).pid).toBe(process.pid);
|
|
32
|
+
|
|
33
|
+
// Act
|
|
31
34
|
const session = await client.sessionCreate(WS, PLAN);
|
|
35
|
+
|
|
36
|
+
// Assert
|
|
32
37
|
expect(session.status).toBe("pending");
|
|
38
|
+
|
|
39
|
+
// Act
|
|
33
40
|
await client.sessionAnnotate(session.id, {
|
|
34
41
|
id: "a1",
|
|
35
42
|
kind: "comment",
|
|
@@ -39,6 +46,8 @@ describe("socket round-trip", () => {
|
|
|
39
46
|
const wait = client.sessionWait(session.id, 5_000);
|
|
40
47
|
await client.sessionResolve(session.id, "request_changes", "Expand it.");
|
|
41
48
|
const resolved = (await wait)!;
|
|
49
|
+
|
|
50
|
+
// Assert
|
|
42
51
|
expect(resolved.verdict!.kind).toBe("request_changes");
|
|
43
52
|
expect(resolved.verdict!.feedback).toContain("More detail please.");
|
|
44
53
|
});
|
|
@@ -53,12 +62,17 @@ describe("socket round-trip", () => {
|
|
|
53
62
|
});
|
|
54
63
|
|
|
55
64
|
test("events push to subscribed connections only", async () => {
|
|
65
|
+
// Arrange
|
|
56
66
|
const observer = await DaemonClient.connect({ home });
|
|
57
67
|
const seen: string[] = [];
|
|
58
68
|
observer.onEvent((event) => seen.push(event.event));
|
|
59
69
|
await observer.subscribe();
|
|
70
|
+
|
|
71
|
+
// Act
|
|
60
72
|
const session = await client.sessionCreate(WS, PLAN);
|
|
61
73
|
await client.sessionResolve(session.id, "approve", "");
|
|
74
|
+
|
|
75
|
+
// Assert
|
|
62
76
|
// events are pushed async over the socket; give the loop a beat
|
|
63
77
|
await Bun.sleep(50);
|
|
64
78
|
expect(seen).toContain("session.created");
|
|
@@ -94,20 +108,28 @@ describe("socket round-trip", () => {
|
|
|
94
108
|
});
|
|
95
109
|
|
|
96
110
|
test("meta fields survive the wire: herdrPane set on create comes back from get", async () => {
|
|
111
|
+
// Act
|
|
97
112
|
const session = await client.sessionCreate(WS, {
|
|
98
113
|
type: "plan",
|
|
99
114
|
content: "# P",
|
|
100
115
|
meta: { agent: "claude-code", herdrPane: "%7" },
|
|
101
116
|
});
|
|
102
117
|
const got = await client.sessionGet(session.id);
|
|
118
|
+
|
|
119
|
+
// Assert
|
|
103
120
|
expect(got.artifact.meta.herdrPane).toBe("%7");
|
|
104
121
|
expect(got.artifact.meta.agent).toBe("claude-code");
|
|
105
122
|
});
|
|
106
123
|
|
|
107
124
|
test("two clients see the same state (thin-renderer model)", async () => {
|
|
125
|
+
// Arrange
|
|
108
126
|
const second = await DaemonClient.connect({ home });
|
|
127
|
+
|
|
128
|
+
// Act
|
|
109
129
|
const session = await client.sessionCreate(WS, PLAN);
|
|
110
130
|
const fromSecond = await second.sessionGet(session.id);
|
|
131
|
+
|
|
132
|
+
// Assert
|
|
111
133
|
expect(fromSecond.artifact.content).toBe(PLAN.content);
|
|
112
134
|
second.close();
|
|
113
135
|
});
|
|
@@ -118,15 +140,24 @@ describe("large payloads survive socket backpressure", () => {
|
|
|
118
140
|
// Regression: responses larger than one socket write used to truncate
|
|
119
141
|
// mid-line because the write return value was ignored, so the client
|
|
120
142
|
// never saw the frame's newline and every later request wedged too.
|
|
143
|
+
// Arrange
|
|
121
144
|
const bigContent = "# Big plan\n" + "lorem ipsum dolor sit amet, consectetur adipiscing elit\n".repeat(4000);
|
|
145
|
+
|
|
146
|
+
// Act
|
|
122
147
|
const created = await client.sessionCreate(
|
|
123
148
|
{ repoRoot: "/repo", branch: "main" },
|
|
124
149
|
{ type: "plan", content: bigContent, meta: { title: "big" } },
|
|
125
150
|
);
|
|
126
151
|
const fetched = await client.sessionGet(created.id);
|
|
152
|
+
|
|
153
|
+
// Assert
|
|
127
154
|
expect(fetched.artifact.content).toBe(bigContent);
|
|
155
|
+
|
|
156
|
+
// Act
|
|
128
157
|
// the connection must still be usable for the next frame
|
|
129
158
|
const listed = await client.sessionList();
|
|
159
|
+
|
|
160
|
+
// Assert
|
|
130
161
|
expect(listed.some((session) => session.id === created.id)).toBe(true);
|
|
131
162
|
}, 15_000);
|
|
132
163
|
});
|
package/src/server.ts
CHANGED
|
@@ -251,7 +251,7 @@ export class DaemonServer {
|
|
|
251
251
|
}
|
|
252
252
|
case "session.submitRevision": {
|
|
253
253
|
const params = parseParams("session.submitRevision", request.params);
|
|
254
|
-
return core.sessionSubmitRevision(params.id, params.content);
|
|
254
|
+
return core.sessionSubmitRevision(params.id, params.content, params.addressedAnnotationIds);
|
|
255
255
|
}
|
|
256
256
|
default:
|
|
257
257
|
throw new DaemonError("unknown_method", `unknown method ${request.method}`);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { gzipSync } from "node:zlib";
|
|
3
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import type { Artifact, WorkspaceKey } from "@cueloop/schema";
|
|
7
|
+
import { DaemonCore } from "./api";
|
|
8
|
+
import { MAX_BLOB_BYTES, packSessionBlob, unpackSessionBlob } from "./share-blob";
|
|
9
|
+
|
|
10
|
+
const WS: WorkspaceKey = { repoRoot: "/repo", branch: "main" };
|
|
11
|
+
const PLAN: Artifact = { type: "plan", content: "# Plan\n\nDo the thing.\n", meta: {} };
|
|
12
|
+
|
|
13
|
+
let home: string;
|
|
14
|
+
let core: DaemonCore;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-blob-"));
|
|
18
|
+
core = new DaemonCore(home);
|
|
19
|
+
});
|
|
20
|
+
afterEach(() => rmSync(home, { recursive: true, force: true }));
|
|
21
|
+
|
|
22
|
+
describe("share blob roundtrip", () => {
|
|
23
|
+
test("pack then unpack restores the session with its annotations", () => {
|
|
24
|
+
// Arrange
|
|
25
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
26
|
+
const annotated = core.sessionAnnotate(session.id, {
|
|
27
|
+
id: "a_1",
|
|
28
|
+
kind: "comment",
|
|
29
|
+
anchor: { quote: "the thing", prefix: "", suffix: "" },
|
|
30
|
+
body: "why this?",
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Act
|
|
34
|
+
const restored = unpackSessionBlob(packSessionBlob(annotated));
|
|
35
|
+
|
|
36
|
+
// Assert
|
|
37
|
+
expect(restored).toEqual(annotated);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("unpack rejects bad input", () => {
|
|
42
|
+
test("caps the decompressed size, so a compression bomb throws", () => {
|
|
43
|
+
// Arrange: tiny gzip, huge inflation - the decompression-bomb shape
|
|
44
|
+
const bomb = gzipSync(Buffer.alloc(MAX_BLOB_BYTES + 1, 0x61));
|
|
45
|
+
|
|
46
|
+
// Act / Assert
|
|
47
|
+
expect(() => unpackSessionBlob(bomb)).toThrow(/exceeds|maxOutputLength/i);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("throws on bytes that are not gzip", () => {
|
|
51
|
+
// Act / Assert
|
|
52
|
+
expect(() => unpackSessionBlob(Buffer.from("not gzip at all"))).toThrow(/gzip/i);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("throws on a shape that is not a session", () => {
|
|
56
|
+
// Arrange
|
|
57
|
+
const notASession = gzipSync(Buffer.from(JSON.stringify({ hello: "world" })));
|
|
58
|
+
|
|
59
|
+
// Act / Assert
|
|
60
|
+
expect(() => unpackSessionBlob(notASession)).toThrow(/not a valid session/i);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The share-blob wire format: how a ReviewSession travels from `cueloop share`
|
|
3
|
+
* to the gateway. The planner's CLI packs (this is the only thing it can do -
|
|
4
|
+
* it holds no encryption key); the gateway unpacks, then encrypts for R2.
|
|
5
|
+
*
|
|
6
|
+
* Format is gzip(JSON). Unpack is the untrusted boundary - the bytes come off
|
|
7
|
+
* an SSH channel from anyone who can reach the `share` user - so it caps the
|
|
8
|
+
* decompressed size (a decompression-bomb guard) and runs the same record
|
|
9
|
+
* validator the daemon store uses, never trusting the shape.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { gzipSync, gunzipSync } from "node:zlib";
|
|
13
|
+
import type { ReviewSession } from "@cueloop/schema";
|
|
14
|
+
import { validateSessionRecord } from "./validate";
|
|
15
|
+
|
|
16
|
+
/** Decompressed ceiling for one shared session (ADR 0004's payload cap). */
|
|
17
|
+
export const MAX_BLOB_BYTES = 1024 * 1024;
|
|
18
|
+
|
|
19
|
+
/** The SSH username `cueloop share` connects as to upload; the gateway routes on it. */
|
|
20
|
+
export const SHARE_UPLOAD_USER = "share";
|
|
21
|
+
|
|
22
|
+
/** Where shares live, in one place: the client uploads here, the gateway prints it. */
|
|
23
|
+
export const DEFAULT_SHARE_HOST = "cueloop.dev";
|
|
24
|
+
export const DEFAULT_SHARE_PORT = 22;
|
|
25
|
+
|
|
26
|
+
/** Serialise + compress a session for upload. */
|
|
27
|
+
export function packSessionBlob(session: ReviewSession): Buffer {
|
|
28
|
+
return gzipSync(Buffer.from(JSON.stringify(session), "utf8"));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Decompress + validate an uploaded blob, or throw a precise reason. */
|
|
32
|
+
export function unpackSessionBlob(bytes: Uint8Array): ReviewSession {
|
|
33
|
+
let json: string;
|
|
34
|
+
try {
|
|
35
|
+
json = gunzipSync(bytes, { maxOutputLength: MAX_BLOB_BYTES }).toString("utf8");
|
|
36
|
+
} catch (err) {
|
|
37
|
+
throw new Error(`blob is not valid gzip or exceeds ${MAX_BLOB_BYTES} bytes: ${err instanceof Error ? err.message : String(err)}`);
|
|
38
|
+
}
|
|
39
|
+
let raw: unknown;
|
|
40
|
+
try {
|
|
41
|
+
raw = JSON.parse(json);
|
|
42
|
+
} catch {
|
|
43
|
+
throw new Error("blob is not valid JSON");
|
|
44
|
+
}
|
|
45
|
+
const parsed = validateSessionRecord(raw);
|
|
46
|
+
if (!parsed.ok) throw new Error(`blob is not a valid session: ${parsed.error}`);
|
|
47
|
+
return parsed.value as ReviewSession;
|
|
48
|
+
}
|
|
@@ -35,21 +35,27 @@ function server(): DaemonServer {
|
|
|
35
35
|
|
|
36
36
|
describe("one daemon per home", () => {
|
|
37
37
|
test("the second start is refused instead of stealing the socket", () => {
|
|
38
|
+
// Arrange
|
|
38
39
|
const first = server();
|
|
39
40
|
const path = first.start();
|
|
40
41
|
expect(path).toBe(socketPath(home));
|
|
41
42
|
const second = server();
|
|
43
|
+
|
|
44
|
+
// Assert
|
|
42
45
|
expect(second.start()).toBeNull();
|
|
43
46
|
// the winner's socket survives untouched
|
|
44
47
|
expect(existsSync(path!)).toBe(true);
|
|
45
48
|
});
|
|
46
49
|
|
|
47
50
|
test("clients keep talking to the one live daemon after a refused start", async () => {
|
|
51
|
+
// Arrange
|
|
48
52
|
const first = server();
|
|
49
53
|
first.start();
|
|
50
54
|
const created = first.core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
51
55
|
server().start(); // refused
|
|
52
56
|
const client = await DaemonClient.connect({ home });
|
|
57
|
+
|
|
58
|
+
// Assert
|
|
53
59
|
try {
|
|
54
60
|
// no split brain: the session created on the winner is visible
|
|
55
61
|
expect((await client.sessionGet(created.id)).id).toBe(created.id);
|
|
@@ -60,29 +66,40 @@ describe("one daemon per home", () => {
|
|
|
60
66
|
});
|
|
61
67
|
|
|
62
68
|
test("a stale lock from a crashed daemon is reclaimed", () => {
|
|
69
|
+
// Arrange
|
|
63
70
|
// a pid that cannot be running (init is never a cueloop daemon, and this
|
|
64
71
|
// simulates the record a crashed process leaves behind)
|
|
65
72
|
writeFileSync(lockPath(home), "999999");
|
|
66
73
|
const daemonServer = server();
|
|
74
|
+
|
|
75
|
+
// Assert
|
|
67
76
|
expect(daemonServer.start()).toBe(socketPath(home));
|
|
68
77
|
});
|
|
69
78
|
|
|
70
79
|
test("stopping releases the lock so a restart works", () => {
|
|
80
|
+
// Arrange
|
|
71
81
|
const first = server();
|
|
72
82
|
first.start();
|
|
83
|
+
|
|
84
|
+
// Act
|
|
73
85
|
first.stop();
|
|
86
|
+
|
|
87
|
+
// Assert
|
|
74
88
|
expect(existsSync(lockPath(home))).toBe(false);
|
|
75
89
|
const second = server();
|
|
76
90
|
expect(second.start()).toBe(socketPath(home));
|
|
77
91
|
});
|
|
78
92
|
|
|
79
93
|
test("concurrent autostarts converge on a single daemon", async () => {
|
|
94
|
+
// Act
|
|
80
95
|
// several clients race to autostart; all must end up on the same daemon
|
|
81
96
|
const clients = await Promise.all([
|
|
82
97
|
DaemonClient.connect({ home, autostart: true }),
|
|
83
98
|
DaemonClient.connect({ home, autostart: true }),
|
|
84
99
|
DaemonClient.connect({ home, autostart: true }),
|
|
85
100
|
]);
|
|
101
|
+
|
|
102
|
+
// Assert
|
|
86
103
|
try {
|
|
87
104
|
const pids = await Promise.all(clients.map((client) => client.ping()));
|
|
88
105
|
const unique = new Set(pids.map((ping) => ping.pid));
|
package/src/validate.test.ts
CHANGED
|
@@ -38,10 +38,13 @@ describe("method allowlist", () => {
|
|
|
38
38
|
|
|
39
39
|
describe("parseParams", () => {
|
|
40
40
|
test("accepts a well-formed create and defaults meta", () => {
|
|
41
|
+
// Act
|
|
41
42
|
const params = parseParams("session.create", {
|
|
42
43
|
workspace: { repoRoot: "/repo", branch: "main" },
|
|
43
44
|
artifact: { type: "plan", content: "# P" },
|
|
44
45
|
});
|
|
46
|
+
|
|
47
|
+
// Assert
|
|
45
48
|
expect(params.artifact.meta).toEqual({});
|
|
46
49
|
});
|
|
47
50
|
|
|
@@ -81,10 +84,13 @@ describe("parseParams", () => {
|
|
|
81
84
|
});
|
|
82
85
|
|
|
83
86
|
test("annotation kinds stay open (extension kinds are allowed)", () => {
|
|
87
|
+
// Act
|
|
84
88
|
const params = parseParams("session.annotate", {
|
|
85
89
|
id: "s",
|
|
86
90
|
annotation: { id: "a1", kind: "praise", anchor: { quote: "x" }, body: "nice" },
|
|
87
91
|
});
|
|
92
|
+
|
|
93
|
+
// Assert
|
|
88
94
|
expect(params.annotation.kind).toBe("praise");
|
|
89
95
|
expect(params.annotation.anchor.prefix).toBe("");
|
|
90
96
|
});
|
|
@@ -122,13 +128,19 @@ describe("validateSessionRecord", () => {
|
|
|
122
128
|
});
|
|
123
129
|
|
|
124
130
|
test("rejects a foreign schema version with a readable reason", () => {
|
|
131
|
+
// Act
|
|
125
132
|
const result = validateSessionRecord({ ...record, schemaVersion: "99" });
|
|
133
|
+
|
|
134
|
+
// Assert
|
|
126
135
|
expect(result.ok).toBe(false);
|
|
127
136
|
if (!result.ok) expect(result.error).toContain("schemaVersion");
|
|
128
137
|
});
|
|
129
138
|
|
|
130
139
|
test("rejects a structurally broken record", () => {
|
|
140
|
+
// Act
|
|
131
141
|
const result = validateSessionRecord({ ...record, revisions: "nope" });
|
|
142
|
+
|
|
143
|
+
// Assert
|
|
132
144
|
expect(result.ok).toBe(false);
|
|
133
145
|
});
|
|
134
146
|
});
|
|
@@ -159,6 +171,8 @@ describe("wire pins", () => {
|
|
|
159
171
|
anchor: fullAnchor,
|
|
160
172
|
body: "b",
|
|
161
173
|
orphan: false,
|
|
174
|
+
author: "SHA256:abc",
|
|
175
|
+
resolution: { revision: 2, source: "agent" },
|
|
162
176
|
createdAt: "now",
|
|
163
177
|
};
|
|
164
178
|
const fullArtifact: Required<Artifact> = { type: "plan", content: "# P", meta: fullMeta };
|
|
@@ -198,12 +212,17 @@ describe("wire pins", () => {
|
|
|
198
212
|
test("a fully-populated meta survives validation and DaemonCore unchanged", () => {
|
|
199
213
|
const home = mkdtempSync(join(tmpdir(), "cueloop-val-"));
|
|
200
214
|
try {
|
|
215
|
+
// Arrange
|
|
201
216
|
const params = parseParams("session.create", {
|
|
202
217
|
workspace: fullWorkspace,
|
|
203
218
|
artifact: { type: "plan", content: "# P", meta: fullMeta },
|
|
204
219
|
});
|
|
205
220
|
const core = new DaemonCore(home);
|
|
221
|
+
|
|
222
|
+
// Act
|
|
206
223
|
const created = core.sessionCreate(params);
|
|
224
|
+
|
|
225
|
+
// Assert
|
|
207
226
|
expect(core.sessionGet(created.id).artifact.meta).toEqual(fullMeta);
|
|
208
227
|
} finally {
|
|
209
228
|
rmSync(home, { recursive: true, force: true });
|
package/src/validate.ts
CHANGED
|
@@ -72,6 +72,13 @@ export const AnnotationSchema = v.object({
|
|
|
72
72
|
anchor: AnchorSchema,
|
|
73
73
|
body: v.string(),
|
|
74
74
|
orphan: v.optional(v.boolean()),
|
|
75
|
+
author: v.optional(v.string()),
|
|
76
|
+
resolution: v.optional(
|
|
77
|
+
v.object({
|
|
78
|
+
revision: v.number(),
|
|
79
|
+
source: v.picklist(["agent", "drift"]),
|
|
80
|
+
}),
|
|
81
|
+
),
|
|
75
82
|
} satisfies EntriesOf<Omit<Annotation, "createdAt">>);
|
|
76
83
|
|
|
77
84
|
const SessionId = NonEmpty;
|
|
@@ -101,7 +108,12 @@ export const Params = {
|
|
|
101
108
|
verdictKind: v.picklist(["comment", "approve", "request_changes"]),
|
|
102
109
|
summary: v.optional(v.string(), ""),
|
|
103
110
|
}),
|
|
104
|
-
"session.submitRevision": v.object({
|
|
111
|
+
"session.submitRevision": v.object({
|
|
112
|
+
id: SessionId,
|
|
113
|
+
content: v.string(),
|
|
114
|
+
/** Annotation ids the agent acted on; each is marked addressed. */
|
|
115
|
+
addressedAnnotationIds: v.optional(v.array(NonEmpty), []),
|
|
116
|
+
}),
|
|
105
117
|
"events.subscribe": v.object({}),
|
|
106
118
|
"daemon.ping": v.object({}),
|
|
107
119
|
"daemon.shutdown": v.object({}),
|