@cueloop/daemon 0.1.0-alpha.63 → 0.1.0-alpha.65

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.
@@ -1,28 +1,47 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { asDaemonRole, roleAllowsMethod } from "./capabilities";
2
+ import { asDaemonRole, DEFAULT_ROLE, roleAllowsMethod } from "./capabilities";
3
+ import type { MethodName } from "./validate";
3
4
 
4
5
  describe("roleAllowsMethod", () => {
5
- test("the owner may call every verb", () => {
6
+ test("the owner may call every primitive", () => {
6
7
  // Assert
7
- for (const method of ["session.resolve", "session.submitRevision", "session.delete"])
8
- expect(roleAllowsMethod("owner", method)).toBe(true);
8
+ const owned: MethodName[] = ["session.resolve", "session.submitRevision", "session.delete"];
9
+
10
+ for (const method of owned) expect(roleAllowsMethod("owner", method)).toBe(true);
9
11
  });
10
12
 
11
13
  test("a capped role may read and annotate but not escalate", () => {
12
14
  // Assert - allowed
13
- for (const method of ["session.get", "session.list", "session.wait", "session.annotate"])
14
- expect(roleAllowsMethod("agent", method)).toBe(true);
15
+ const open: MethodName[] = ["session.get", "session.list", "session.wait", "session.annotate"];
16
+
17
+ for (const method of open) expect(roleAllowsMethod("agent", method)).toBe(true);
15
18
  // Assert - denied
16
- for (const method of [
19
+ const owned: MethodName[] = [
17
20
  "session.resolve",
18
21
  "session.submitRevision",
19
22
  "session.setWorkingCopy",
20
23
  "session.delete",
21
24
  "session.setShareId",
22
25
  "session.refreshDiff",
23
- ])
24
- expect(roleAllowsMethod("agent", method)).toBe(false);
26
+ "session.create",
27
+ "session.navigate",
28
+ "session.branch",
29
+ "session.switch",
30
+ "session.label",
31
+ "session.fork",
32
+ ];
33
+
34
+ for (const method of owned) expect(roleAllowsMethod("agent", method)).toBe(false);
25
35
  expect(roleAllowsMethod("collaborator", "session.resolve")).toBe(false);
36
+ // removal and naming are open to every role; the primitive itself scopes a
37
+ // non-owner to the author it acts as
38
+ expect(roleAllowsMethod("agent", "session.removeAnnotation")).toBe(true);
39
+ expect(roleAllowsMethod("agent", "session.setParticipantName")).toBe(true);
40
+ });
41
+
42
+ test("a connection starts as a collaborator, never as the owner", () => {
43
+ // Assert
44
+ expect(DEFAULT_ROLE).toBe("collaborator");
26
45
  });
27
46
  });
28
47
 
@@ -1,30 +1,66 @@
1
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.
2
+ * Daemon roles: which primitives a connection may call. A connection is a
3
+ * collaborator until it proves ownership with the owner token in daemon.hello
4
+ * - never an owner by default. The owner (the local planner) may call every
5
+ * primitive; a collaborator or a review-side agent is capped to reading the
6
+ * session and adding comments, so a bring-your-own agent cannot resolve,
7
+ * rewrite, cut, share, or delete (ADR 0009). The table below is exhaustive
8
+ * over the primitive schemas: adding a primitive without naming its roles
9
+ * does not compile.
7
10
  */
8
11
 
12
+ import * as v from "valibot";
13
+ import type { MethodName } from "./validate";
14
+
9
15
  export type DaemonRole = "owner" | "collaborator" | "agent";
10
16
 
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);
17
+ /** Who may call a primitive: everyone, or the owner only. */
18
+ type Audience = "any" | "owner";
19
+
20
+ const PRIMITIVE_ROLES = {
21
+ "daemon.ping": "any",
22
+ "daemon.hello": "any",
23
+ "daemon.shutdown": "owner",
24
+ "events.subscribe": "any",
25
+ "session.create": "owner",
26
+ "session.get": "any",
27
+ "session.list": "any",
28
+ "session.wait": "any",
29
+ "session.annotate": "any",
30
+ // any role may call it; a non-owner must act on behalf of an author and stays scoped to it
31
+ "session.removeAnnotation": "any",
32
+ "session.setParticipantName": "any",
33
+ "session.setWorkingCopy": "owner",
34
+ "session.cutBlock": "owner",
35
+ "session.restoreBlock": "owner",
36
+ "session.curate": "owner",
37
+ "session.setViewed": "owner",
38
+ // the tree is the owner's: collaborators comment on the branch a share follows
39
+ "session.navigate": "owner",
40
+ "session.branch": "owner",
41
+ "session.switch": "owner",
42
+ "session.label": "owner",
43
+ "session.fork": "owner",
44
+ "session.refreshDiff": "owner",
45
+ "session.setShareId": "owner",
46
+ "session.delete": "owner",
47
+ "session.mergeShared": "owner",
48
+ "session.resolve": "owner",
49
+ "session.submitRevision": "owner",
50
+ "herdr.getTab": "owner",
51
+ "herdr.setTab": "owner",
52
+ } as const satisfies Record<MethodName, Audience>;
53
+
54
+ /** Whether `role` may call `method`. */
55
+ export function roleAllowsMethod(role: DaemonRole, method: MethodName): boolean {
56
+ return role === "owner" || PRIMITIVE_ROLES[method] === "any";
25
57
  }
26
58
 
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";
59
+ /** The role a connection starts with, before any proof. */
60
+ export const DEFAULT_ROLE: DaemonRole = "collaborator";
61
+
62
+ export function asDaemonRole(value: Parameters<typeof v.safeParse>[1]): DaemonRole {
63
+ const result = v.safeParse(v.picklist(["owner", "collaborator"]), value);
64
+
65
+ return result.success ? result.output : "agent";
30
66
  }
package/src/client.ts CHANGED
@@ -1,22 +1,34 @@
1
1
  /**
2
- * Daemon client: the one library every consumer shares - CLI verbs, the TUI,
2
+ * Daemon client: the one library every consumer shares - CLI primitives, the TUI,
3
3
  * adapters, and tests. Also owns the lazy-launch story: connect() with
4
4
  * autostart spawns a detached daemon when the socket is dead, then attaches.
5
5
  */
6
6
 
7
- import { existsSync, rmSync } from "node:fs";
7
+ import { existsSync, readFileSync, rmSync } from "node:fs";
8
+ import * as v from "valibot";
8
9
  import type {
9
10
  Annotation,
10
11
  Artifact,
11
- Identity,
12
+ HunkRejection,
12
13
  ReviewSession,
13
14
  VerdictKind,
14
15
  WorkspaceKey,
15
16
  } from "@cueloop/schema";
16
- import { BackpressureWriter, LineBuffer, type EventFrame, type Response } from "./protocol";
17
+ import {
18
+ BackpressureWriter,
19
+ LineBuffer,
20
+ parseInboundFrame,
21
+ type EventFrame,
22
+ type Request,
23
+ type Response,
24
+ } from "./protocol";
17
25
  import type { DaemonRole } from "./capabilities";
18
26
  import type { HerdrTabHandle } from "./herdr-tab-store";
19
- import { cueloopHome, socketPath } from "./paths";
27
+ import type { SharedMerge } from "./api";
28
+
29
+ export type { SharedMerge } from "./api";
30
+ import { cueloopHome, ownerTokenPath, socketPath } from "./paths";
31
+ import { Params, SessionRecordSchema } from "./validate";
20
32
 
21
33
  export type { EventFrame } from "./protocol";
22
34
 
@@ -26,12 +38,22 @@ export interface ConnectOptions {
26
38
  autostart?: boolean;
27
39
  /** Capability role for this connection; a review-side agent connects capped. Defaults to owner. */
28
40
  role?: DaemonRole;
41
+ /** The author a non-owner connection acts as; its comments, removals, and name are bound to it. */
42
+ author?: string;
29
43
  }
30
44
 
31
- type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void };
45
+ type PendingRequest = {
46
+ resolve: (value: Response["result"]) => void;
47
+ reject: (error: Error) => void;
48
+ };
49
+
50
+ const EmptyResultSchema = v.object({});
51
+ const PingResultSchema = v.object({ pid: v.number() });
52
+ const RefreshDiffResultSchema = v.object({ changed: v.boolean() });
53
+ const HerdrTabResultSchema = v.nullable(v.object({ tabId: v.string(), paneId: v.string() }));
32
54
 
