@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.
- package/package.json +33 -22
- package/src/api.test.ts +891 -50
- package/src/api.ts +849 -72
- package/src/capabilities.test.ts +57 -0
- package/src/capabilities.ts +77 -0
- package/src/client.test.ts +24 -0
- package/src/client.ts +459 -69
- package/src/curate.ts +109 -0
- package/src/daemon-version.test.ts +93 -0
- package/src/diff-hot-reload.test.ts +186 -0
- package/src/diff-watcher.ts +102 -0
- package/src/herdr-pane.test.ts +240 -0
- package/src/herdr-pane.ts +167 -0
- package/src/herdr-split.test.ts +106 -0
- package/src/herdr-split.ts +92 -0
- package/src/herdr-tab-store.test.ts +96 -0
- package/src/herdr-tab-store.ts +66 -0
- package/src/index.ts +8 -9
- package/src/interruptible-wait.test.ts +127 -0
- package/src/interruptible-wait.ts +66 -0
- package/src/main.ts +4 -1
- package/src/paths.ts +39 -0
- package/src/project-files.test.ts +124 -0
- package/src/project-files.ts +56 -0
- package/src/protocol.ts +79 -7
- package/src/retention.test.ts +182 -0
- package/src/retention.ts +122 -0
- package/src/review.test.ts +255 -28
- package/src/review.ts +164 -62
- package/src/server.test.ts +381 -39
- package/src/server.ts +322 -84
- package/src/share-blob.test.ts +112 -0
- package/src/share-blob.ts +55 -0
- package/src/single-instance.test.ts +38 -11
- package/src/store.conformance.test.ts +41 -0
- package/src/store.jsonl.test.ts +159 -0
- package/src/store.ts +261 -34
- package/src/testing/store-conformance.ts +194 -0
- package/src/validate.test.ts +174 -28
- package/src/validate.ts +226 -16
- package/src/version.ts +6 -0
- package/src/working-tree.ts +149 -0
package/src/review.test.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
37
|
-
|
|
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
|
-
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
249
|
+
const seen: Thread[] = [];
|
|
133
250
|
const waiting = review.awaitVerdict({
|
|
134
251
|
timeoutMs: Infinity,
|
|
135
252
|
pollMs: 100,
|
|
136
|
-
onProgress: (
|
|
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((
|
|
146
|
-
|
|
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({
|
|
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
|
+
});
|