@cueloop/daemon 0.1.0-alpha.82 → 0.1.0-alpha.84

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/daemon",
3
- "version": "0.1.0-alpha.82",
3
+ "version": "0.1.0-alpha.84",
4
4
  "description": "The cueloop session daemon and its client: socket protocol, atomic store, resumable waits",
5
5
  "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
6
  "bugs": {
@@ -38,7 +38,7 @@
38
38
  "access": "public"
39
39
  },
40
40
  "dependencies": {
41
- "@cueloop/schema": "0.1.0-alpha.82",
41
+ "@cueloop/schema": "0.1.0-alpha.84",
42
42
  "@pierre/diffs": "^1.4.1",
43
43
  "effect": "^4.0.0-rc.115",
44
44
  "smol-toml": "^1.8.0",
package/src/api.test.ts CHANGED
@@ -169,6 +169,71 @@ describe("session lifecycle", () => {
169
169
  ).toThrow("already resolved");
170
170
  });
171
171
 
172
+ test("Comment delivers only changed annotations and keeps the Thread pending", () => {
173
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
174
+
175
+ core.sessionAnnotate(session.id, {
176
+ id: "first",
177
+ kind: "comment",
178
+ anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
179
+ body: "first note",
180
+ });
181
+
182
+ const commented = core.sessionSendMessage(session.id, "comment", "Interim feedback");
183
+
184
+ expect(commented.status).toBe("pending");
185
+ expect(commented.message!.annotations?.map((annotation) => annotation.id)).toEqual(["first"]);
186
+ expect(commented.message!.body).toContain("first note");
187
+
188
+ core.sessionAnnotate(session.id, {
189
+ id: "second",
190
+ kind: "comment",
191
+ anchor: { quote: "Context", prefix: "Plan", suffix: "Do" },
192
+ body: "second note",
193
+ });
194
+
195
+ const secondComment = core.sessionSendMessage(session.id, "comment", "Another note");
196
+
197
+ expect(secondComment.status).toBe("pending");
198
+ expect(secondComment.message!.annotations?.map((annotation) => annotation.id)).toEqual([
199
+ "second",
200
+ ]);
201
+ expect(secondComment.message!.body).not.toContain("first note");
202
+ expect(secondComment.message!.body).toContain("second note");
203
+
204
+ core.sessionAnnotate(session.id, {
205
+ id: "first",
206
+ kind: "comment",
207
+ anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
208
+ body: "edited first note",
209
+ });
210
+
211
+ const resolved = core.sessionSendMessage(session.id, "approved", "Ready");
212
+
213
+ expect(resolved.status).toBe("resolved");
214
+ expect(resolved.message!.annotations?.map((annotation) => annotation.id)).toEqual(["first"]);
215
+ expect(resolved.message!.body).toContain("edited first note");
216
+ expect(resolved.message!.body).not.toContain("second note");
217
+ });
218
+
219
+ test("agent-side resolution does not redeliver a sent annotation", () => {
220
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
221
+
222
+ core.sessionAnnotate(session.id, {
223
+ id: "sent",
224
+ kind: "comment",
225
+ anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
226
+ body: "sent note",
227
+ });
228
+ core.sessionSendMessage(session.id, "comment", "Interim feedback");
229
+ core.sessionSubmitRevision(session.id, PLAN.content, ["sent"]);
230
+
231
+ const resolved = core.sessionSendMessage(session.id, "approved", "Ready");
232
+
233
+ expect(resolved.message!.annotations).toEqual([]);
234
+ expect(resolved.message!.body).not.toContain("sent note");
235
+ });
236
+
172
237
  test("working copy stores edits and clears on revert or no-op", () => {
173
238
  // Arrange
174
239
  const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
@@ -186,6 +251,55 @@ describe("session lifecycle", () => {
186
251
  expect(core.sessionGet(session.id).workingCopy).toBeUndefined();
187
252
  });
188
253
 
254
+ test("character Cut provenance survives persistence and clears on an arbitrary edit", () => {
255
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
256
+ const start = PLAN.content.indexOf("carefully");
257
+ const end = start + "carefully".length;
258
+ const workingCopy = PLAN.content.slice(0, start) + PLAN.content.slice(end);
259
+ const cut = { start, end, quote: "carefully" };
260
+
261
+ core.sessionSetWorkingCopy(session.id, workingCopy, [cut]);
262
+
263
+ expect(core.sessionGet(session.id).textCuts).toEqual([cut]);
264
+ expect(new DaemonCore(home).sessionGet(session.id).textCuts).toEqual([cut]);
265
+ const cutTip = core.sessionGet(session.id).history!.tips.main!;
266
+ const root = core.sessionGet(session.id).history!.entries[0]!.id;
267
+
268
+ core.sessionBranch(session.id, "with-cut");
269
+ core.sessionNavigate(session.id, root, undefined, "main");
270
+ expect(core.sessionGet(session.id).textCuts).toBeUndefined();
271
+ core.sessionSwitch(session.id, "with-cut");
272
+ expect(core.sessionGet(session.id).history!.tips["with-cut"]).toBe(cutTip);
273
+ expect(core.sessionGet(session.id).textCuts).toEqual([cut]);
274
+ core.sessionSetWorkingCopy(session.id, PLAN.content.replace("carefully", "safely"));
275
+ expect(core.sessionGet(session.id).textCuts).toBeUndefined();
276
+ });
277
+
278
+ test("rejects character Cut provenance that does not match the artifact", () => {
279
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
280
+
281
+ for (const textCuts of [
282
+ [{ start: 0, end: 5, quote: "stale" }],
283
+ [{ start: PLAN.content.length - 1, end: PLAN.content.length + 1, quote: "xx" }],
284
+ ]) {
285
+ expect(() => core.sessionSetWorkingCopy(session.id, "remaining", textCuts)).toThrow(
286
+ "text Cuts do not match the submitted artifact and working copy",
287
+ );
288
+ }
289
+ expect(core.sessionGet(session.id).workingCopy).toBeUndefined();
290
+ expect(core.sessionGet(session.id).textCuts).toBeUndefined();
291
+ });
292
+
293
+ test("rejects character Cut provenance whose result differs from the working copy", () => {
294
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
295
+ const start = PLAN.content.indexOf("carefully");
296
+ const textCuts = [{ start, end: start + "carefully".length, quote: "carefully" }];
297
+
298
+ expect(() => core.sessionSetWorkingCopy(session.id, "different", textCuts)).toThrow(
299
+ "text Cuts do not match the submitted artifact and working copy",
300
+ );
301
+ });
302
+
189
303
  test("a thread rename sets the title, clears back to the default on empty, and survives a restart", () => {
190
304
  // Arrange
191
305
  const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
@@ -235,8 +349,13 @@ describe("session lifecycle", () => {
235
349
  test("revision reopens the session and resets working copy + message", () => {
236
350
  // Arrange
237
351
  const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
352
+ const start = PLAN.content.indexOf("carefully");
353
+ const end = start + "carefully".length;
354
+ const cut = { start, end, quote: "carefully" };
238
355
 
239
- core.sessionSetWorkingCopy(session.id, PLAN.content + "\nedit");
356
+ core.sessionSetWorkingCopy(session.id, PLAN.content.slice(0, start) + PLAN.content.slice(end), [
357
+ cut,
358
+ ]);
240
359
  core.sessionSendMessage(session.id, "changes_requested", "redo");
241
360
 
242
361
  // Act
@@ -246,6 +365,7 @@ describe("session lifecycle", () => {
246
365
  expect(revised.status).toBe("pending");
247
366
  expect(revised.message).toBeNull();
248
367
  expect(revised.workingCopy).toBeUndefined();
368
+ expect(revised.textCuts).toBeUndefined();
249
369
  expect(revised.revisions.length).toBe(2);
250
370
  expect(revised.artifact.content).toBe("# Plan v2\n");
251
371
  });
package/src/api.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  import {
9
9
  SCHEMA_VERSION,
10
10
  appendEntry,
11
+ applyTextCuts,
11
12
  applyPathView,
12
13
  createBranch,
13
14
  cutBlock,
@@ -44,6 +45,7 @@ import {
44
45
  type Delivery,
45
46
  type PendingDelivery,
46
47
  type Thread,
48
+ type TextCut,
47
49
  type SessionHistory,
48
50
  type Message,
49
51
  type MessageOutcome,
@@ -69,6 +71,29 @@ import { listProjectFiles, readProjectFile } from "./project-files";
69
71
  import { resolveWorkspace } from "./thread-review";
70
72
  import { DaemonError } from "./errors";
71
73
 
74
+ /** Reviewer-controlled fields that make an annotation new or edited for delivery. */
75
+ function annotationDeliveryFingerprint(annotation: Annotation | undefined): string {
76
+ if (!annotation) return "";
77
+
78
+ return JSON.stringify({
79
+ kind: annotation.kind,
80
+ anchor: {
81
+ quote: annotation.anchor.quote,
82
+ prefix: annotation.anchor.prefix,
83
+ suffix: annotation.anchor.suffix,
84
+ blockIndex: annotation.anchor.blockIndex,
85
+ endBlockIndex: annotation.anchor.endBlockIndex,
86
+ start: annotation.anchor.start,
87
+ end: annotation.anchor.end,
88
+ selector: annotation.anchor.selector,
89
+ },
90
+ target: annotation.target,
91
+ body: annotation.body,
92
+ author: annotation.author,
93
+ replyTo: annotation.replyTo,
94
+ });
95
+ }
96
+
72
97
  /** What a share hands back: the notes and names it collected, and the removals it recorded. */
73
98
  export interface SharedMerge {
74
99
  annotations: Annotation[];
@@ -445,9 +470,11 @@ export class DaemonCore {
445
470
  }
446
471
 
447
472
  /** The reviewer's working copy; undefined clears it (revert all edits). */
448
- sessionSetWorkingCopy(id: string, workingCopy: string | undefined): Thread {
473
+ sessionSetWorkingCopy(id: string, workingCopy: string | undefined, textCuts?: TextCut[]): Thread {
449
474
  const session = this.mutable(id);
450
- const entryId = this.applyWorkingCopy(session, workingCopy);
475
+
476
+ if (textCuts?.length) this.assertTextCuts(session.artifact.content, workingCopy, textCuts);
477
+ const entryId = this.applyWorkingCopy(session, workingCopy, textCuts);
451
478
 
452
479
  this.store.upsert(session);
453
480
  this.emit("session.updated", id, entryId);
@@ -455,6 +482,32 @@ export class DaemonCore {
455
482
  return session;
456
483
  }
457
484
 
485
+ private assertTextCuts(
486
+ source: string,
487
+ workingCopy: string | undefined,
488
+ textCuts: readonly TextCut[],
489
+ ): void {
490
+ let previousEnd = 0;
491
+ const valid = textCuts.every((cut) => {
492
+ const matches =
493
+ cut.start >= previousEnd &&
494
+ cut.end > cut.start &&
495
+ cut.end <= source.length &&
496
+ source.slice(cut.start, cut.end) === cut.quote;
497
+
498
+ previousEnd = cut.end;
499
+
500
+ return matches;
501
+ });
502
+
503
+ if (!valid || workingCopy === undefined || applyTextCuts(source, textCuts) !== workingCopy) {
504
+ throw new DaemonError(
505
+ "invalid_params",
506
+ "text Cuts do not match the submitted artifact and working copy",
507
+ );
508
+ }
509
+ }
510
+
458
511
  /**
459
512
  * The guided walk's viewed marks. Merge-additive: walking only ever adds
460
513
  * marks, so concurrent or stale clients converge instead of overwriting
@@ -755,16 +808,29 @@ export class DaemonCore {
755
808
  ): Thread {
756
809
  const session = this.mutable(id);
757
810
  const sentAt = new Date().toISOString();
811
+ const sentAnnotations = new Map(
812
+ (session.history?.entries ?? [])
813
+ .filter((entry) => entry.type === "message")
814
+ .flatMap((entry) => (entry.type === "message" ? (entry.message.annotations ?? []) : []))
815
+ .map((annotation) => [annotation.id, annotation]),
816
+ );
817
+ const annotations = session.annotations.filter(
818
+ (annotation) =>
819
+ !isAddressed(annotation) &&
820
+ annotationDeliveryFingerprint(sentAnnotations.get(annotation.id)) !==
821
+ annotationDeliveryFingerprint(annotation),
822
+ );
758
823
  const message: Message = {
759
824
  id: newMessageId(),
760
825
  outcome,
761
826
  summary,
762
- body: feedbackForSession(session, outcome, summary, actionBodies),
827
+ body: feedbackForSession(session, outcome, summary, actionBodies, annotations),
828
+ annotations,
763
829
  sentAt,
764
830
  };
765
831
 
766
832
  session.message = message;
767
- session.status = "resolved";
833
+ session.status = outcome === "comment" ? "pending" : "resolved";
768
834
  const entryId = this.record(session, {
769
835
  type: "message",
770
836
  message,
@@ -772,13 +838,18 @@ export class DaemonCore {
772
838
  });
773
839
  this.store.upsert(session);
774
840
  this.reconcileDeliveries(session);
841
+ this.emit("message.sent", id, entryId);
842
+ if (outcome === "comment") {
843
+ this.emit("session.updated", id, entryId);
844
+
845
+ return session;
846
+ }
775
847
  // a resolved diff review is frozen; stop hot-reloading its working tree
776
848
  this.untrackLiveDiffSession(session);
777
849
  const parked = this.waiters.get(id) ?? [];
778
850
 
779
851
  this.waiters.delete(id);
780
852
  for (const parkedWaiter of parked) parkedWaiter(session);
781
- this.emit("message.sent", id, entryId);
782
853
  this.emit("inbox.changed", id);
783
854
 
784
855
  return session;
@@ -815,6 +886,7 @@ export class DaemonCore {
815
886
  createdAt: now,
816
887
  });
817
888
  delete session.workingCopy;
889
+ delete session.textCuts;
818
890
  session.message = null;
819
891
  session.status = "pending";
820
892
 
@@ -1063,25 +1135,35 @@ export class DaemonCore {
1063
1135
  * Set or clear the working copy and, when the reviewer's text changed, record
1064
1136
  * it as a reviewer revision on the current branch. Returns that entry's id.
1065
1137
  */
1066
- private applyWorkingCopy(session: Thread, workingCopy: string | undefined): string | undefined {
1138
+ private applyWorkingCopy(
1139
+ session: Thread,
1140
+ workingCopy: string | undefined,
1141
+ textCuts?: TextCut[],
1142
+ ): string | undefined {
1067
1143
  const before = session.workingCopy ?? session.artifact.content;
1068
1144
  const next =
1069
1145
  workingCopy === undefined || workingCopy === session.artifact.content
1070
1146
  ? undefined
1071
1147
  : workingCopy;
1072
1148
 
1149
+ if (textCuts?.length) session.textCuts = textCuts;
1150
+ else delete session.textCuts;
1073
1151
  if (next === undefined) delete session.workingCopy;
1074
1152
  else session.workingCopy = next;
1075
1153
  const after = next ?? session.artifact.content;
1076
1154
 
1077
1155
  if (after === before) return undefined;
1078
1156
 
1079
- return this.record(session, {
1157
+ const revision: Extract<NewEntry, { type: "revision" }> = {
1080
1158
  type: "revision",
1081
1159
  by: "reviewer",
1082
1160
  content: after,
1083
1161
  createdAt: new Date().toISOString(),
1084
- });
1162
+ };
1163
+
1164
+ if (textCuts?.length) revision.textCuts = textCuts;
1165
+
1166
+ return this.record(session, revision);
1085
1167
  }
1086
1168
 
1087
1169
  /** The session's history; a record without a revision has none and cannot be moved through. */
package/src/client.ts CHANGED
@@ -31,6 +31,7 @@ import type {
31
31
  PendingDelivery,
32
32
  MessageOutcome,
33
33
  WorkspaceKey,
34
+ TextCut,
34
35
  } from "@cueloop/schema";
35
36
  import {
36
37
  BackpressureWriter,
@@ -77,7 +78,7 @@ type PendingRequest = {
77
78
 
78
79
  const EmptyResultSchema = v.object({});
79
80
  // version is optional: a daemon from before the handshake carried one reads as
80
- // undefined, which never equals this build - so it is treated as stale and replaced
81
+ // undefined, which never equals this build.
81
82
  const PingResultSchema = v.object({ pid: v.number(), version: v.optional(v.string()) });
82
83
  const RefreshDiffResultSchema = v.object({ changed: v.boolean() });
83
84
  const HerdrThreadSurfaceResultSchema = v.nullable(
@@ -113,7 +114,11 @@ export interface ThreadClient {
113
114
  ): Promise<Thread>;
114
115
  /** Remove a comment; a non-owner connection removes only the comments of the author it is bound to. */
115
116
  sessionRemoveAnnotation(id: string, annotationId: string): Promise<Thread>;
116
- sessionSetWorkingCopy(id: string, workingCopy: string | undefined): Promise<Thread>;
117
+ sessionSetWorkingCopy(
118
+ id: string,
119
+ workingCopy: string | undefined,
120
+ textCuts?: TextCut[],
121
+ ): Promise<Thread>;
117
122
  /** Cut the `blockIndex`-th block of the working copy. */
118
123
  sessionCutBlock(id: string, blockIndex: number): Promise<Thread>;
119
124
  /** Re-insert the `baseBlockIndex`-th block of the submitted revision before `line` (default: the end). */
@@ -180,6 +185,7 @@ export interface ThreadClient {
180
185
  /** What a connection says about itself: its role, the owner token when it claims ownership, the author it acts as otherwise. */
181
186
  interface HelloParams {
182
187
  role: DaemonRole;
188
+ clientVersion: string;
183
189
  token?: string;
184
190
  author?: string;
185
191
  }
@@ -209,9 +215,8 @@ export class DaemonClient implements ThreadClient {
209
215
  private writer: BackpressureWriter | null = null;
210
216
  private connectionEpoch = 0;
211
217
  private pending = new Map<number, PendingRequest>();
212
- /** The connected daemon's build version and pid, learned from the ping handshake. */
218
+ /** The connected daemon's build version, learned from the ping handshake. */
213
219
  private daemonVersion: string | undefined;
214
- private daemonPid: number | undefined;
215
220
  private nextId = 1;
216
221
  private eventListeners = new Set<(event: EventFrame) => void>();
217
222
  private disconnectListeners = new Set<() => void>();
@@ -230,52 +235,25 @@ export class DaemonClient implements ThreadClient {
230
235
  client.home = home;
231
236
  try {
232
237
  await client.dial(path);
233
- // A daemon from an earlier build lingers after an upgrade; talking to it
234
- // means new client, old behaviour. The owner replaces it so an upgrade
235
- // never needs a manual restart; without autostart there is nothing to
236
- // replace it with, so the caller hears exactly why.
237
238
  if (client.daemonVersion === DAEMON_VERSION) return client;
238
- if (!options.autostart) {
239
- client.close();
240
- throw new DaemonClientError(
241
- "version_mismatch",
242
- `daemon is version ${client.daemonVersion ?? "unknown"}, but this client is ${DAEMON_VERSION}; restart the daemon`,
243
- );
244
- }
245
- await client.stopStaleDaemon(path);
246
- } catch (err) {
247
- // a live daemon that refused the handshake is not a dead socket: the
248
- // caller hears why instead of the client replacing a running daemon
249
- if (!options.autostart || err instanceof DaemonClientError) throw err;
250
- }
251
- // Socket dead, absent, or just-replaced: let the new daemon own socket cleanup.
252
- return client.attachFreshDaemon(home, path);
253
- }
239
+ const daemonVersion = client.daemonVersion ?? "unknown";
254
240
 
255
- /**
256
- * Tear down a daemon from an earlier build so a fresh one can bind: ask it to
257
- * shut down (owner-gated) or, failing that, signal its pid, then wait for it to
258
- * release the socket. Best-effort - the next daemon reclaims stale socket
259
- * and lock files after it takes ownership.
260
- */
261
- private async stopStaleDaemon(path: string): Promise<void> {
262
- const pid = this.daemonPid;
241
+ client.close();
242
+ throw new DaemonClientError(
243
+ "version_mismatch",
244
+ `daemon is version ${daemonVersion}, but this client is ${DAEMON_VERSION}; align cueloop versions before restarting the daemon`,
245
+ );
246
+ } catch (err) {
247
+ // Autostart repairs an unavailable socket, never a live incompatible daemon.
248
+ if (!options.autostart || err instanceof DaemonClientError) {
249
+ client.close();
263
250
 
264
- try {
265
- await this.request("daemon.shutdown", {}, EmptyResultSchema, 2_000);
266
- } catch {
267
- if (pid !== undefined) {
268
- try {
269
- process.kill(pid);
270
- } catch {}
251
+ throw err;
271
252
  }
272
253
  }
273
- this.socket?.end();
274
- this.resetConnection();
275
- // the old daemon removes its socket in stop(); wait so the new one binds cleanly
276
- const deadline = Date.now() + 5_000;
277
254
 
278
- while (Date.now() < deadline && existsSync(path)) await sleep(50);
255
+ // Socket dead or absent: let the new daemon own stale socket cleanup.
256
+ return client.attachFreshDaemon(home, path);
279
257
  }
280
258
 
281
259
  /** Reset per-connection state so a fresh dial() can reuse this client instance. */
@@ -383,11 +361,10 @@ export class DaemonClient implements ThreadClient {
383
361
  }
384
362
  // Verify liveness: a dead socket file accepts connects on some platforms
385
363
  // only to fail later, so a ping is the actual handshake. It also carries the
386
- // daemon's build version and pid, so connect() can replace a stale daemon.
364
+ // daemon's build version, so connect() can reject incompatible clients.
387
365
  const pong = await this.request("daemon.ping", {}, PingResultSchema, 2_000);
388
366
 
389
367
  this.daemonVersion = pong.version;
390
- this.daemonPid = pong.pid;
391
368
  // Every connection starts as a collaborator; the owner proves itself with
392
369
  // the token the daemon wrote into the home it serves, which only the home's
393
370
  // user can read. A capped role just names itself.
@@ -397,13 +374,15 @@ export class DaemonClient implements ThreadClient {
397
374
  private helloParams(): HelloParams {
398
375
  if (this.role !== "owner") {
399
376
  return this.author === undefined
400
- ? { role: this.role }
401
- : { role: this.role, author: this.author };
377
+ ? { role: this.role, clientVersion: DAEMON_VERSION }
378
+ : { role: this.role, clientVersion: DAEMON_VERSION, author: this.author };
402
379
  }
403
380
  const token = readOwnerToken(this.home);
404
381
 
405
382
  // a daemon from before owner tokens has no file; it still knows the bare hello
406
- return token === undefined ? { role: "owner" } : { role: "owner", token };
383
+ return token === undefined
384
+ ? { role: "owner", clientVersion: DAEMON_VERSION }
385
+ : { role: "owner", clientVersion: DAEMON_VERSION, token };
407
386
  }
408
387
 
409
388
  onEvent(listener: (event: EventFrame) => void): () => void {
@@ -521,8 +500,16 @@ export class DaemonClient implements ThreadClient {
521
500
  sessionSetParticipantName(id: string, author: string, name: string): Promise<Thread> {
522
501
  return this.request("session.setParticipantName", { id, author, name }, ThreadRecordSchema);
523
502
  }
524
- sessionSetWorkingCopy(id: string, workingCopy: string | undefined): Promise<Thread> {
525
- return this.request("session.setWorkingCopy", { id, workingCopy }, ThreadRecordSchema);
503
+ sessionSetWorkingCopy(
504
+ id: string,
505
+ workingCopy: string | undefined,
506
+ textCuts?: TextCut[],
507
+ ): Promise<Thread> {
508
+ return this.request(
509
+ "session.setWorkingCopy",
510
+ { id, workingCopy, textCuts },
511
+ ThreadRecordSchema,
512
+ );
526
513
  }
527
514
  sessionCutBlock(id: string, blockIndex: number): Promise<Thread> {
528
515
  return this.request("session.cutBlock", { id, blockIndex }, ThreadRecordSchema);
@@ -6,12 +6,13 @@
6
6
  */
7
7
 
8
8
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
9
- import { mkdtempSync, rmSync } from "node:fs";
9
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
10
10
  import { tmpdir } from "node:os";
11
11
  import { join } from "node:path";
12
12
  import { DaemonServer } from "./server";
13
13
  import { DaemonClient, DaemonClientError, daemonSpawnCommand } from "./client";
14
14
  import { DAEMON_VERSION } from "./version";
15
+ import { pidPath } from "./paths";
15
16
  import type { Artifact, WorkspaceKey } from "@cueloop/schema";
16
17
 
17
18
  const WS: WorkspaceKey = { repoRoot: "/repo", branch: "main" };
@@ -29,7 +30,12 @@ afterEach(() => {
29
30
  });
30
31
 
31
32
  function server(version?: string): DaemonServer {
32
- const daemonServer = new DaemonServer({ home, idleExitMs: 0, version });
33
+ const daemonServer = new DaemonServer({
34
+ home,
35
+ idleExitMs: 0,
36
+ version,
37
+ onIdleExit: () => {},
38
+ });
33
39
 
34
40
  servers.push(daemonServer);
35
41
 
@@ -50,6 +56,49 @@ describe("daemon version handshake", () => {
50
56
  );
51
57
  });
52
58
 
59
+ test("autostart never replaces a live daemon from another version", async () => {
60
+ const liveServer = server("0.1.0-alpha.81");
61
+
62
+ liveServer.start();
63
+ const session = liveServer.core.sessionCreate({ workspace: WS, artifact: PLAN });
64
+ let replacement: DaemonClient | undefined;
65
+ let connectionError: unknown;
66
+
67
+ try {
68
+ replacement = await DaemonClient.connect({ home, autostart: true });
69
+ } catch (error) {
70
+ connectionError = error;
71
+ }
72
+
73
+ replacement?.close();
74
+ expect(connectionError).toBeInstanceOf(DaemonClientError);
75
+ expect(connectionError).toMatchObject({ code: "version_mismatch" });
76
+ expect(readFileSync(pidPath(home), "utf8")).toBe(String(process.pid));
77
+ expect(liveServer.core.sessionGet(session.id).id).toBe(session.id);
78
+ });
79
+
80
+ test("a rejected client releases its connection so the daemon can become idle", async () => {
81
+ let idleExited = false;
82
+ const liveServer = new DaemonServer({
83
+ home,
84
+ idleExitMs: 50,
85
+ version: "0.1.0-alpha.81",
86
+ onIdleExit: () => {
87
+ idleExited = true;
88
+ },
89
+ });
90
+
91
+ servers.push(liveServer);
92
+ liveServer.start();
93
+
94
+ await expect(DaemonClient.connect({ home, autostart: true })).rejects.toMatchObject({
95
+ code: "version_mismatch",
96
+ });
97
+ await Bun.sleep(100);
98
+
99
+ expect(idleExited).toBe(true);
100
+ });
101
+
53
102
  test("a current daemon connects and serves normally", async () => {
54
103
  // Arrange - a daemon on this build
55
104
  server(DAEMON_VERSION).start();
@@ -34,6 +34,23 @@ function createPlan(instance: DaemonCore) {
34
34
  }
35
35
 
36
36
  describe("durable harness delivery", () => {
37
+ test("delivers Comment without resolving the Thread", () => {
38
+ const instance = core();
39
+ const thread = createPlan(instance);
40
+ const binding = instance.harnessBind({
41
+ threadId: thread.id,
42
+ harness: "fake",
43
+ harnessSessionId: "fake_1",
44
+ });
45
+
46
+ const sent = instance.sessionSendMessage(thread.id, "comment", "Interim note.");
47
+ const pending = instance.deliveryPending(binding.id);
48
+
49
+ expect(sent.status).toBe("pending");
50
+ expect(pending).toHaveLength(1);
51
+ expect(pending[0]!.message.outcome).toBe("comment");
52
+ });
53
+
37
54
  test("reload finds only bindings for the active harness conversation", () => {
38
55
  const first = core();
39
56
  const thread = createPlan(first);
@@ -5,6 +5,7 @@ import { join } from "node:path";
5
5
  import * as v from "valibot";
6
6
  import { DaemonServer } from "./server";
7
7
  import { DaemonClient, DaemonClientError } from "./client";
8
+ import { DAEMON_VERSION } from "./version";
8
9
  import type { Artifact, WorkspaceKey } from "@cueloop/schema";
9
10
 
10
11
  const WS: WorkspaceKey = { repoRoot: "/repo", branch: "main" };
@@ -413,7 +414,15 @@ describe("ownership is proven, never declared", () => {
413
414
  // Act: the token the daemon wrote into its home
414
415
  const token = readFileSync(join(home, "owner.token"), "utf8").trim();
415
416
 
416
- expect((await raw.call("daemon.hello", { role: "owner", token })).error).toBeUndefined();
417
+ expect(
418
+ (
419
+ await raw.call("daemon.hello", {
420
+ role: "owner",
421
+ token,
422
+ clientVersion: DAEMON_VERSION,
423
+ })
424
+ ).error,
425
+ ).toBeUndefined();
417
426
 
418
427
  // Assert: owner primitives open up
419
428
  const created = await raw.call("session.create", { workspace: WS, artifact: PLAN });
@@ -422,6 +431,18 @@ describe("ownership is proven, never declared", () => {
422
431
  raw.close();
423
432
  });
424
433
 
434
+ test("an older owner cannot shut down a newer daemon", async () => {
435
+ const raw = await rawConnection();
436
+ const token = readFileSync(join(home, "owner.token"), "utf8").trim();
437
+
438
+ expect((await raw.call("daemon.hello", { role: "owner", token })).error?.code).toBe(
439
+ "version_mismatch",
440
+ );
441
+ expect((await raw.call("daemon.shutdown", {})).error?.code).toBe("forbidden");
442
+ expect((await raw.call("daemon.ping", {})).error).toBeUndefined();
443
+ raw.close();
444
+ });
445
+
425
446
  test("a refused handshake reaches the caller and leaves the live daemon's socket alone", async () => {
426
447
  // Arrange: a token file that is not what the daemon minted
427
448
  const tokenPath = join(home, "owner.token");
package/src/server.ts CHANGED
@@ -297,6 +297,13 @@ export class DaemonServer {
297
297
  if (params.role === "owner" && params.token !== this.ownerToken) {
298
298
  throw new DaemonError("forbidden", "owner token required");
299
299
  }
300
+ if (params.clientVersion !== this.version) {
301
+ throw new DaemonError(
302
+ "version_mismatch",
303
+ `daemon is version ${this.version}, but this client is ${params.clientVersion ?? "unknown"}; align cueloop versions before restarting the daemon`,
304
+ );
305
+ }
306
+
300
307
  connection.role = params.role;
301
308
  // identity is bound once, here; a non-owner never names it per call
302
309
  if (params.role !== "owner" && params.author !== undefined) connection.author = params.author;
@@ -370,7 +377,7 @@ export class DaemonServer {
370
377
  "session.setWorkingCopy": (_connection, request) => {
371
378
  const params = parseParams("session.setWorkingCopy", request.params);
372
379
 
373
- return this.core.sessionSetWorkingCopy(params.id, params.workingCopy);
380
+ return this.core.sessionSetWorkingCopy(params.id, params.workingCopy, params.textCuts);
374
381
  },
375
382
  "session.cutBlock": (_connection, request) => {
376
383
  const params = parseParams("session.cutBlock", request.params);
@@ -76,6 +76,30 @@ describe("parseParams", () => {
76
76
  }
77
77
  });
78
78
 
79
+ test.each([
80
+ [
81
+ [
82
+ { start: 2, end: 4, quote: "ab" },
83
+ { start: 1, end: 2, quote: "c" },
84
+ ],
85
+ ],
86
+ [
87
+ [
88
+ { start: 1, end: 4, quote: "abc" },
89
+ { start: 3, end: 5, quote: "de" },
90
+ ],
91
+ ],
92
+ [[{ start: 1, end: 4, quote: "ab" }]],
93
+ ])("rejects malformed text Cut provenance", (textCuts) => {
94
+ expect(() =>
95
+ parseParams("session.setWorkingCopy", {
96
+ id: "ses_1",
97
+ workingCopy: "remaining",
98
+ textCuts,
99
+ }),
100
+ ).toThrow("text Cuts must be ordered, non-overlapping, and match their quote lengths");
101
+ });
102
+
79
103
  test("rejects an unknown artifact type", () => {
80
104
  expect(() =>
81
105
  parseParams("session.create", {
@@ -224,6 +248,7 @@ describe("wire pins", () => {
224
248
  outcome: "approved",
225
249
  summary: "",
226
250
  body: "",
251
+ annotations: [fullAnnotation],
227
252
  sentAt: "now",
228
253
  };
229
254
  const fullRevision: Required<Revision> = { revision: 1, content: "# P", submittedAt: "now" };
@@ -276,7 +301,8 @@ describe("wire pins", () => {
276
301
  shelvedAnnotations: [fullAnnotation],
277
302
  parentSessionId: "ses_0",
278
303
  shareBranch: "main",
279
- workingCopy: "# P edited",
304
+ workingCopy: "# ",
305
+ textCuts: [{ start: 2, end: 3, quote: "P" }],
280
306
  viewedPaths: ["src/a.ts"],
281
307
  message: fullMessage,
282
308
  status: "pending",
@@ -320,6 +346,20 @@ describe("wire pins", () => {
320
346
  if (!parsed.ok) expect(parsed.error).toContain("history:");
321
347
  });
322
348
 
349
+ test("a persisted Thread with stale text Cut provenance is refused", () => {
350
+ const result = validateThreadRecord({
351
+ ...fullSession,
352
+ workingCopy: "# P",
353
+ });
354
+
355
+ expect(result.ok).toBe(false);
356
+ if (!result.ok) {
357
+ expect(result.error).toContain(
358
+ "text Cuts do not match the submitted artifact and working copy",
359
+ );
360
+ }
361
+ });
362
+
323
363
  test("schema key sets match the schema types", () => {
324
364
  expect(entryKeys(WorkspaceSchema)).toEqual(keys(fullWorkspace));
325
365
  expect(entryKeys(ArtifactMetaSchema)).toEqual(keys(fullMeta));
package/src/validate.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  import * as v from "valibot";
13
13
  import {
14
14
  ARTIFACT_TYPES,
15
+ MESSAGE_OUTCOMES,
15
16
  WORKFLOW_KINDS,
16
17
  SCHEMA_VERSION,
17
18
  type Anchor,
@@ -22,6 +23,7 @@ import {
22
23
  type DiffFileContents,
23
24
  type Identity,
24
25
  type Thread,
26
+ type TextCut,
25
27
  type HunkRejection,
26
28
  type HarnessBinding,
27
29
  type Delivery,
@@ -33,6 +35,7 @@ import {
33
35
  validateHistory,
34
36
  type Message,
35
37
  type WorkspaceKey,
38
+ applyTextCuts,
36
39
  } from "@cueloop/schema";
37
40
  import { DaemonError } from "./errors";
38
41
  import type { Request } from "./protocol";
@@ -48,6 +51,25 @@ type EntriesOf<T> = { [K in keyof T]-?: v.GenericSchema<any, any> };
48
51
 
49
52
  const NonEmpty = v.pipe(v.string(), v.minLength(1));
50
53
 
54
+ const TextCutSchema = v.object({
55
+ start: v.pipe(v.number(), v.integer(), v.minValue(0)),
56
+ end: v.pipe(v.number(), v.integer(), v.minValue(1)),
57
+ quote: NonEmpty,
58
+ } satisfies EntriesOf<TextCut>);
59
+
60
+ const TextCutsSchema = v.pipe(
61
+ v.array(TextCutSchema),
62
+ v.check(
63
+ (cuts) =>
64
+ cuts.every(
65
+ (cut, index) =>
66
+ cut.end - cut.start === cut.quote.length &&
67
+ (index === 0 || cuts[index - 1]!.end <= cut.start),
68
+ ),
69
+ "text Cuts must be ordered, non-overlapping, and match their quote lengths",
70
+ ),
71
+ );
72
+
51
73
  export const WorkspaceSchema = v.object({
52
74
  repoRoot: NonEmpty,
53
75
  branch: NonEmpty,
@@ -196,7 +218,11 @@ export const Params = {
196
218
  }),
197
219
  "session.removeAnnotation": v.object({ id: SessionId, annotationId: NonEmpty }),
198
220
  "session.setParticipantName": v.object({ id: SessionId, author: NonEmpty, name: NonEmpty }),
199
- "session.setWorkingCopy": v.object({ id: SessionId, workingCopy: v.optional(v.string()) }),
221
+ "session.setWorkingCopy": v.object({
222
+ id: SessionId,
223
+ workingCopy: v.optional(v.string()),
224
+ textCuts: v.optional(TextCutsSchema),
225
+ }),
200
226
  "session.cutBlock": v.object({
201
227
  id: SessionId,
202
228
  blockIndex: v.pipe(v.number(), v.integer(), v.minValue(0)),
@@ -252,7 +278,7 @@ export const Params = {
252
278
  }),
253
279
  "session.sendMessage": v.object({
254
280
  id: SessionId,
255
- outcome: v.picklist(["approved", "changes_requested"]),
281
+ outcome: v.picklist(MESSAGE_OUTCOMES),
256
282
  summary: v.optional(v.string(), ""),
257
283
  actionBodies: v.optional(v.record(v.string(), v.string())),
258
284
  }),
@@ -282,6 +308,7 @@ export const Params = {
282
308
  // the owner token proves ownership; without it a request for owner stays a collaborator
283
309
  "daemon.hello": v.object({
284
310
  role: v.picklist(["owner", "collaborator", "agent"]),
311
+ clientVersion: v.optional(v.string()),
285
312
  token: v.optional(v.string()),
286
313
  // the author a non-owner acts as, bound once for the connection
287
314
  author: v.optional(NonEmpty),
@@ -335,9 +362,10 @@ export const RevisionSchema = v.object({
335
362
 
336
363
  export const MessageSchema = v.object({
337
364
  id: NonEmpty,
338
- outcome: v.picklist(["approved", "changes_requested"]),
365
+ outcome: v.picklist(MESSAGE_OUTCOMES),
339
366
  summary: v.string(),
340
367
  body: v.string(),
368
+ annotations: v.optional(v.array(FullAnnotationSchema)),
341
369
  sentAt: v.string(),
342
370
  } satisfies EntriesOf<Message>);
343
371
 
@@ -359,6 +387,7 @@ export const SessionEntrySchema = v.variant("type", [
359
387
  type: v.literal("revision"),
360
388
  by: v.picklist(["agent", "reviewer"]),
361
389
  content: v.string(),
390
+ textCuts: v.optional(TextCutsSchema),
362
391
  }),
363
392
  v.object({ ...EntryBaseEntries, type: v.literal("comment"), annotationId: NonEmpty }),
364
393
  v.object({ ...EntryBaseEntries, type: v.literal("comment-removed"), annotationId: NonEmpty }),
@@ -388,39 +417,53 @@ export const SessionHistorySchema = v.pipe(
388
417
  );
389
418
 
390
419
  /** Persisted records are validated on recovery: a bad file is skipped, not fatal. */
391
- export const ThreadRecordSchema = v.object({
392
- schemaVersion: v.literal(SCHEMA_VERSION),
393
- id: NonEmpty,
394
- workspace: WorkspaceSchema,
395
- artifact: ArtifactSchema,
396
- revisions: v.array(RevisionSchema),
397
- annotations: v.array(FullAnnotationSchema),
398
- history: v.optional(SessionHistorySchema),
399
- curation: v.optional(
400
- v.array(
401
- v.object({
402
- path: NonEmpty,
403
- hunkIndex: v.number(),
404
- changeIndex: v.optional(v.number()),
405
- } satisfies EntriesOf<HunkRejection>),
420
+ export const ThreadRecordSchema = v.pipe(
421
+ v.object({
422
+ schemaVersion: v.literal(SCHEMA_VERSION),
423
+ id: NonEmpty,
424
+ workspace: WorkspaceSchema,
425
+ artifact: ArtifactSchema,
426
+ revisions: v.array(RevisionSchema),
427
+ annotations: v.array(FullAnnotationSchema),
428
+ history: v.optional(SessionHistorySchema),
429
+ curation: v.optional(
430
+ v.array(
431
+ v.object({
432
+ path: NonEmpty,
433
+ hunkIndex: v.number(),
434
+ changeIndex: v.optional(v.number()),
435
+ } satisfies EntriesOf<HunkRejection>),
436
+ ),
406
437
  ),
407
- ),
408
- workingCopy: v.optional(v.string()),
409
- viewedPaths: v.optional(v.array(v.string())),
410
- message: v.nullable(MessageSchema),
411
- status: v.picklist(["pending", "resolved"]),
412
- createdAt: v.string(),
413
- shelvedAnnotations: v.optional(v.array(FullAnnotationSchema)),
414
- parentSessionId: v.optional(v.string()),
415
- shares: v.optional(v.array(ShareLinkSchema)),
416
- shareId: v.optional(v.string()),
417
- shareBranch: v.optional(v.string()),
418
- owner: v.optional(v.string()),
419
- access: v.optional(
420
- v.object({ githubLogins: v.array(NonEmpty) } satisfies EntriesOf<ShareAccess>),
421
- ),
422
- participants: v.optional(v.array(IdentitySchema)),
423
- } satisfies EntriesOf<Thread>);
438
+ workingCopy: v.optional(v.string()),
439
+ textCuts: v.optional(TextCutsSchema),
440
+ viewedPaths: v.optional(v.array(v.string())),
441
+ message: v.nullable(MessageSchema),
442
+ status: v.picklist(["pending", "resolved"]),
443
+ createdAt: v.string(),
444
+ shelvedAnnotations: v.optional(v.array(FullAnnotationSchema)),
445
+ parentSessionId: v.optional(v.string()),
446
+ shares: v.optional(v.array(ShareLinkSchema)),
447
+ shareId: v.optional(v.string()),
448
+ shareBranch: v.optional(v.string()),
449
+ owner: v.optional(v.string()),
450
+ access: v.optional(
451
+ v.object({ githubLogins: v.array(NonEmpty) } satisfies EntriesOf<ShareAccess>),
452
+ ),
453
+ participants: v.optional(v.array(IdentitySchema)),
454
+ } satisfies EntriesOf<Thread>),
455
+ v.rawCheck(({ dataset, addIssue }) => {
456
+ if (!dataset.typed || !dataset.value.textCuts?.length) return;
457
+ const { artifact, textCuts, workingCopy } = dataset.value;
458
+ const matchesSource = textCuts.every(
459
+ (cut) => artifact.content.slice(cut.start, cut.end) === cut.quote,
460
+ );
461
+
462
+ if (!matchesSource || workingCopy !== applyTextCuts(artifact.content, textCuts)) {
463
+ addIssue({ message: "text Cuts do not match the submitted artifact and working copy" });
464
+ }
465
+ }),
466
+ );
424
467
 
425
468
  export function validateThreadRecord(
426
469
  raw: Parameters<typeof v.safeParse>[1],