33
55
  /**
34
- * The session verbs the review controller drives. DaemonClient is the local
56
+ * The session primitives the review controller drives. DaemonClient is the local
35
57
  * implementation (unix socket); the sharing gateway supplies an in-memory,
36
58
  * blob-backed one. Depending on this interface - not DaemonClient - is what
37
59
  * lets the same <App> render a local session or a decrypted share unchanged.
@@ -46,14 +68,32 @@ export interface SessionClient {
46
68
  annotation: Omit<Annotation, "createdAt">,
47
69
  authorName?: string,
48
70
  ): Promise<ReviewSession>;
71
+ /** Remove a comment; a non-owner connection removes only the comments of the author it is bound to. */
49
72
  sessionRemoveAnnotation(id: string, annotationId: string): Promise<ReviewSession>;
50
73
  sessionSetWorkingCopy(id: string, workingCopy: string | undefined): Promise<ReviewSession>;
74
+ /** Cut the `blockIndex`-th block of the working copy. */
75
+ sessionCutBlock(id: string, blockIndex: number): Promise<ReviewSession>;
76
+ /** Re-insert the `baseBlockIndex`-th block of the submitted revision before `line` (default: the end). */
77
+ sessionRestoreBlock(id: string, baseBlockIndex: number, line?: number): Promise<ReviewSession>;
78
+ /** Replace a diff review's reject decisions; the working copy follows. */
79
+ sessionCurate(id: string, rejections: HunkRejection[]): Promise<ReviewSession>;
51
80
  sessionSetViewed(id: string, viewedPaths: string[]): Promise<ReviewSession>;
