@cueloop/daemon 0.1.0-alpha.7 → 0.1.0-alpha.70

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.
Files changed (42) hide show
  1. package/package.json +33 -22
  2. package/src/api.test.ts +891 -50
  3. package/src/api.ts +849 -72
  4. package/src/capabilities.test.ts +57 -0
  5. package/src/capabilities.ts +77 -0
  6. package/src/client.test.ts +24 -0
  7. package/src/client.ts +459 -69
  8. package/src/curate.ts +109 -0
  9. package/src/daemon-version.test.ts +93 -0
  10. package/src/diff-hot-reload.test.ts +186 -0
  11. package/src/diff-watcher.ts +102 -0
  12. package/src/herdr-pane.test.ts +240 -0
  13. package/src/herdr-pane.ts +167 -0
  14. package/src/herdr-split.test.ts +106 -0
  15. package/src/herdr-split.ts +92 -0
  16. package/src/herdr-tab-store.test.ts +96 -0
  17. package/src/herdr-tab-store.ts +66 -0
  18. package/src/index.ts +8 -9
  19. package/src/interruptible-wait.test.ts +127 -0
  20. package/src/interruptible-wait.ts +66 -0
  21. package/src/main.ts +4 -1
  22. package/src/paths.ts +39 -0
  23. package/src/project-files.test.ts +124 -0
  24. package/src/project-files.ts +56 -0
  25. package/src/protocol.ts +79 -7
  26. package/src/retention.test.ts +182 -0
  27. package/src/retention.ts +122 -0
  28. package/src/review.test.ts +255 -28
  29. package/src/review.ts +164 -62
  30. package/src/server.test.ts +381 -39
  31. package/src/server.ts +322 -84
  32. package/src/share-blob.test.ts +112 -0
  33. package/src/share-blob.ts +55 -0
  34. package/src/single-instance.test.ts +38 -11
  35. package/src/store.conformance.test.ts +41 -0
  36. package/src/store.jsonl.test.ts +159 -0
  37. package/src/store.ts +261 -34
  38. package/src/testing/store-conformance.ts +194 -0
  39. package/src/validate.test.ts +174 -28
  40. package/src/validate.ts +226 -16
  41. package/src/version.ts +6 -0
  42. package/src/working-tree.ts +149 -0
@@ -1,18 +1,13 @@
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";
10
5
  import { tmpdir } from "node:os";
11
6
  import { join } from "node:path";
12
- import type { ReviewSession } from "@cueloop/schema";
7
+ import type { Thread } from "@cueloop/schema";
13
8
  import { DaemonServer } from "./server";
14
9
  import { DaemonClient } from "./client";
15
- import { openReview, resolveWorkspace } from "./review";
10
+ import { awaitResolve, openReview, resolveWorkspace } from "./review";
16
11
 
17
12
  const PLAN = "# Rollout Plan\n\nShip it in two stages.\n";
18
13
 
@@ -33,19 +28,26 @@ afterEach(() => {
33
28
  });
34
29
 
35
30
  function sh(args: string[], cwd: string): void {
36
- const p = Bun.spawnSync(args, { cwd, stdout: "ignore", stderr: "ignore" });
37
- if (p.exitCode !== 0) throw new Error(`${args.join(" ")} failed`);
31
+ const gitResult = Bun.spawnSync(args, { cwd, stdout: "ignore", stderr: "ignore" });
32
+
33
+ if (gitResult.exitCode !== 0) throw new Error(`${args.join(" ")} failed`);
38
34
  }
39
35
 
