@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/client.ts
CHANGED
|
@@ -1,122 +1,365 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Daemon client: the one library every consumer shares - CLI
|
|
3
|
-
* adapters, and tests. Also owns the lazy-launch story
|
|
2
|
+
* Daemon client: the one library every consumer shares - CLI primitives, the TUI,
|
|
3
|
+
* adapters, and tests. Also owns the lazy-launch story: connect() with
|
|
4
4
|
* autostart spawns a detached daemon when the socket is dead, then attaches.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { existsSync, rmSync } from "node:fs";
|
|
8
|
-
import
|
|
9
|
-
import
|
|
10
|
-
|
|
7
|
+
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
8
|
+
import * as v from "valibot";
|
|
9
|
+
import type {
|
|
10
|
+
Annotation,
|
|
11
|
+
Artifact,
|
|
12
|
+
DiffFileStatus,
|
|
13
|
+
HunkRejection,
|
|
14
|
+
Thread,
|
|
15
|
+
VerdictKind,
|
|
16
|
+
WorkspaceKey,
|
|
17
|
+
} from "@cueloop/schema";
|
|
18
|
+
import {
|
|
19
|
+
BackpressureWriter,
|
|
20
|
+
LineBuffer,
|
|
21
|
+
parseInboundFrame,
|
|
22
|
+
type EventFrame,
|
|
23
|
+
type Request,
|
|
24
|
+
type Response,
|
|
25
|
+
} from "./protocol";
|
|
26
|
+
import type { DaemonRole } from "./capabilities";
|
|
27
|
+
import type { HerdrTabHandle } from "./herdr-tab-store";
|
|
28
|
+
import type { SharedMerge } from "./api";
|
|
29
|
+
|
|
30
|
+
export type { SharedMerge } from "./api";
|
|
31
|
+
import { cueloopHome, ownerTokenPath, socketPath } from "./paths";
|
|
32
|
+
import { Params, ThreadRecordSchema, DiffFileContentsSchema } from "./validate";
|
|
33
|
+
import type { WorkingTreeDiff } from "./working-tree";
|
|
34
|
+
import { DAEMON_VERSION } from "./version";
|
|
35
|
+
|
|
36
|
+
export type { EventFrame } from "./protocol";
|
|
11
37
|
|
|
12
38
|
export interface ConnectOptions {
|
|
13
39
|
home?: string;
|
|
14
40
|
/** Spawn the daemon when the socket is not alive. */
|
|
15
41
|
autostart?: boolean;
|
|
42
|
+
/** Capability role for this connection; a review-side agent connects capped. Defaults to owner. */
|
|
43
|
+
role?: DaemonRole;
|
|
44
|
+
/** The author a non-owner connection acts as; its comments, removals, and name are bound to it. */
|
|
45
|
+
author?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type PendingRequest = {
|
|
49
|
+
resolve: (value: Response["result"]) => void;
|
|
50
|
+
reject: (error: Error) => void;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const EmptyResultSchema = v.object({});
|
|
54
|
+
// version is optional: a daemon from before the handshake carried one reads as
|
|
55
|
+
// undefined, which never equals this build - so it is treated as stale and replaced
|
|
56
|
+
const PingResultSchema = v.object({ pid: v.number(), version: v.optional(v.string()) });
|
|
57
|
+
const RefreshDiffResultSchema = v.object({ changed: v.boolean() });
|
|
58
|
+
const HerdrTabResultSchema = v.nullable(v.object({ tabId: v.string(), paneId: v.string() }));
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The session primitives the review controller drives. DaemonClient is the local
|
|
62
|
+
* implementation (unix socket); the sharing gateway supplies an in-memory,
|
|
63
|
+
* blob-backed one. Depending on this interface - not DaemonClient - is what
|
|
64
|
+
* lets the same <App> render a local session or a decrypted share unchanged.
|
|
65
|
+
*/
|
|
66
|
+
export interface SessionClient {
|
|
67
|
+
onEvent(listener: (event: EventFrame) => void): () => void;
|
|
68
|
+
subscribe(): Promise<void>;
|
|
69
|
+
sessionGet(id: string): Promise<Thread>;
|
|
70
|
+
sessionList(filter?: { status?: "pending" | "resolved" }): Promise<Thread[]>;
|
|
71
|
+
/** Add a comment; the primary annotate method. `sessionAnnotate` is the retained alias. */
|
|
72
|
+
sessionComment(
|
|
73
|
+
id: string,
|
|
74
|
+
annotation: Omit<Annotation, "createdAt">,
|
|
75
|
+
authorName?: string,
|
|
76
|
+
): Promise<Thread>;
|
|
77
|
+
sessionAnnotate(
|
|
78
|
+
id: string,
|
|
79
|
+
annotation: Omit<Annotation, "createdAt">,
|
|
80
|
+
authorName?: string,
|
|
81
|
+
): Promise<Thread>;
|
|
82
|
+
/** Remove a comment; a non-owner connection removes only the comments of the author it is bound to. */
|
|
83
|
+
sessionRemoveAnnotation(id: string, annotationId: string): Promise<Thread>;
|
|
84
|
+
sessionSetWorkingCopy(id: string, workingCopy: string | undefined): Promise<Thread>;
|
|
85
|
+
/** Cut the `blockIndex`-th block of the working copy. */
|
|
86
|
+
sessionCutBlock(id: string, blockIndex: number): Promise<Thread>;
|
|
87
|
+
/** Re-insert the `baseBlockIndex`-th block of the submitted revision before `line` (default: the end). */
|
|
88
|
+
sessionRestoreBlock(id: string, baseBlockIndex: number, line?: number): Promise<Thread>;
|
|
89
|
+
/** Replace a diff review's reject decisions; the working copy follows. */
|
|
90
|
+
sessionCurate(id: string, rejections: HunkRejection[]): Promise<Thread>;
|
|
91
|
+
sessionSetViewed(id: string, viewedPaths: string[]): Promise<Thread>;
|
|
92
|
+
/** Rename a session's display title; an empty title restores the derived default. */
|
|
93
|
+
sessionSetTitle(id: string, title: string): Promise<Thread>;
|
|
94
|
+
/** Tracked, repo-relative file paths for the session's workspace; a client with no local repo (a share) omits it. */
|
|
95
|
+
projectFiles?(sessionId: string): Promise<string[]>;
|
|
96
|
+
/** UTF-8 contents of a repo-relative file, or null when it cannot be read safely; omitted by a client with no local repo. */
|
|
97
|
+
fileContents?(sessionId: string, path: string): Promise<string | null>;
|
|
98
|
+
/** Tracked, repo-relative paths for the git repo containing `cwd`, for the no-session welcome shell. */
|
|
99
|
+
repoFiles?(cwd: string): Promise<string[]>;
|
|
100
|
+
/** UTF-8 contents of a repo-relative file in `cwd`'s repo, or null when unreadable. */
|
|
101
|
+
repoFileContents?(cwd: string, path: string): Promise<string | null>;
|
|
102
|
+
/** Changed files (path plus git status) in the working tree at `cwd`. */
|
|
103
|
+
repoChanges?(cwd: string): Promise<{ path: string; status: DiffFileStatus }[]>;
|
|
104
|
+
/** The live working-tree diff (patch plus per-file contents) at `cwd`. */
|
|
105
|
+
repoDiff?(cwd: string): Promise<WorkingTreeDiff>;
|
|
106
|
+
/** Find-or-create the per-repo workbench thread for `cwd`, so a bare launch's first comment persists. */
|
|
107
|
+
sessionWorkbench?(cwd: string): Promise<Thread>;
|
|
108
|
+
/** Move a branch's tip (the current one, or `branch` after switching to it) back to an entry on its path; a summary records the abandoned segment. */
|
|
109
|
+
sessionNavigate(id: string, entryId: string, summary?: string, branch?: string): Promise<Thread>;
|
|
110
|
+
/** Start a branch at the current tip and switch to it. */
|
|
111
|
+
sessionBranch(id: string, name: string): Promise<Thread>;
|
|
112
|
+
sessionSwitch(id: string, branch: string): Promise<Thread>;
|
|
113
|
+
/** Name the current tip as a checkpoint. */
|
|
114
|
+
sessionLabel(id: string, label: string): Promise<Thread>;
|
|
115
|
+
/** Copy the current path into a new session; returns the fork. */
|
|
116
|
+
sessionFork(id: string): Promise<Thread>;
|
|
117
|
+
sessionSetShareId(id: string, shareId: string): Promise<Thread>;
|
|
118
|
+
sessionMergeShared(id: string, incoming: SharedMerge): Promise<Thread>;
|
|
119
|
+
sessionDelete(id: string): Promise<void>;
|
|
120
|
+
/** Record the caller's own identity name (collaborator self-naming on a share). */
|
|
121
|
+
sessionSetSelfName(id: string, name: string): Promise<Thread>;
|
|
122
|
+
sessionResolve(
|
|
123
|
+
id: string,
|
|
124
|
+
verdictKind: VerdictKind,
|
|
125
|
+
summary: string,
|
|
126
|
+
actionBodies?: Record<string, string>,
|
|
127
|
+
): Promise<Thread>;
|
|
128
|
+
close(): void;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** What a connection says about itself: its role, the owner token when it claims ownership, the author it acts as otherwise. */
|
|
132
|
+
interface HelloParams {
|
|
133
|
+
role: DaemonRole;
|
|
134
|
+
token?: string;
|
|
135
|
+
author?: string;
|
|
16
136
|
}
|
|
17
137
|
|
|
18
|
-
|
|
138
|
+
/** The token as the daemon writes it: 32 random bytes in hex. */
|
|
139
|
+
const OwnerTokenSchema = v.pipe(v.string(), v.trim(), v.regex(/^[0-9a-f]{64}$/));
|
|
19
140
|
|
|
20
|
-
|
|
141
|
+
/** The owner token in `home`, or undefined when the daemon there never wrote one. */
|
|
142
|
+
function readOwnerToken(home: string): string | undefined {
|
|
143
|
+
const path = ownerTokenPath(home);
|
|
144
|
+
|
|
145
|
+
if (!existsSync(path)) return undefined;
|
|
146
|
+
const parsed = v.safeParse(OwnerTokenSchema, readFileSync(path, "utf8"));
|
|
147
|
+
|
|
148
|
+
if (!parsed.success) {
|
|
149
|
+
throw new DaemonClientError(
|
|
150
|
+
"invalid_owner_token",
|
|
151
|
+
`${path} is not an owner token; restart the daemon to mint a fresh one`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return parsed.output;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export class DaemonClient implements SessionClient {
|
|
21
159
|
private socket: Awaited<ReturnType<typeof Bun.connect>> | null = null;
|
|
22
|
-
private
|
|
160
|
+
private writer: BackpressureWriter | null = null;
|
|
161
|
+
private pending = new Map<number, PendingRequest>();
|
|
162
|
+
/** The connected daemon's build version and pid, learned from the ping handshake. */
|
|
163
|
+
private daemonVersion: string | undefined;
|
|
164
|
+
private daemonPid: number | undefined;
|
|
23
165
|
private nextId = 1;
|
|
24
|
-
private eventListeners = new Set<(
|
|
166
|
+
private eventListeners = new Set<(event: EventFrame) => void>();
|
|
25
167
|
private closed = false;
|
|
168
|
+
private role: DaemonRole = "owner";
|
|
169
|
+
private author: string | undefined;
|
|
170
|
+
private home = cueloopHome();
|
|
26
171
|
|
|
27
|
-
static async connect(
|
|
28
|
-
const home =
|
|
172
|
+
static async connect(options: ConnectOptions = {}): Promise<DaemonClient> {
|
|
173
|
+
const home = options.home ?? cueloopHome();
|
|
29
174
|
const path = socketPath(home);
|
|
30
175
|
const client = new DaemonClient();
|
|
176
|
+
|
|
177
|
+
client.role = options.role ?? "owner";
|
|
178
|
+
client.author = options.author;
|
|
179
|
+
client.home = home;
|
|
31
180
|
try {
|
|
32
181
|
await client.dial(path);
|
|
33
|
-
|
|
182
|
+
// A daemon from an earlier build lingers after an upgrade; talking to it
|
|
183
|
+
// means new client, old behaviour. The owner replaces it so an upgrade
|
|
184
|
+
// never needs a manual restart; without autostart there is nothing to
|
|
185
|
+
// replace it with, so the caller hears exactly why.
|
|
186
|
+
if (client.daemonVersion === DAEMON_VERSION) return client;
|
|
187
|
+
if (!options.autostart) {
|
|
188
|
+
client.close();
|
|
189
|
+
throw new DaemonClientError(
|
|
190
|
+
"version_mismatch",
|
|
191
|
+
`daemon is version ${client.daemonVersion ?? "unknown"}, but this client is ${DAEMON_VERSION}; restart the daemon`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
await client.stopStaleDaemon(path);
|
|
34
195
|
} catch (err) {
|
|
35
|
-
|
|
196
|
+
// a live daemon that refused the handshake is not a dead socket: the
|
|
197
|
+
// caller hears why instead of the client replacing a running daemon
|
|
198
|
+
if (!options.autostart || err instanceof DaemonClientError) throw err;
|
|
36
199
|
}
|
|
37
|
-
// Socket dead or
|
|
200
|
+
// Socket dead, absent, or just-replaced: clean a stale file and spawn detached.
|
|
201
|
+
return client.attachFreshDaemon(home, path);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Tear down a daemon from an earlier build so a fresh one can bind: ask it to
|
|
206
|
+
* shut down (owner-gated) or, failing that, signal its pid, then wait for it to
|
|
207
|
+
* release the socket. Best-effort - attachFreshDaemon's stale-socket and
|
|
208
|
+
* stale-lock cleanup recovers even a daemon that never ran its own teardown.
|
|
209
|
+
*/
|
|
210
|
+
private async stopStaleDaemon(path: string): Promise<void> {
|
|
211
|
+
const pid = this.daemonPid;
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
await this.request("daemon.shutdown", {}, EmptyResultSchema, 2_000);
|
|
215
|
+
} catch {
|
|
216
|
+
if (pid !== undefined) {
|
|
217
|
+
try {
|
|
218
|
+
process.kill(pid);
|
|
219
|
+
} catch {}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
this.socket?.end();
|
|
223
|
+
this.resetConnection();
|
|
224
|
+
// the old daemon removes its socket in stop(); wait so the new one binds cleanly
|
|
225
|
+
const deadline = Date.now() + 5_000;
|
|
226
|
+
|
|
227
|
+
while (Date.now() < deadline && existsSync(path)) await Bun.sleep(50);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Reset per-connection state so a fresh dial() can reuse this client instance. */
|
|
231
|
+
private resetConnection(): void {
|
|
232
|
+
this.socket = null;
|
|
233
|
+
this.writer = null;
|
|
234
|
+
this.closed = false;
|
|
235
|
+
for (const pendingRequest of this.pending.values())
|
|
236
|
+
pendingRequest.reject(new Error("daemon connection replaced"));
|
|
237
|
+
this.pending.clear();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Clean any stale socket, spawn a detached daemon, and dial it until it answers. */
|
|
241
|
+
private async attachFreshDaemon(home: string, path: string): Promise<DaemonClient> {
|
|
38
242
|
if (existsSync(path)) rmSync(path, { force: true });
|
|
39
243
|
spawnDaemon(home);
|
|
40
244
|
// Generous: a cold or loaded machine pays for a runtime start before the
|
|
41
245
|
// socket exists, and giving up early looks to callers like a broken daemon.
|
|
42
246
|
const deadline = Date.now() + Number(process.env.CUELOOP_START_TIMEOUT_MS ?? 30_000);
|
|
43
|
-
let
|
|
247
|
+
let lastError: unknown;
|
|
248
|
+
|
|
44
249
|
while (Date.now() < deadline) {
|
|
45
250
|
try {
|
|
46
|
-
await
|
|
47
|
-
|
|
251
|
+
await this.dial(path);
|
|
252
|
+
|
|
253
|
+
return this;
|
|
48
254
|
} catch (err) {
|
|
49
|
-
|
|
255
|
+
lastError = err;
|
|
50
256
|
await Bun.sleep(50);
|
|
51
257
|
}
|
|
52
258
|
}
|
|
53
|
-
throw new Error(`daemon did not come up at ${path}: ${String(
|
|
259
|
+
throw new Error(`daemon did not come up at ${path}: ${String(lastError)}`);
|
|
54
260
|
}
|
|
55
261
|
|
|
56
262
|
private async dial(path: string): Promise<void> {
|
|
57
263
|
const buffer = new LineBuffer();
|
|
58
|
-
|
|
264
|
+
|
|
59
265
|
this.socket = await Bun.connect({
|
|
60
266
|
unix: path,
|
|
61
267
|
socket: {
|
|
62
|
-
data(_socket, data) {
|
|
63
|
-
buffer.push(data.toString(), (line) =>
|
|
268
|
+
data: (_socket, data) => {
|
|
269
|
+
buffer.push(data.toString(), (line) => this.routeInboundFrame(line));
|
|
270
|
+
},
|
|
271
|
+
drain: () => {
|
|
272
|
+
this.writer?.drain();
|
|
64
273
|
},
|
|
65
|
-
close() {
|
|
66
|
-
|
|
67
|
-
for (const
|
|
68
|
-
|
|
274
|
+
close: () => {
|
|
275
|
+
this.closed = true;
|
|
276
|
+
for (const pendingRequest of this.pending.values())
|
|
277
|
+
pendingRequest.reject(new Error("daemon connection closed"));
|
|
278
|
+
this.pending.clear();
|
|
69
279
|
},
|
|
70
280
|
error() {},
|
|
71
281
|
},
|
|
72
282
|
});
|
|
283
|
+
this.writer = new BackpressureWriter(this.socket);
|
|
73
284
|
// Verify liveness: a dead socket file accepts connects on some platforms
|
|
74
|
-
// only to fail later, so a ping is the actual handshake.
|
|
75
|
-
|
|
285
|
+
// only to fail later, so a ping is the actual handshake. It also carries the
|
|
286
|
+
// daemon's build version and pid, so connect() can replace a stale daemon.
|
|
287
|
+
const pong = await this.request("daemon.ping", {}, PingResultSchema, 2_000);
|
|
288
|
+
|
|
289
|
+
this.daemonVersion = pong.version;
|
|
290
|
+
this.daemonPid = pong.pid;
|
|
291
|
+
// Every connection starts as a collaborator; the owner proves itself with
|
|
292
|
+
// the token the daemon wrote into the home it serves, which only the home's
|
|
293
|
+
// user can read. A capped role just names itself.
|
|
294
|
+
await this.request("daemon.hello", this.helloParams(), EmptyResultSchema, 2_000);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private helloParams(): HelloParams {
|
|
298
|
+
if (this.role !== "owner") {
|
|
299
|
+
return this.author === undefined
|
|
300
|
+
? { role: this.role }
|
|
301
|
+
: { role: this.role, author: this.author };
|
|
302
|
+
}
|
|
303
|
+
const token = readOwnerToken(this.home);
|
|
304
|
+
|
|
305
|
+
// a daemon from before owner tokens has no file; it still knows the bare hello
|
|
306
|
+
return token === undefined ? { role: "owner" } : { role: "owner", token };
|
|
76
307
|
}
|
|
77
308
|
|
|
78
|
-
onEvent(listener: (
|
|
309
|
+
onEvent(listener: (event: EventFrame) => void): () => void {
|
|
79
310
|
this.eventListeners.add(listener);
|
|
311
|
+
|
|
80
312
|
return () => this.eventListeners.delete(listener);
|
|
81
313
|
}
|
|
82
314
|
|
|
83
|
-
private
|
|
84
|
-
let frame
|
|
315
|
+
private routeInboundFrame(line: string): void {
|
|
316
|
+
let frame;
|
|
317
|
+
|
|
85
318
|
try {
|
|
86
|
-
frame =
|
|
319
|
+
frame = parseInboundFrame(line);
|
|
87
320
|
} catch {
|
|
88
321
|
return;
|
|
89
322
|
}
|
|
90
323
|
if ("event" in frame) {
|
|
91
|
-
for (const
|
|
324
|
+
for (const listener of this.eventListeners) listener(frame);
|
|
325
|
+
|
|
92
326
|
return;
|
|
93
327
|
}
|
|
94
|
-
const
|
|
95
|
-
|
|
328
|
+
const pendingRequest = this.pending.get(frame.id);
|
|
329
|
+
|
|
330
|
+
if (!pendingRequest) return;
|
|
96
331
|
this.pending.delete(frame.id);
|
|
97
|
-
if (frame.error)
|
|
98
|
-
|
|
332
|
+
if (frame.error)
|
|
333
|
+
pendingRequest.reject(new DaemonClientError(frame.error.code, frame.error.message));
|
|
334
|
+
else pendingRequest.resolve(frame.result);
|
|
99
335
|
}
|
|
100
336
|
|
|
101
|
-
request<
|
|
337
|
+
request<TOutput>(
|
|
338
|
+
method: string,
|
|
339
|
+
params: Request["params"],
|
|
340
|
+
resultSchema: v.GenericSchema<unknown, TOutput>,
|
|
341
|
+
timeoutMs = 30_000,
|
|
342
|
+
): Promise<TOutput> {
|
|
102
343
|
if (this.closed || !this.socket) return Promise.reject(new Error("not connected"));
|
|
103
344
|
const id = this.nextId++;
|
|
104
|
-
|
|
345
|
+
|
|
346
|
+
return new Promise<TOutput>((resolve, reject) => {
|
|
105
347
|
const timer = setTimeout(() => {
|
|
106
348
|
this.pending.delete(id);
|
|
107
349
|
reject(new Error(`request ${method} timed out`));
|
|
108
350
|
}, timeoutMs);
|
|
351
|
+
|
|
109
352
|
this.pending.set(id, {
|
|
110
|
-
resolve: (
|
|
353
|
+
resolve: (value) => {
|
|
111
354
|
clearTimeout(timer);
|
|
112
|
-
resolve(v
|
|
355
|
+
resolve(v.parse(resultSchema, value));
|
|
113
356
|
},
|
|
114
|
-
reject: (
|
|
357
|
+
reject: (error) => {
|
|
115
358
|
clearTimeout(timer);
|
|
116
|
-
reject(
|
|
359
|
+
reject(error);
|
|
117
360
|
},
|
|
118
361
|
});
|
|
119
|
-
this.
|
|
362
|
+
this.writer!.write(JSON.stringify({ id, method, params }) + "\n");
|
|
120
363
|
});
|
|
121
364
|
}
|
|
122
365
|
|
|
@@ -125,43 +368,172 @@ export class DaemonClient {
|
|
|
125
368
|
this.socket?.end();
|
|
126
369
|
}
|
|
127
370
|
|
|
128
|
-
// ── typed
|
|
371
|
+
// ── typed primitives ─────────────────────────────
|
|
129
372
|
ping(): Promise<{ pid: number }> {
|
|
130
|
-
return this.request("daemon.ping", {});
|
|
373
|
+
return this.request("daemon.ping", {}, PingResultSchema);
|
|
131
374
|
}
|
|
132
375
|
subscribe(): Promise<void> {
|
|
133
|
-
return this.request("events.subscribe", {});
|
|
376
|
+
return this.request("events.subscribe", {}, EmptyResultSchema).then(() => undefined);
|
|
134
377
|
}
|
|
135
|
-
sessionCreate(workspace: WorkspaceKey, artifact: Artifact): Promise<
|
|
136
|
-
return this.request("session.create", { workspace, artifact });
|
|
378
|
+
sessionCreate(workspace: WorkspaceKey, artifact: Artifact): Promise<Thread> {
|
|
379
|
+
return this.request("session.create", { workspace, artifact }, ThreadRecordSchema);
|
|
137
380
|
}
|
|
138
|
-
sessionGet(id: string): Promise<
|
|
139
|
-
return this.request("session.get", { id });
|
|
381
|
+
sessionGet(id: string): Promise<Thread> {
|
|
382
|
+
return this.request("session.get", { id }, ThreadRecordSchema);
|
|
140
383
|
}
|
|
141
|
-
sessionList(filter?: { status?: "pending" | "resolved" }): Promise<
|
|
142
|
-
return this.request("session.list", { filter });
|
|
384
|
+
sessionList(filter?: { status?: "pending" | "resolved" }): Promise<Thread[]> {
|
|
385
|
+
return this.request("session.list", { filter }, v.array(ThreadRecordSchema));
|
|
143
386
|
}
|
|
144
387
|
/** Long-poll; null = still pending after timeoutMs (re-poll to collect). */
|
|
145
|
-
sessionWait(id: string, timeoutMs: number): Promise<
|
|
146
|
-
return this.request(
|
|
388
|
+
sessionWait(id: string, timeoutMs: number): Promise<Thread | null> {
|
|
389
|
+
return this.request(
|
|
390
|
+
"session.wait",
|
|
391
|
+
{ id, timeoutMs },
|
|
392
|
+
v.nullable(ThreadRecordSchema),
|
|
393
|
+
timeoutMs + 10_000,
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
sessionComment(
|
|
397
|
+
id: string,
|
|
398
|
+
annotation: Omit<Annotation, "createdAt">,
|
|
399
|
+
authorName?: string,
|
|
400
|
+
): Promise<Thread> {
|
|
401
|
+
return this.request("session.comment", { id, annotation, authorName }, ThreadRecordSchema);
|
|
402
|
+
}
|
|
403
|
+
sessionAnnotate(
|
|
404
|
+
id: string,
|
|
405
|
+
annotation: Omit<Annotation, "createdAt">,
|
|
406
|
+
authorName?: string,
|
|
407
|
+
): Promise<Thread> {
|
|
408
|
+
return this.request("session.annotate", { id, annotation, authorName }, ThreadRecordSchema);
|
|
409
|
+
}
|
|
410
|
+
sessionRemoveAnnotation(id: string, annotationId: string): Promise<Thread> {
|
|
411
|
+
return this.request("session.removeAnnotation", { id, annotationId }, ThreadRecordSchema);
|
|
412
|
+
}
|
|
413
|
+
/** Register a display name for a participant - a collaborator's or an agent's own, on a share or locally. */
|
|
414
|
+
sessionSetParticipantName(id: string, author: string, name: string): Promise<Thread> {
|
|
415
|
+
return this.request("session.setParticipantName", { id, author, name }, ThreadRecordSchema);
|
|
416
|
+
}
|
|
417
|
+
sessionSetWorkingCopy(id: string, workingCopy: string | undefined): Promise<Thread> {
|
|
418
|
+
return this.request("session.setWorkingCopy", { id, workingCopy }, ThreadRecordSchema);
|
|
419
|
+
}
|
|
420
|
+
sessionCutBlock(id: string, blockIndex: number): Promise<Thread> {
|
|
421
|
+
return this.request("session.cutBlock", { id, blockIndex }, ThreadRecordSchema);
|
|
422
|
+
}
|
|
423
|
+
sessionNavigate(id: string, entryId: string, summary?: string, branch?: string): Promise<Thread> {
|
|
424
|
+
const params: v.InferInput<(typeof Params)["session.navigate"]> = { id, entryId };
|
|
425
|
+
|
|
426
|
+
if (summary !== undefined) params.summary = summary;
|
|
427
|
+
if (branch !== undefined) params.branch = branch;
|
|
428
|
+
|
|
429
|
+
return this.request("session.navigate", params, ThreadRecordSchema);
|
|
430
|
+
}
|
|
431
|
+
sessionBranch(id: string, name: string): Promise<Thread> {
|
|
432
|
+
return this.request("session.branch", { id, name }, ThreadRecordSchema);
|
|
433
|
+
}
|
|
434
|
+
sessionSwitch(id: string, branch: string): Promise<Thread> {
|
|
435
|
+
return this.request("session.switch", { id, branch }, ThreadRecordSchema);
|
|
436
|
+
}
|
|
437
|
+
sessionLabel(id: string, label: string): Promise<Thread> {
|
|
438
|
+
return this.request("session.label", { id, label }, ThreadRecordSchema);
|
|
439
|
+
}
|
|
440
|
+
sessionFork(id: string): Promise<Thread> {
|
|
441
|
+
return this.request("session.fork", { id }, ThreadRecordSchema);
|
|
147
442
|
}
|
|
148
|
-
|
|
149
|
-
return this.request(
|
|
443
|
+
sessionRestoreBlock(id: string, baseBlockIndex: number, line?: number): Promise<Thread> {
|
|
444
|
+
return this.request(
|
|
445
|
+
"session.restoreBlock",
|
|
446
|
+
line === undefined ? { id, baseBlockIndex } : { id, baseBlockIndex, line },
|
|
447
|
+
ThreadRecordSchema,
|
|
448
|
+
);
|
|
150
449
|
}
|
|
151
|
-
|
|
152
|
-
return this.request("session.
|
|
450
|
+
sessionCurate(id: string, rejections: HunkRejection[]): Promise<Thread> {
|
|
451
|
+
return this.request("session.curate", { id, rejections }, ThreadRecordSchema);
|
|
153
452
|
}
|
|
154
|
-
|
|
155
|
-
return this.request("session.
|
|
453
|
+
sessionSetViewed(id: string, viewedPaths: string[]): Promise<Thread> {
|
|
454
|
+
return this.request("session.setViewed", { id, viewedPaths }, ThreadRecordSchema);
|
|
156
455
|
}
|
|
157
|
-
|
|
158
|
-
return this.request("session.
|
|
456
|
+
sessionSetTitle(id: string, title: string): Promise<Thread> {
|
|
457
|
+
return this.request("session.setTitle", { id, title }, ThreadRecordSchema);
|
|
159
458
|
}
|
|
160
|
-
|
|
161
|
-
return this.request("session.
|
|
459
|
+
projectFiles(sessionId: string): Promise<string[]> {
|
|
460
|
+
return this.request("session.projectFiles", { id: sessionId }, v.array(v.string()));
|
|
461
|
+
}
|
|
462
|
+
fileContents(sessionId: string, path: string): Promise<string | null> {
|
|
463
|
+
return this.request("session.fileContents", { id: sessionId, path }, v.nullable(v.string()));
|
|
464
|
+
}
|
|
465
|
+
repoFiles(cwd: string): Promise<string[]> {
|
|
466
|
+
return this.request("repo.files", { cwd }, v.array(v.string()));
|
|
467
|
+
}
|
|
468
|
+
repoFileContents(cwd: string, path: string): Promise<string | null> {
|
|
469
|
+
return this.request("repo.fileContents", { cwd, path }, v.nullable(v.string()));
|
|
470
|
+
}
|
|
471
|
+
repoChanges(cwd: string): Promise<{ path: string; status: DiffFileStatus }[]> {
|
|
472
|
+
return this.request(
|
|
473
|
+
"repo.changes",
|
|
474
|
+
{ cwd },
|
|
475
|
+
v.array(v.object({ path: v.string(), status: v.picklist(["added", "modified", "deleted"]) })),
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
repoDiff(cwd: string): Promise<WorkingTreeDiff> {
|
|
479
|
+
return this.request(
|
|
480
|
+
"repo.diff",
|
|
481
|
+
{ cwd },
|
|
482
|
+
v.object({ patch: v.string(), files: v.array(DiffFileContentsSchema) }),
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
sessionWorkbench(cwd: string): Promise<Thread> {
|
|
486
|
+
return this.request("session.workbench", { cwd }, ThreadRecordSchema);
|
|
487
|
+
}
|
|
488
|
+
/** Re-capture a diff session's working tree; changed=true when the patch moved and an event fired. */
|
|
489
|
+
sessionRefreshDiff(id: string): Promise<{ changed: boolean }> {
|
|
490
|
+
return this.request("session.refreshDiff", { id }, RefreshDiffResultSchema);
|
|
491
|
+
}
|
|
492
|
+
sessionSetShareId(id: string, shareId: string): Promise<Thread> {
|
|
493
|
+
return this.request("session.setShareId", { id, shareId }, ThreadRecordSchema);
|
|
494
|
+
}
|
|
495
|
+
sessionMergeShared(id: string, incoming: SharedMerge): Promise<Thread> {
|
|
496
|
+
return this.request("session.mergeShared", { id, ...incoming }, ThreadRecordSchema);
|
|
497
|
+
}
|
|
498
|
+
sessionDelete(id: string): Promise<void> {
|
|
499
|
+
return this.request("session.delete", { id }, EmptyResultSchema).then(() => undefined);
|
|
500
|
+
}
|
|
501
|
+
/** Local sessions have no collaborator self-name; the share client owns this. */
|
|
502
|
+
sessionSetSelfName(id: string, _name: string): Promise<Thread> {
|
|
503
|
+
return this.sessionGet(id);
|
|
504
|
+
}
|
|
505
|
+
sessionResolve(
|
|
506
|
+
id: string,
|
|
507
|
+
verdictKind: VerdictKind,
|
|
508
|
+
summary: string,
|
|
509
|
+
actionBodies?: Record<string, string>,
|
|
510
|
+
): Promise<Thread> {
|
|
511
|
+
return this.request(
|
|
512
|
+
"session.resolve",
|
|
513
|
+
{ id, verdictKind, summary, actionBodies },
|
|
514
|
+
ThreadRecordSchema,
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
sessionSubmitRevision(
|
|
518
|
+
id: string,
|
|
519
|
+
content: string,
|
|
520
|
+
addressedAnnotationIds: string[] = [],
|
|
521
|
+
): Promise<Thread> {
|
|
522
|
+
return this.request(
|
|
523
|
+
"session.submitRevision",
|
|
524
|
+
{ id, content, addressedAnnotationIds },
|
|
525
|
+
ThreadRecordSchema,
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
/** herdr adapter scratch: the tab opened for a review; local-only, off the SessionClient contract. */
|
|
529
|
+
herdrGetTab(id: string): Promise<HerdrTabHandle | null> {
|
|
530
|
+
return this.request("herdr.getTab", { id }, HerdrTabResultSchema);
|
|
531
|
+
}
|
|
532
|
+
async herdrSetTab(id: string, handle: HerdrTabHandle): Promise<void> {
|
|
533
|
+
await this.request("herdr.setTab", { id, ...handle }, EmptyResultSchema);
|
|
162
534
|
}
|
|
163
535
|
shutdown(): Promise<void> {
|
|
164
|
-
return this.request("daemon.shutdown", {});
|
|
536
|
+
return this.request("daemon.shutdown", {}, EmptyResultSchema).then(() => undefined);
|
|
165
537
|
}
|
|
166
538
|
}
|
|
167
539
|
|
|
@@ -174,9 +546,27 @@ export class DaemonClientError extends Error {
|
|
|
174
546
|
}
|
|
175
547
|
}
|
|
176
548
|
|
|
549
|
+
// A compiled binary re-execs `cueloop daemon --autostart` (idle-exits like main.ts,
|
|
550
|
+
// unlike the never-exiting foreground daemon); from source, bun runs main.ts. In
|
|
551
|
+
// dev (CUELOOP_DEV_WATCH=1, source only) it runs under --watch so daemon-code edits
|
|
552
|
+
// reload the daemon without a manual restart - the version handshake only catches
|
|
553
|
+
// release upgrades, not same-version source changes.
|
|
554
|
+
export function daemonSpawnCommand(
|
|
555
|
+
execPath: string,
|
|
556
|
+
moduleUrl: string,
|
|
557
|
+
devWatch = process.env.CUELOOP_DEV_WATCH === "1",
|
|
558
|
+
): string[] {
|
|
559
|
+
const compiled =
|
|
560
|
+
moduleUrl.includes("$bunfs") || moduleUrl.includes("~BUN") || moduleUrl.includes("%7EBUN");
|
|
561
|
+
|
|
562
|
+
if (compiled) return [execPath, "daemon", "--autostart"];
|
|
563
|
+
const mainPath = new URL("./main.ts", moduleUrl).pathname;
|
|
564
|
+
|
|
565
|
+
return devWatch ? [execPath, "--watch", "run", mainPath] : [execPath, "run", mainPath];
|
|
566
|
+
}
|
|
567
|
+
|
|
177
568
|
function spawnDaemon(home: string): void {
|
|
178
|
-
|
|
179
|
-
Bun.spawn([process.execPath, "run", entry], {
|
|
569
|
+
Bun.spawn(daemonSpawnCommand(process.execPath, import.meta.url), {
|
|
180
570
|
env: { ...process.env, CUELOOP_HOME: home },
|
|
181
571
|
stdio: ["ignore", "ignore", "ignore"],
|
|
182
572
|
}).unref();
|