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