@cueloop/daemon 0.1.0-alpha.6 → 0.1.0-alpha.61
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 +30 -22
- package/src/api.test.ts +292 -46
- package/src/api.ts +301 -52
- package/src/capabilities.test.ts +38 -0
- package/src/capabilities.ts +30 -0
- package/src/client.test.ts +24 -0
- package/src/client.ts +151 -38
- package/src/diff-hot-reload.test.ts +170 -0
- package/src/diff-watcher.ts +105 -0
- package/src/herdr-pane.test.ts +243 -0
- package/src/herdr-pane.ts +154 -0
- package/src/herdr-split.test.ts +106 -0
- package/src/herdr-split.ts +88 -0
- package/src/herdr-tab-store.test.ts +96 -0
- package/src/herdr-tab-store.ts +69 -0
- package/src/index.ts +8 -8
- package/src/main.ts +4 -1
- package/src/paths.ts +9 -0
- package/src/protocol.ts +51 -7
- package/src/retention.test.ts +182 -0
- package/src/retention.ts +122 -0
- package/src/review.test.ts +370 -0
- package/src/review.ts +289 -0
- package/src/server.test.ts +169 -20
- package/src/server.ts +193 -84
- package/src/share-blob.test.ts +62 -0
- package/src/share-blob.ts +55 -0
- package/src/single-instance.test.ts +38 -11
- package/src/store.ts +13 -5
- package/src/validate.test.ts +196 -14
- package/src/validate.ts +138 -38
- package/src/working-tree.ts +113 -0
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,26 @@
|
|
|
8
8
|
import {
|
|
9
9
|
SCHEMA_VERSION,
|
|
10
10
|
feedbackForSession,
|
|
11
|
+
isAddressed,
|
|
12
|
+
isAgentNote,
|
|
13
|
+
isMarkdownArtifact,
|
|
14
|
+
parseBlocks,
|
|
15
|
+
registerParticipant,
|
|
16
|
+
resolveAnchor,
|
|
11
17
|
verdictAllows,
|
|
12
18
|
type Annotation,
|
|
13
19
|
type Artifact,
|
|
20
|
+
type Identity,
|
|
14
21
|
type ReviewSession,
|
|
15
22
|
type Verdict,
|
|
16
23
|
type VerdictKind,
|
|
17
24
|
type WorkspaceKey,
|
|
18
25
|
} from "@cueloop/schema";
|
|
19
26
|
import { SessionStore } from "./store";
|
|
27
|
+
import { pruneExpiredSessions, resolveCleanupPeriodDays } from "./retention";
|
|
28
|
+
import { HerdrTabStore, type HerdrTabHandle } from "./herdr-tab-store";
|
|
29
|
+
import { DiffWatcher } from "./diff-watcher";
|
|
30
|
+
import { workingTreeDiff } from "./working-tree";
|
|
20
31
|
import { DaemonError } from "./errors";
|
|
21
32
|
|
|
22
33
|
export type EventName =
|
|
@@ -31,31 +42,60 @@ export interface DaemonEvent {
|
|
|
31
42
|
sessionId: string;
|
|
32
43
|
}
|
|
33
44
|
|
|
34
|
-
type EventListener = (
|
|
45
|
+
type EventListener = (event: DaemonEvent) => void;
|
|
35
46
|
|
|
36
47
|
export class DaemonCore {
|
|
37
48
|
readonly store: SessionStore;
|
|
49
|
+
readonly herdrTabs: HerdrTabStore;
|
|
38
50
|
private waiters = new Map<string, ((session: ReviewSession) => void)[]>();
|
|
39
51
|
private listeners = new Set<EventListener>();
|
|
40
52
|
private seq = 0;
|
|
53
|
+
/** Drives diff hot-reload: watches each live diff session's repo for working-tree changes. */
|
|
54
|
+
private readonly diffWatcher: DiffWatcher;
|
|
55
|
+
/**
|
|
56
|
+
* Per-diff-session capture generation. Bumped when a refresh begins; a
|
|
57
|
+
* capture whose generation is stale by the time it finishes discards its
|
|
58
|
+
* result, so overlapping captures never write an older patch over a newer one.
|
|
59
|
+
*/
|
|
60
|
+
private readonly diffRefreshGenerations = new Map<string, number>();
|
|
41
61
|
|
|
42
62
|
constructor(home: string) {
|
|
43
63
|
this.store = new SessionStore(home);
|
|
44
64
|
this.store.recover();
|
|
65
|
+
pruneExpiredSessions(this.store, resolveCleanupPeriodDays(), Date.now());
|
|
66
|
+
this.herdrTabs = new HerdrTabStore(home);
|
|
67
|
+
this.diffWatcher = new DiffWatcher((repoRoot) => void this.refreshDiffsForRepo(repoRoot));
|
|
68
|
+
// resume hot-reload for diff sessions that survived a daemon restart
|
|
69
|
+
for (const session of this.store.list()) this.watchIfDiffSession(session);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Release the fs watchers behind diff hot-reload; call on daemon shutdown. */
|
|
73
|
+
dispose(): void {
|
|
74
|
+
this.diffWatcher.close();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The herdr tab opened for a review, if any (adapter scratch, not on the session). */
|
|
78
|
+
herdrGetTab(sessionId: string): HerdrTabHandle | null {
|
|
79
|
+
return this.herdrTabs.get(sessionId);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
herdrSetTab(sessionId: string, handle: HerdrTabHandle): void {
|
|
83
|
+
this.herdrTabs.set(sessionId, handle);
|
|
45
84
|
}
|
|
46
85
|
|
|
47
86
|
onEvent(listener: EventListener): () => void {
|
|
48
87
|
this.listeners.add(listener);
|
|
88
|
+
|
|
49
89
|
return () => this.listeners.delete(listener);
|
|
50
90
|
}
|
|
51
91
|
|
|
52
92
|
private emit(event: EventName, sessionId: string): void {
|
|
53
|
-
for (const
|
|
93
|
+
for (const listener of this.listeners) listener({ event, sessionId });
|
|
54
94
|
}
|
|
55
95
|
|
|
56
96
|
/** True when nothing awaits a verdict - drives idle-exit. */
|
|
57
97
|
hasPendingSessions(): boolean {
|
|
58
|
-
return this.store.list().some((
|
|
98
|
+
return this.store.list().some((session) => session.status === "pending");
|
|
59
99
|
}
|
|
60
100
|
|
|
61
101
|
sessionCreate(params: { workspace: WorkspaceKey; artifact: Artifact }): ReviewSession {
|
|
@@ -71,23 +111,34 @@ export class DaemonCore {
|
|
|
71
111
|
status: "pending",
|
|
72
112
|
createdAt: now,
|
|
73
113
|
};
|
|
114
|
+
|
|
74
115
|
this.store.upsert(session);
|
|
116
|
+
this.watchIfDiffSession(session);
|
|
75
117
|
this.emit("session.created", session.id);
|
|
76
118
|
this.emit("inbox.changed", session.id);
|
|
119
|
+
|
|
77
120
|
return session;
|
|
78
121
|
}
|
|
79
122
|
|
|
80
123
|
sessionGet(id: string): ReviewSession {
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
124
|
+
const session = this.store.get(id);
|
|
125
|
+
|
|
126
|
+
if (!session) throw new DaemonError("not_found", `no session ${id}`);
|
|
127
|
+
|
|
128
|
+
return session;
|
|
84
129
|
}
|
|
85
130
|
|
|
86
|
-
sessionList(filter?: {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
131
|
+
sessionList(filter?: {
|
|
132
|
+
status?: "pending" | "resolved";
|
|
133
|
+
workspace?: Partial<WorkspaceKey>;
|
|
134
|
+
}): ReviewSession[] {
|
|
135
|
+
return this.store.list().filter((session) => {
|
|
136
|
+
if (filter?.status && session.status !== filter.status) return false;
|
|
137
|
+
if (filter?.workspace?.repoRoot && session.workspace.repoRoot !== filter.workspace.repoRoot)
|
|
138
|
+
return false;
|
|
139
|
+
if (filter?.workspace?.branch && session.workspace.branch !== filter.workspace.branch)
|
|
140
|
+
return false;
|
|
141
|
+
|
|
91
142
|
return true;
|
|
92
143
|
});
|
|
93
144
|
}
|
|
@@ -98,16 +149,19 @@ export class DaemonCore {
|
|
|
98
149
|
* still pending - the caller re-polls later; the verdict is never lost).
|
|
99
150
|
*/
|
|
100
151
|
sessionWait(id: string, timeoutMs: number): Promise<ReviewSession | null> {
|
|
101
|
-
const
|
|
102
|
-
|
|
152
|
+
const current = this.sessionGet(id);
|
|
153
|
+
|
|
154
|
+
if (current.status === "resolved") return Promise.resolve(current);
|
|
155
|
+
|
|
103
156
|
return new Promise((resolve) => {
|
|
104
157
|
const list = this.waiters.get(id) ?? [];
|
|
105
158
|
let done = false;
|
|
106
159
|
const timer = setTimeout(() => {
|
|
107
160
|
if (done) return;
|
|
108
161
|
done = true;
|
|
109
|
-
const
|
|
110
|
-
|
|
162
|
+
const waiterIndex = list.indexOf(waiter);
|
|
163
|
+
|
|
164
|
+
if (waiterIndex !== -1) list.splice(waiterIndex, 1);
|
|
111
165
|
resolve(null);
|
|
112
166
|
}, timeoutMs);
|
|
113
167
|
const waiter = (session: ReviewSession) => {
|
|
@@ -116,85 +170,280 @@ export class DaemonCore {
|
|
|
116
170
|
clearTimeout(timer);
|
|
117
171
|
resolve(session);
|
|
118
172
|
};
|
|
173
|
+
|
|
119
174
|
list.push(waiter);
|
|
120
175
|
this.waiters.set(id, list);
|
|
121
176
|
});
|
|
122
177
|
}
|
|
123
178
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
179
|
+
/**
|
|
180
|
+
* `authorName` registers the annotation's author in the participant registry;
|
|
181
|
+
* a bare author id (no name) keeps its short-handle fallback instead.
|
|
182
|
+
*/
|
|
183
|
+
sessionAnnotate(
|
|
184
|
+
id: string,
|
|
185
|
+
annotation: Omit<Annotation, "createdAt">,
|
|
186
|
+
authorName?: string,
|
|
187
|
+
): ReviewSession {
|
|
188
|
+
const session = this.mutable(id);
|
|
189
|
+
const existing = session.annotations.findIndex((candidate) => candidate.id === annotation.id);
|
|
127
190
|
const full: Annotation = { ...annotation, createdAt: new Date().toISOString() };
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
191
|
+
|
|
192
|
+
if (existing === -1) session.annotations.push(full);
|
|
193
|
+
else
|
|
194
|
+
session.annotations[existing] = {
|
|
195
|
+
...full,
|
|
196
|
+
createdAt: session.annotations[existing]!.createdAt,
|
|
197
|
+
};
|
|
198
|
+
if (annotation.author && authorName)
|
|
199
|
+
session.participants = registerParticipant(
|
|
200
|
+
session,
|
|
201
|
+
annotation.author,
|
|
202
|
+
authorName,
|
|
203
|
+
).participants;
|
|
204
|
+
this.store.upsert(session);
|
|
131
205
|
this.emit("session.updated", id);
|
|
132
|
-
|
|
206
|
+
|
|
207
|
+
return session;
|
|
133
208
|
}
|
|
134
209
|
|
|
135
210
|
sessionRemoveAnnotation(id: string, annotationId: string): ReviewSession {
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
211
|
+
const session = this.mutable(id);
|
|
212
|
+
|
|
213
|
+
session.annotations = session.annotations.filter((candidate) => candidate.id !== annotationId);
|
|
214
|
+
this.store.upsert(session);
|
|
139
215
|
this.emit("session.updated", id);
|
|
140
|
-
|
|
216
|
+
|
|
217
|
+
return session;
|
|
141
218
|
}
|
|
142
219
|
|
|
143
220
|
/** The reviewer's working copy; undefined clears it (revert all edits). */
|
|
144
221
|
sessionSetWorkingCopy(id: string, workingCopy: string | undefined): ReviewSession {
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
222
|
+
const session = this.mutable(id);
|
|
223
|
+
|
|
224
|
+
if (workingCopy === undefined || workingCopy === session.artifact.content)
|
|
225
|
+
delete session.workingCopy;
|
|
226
|
+
else session.workingCopy = workingCopy;
|
|
227
|
+
this.store.upsert(session);
|
|
149
228
|
this.emit("session.updated", id);
|
|
150
|
-
|
|
229
|
+
|
|
230
|
+
return session;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The guided walk's viewed marks. Merge-additive: walking only ever adds
|
|
235
|
+
* marks, so concurrent or stale clients converge instead of overwriting
|
|
236
|
+
* each other. An empty array is the explicit reset.
|
|
237
|
+
*/
|
|
238
|
+
sessionSetViewed(id: string, viewedPaths: string[]): ReviewSession {
|
|
239
|
+
const session = this.mutable(id);
|
|
240
|
+
|
|
241
|
+
if (viewedPaths.length === 0) delete session.viewedPaths;
|
|
242
|
+
else session.viewedPaths = [...new Set([...(session.viewedPaths ?? []), ...viewedPaths])];
|
|
243
|
+
this.store.upsert(session);
|
|
244
|
+
this.emit("session.updated", id);
|
|
245
|
+
|
|
246
|
+
return session;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
sessionSetShareId(id: string, shareId: string): ReviewSession {
|
|
250
|
+
const session = this.mutable(id);
|
|
251
|
+
|
|
252
|
+
session.shareId = shareId;
|
|
253
|
+
this.store.upsert(session);
|
|
254
|
+
this.emit("session.updated", id);
|
|
255
|
+
|
|
256
|
+
return session;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Remove a session for good (inbox delete); resolved or pending, both go. */
|
|
260
|
+
sessionDelete(id: string): void {
|
|
261
|
+
const session = this.store.get(id);
|
|
262
|
+
|
|
263
|
+
if (!this.store.delete(id)) throw new DaemonError("not_found", `no session ${id}`);
|
|
264
|
+
if (session) this.unwatchIfDiffSession(session);
|
|
265
|
+
this.diffRefreshGenerations.delete(id);
|
|
266
|
+
this.herdrTabs.delete(id);
|
|
267
|
+
this.emit("inbox.changed", id);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Merge a share's collaborator state back into the local session: annotations
|
|
272
|
+
* union by id with existing ones (the planner's) winning, and the participant
|
|
273
|
+
* registry union by id with the incoming identity winning (a collaborator is
|
|
274
|
+
* the authority on their own name). This is how a teammate's name reaches the
|
|
275
|
+
* planner after a pull, alongside their notes.
|
|
276
|
+
*/
|
|
277
|
+
sessionMergeShared(
|
|
278
|
+
id: string,
|
|
279
|
+
incoming: { annotations: Annotation[]; participants?: Identity[] },
|
|
280
|
+
): ReviewSession {
|
|
281
|
+
const session = this.mutable(id);
|
|
282
|
+
const known = new Set(session.annotations.map((annotation) => annotation.id));
|
|
283
|
+
|
|
284
|
+
for (const annotation of incoming.annotations)
|
|
285
|
+
if (!known.has(annotation.id)) session.annotations.push(annotation);
|
|
286
|
+
if (incoming.participants?.length) {
|
|
287
|
+
const registry = new Map(
|
|
288
|
+
(session.participants ?? []).map((participant) => [participant.id, participant]),
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
for (const participant of incoming.participants) registry.set(participant.id, participant);
|
|
292
|
+
session.participants = [...registry.values()];
|
|
293
|
+
}
|
|
294
|
+
this.store.upsert(session);
|
|
295
|
+
this.emit("session.updated", id);
|
|
296
|
+
|
|
297
|
+
return session;
|
|
151
298
|
}
|
|
152
299
|
|
|
153
300
|
sessionResolve(id: string, verdictKind: VerdictKind, summary: string): ReviewSession {
|
|
154
|
-
const
|
|
301
|
+
const session = this.mutable(id);
|
|
155
302
|
const verdict: Verdict = {
|
|
156
303
|
kind: verdictKind,
|
|
157
304
|
summary,
|
|
158
|
-
feedback: feedbackForSession(
|
|
305
|
+
feedback: feedbackForSession(session, verdictKind, summary),
|
|
159
306
|
resolvedAt: new Date().toISOString(),
|
|
160
307
|
};
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
308
|
+
|
|
309
|
+
session.verdict = verdict;
|
|
310
|
+
session.status = "resolved";
|
|
311
|
+
this.store.upsert(session);
|
|
312
|
+
// a resolved diff review is frozen; stop hot-reloading its working tree
|
|
313
|
+
this.unwatchIfDiffSession(session);
|
|
164
314
|
const parked = this.waiters.get(id) ?? [];
|
|
315
|
+
|
|
165
316
|
this.waiters.delete(id);
|
|
166
|
-
for (const
|
|
317
|
+
for (const parkedWaiter of parked) parkedWaiter(session);
|
|
167
318
|
this.emit("session.resolved", id);
|
|
168
319
|
this.emit("inbox.changed", id);
|
|
169
|
-
|
|
320
|
+
|
|
321
|
+
return session;
|
|
170
322
|
}
|
|
171
323
|
|
|
172
|
-
/**
|
|
173
|
-
|
|
174
|
-
|
|
324
|
+
/**
|
|
325
|
+
* Agent resubmits: new revision becomes the artifact, session reopens.
|
|
326
|
+
* Annotations the agent reports as acted on (by id) are marked addressed,
|
|
327
|
+
* and for plan revisions any still-open annotation whose quoted text no
|
|
328
|
+
* longer resolves is marked addressed too ("drift" - the line it pointed at
|
|
329
|
+
* was rewritten). Addressed is a marker, never a delete: the reviewer's
|
|
330
|
+
* rail hides them behind a count, and the next feedback document omits them.
|
|
331
|
+
* Unknown ids are ignored, so a stale id in the agent's list never fails
|
|
332
|
+
* the resubmit.
|
|
333
|
+
*/
|
|
334
|
+
sessionSubmitRevision(
|
|
335
|
+
id: string,
|
|
336
|
+
content: string,
|
|
337
|
+
addressedAnnotationIds: string[] = [],
|
|
338
|
+
): ReviewSession {
|
|
339
|
+
const session = this.sessionGet(id);
|
|
175
340
|
const now = new Date().toISOString();
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
341
|
+
const revisionNumber = session.revisions.length + 1;
|
|
342
|
+
|
|
343
|
+
session.revisions.push({ revision: revisionNumber, content, submittedAt: now });
|
|
344
|
+
session.artifact = { ...session.artifact, content };
|
|
345
|
+
delete session.workingCopy;
|
|
346
|
+
session.verdict = null;
|
|
347
|
+
session.status = "pending";
|
|
348
|
+
|
|
349
|
+
const reportedIds = new Set(addressedAnnotationIds);
|
|
350
|
+
// drift assist applies to markdown artifacts (plan, reply) only: a diff
|
|
351
|
+
// revision is a whole new patch, where a vanished quote says nothing about
|
|
352
|
+
// the feedback
|
|
353
|
+
const revisedBlocks = isMarkdownArtifact(session.artifact.type) ? parseBlocks(content) : null;
|
|
354
|
+
|
|
355
|
+
for (const annotation of session.annotations) {
|
|
356
|
+
if (isAddressed(annotation) || isAgentNote(annotation)) continue;
|
|
357
|
+
if (reportedIds.has(annotation.id)) {
|
|
358
|
+
annotation.resolution = { revision: revisionNumber, source: "agent" };
|
|
359
|
+
} else if (
|
|
360
|
+
revisedBlocks !== null &&
|
|
361
|
+
resolveAnchor(annotation.anchor, revisedBlocks) === null
|
|
362
|
+
) {
|
|
363
|
+
annotation.resolution = { revision: revisionNumber, source: "drift" };
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
this.store.upsert(session);
|
|
182
368
|
this.emit("session.revised", id);
|
|
183
369
|
this.emit("inbox.changed", id);
|
|
184
|
-
|
|
370
|
+
|
|
371
|
+
return session;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Re-capture a diff session's working tree; broadcasts session.updated only when the patch moved. */
|
|
375
|
+
async sessionRefreshDiff(id: string): Promise<{ changed: boolean }> {
|
|
376
|
+
const session = this.mutable(id);
|
|
377
|
+
|
|
378
|
+
if (session.artifact.type !== "diff") return { changed: false };
|
|
379
|
+
const generation = (this.diffRefreshGenerations.get(id) ?? 0) + 1;
|
|
380
|
+
|
|
381
|
+
this.diffRefreshGenerations.set(id, generation);
|
|
382
|
+
const diff = await workingTreeDiff(session.workspace.repoRoot);
|
|
383
|
+
|
|
384
|
+
// The capture yields the event loop: a newer refresh may have started, or a
|
|
385
|
+
// concurrent resolve/delete may have closed the session. Discard a stale
|
|
386
|
+
// capture, and re-read so a resolved review is never mutated and a deleted
|
|
387
|
+
// one is never revived by this upsert.
|
|
388
|
+
if (this.diffRefreshGenerations.get(id) !== generation) return { changed: false };
|
|
389
|
+
const current = this.store.get(id);
|
|
390
|
+
|
|
391
|
+
if (!current || current.status !== "pending" || current.artifact.type !== "diff")
|
|
392
|
+
return { changed: false };
|
|
393
|
+
if (diff.patch === current.artifact.content) return { changed: false };
|
|
394
|
+
current.artifact = { ...current.artifact, content: diff.patch, files: diff.files };
|
|
395
|
+
this.store.upsert(current);
|
|
396
|
+
this.emit("session.updated", id);
|
|
397
|
+
|
|
398
|
+
return { changed: true };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Re-capture every live diff session sharing a repo root (one debounced fs change). */
|
|
402
|
+
private async refreshDiffsForRepo(repoRoot: string): Promise<void> {
|
|
403
|
+
const live = this.store
|
|
404
|
+
.list()
|
|
405
|
+
.filter((session) => isLiveDiffSession(session) && session.workspace.repoRoot === repoRoot);
|
|
406
|
+
|
|
407
|
+
for (const session of live) {
|
|
408
|
+
// a session resolved or deleted between the change and this tick just skips
|
|
409
|
+
try {
|
|
410
|
+
await this.sessionRefreshDiff(session.id);
|
|
411
|
+
} catch {
|
|
412
|
+
// no-op: the session is gone or already resolved
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
private watchIfDiffSession(session: ReviewSession): void {
|
|
418
|
+
if (isLiveDiffSession(session))
|
|
419
|
+
this.diffWatcher.trackDiffRepo(session.workspace.repoRoot, session.id);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
private unwatchIfDiffSession(session: ReviewSession): void {
|
|
423
|
+
if (session.artifact.type === "diff")
|
|
424
|
+
this.diffWatcher.untrackDiffRepo(session.workspace.repoRoot, session.id);
|
|
185
425
|
}
|
|
186
426
|
|
|
187
427
|
private mutable(id: string): ReviewSession {
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
428
|
+
const session = this.sessionGet(id);
|
|
429
|
+
|
|
430
|
+
if (session.status === "resolved")
|
|
431
|
+
throw new DaemonError("resolved", `session ${id} is already resolved`);
|
|
432
|
+
|
|
433
|
+
return session;
|
|
191
434
|
}
|
|
192
435
|
}
|
|
193
436
|
|
|
194
437
|
export { DaemonError };
|
|
195
438
|
|
|
439
|
+
/** A pending diff session: the state that warrants hot-reload watching of its working tree. */
|
|
440
|
+
function isLiveDiffSession(session: ReviewSession): boolean {
|
|
441
|
+
return session.status === "pending" && session.artifact.type === "diff";
|
|
442
|
+
}
|
|
443
|
+
|
|
196
444
|
/** Convenience for adapters: map a resolved session to the agent contract. */
|
|
197
445
|
export function verdictResponse(session: ReviewSession): { allow: boolean; feedback: string } {
|
|
198
446
|
if (!session.verdict) throw new DaemonError("pending", "session has no verdict");
|
|
447
|
+
|
|
199
448
|
return { allow: verdictAllows(session.verdict.kind), feedback: session.verdict.feedback };
|
|
200
449
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
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
|
+
"session.refreshDiff",
|
|
23
|
+
])
|
|
24
|
+
expect(roleAllowsMethod("agent", method)).toBe(false);
|
|
25
|
+
expect(roleAllowsMethod("collaborator", "session.resolve")).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("asDaemonRole", () => {
|
|
30
|
+
test("passes owner and collaborator through and caps anything unknown to agent", () => {
|
|
31
|
+
// Assert
|
|
32
|
+
expect(asDaemonRole("owner")).toBe("owner");
|
|
33
|
+
expect(asDaemonRole("collaborator")).toBe("collaborator");
|
|
34
|
+
expect(asDaemonRole("agent")).toBe("agent");
|
|
35
|
+
expect(asDaemonRole("root")).toBe("agent");
|
|
36
|
+
expect(asDaemonRole(undefined)).toBe("agent");
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { daemonSpawnCommand } from "./client";
|
|
3
|
+
|
|
4
|
+
describe("daemonSpawnCommand", () => {
|
|
5
|
+
test("re-execs the binary as `daemon` when compiled (bun virtual-fs markers)", () => {
|
|
6
|
+
for (const moduleUrl of [
|
|
7
|
+
"file:///$bunfs/root/cueloop",
|
|
8
|
+
"file:///B:/~BUN/root/cueloop",
|
|
9
|
+
"file:///B:/%7EBUN/root/cueloop",
|
|
10
|
+
]) {
|
|
11
|
+
expect(daemonSpawnCommand("/usr/local/bin/cueloop", moduleUrl)).toEqual([
|
|
12
|
+
"/usr/local/bin/cueloop",
|
|
13
|
+
"daemon",
|
|
14
|
+
"--autostart",
|
|
15
|
+
]);
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("runs main.ts with bun from source", () => {
|
|
20
|
+
expect(
|
|
21
|
+
daemonSpawnCommand("/path/to/bun", "file:///repo/packages/daemon/src/client.ts"),
|
|
22
|
+
).toEqual(["/path/to/bun", "run", "/repo/packages/daemon/src/main.ts"]);
|
|
23
|
+
});
|
|
24
|
+
});
|