52
- sessionSetShareId(id: string, shareId: string): Promise<ReviewSession>;
53
- sessionMergeShared(
81
+ /** Move a branch's tip (the current one, or `branch` after switching to it) back to an entry on its path; a summary records the abandoned segment. */
82
+ sessionNavigate(
54
83
  id: string,
55
- incoming: { annotations: Annotation[]; participants?: Identity[] },
84
+ entryId: string,
85
+ summary?: string,
86
+ branch?: string,
56
87
  ): Promise<ReviewSession>;
88
+ /** Start a branch at the current tip and switch to it. */
89
+ sessionBranch(id: string, name: string): Promise<ReviewSession>;
90
+ sessionSwitch(id: string, branch: string): Promise<ReviewSession>;
91
+ /** Name the current tip as a checkpoint. */
92
+ sessionLabel(id: string, label: string): Promise<ReviewSession>;
93
+ /** Copy the current path into a new session; returns the fork. */
94
+ sessionFork(id: string): Promise<ReviewSession>;
95
+ sessionSetShareId(id: string, shareId: string): Promise<ReviewSession>;
96
+ sessionMergeShared(id: string, incoming: SharedMerge): Promise<ReviewSession>;
57
97
  sessionDelete(id: string): Promise<void>;
58
98
  /** Record the caller's own identity name (collaborator self-naming on a share). */
59
99
  sessionSetSelfName(id: string, name: string): Promise<ReviewSession>;
@@ -61,6 +101,33 @@ export interface SessionClient {
61
101
  close(): void;
62
102
  }
63
103
 
104
+ /** What a connection says about itself: its role, the owner token when it claims ownership, the author it acts as otherwise. */
105
+ interface HelloParams {
106
+ role: DaemonRole;
107
+ token?: string;
108
+ author?: string;
109
+ }
110
+
111
+ /** The token as the daemon writes it: 32 random bytes in hex. */
112
+ const OwnerTokenSchema = v.pipe(v.string(), v.trim(), v.regex(/^[0-9a-f]{64}$/));
113
+
114
+ /** The owner token in `home`, or undefined when the daemon there never wrote one. */
115
+ function readOwnerToken(home: string): string | undefined {
116
+ const path = ownerTokenPath(home);
117
+
118
+ if (!existsSync(path)) return undefined;
119
+ const parsed = v.safeParse(OwnerTokenSchema, readFileSync(path, "utf8"));
120
+
121
+ if (!parsed.success) {
122
+ throw new DaemonClientError(
123
+ "invalid_owner_token",
124
+ `${path} is not an owner token; restart the daemon to mint a fresh one`,
125
+ );
126
+ }
127
+
128
+ return parsed.output;
129
+ }
130
+
64
131
  export class DaemonClient implements SessionClient {
65
132
  private socket: Awaited<ReturnType<typeof Bun.connect>> | null = null;
66
133
  private writer: BackpressureWriter | null = null;
@@ -69,6 +136,8 @@ export class DaemonClient implements SessionClient {
69
136
  private eventListeners = new Set<(event: EventFrame) => void>();
70
137
  private closed = false;
71
138
  private role: DaemonRole = "owner";
139
+ private author: string | undefined;
140
+ private home = cueloopHome();
72
141
 
73
142
  static async connect(options: ConnectOptions = {}): Promise<DaemonClient> {
74
143
  const home = options.home ?? cueloopHome();
@@ -76,12 +145,16 @@ export class DaemonClient implements SessionClient {
76
145
  const client = new DaemonClient();
77
146
 
78
147
  client.role = options.role ?? "owner";
148
+ client.author = options.author;
149
+ client.home = home;
79
150
  try {
80
151
  await client.dial(path);
81
152
 
82
153
  return client;
83
154
  } catch (err) {
84
- if (!options.autostart) throw err;
155
+ // a live daemon that refused the handshake is not a dead socket: the
156
+ // caller hears why instead of the client replacing a running daemon
157
+ if (!options.autostart || err instanceof DaemonClientError) throw err;
85
158
  }
86
159
  // Socket dead or absent: clean a stale file and spawn the daemon detached.
87
160
  if (existsSync(path)) rmSync(path, { force: true });
@@ -128,9 +201,23 @@ export class DaemonClient implements SessionClient {
128
201
  this.writer = new BackpressureWriter(this.socket);
129
202
  // Verify liveness: a dead socket file accepts connects on some platforms
130
203
  // only to fail later, so a ping is the actual handshake.
131
- await this.request("daemon.ping", {}, 2_000);
132
- // Cap this connection's role for the daemon's capability gate (owner is the default).
133
- if (this.role !== "owner") await this.request("daemon.hello", { role: this.role }, 2_000);
204
+ await this.request("daemon.ping", {}, PingResultSchema, 2_000);
205
+ // Every connection starts as a collaborator; the owner proves itself with
206
+ // the token the daemon wrote into the home it serves, which only the home's
207
+ // user can read. A capped role just names itself.
208
+ await this.request("daemon.hello", this.helloParams(), EmptyResultSchema, 2_000);
209
+ }
210
+
211
+ private helloParams(): HelloParams {
212
+ if (this.role !== "owner") {
213
+ return this.author === undefined
214
+ ? { role: this.role }
215
+ : { role: this.role, author: this.author };
216
+ }
217
+ const token = readOwnerToken(this.home);
218
+
219
+ // a daemon from before owner tokens has no file; it still knows the bare hello
220
+ return token === undefined ? { role: "owner" } : { role: "owner", token };
134
221
  }
135
222
 
136
223
  onEvent(listener: (event: EventFrame) => void): () => void {
@@ -140,10 +227,10 @@ export class DaemonClient implements SessionClient {
140
227
  }
141
228
 
142
229
  private routeInboundFrame(line: string): void {
143
- let frame: Response | EventFrame;
230
+ let frame;
144
231
 
145
232
  try {
146
- frame = JSON.parse(line) as Response | EventFrame;
233
+ frame = parseInboundFrame(line);
147
234
  } catch {
148
235
  return;
149
236
  }
@@ -161,11 +248,16 @@ export class DaemonClient implements SessionClient {
161
248
  else pendingRequest.resolve(frame.result);
162
249
  }
163
250
 
164
- request<T = unknown>(method: string, params: unknown, timeoutMs = 30_000): Promise<T> {
251
+ request<TOutput>(
252
+ method: string,
253
+ params: Request["params"],
254
+ resultSchema: v.GenericSchema<unknown, TOutput>,
255
+ timeoutMs = 30_000,
256
+ ): Promise<TOutput> {
165
257
  if (this.closed || !this.socket) return Promise.reject(new Error("not connected"));
166
258
  const id = this.nextId++;
167
259
 
168
- return new Promise<T>((resolve, reject) => {
260
+ return new Promise<TOutput>((resolve, reject) => {
169
261
  const timer = setTimeout(() => {
170
262
  this.pending.delete(id);
171
263
  reject(new Error(`request ${method} timed out`));
@@ -174,7 +266,7 @@ export class DaemonClient implements SessionClient {
174
266
  this.pending.set(id, {
175
267
  resolve: (value) => {
176
268
  clearTimeout(timer);
177
- resolve(value as T);
269
+ resolve(v.parse(resultSchema, value));
178
270
  },
179
271
  reject: (error) => {
180
272
  clearTimeout(timer);
@@ -190,81 +282,129 @@ export class DaemonClient implements SessionClient {
190
282
  this.socket?.end();
191
283
  }
192
284
 
193
- // ── typed verbs ─────────────────────────────
285
+ // ── typed primitives ─────────────────────────────
194
286
  ping(): Promise<{ pid: number }> {
195
- return this.request("daemon.ping", {});
287
+ return this.request("daemon.ping", {}, PingResultSchema);
196
288
  }
197
289
  subscribe(): Promise<void> {
198
- return this.request("events.subscribe", {});
290
+ return this.request("events.subscribe", {}, EmptyResultSchema).then(() => undefined);
199
291
  }
200
292
  sessionCreate(workspace: WorkspaceKey, artifact: Artifact): Promise<ReviewSession> {
201
- return this.request("session.create", { workspace, artifact });
293
+ return this.request("session.create", { workspace, artifact }, SessionRecordSchema);
202
294
  }
203
295
  sessionGet(id: string): Promise<ReviewSession> {
204
- return this.request("session.get", { id });
296
+ return this.request("session.get", { id }, SessionRecordSchema);
205
297
  }
206
298
  sessionList(filter?: { status?: "pending" | "resolved" }): Promise<ReviewSession[]> {
207
- return this.request("session.list", { filter });
299
+ return this.request("session.list", { filter }, v.array(SessionRecordSchema));
208
300
  }
209
301
  /** Long-poll; null = still pending after timeoutMs (re-poll to collect). */
210
302
  sessionWait(id: string, timeoutMs: number): Promise<ReviewSession | null> {
211
- return this.request("session.wait", { id, timeoutMs }, timeoutMs + 10_000);
303
+ return this.request(
304
+ "session.wait",
305
+ { id, timeoutMs },
306
+ v.nullable(SessionRecordSchema),
307
+ timeoutMs + 10_000,
308
+ );
212
309
  }
213
310
  sessionAnnotate(
214
311
  id: string,
215
312
  annotation: Omit<Annotation, "createdAt">,
216
313
  authorName?: string,
217
314
  ): Promise<ReviewSession> {
218
- return this.request("session.annotate", { id, annotation, authorName });
315
+ return this.request("session.annotate", { id, annotation, authorName }, SessionRecordSchema);
219
316
  }
220
317
  sessionRemoveAnnotation(id: string, annotationId: string): Promise<ReviewSession> {
221
- return this.request("session.removeAnnotation", { id, annotationId });
318
+ return this.request("session.removeAnnotation", { id, annotationId }, SessionRecordSchema);
319
+ }
320
+ /** Register a display name for a participant - a collaborator's or an agent's own, on a share or locally. */
321
+ sessionSetParticipantName(id: string, author: string, name: string): Promise<ReviewSession> {
322
+ return this.request("session.setParticipantName", { id, author, name }, SessionRecordSchema);
222
323
  }
223
324
  sessionSetWorkingCopy(id: string, workingCopy: string | undefined): Promise<ReviewSession> {
224
- return this.request("session.setWorkingCopy", { id, workingCopy });
325
+ return this.request("session.setWorkingCopy", { id, workingCopy }, SessionRecordSchema);
326
+ }
327
+ sessionCutBlock(id: string, blockIndex: number): Promise<ReviewSession> {
328
+ return this.request("session.cutBlock", { id, blockIndex }, SessionRecordSchema);
329
+ }
330
+ sessionNavigate(
331
+ id: string,
332
+ entryId: string,
333
+ summary?: string,
334
+ branch?: string,
335
+ ): Promise<ReviewSession> {
336
+ const params: v.InferInput<(typeof Params)["session.navigate"]> = { id, entryId };
337
+
338
+ if (summary !== undefined) params.summary = summary;
339
+ if (branch !== undefined) params.branch = branch;
340
+
341
+ return this.request("session.navigate", params, SessionRecordSchema);
342
+ }
343
+ sessionBranch(id: string, name: string): Promise<ReviewSession> {
344
+ return this.request("session.branch", { id, name }, SessionRecordSchema);
345
+ }
346
+ sessionSwitch(id: string, branch: string): Promise<ReviewSession> {
347
+ return this.request("session.switch", { id, branch }, SessionRecordSchema);
348
+ }
349
+ sessionLabel(id: string, label: string): Promise<ReviewSession> {
350
+ return this.request("session.label", { id, label }, SessionRecordSchema);
351
+ }
352
+ sessionFork(id: string): Promise<ReviewSession> {
353
+ return this.request("session.fork", { id }, SessionRecordSchema);
354
+ }
355
+ sessionRestoreBlock(id: string, baseBlockIndex: number, line?: number): Promise<ReviewSession> {
356
+ return this.request(
357
+ "session.restoreBlock",
358
+ line === undefined ? { id, baseBlockIndex } : { id, baseBlockIndex, line },
359
+ SessionRecordSchema,
360
+ );
361
+ }
362
+ sessionCurate(id: string, rejections: HunkRejection[]): Promise<ReviewSession> {
363
+ return this.request("session.curate", { id, rejections }, SessionRecordSchema);
225
364
  }
226
365
  sessionSetViewed(id: string, viewedPaths: string[]): Promise<ReviewSession> {
227
- return this.request("session.setViewed", { id, viewedPaths });
366
+ return this.request("session.setViewed", { id, viewedPaths }, SessionRecordSchema);
228
367
  }
229
368
  /** Re-capture a diff session's working tree; changed=true when the patch moved and an event fired. */
230
369
  sessionRefreshDiff(id: string): Promise<{ changed: boolean }> {
231
- return this.request("session.refreshDiff", { id });
370
+ return this.request("session.refreshDiff", { id }, RefreshDiffResultSchema);
232
371
  }
233
372
  sessionSetShareId(id: string, shareId: string): Promise<ReviewSession> {
234
- return this.request("session.setShareId", { id, shareId });
373
+ return this.request("session.setShareId", { id, shareId }, SessionRecordSchema);
235
374
  }
236
- sessionMergeShared(
237
- id: string,
238
- incoming: { annotations: Annotation[]; participants?: Identity[] },
239
- ): Promise<ReviewSession> {
240
- return this.request("session.mergeShared", { id, ...incoming });
375
+ sessionMergeShared(id: string, incoming: SharedMerge): Promise<ReviewSession> {
376
+ return this.request("session.mergeShared", { id, ...incoming }, SessionRecordSchema);
241
377
  }
242
378
  sessionDelete(id: string): Promise<void> {
243
- return this.request("session.delete", { id });
379
+ return this.request("session.delete", { id }, EmptyResultSchema).then(() => undefined);
244
380
  }
245
381
  /** Local sessions have no collaborator self-name; the share client owns this. */
246
382
  sessionSetSelfName(id: string, _name: string): Promise<ReviewSession> {
247
383
  return this.sessionGet(id);
248
384
  }
249
385
  sessionResolve(id: string, verdictKind: VerdictKind, summary: string): Promise<ReviewSession> {
250
- return this.request("session.resolve", { id, verdictKind, summary });
386
+ return this.request("session.resolve", { id, verdictKind, summary }, SessionRecordSchema);
251
387
  }
252
388
  sessionSubmitRevision(
253
389
  id: string,
254
390
  content: string,
255
391
  addressedAnnotationIds: string[] = [],
256
392
  ): Promise<ReviewSession> {
257
- return this.request("session.submitRevision", { id, content, addressedAnnotationIds });
393
+ return this.request(
394
+ "session.submitRevision",
395
+ { id, content, addressedAnnotationIds },
396
+ SessionRecordSchema,
397
+ );
258
398
  }
259
399
  /** herdr adapter scratch: the tab opened for a review; local-only, off the SessionClient contract. */
260
400
  herdrGetTab(id: string): Promise<HerdrTabHandle | null> {
261
- return this.request("herdr.getTab", { id });
401
+ return this.request("herdr.getTab", { id }, HerdrTabResultSchema);
262
402
  }
263
403
  async herdrSetTab(id: string, handle: HerdrTabHandle): Promise<void> {
264
- await this.request("herdr.setTab", { id, ...handle });
404
+ await this.request("herdr.setTab", { id, ...handle }, EmptyResultSchema);
265
405
  }
266
406
  shutdown(): Promise<void> {
267
- return this.request("daemon.shutdown", {});
407
+ return this.request("daemon.shutdown", {}, EmptyResultSchema).then(() => undefined);
268
408
  }
269
409
  }
270
410
 
package/src/curate.ts ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * The curated patch of a diff review: the submitted changes minus the hunks and
3
+ * change blocks the reviewer rejected. The daemon computes it from the
4
+ * session's reject decisions so the working copy every client sees is one and
5
+ * the same; the client only maps rows to decisions.
6
+ */
7
+
8
+ import { diffAcceptRejectHunk, parseDiffFromFile, type FileDiffMetadata } from "@pierre/diffs";
9
+ import {
10
+ unifiedDiffText,
11
+ type DiffFileContents,
12
+ type DiffFileStatus,
13
+ type HunkRejection,
14
+ } from "@cueloop/schema";
15
+
16
+ /**
17
+ * Context lines around each change. Matching git's default (3) makes the model
18
+ * split hunks like the rendered working-tree patch does; a row is mapped by
19
+ * absolute line number, never by counting hunks, so a whole-hunk reject can
20
+ * only ever span the model hunk it lands in.
21
+ */
22
+ export const DIFF_CONTEXT = 3;
23
+
24
+ /** Parse one file's full contents into a non-partial diff model. */
25
+ export function parseFileDiff(file: DiffFileContents): FileDiffMetadata {
26
+ return parseDiffFromFile(
27
+ { name: file.path, contents: file.oldContents },
28
+ { name: file.path, contents: file.newContents },
29
+ { context: DIFF_CONTEXT },
30
+ );
31
+ }
32
+
33
+ /**
34
+ * Apply one file's rejections and return its curated patch, or null when the
35
+ * curated content equals the old content (every change rejected - no patch).
36
+ * Whole-hunk rejections supersede change-level ones for the same hunk. Because
37
+ * `diffAcceptRejectHunk` returns a model with the same hunk/content shape, the
38
+ * indices stay valid across calls; they are applied high-to-low regardless, so
39
+ * the order is deterministic.
40
+ */
41
+ function curateFilePatch(file: DiffFileContents, rejections: HunkRejection[]): string | null {
42
+ let model = parseFileDiff(file);
43
+ const wholeHunks = new Set(
44
+ rejections
45
+ .filter((rejection) => rejection.changeIndex === undefined)
46
+ .map((rejection) => rejection.hunkIndex),
47
+ );
48
+
49
+ for (const hunkIndex of [...wholeHunks].sort((a, b) => b - a)) {
50
+ model = diffAcceptRejectHunk(model, hunkIndex, "reject");
51
+ }
52
+ const changeRejections = rejections
53
+ .filter(
54
+ (rejection) => rejection.changeIndex !== undefined && !wholeHunks.has(rejection.hunkIndex),
55
+ )
56
+ .sort((a, b) => b.hunkIndex - a.hunkIndex || b.changeIndex! - a.changeIndex!);
57
+
58
+ for (const rejection of changeRejections) {
59
+ model = diffAcceptRejectHunk(model, rejection.hunkIndex, {
60
+ type: "reject",
61
+ changeIndex: rejection.changeIndex!,
62
+ });
63
+ }
64
+ const curatedNew = model.additionLines.join("");
65
+
66
+ if (curatedNew === file.oldContents) return null;
67
+ const patch = unifiedDiffText(file.oldContents, curatedNew, file.path);
68
+
69
+ if (patch === null) return null;
70
+
71
+ return withFileStateHeaders(patch, file.path, file.status, curatedNew);
72
+ }
73
+
74
+ /**
75
+ * Point the diff headers at /dev/null for a created or fully-deleted file so the
76
+ * curated patch applies. git's status - not empty contents - decides existence,
77
+ * so editing an existing empty file stays a modify; a deletion whose removals
78
+ * were partly restored also falls back to a modify.
79
+ */
80
+ function withFileStateHeaders(
81
+ patch: string,
82
+ path: string,
83
+ status: DiffFileStatus,
84
+ curatedNew: string,
85
+ ): string {
86
+ if (status === "added") return patch.replace(`--- a/${path}`, "--- /dev/null");
87
+ if (status === "deleted" && curatedNew === "") {
88
+ return patch.replace(`+++ b/${path}`, "+++ /dev/null");
89
+ }
90
+
91
+ return patch;
92
+ }
93
+
94
+ /**
95
+ * The curated unified-diff patch of the accepted changes across every file.
96
+ * Files with no net change (unchanged, or with all changes rejected) drop out.
97
+ */
98
+ export function curateDiff(files: DiffFileContents[], rejections: HunkRejection[]): string {
99
+ const parts: string[] = [];
100
+
101
+ for (const file of files) {
102
+ const fileRejections = rejections.filter((rejection) => rejection.path === file.path);
103
+ const patch = curateFilePatch(file, fileRejections);
104
+
105
+ if (patch) parts.push(patch);
106
+ }
107
+
108
+ return parts.join("\n");
109
+ }
@@ -100,6 +100,22 @@ describe("session.refreshDiff", () => {
100
100
  expect(events).toContain("session.updated");
101
101
  });
102
102
 
103
+ test("a tree move after a refresh keeps the fresh patch", async () => {
104
+ // Given a refreshed diff session
105
+ const session = await openDiffSession();
106
+
107
+ writeFileSync(join(repo, "a.ts"), "export const a = 4;\n");
108
+ await core.sessionRefreshDiff(session.id);
109
+
110
+ // When the owner branches off and switches back
111
+ core.sessionBranch(session.id, "alt");
112
+ const shown = core.sessionSwitch(session.id, "main");
113
+
114
+ // Then the artifact still shows the re-captured working tree, and the history has one revision
115
+ expect(shown.artifact.content).toContain("+export const a = 4;");
116
+ expect(shown.history!.entries.filter((entry) => entry.type === "revision")).toHaveLength(1);
117
+ });
118
+
103
119
  test("is a no-op for a non-diff (plan) session", async () => {
104
120
  // Given a plan session in the same workspace
105
121
  const workspace = await resolveWorkspace(repo);