@cueloop/daemon 0.1.0-alpha.83 → 0.1.0-alpha.85
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 +16 -3
- package/src/harness-delivery.test.ts +17 -0
- package/src/server.ts +1 -1
- package/src/validate.test.ts +41 -1
- package/src/validate.ts +77 -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.85",
|
|
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.85",
|
|
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,
|
|
@@ -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). */
|
|
@@ -495,8 +500,16 @@ export class DaemonClient implements ThreadClient {
|
|
|
495
500
|
sessionSetParticipantName(id: string, author: string, name: string): Promise<Thread> {
|
|
496
501
|
return this.request("session.setParticipantName", { id, author, name }, ThreadRecordSchema);
|
|
497
502
|
}
|
|
498
|
-
sessionSetWorkingCopy(
|
|
499
|
-
|
|
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
|
+
);
|
|
500
513
|
}
|
|
501
514
|
sessionCutBlock(id: string, blockIndex: number): Promise<Thread> {
|
|
502
515
|
return this.request("session.cutBlock", { id, blockIndex }, ThreadRecordSchema);
|
|
@@ -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.ts
CHANGED
|
@@ -377,7 +377,7 @@ export class DaemonServer {
|
|
|
377
377
|
"session.setWorkingCopy": (_connection, request) => {
|
|
378
378
|
const params = parseParams("session.setWorkingCopy", request.params);
|
|
379
379
|
|
|
380
|
-
return this.core.sessionSetWorkingCopy(params.id, params.workingCopy);
|
|
380
|
+
return this.core.sessionSetWorkingCopy(params.id, params.workingCopy, params.textCuts);
|
|
381
381
|
},
|
|
382
382
|
"session.cutBlock": (_connection, request) => {
|
|
383
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
|
}),
|
|
@@ -336,9 +362,10 @@ export const RevisionSchema = v.object({
|
|
|
336
362
|
|
|
337
363
|
export const MessageSchema = v.object({
|
|
338
364
|
id: NonEmpty,
|
|
339
|
-
outcome: v.picklist(
|
|
365
|
+
outcome: v.picklist(MESSAGE_OUTCOMES),
|
|
340
366
|
summary: v.string(),
|
|
341
367
|
body: v.string(),
|
|
368
|
+
annotations: v.optional(v.array(FullAnnotationSchema)),
|
|
342
369
|
sentAt: v.string(),
|
|
343
370
|
} satisfies EntriesOf<Message>);
|
|
344
371
|
|
|
@@ -360,6 +387,7 @@ export const SessionEntrySchema = v.variant("type", [
|
|
|
360
387
|
type: v.literal("revision"),
|
|
361
388
|
by: v.picklist(["agent", "reviewer"]),
|
|
362
389
|
content: v.string(),
|
|
390
|
+
textCuts: v.optional(TextCutsSchema),
|
|
363
391
|
}),
|
|
364
392
|
v.object({ ...EntryBaseEntries, type: v.literal("comment"), annotationId: NonEmpty }),
|
|
365
393
|
v.object({ ...EntryBaseEntries, type: v.literal("comment-removed"), annotationId: NonEmpty }),
|
|
@@ -389,39 +417,53 @@ export const SessionHistorySchema = v.pipe(
|
|
|
389
417
|
);
|
|
390
418
|
|
|
391
419
|
/** Persisted records are validated on recovery: a bad file is skipped, not fatal. */
|
|
392
|
-
export const ThreadRecordSchema = v.
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
v.
|
|
402
|
-
v.
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
+
),
|
|
407
437
|
),
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
} 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
|
+
);
|
|
425
467
|
|
|
426
468
|
export function validateThreadRecord(
|
|
427
469
|
raw: Parameters<typeof v.safeParse>[1],
|