@cueloop/daemon 0.1.0-alpha.0 → 0.1.0-alpha.100
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/README.md +7 -0
- package/SDK.md +102 -0
- package/package.json +43 -16
- package/src/agent-tools.ts +98 -0
- package/src/agent-turn.test.ts +24 -0
- package/src/agent-turn.ts +34 -0
- package/src/api.test.ts +1081 -65
- package/src/api.ts +1425 -89
- package/src/capabilities.test.ts +57 -0
- package/src/capabilities.ts +95 -0
- package/src/client-errors.ts +24 -0
- package/src/client-reliability.test.ts +189 -0
- package/src/client.test.ts +37 -0
- package/src/client.ts +820 -94
- package/src/curate.ts +109 -0
- package/src/daemon-sdk-effect.ts +113 -0
- package/src/daemon-sdk.test.ts +306 -0
- package/src/daemon-sdk.ts +397 -0
- package/src/daemon-version.test.ts +142 -0
- package/src/diff-hot-reload.test.ts +419 -0
- package/src/diff-watcher.test.ts +117 -0
- package/src/diff-watcher.ts +326 -0
- package/src/examples/daemon-sdk.ts +55 -0
- package/src/gh.test.ts +59 -0
- package/src/gh.ts +74 -0
- package/src/ghostty-thread-surface-store.test.ts +49 -0
- package/src/ghostty-thread-surface-store.ts +101 -0
- package/src/ghostty-thread-surface.test.ts +398 -0
- package/src/ghostty-thread-surface.ts +267 -0
- package/src/harness-delivery.test.ts +269 -0
- package/src/harness-diagnostics.test.ts +26 -0
- package/src/harness-diagnostics.ts +30 -0
- package/src/harness-state-store.test.ts +93 -0
- package/src/harness-state-store.ts +213 -0
- package/src/herdr-split.test.ts +106 -0
- package/src/herdr-split.ts +85 -0
- package/src/herdr-thread-surface-store.test.ts +81 -0
- package/src/herdr-thread-surface-store.ts +66 -0
- package/src/herdr-thread-surface.test.ts +465 -0
- package/src/herdr-thread-surface.ts +378 -0
- package/src/index.ts +8 -8
- package/src/interruptible-wait.test.ts +127 -0
- package/src/interruptible-wait.ts +66 -0
- package/src/jj-working-tree.ts +131 -0
- package/src/main.ts +4 -1
- package/src/operation-receipts.test.ts +111 -0
- package/src/operation-receipts.ts +91 -0
- package/src/paths.ts +49 -0
- package/src/pr-poller.test.ts +102 -0
- package/src/pr-poller.ts +73 -0
- package/src/project-files.test.ts +134 -0
- package/src/project-files.ts +68 -0
- package/src/protocol.ts +78 -7
- package/src/retention.test.ts +191 -0
- package/src/retention.ts +123 -0
- package/src/sdk-identities.ts +35 -0
- package/src/server.test.ts +465 -40
- package/src/server.ts +601 -88
- package/src/share-blob.test.ts +119 -0
- package/src/share-blob.ts +57 -0
- package/src/single-instance.test.ts +162 -13
- package/src/store.conformance.test.ts +41 -0
- package/src/store.jsonl.test.ts +173 -0
- package/src/store.ts +267 -34
- package/src/testing/store-conformance.ts +192 -0
- package/src/thread-agent-validation.ts +119 -0
- package/src/thread-agent.test.ts +412 -0
- package/src/thread-agent.ts +713 -0
- package/src/thread-review.test.ts +483 -0
- package/src/thread-review.ts +382 -0
- package/src/thread-subscription.test.ts +174 -0
- package/src/thread-subscription.ts +129 -0
- package/src/thread-surface-config.test.ts +63 -0
- package/src/thread-surface-config.ts +61 -0
- package/src/validate.test.ts +366 -21
- package/src/validate.ts +458 -47
- package/src/vcs-source.test.ts +180 -0
- package/src/vcs-source.ts +229 -0
- package/src/version.ts +6 -0
- package/src/working-tree.ts +150 -0
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isMarkdownArtifact,
|
|
3
|
+
newAnnotationId,
|
|
4
|
+
type Annotation,
|
|
5
|
+
type Artifact,
|
|
6
|
+
type ArtifactType,
|
|
7
|
+
type DiffFileContents,
|
|
8
|
+
type DiffSource,
|
|
9
|
+
type Thread,
|
|
10
|
+
type WorkspaceKey,
|
|
11
|
+
type WorkflowKind,
|
|
12
|
+
} from "@cueloop/schema";
|
|
13
|
+
import { execFile } from "node:child_process";
|
|
14
|
+
import { promisify } from "node:util";
|
|
15
|
+
import { messageResponse } from "./api";
|
|
16
|
+
import { ABORTED, pollUntilResolved, raceAbort } from "./interruptible-wait";
|
|
17
|
+
import { VcsSourceManager } from "./vcs-source";
|
|
18
|
+
|
|
19
|
+
// Adapters and CLI primitives reach the message mapping through this module too,
|
|
20
|
+
// so a session obtained outside a ReviewHandle maps the same way.
|
|
21
|
+
export { messageResponse };
|
|
22
|
+
|
|
23
|
+
async function git(args: string[], cwd: string): Promise<string | null> {
|
|
24
|
+
try {
|
|
25
|
+
const { stdout } = await promisify(execFile)("git", args, { cwd });
|
|
26
|
+
|
|
27
|
+
return stdout.trim();
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function jjRoot(cwd: string): Promise<string | null> {
|
|
34
|
+
try {
|
|
35
|
+
const { stdout } = await promisify(execFile)("jj", ["--ignore-working-copy", "root"], { cwd });
|
|
36
|
+
|
|
37
|
+
return stdout.trim();
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The oldest root commit reachable from HEAD; the project key that survives moving or re-cloning the repo. */
|
|
44
|
+
function earliestRootCommit(revList: string | null): string | undefined {
|
|
45
|
+
if (!revList) return undefined;
|
|
46
|
+
const roots = revList.split("\n").filter((line) => line.length > 0);
|
|
47
|
+
|
|
48
|
+
// --date-order lists newest first, so the last root is the earliest
|
|
49
|
+
return roots.at(-1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Workspace key resolution: repo root, branch, and the project identity (root commit + remote) from the cwd. */
|
|
53
|
+
export async function resolveWorkspace(cwd = process.cwd()): Promise<WorkspaceKey> {
|
|
54
|
+
const [gitRepoRoot, jjRepoRoot] = await Promise.all([
|
|
55
|
+
git(["rev-parse", "--show-toplevel"], cwd),
|
|
56
|
+
jjRoot(cwd),
|
|
57
|
+
]);
|
|
58
|
+
const nativeJjRoot =
|
|
59
|
+
jjRepoRoot && (!gitRepoRoot || jjRepoRoot.length > gitRepoRoot.length) ? jjRepoRoot : null;
|
|
60
|
+
const repoRoot = nativeJjRoot ?? gitRepoRoot ?? cwd;
|
|
61
|
+
const [gitBranch, isShallow, rootList, gitRemote] = nativeJjRoot
|
|
62
|
+
? [null, null, null, null]
|
|
63
|
+
: await Promise.all([
|
|
64
|
+
git(["rev-parse", "--abbrev-ref", "HEAD"], cwd),
|
|
65
|
+
git(["rev-parse", "--is-shallow-repository"], cwd),
|
|
66
|
+
git(["rev-list", "--max-parents=0", "--date-order", "HEAD"], cwd),
|
|
67
|
+
git(["remote", "get-url", "origin"], cwd),
|
|
68
|
+
]);
|
|
69
|
+
const branch = nativeJjRoot ? "jj" : (gitBranch ?? "detached");
|
|
70
|
+
// a shallow clone's oldest commit is the graft boundary, not the true root, so
|
|
71
|
+
// it would key a different project than a full clone - leave it unset instead
|
|
72
|
+
const shallow = nativeJjRoot !== null || isShallow === "true";
|
|
73
|
+
const rootCommit = shallow ? undefined : earliestRootCommit(rootList);
|
|
74
|
+
const remote = nativeJjRoot ? null : gitRemote;
|
|
75
|
+
|
|
76
|
+
const workspace: WorkspaceKey = { repoRoot, branch };
|
|
77
|
+
|
|
78
|
+
// a repo with no commits (or a shallow clone) has no reliable root, so the thread stays standalone
|
|
79
|
+
if (rootCommit) workspace.rootCommit = rootCommit;
|
|
80
|
+
if (remote) workspace.remote = remote;
|
|
81
|
+
|
|
82
|
+
return workspace;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function firstHeading(markdown: string): string | undefined {
|
|
86
|
+
const headingMatch = markdown.match(/^#\s+(.+)$/m);
|
|
87
|
+
|
|
88
|
+
return headingMatch?.[1]?.trim();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface OpenReviewOptions {
|
|
92
|
+
type: ArtifactType;
|
|
93
|
+
workflow?: WorkflowKind;
|
|
94
|
+
content: string;
|
|
95
|
+
cwd?: string;
|
|
96
|
+
vcs?: string;
|
|
97
|
+
vcsChangeId?: string;
|
|
98
|
+
workspace?: WorkspaceKey;
|
|
99
|
+
agent?: string;
|
|
100
|
+
agentSessionId?: string;
|
|
101
|
+
planPath?: string;
|
|
102
|
+
prototypePath?: string;
|
|
103
|
+
pr?: string;
|
|
104
|
+
prBrief?: string;
|
|
105
|
+
prBaseSha?: string;
|
|
106
|
+
prHeadSha?: string;
|
|
107
|
+
prUrl?: string;
|
|
108
|
+
herdrPane?: string;
|
|
109
|
+
files?: DiffFileContents[];
|
|
110
|
+
title?: string;
|
|
111
|
+
notes?: ReviewNote[];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The note contract: one note per changed file, anchored by the file path. */
|
|
115
|
+
export interface ReviewNote {
|
|
116
|
+
path: string;
|
|
117
|
+
body: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The transport-neutral daemon operations used by the shared review lifecycle. */
|
|
121
|
+
export interface ThreadSessionClient {
|
|
122
|
+
sessionList(filter?: { status?: "pending" | "resolved" }): Promise<Thread[]>;
|
|
123
|
+
sessionCreate(workspace: WorkspaceKey, artifact: Artifact): Promise<Thread>;
|
|
124
|
+
sessionSubmitRevision(
|
|
125
|
+
id: string,
|
|
126
|
+
content: string,
|
|
127
|
+
addressedAnnotationIds?: string[],
|
|
128
|
+
files?: DiffFileContents[],
|
|
129
|
+
source?: DiffSource,
|
|
130
|
+
): Promise<Thread>;
|
|
131
|
+
sessionAnnotate(
|
|
132
|
+
id: string,
|
|
133
|
+
annotation: Omit<Annotation, "createdAt">,
|
|
134
|
+
authorName?: string,
|
|
135
|
+
): Promise<Thread>;
|
|
136
|
+
sessionGet(id: string): Promise<Thread>;
|
|
137
|
+
sessionWait(id: string, timeoutMs: number): Promise<Thread | null>;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Anchor a note at its file: quote = the path, no context selectors. */
|
|
141
|
+
async function attachNotes(
|
|
142
|
+
client: ThreadSessionClient,
|
|
143
|
+
sessionId: string,
|
|
144
|
+
notes: ReviewNote[],
|
|
145
|
+
): Promise<void> {
|
|
146
|
+
for (const note of notes) {
|
|
147
|
+
await client.sessionAnnotate(sessionId, {
|
|
148
|
+
id: newAnnotationId(),
|
|
149
|
+
kind: "note",
|
|
150
|
+
anchor: { quote: note.path, prefix: "", suffix: "" },
|
|
151
|
+
body: note.body,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface AwaitMessageOptions {
|
|
157
|
+
timeoutMs: number;
|
|
158
|
+
pollMs?: number;
|
|
159
|
+
onProgress?: (session: Thread) => void;
|
|
160
|
+
signal?: AbortSignal;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** A resolved review mapped onto the agent contract, plus the full session. */
|
|
164
|
+
export interface MessageResult {
|
|
165
|
+
allow: boolean;
|
|
166
|
+
message: import("@cueloop/schema").Message;
|
|
167
|
+
session: Thread;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export class ReviewHandle {
|
|
171
|
+
constructor(
|
|
172
|
+
private readonly client: Pick<ThreadSessionClient, "sessionWait" | "sessionGet">,
|
|
173
|
+
readonly session: Thread,
|
|
174
|
+
) {}
|
|
175
|
+
|
|
176
|
+
get id(): string {
|
|
177
|
+
return this.session.id;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Block on the message. "pending" means the budget ran out or the signal
|
|
182
|
+
* aborted - the session stays open and the message is collectable later
|
|
183
|
+
* (messages outlive waits). With only timeoutMs this is one long-poll;
|
|
184
|
+
* pollMs/onProgress/signal switch to the chunked loop.
|
|
185
|
+
*/
|
|
186
|
+
async awaitMessage(options: AwaitMessageOptions): Promise<MessageResult | "pending"> {
|
|
187
|
+
const { timeoutMs, pollMs, onProgress, signal } = options;
|
|
188
|
+
|
|
189
|
+
if (pollMs === undefined && onProgress === undefined && signal === undefined) {
|
|
190
|
+
const resolved = await this.client.sessionWait(this.session.id, timeoutMs);
|
|
191
|
+
|
|
192
|
+
return resolved === null ? "pending" : outcome(resolved);
|
|
193
|
+
}
|
|
194
|
+
const chunkMs = pollMs ?? 10_000;
|
|
195
|
+
const deadline = Number.isFinite(timeoutMs) ? Date.now() + timeoutMs : undefined;
|
|
196
|
+
|
|
197
|
+
for (;;) {
|
|
198
|
+
const budget = deadline === undefined ? chunkMs : Math.min(chunkMs, deadline - Date.now());
|
|
199
|
+
|
|
200
|
+
if (budget <= 0 || signal?.aborted) return "pending";
|
|
201
|
+
const resolved = await raceAbort(this.client.sessionWait(this.session.id, budget), signal);
|
|
202
|
+
|
|
203
|
+
if (resolved === ABORTED) return "pending";
|
|
204
|
+
|
|
205
|
+
if (resolved !== null) return outcome(resolved);
|
|
206
|
+
// Still pending after this chunk: re-read to surface reviewer progress.
|
|
207
|
+
const current = await raceAbort(this.client.sessionGet(this.session.id), signal);
|
|
208
|
+
|
|
209
|
+
if (current === ABORTED) return "pending";
|
|
210
|
+
|
|
211
|
+
onProgress?.(current);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function outcome(session: Thread): MessageResult {
|
|
217
|
+
return { ...messageResponse(session), session };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export interface AwaitResolveOptions {
|
|
221
|
+
pollMs?: number;
|
|
222
|
+
signal?: AbortSignal;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Park until a thread resolves, then return the message outcome; null
|
|
227
|
+
* when the signal aborts first. Where ReviewHandle.awaitMessage needs the handle
|
|
228
|
+
* that opened the review, this needs only a session id - so a background waiter
|
|
229
|
+
* that woke on a session it did not open (a detached Claude Code / Codex waiter,
|
|
230
|
+
* or pi's session_start listener) can collect the same message. This is the
|
|
231
|
+
* wake seam every non-blocking adapter builds on. Loops the daemon long-poll, so
|
|
232
|
+
* a message that lands between chunks is never missed and a session already
|
|
233
|
+
* resolved returns on the first chunk. The held connection also keeps the daemon
|
|
234
|
+
* off its idle-exit path for the whole wait.
|
|
235
|
+
*/
|
|
236
|
+
export async function awaitResolve(
|
|
237
|
+
client: Pick<ThreadSessionClient, "sessionWait">,
|
|
238
|
+
sessionId: string,
|
|
239
|
+
options: AwaitResolveOptions = {},
|
|
240
|
+
): Promise<MessageResult | null> {
|
|
241
|
+
const chunkMs = options.pollMs ?? 30_000;
|
|
242
|
+
const resolved = await pollUntilResolved(
|
|
243
|
+
() => client.sessionWait(sessionId, chunkMs),
|
|
244
|
+
options.signal,
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
return resolved === null ? null : outcome(resolved);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Find the Thread already submitted by this agent in the same workspace and primitive. */
|
|
251
|
+
export async function findExistingReview(
|
|
252
|
+
client: ThreadSessionClient,
|
|
253
|
+
options: OpenReviewOptions,
|
|
254
|
+
): Promise<Thread | undefined> {
|
|
255
|
+
const cwd = options.cwd ?? process.cwd();
|
|
256
|
+
const workspace = options.workspace ?? (await resolveWorkspace(cwd));
|
|
257
|
+
|
|
258
|
+
if (options.agentSessionId === undefined) return undefined;
|
|
259
|
+
|
|
260
|
+
const isJjDiff = options.type === "diff" && options.vcs === "jj";
|
|
261
|
+
const hasJjProvenance = (candidate: Thread): boolean =>
|
|
262
|
+
candidate.artifact.meta.vcs === "jj" ||
|
|
263
|
+
candidate.revisions.some((revision) => revision.source?.vcs === "jj");
|
|
264
|
+
const candidates = (await client.sessionList()).filter(
|
|
265
|
+
(candidate) =>
|
|
266
|
+
candidate.artifact.meta.agentSessionId === options.agentSessionId &&
|
|
267
|
+
candidate.artifact.meta.agent === options.agent &&
|
|
268
|
+
candidate.artifact.type === options.type &&
|
|
269
|
+
(candidate.artifact.meta.workflow ??
|
|
270
|
+
(candidate.artifact.meta.pr ? "review" : candidate.artifact.type)) ===
|
|
271
|
+
(options.workflow ?? (options.pr ? "review" : options.type)) &&
|
|
272
|
+
candidate.artifact.meta.pr === options.pr &&
|
|
273
|
+
candidate.workspace.repoRoot === workspace.repoRoot &&
|
|
274
|
+
(candidate.workspace.branch === workspace.branch || (isJjDiff && hasJjProvenance(candidate))),
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
if (options.vcsChangeId !== undefined)
|
|
278
|
+
return candidates.find(
|
|
279
|
+
(candidate) => candidate.artifact.meta.vcsChangeId === options.vcsChangeId,
|
|
280
|
+
);
|
|
281
|
+
if (isJjDiff) {
|
|
282
|
+
const jjCandidates = candidates.filter(hasJjProvenance);
|
|
283
|
+
|
|
284
|
+
// An unmatched patch has no verified change ID. Reuse the Thread only when
|
|
285
|
+
// there is exactly one possible JJ change in this agent session.
|
|
286
|
+
return jjCandidates.length === 1 ? jjCandidates[0] : undefined;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return candidates.find((candidate) => candidate.artifact.meta.vcsChangeId === undefined);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Attach VCS provenance only when the submitted patch matches a native capture. */
|
|
293
|
+
async function matchingDiffCapture(
|
|
294
|
+
options: OpenReviewOptions,
|
|
295
|
+
cwd: string,
|
|
296
|
+
): Promise<{ source?: DiffSource; files?: DiffFileContents[] }> {
|
|
297
|
+
if (options.type !== "diff" || options.pr || !options.content.trim()) return {};
|
|
298
|
+
try {
|
|
299
|
+
const captured = await new VcsSourceManager().capture(cwd, options.vcs);
|
|
300
|
+
|
|
301
|
+
if (captured.patch.replace(/\n+$/, "") !== options.content.replace(/\n+$/, "")) return {};
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
source: {
|
|
305
|
+
vcs: captured.vcs,
|
|
306
|
+
changeId: captured.source?.changeId,
|
|
307
|
+
revisionId: captured.source?.revisionId,
|
|
308
|
+
},
|
|
309
|
+
files: captured.files,
|
|
310
|
+
};
|
|
311
|
+
} catch {
|
|
312
|
+
// A supplied patch stays reviewable even when the local source is unavailable.
|
|
313
|
+
return {};
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Open a thread (or revise the agent session's existing one) and hand back the wait surface. */
|
|
318
|
+
export async function openReview(
|
|
319
|
+
client: ThreadSessionClient,
|
|
320
|
+
options: OpenReviewOptions,
|
|
321
|
+
): Promise<ReviewHandle> {
|
|
322
|
+
const cwd = options.cwd ?? process.cwd();
|
|
323
|
+
const workspace = options.workspace ?? (await resolveWorkspace(cwd));
|
|
324
|
+
const { source, files: capturedFiles } = await matchingDiffCapture(options, cwd);
|
|
325
|
+
const existing = await findExistingReview(client, {
|
|
326
|
+
...options,
|
|
327
|
+
vcsChangeId: source?.changeId,
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
if (existing !== undefined) {
|
|
331
|
+
// PR diffs have no full file contents. An absent local-diff capture clears
|
|
332
|
+
// old curatable files, while a PR revision must keep curation disabled.
|
|
333
|
+
const revisionFiles =
|
|
334
|
+
options.type !== "diff" || options.pr ? undefined : (options.files ?? capturedFiles ?? []);
|
|
335
|
+
let revised = await client.sessionSubmitRevision(
|
|
336
|
+
existing.id,
|
|
337
|
+
options.content,
|
|
338
|
+
[],
|
|
339
|
+
revisionFiles,
|
|
340
|
+
source,
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
if (options.notes?.length) {
|
|
344
|
+
await attachNotes(client, revised.id, options.notes);
|
|
345
|
+
revised = await client.sessionGet(revised.id);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return new ReviewHandle(client, revised);
|
|
349
|
+
}
|
|
350
|
+
let session = await client.sessionCreate(workspace, {
|
|
351
|
+
type: options.type,
|
|
352
|
+
content: options.content,
|
|
353
|
+
files: options.files ?? capturedFiles,
|
|
354
|
+
meta: {
|
|
355
|
+
workflow: options.workflow,
|
|
356
|
+
agent: options.agent,
|
|
357
|
+
agentSessionId: options.agentSessionId,
|
|
358
|
+
planPath: options.planPath,
|
|
359
|
+
prototypePath: options.prototypePath,
|
|
360
|
+
pr: options.pr,
|
|
361
|
+
prBrief: options.prBrief,
|
|
362
|
+
prBaseSha: options.prBaseSha,
|
|
363
|
+
prHeadSha: options.prHeadSha,
|
|
364
|
+
prUrl: options.prUrl,
|
|
365
|
+
herdrPane: options.herdrPane,
|
|
366
|
+
title:
|
|
367
|
+
options.title ??
|
|
368
|
+
(isMarkdownArtifact(options.type) ? firstHeading(options.content) : undefined),
|
|
369
|
+
cwd,
|
|
370
|
+
vcs: source?.vcs,
|
|
371
|
+
vcsChangeId: source?.changeId,
|
|
372
|
+
vcsRevisionId: source?.revisionId,
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
if (options.notes?.length) {
|
|
377
|
+
await attachNotes(client, session.id, options.notes);
|
|
378
|
+
session = await client.sessionGet(session.id);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return new ReviewHandle(client, session);
|
|
382
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { DaemonServer } from "./server";
|
|
6
|
+
import { DaemonClient } from "./client";
|
|
7
|
+
import { subscribeThreadState, connectThreadObserver } from "./thread-subscription";
|
|
8
|
+
|
|
9
|
+
import { createTestSignal } from "../../../test/helpers/test-signal";
|
|
10
|
+
|
|
11
|
+
test("subscription refresh discards stale reads and disposal suppresses late callbacks", async () => {
|
|
12
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-subscription-"));
|
|
13
|
+
const server = new DaemonServer({ home, idleExitMs: 0 });
|
|
14
|
+
|
|
15
|
+
server.start();
|
|
16
|
+
const client = await DaemonClient.connect({ home });
|
|
17
|
+
const firstRead = createTestSignal<void>();
|
|
18
|
+
const releaseFirst = createTestSignal<void>();
|
|
19
|
+
const latest = createTestSignal<void>();
|
|
20
|
+
const values: string[] = [];
|
|
21
|
+
let reads = 0;
|
|
22
|
+
let dispose = () => {};
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const thread = await client.sessionCreate(
|
|
26
|
+
{ repoRoot: home, branch: "main" },
|
|
27
|
+
{ type: "plan", content: "Initial", meta: {} },
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
dispose = subscribeThreadState({
|
|
31
|
+
connect: connectThreadObserver({ home }),
|
|
32
|
+
matches: (event) => event.sessionId === thread.id,
|
|
33
|
+
read: async (api) => {
|
|
34
|
+
const state = await api.sessionGet(thread.id);
|
|
35
|
+
|
|
36
|
+
if (++reads === 1) {
|
|
37
|
+
firstRead.resolve();
|
|
38
|
+
await releaseFirst.promise;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return state;
|
|
42
|
+
},
|
|
43
|
+
onValue: (state) => {
|
|
44
|
+
values.push(state.artifact.meta.title ?? "Initial");
|
|
45
|
+
latest.resolve();
|
|
46
|
+
},
|
|
47
|
+
onError: (error) => latest.reject(error),
|
|
48
|
+
});
|
|
49
|
+
await firstRead.promise;
|
|
50
|
+
await client.sessionSetTitle(thread.id, "New");
|
|
51
|
+
await latest.promise;
|
|
52
|
+
releaseFirst.resolve();
|
|
53
|
+
await Promise.resolve();
|
|
54
|
+
expect(values).toEqual(["New"]);
|
|
55
|
+
dispose();
|
|
56
|
+
await client.sessionSetTitle(thread.id, "After disposal");
|
|
57
|
+
expect(values).toEqual(["New"]);
|
|
58
|
+
} finally {
|
|
59
|
+
dispose();
|
|
60
|
+
client.close();
|
|
61
|
+
server.stop();
|
|
62
|
+
rmSync(home, { recursive: true, force: true });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("reconnect subscribes again and refreshes authoritative state even without a notification", async () => {
|
|
67
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-reconnect-"));
|
|
68
|
+
let server = new DaemonServer({ home, idleExitMs: 0 });
|
|
69
|
+
|
|
70
|
+
server.start();
|
|
71
|
+
const client = await DaemonClient.connect({ home });
|
|
72
|
+
const initial = createTestSignal<void>();
|
|
73
|
+
const recovered = createTestSignal<void>();
|
|
74
|
+
let connections = 0;
|
|
75
|
+
const values: string[] = [];
|
|
76
|
+
let dispose = () => {};
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const thread = await client.sessionCreate(
|
|
80
|
+
{ repoRoot: home, branch: "main" },
|
|
81
|
+
{ type: "plan", content: "Initial", meta: {} },
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
dispose = subscribeThreadState({
|
|
85
|
+
connect: connectThreadObserver({ home }),
|
|
86
|
+
reconnectMs: 1,
|
|
87
|
+
matches: (event) => event.sessionId === thread.id,
|
|
88
|
+
read: (api) => api.sessionGet(thread.id),
|
|
89
|
+
onConnect: () => {
|
|
90
|
+
connections++;
|
|
91
|
+
},
|
|
92
|
+
onValue: (state) => {
|
|
93
|
+
values.push(state.artifact.meta.title ?? "Initial");
|
|
94
|
+
if (connections === 1) initial.resolve();
|
|
95
|
+
else recovered.resolve();
|
|
96
|
+
},
|
|
97
|
+
onError() {},
|
|
98
|
+
});
|
|
99
|
+
await initial.promise;
|
|
100
|
+
server.stop();
|
|
101
|
+
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
102
|
+
server.core.sessionSetTitle(thread.id, "Recovered");
|
|
103
|
+
server.start();
|
|
104
|
+
await recovered.promise;
|
|
105
|
+
expect(connections).toBe(2);
|
|
106
|
+
expect(values).toEqual(["Initial", "Recovered"]);
|
|
107
|
+
} finally {
|
|
108
|
+
dispose();
|
|
109
|
+
client.close();
|
|
110
|
+
server.stop();
|
|
111
|
+
rmSync(home, { recursive: true, force: true });
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("a malformed protocol frame stops the observer instead of reconnecting", async () => {
|
|
116
|
+
const { LineBuffer, parseRequestFrame } = await import("./protocol");
|
|
117
|
+
const { socketPath } = await import("./paths");
|
|
118
|
+
const { DAEMON_VERSION } = await import("./version");
|
|
119
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-subscription-protocol-"));
|
|
120
|
+
const failed = createTestSignal<void>();
|
|
121
|
+
const reads = new Map<object, InstanceType<typeof LineBuffer>>();
|
|
122
|
+
let connections = 0;
|
|
123
|
+
const server = Bun.listen({
|
|
124
|
+
unix: socketPath(home),
|
|
125
|
+
socket: {
|
|
126
|
+
open(socket) {
|
|
127
|
+
reads.set(socket, new LineBuffer());
|
|
128
|
+
connections++;
|
|
129
|
+
if (connections > 1) failed.reject(new Error("Unexpected reconnect"));
|
|
130
|
+
},
|
|
131
|
+
data(socket, bytes) {
|
|
132
|
+
reads.get(socket)!.push(bytes.toString(), (line) => {
|
|
133
|
+
const request = parseRequestFrame(line);
|
|
134
|
+
|
|
135
|
+
socket.write(
|
|
136
|
+
JSON.stringify({
|
|
137
|
+
id: request.id,
|
|
138
|
+
result:
|
|
139
|
+
request.method === "daemon.ping"
|
|
140
|
+
? { pid: process.pid, version: DAEMON_VERSION }
|
|
141
|
+
: {},
|
|
142
|
+
}) + "\n",
|
|
143
|
+
);
|
|
144
|
+
if (request.method === "events.subscribe") socket.write("{broken\n");
|
|
145
|
+
});
|
|
146
|
+
},
|
|
147
|
+
close(socket) {
|
|
148
|
+
reads.delete(socket);
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
const dispose = subscribeThreadState({
|
|
153
|
+
connect: connectThreadObserver({ home }),
|
|
154
|
+
reconnectMs: 1,
|
|
155
|
+
matches: () => true,
|
|
156
|
+
read: (client) => client.ping(),
|
|
157
|
+
onValue() {},
|
|
158
|
+
onError: (error) => {
|
|
159
|
+
expect(error).toMatchObject({ kind: "protocol" });
|
|
160
|
+
failed.resolve();
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
await failed.promise;
|
|
166
|
+
// A timer boundary proves no reconnect remains scheduled after the protocol closure.
|
|
167
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
168
|
+
expect(connections).toBe(1);
|
|
169
|
+
} finally {
|
|
170
|
+
dispose();
|
|
171
|
+
server.stop(true);
|
|
172
|
+
rmSync(home, { recursive: true, force: true });
|
|
173
|
+
}
|
|
174
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { DaemonClient, DaemonClientError, type ConnectOptions } from "./client";
|
|
2
|
+
import { DaemonTransportError } from "./client-errors";
|
|
3
|
+
import type { EventFrame } from "./protocol";
|
|
4
|
+
|
|
5
|
+
// eslint-disable-next-line type-evidence/no-unknown-parameters -- Socket and read failures arrive from an untyped Promise rejection boundary.
|
|
6
|
+
type ThreadSubscriptionErrorHandler = (error: unknown) => void;
|
|
7
|
+
|
|
8
|
+
/** Notifications are refresh hints; each connection starts with an authoritative read. */
|
|
9
|
+
export interface ThreadSubscriptionOptions<Value> {
|
|
10
|
+
connect: () => Promise<DaemonClient>;
|
|
11
|
+
matches: (event: EventFrame) => boolean;
|
|
12
|
+
read: (client: DaemonClient, signal: AbortSignal) => Promise<Value>;
|
|
13
|
+
onValue: (value: Value, client: DaemonClient) => void;
|
|
14
|
+
onError: ThreadSubscriptionErrorHandler;
|
|
15
|
+
onConnect?: (client: DaemonClient) => void;
|
|
16
|
+
reconnectMs?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Dispose removes listeners, aborts local reads, and closes only the observation connection. */
|
|
20
|
+
export function subscribeThreadState<Value>(options: ThreadSubscriptionOptions<Value>): () => void {
|
|
21
|
+
let stopped = false;
|
|
22
|
+
let generation = 0;
|
|
23
|
+
let connection: DaemonClient | undefined;
|
|
24
|
+
let detach: (() => void) | undefined;
|
|
25
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
26
|
+
const reads = new Set<AbortController>();
|
|
27
|
+
const schedule = () => {
|
|
28
|
+
if (!stopped) timer = setTimeout(() => void connect(), options.reconnectMs ?? 250);
|
|
29
|
+
};
|
|
30
|
+
const connect = async () => {
|
|
31
|
+
const epoch = ++generation;
|
|
32
|
+
let request = 0;
|
|
33
|
+
let api: DaemonClient | undefined;
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
api = await options.connect();
|
|
37
|
+
if (stopped || epoch !== generation) {
|
|
38
|
+
api.close();
|
|
39
|
+
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
connection = api;
|
|
43
|
+
const current = api;
|
|
44
|
+
const refresh = async () => {
|
|
45
|
+
const version = ++request;
|
|
46
|
+
|
|
47
|
+
for (const read of reads) read.abort();
|
|
48
|
+
const controller = new AbortController();
|
|
49
|
+
|
|
50
|
+
reads.add(controller);
|
|
51
|
+
try {
|
|
52
|
+
const value = await options.read(current, controller.signal);
|
|
53
|
+
|
|
54
|
+
if (!stopped && epoch === generation && version === request)
|
|
55
|
+
options.onValue(value, current);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (!stopped && epoch === generation && version === request) {
|
|
58
|
+
if (error instanceof Error && isFatalSubscriptionError(error)) {
|
|
59
|
+
stopped = true;
|
|
60
|
+
++generation;
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
detach?.();
|
|
63
|
+
current.close();
|
|
64
|
+
}
|
|
65
|
+
options.onError(error);
|
|
66
|
+
}
|
|
67
|
+
} finally {
|
|
68
|
+
reads.delete(controller);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
const offEvent = api.onEvent((event) => {
|
|
72
|
+
if (options.matches(event)) void refresh();
|
|
73
|
+
});
|
|
74
|
+
const offDisconnect = api.onDisconnect((reason) => {
|
|
75
|
+
if (stopped || epoch !== generation) return;
|
|
76
|
+
++generation;
|
|
77
|
+
detach?.();
|
|
78
|
+
for (const read of reads) read.abort();
|
|
79
|
+
connection = undefined;
|
|
80
|
+
current.close();
|
|
81
|
+
if (isFatalSubscriptionError(reason)) {
|
|
82
|
+
stopped = true;
|
|
83
|
+
options.onError(reason);
|
|
84
|
+
} else schedule();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
detach = () => {
|
|
88
|
+
offEvent();
|
|
89
|
+
offDisconnect();
|
|
90
|
+
};
|
|
91
|
+
await api.subscribe();
|
|
92
|
+
if (stopped || epoch !== generation) return;
|
|
93
|
+
options.onConnect?.(api);
|
|
94
|
+
await refresh();
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if (stopped || epoch !== generation) return;
|
|
97
|
+
++generation;
|
|
98
|
+
detach?.();
|
|
99
|
+
api?.close();
|
|
100
|
+
connection = undefined;
|
|
101
|
+
options.onError(error);
|
|
102
|
+
if (!(error instanceof Error && isFatalSubscriptionError(error))) schedule();
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
void connect();
|
|
107
|
+
|
|
108
|
+
return () => {
|
|
109
|
+
stopped = true;
|
|
110
|
+
++generation;
|
|
111
|
+
clearTimeout(timer);
|
|
112
|
+
detach?.();
|
|
113
|
+
for (const read of reads) read.abort();
|
|
114
|
+
reads.clear();
|
|
115
|
+
connection?.close();
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Reconnect never replays writes, only subscribes and reads the latest Thread state. */
|
|
120
|
+
export function connectThreadObserver(options: ConnectOptions): () => Promise<DaemonClient> {
|
|
121
|
+
return () => DaemonClient.connect(options);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function isFatalSubscriptionError(error: Error): boolean {
|
|
125
|
+
return (
|
|
126
|
+
error instanceof DaemonClientError ||
|
|
127
|
+
(error instanceof DaemonTransportError && error.kind === "protocol")
|
|
128
|
+
);
|
|
129
|
+
}
|