@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 +2 -2
- package/src/api.test.ts +121 -1
- package/src/api.ts +90 -8
- package/src/client.ts +38 -51
- package/src/daemon-version.test.ts +51 -2
- package/src/harness-delivery.test.ts +17 -0
- package/src/server.test.ts +22 -1
- package/src/server.ts +8 -1
- package/src/validate.test.ts +41 -1
- package/src/validate.ts +78 -35
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/daemon",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
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.
|
|
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 +
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
525
|
-
|
|
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({
|
|
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);
|
package/src/server.test.ts
CHANGED
|
@@ -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(
|
|
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);
|
package/src/validate.test.ts
CHANGED
|
@@ -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: "#
|
|
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({
|
|
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(
|
|
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(
|
|
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.
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
v.
|
|
401
|
-
v.
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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],
|