@cueloop/daemon 0.1.0-alpha.5 → 0.1.0-alpha.51

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 CHANGED
@@ -1,34 +1,42 @@
1
1
  {
2
2
  "name": "@cueloop/daemon",
3
- "version": "0.1.0-alpha.5",
3
+ "version": "0.1.0-alpha.51",
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",
8
- "./client": "./src/client.ts"
9
- },
10
- "dependencies": {
11
- "@cueloop/schema": "0.1.0-alpha.5",
12
- "valibot": "^1.4.2"
22
+ "./client": "./src/client.ts",
23
+ "./review": "./src/review.ts",
24
+ "./working-tree": "./src/working-tree.ts",
25
+ "./store": "./src/store.ts",
26
+ "./paths": "./src/paths.ts",
27
+ "./retention": "./src/retention.ts",
28
+ "./herdr-pane": "./src/herdr-pane.ts",
29
+ "./herdr-split": "./src/herdr-split.ts",
30
+ "./share-blob": "./src/share-blob.ts"
13
31
  },
14
32
  "publishConfig": {
15
33
  "access": "public"
16
34
  },
17
- "files": [
18
- "src",
19
- "README.md"
20
- ],
35
+ "dependencies": {
36
+ "@cueloop/schema": "0.1.0-alpha.51",
37
+ "valibot": "^1.4.2"
38
+ },
21
39
  "engines": {
22
40
  "bun": ">=1.3.0"
23
- },
24
- "repository": {
25
- "type": "git",
26
- "url": "git+https://github.com/mmurakaru/cueloop.git",
27
- "directory": "packages/daemon"
28
- },
29
- "homepage": "https://github.com/mmurakaru/cueloop#readme",
30
- "bugs": {
31
- "url": "https://github.com/mmurakaru/cueloop/issues"
32
- },
33
- "description": "The cueloop session daemon and its client: socket protocol, atomic store, resumable waits"
34
- }
41
+ }
42
+ }
package/src/api.test.ts CHANGED
@@ -24,71 +24,169 @@ afterEach(() => rmSync(home, { recursive: true, force: true }));
24
24
 
