@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/src/api.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The daemon's method surface (#14). Transport-independent: the socket
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 = (e: DaemonEvent) => void;
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 l of this.listeners) l({ event, sessionId });
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((s) => s.status === "pending");
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 s = this.store.get(id);
82
- if (!s) throw new DaemonError("not_found", `no session ${id}`);
83
- return s;
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?: { status?: "pending" | "resolved"; workspace?: Partial<WorkspaceKey> }): ReviewSession[] {
87
- return this.store.list().filter((s) => {
88
- if (filter?.status && s.status !== filter.status) return false;
89
- if (filter?.workspace?.repoRoot && s.workspace.repoRoot !== filter.workspace.repoRoot) return false;
90
- if (filter?.workspace?.branch && s.workspace.branch !== filter.workspace.branch) return false;
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 s = this.sessionGet(id);
102
- if (s.status === "resolved") return Promise.resolve(s);
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 idx = list.indexOf(waiter);
110
- if (idx !== -1) list.splice(idx, 1);
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
- sessionAnnotate(id: string, annotation: Omit<Annotation, "createdAt">): ReviewSession {
125
- const s = this.mutable(id);
126
- const existing = s.annotations.findIndex((a) => a.id === annotation.id);
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) s.annotations.push(full);
129
- else s.annotations[existing] = { ...full, createdAt: s.annotations[existing]!.createdAt };
130
- this.store.upsert(s);
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 s;
194
+ return session;
133
195
  }
134
196
 
135
197
  sessionRemoveAnnotation(id: string, annotationId: string): ReviewSession {
136
- const s = this.mutable(id);
137
- s.annotations = s.annotations.filter((a) => a.id !== annotationId);
138
- this.store.upsert(s);
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 s;
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 s = this.mutable(id);
146
- if (workingCopy === undefined || workingCopy === s.artifact.content) delete s.workingCopy;
147
- else s.workingCopy = workingCopy;
148
- this.store.upsert(s);
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 s;
272
+ return session;
151
273
  }
152
274
 
153
275
  sessionResolve(id: string, verdictKind: VerdictKind, summary: string): ReviewSession {
154
- const s = this.mutable(id);
276
+ const session = this.mutable(id);
155
277
  const verdict: Verdict = {
156
278
  kind: verdictKind,
157
279
  summary,
158
- feedback: feedbackForSession(s, verdictKind, summary),
280
+ feedback: feedbackForSession(session, verdictKind, summary),
159
281
  resolvedAt: new Date().toISOString(),
160
282
  };
161
- s.verdict = verdict;
162
- s.status = "resolved";
163
- this.store.upsert(s);
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 w of parked) w(s);
290
+ for (const parkedWaiter of parked) parkedWaiter(session);
167
291
  this.emit("session.resolved", id);
168
292
  this.emit("inbox.changed", id);
169
- return s;
293
+ return session;
170
294
  }
171
295
 
172
- /** Agent resubmits: new revision becomes the artifact, session reopens. */
173
- sessionSubmitRevision(id: string, content: string): ReviewSession {
174
- const s = this.sessionGet(id);
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
- s.revisions.push({ revision: s.revisions.length + 1, content, submittedAt: now });
177
- s.artifact = { ...s.artifact, content };
178
- delete s.workingCopy;
179
- s.verdict = null;
180
- s.status = "pending";
181
- this.store.upsert(s);
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 s;
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 s = this.sessionGet(id);
189
- if (s.status === "resolved") throw new DaemonError("resolved", `session ${id} is already resolved`);
190
- return s;
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
+ }