@cueloop/daemon 0.1.0-alpha.11 → 0.1.0-alpha.111

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 (85) hide show
  1. package/README.md +7 -0
  2. package/SDK.md +102 -0
  3. package/package.json +44 -22
  4. package/src/agent-tools.ts +104 -0
  5. package/src/agent-turn.test.ts +24 -0
  6. package/src/agent-turn.ts +34 -0
  7. package/src/api.test.ts +1081 -65
  8. package/src/api.ts +1483 -90
  9. package/src/capabilities.test.ts +57 -0
  10. package/src/capabilities.ts +95 -0
  11. package/src/client-errors.ts +24 -0
  12. package/src/client-reliability.test.ts +191 -0
  13. package/src/client.test.ts +37 -0
  14. package/src/client.ts +844 -95
  15. package/src/curate.ts +111 -0
  16. package/src/daemon-sdk-effect.ts +113 -0
  17. package/src/daemon-sdk.test.ts +306 -0
  18. package/src/daemon-sdk.ts +405 -0
  19. package/src/daemon-version.test.ts +142 -0
  20. package/src/diff-hot-reload.test.ts +423 -0
  21. package/src/diff-watcher.test.ts +165 -0
  22. package/src/diff-watcher.ts +363 -0
  23. package/src/examples/daemon-sdk.ts +57 -0
  24. package/src/gh.test.ts +59 -0
  25. package/src/gh.ts +78 -0
  26. package/src/ghostty-thread-surface-store.test.ts +49 -0
  27. package/src/ghostty-thread-surface-store.ts +104 -0
  28. package/src/ghostty-thread-surface.test.ts +400 -0
  29. package/src/ghostty-thread-surface.ts +273 -0
  30. package/src/harness-delivery.test.ts +269 -0
  31. package/src/harness-diagnostics.test.ts +26 -0
  32. package/src/harness-diagnostics.ts +31 -0
  33. package/src/harness-handoff.test.ts +725 -0
  34. package/src/harness-handoff.ts +27 -0
  35. package/src/harness-state-store.test.ts +93 -0
  36. package/src/harness-state-store.ts +218 -0
  37. package/src/herdr-split.test.ts +106 -0
  38. package/src/herdr-split.ts +90 -0
  39. package/src/herdr-thread-surface-store.test.ts +81 -0
  40. package/src/herdr-thread-surface-store.ts +66 -0
  41. package/src/herdr-thread-surface.test.ts +465 -0
  42. package/src/herdr-thread-surface.ts +392 -0
  43. package/src/index.ts +8 -9
  44. package/src/interruptible-wait.test.ts +127 -0
  45. package/src/interruptible-wait.ts +69 -0
  46. package/src/jj-working-tree.ts +140 -0
  47. package/src/main.ts +5 -1
  48. package/src/operation-receipts.test.ts +111 -0
  49. package/src/operation-receipts.ts +104 -0
  50. package/src/paths.ts +49 -0
  51. package/src/pr-poller.test.ts +102 -0
  52. package/src/pr-poller.ts +80 -0
  53. package/src/project-files.test.ts +134 -0
  54. package/src/project-files.ts +72 -0
  55. package/src/protocol.ts +82 -7
  56. package/src/retention.test.ts +191 -0
  57. package/src/retention.ts +131 -0
  58. package/src/sdk-identities.ts +35 -0
  59. package/src/server.test.ts +502 -42
  60. package/src/server.ts +660 -88
  61. package/src/share-blob.test.ts +142 -0
  62. package/src/share-blob.ts +57 -0
  63. package/src/shared-agent-protocol.ts +30 -0
  64. package/src/single-instance.test.ts +165 -13
  65. package/src/store.conformance.test.ts +41 -0
  66. package/src/store.jsonl.test.ts +173 -0
  67. package/src/store.ts +278 -34
  68. package/src/testing/store-conformance.ts +192 -0
  69. package/src/thread-agent-validation.ts +141 -0
  70. package/src/thread-agent.test.ts +732 -0
  71. package/src/thread-agent.ts +1171 -0
  72. package/src/thread-review.test.ts +487 -0
  73. package/src/thread-review.ts +394 -0
  74. package/src/thread-subscription.test.ts +177 -0
  75. package/src/thread-subscription.ts +138 -0
  76. package/src/thread-surface-config.test.ts +63 -0
  77. package/src/thread-surface-config.ts +61 -0
  78. package/src/validate.test.ts +290 -37
  79. package/src/validate.ts +427 -33
  80. package/src/vcs-source.test.ts +180 -0
  81. package/src/vcs-source.ts +235 -0
  82. package/src/version.ts +6 -0
  83. package/src/working-tree.ts +154 -0
  84. package/src/review.test.ts +0 -167
  85. package/src/review.ts +0 -174
