@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
package/src/api.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The daemon's method surface
|
|
2
|
+
* The daemon's method surface. Transport-independent: the socket
|
|
3
3
|
* server and the in-process test harness both call these handlers.
|
|
4
4
|
* The wait contract: verdicts outlive waits - session.wait long-polls,
|
|
5
5
|
* and a verdict resolved while nobody waited is delivered on next contact.
|
|
@@ -8,15 +8,22 @@
|
|
|
8
8
|
import {
|
|
9
9
|
SCHEMA_VERSION,
|
|
10
10
|
feedbackForSession,
|
|
11
|
+
isAddressed,
|
|
12
|
+
isAgentNote,
|
|
13
|
+
parseBlocks,
|
|
14
|
+
registerParticipant,
|
|
15
|
+
resolveAnchor,
|
|
11
16
|
verdictAllows,
|
|
12
17
|
type Annotation,
|
|
13
18
|
type Artifact,
|
|
19
|
+
type Identity,
|
|
14
20
|
type ReviewSession,
|
|
15
21
|
type Verdict,
|
|
16
22
|
type VerdictKind,
|
|
17
23
|
type WorkspaceKey,
|
|
18
24
|
} from "@cueloop/schema";
|
|
19
25
|
import { SessionStore } from "./store";
|
|
26
|
+
import { HerdrTabStore, type HerdrTabHandle } from "./herdr-tab-store";
|
|
20
27
|
import { DaemonError } from "./errors";
|
|
21
28
|
|
|
22
29
|
export type EventName =
|
|
@@ -31,10 +38,11 @@ export interface DaemonEvent {
|
|
|
31
38
|
sessionId: string;
|
|
32
39
|
}
|
|
33
40
|
|
|
34
|
-
type EventListener = (
|
|
41
|
+
type EventListener = (event: DaemonEvent) => void;
|
|
35
42
|
|
|
36
43
|
export class DaemonCore {
|
|
37
44
|
readonly store: SessionStore;
|
|
45
|
+
readonly herdrTabs: HerdrTabStore;
|
|
38
46
|
private waiters = new Map<string, ((session: ReviewSession) => void)[]>();
|
|
39
47
|
private listeners = new Set<EventListener>();
|
|
40
48
|
private seq = 0;
|
|
@@ -42,6 +50,16 @@ export class DaemonCore {
|
|
|
42
50
|
constructor(home: string) {
|
|
43
51
|
this.store = new SessionStore(home);
|
|
44
52
|
this.store.recover();
|
|
53
|
+
this.herdrTabs = new HerdrTabStore(home);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The herdr tab opened for a review, if any (adapter scratch, not on the session). */
|
|
57
|
+
herdrGetTab(sessionId: string): HerdrTabHandle | null {
|
|
58
|
+
return this.herdrTabs.get(sessionId);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
herdrSetTab(sessionId: string, handle: HerdrTabHandle): void {
|
|
62
|
+
this.herdrTabs.set(sessionId, handle);
|
|
45
63
|
}
|
|
46
64
|
|
|
47
65
|
onEvent(listener: EventListener): () => void {
|
|
@@ -50,12 +68,12 @@ export class DaemonCore {
|
|
|
50
68
|
}
|
|
51
69
|
|
|
52
70
|
private emit(event: EventName, sessionId: string): void {
|
|
53
|
-
for (const
|
|
71
|
+
for (const listener of this.listeners) listener({ event, sessionId });
|
|
54
72
|
}
|
|
55
73
|
|
|
56
74
|
/** True when nothing awaits a verdict - drives idle-exit. */
|
|
57
75
|
hasPendingSessions(): boolean {
|
|
58
|
-
return this.store.list().some((
|
|
76
|
+
return this.store.list().some((session) => session.status === "pending");
|
|
59
77
|
}
|
|
60
78
|
|
|
61
79
|
sessionCreate(params: { workspace: WorkspaceKey; artifact: Artifact }): ReviewSession {
|
|
@@ -78,16 +96,21 @@ export class DaemonCore {
|
|
|
78
96
|
}
|
|
79
97
|
|
|
80
98
|
sessionGet(id: string): ReviewSession {
|
|
81
|
-
const
|
|
82
|
-
if (!
|
|
83
|
-
return
|
|
99
|
+
const session = this.store.get(id);
|
|
100
|
+
if (!session) throw new DaemonError("not_found", `no session ${id}`);
|
|
101
|
+
return session;
|
|
84
102
|
}
|
|
85
103
|
|
|
86
|
-
sessionList(filter?: {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
104
|
+
sessionList(filter?: {
|
|
105
|
+
status?: "pending" | "resolved";
|
|
106
|
+
workspace?: Partial<WorkspaceKey>;
|
|
107
|
+
}): ReviewSession[] {
|
|
108
|
+
return this.store.list().filter((session) => {
|
|
109
|
+
if (filter?.status && session.status !== filter.status) return false;
|
|
110
|
+
if (filter?.workspace?.repoRoot && session.workspace.repoRoot !== filter.workspace.repoRoot)
|
|
111
|
+
return false;
|
|
112
|
+
if (filter?.workspace?.branch && session.workspace.branch !== filter.workspace.branch)
|
|
113
|
+
return false;
|
|
91
114
|
return true;
|
|
92
115
|
});
|
|
93
116
|
}
|
|
@@ -98,16 +121,16 @@ export class DaemonCore {
|
|
|
98
121
|
* still pending - the caller re-polls later; the verdict is never lost).
|
|
99
122
|
*/
|
|
100
123
|
sessionWait(id: string, timeoutMs: number): Promise<ReviewSession | null> {
|
|
101
|
-
const
|
|
102
|
-
if (
|
|
124
|
+
const current = this.sessionGet(id);
|
|
125
|
+
if (current.status === "resolved") return Promise.resolve(current);
|
|
103
126
|
return new Promise((resolve) => {
|
|
104
127
|
const list = this.waiters.get(id) ?? [];
|
|
105
128
|
let done = false;
|
|
106
129
|
const timer = setTimeout(() => {
|
|
107
130
|
if (done) return;
|
|
108
131
|
done = true;
|
|
109
|
-
const
|
|
110
|
-
if (
|
|
132
|
+
const waiterIndex = list.indexOf(waiter);
|
|
133
|
+
if (waiterIndex !== -1) list.splice(waiterIndex, 1);
|
|
111
134
|
resolve(null);
|
|
112
135
|
}, timeoutMs);
|
|
113
136
|
const waiter = (session: ReviewSession) => {
|
|
@@ -121,73 +144,180 @@ export class DaemonCore {
|
|
|
121
144
|
});
|
|
122
145
|
}
|
|
123
146
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
147
|
+
/**
|
|
148
|
+
* `authorName` registers the annotation's author in the participant registry;
|
|
149
|
+
* a bare author id (no name) keeps its short-handle fallback instead.
|
|
150
|
+
*/
|
|
151
|
+
sessionAnnotate(
|
|
152
|
+
id: string,
|
|
153
|
+
annotation: Omit<Annotation, "createdAt">,
|
|
154
|
+
authorName?: string,
|
|
155
|
+
): ReviewSession {
|
|
156
|
+
const session = this.mutable(id);
|
|
157
|
+
const existing = session.annotations.findIndex((candidate) => candidate.id === annotation.id);
|
|
127
158
|
const full: Annotation = { ...annotation, createdAt: new Date().toISOString() };
|
|
128
|
-
if (existing === -1)
|
|
129
|
-
else
|
|
130
|
-
|
|
159
|
+
if (existing === -1) session.annotations.push(full);
|
|
160
|
+
else
|
|
161
|
+
session.annotations[existing] = {
|
|
162
|
+
...full,
|
|
163
|
+
createdAt: session.annotations[existing]!.createdAt,
|
|
164
|
+
};
|
|
165
|
+
if (annotation.author && authorName)
|
|
166
|
+
session.participants = registerParticipant(
|
|
167
|
+
session,
|
|
168
|
+
annotation.author,
|
|
169
|
+
authorName,
|
|
170
|
+
).participants;
|
|
171
|
+
this.store.upsert(session);
|
|
131
172
|
this.emit("session.updated", id);
|
|
132
|
-
return
|
|
173
|
+
return session;
|
|
133
174
|
}
|
|
134
175
|
|
|
135
176
|
sessionRemoveAnnotation(id: string, annotationId: string): ReviewSession {
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
this.store.upsert(
|
|
177
|
+
const session = this.mutable(id);
|
|
178
|
+
session.annotations = session.annotations.filter((candidate) => candidate.id !== annotationId);
|
|
179
|
+
this.store.upsert(session);
|
|
139
180
|
this.emit("session.updated", id);
|
|
140
|
-
return
|
|
181
|
+
return session;
|
|
141
182
|
}
|
|
142
183
|
|
|
143
184
|
/** The reviewer's working copy; undefined clears it (revert all edits). */
|
|
144
185
|
sessionSetWorkingCopy(id: string, workingCopy: string | undefined): ReviewSession {
|
|
145
|
-
const
|
|
146
|
-
if (workingCopy === undefined || workingCopy ===
|
|
147
|
-
|
|
148
|
-
|
|
186
|
+
const session = this.mutable(id);
|
|
187
|
+
if (workingCopy === undefined || workingCopy === session.artifact.content)
|
|
188
|
+
delete session.workingCopy;
|
|
189
|
+
else session.workingCopy = workingCopy;
|
|
190
|
+
this.store.upsert(session);
|
|
149
191
|
this.emit("session.updated", id);
|
|
150
|
-
return
|
|
192
|
+
return session;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The guided walk's viewed marks. Merge-additive: walking only ever adds
|
|
197
|
+
* marks, so concurrent or stale clients converge instead of overwriting
|
|
198
|
+
* each other. An empty array is the explicit reset.
|
|
199
|
+
*/
|
|
200
|
+
sessionSetViewed(id: string, viewedPaths: string[]): ReviewSession {
|
|
201
|
+
const session = this.mutable(id);
|
|
202
|
+
if (viewedPaths.length === 0) delete session.viewedPaths;
|
|
203
|
+
else session.viewedPaths = [...new Set([...(session.viewedPaths ?? []), ...viewedPaths])];
|
|
204
|
+
this.store.upsert(session);
|
|
205
|
+
this.emit("session.updated", id);
|
|
206
|
+
return session;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
sessionSetShareId(id: string, shareId: string): ReviewSession {
|
|
210
|
+
const session = this.mutable(id);
|
|
211
|
+
session.shareId = shareId;
|
|
212
|
+
this.store.upsert(session);
|
|
213
|
+
this.emit("session.updated", id);
|
|
214
|
+
return session;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Remove a session for good (inbox delete); resolved or pending, both go. */
|
|
218
|
+
sessionDelete(id: string): void {
|
|
219
|
+
if (!this.store.delete(id)) throw new DaemonError("not_found", `no session ${id}`);
|
|
220
|
+
this.herdrTabs.delete(id);
|
|
221
|
+
this.emit("inbox.changed", id);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Merge a share's collaborator state back into the local session: annotations
|
|
226
|
+
* union by id with existing ones (the planner's) winning, and the participant
|
|
227
|
+
* registry union by id with the incoming identity winning (a collaborator is
|
|
228
|
+
* the authority on their own name). This is how a teammate's name reaches the
|
|
229
|
+
* planner after a pull, alongside their notes.
|
|
230
|
+
*/
|
|
231
|
+
sessionMergeShared(
|
|
232
|
+
id: string,
|
|
233
|
+
incoming: { annotations: Annotation[]; participants?: Identity[] },
|
|
234
|
+
): ReviewSession {
|
|
235
|
+
const session = this.mutable(id);
|
|
236
|
+
const known = new Set(session.annotations.map((annotation) => annotation.id));
|
|
237
|
+
for (const annotation of incoming.annotations)
|
|
238
|
+
if (!known.has(annotation.id)) session.annotations.push(annotation);
|
|
239
|
+
if (incoming.participants?.length) {
|
|
240
|
+
const registry = new Map(
|
|
241
|
+
(session.participants ?? []).map((participant) => [participant.id, participant]),
|
|
242
|
+
);
|
|
243
|
+
for (const participant of incoming.participants) registry.set(participant.id, participant);
|
|
244
|
+
session.participants = [...registry.values()];
|
|
245
|
+
}
|
|
246
|
+
this.store.upsert(session);
|
|
247
|
+
this.emit("session.updated", id);
|
|
248
|
+
return session;
|
|
151
249
|
}
|
|
152
250
|
|
|
153
251
|
sessionResolve(id: string, verdictKind: VerdictKind, summary: string): ReviewSession {
|
|
154
|
-
const
|
|
252
|
+
const session = this.mutable(id);
|
|
155
253
|
const verdict: Verdict = {
|
|
156
254
|
kind: verdictKind,
|
|
157
255
|
summary,
|
|
158
|
-
feedback: feedbackForSession(
|
|
256
|
+
feedback: feedbackForSession(session, verdictKind, summary),
|
|
159
257
|
resolvedAt: new Date().toISOString(),
|
|
160
258
|
};
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
this.store.upsert(
|
|
259
|
+
session.verdict = verdict;
|
|
260
|
+
session.status = "resolved";
|
|
261
|
+
this.store.upsert(session);
|
|
164
262
|
const parked = this.waiters.get(id) ?? [];
|
|
165
263
|
this.waiters.delete(id);
|
|
166
|
-
for (const
|
|
264
|
+
for (const parkedWaiter of parked) parkedWaiter(session);
|
|
167
265
|
this.emit("session.resolved", id);
|
|
168
266
|
this.emit("inbox.changed", id);
|
|
169
|
-
return
|
|
267
|
+
return session;
|
|
170
268
|
}
|
|
171
269
|
|
|
172
|
-
/**
|
|
173
|
-
|
|
174
|
-
|
|
270
|
+
/**
|
|
271
|
+
* Agent resubmits: new revision becomes the artifact, session reopens.
|
|
272
|
+
* Annotations the agent reports as acted on (by id) are marked addressed,
|
|
273
|
+
* and for plan revisions any still-open annotation whose quoted text no
|
|
274
|
+
* longer resolves is marked addressed too ("drift" - the line it pointed at
|
|
275
|
+
* was rewritten). Addressed is a marker, never a delete: the reviewer's
|
|
276
|
+
* rail hides them behind a count, and the next feedback document omits them.
|
|
277
|
+
* Unknown ids are ignored, so a stale id in the agent's list never fails
|
|
278
|
+
* the resubmit.
|
|
279
|
+
*/
|
|
280
|
+
sessionSubmitRevision(
|
|
281
|
+
id: string,
|
|
282
|
+
content: string,
|
|
283
|
+
addressedAnnotationIds: string[] = [],
|
|
284
|
+
): ReviewSession {
|
|
285
|
+
const session = this.sessionGet(id);
|
|
175
286
|
const now = new Date().toISOString();
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
287
|
+
const revisionNumber = session.revisions.length + 1;
|
|
288
|
+
session.revisions.push({ revision: revisionNumber, content, submittedAt: now });
|
|
289
|
+
session.artifact = { ...session.artifact, content };
|
|
290
|
+
delete session.workingCopy;
|
|
291
|
+
session.verdict = null;
|
|
292
|
+
session.status = "pending";
|
|
293
|
+
|
|
294
|
+
const reportedIds = new Set(addressedAnnotationIds);
|
|
295
|
+
// drift assist applies to plans only: a diff revision is a whole new
|
|
296
|
+
// patch, where a vanished quote says nothing about the feedback
|
|
297
|
+
const revisedBlocks = session.artifact.type === "plan" ? parseBlocks(content) : null;
|
|
298
|
+
for (const annotation of session.annotations) {
|
|
299
|
+
if (isAddressed(annotation) || isAgentNote(annotation)) continue;
|
|
300
|
+
if (reportedIds.has(annotation.id)) {
|
|
301
|
+
annotation.resolution = { revision: revisionNumber, source: "agent" };
|
|
302
|
+
} else if (
|
|
303
|
+
revisedBlocks !== null &&
|
|
304
|
+
resolveAnchor(annotation.anchor, revisedBlocks) === null
|
|
305
|
+
) {
|
|
306
|
+
annotation.resolution = { revision: revisionNumber, source: "drift" };
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
this.store.upsert(session);
|
|
182
311
|
this.emit("session.revised", id);
|
|
183
312
|
this.emit("inbox.changed", id);
|
|
184
|
-
return
|
|
313
|
+
return session;
|
|
185
314
|
}
|
|
186
315
|
|
|
187
316
|
private mutable(id: string): ReviewSession {
|
|
188
|
-
const
|
|
189
|
-
if (
|
|
190
|
-
|
|
317
|
+
const session = this.sessionGet(id);
|
|
318
|
+
if (session.status === "resolved")
|
|
319
|
+
throw new DaemonError("resolved", `session ${id} is already resolved`);
|
|
320
|
+
return session;
|
|
191
321
|
}
|
|
192
322
|
}
|
|
193
323
|
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { asDaemonRole, roleAllowsMethod } from "./capabilities";
|
|
3
|
+
|
|
4
|
+
describe("roleAllowsMethod", () => {
|
|
5
|
+
test("the owner may call every verb", () => {
|
|
6
|
+
// Assert
|
|
7
|
+
for (const method of ["session.resolve", "session.submitRevision", "session.delete"])
|
|
8
|
+
expect(roleAllowsMethod("owner", method)).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("a capped role may read and annotate but not escalate", () => {
|
|
12
|
+
// Assert - allowed
|
|
13
|
+
for (const method of ["session.get", "session.list", "session.wait", "session.annotate"])
|
|
14
|
+
expect(roleAllowsMethod("agent", method)).toBe(true);
|
|
15
|
+
// Assert - denied
|
|
16
|
+
for (const method of [
|
|
17
|
+
"session.resolve",
|
|
18
|
+
"session.submitRevision",
|
|
19
|
+
"session.setWorkingCopy",
|
|
20
|
+
"session.delete",
|
|
21
|
+
"session.setShareId",
|
|
22
|
+
])
|
|
23
|
+
expect(roleAllowsMethod("agent", method)).toBe(false);
|
|
24
|
+
expect(roleAllowsMethod("collaborator", "session.resolve")).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("asDaemonRole", () => {
|
|
29
|
+
test("passes owner and collaborator through and caps anything unknown to agent", () => {
|
|
30
|
+
// Assert
|
|
31
|
+
expect(asDaemonRole("owner")).toBe("owner");
|
|
32
|
+
expect(asDaemonRole("collaborator")).toBe("collaborator");
|
|
33
|
+
expect(asDaemonRole("agent")).toBe("agent");
|
|
34
|
+
expect(asDaemonRole("root")).toBe("agent");
|
|
35
|
+
expect(asDaemonRole(undefined)).toBe("agent");
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon role capabilities: which socket methods a connection may call. The
|
|
3
|
+
* owner (the local planner) may call everything; a collaborator or a review-side
|
|
4
|
+
* agent is capped to reading the session and adding annotations, so a bring-your-
|
|
5
|
+
* own agent literally cannot resolve, rewrite, cut, share, or delete (ADR 0009).
|
|
6
|
+
* This is the single source of truth the server dispatch enforces.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type DaemonRole = "owner" | "collaborator" | "agent";
|
|
10
|
+
|
|
11
|
+
/** Methods a non-owner role may call: read the session and annotate, plus connection plumbing. */
|
|
12
|
+
const NON_OWNER_METHODS = new Set<string>([
|
|
13
|
+
"daemon.ping",
|
|
14
|
+
"daemon.hello",
|
|
15
|
+
"events.subscribe",
|
|
16
|
+
"session.get",
|
|
17
|
+
"session.list",
|
|
18
|
+
"session.wait",
|
|
19
|
+
"session.annotate",
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
/** Whether `role` may call `method`. The owner may call anything; others are capped to read + annotate. */
|
|
23
|
+
export function roleAllowsMethod(role: DaemonRole, method: string): boolean {
|
|
24
|
+
return role === "owner" || NON_OWNER_METHODS.has(method);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Parse an untrusted role string, defaulting unknown values to the capped "agent". */
|
|
28
|
+
export function asDaemonRole(value: unknown): DaemonRole {
|
|
29
|
+
return value === "owner" || value === "collaborator" ? value : "agent";
|
|
30
|
+
}
|
package/src/client.ts
CHANGED
|
@@ -1,38 +1,85 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Daemon client: the one library every consumer shares - CLI verbs, the TUI,
|
|
3
|
-
* adapters, and tests. Also owns the lazy-launch story
|
|
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
7
|
import { existsSync, rmSync } from "node:fs";
|
|
8
|
-
import type {
|
|
9
|
-
|
|
8
|
+
import type {
|
|
9
|
+
Annotation,
|
|
10
|
+
Artifact,
|
|
11
|
+
Identity,
|
|
12
|
+
ReviewSession,
|
|
13
|
+
VerdictKind,
|
|
14
|
+
WorkspaceKey,
|
|
15
|
+
} from "@cueloop/schema";
|
|
16
|
+
import { BackpressureWriter, LineBuffer, type EventFrame, type Response } from "./protocol";
|
|
17
|
+
import type { DaemonRole } from "./capabilities";
|
|
18
|
+
import type { HerdrTabHandle } from "./herdr-tab-store";
|
|
10
19
|
import { cueloopHome, socketPath } from "./paths";
|
|
11
20
|
|
|
21
|
+
export type { EventFrame } from "./protocol";
|
|
22
|
+
|
|
12
23
|
export interface ConnectOptions {
|
|
13
24
|
home?: string;
|
|
14
25
|
/** Spawn the daemon when the socket is not alive. */
|
|
15
26
|
autostart?: boolean;
|
|
27
|
+
/** Capability role for this connection; a review-side agent connects capped. Defaults to owner. */
|
|
28
|
+
role?: DaemonRole;
|
|
16
29
|
}
|
|
17
30
|
|
|
18
|
-
type
|
|
31
|
+
type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void };
|
|
19
32
|
|
|
20
|
-
|
|
33
|
+
/**
|
|
34
|
+
* The session verbs the review controller drives. DaemonClient is the local
|
|
35
|
+
* implementation (unix socket); the sharing gateway supplies an in-memory,
|
|
36
|
+
* blob-backed one. Depending on this interface - not DaemonClient - is what
|
|
37
|
+
* lets the same <App> render a local session or a decrypted share unchanged.
|
|
38
|
+
*/
|
|
39
|
+
export interface SessionClient {
|
|
40
|
+
onEvent(listener: (event: EventFrame) => void): () => void;
|
|
41
|
+
subscribe(): Promise<void>;
|
|
42
|
+
sessionGet(id: string): Promise<ReviewSession>;
|
|
43
|
+
sessionList(filter?: { status?: "pending" | "resolved" }): Promise<ReviewSession[]>;
|
|
44
|
+
sessionAnnotate(
|
|
45
|
+
id: string,
|
|
46
|
+
annotation: Omit<Annotation, "createdAt">,
|
|
47
|
+
authorName?: string,
|
|
48
|
+
): Promise<ReviewSession>;
|
|
49
|
+
sessionRemoveAnnotation(id: string, annotationId: string): Promise<ReviewSession>;
|
|
50
|
+
sessionSetWorkingCopy(id: string, workingCopy: string | undefined): Promise<ReviewSession>;
|
|
51
|
+
sessionSetViewed(id: string, viewedPaths: string[]): Promise<ReviewSession>;
|
|
52
|
+
sessionSetShareId(id: string, shareId: string): Promise<ReviewSession>;
|
|
53
|
+
sessionMergeShared(
|
|
54
|
+
id: string,
|
|
55
|
+
incoming: { annotations: Annotation[]; participants?: Identity[] },
|
|
56
|
+
): Promise<ReviewSession>;
|
|
57
|
+
sessionDelete(id: string): Promise<void>;
|
|
58
|
+
/** Record the caller's own identity name (collaborator self-naming on a share). */
|
|
59
|
+
sessionSetSelfName(id: string, name: string): Promise<ReviewSession>;
|
|
60
|
+
sessionResolve(id: string, verdictKind: VerdictKind, summary: string): Promise<ReviewSession>;
|
|
61
|
+
close(): void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class DaemonClient implements SessionClient {
|
|
21
65
|
private socket: Awaited<ReturnType<typeof Bun.connect>> | null = null;
|
|
22
|
-
private
|
|
66
|
+
private writer: BackpressureWriter | null = null;
|
|
67
|
+
private pending = new Map<number, PendingRequest>();
|
|
23
68
|
private nextId = 1;
|
|
24
|
-
private eventListeners = new Set<(
|
|
69
|
+
private eventListeners = new Set<(event: EventFrame) => void>();
|
|
25
70
|
private closed = false;
|
|
71
|
+
private role: DaemonRole = "owner";
|
|
26
72
|
|
|
27
|
-
static async connect(
|
|
28
|
-
const home =
|
|
73
|
+
static async connect(options: ConnectOptions = {}): Promise<DaemonClient> {
|
|
74
|
+
const home = options.home ?? cueloopHome();
|
|
29
75
|
const path = socketPath(home);
|
|
30
76
|
const client = new DaemonClient();
|
|
77
|
+
client.role = options.role ?? "owner";
|
|
31
78
|
try {
|
|
32
79
|
await client.dial(path);
|
|
33
80
|
return client;
|
|
34
81
|
} catch (err) {
|
|
35
|
-
if (!
|
|
82
|
+
if (!options.autostart) throw err;
|
|
36
83
|
}
|
|
37
84
|
// Socket dead or absent: clean a stale file and spawn the daemon detached.
|
|
38
85
|
if (existsSync(path)) rmSync(path, { force: true });
|
|
@@ -40,47 +87,53 @@ export class DaemonClient {
|
|
|
40
87
|
// Generous: a cold or loaded machine pays for a runtime start before the
|
|
41
88
|
// socket exists, and giving up early looks to callers like a broken daemon.
|
|
42
89
|
const deadline = Date.now() + Number(process.env.CUELOOP_START_TIMEOUT_MS ?? 30_000);
|
|
43
|
-
let
|
|
90
|
+
let lastError: unknown;
|
|
44
91
|
while (Date.now() < deadline) {
|
|
45
92
|
try {
|
|
46
93
|
await client.dial(path);
|
|
47
94
|
return client;
|
|
48
95
|
} catch (err) {
|
|
49
|
-
|
|
96
|
+
lastError = err;
|
|
50
97
|
await Bun.sleep(50);
|
|
51
98
|
}
|
|
52
99
|
}
|
|
53
|
-
throw new Error(`daemon did not come up at ${path}: ${String(
|
|
100
|
+
throw new Error(`daemon did not come up at ${path}: ${String(lastError)}`);
|
|
54
101
|
}
|
|
55
102
|
|
|
56
103
|
private async dial(path: string): Promise<void> {
|
|
57
104
|
const buffer = new LineBuffer();
|
|
58
|
-
const self = this;
|
|
59
105
|
this.socket = await Bun.connect({
|
|
60
106
|
unix: path,
|
|
61
107
|
socket: {
|
|
62
|
-
data(_socket, data) {
|
|
63
|
-
buffer.push(data.toString(), (line) =>
|
|
108
|
+
data: (_socket, data) => {
|
|
109
|
+
buffer.push(data.toString(), (line) => this.routeInboundFrame(line));
|
|
110
|
+
},
|
|
111
|
+
drain: () => {
|
|
112
|
+
this.writer?.drain();
|
|
64
113
|
},
|
|
65
|
-
close() {
|
|
66
|
-
|
|
67
|
-
for (const
|
|
68
|
-
|
|
114
|
+
close: () => {
|
|
115
|
+
this.closed = true;
|
|
116
|
+
for (const pendingRequest of this.pending.values())
|
|
117
|
+
pendingRequest.reject(new Error("daemon connection closed"));
|
|
118
|
+
this.pending.clear();
|
|
69
119
|
},
|
|
70
120
|
error() {},
|
|
71
121
|
},
|
|
72
122
|
});
|
|
123
|
+
this.writer = new BackpressureWriter(this.socket);
|
|
73
124
|
// Verify liveness: a dead socket file accepts connects on some platforms
|
|
74
125
|
// only to fail later, so a ping is the actual handshake.
|
|
75
126
|
await this.request("daemon.ping", {}, 2_000);
|
|
127
|
+
// Cap this connection's role for the daemon's capability gate (owner is the default).
|
|
128
|
+
if (this.role !== "owner") await this.request("daemon.hello", { role: this.role }, 2_000);
|
|
76
129
|
}
|
|
77
130
|
|
|
78
|
-
onEvent(listener: (
|
|
131
|
+
onEvent(listener: (event: EventFrame) => void): () => void {
|
|
79
132
|
this.eventListeners.add(listener);
|
|
80
133
|
return () => this.eventListeners.delete(listener);
|
|
81
134
|
}
|
|
82
135
|
|
|
83
|
-
private
|
|
136
|
+
private routeInboundFrame(line: string): void {
|
|
84
137
|
let frame: Response | EventFrame;
|
|
85
138
|
try {
|
|
86
139
|
frame = JSON.parse(line) as Response | EventFrame;
|
|
@@ -88,14 +141,15 @@ export class DaemonClient {
|
|
|
88
141
|
return;
|
|
89
142
|
}
|
|
90
143
|
if ("event" in frame) {
|
|
91
|
-
for (const
|
|
144
|
+
for (const listener of this.eventListeners) listener(frame);
|
|
92
145
|
return;
|
|
93
146
|
}
|
|
94
|
-
const
|
|
95
|
-
if (!
|
|
147
|
+
const pendingRequest = this.pending.get(frame.id);
|
|
148
|
+
if (!pendingRequest) return;
|
|
96
149
|
this.pending.delete(frame.id);
|
|
97
|
-
if (frame.error)
|
|
98
|
-
|
|
150
|
+
if (frame.error)
|
|
151
|
+
pendingRequest.reject(new DaemonClientError(frame.error.code, frame.error.message));
|
|
152
|
+
else pendingRequest.resolve(frame.result);
|
|
99
153
|
}
|
|
100
154
|
|
|
101
155
|
request<T = unknown>(method: string, params: unknown, timeoutMs = 30_000): Promise<T> {
|
|
@@ -107,16 +161,16 @@ export class DaemonClient {
|
|
|
107
161
|
reject(new Error(`request ${method} timed out`));
|
|
108
162
|
}, timeoutMs);
|
|
109
163
|
this.pending.set(id, {
|
|
110
|
-
resolve: (
|
|
164
|
+
resolve: (value) => {
|
|
111
165
|
clearTimeout(timer);
|
|
112
|
-
resolve(
|
|
166
|
+
resolve(value as T);
|
|
113
167
|
},
|
|
114
|
-
reject: (
|
|
168
|
+
reject: (error) => {
|
|
115
169
|
clearTimeout(timer);
|
|
116
|
-
reject(
|
|
170
|
+
reject(error);
|
|
117
171
|
},
|
|
118
172
|
});
|
|
119
|
-
this.
|
|
173
|
+
this.writer!.write(JSON.stringify({ id, method, params }) + "\n");
|
|
120
174
|
});
|
|
121
175
|
}
|
|
122
176
|
|
|
@@ -145,8 +199,12 @@ export class DaemonClient {
|
|
|
145
199
|
sessionWait(id: string, timeoutMs: number): Promise<ReviewSession | null> {
|
|
146
200
|
return this.request("session.wait", { id, timeoutMs }, timeoutMs + 10_000);
|
|
147
201
|
}
|
|
148
|
-
sessionAnnotate(
|
|
149
|
-
|
|
202
|
+
sessionAnnotate(
|
|
203
|
+
id: string,
|
|
204
|
+
annotation: Omit<Annotation, "createdAt">,
|
|
205
|
+
authorName?: string,
|
|
206
|
+
): Promise<ReviewSession> {
|
|
207
|
+
return this.request("session.annotate", { id, annotation, authorName });
|
|
150
208
|
}
|
|
151
209
|
sessionRemoveAnnotation(id: string, annotationId: string): Promise<ReviewSession> {
|
|
152
210
|
return this.request("session.removeAnnotation", { id, annotationId });
|
|
@@ -154,11 +212,41 @@ export class DaemonClient {
|
|
|
154
212
|
sessionSetWorkingCopy(id: string, workingCopy: string | undefined): Promise<ReviewSession> {
|
|
155
213
|
return this.request("session.setWorkingCopy", { id, workingCopy });
|
|
156
214
|
}
|
|
215
|
+
sessionSetViewed(id: string, viewedPaths: string[]): Promise<ReviewSession> {
|
|
216
|
+
return this.request("session.setViewed", { id, viewedPaths });
|
|
217
|
+
}
|
|
218
|
+
sessionSetShareId(id: string, shareId: string): Promise<ReviewSession> {
|
|
219
|
+
return this.request("session.setShareId", { id, shareId });
|
|
220
|
+
}
|
|
221
|
+
sessionMergeShared(
|
|
222
|
+
id: string,
|
|
223
|
+
incoming: { annotations: Annotation[]; participants?: Identity[] },
|
|
224
|
+
): Promise<ReviewSession> {
|
|
225
|
+
return this.request("session.mergeShared", { id, ...incoming });
|
|
226
|
+
}
|
|
227
|
+
sessionDelete(id: string): Promise<void> {
|
|
228
|
+
return this.request("session.delete", { id });
|
|
229
|
+
}
|
|
230
|
+
/** Local sessions have no collaborator self-name; the share client owns this. */
|
|
231
|
+
sessionSetSelfName(id: string, _name: string): Promise<ReviewSession> {
|
|
232
|
+
return this.sessionGet(id);
|
|
233
|
+
}
|
|
157
234
|
sessionResolve(id: string, verdictKind: VerdictKind, summary: string): Promise<ReviewSession> {
|
|
158
235
|
return this.request("session.resolve", { id, verdictKind, summary });
|
|
159
236
|
}
|
|
160
|
-
sessionSubmitRevision(
|
|
161
|
-
|
|
237
|
+
sessionSubmitRevision(
|
|
238
|
+
id: string,
|
|
239
|
+
content: string,
|
|
240
|
+
addressedAnnotationIds: string[] = [],
|
|
241
|
+
): Promise<ReviewSession> {
|
|
242
|
+
return this.request("session.submitRevision", { id, content, addressedAnnotationIds });
|
|
243
|
+
}
|
|
244
|
+
/** herdr adapter scratch: the tab opened for a review; local-only, off the SessionClient contract. */
|
|
245
|
+
herdrGetTab(id: string): Promise<HerdrTabHandle | null> {
|
|
246
|
+
return this.request("herdr.getTab", { id });
|
|
247
|
+
}
|
|
248
|
+
async herdrSetTab(id: string, handle: HerdrTabHandle): Promise<void> {
|
|
249
|
+
await this.request("herdr.setTab", { id, ...handle });
|
|
162
250
|
}
|
|
163
251
|
shutdown(): Promise<void> {
|
|
164
252
|
return this.request("daemon.shutdown", {});
|