@cueloop/daemon 0.1.0-alpha.4 → 0.1.0-alpha.40
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 +24 -15
- package/src/api.test.ts +278 -46
- package/src/api.ts +182 -52
- package/src/capabilities.test.ts +37 -0
- package/src/capabilities.ts +30 -0
- package/src/client.ts +124 -36
- package/src/herdr-pane.test.ts +236 -0
- package/src/herdr-pane.ts +143 -0
- package/src/herdr-split.test.ts +103 -0
- package/src/herdr-split.ts +84 -0
- package/src/herdr-tab-store.test.ts +95 -0
- package/src/herdr-tab-store.ts +67 -0
- package/src/index.ts +8 -8
- package/src/main.ts +3 -1
- package/src/paths.ts +5 -0
- package/src/protocol.ts +44 -7
- package/src/review.test.ts +362 -0
- package/src/review.ts +263 -0
- package/src/server.test.ts +151 -20
- package/src/server.ts +117 -55
- package/src/share-blob.test.ts +62 -0
- package/src/share-blob.ts +51 -0
- package/src/single-instance.test.ts +28 -11
- package/src/store.ts +7 -5
- package/src/validate.test.ts +182 -14
- package/src/validate.ts +129 -37
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* herdr adapter scratch, kept out of the core session record: the tab cueloop
|
|
3
|
+
* opened to render each review, keyed by session id. One JSON map, atomic write.
|
|
4
|
+
* The core `ReviewSession` never mentions herdr; this side-store is the whole
|
|
5
|
+
* leak boundary.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { herdrTabsPath } from "./paths";
|
|
10
|
+
|
|
11
|
+
/** The tab and its root pane: pane id checks liveness, tab id focuses it. */
|
|
12
|
+
export interface HerdrTabHandle {
|
|
13
|
+
tabId: string;
|
|
14
|
+
paneId: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Guard a persisted entry - both ids must be present strings, or it is dropped. */
|
|
18
|
+
function isHerdrTabHandle(value: unknown): value is HerdrTabHandle {
|
|
19
|
+
return (
|
|
20
|
+
typeof value === "object" &&
|
|
21
|
+
value !== null &&
|
|
22
|
+
typeof (value as HerdrTabHandle).tabId === "string" &&
|
|
23
|
+
typeof (value as HerdrTabHandle).paneId === "string"
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class HerdrTabStore {
|
|
28
|
+
private tabs = new Map<string, HerdrTabHandle>();
|
|
29
|
+
private readonly path: string;
|
|
30
|
+
|
|
31
|
+
constructor(home: string) {
|
|
32
|
+
this.path = herdrTabsPath(home);
|
|
33
|
+
this.load();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private load(): void {
|
|
37
|
+
try {
|
|
38
|
+
const parsed: unknown = JSON.parse(readFileSync(this.path, "utf8"));
|
|
39
|
+
if (typeof parsed !== "object" || parsed === null) return;
|
|
40
|
+
for (const [sessionId, value] of Object.entries(parsed)) {
|
|
41
|
+
if (isHerdrTabHandle(value))
|
|
42
|
+
this.tabs.set(sessionId, { tabId: value.tabId, paneId: value.paneId });
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
// absent, unreadable, or malformed: start empty - a stale tab just reopens on resubmit
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private persist(): void {
|
|
50
|
+
const tempPath = this.path + ".tmp";
|
|
51
|
+
writeFileSync(tempPath, JSON.stringify(Object.fromEntries(this.tabs), null, 2));
|
|
52
|
+
renameSync(tempPath, this.path);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
get(sessionId: string): HerdrTabHandle | null {
|
|
56
|
+
return this.tabs.get(sessionId) ?? null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
set(sessionId: string, handle: HerdrTabHandle): void {
|
|
60
|
+
this.tabs.set(sessionId, handle);
|
|
61
|
+
this.persist();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
delete(sessionId: string): void {
|
|
65
|
+
if (this.tabs.delete(sessionId)) this.persist();
|
|
66
|
+
}
|
|
67
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
export
|
|
8
|
-
export {
|
|
1
|
+
/**
|
|
2
|
+
* The daemon's public surface. Consumers reach the client and the review
|
|
3
|
+
* helpers through the ./client and ./review subpaths; this barrel carries
|
|
4
|
+
* only the two names imported bare: the server and the home-dir resolver.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { DaemonServer, type DaemonOptions } from "./server";
|
|
8
|
+
export { cueloopHome } from "./paths";
|
package/src/main.ts
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
import { DaemonServer } from "./server";
|
|
4
4
|
|
|
5
|
-
const idleExitMs = process.env.CUELOOP_IDLE_EXIT_MS
|
|
5
|
+
const idleExitMs = process.env.CUELOOP_IDLE_EXIT_MS
|
|
6
|
+
? Number(process.env.CUELOOP_IDLE_EXIT_MS)
|
|
7
|
+
: undefined;
|
|
6
8
|
const server = new DaemonServer({ idleExitMs });
|
|
7
9
|
const path = server.start();
|
|
8
10
|
if (path === null) {
|
package/src/paths.ts
CHANGED
|
@@ -25,3 +25,8 @@ export function pidPath(home = cueloopHome()): string {
|
|
|
25
25
|
export function lockPath(home = cueloopHome()): string {
|
|
26
26
|
return join(home, "cueloop.lock");
|
|
27
27
|
}
|
|
28
|
+
|
|
29
|
+
/** Adapter scratch: herdr tab handles keyed by session id, kept out of the core session record. */
|
|
30
|
+
export function herdrTabsPath(home = cueloopHome()): string {
|
|
31
|
+
return join(home, "herdr-tabs.json");
|
|
32
|
+
}
|
package/src/protocol.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Wire protocol
|
|
2
|
+
* Wire protocol: newline-delimited JSON over the unix socket.
|
|
3
3
|
* Requests: { id, method, params } Responses: { id, result } | { id, error }
|
|
4
4
|
* Events (push, after events.subscribe): { event, sessionId }
|
|
5
5
|
*/
|
|
@@ -25,16 +25,53 @@ export type Frame = Response | EventFrame;
|
|
|
25
25
|
|
|
26
26
|
/** Incremental NDJSON splitter; tolerates partial writes. */
|
|
27
27
|
export class LineBuffer {
|
|
28
|
-
private
|
|
28
|
+
private buffered = "";
|
|
29
29
|
|
|
30
30
|
push(chunk: string, onLine: (line: string) => void): void {
|
|
31
|
-
this.
|
|
31
|
+
this.buffered += chunk;
|
|
32
32
|
for (;;) {
|
|
33
|
-
const
|
|
34
|
-
if (
|
|
35
|
-
const line = this.
|
|
36
|
-
this.
|
|
33
|
+
const newlineIndex = this.buffered.indexOf("\n");
|
|
34
|
+
if (newlineIndex === -1) return;
|
|
35
|
+
const line = this.buffered.slice(0, newlineIndex).trim();
|
|
36
|
+
this.buffered = this.buffered.slice(newlineIndex + 1);
|
|
37
37
|
if (line) onLine(line);
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
|
+
|
|
42
|
+
/** The surface both Bun.listen and Bun.connect sockets satisfy. */
|
|
43
|
+
interface WritableSocket {
|
|
44
|
+
write(data: Buffer): number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Outbound writes honoring socket backpressure. A frame larger than the
|
|
49
|
+
* kernel buffer is written partially by the socket; the unwritten tail must
|
|
50
|
+
* wait for the drain callback or the frame truncates mid-line and the NDJSON
|
|
51
|
+
* stream corrupts for good. Frames never reorder: while a tail is pending,
|
|
52
|
+
* new writes append behind it.
|
|
53
|
+
*/
|
|
54
|
+
export class BackpressureWriter {
|
|
55
|
+
private pending: Buffer | null = null;
|
|
56
|
+
|
|
57
|
+
constructor(private socket: WritableSocket) {}
|
|
58
|
+
|
|
59
|
+
write(data: string): void {
|
|
60
|
+
const bytes = Buffer.from(data, "utf8");
|
|
61
|
+
if (this.pending) {
|
|
62
|
+
this.pending = Buffer.concat([this.pending, bytes]);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const written = Math.max(0, this.socket.write(bytes));
|
|
66
|
+
if (written < bytes.length) this.pending = bytes.subarray(written);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Call from the socket's drain handler. */
|
|
70
|
+
drain(): void {
|
|
71
|
+
if (!this.pending) return;
|
|
72
|
+
const tail = this.pending;
|
|
73
|
+
this.pending = null;
|
|
74
|
+
const written = Math.max(0, this.socket.write(tail));
|
|
75
|
+
if (written < tail.length) this.pending = tail.subarray(written);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,362 @@
|
|
|
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). */
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
4
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import type { ReviewSession } from "@cueloop/schema";
|
|
8
|
+
import { DaemonServer } from "./server";
|
|
9
|
+
import { DaemonClient } from "./client";
|
|
10
|
+
import { awaitResolve, openReview, resolveWorkspace } from "./review";
|
|
11
|
+
|
|
12
|
+
const PLAN = "# Rollout Plan\n\nShip it in two stages.\n";
|
|
13
|
+
|
|
14
|
+
let home: string;
|
|
15
|
+
let server: DaemonServer;
|
|
16
|
+
let client: DaemonClient;
|
|
17
|
+
|
|
18
|
+
beforeEach(async () => {
|
|
19
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-review-core-"));
|
|
20
|
+
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
21
|
+
server.start();
|
|
22
|
+
client = await DaemonClient.connect({ home });
|
|
23
|
+
});
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
client.close();
|
|
26
|
+
server.stop();
|
|
27
|
+
rmSync(home, { recursive: true, force: true });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function sh(args: string[], cwd: string): void {
|
|
31
|
+
const gitResult = Bun.spawnSync(args, { cwd, stdout: "ignore", stderr: "ignore" });
|
|
32
|
+
if (gitResult.exitCode !== 0) throw new Error(`${args.join(" ")} failed`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("resolveWorkspace", () => {
|
|
36
|
+
test("a git repo resolves to its root and branch", async () => {
|
|
37
|
+
const repo = mkdtempSync(join(tmpdir(), "cueloop-ws-"));
|
|
38
|
+
try {
|
|
39
|
+
// Arrange
|
|
40
|
+
sh(["git", "init", "-q", "-b", "main"], repo);
|
|
41
|
+
sh(["git", "config", "user.email", "t@t"], repo);
|
|
42
|
+
sh(["git", "config", "user.name", "t"], repo);
|
|
43
|
+
sh(["git", "commit", "-q", "--allow-empty", "-m", "init"], repo);
|
|
44
|
+
|
|
45
|
+
// Act
|
|
46
|
+
const ws = await resolveWorkspace(repo);
|
|
47
|
+
|
|
48
|
+
// Assert
|
|
49
|
+
expect(ws.branch).toBe("main");
|
|
50
|
+
// macOS tmpdirs can resolve through /private; compare the tail
|
|
51
|
+
expect(ws.repoRoot.endsWith(repo.split("/").at(-1)!)).toBe(true);
|
|
52
|
+
} finally {
|
|
53
|
+
rmSync(repo, { recursive: true, force: true });
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("outside a repo the cwd itself is the workspace, branch detached", async () => {
|
|
58
|
+
// Act
|
|
59
|
+
const ws = await resolveWorkspace(home);
|
|
60
|
+
|
|
61
|
+
// Assert
|
|
62
|
+
expect(ws).toEqual({ repoRoot: home, branch: "detached" });
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("openReview", () => {
|
|
67
|
+
test("shapes the artifact: agent, derived title, cwd", async () => {
|
|
68
|
+
// Act
|
|
69
|
+
const review = await openReview(client, {
|
|
70
|
+
type: "plan",
|
|
71
|
+
content: PLAN,
|
|
72
|
+
cwd: home,
|
|
73
|
+
agent: "test-agent",
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Assert
|
|
77
|
+
const session = review.session;
|
|
78
|
+
expect(session.status).toBe("pending");
|
|
79
|
+
expect(session.artifact.type).toBe("plan");
|
|
80
|
+
expect(session.artifact.meta.agent).toBe("test-agent");
|
|
81
|
+
expect(session.artifact.meta.title).toBe("Rollout Plan");
|
|
82
|
+
expect(session.artifact.meta.cwd).toBe(home);
|
|
83
|
+
expect(session.workspace).toEqual({ repoRoot: home, branch: "detached" });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("an explicit title wins; diffs never derive one from content", async () => {
|
|
87
|
+
// Act
|
|
88
|
+
const titled = await openReview(client, {
|
|
89
|
+
type: "plan",
|
|
90
|
+
content: PLAN,
|
|
91
|
+
cwd: home,
|
|
92
|
+
title: "Custom",
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Assert
|
|
96
|
+
expect(titled.session.artifact.meta.title).toBe("Custom");
|
|
97
|
+
|
|
98
|
+
// Act
|
|
99
|
+
const diff = await openReview(client, {
|
|
100
|
+
type: "diff",
|
|
101
|
+
content: "# not a heading\n",
|
|
102
|
+
cwd: home,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Assert
|
|
106
|
+
expect(diff.session.artifact.meta.title).toBeUndefined();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("a pre-resolved workspace skips git resolution", async () => {
|
|
110
|
+
// Arrange
|
|
111
|
+
const ws = { repoRoot: "/elsewhere", branch: "feature" };
|
|
112
|
+
|
|
113
|
+
// Act
|
|
114
|
+
const review = await openReview(client, {
|
|
115
|
+
type: "diff",
|
|
116
|
+
content: "d",
|
|
117
|
+
cwd: home,
|
|
118
|
+
workspace: ws,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Assert
|
|
122
|
+
expect(review.session.workspace).toEqual(ws);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("the same agentSessionId revises instead of opening a new session", async () => {
|
|
126
|
+
// Arrange
|
|
127
|
+
const first = await openReview(client, {
|
|
128
|
+
type: "plan",
|
|
129
|
+
content: PLAN,
|
|
130
|
+
cwd: home,
|
|
131
|
+
agentSessionId: "agent-1",
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Act
|
|
135
|
+
const second = await openReview(client, {
|
|
136
|
+
type: "plan",
|
|
137
|
+
content: PLAN + "\nStage three.\n",
|
|
138
|
+
cwd: home,
|
|
139
|
+
agentSessionId: "agent-1",
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Assert
|
|
143
|
+
expect(second.id).toBe(first.id);
|
|
144
|
+
expect(second.session.revisions.length).toBe(2);
|
|
145
|
+
expect(second.session.status).toBe("pending");
|
|
146
|
+
|
|
147
|
+
// Act
|
|
148
|
+
const other = await openReview(client, {
|
|
149
|
+
type: "plan",
|
|
150
|
+
content: PLAN,
|
|
151
|
+
cwd: home,
|
|
152
|
+
agentSessionId: "agent-2",
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// Assert
|
|
156
|
+
expect(other.id).not.toBe(first.id);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("per-file notes land as note annotations anchored at the file path", async () => {
|
|
160
|
+
// Act
|
|
161
|
+
const review = await openReview(client, {
|
|
162
|
+
type: "diff",
|
|
163
|
+
content: "d",
|
|
164
|
+
cwd: home,
|
|
165
|
+
notes: [
|
|
166
|
+
{ path: "src/a.ts", body: "Renames b and adds c." },
|
|
167
|
+
{ path: "src/b.ts", body: "Swaps the line." },
|
|
168
|
+
],
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// Assert
|
|
172
|
+
const notes = review.session.annotations;
|
|
173
|
+
expect(notes.length).toBe(2);
|
|
174
|
+
expect(notes.every((annotation) => annotation.kind === "note")).toBe(true);
|
|
175
|
+
expect(notes.map((annotation) => annotation.anchor.quote)).toEqual(["src/a.ts", "src/b.ts"]);
|
|
176
|
+
expect(notes[0]!.body).toBe("Renames b and adds c.");
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe("awaitVerdict: one long-poll (the hook shape)", () => {
|
|
181
|
+
test("times out to pending; a later wait collects the stored verdict", async () => {
|
|
182
|
+
// Arrange
|
|
183
|
+
const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
|
|
184
|
+
|
|
185
|
+
// Assert
|
|
186
|
+
expect(await review.awaitVerdict({ timeoutMs: 50 })).toBe("pending");
|
|
187
|
+
|
|
188
|
+
// Act
|
|
189
|
+
await client.sessionResolve(review.id, "approve", "Fine.");
|
|
190
|
+
const verdict = await review.awaitVerdict({ timeoutMs: 1_000 });
|
|
191
|
+
|
|
192
|
+
// Assert
|
|
193
|
+
expect(verdict).not.toBe("pending");
|
|
194
|
+
if (verdict === "pending") throw new Error("unreachable");
|
|
195
|
+
expect(verdict.allow).toBe(true);
|
|
196
|
+
expect(verdict.feedback).toContain("Fine.");
|
|
197
|
+
expect(verdict.session.verdict!.kind).toBe("approve");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("request_changes maps to allow=false with feedback.md attached", async () => {
|
|
201
|
+
// Arrange
|
|
202
|
+
const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
|
|
203
|
+
const waiting = review.awaitVerdict({ timeoutMs: 5_000 });
|
|
204
|
+
|
|
205
|
+
// Act
|
|
206
|
+
await client.sessionResolve(review.id, "request_changes", "One stage only.");
|
|
207
|
+
const verdict = await waiting;
|
|
208
|
+
|
|
209
|
+
// Assert
|
|
210
|
+
if (verdict === "pending") throw new Error("expected a verdict");
|
|
211
|
+
expect(verdict.allow).toBe(false);
|
|
212
|
+
expect(verdict.feedback).toContain("# Review: request changes");
|
|
213
|
+
expect(verdict.feedback).toContain("One stage only.");
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe("awaitVerdict: chunked loop (the pi shape)", () => {
|
|
218
|
+
test("onProgress sees fresh sessions between chunks; the verdict ends the loop", async () => {
|
|
219
|
+
// Arrange
|
|
220
|
+
const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
|
|
221
|
+
const seen: ReviewSession[] = [];
|
|
222
|
+
const waiting = review.awaitVerdict({
|
|
223
|
+
timeoutMs: Infinity,
|
|
224
|
+
pollMs: 100,
|
|
225
|
+
onProgress: (progress) => seen.push(progress),
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// Act
|
|
229
|
+
await client.sessionAnnotate(review.id, {
|
|
230
|
+
id: "a1",
|
|
231
|
+
kind: "comment",
|
|
232
|
+
anchor: { quote: "two stages", prefix: "in ", suffix: "." },
|
|
233
|
+
body: "Name them.",
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// Assert
|
|
237
|
+
// give the loop a chunk to observe the annotation before resolving
|
|
238
|
+
for (let i = 0; i < 100 && !seen.some((snapshot) => snapshot.annotations.length === 1); i++)
|
|
239
|
+
await Bun.sleep(20);
|
|
240
|
+
expect(seen.some((snapshot) => snapshot.annotations.length === 1)).toBe(true);
|
|
241
|
+
|
|
242
|
+
// Act
|
|
243
|
+
await client.sessionResolve(review.id, "request_changes", "Too vague.");
|
|
244
|
+
const verdict = await waiting;
|
|
245
|
+
|
|
246
|
+
// Assert
|
|
247
|
+
if (verdict === "pending") throw new Error("expected a verdict");
|
|
248
|
+
expect(verdict.allow).toBe(false);
|
|
249
|
+
expect(verdict.session.annotations.length).toBe(1);
|
|
250
|
+
}, 15_000);
|
|
251
|
+
|
|
252
|
+
test("abort surfaces as pending and leaves the session collectable", async () => {
|
|
253
|
+
// Arrange
|
|
254
|
+
const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
|
|
255
|
+
const controller = new AbortController();
|
|
256
|
+
const waiting = review.awaitVerdict({
|
|
257
|
+
timeoutMs: Infinity,
|
|
258
|
+
pollMs: 100,
|
|
259
|
+
signal: controller.signal,
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// Act
|
|
263
|
+
controller.abort();
|
|
264
|
+
|
|
265
|
+
// Assert
|
|
266
|
+
expect(await waiting).toBe("pending");
|
|
267
|
+
expect((await client.sessionGet(review.id)).status).toBe("pending");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("a finite budget runs out to pending", async () => {
|
|
271
|
+
// Arrange
|
|
272
|
+
const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
|
|
273
|
+
|
|
274
|
+
// Assert
|
|
275
|
+
expect(await review.awaitVerdict({ timeoutMs: 120, pollMs: 50 })).toBe("pending");
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
describe("awaitResolve: the adapter wake seam (session id only)", () => {
|
|
280
|
+
test("resolves to the outcome when the verdict lands during the wait", async () => {
|
|
281
|
+
// Arrange
|
|
282
|
+
const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
|
|
283
|
+
const waiting = awaitResolve(client, review.id, { pollMs: 100 });
|
|
284
|
+
|
|
285
|
+
// Act
|
|
286
|
+
await client.sessionResolve(review.id, "approve", "Ship it.");
|
|
287
|
+
const verdict = await waiting;
|
|
288
|
+
|
|
289
|
+
// Assert
|
|
290
|
+
expect(verdict).not.toBeNull();
|
|
291
|
+
expect(verdict!.allow).toBe(true);
|
|
292
|
+
expect(verdict!.feedback).toContain("Ship it.");
|
|
293
|
+
expect(verdict!.session.verdict!.kind).toBe("approve");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("returns the stored verdict immediately when the session already resolved", async () => {
|
|
297
|
+
// Arrange - a detached waiter that only attaches after the human decided
|
|
298
|
+
const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
|
|
299
|
+
await client.sessionResolve(review.id, "request_changes", "One stage only.");
|
|
300
|
+
|
|
301
|
+
// Act
|
|
302
|
+
const verdict = await awaitResolve(client, review.id);
|
|
303
|
+
|
|
304
|
+
// Assert
|
|
305
|
+
expect(verdict).not.toBeNull();
|
|
306
|
+
expect(verdict!.allow).toBe(false);
|
|
307
|
+
expect(verdict!.feedback).toContain("One stage only.");
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("returns null when the signal aborts first", async () => {
|
|
311
|
+
// Arrange
|
|
312
|
+
const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
|
|
313
|
+
const controller = new AbortController();
|
|
314
|
+
const waiting = awaitResolve(client, review.id, { pollMs: 100, signal: controller.signal });
|
|
315
|
+
|
|
316
|
+
// Act
|
|
317
|
+
controller.abort();
|
|
318
|
+
|
|
319
|
+
// Assert
|
|
320
|
+
expect(await waiting).toBeNull();
|
|
321
|
+
expect((await client.sessionGet(review.id)).status).toBe("pending");
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
describe("awaitResolve: the held wait keeps the daemon alive", () => {
|
|
326
|
+
test("a pending session does not idle-exit while a waiter is parked, then the verdict lands", async () => {
|
|
327
|
+
// Arrange - a daemon with an aggressive idle timer and its own home
|
|
328
|
+
const idleHome = mkdtempSync(join(tmpdir(), "cueloop-idle-"));
|
|
329
|
+
let idleExits = 0;
|
|
330
|
+
const idleServer = new DaemonServer({
|
|
331
|
+
home: idleHome,
|
|
332
|
+
idleExitMs: 30,
|
|
333
|
+
onIdleExit: () => idleExits++,
|
|
334
|
+
});
|
|
335
|
+
idleServer.start();
|
|
336
|
+
const waiterClient = await DaemonClient.connect({ home: idleHome });
|
|
337
|
+
try {
|
|
338
|
+
const review = await openReview(waiterClient, { type: "plan", content: PLAN, cwd: idleHome });
|
|
339
|
+
const waiting = awaitResolve(waiterClient, review.id, { pollMs: 40 });
|
|
340
|
+
|
|
341
|
+
// Act - wait well past the idle window while the review is still pending
|
|
342
|
+
await Bun.sleep(120);
|
|
343
|
+
|
|
344
|
+
// Assert - the parked waiter and the pending session both keep it alive
|
|
345
|
+
expect(idleExits).toBe(0);
|
|
346
|
+
expect((await waiterClient.ping()).pid).toBe(process.pid);
|
|
347
|
+
|
|
348
|
+
// Act - the human returns a verdict; the parked wait collects it
|
|
349
|
+
const resolver = await DaemonClient.connect({ home: idleHome });
|
|
350
|
+
await resolver.sessionResolve(review.id, "approve", "Good to go.");
|
|
351
|
+
resolver.close();
|
|
352
|
+
const verdict = await waiting;
|
|
353
|
+
|
|
354
|
+
// Assert
|
|
355
|
+
expect(verdict!.allow).toBe(true);
|
|
356
|
+
} finally {
|
|
357
|
+
waiterClient.close();
|
|
358
|
+
idleServer.stop();
|
|
359
|
+
rmSync(idleHome, { recursive: true, force: true });
|
|
360
|
+
}
|
|
361
|
+
}, 15_000);
|
|
362
|
+
});
|