40
36
  describe("resolveWorkspace", () => {
41
37
  test("a git repo resolves to its root and branch", async () => {
42
38
  const repo = mkdtempSync(join(tmpdir(), "cueloop-ws-"));
39
+
43
40
  try {
41
+ // Arrange
44
42
  sh(["git", "init", "-q", "-b", "main"], repo);
45
43
  sh(["git", "config", "user.email", "t@t"], repo);
46
44
  sh(["git", "config", "user.name", "t"], repo);
47
45
  sh(["git", "commit", "-q", "--allow-empty", "-m", "init"], repo);
46
+
47
+ // Act
48
48
  const ws = await resolveWorkspace(repo);
49
+
50
+ // Assert
49
51
  expect(ws.branch).toBe("main");
50
52
  // macOS tmpdirs can resolve through /private; compare the tail
51
53
  expect(ws.repoRoot.endsWith(repo.split("/").at(-1)!)).toBe(true);
@@ -55,58 +57,167 @@ describe("resolveWorkspace", () => {
55
57
  });
56
58
 
57
59
  test("outside a repo the cwd itself is the workspace, branch detached", async () => {
60
+ // Act
58
61
  const ws = await resolveWorkspace(home);
62
+
63
+ // Assert
59
64
  expect(ws).toEqual({ repoRoot: home, branch: "detached" });
60
65
  });
61
66
  });
62
67
 
63
68
  describe("openReview", () => {
64
69
  test("shapes the artifact: agent, derived title, cwd", async () => {
65
- const review = await openReview(client, { type: "plan", content: PLAN, cwd: home, agent: "test-agent" });
66
- const s = review.session;
67
- expect(s.status).toBe("pending");
68
- expect(s.artifact.type).toBe("plan");
69
- expect(s.artifact.meta.agent).toBe("test-agent");
70
- expect(s.artifact.meta.title).toBe("Rollout Plan");
71
- expect(s.artifact.meta.cwd).toBe(home);
72
- expect(s.workspace).toEqual({ repoRoot: home, branch: "detached" });
70
+ // Act
71
+ const review = await openReview(client, {
72
+ type: "plan",
73
+ content: PLAN,
74
+ cwd: home,
75
+ agent: "test-agent",
76
+ });
77
+
78
+ // Assert
79
+ const session = review.session;
80
+
81
+ expect(session.status).toBe("pending");
82
+ expect(session.artifact.type).toBe("plan");
83
+ expect(session.artifact.meta.agent).toBe("test-agent");
84
+ expect(session.artifact.meta.title).toBe("Rollout Plan");
85
+ expect(session.artifact.meta.cwd).toBe(home);
86
+ expect(session.workspace).toEqual({ repoRoot: home, branch: "detached" });
73
87
  });
74
88
 
75
89
  test("an explicit title wins; diffs never derive one from content", async () => {
76
- const titled = await openReview(client, { type: "plan", content: PLAN, cwd: home, title: "Custom" });
90
+ // Act
91
+ const titled = await openReview(client, {
92
+ type: "plan",
93
+ content: PLAN,
94
+ cwd: home,
95
+ title: "Custom",
96
+ });
97
+
98
+ // Assert
77
99
  expect(titled.session.artifact.meta.title).toBe("Custom");
78
- const diff = await openReview(client, { type: "diff", content: "# not a heading\n", cwd: home });
100
+
101
+ // Act
102
+ const diff = await openReview(client, {
103
+ type: "diff",
104
+ content: "# not a heading\n",
105
+ cwd: home,
106
+ });
107
+
108
+ // Assert
79
109
  expect(diff.session.artifact.meta.title).toBeUndefined();
80
110
  });
81
111
 
82
112
  test("a pre-resolved workspace skips git resolution", async () => {
113
+ // Arrange
83
114
  const ws = { repoRoot: "/elsewhere", branch: "feature" };
84
- const review = await openReview(client, { type: "diff", content: "d", cwd: home, workspace: ws });
115
+
116
+ // Act
117
+ const review = await openReview(client, {
118
+ type: "diff",
119
+ content: "d",
120
+ cwd: home,
121
+ workspace: ws,
122
+ });
123
+
124
+ // Assert
85
125
  expect(review.session.workspace).toEqual(ws);
86
126
  });
87
127
 
88
128
  test("the same agentSessionId revises instead of opening a new session", async () => {
89
- const first = await openReview(client, { type: "plan", content: PLAN, cwd: home, agentSessionId: "agent-1" });
129
+ // Arrange
130
+ const first = await openReview(client, {
131
+ type: "plan",
132
+ content: PLAN,
133
+ cwd: home,
134
+ agentSessionId: "agent-1",
135
+ });
136
+
137
+ // Act
90
138
  const second = await openReview(client, {
91
139
  type: "plan",
92
140
  content: PLAN + "\nStage three.\n",
93
141
  cwd: home,
94
142
  agentSessionId: "agent-1",
95
143
  });
144
+
145
+ // Assert
96
146
  expect(second.id).toBe(first.id);
97
147
  expect(second.session.revisions.length).toBe(2);
98
148
  expect(second.session.status).toBe("pending");
99
- const other = await openReview(client, { type: "plan", content: PLAN, cwd: home, agentSessionId: "agent-2" });
149
+
150
+ // Act
151
+ const other = await openReview(client, {
152
+ type: "plan",
153
+ content: PLAN,
154
+ cwd: home,
155
+ agentSessionId: "agent-2",
156
+ });
157
+
158
+ // Assert
100
159
  expect(other.id).not.toBe(first.id);
101
160
  });
161
+
162
+ test("the same agentSessionId with a different type opens a new session, never a type mismatch", async () => {
163
+ // Arrange - the agent session's plan review is open
164
+ const plan = await openReview(client, {
165
+ type: "plan",
166
+ content: PLAN,
167
+ cwd: home,
168
+ agentSessionId: "agent-cross",
169
+ });
170
+
171
+ // Act - the same agent session now submits a reply for review
172
+ const reply = await openReview(client, {
173
+ type: "reply",
174
+ content: "# Findings\n\nThe cache is fine.\n",
175
+ cwd: home,
176
+ agentSessionId: "agent-cross",
177
+ });
178
+
179
+ // Assert - a fresh reply session; the plan session keeps its type and history
180
+ expect(reply.id).not.toBe(plan.id);
181
+ expect(reply.session.artifact.type).toBe("reply");
182
+ expect(reply.session.revisions.length).toBe(1);
183
+ expect((await client.sessionGet(plan.id)).artifact.type).toBe("plan");
184
+ });
185
+
186
+ test("per-file notes land as note annotations anchored at the file path", async () => {
187
+ // Act
188
+ const review = await openReview(client, {
189
+ type: "diff",
190
+ content: "d",
191
+ cwd: home,
192
+ notes: [
193
+ { path: "src/a.ts", body: "Renames b and adds c." },
194
+ { path: "src/b.ts", body: "Swaps the line." },
195
+ ],
196
+ });
197
+
198
+ // Assert
199
+ const notes = review.session.annotations;
200
+
201
+ expect(notes.length).toBe(2);
202
+ expect(notes.every((annotation) => annotation.kind === "note")).toBe(true);
203
+ expect(notes.map((annotation) => annotation.anchor.quote)).toEqual(["src/a.ts", "src/b.ts"]);
204
+ expect(notes[0]!.body).toBe("Renames b and adds c.");
205
+ });
102
206
  });
103
207
 
104
208
  describe("awaitVerdict: one long-poll (the hook shape)", () => {
105
209
  test("times out to pending; a later wait collects the stored verdict", async () => {
210
+ // Arrange
106
211
  const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
212
+
213
+ // Assert
107
214
  expect(await review.awaitVerdict({ timeoutMs: 50 })).toBe("pending");
215
+
216
+ // Act
108
217
  await client.sessionResolve(review.id, "approve", "Fine.");
109
218
  const verdict = await review.awaitVerdict({ timeoutMs: 1_000 });
219
+
220
+ // Assert
110
221
  expect(verdict).not.toBe("pending");
111
222
  if (verdict === "pending") throw new Error("unreachable");
112
223
  expect(verdict.allow).toBe(true);
@@ -115,10 +226,15 @@ describe("awaitVerdict: one long-poll (the hook shape)", () => {
115
226
  });
116
227
 
117
228
  test("request_changes maps to allow=false with feedback.md attached", async () => {
229
+ // Arrange
118
230
  const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
119
231
  const waiting = review.awaitVerdict({ timeoutMs: 5_000 });
232
+
233
+ // Act
120
234
  await client.sessionResolve(review.id, "request_changes", "One stage only.");
121
235
  const verdict = await waiting;
236
+
237
+ // Assert
122
238
  if (verdict === "pending") throw new Error("expected a verdict");
123
239
  expect(verdict.allow).toBe(false);
124
240
  expect(verdict.feedback).toContain("# Review: request changes");
@@ -128,40 +244,151 @@ describe("awaitVerdict: one long-poll (the hook shape)", () => {
128
244
 
129
245
  describe("awaitVerdict: chunked loop (the pi shape)", () => {
130
246
  test("onProgress sees fresh sessions between chunks; the verdict ends the loop", async () => {
247
+ // Arrange
131
248
  const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
132
- const seen: ReviewSession[] = [];
249
+ const seen: Thread[] = [];
133
250
  const waiting = review.awaitVerdict({
134
251
  timeoutMs: Infinity,
135
252
  pollMs: 100,
136
- onProgress: (s) => seen.push(s),
253
+ onProgress: (progress) => seen.push(progress),
137
254
  });
255
+
256
+ // Act
138
257
  await client.sessionAnnotate(review.id, {
139
258
  id: "a1",
140
259
  kind: "comment",
141
260
  anchor: { quote: "two stages", prefix: "in ", suffix: "." },
142
261
  body: "Name them.",
143
262
  });
263
+
264
+ // Assert
144
265
  // give the loop a chunk to observe the annotation before resolving
145
- for (let i = 0; i < 100 && !seen.some((s) => s.annotations.length === 1); i++) await Bun.sleep(20);
146
- expect(seen.some((s) => s.annotations.length === 1)).toBe(true);
266
+ for (let i = 0; i < 100 && !seen.some((snapshot) => snapshot.annotations.length === 1); i++)
267
+ await Bun.sleep(20);
268
+ expect(seen.some((snapshot) => snapshot.annotations.length === 1)).toBe(true);
269
+
270
+ // Act
147
271
  await client.sessionResolve(review.id, "request_changes", "Too vague.");
148
272
  const verdict = await waiting;
273
+
274
+ // Assert
149
275
  if (verdict === "pending") throw new Error("expected a verdict");
150
276
  expect(verdict.allow).toBe(false);
151
277
  expect(verdict.session.annotations.length).toBe(1);
152
278
  }, 15_000);
153
279
 
154
280
  test("abort surfaces as pending and leaves the session collectable", async () => {
281
+ // Arrange
155
282
  const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
156
283
  const controller = new AbortController();
157
- const waiting = review.awaitVerdict({ timeoutMs: Infinity, pollMs: 100, signal: controller.signal });
284
+ const waiting = review.awaitVerdict({
285
+ timeoutMs: Infinity,
286
+ pollMs: 100,
287
+ signal: controller.signal,
288
+ });
289
+
290
+ // Act
158
291
  controller.abort();
292
+
293
+ // Assert
159
294
  expect(await waiting).toBe("pending");
160
295
  expect((await client.sessionGet(review.id)).status).toBe("pending");
161
296
  });
162
297
 
163
298
  test("a finite budget runs out to pending", async () => {
299
+ // Arrange
164
300
  const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
301
+
302
+ // Assert
165
303
  expect(await review.awaitVerdict({ timeoutMs: 120, pollMs: 50 })).toBe("pending");
166
304
  });
167
305
  });
306
+
307
+ describe("awaitResolve: the adapter wake seam (session id only)", () => {
308
+ test("resolves to the outcome when the verdict lands during the wait", async () => {
309
+ // Arrange
310
+ const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
311
+ const waiting = awaitResolve(client, review.id, { pollMs: 100 });
312
+
313
+ // Act
314
+ await client.sessionResolve(review.id, "approve", "Ship it.");
315
+ const verdict = await waiting;
316
+
317
+ // Assert
318
+ expect(verdict).not.toBeNull();
319
+ expect(verdict!.allow).toBe(true);
320
+ expect(verdict!.feedback).toContain("Ship it.");
321
+ expect(verdict!.session.verdict!.kind).toBe("approve");
322
+ });
323
+
324
+ test("returns the stored verdict immediately when the session already resolved", async () => {
325
+ // Arrange - a detached waiter that only attaches after the human decided
326
+ const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
327
+
328
+ await client.sessionResolve(review.id, "request_changes", "One stage only.");
329
+
330
+ // Act
331
+ const verdict = await awaitResolve(client, review.id);
332
+
333
+ // Assert
334
+ expect(verdict).not.toBeNull();
335
+ expect(verdict!.allow).toBe(false);
336
+ expect(verdict!.feedback).toContain("One stage only.");
337
+ });
338
+
339
+ test("returns null when the signal aborts first", async () => {
340
+ // Arrange
341
+ const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
342
+ const controller = new AbortController();
343
+ const waiting = awaitResolve(client, review.id, { pollMs: 100, signal: controller.signal });
344
+
345
+ // Act
346
+ controller.abort();
347
+
348
+ // Assert
349
+ expect(await waiting).toBeNull();
350
+ expect((await client.sessionGet(review.id)).status).toBe("pending");
351
+ });
352
+ });
353
+
354
+ describe("awaitResolve: the held wait keeps the daemon alive", () => {
355
+ test("a pending session does not idle-exit while a waiter is parked, then the verdict lands", async () => {
356
+ // Arrange - a daemon with an aggressive idle timer and its own home
357
+ const idleHome = mkdtempSync(join(tmpdir(), "cueloop-idle-"));
358
+ let idleExits = 0;
359
+ const idleServer = new DaemonServer({
360
+ home: idleHome,
361
+ idleExitMs: 30,
362
+ onIdleExit: () => idleExits++,
363
+ });
364
+
365
+ idleServer.start();
366
+ const waiterClient = await DaemonClient.connect({ home: idleHome });
367
+
368
+ try {
369
+ const review = await openReview(waiterClient, { type: "plan", content: PLAN, cwd: idleHome });
370
+ const waiting = awaitResolve(waiterClient, review.id, { pollMs: 40 });
371
+
372
+ // Act - wait well past the idle window while the review is still pending
373
+ await Bun.sleep(120);
374
+
375
+ // Assert - the parked waiter and the pending session both keep it alive
376
+ expect(idleExits).toBe(0);
377
+ expect((await waiterClient.ping()).pid).toBe(process.pid);
378
+
379
+ // Act - the human returns a verdict; the parked wait collects it
380
+ const resolver = await DaemonClient.connect({ home: idleHome });
381
+
382
+ await resolver.sessionResolve(review.id, "approve", "Good to go.");
383
+ resolver.close();
384
+ const verdict = await waiting;
385
+
386
+ // Assert
387
+ expect(verdict!.allow).toBe(true);
388
+ } finally {
389
+ waiterClient.close();
390
+ idleServer.stop();
391
+ rmSync(idleHome, { recursive: true, force: true });
392
+ }
393
+ }, 15_000);
394
+ });