25
25
  describe("session lifecycle", () => {
26
26
  test("create → get → list", () => {
27
- const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
28
- expect(s.status).toBe("pending");
29
- expect(s.revisions.length).toBe(1);
30
- expect(core.sessionGet(s.id).id).toBe(s.id);
27
+ // Act
28
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
29
+
30
+ // Assert
31
+ expect(session.status).toBe("pending");
32
+ expect(session.revisions.length).toBe(1);
33
+ expect(core.sessionGet(session.id).id).toBe(session.id);
31
34
  expect(core.sessionList({ status: "pending" }).length).toBe(1);
32
35
  });
33
36
 
34
37
  test("annotate upserts by id; remove deletes", () => {
35
- const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
36
- core.sessionAnnotate(s.id, {
38
+ // Arrange
39
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
40
+
41
+ // Act
42
+ core.sessionAnnotate(session.id, {
37
43
  id: "a1",
38
44
  kind: "comment",
39
45
  anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
40
46
  body: "How carefully?",
41
47
  });
42
- core.sessionAnnotate(s.id, {
48
+ core.sessionAnnotate(session.id, {
43
49
  id: "a1",
44
50
  kind: "comment",
45
51
  anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
46
52
  body: "Define carefully.",
47
53
  });
48
- expect(core.sessionGet(s.id).annotations.length).toBe(1);
49
- expect(core.sessionGet(s.id).annotations[0]!.body).toBe("Define carefully.");
50
- core.sessionRemoveAnnotation(s.id, "a1");
51
- expect(core.sessionGet(s.id).annotations.length).toBe(0);
54
+
55
+ // Assert
56
+ expect(core.sessionGet(session.id).annotations.length).toBe(1);
57
+ expect(core.sessionGet(session.id).annotations[0]!.body).toBe("Define carefully.");
58
+
59
+ // Act
60
+ core.sessionRemoveAnnotation(session.id, "a1");
61
+
62
+ // Assert
63
+ expect(core.sessionGet(session.id).annotations.length).toBe(0);
64
+ });
65
+
66
+ test("annotate with an author registers them in the participant registry", () => {
67
+ // Arrange
68
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
69
+
70
+ // Act
71
+ core.sessionAnnotate(
72
+ session.id,
73
+ {
74
+ id: "a1",
75
+ kind: "comment",
76
+ anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
77
+ body: "A collaborator's note.",
78
+ author: "SHA256:ana",
79
+ },
80
+ "Ana",
81
+ );
82
+
83
+ // Assert
84
+ const stored = core.sessionGet(session.id);
85
+ expect(stored.annotations[0]!.author).toBe("SHA256:ana");
86
+ expect(stored.participants).toEqual([{ id: "SHA256:ana", provider: "ssh", name: "Ana" }]);
52
87
  });
53
88
 
54
89
  test("resolve produces feedback.md and maps to the agent contract", () => {
55
- const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
56
- core.sessionAnnotate(s.id, {
90
+ // Arrange
91
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
92
+ core.sessionAnnotate(session.id, {
57
93
  id: "a1",
58
94
  kind: "comment",
59
95
  anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
60
96
  body: "Spell out the steps.",
61
97
  });
62
- const resolved = core.sessionResolve(s.id, "request_changes", "Needs detail.");
98
+
99
+ // Act
100
+ const resolved = core.sessionResolve(session.id, "request_changes", "Needs detail.");
101
+
102
+ // Assert
63
103
  expect(resolved.verdict!.feedback).toContain("# Review: request changes");
64
104
  expect(resolved.verdict!.feedback).toContain("> carefully");
65
- expect(verdictResponse(resolved)).toEqual({ allow: false, feedback: resolved.verdict!.feedback });
105
+ expect(verdictResponse(resolved)).toEqual({
106
+ allow: false,
107
+ feedback: resolved.verdict!.feedback,
108
+ });
109
+
110
+ // Arrange
66
111
  const approved = core.sessionCreate({ workspace: WS, artifact: PLAN });
112
+
113
+ // Act
67
114
  core.sessionResolve(approved.id, "approve", "LGTM");
115
+
116
+ // Assert
68
117
  expect(verdictResponse(core.sessionGet(approved.id)).allow).toBe(true);
69
118
  });
70
119
 
71
120
  test("mutating a resolved session throws", () => {
72
- const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
73
- core.sessionResolve(s.id, "approve", "");
121
+ // Arrange
122
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
123
+ core.sessionResolve(session.id, "approve", "");
124
+
125
+ // Assert
74
126
  expect(() =>
75
- core.sessionAnnotate(s.id, { id: "a1", kind: "comment", anchor: { quote: "x", prefix: "", suffix: "" }, body: "b" }),
127
+ core.sessionAnnotate(session.id, {
128
+ id: "a1",
129
+ kind: "comment",
130
+ anchor: { quote: "x", prefix: "", suffix: "" },
131
+ body: "b",
132
+ }),
76
133
  ).toThrow("already resolved");
77
134
  });
78
135
 
79
136
  test("working copy stores edits and clears on revert or no-op", () => {
80
- const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
81
- core.sessionSetWorkingCopy(s.id, PLAN.content.replace("carefully", "very carefully"));
82
- expect(core.sessionGet(s.id).workingCopy).toContain("very carefully");
83
- core.sessionSetWorkingCopy(s.id, PLAN.content);
84
- expect(core.sessionGet(s.id).workingCopy).toBeUndefined();
137
+ // Arrange
138
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
139
+
140
+ // Act
141
+ core.sessionSetWorkingCopy(session.id, PLAN.content.replace("carefully", "very carefully"));
142
+
143
+ // Assert
144
+ expect(core.sessionGet(session.id).workingCopy).toContain("very carefully");
145
+
146
+ // Act
147
+ core.sessionSetWorkingCopy(session.id, PLAN.content);
148
+
149
+ // Assert
150
+ expect(core.sessionGet(session.id).workingCopy).toBeUndefined();
151
+ });
152
+
153
+ test("viewed paths merge, dedupe, clear on empty, and survive a daemon restart", () => {
154
+ // Arrange
155
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
156
+
157
+ // Act
158
+ core.sessionSetViewed(session.id, ["src/a.ts", "src/b.ts", "src/a.ts"]);
159
+
160
+ // Assert
161
+ expect(core.sessionGet(session.id).viewedPaths).toEqual(["src/a.ts", "src/b.ts"]);
162
+
163
+ // Act
164
+ // merge-additive: a stale client sending only its own new mark loses nothing
165
+ core.sessionSetViewed(session.id, ["src/c.ts"]);
166
+
167
+ // Assert
168
+ expect(core.sessionGet(session.id).viewedPaths).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]);
169
+ // a resumed review reads its progress back after a daemon restart
170
+ const reborn = new DaemonCore(home);
171
+ expect(reborn.sessionGet(session.id).viewedPaths).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]);
172
+
173
+ // Act
174
+ core.sessionSetViewed(session.id, []);
175
+
176
+ // Assert
177
+ expect(core.sessionGet(session.id).viewedPaths).toBeUndefined();
85
178
  });
86
179
 
87
180
  test("revision reopens the session and resets working copy + verdict", () => {
88
- const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
89
- core.sessionSetWorkingCopy(s.id, PLAN.content + "\nedit");
90
- core.sessionResolve(s.id, "request_changes", "redo");
91
- const revised = core.sessionSubmitRevision(s.id, "# Plan v2\n");
181
+ // Arrange
182
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
183
+ core.sessionSetWorkingCopy(session.id, PLAN.content + "\nedit");
184
+ core.sessionResolve(session.id, "request_changes", "redo");
185
+
186
+ // Act
187
+ const revised = core.sessionSubmitRevision(session.id, "# Plan v2\n");
188
+
189
+ // Assert
92
190
  expect(revised.status).toBe("pending");
93
191
  expect(revised.verdict).toBeNull();
94
192
  expect(revised.workingCopy).toBeUndefined();
@@ -97,47 +195,165 @@ describe("session lifecycle", () => {
97
195
  });
98
196
  });
99
197
 
198
+ describe("revision marks addressed annotations", () => {
199
+ const annotate = (sessionId: string, id: string, quote: string, body = "note") =>
200
+ core.sessionAnnotate(sessionId, {
201
+ id,
202
+ kind: "comment",
203
+ anchor: { quote, prefix: "", suffix: "" },
204
+ body,
205
+ });
206
+
207
+ test("ids the agent reports are marked addressed by that revision", () => {
208
+ // Arrange
209
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
210
+ annotate(session.id, "a1", "carefully");
211
+ annotate(session.id, "a2", "Context");
212
+
213
+ // Act: the revision keeps both quoted texts, so only the reported id resolves
214
+ const revised = core.sessionSubmitRevision(session.id, PLAN.content + "\nMore.\n", ["a1"]);
215
+
216
+ // Assert
217
+ const [first, second] = revised.annotations;
218
+ expect(first!.resolution).toEqual({ revision: 2, source: "agent" });
219
+ expect(second!.resolution).toBeUndefined();
220
+ });
221
+
222
+ test("a plan annotation whose quoted text vanished is drift-addressed; a surviving quote stays open", () => {
223
+ // Arrange
224
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
225
+ annotate(session.id, "gone", "carefully");
226
+ annotate(session.id, "kept", "Context");
227
+
228
+ // Act: the revision rewrites the "carefully" sentence but keeps the Context heading
229
+ const revised = core.sessionSubmitRevision(
230
+ session.id,
231
+ "# Plan\n\n## Context\n\nDo the thing with tests.\n",
232
+ );
233
+
234
+ // Assert
235
+ expect(revised.annotations.find((a) => a.id === "gone")!.resolution).toEqual({
236
+ revision: 2,
237
+ source: "drift",
238
+ });
239
+ expect(revised.annotations.find((a) => a.id === "kept")!.resolution).toBeUndefined();
240
+ });
241
+
242
+ test("an unknown reported id is ignored, and an already-addressed annotation keeps its first resolution", () => {
243
+ // Arrange
244
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
245
+ annotate(session.id, "a1", "carefully");
246
+ core.sessionSubmitRevision(session.id, PLAN.content, ["a1"]);
247
+
248
+ // Act: a second revision reports a stale id and re-reports the settled one
249
+ const revised = core.sessionSubmitRevision(session.id, PLAN.content, ["a1", "a_never_existed"]);
250
+
251
+ // Assert
252
+ expect(revised.annotations[0]!.resolution).toEqual({ revision: 2, source: "agent" });
253
+ expect(revised.revisions.length).toBe(3);
254
+ });
255
+
256
+ test("a diff revision never drift-addresses - a new patch says nothing about the feedback", () => {
257
+ // Arrange
258
+ const diffArtifact: Artifact = { type: "diff", content: "+++ b/a.ts\n+new line\n", meta: {} };
259
+ const session = core.sessionCreate({ workspace: WS, artifact: diffArtifact });
260
+ annotate(session.id, "d1", "new line");
261
+
262
+ // Act: the revised patch no longer contains the quoted text
263
+ const revised = core.sessionSubmitRevision(session.id, "+++ b/b.ts\n+other\n");
264
+
265
+ // Assert
266
+ expect(revised.annotations[0]!.resolution).toBeUndefined();
267
+ });
268
+
269
+ test("the next feedback document omits addressed annotations and teaches the addressed-ids call", () => {
270
+ // Arrange
271
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
272
+ annotate(session.id, "settled", "carefully");
273
+ annotate(session.id, "open", "Context", "needs a diagram");
274
+ core.sessionSubmitRevision(session.id, PLAN.content, ["settled"]);
275
+
276
+ // Act
277
+ const resolved = core.sessionResolve(session.id, "request_changes", "one left");
278
+
279
+ // Assert
280
+ expect(resolved.verdict!.feedback).not.toContain("settled");
281
+ expect(resolved.verdict!.feedback).toContain("annotation id: `open`");
282
+ expect(resolved.verdict!.feedback).toContain(`submit-revision ${session.id}`);
283
+ expect(resolved.verdict!.feedback).toContain("--addressed");
284
+ });
285
+ });
286
+
100
287
  describe("the wait contract: verdicts outlive waits", () => {
101
288
  test("wait resolves when the verdict arrives", async () => {
102
- const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
103
- const wait = core.sessionWait(s.id, 5_000);
104
- core.sessionResolve(s.id, "approve", "");
289
+ // Arrange
290
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
291
+ const wait = core.sessionWait(session.id, 5_000);
292
+
293
+ // Act
294
+ core.sessionResolve(session.id, "approve", "");
105
295
  const resolved = await wait;
296
+
297
+ // Assert
106
298
  expect(resolved!.verdict!.kind).toBe("approve");
107
299
  });
108
300
 
109
301
  test("wait times out to null; a later wait collects the stored verdict", async () => {
110
- const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
111
- const first = await core.sessionWait(s.id, 30);
302
+ // Arrange
303
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
304
+
305
+ // Act
306
+ const first = await core.sessionWait(session.id, 30);
307
+
308
+ // Assert
112
309
  expect(first).toBeNull();
113
- core.sessionResolve(s.id, "request_changes", "later");
114
- const second = await core.sessionWait(s.id, 30);
310
+
311
+ // Act
312
+ core.sessionResolve(session.id, "request_changes", "later");
313
+ const second = await core.sessionWait(session.id, 30);
314
+
315
+ // Assert
115
316
  expect(second!.verdict!.summary).toBe("later");
116
317
  });
117
318
 
118
319
  test("multiple waiters all resolve", async () => {
119
- const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
120
- const waits = [core.sessionWait(s.id, 5_000), core.sessionWait(s.id, 5_000)];
121
- core.sessionResolve(s.id, "approve", "");
320
+ // Arrange
321
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
322
+ const waits = [core.sessionWait(session.id, 5_000), core.sessionWait(session.id, 5_000)];
323
+
324
+ // Act
325
+ core.sessionResolve(session.id, "approve", "");
122
326
  const results = await Promise.all(waits);
123
- expect(results.every((r) => r?.status === "resolved")).toBe(true);
327
+
328
+ // Assert
329
+ expect(results.every((result) => result?.status === "resolved")).toBe(true);
124
330
  });
125
331
  });
126
332
 
127
333
  describe("persistence and recovery", () => {
128
334
  test("sessions survive a daemon restart", () => {
129
- const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
130
- core.sessionResolve(s.id, "approve", "done");
335
+ // Arrange
336
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
337
+ core.sessionResolve(session.id, "approve", "done");
338
+
339
+ // Act
131
340
  const reborn = new DaemonCore(home);
132
- const recovered = reborn.sessionGet(s.id);
341
+ const recovered = reborn.sessionGet(session.id);
342
+
343
+ // Assert
133
344
  expect(recovered.verdict!.summary).toBe("done");
134
345
  });
135
346
 
136
347
  test("corrupt records are skipped, not fatal, and never deleted", async () => {
348
+ // Arrange
137
349
  core.sessionCreate({ workspace: WS, artifact: PLAN });
138
350
  await Bun.write(join(home, "sessions", "broken.json"), "{ not json");
139
351
  const store = new SessionStore(home);
352
+
353
+ // Act
140
354
  const report = store.recover();
355
+
356
+ // Assert
141
357
  expect(report.recovered.length).toBe(1);
142
358
  expect(report.skipped.length).toBe(1);
143
359
  expect(await Bun.file(join(home, "sessions", "broken.json")).exists()).toBe(true);
@@ -146,11 +362,27 @@ describe("persistence and recovery", () => {
146
362
 
147
363
  describe("events", () => {
148
364
  test("lifecycle events fire in order", () => {
365
+ // Arrange
149
366
  const seen: string[] = [];
150
- core.onEvent((e) => seen.push(e.event));
151
- const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
152
- core.sessionAnnotate(s.id, { id: "a1", kind: "comment", anchor: { quote: "thing", prefix: "", suffix: "" }, body: "b" });
153
- core.sessionResolve(s.id, "approve", "");
154
- expect(seen).toEqual(["session.created", "inbox.changed", "session.updated", "session.resolved", "inbox.changed"]);
367
+ core.onEvent((event) => seen.push(event.event));
368
+
369
+ // Act
370
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
371
+ core.sessionAnnotate(session.id, {
372
+ id: "a1",
373
+ kind: "comment",
374
+ anchor: { quote: "thing", prefix: "", suffix: "" },
375
+ body: "b",
376
+ });
377
+ core.sessionResolve(session.id, "approve", "");
378
+
379
+ // Assert
380
+ expect(seen).toEqual([
381
+ "session.created",
382
+ "inbox.changed",
383
+ "session.updated",
384
+ "session.resolved",
385
+ "inbox.changed",
386
+ ]);
155
387
  });
156
388
  });