@@ -0,0 +1,487 @@
1
+ /** The shared review core against a real DaemonServer in a temp home: workspace resolution, title derivation, open-or-revise by agentSessionId, and both awaitMessage shapes (one long-poll and the chunked loop with progress and abort). */
2
+
3
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import type { Thread } from "@cueloop/schema";
8
+ import { DaemonServer } from "./server";
9
+ import { DaemonClient } from "./client";
10
+ import { awaitResolve, openReview, resolveWorkspace } from "./thread-review";
11
+ import { VcsSourceManager } from "./vcs-source";
12
+
13
+ const PLAN = "# Rollout Plan\n\nShip it in two stages.\n";
14
+
15
+ let home: string;
16
+ let server: DaemonServer;
17
+ let client: DaemonClient;
18
+
19
+ beforeEach(async () => {
20
+ home = mkdtempSync(join(tmpdir(), "cueloop-review-core-"));
21
+ server = new DaemonServer({ home, idleExitMs: 0 });
22
+ server.start();
23
+ client = await DaemonClient.connect({ home });
24
+ });
25
+ afterEach(() => {
26
+ client.close();
27
+ server.stop();
28
+ rmSync(home, { recursive: true, force: true });
29
+ });
30
+
31
+ function sh(args: string[], cwd: string): void {
32
+ const gitResult = Bun.spawnSync(args, { cwd, stdout: "ignore", stderr: "ignore" });
33
+
34
+ if (gitResult.exitCode !== 0) throw new Error(`${args.join(" ")} failed`);
35
+ }
36
+
37
+ describe("resolveWorkspace", () => {
38
+ test("a git repo resolves to its root and branch", async () => {
39
+ const repo = mkdtempSync(join(tmpdir(), "cueloop-ws-"));
40
+
41
+ try {
42
+ // Arrange
43
+ sh(["git", "init", "-q", "-b", "main"], repo);
44
+ sh(["git", "config", "user.email", "t@t"], repo);
45
+ sh(["git", "config", "user.name", "t"], repo);
46
+ sh(["git", "commit", "-q", "--allow-empty", "-m", "init"], repo);
47
+
48
+ // Act
49
+ const ws = await resolveWorkspace(repo);
50
+
51
+ // Assert
52
+ expect(ws.branch).toBe("main");
53
+ // macOS tmpdirs can resolve through /private; compare the tail
54
+ expect(ws.repoRoot.endsWith(repo.split("/").at(-1)!)).toBe(true);
55
+ } finally {
56
+ rmSync(repo, { recursive: true, force: true });
57
+ }
58
+ });
59
+
60
+ test("outside a repo the cwd itself is the workspace, branch detached", async () => {
61
+ // Act
62
+ const ws = await resolveWorkspace(home);
63
+
64
+ // Assert
65
+ expect(ws).toEqual({ repoRoot: home, branch: "detached" });
66
+ });
67
+ });
68
+
69
+ describe("openReview", () => {
70
+ test("a submitted JJ diff follows the same change through a rewrite", async () => {
71
+ const repo = mkdtempSync(join(tmpdir(), "cueloop-review-jj-"));
72
+
73
+ try {
74
+ sh(["git", "init", "-q", "-b", "main"], repo);
75
+ sh(["git", "config", "user.email", "t@t"], repo);
76
+ sh(["git", "config", "user.name", "t"], repo);
77
+ writeFileSync(join(repo, "a.txt"), "before\n");
78
+ sh(["git", "add", "a.txt"], repo);
79
+ sh(["git", "commit", "-qm", "initial"], repo);
80
+ sh(["jj", "git", "init", "--colocate", repo], repo);
81
+ writeFileSync(join(repo, "a.txt"), "first\n");
82
+ const sources = new VcsSourceManager(join(home, "vcs-config.toml"));
83
+ const firstDiff = await sources.capture(repo, "jj");
84
+ const first = await openReview(client, {
85
+ type: "diff",
86
+ content: firstDiff.patch,
87
+ cwd: repo,
88
+ vcs: "jj",
89
+ agent: "codex",
90
+ agentSessionId: "one-agent-session",
91
+ });
92
+
93
+ writeFileSync(join(repo, "a.txt"), "second\n");
94
+ const secondDiff = await sources.capture(repo, "jj");
95
+ const second = await openReview(client, {
96
+ type: "diff",
97
+ content: secondDiff.patch,
98
+ cwd: repo,
99
+ vcs: "jj",
100
+ agent: "codex",
101
+ agentSessionId: "one-agent-session",
102
+ });
103
+
104
+ expect(first.session.artifact.meta.vcs).toBe("jj");
105
+ expect(first.session.artifact.files?.[0]?.newContents).toBe("first\n");
106
+ expect(first.session.artifact.meta.vcsChangeId).toBe(
107
+ second.session.artifact.meta.vcsChangeId,
108
+ );
109
+ expect(second.id).toBe(first.id);
110
+ expect(second.session.revisions.map((revision) => revision.content)).toEqual([
111
+ firstDiff.patch,
112
+ secondDiff.patch,
113
+ ]);
114
+ const unmatched = await openReview(client, {
115
+ type: "diff",
116
+ content: "diff --git a/elsewhere b/elsewhere\n",
117
+ cwd: repo,
118
+ vcs: "jj",
119
+ agent: "codex",
120
+ agentSessionId: "one-agent-session",
121
+ });
122
+
123
+ expect(unmatched.id).toBe(first.id);
124
+ expect(unmatched.session.artifact.files).toEqual([]);
125
+ expect(unmatched.session.artifact.meta.vcsRevisionId).toBeUndefined();
126
+ sh(["jj", "new", "-m", "next change"], repo);
127
+ writeFileSync(join(repo, "b.txt"), "third\n");
128
+ const thirdDiff = await sources.capture(repo, "jj");
129
+ const third = await openReview(client, {
130
+ type: "diff",
131
+ content: thirdDiff.patch,
132
+ cwd: repo,
133
+ vcs: "jj",
134
+ agent: "codex",
135
+ agentSessionId: "one-agent-session",
136
+ });
137
+
138
+ expect(third.id).not.toBe(first.id);
139
+ const unmatchedAfterTwoChanges = await openReview(client, {
140
+ type: "diff",
141
+ content: "diff --git a/unmatched b/unmatched\n",
142
+ cwd: repo,
143
+ vcs: "jj",
144
+ agent: "codex",
145
+ agentSessionId: "one-agent-session",
146
+ });
147
+
148
+ expect(unmatchedAfterTwoChanges.id).not.toBe(first.id);
149
+ expect(unmatchedAfterTwoChanges.id).not.toBe(third.id);
150
+ expect((await client.sessionGet(third.id)).artifact.meta.vcsChangeId).toBe(
151
+ third.session.artifact.meta.vcsChangeId,
152
+ );
153
+ } finally {
154
+ rmSync(repo, { recursive: true, force: true });
155
+ }
156
+ });
157
+
158
+ test("shapes the artifact: agent, derived title, cwd", async () => {
159
+ // Act
160
+ const review = await openReview(client, {
161
+ type: "plan",
162
+ content: PLAN,
163
+ cwd: home,
164
+ agent: "test-agent",
165
+ });
166
+
167
+ // Assert
168
+ const session = review.session;
169
+
170
+ expect(session.status).toBe("pending");
171
+ expect(session.artifact.type).toBe("plan");
172
+ expect(session.artifact.meta.agent).toBe("test-agent");
173
+ expect(session.artifact.meta.title).toBe("Rollout Plan");
174
+ expect(session.artifact.meta.cwd).toBe(home);
175
+ expect(session.workspace).toEqual({ repoRoot: home, branch: "detached" });
176
+ });
177
+
178
+ test("an explicit title wins; diffs never derive one from content", async () => {
179
+ // Act
180
+ const titled = await openReview(client, {
181
+ type: "plan",
182
+ content: PLAN,
183
+ cwd: home,
184
+ title: "Custom",
185
+ });
186
+
187
+ // Assert
188
+ expect(titled.session.artifact.meta.title).toBe("Custom");
189
+
190
+ // Act
191
+ const diff = await openReview(client, {
192
+ type: "diff",
193
+ content: "# not a heading\n",
194
+ cwd: home,
195
+ });
196
+
197
+ // Assert
198
+ expect(diff.session.artifact.meta.title).toBeUndefined();
199
+ });
200
+
201
+ test("a pre-resolved workspace skips git resolution", async () => {
202
+ // Arrange
203
+ const ws = { repoRoot: "/elsewhere", branch: "feature" };
204
+
205
+ // Act
206
+ const review = await openReview(client, {
207
+ type: "diff",
208
+ content: "d",
209
+ cwd: home,
210
+ workspace: ws,
211
+ });
212
+
213
+ // Assert
214
+ expect(review.session.workspace).toEqual(ws);
215
+ });
216
+
217
+ test("the same agentSessionId revises instead of opening a new session", async () => {
218
+ // Arrange
219
+ const first = await openReview(client, {
220
+ type: "plan",
221
+ content: PLAN,
222
+ cwd: home,
223
+ agentSessionId: "agent-1",
224
+ });
225
+
226
+ // Act
227
+ const second = await openReview(client, {
228
+ type: "plan",
229
+ content: PLAN + "\nStage three.\n",
230
+ cwd: home,
231
+ agentSessionId: "agent-1",
232
+ });
233
+
234
+ // Assert
235
+ expect(second.id).toBe(first.id);
236
+ expect(second.session.revisions.length).toBe(2);
237
+ expect(second.session.status).toBe("pending");
238
+
239
+ // Act
240
+ const other = await openReview(client, {
241
+ type: "plan",
242
+ content: PLAN,
243
+ cwd: home,
244
+ agentSessionId: "agent-2",
245
+ });
246
+
247
+ // Assert
248
+ expect(other.id).not.toBe(first.id);
249
+ });
250
+
251
+ test("the same agentSessionId with a different type opens a new session, never a type mismatch", async () => {
252
+ // Arrange - the agent session's plan review is open
253
+ const plan = await openReview(client, {
254
+ type: "plan",
255
+ content: PLAN,
256
+ cwd: home,
257
+ agentSessionId: "agent-cross",
258
+ });
259
+
260
+ // Act - the same agent session now submits a reply for review
261
+ const reply = await openReview(client, {
262
+ type: "reply",
263
+ content: "# Findings\n\nThe cache is fine.\n",
264
+ cwd: home,
265
+ agentSessionId: "agent-cross",
266
+ });
267
+
268
+ // Assert - a fresh reply session; the plan session keeps its type and history
269
+ expect(reply.id).not.toBe(plan.id);
270
+ expect(reply.session.artifact.type).toBe("reply");
271
+ expect(reply.session.revisions.length).toBe(1);
272
+ expect((await client.sessionGet(plan.id)).artifact.type).toBe("plan");
273
+ });
274
+
275
+ test("per-file notes land as note annotations anchored at the file path", async () => {
276
+ // Act
277
+ const review = await openReview(client, {
278
+ type: "diff",
279
+ content: "d",
280
+ cwd: home,
281
+ notes: [
282
+ { path: "src/a.ts", body: "Renames b and adds c." },
283
+ { path: "src/b.ts", body: "Swaps the line." },
284
+ ],
285
+ });
286
+
287
+ // Assert
288
+ const notes = review.session.annotations;
289
+
290
+ expect(notes.length).toBe(2);
291
+ expect(notes.every((annotation) => annotation.kind === "note")).toBe(true);
292
+ expect(notes.map((annotation) => annotation.anchor.quote)).toEqual(["src/a.ts", "src/b.ts"]);
293
+ expect(notes[0]!.body).toBe("Renames b and adds c.");
294
+ });
295
+ });
296
+
297
+ describe("awaitMessage: one long-poll (the hook shape)", () => {
298
+ test("times out to pending; a later wait collects the stored message", async () => {
299
+ // Arrange
300
+ const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
301
+
302
+ // Assert
303
+ expect(await review.awaitMessage({ timeoutMs: 50 })).toBe("pending");
304
+
305
+ // Act
306
+ await client.sessionSendMessage(review.id, "approved", "Fine.");
307
+ const message = await review.awaitMessage({ timeoutMs: 1_000 });
308
+
309
+ // Assert
310
+ expect(message).not.toBe("pending");
311
+
312
+ if (message === "pending") throw new Error("unreachable");
313
+
314
+ expect(message.allow).toBe(true);
315
+ expect(message.message.body).toContain("Fine.");
316
+ expect(message.session.message!.outcome).toBe("approved");
317
+ });
318
+
319
+ test("changes_requested maps to allow=false with feedback.md attached", async () => {
320
+ // Arrange
321
+ const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
322
+ const waiting = review.awaitMessage({ timeoutMs: 5_000 });
323
+
324
+ // Act
325
+ await client.sessionSendMessage(review.id, "changes_requested", "One stage only.");
326
+ const message = await waiting;
327
+
328
+ // Assert
329
+ if (message === "pending") throw new Error("expected a message");
330
+
331
+ expect(message.allow).toBe(false);
332
+ expect(message.message.body).toContain("# Review: changes requested");
333
+ expect(message.message.body).toContain("One stage only.");
334
+ });
335
+ });
336
+
337
+ describe("awaitMessage: chunked loop (the pi shape)", () => {
338
+ test("onProgress sees fresh sessions between chunks; the message ends the loop", async () => {
339
+ // Arrange
340
+ const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
341
+ const seen: Thread[] = [];
342
+ const waiting = review.awaitMessage({
343
+ timeoutMs: Infinity,
344
+ pollMs: 100,
345
+ onProgress: (progress) => seen.push(progress),
346
+ });
347
+
348
+ // Act
349
+ await client.sessionAnnotate(review.id, {
350
+ id: "a1",
351
+ kind: "comment",
352
+ anchor: { quote: "two stages", prefix: "in ", suffix: "." },
353
+ body: "Name them.",
354
+ });
355
+
356
+ // Assert
357
+ // give the loop a chunk to observe the annotation before resolving
358
+ for (let i = 0; i < 100 && !seen.some((snapshot) => snapshot.annotations.length === 1); i++)
359
+ await Bun.sleep(20);
360
+ expect(seen.some((snapshot) => snapshot.annotations.length === 1)).toBe(true);
361
+
362
+ // Act
363
+ await client.sessionSendMessage(review.id, "changes_requested", "Too vague.");
364
+ const message = await waiting;
365
+
366
+ // Assert
367
+ if (message === "pending") throw new Error("expected a message");
368
+
369
+ expect(message.allow).toBe(false);
370
+ expect(message.session.annotations.length).toBe(1);
371
+ }, 15_000);
372
+
373
+ test("abort surfaces as pending and leaves the session collectable", async () => {
374
+ // Arrange
375
+ const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
376
+ const controller = new AbortController();
377
+ const waiting = review.awaitMessage({
378
+ timeoutMs: Infinity,
379
+ pollMs: 100,
380
+ signal: controller.signal,
381
+ });
382
+
383
+ // Act
384
+ controller.abort();
385
+
386
+ // Assert
387
+ expect(await waiting).toBe("pending");
388
+ expect((await client.sessionGet(review.id)).status).toBe("pending");
389
+ });
390
+
391
+ test("a finite budget runs out to pending", async () => {
392
+ // Arrange
393
+ const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
394
+
395
+ // Assert
396
+ expect(await review.awaitMessage({ timeoutMs: 120, pollMs: 50 })).toBe("pending");
397
+ });
398
+ });
399
+
400
+ describe("awaitResolve: the adapter wake seam (session id only)", () => {
401
+ test("resolves to the outcome when the message lands during the wait", async () => {
402
+ // Arrange
403
+ const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
404
+ const waiting = awaitResolve(client, review.id, { pollMs: 100 });
405
+
406
+ // Act
407
+ await client.sessionSendMessage(review.id, "approved", "Ship it.");
408
+ const message = await waiting;
409
+
410
+ // Assert
411
+ expect(message).not.toBeNull();
412
+ expect(message!.allow).toBe(true);
413
+ expect(message!.message.body).toContain("Ship it.");
414
+ expect(message!.session.message!.outcome).toBe("approved");
415
+ });
416
+
417
+ test("returns the stored message immediately when the session already resolved", async () => {
418
+ // Arrange - a detached waiter that only attaches after the human decided
419
+ const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
420
+
421
+ await client.sessionSendMessage(review.id, "changes_requested", "One stage only.");
422
+
423
+ // Act
424
+ const message = await awaitResolve(client, review.id);
425
+
426
+ // Assert
427
+ expect(message).not.toBeNull();
428
+ expect(message!.allow).toBe(false);
429
+ expect(message!.message.body).toContain("One stage only.");
430
+ });
431
+
432
+ test("returns null when the signal aborts first", async () => {
433
+ // Arrange
434
+ const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
435
+ const controller = new AbortController();
436
+ const waiting = awaitResolve(client, review.id, { pollMs: 100, signal: controller.signal });
437
+
438
+ // Act
439
+ controller.abort();
440
+
441
+ // Assert
442
+ expect(await waiting).toBeNull();
443
+ expect((await client.sessionGet(review.id)).status).toBe("pending");
444
+ });
445
+ });
446
+
447
+ describe("awaitResolve: the held wait keeps the daemon alive", () => {
448
+ test("a pending session does not idle-exit while a waiter is parked, then the message lands", async () => {
449
+ // Arrange - a daemon with an aggressive idle timer and its own home
450
+ const idleHome = mkdtempSync(join(tmpdir(), "cueloop-idle-"));
451
+ let idleExits = 0;
452
+ const idleServer = new DaemonServer({
453
+ home: idleHome,
454
+ idleExitMs: 30,
455
+ onIdleExit: () => idleExits++,
456
+ });
457
+
458
+ idleServer.start();
459
+ const waiterClient = await DaemonClient.connect({ home: idleHome });
460
+
461
+ try {
462
+ const review = await openReview(waiterClient, { type: "plan", content: PLAN, cwd: idleHome });
463
+ const waiting = awaitResolve(waiterClient, review.id, { pollMs: 40 });
464
+
465
+ // Act - wait well past the idle window while the review is still pending
466
+ await Bun.sleep(120);
467
+
468
+ // Assert - the parked waiter and the pending session both keep it alive
469
+ expect(idleExits).toBe(0);
470
+ expect((await waiterClient.ping()).pid).toBe(process.pid);
471
+
472
+ // Act - the human returns a message; the parked wait collects it
473
+ const resolver = await DaemonClient.connect({ home: idleHome });
474
+
475
+ await resolver.sessionSendMessage(review.id, "approved", "Good to go.");
476
+ resolver.close();
477
+ const message = await waiting;
478
+
479
+ // Assert
480
+ expect(message!.allow).toBe(true);
481
+ } finally {
482
+ waiterClient.close();
483
+ idleServer.stop();
484
+ rmSync(idleHome, { recursive: true, force: true });
485
+ }
486
+ }, 15_000);
487
+ });