@cueloop/schema 0.1.0-alpha.63 → 0.1.0-alpha.65
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/anchor.test.ts +115 -2
- package/src/anchor.ts +199 -6
- package/src/feedback.test.ts +67 -0
- package/src/feedback.ts +23 -1
- package/src/history.test.ts +395 -0
- package/src/history.ts +419 -0
- package/src/index.ts +2 -0
- package/src/path-view.test.ts +201 -0
- package/src/path-view.ts +85 -0
- package/src/types.ts +43 -4
- package/src/working-copy.ts +14 -0
package/src/history.ts
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A review session's history as an append-only tree of entries. Each entry
|
|
3
|
+
* points at its parent; a branch is a named tip; the active path is the chain
|
|
4
|
+
* from the current branch's tip back to the root. Nothing is ever deleted:
|
|
5
|
+
* navigating moves a tip, a comment removal is its own entry, a fork copies a
|
|
6
|
+
* path into a new history. Every view of the session - the artifact text,
|
|
7
|
+
* the open comments, the verdict history - derives from the active path.
|
|
8
|
+
*
|
|
9
|
+
* Pure: no store, no daemon. The daemon owns persistence; the client and the
|
|
10
|
+
* feedback document read what this module derives.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Annotation, ReviewSession, Verdict } from "./types";
|
|
14
|
+
|
|
15
|
+
export type EntryAuthor = "agent" | "reviewer";
|
|
16
|
+
|
|
17
|
+
interface EntryBase {
|
|
18
|
+
id: string;
|
|
19
|
+
/** null on the root entry. */
|
|
20
|
+
parentId: string | null;
|
|
21
|
+
createdAt: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type SessionEntry =
|
|
25
|
+
| (EntryBase & { type: "revision"; by: EntryAuthor; content: string })
|
|
26
|
+
| (EntryBase & { type: "comment"; annotationId: string })
|
|
27
|
+
| (EntryBase & { type: "comment-removed"; annotationId: string })
|
|
28
|
+
| (EntryBase & { type: "verdict"; verdict: Verdict })
|
|
29
|
+
| (EntryBase & { type: "branch-summary"; text: string; abandoned: string[] });
|
|
30
|
+
|
|
31
|
+
export type EntryType = SessionEntry["type"];
|
|
32
|
+
|
|
33
|
+
/** Omit that keeps a discriminated union's members apart. */
|
|
34
|
+
type DistributiveOmit<T, K extends keyof T> = T extends unknown ? Omit<T, K> : never;
|
|
35
|
+
|
|
36
|
+
/** An entry as a caller describes it: the tree pointers are the history's business. */
|
|
37
|
+
export type NewEntry = DistributiveOmit<SessionEntry, "id" | "parentId"> & { id?: string };
|
|
38
|
+
|
|
39
|
+
export interface AppendResult {
|
|
40
|
+
history: SessionHistory;
|
|
41
|
+
entry: SessionEntry;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The agent's line; every other branch is the reviewer's. */
|
|
45
|
+
export const MAIN_BRANCH = "main";
|
|
46
|
+
|
|
47
|
+
export interface SessionHistory {
|
|
48
|
+
entries: SessionEntry[];
|
|
49
|
+
/** Named tips; `main` always exists. */
|
|
50
|
+
tips: Record<string, string>;
|
|
51
|
+
/** The branch whose path the session shows. */
|
|
52
|
+
branch: string;
|
|
53
|
+
/** Checkpoints: an entry id to the name a reviewer gave it. */
|
|
54
|
+
labels: Record<string, string>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let entrySeq = 0;
|
|
58
|
+
|
|
59
|
+
export function newEntryId(): string {
|
|
60
|
+
return `e_${Date.now().toString(36)}${(entrySeq++).toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class HistoryError extends Error {
|
|
64
|
+
constructor(
|
|
65
|
+
readonly code:
|
|
66
|
+
| "unknown-entry"
|
|
67
|
+
| "unknown-branch"
|
|
68
|
+
| "branch-exists"
|
|
69
|
+
| "off-path"
|
|
70
|
+
| "already-there"
|
|
71
|
+
| "not-a-revision"
|
|
72
|
+
| "cycle",
|
|
73
|
+
message: string,
|
|
74
|
+
) {
|
|
75
|
+
super(message);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* ------------------------------------------------------------------ reads */
|
|
80
|
+
|
|
81
|
+
export function entryOf(history: SessionHistory, id: string): SessionEntry | undefined {
|
|
82
|
+
return history.entries.find((entry) => entry.id === id);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function tipOf(history: SessionHistory, branch: string = history.branch): string {
|
|
86
|
+
const tip = history.tips[branch];
|
|
87
|
+
|
|
88
|
+
if (tip === undefined) throw new HistoryError("unknown-branch", `no branch "${branch}"`);
|
|
89
|
+
|
|
90
|
+
return tip;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The entries from `fromId` (default: the current tip) back to the root, root first. */
|
|
94
|
+
export function pathOf(history: SessionHistory, fromId: string = tipOf(history)): SessionEntry[] {
|
|
95
|
+
const byId = new Map(history.entries.map((entry) => [entry.id, entry]));
|
|
96
|
+
const path: SessionEntry[] = [];
|
|
97
|
+
const seen = new Set<string>();
|
|
98
|
+
|
|
99
|
+
for (
|
|
100
|
+
let entry = byId.get(fromId);
|
|
101
|
+
entry;
|
|
102
|
+
entry = entry.parentId ? byId.get(entry.parentId) : undefined
|
|
103
|
+
) {
|
|
104
|
+
if (seen.has(entry.id))
|
|
105
|
+
throw new HistoryError("cycle", `entry "${entry.id}" is its own ancestor`);
|
|
106
|
+
seen.add(entry.id);
|
|
107
|
+
path.unshift(entry);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return path;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The graph invariants every history must hold, as one message or null: ids
|
|
115
|
+
* are unique, every parent exists, no entry is its own ancestor, every tip
|
|
116
|
+
* names an entry, `main` and the current branch exist, and every tip's path
|
|
117
|
+
* reaches a revision. Run at every boundary a history crosses.
|
|
118
|
+
*/
|
|
119
|
+
export function validateHistory(history: SessionHistory): string | null {
|
|
120
|
+
const ids = new Set<string>();
|
|
121
|
+
|
|
122
|
+
for (const entry of history.entries) {
|
|
123
|
+
if (ids.has(entry.id)) return `duplicate entry id "${entry.id}"`;
|
|
124
|
+
ids.add(entry.id);
|
|
125
|
+
}
|
|
126
|
+
for (const entry of history.entries) {
|
|
127
|
+
if (entry.parentId !== null && !ids.has(entry.parentId)) {
|
|
128
|
+
return `entry "${entry.id}" points at missing parent "${entry.parentId}"`;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (history.tips[MAIN_BRANCH] === undefined) return `no "${MAIN_BRANCH}" branch`;
|
|
132
|
+
if (history.tips[history.branch] === undefined)
|
|
133
|
+
return `current branch "${history.branch}" has no tip`;
|
|
134
|
+
for (const [branch, tip] of Object.entries(history.tips)) {
|
|
135
|
+
if (!ids.has(tip)) return `branch "${branch}" points at missing entry "${tip}"`;
|
|
136
|
+
try {
|
|
137
|
+
if (!pathOf(history, tip).some((entry) => entry.type === "revision")) {
|
|
138
|
+
return `branch "${branch}" has no revision on its path`;
|
|
139
|
+
}
|
|
140
|
+
} catch (error) {
|
|
141
|
+
return error instanceof HistoryError ? error.message : String(error);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
for (const entry of history.entries) {
|
|
145
|
+
// a cycle off every path still hangs a walk that starts inside it
|
|
146
|
+
try {
|
|
147
|
+
pathOf(history, entry.id);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
return error instanceof HistoryError ? error.message : String(error);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface DerivedPath {
|
|
157
|
+
/** The last revision on the path: what the artifact shows. */
|
|
158
|
+
head: SessionEntry & { type: "revision" };
|
|
159
|
+
/**
|
|
160
|
+
* Comments added on the path and not removed after, in order. Whether one
|
|
161
|
+
* is addressed by a revision is the annotation's own state, not the path's.
|
|
162
|
+
*/
|
|
163
|
+
annotationIds: string[];
|
|
164
|
+
/** Verdicts on the path, oldest first. */
|
|
165
|
+
verdicts: Verdict[];
|
|
166
|
+
summaries: Array<SessionEntry & { type: "branch-summary" }>;
|
|
167
|
+
/** Agent revisions on the path: one per round. */
|
|
168
|
+
rounds: number;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Everything a viewer sees on the current path, folded from its entries. */
|
|
172
|
+
export function derivePath(history: SessionHistory, fromId?: string): DerivedPath {
|
|
173
|
+
const path = pathOf(history, fromId);
|
|
174
|
+
const revisions = path.filter((entry) => entry.type === "revision");
|
|
175
|
+
const head = revisions.at(-1);
|
|
176
|
+
|
|
177
|
+
if (!head) throw new HistoryError("not-a-revision", "a path without a revision has no head");
|
|
178
|
+
const open = new Set<string>();
|
|
179
|
+
const verdicts: Verdict[] = [];
|
|
180
|
+
const summaries: Array<SessionEntry & { type: "branch-summary" }> = [];
|
|
181
|
+
|
|
182
|
+
for (const entry of path) {
|
|
183
|
+
if (entry.type === "comment") open.add(entry.annotationId);
|
|
184
|
+
if (entry.type === "comment-removed") open.delete(entry.annotationId);
|
|
185
|
+
if (entry.type === "verdict") verdicts.push(entry.verdict);
|
|
186
|
+
if (entry.type === "branch-summary") summaries.push(entry);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
head,
|
|
191
|
+
annotationIds: [...open],
|
|
192
|
+
verdicts,
|
|
193
|
+
summaries,
|
|
194
|
+
rounds: revisions.filter((entry) => entry.by === "agent").length,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/* ----------------------------------------------------------------- writes */
|
|
199
|
+
|
|
200
|
+
/** Append an entry after the current branch's tip and move the tip onto it. */
|
|
201
|
+
export function appendEntry(history: SessionHistory, entry: NewEntry): AppendResult {
|
|
202
|
+
const parentId = tipOf(history);
|
|
203
|
+
// SAFETY: the spread keeps `type` and its fields intact; only the tree
|
|
204
|
+
// pointers are added, which the union's every member carries
|
|
205
|
+
const full = { ...entry, id: entry.id ?? newEntryId(), parentId } as SessionEntry;
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
history: {
|
|
209
|
+
...history,
|
|
210
|
+
entries: [...history.entries, full],
|
|
211
|
+
tips: { ...history.tips, [history.branch]: full.id },
|
|
212
|
+
},
|
|
213
|
+
entry: full,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Replace the text of the agent's current revision on `main` in place. A
|
|
219
|
+
* re-capture of a working tree is the revision the agent is still working on,
|
|
220
|
+
* not a new one, so nothing is appended.
|
|
221
|
+
*/
|
|
222
|
+
export function recaptureMainHead(history: SessionHistory, content: string): SessionHistory {
|
|
223
|
+
const head = pathOf(history, tipOf(history, MAIN_BRANCH))
|
|
224
|
+
.filter((entry) => entry.type === "revision" && entry.by === "agent")
|
|
225
|
+
.at(-1);
|
|
226
|
+
|
|
227
|
+
if (!head) throw new HistoryError("not-a-revision", `"${MAIN_BRANCH}" has no agent revision`);
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
...history,
|
|
231
|
+
entries: history.entries.map((entry) => (entry.id === head.id ? { ...entry, content } : entry)),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Name the current tip as a checkpoint. */
|
|
236
|
+
export function labelTip(history: SessionHistory, label: string): SessionHistory {
|
|
237
|
+
return { ...history, labels: { ...history.labels, [tipOf(history)]: label } };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Start a branch at the current tip and switch to it; `main` is untouched. */
|
|
241
|
+
export function createBranch(history: SessionHistory, name: string): SessionHistory {
|
|
242
|
+
if (history.tips[name] !== undefined) {
|
|
243
|
+
throw new HistoryError("branch-exists", `branch "${name}" exists`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return { ...history, tips: { ...history.tips, [name]: tipOf(history) }, branch: name };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function switchBranch(history: SessionHistory, name: string): SessionHistory {
|
|
250
|
+
tipOf(history, name);
|
|
251
|
+
|
|
252
|
+
return { ...history, branch: name };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export interface NavigateOptions {
|
|
256
|
+
/** Record the abandoned segment as a branch summary parented at the target. */
|
|
257
|
+
summary?: string;
|
|
258
|
+
createdAt?: string;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Move the current branch's tip to an earlier entry on its path. The entries
|
|
263
|
+
* after it stay in the tree; with a summary, a branch-summary entry that
|
|
264
|
+
* names them is appended at the target and becomes the tip.
|
|
265
|
+
*/
|
|
266
|
+
export function navigateTo(
|
|
267
|
+
history: SessionHistory,
|
|
268
|
+
targetId: string,
|
|
269
|
+
options: NavigateOptions = {},
|
|
270
|
+
): SessionHistory {
|
|
271
|
+
if (!entryOf(history, targetId)) {
|
|
272
|
+
throw new HistoryError("unknown-entry", `no entry "${targetId}"`);
|
|
273
|
+
}
|
|
274
|
+
const tip = tipOf(history);
|
|
275
|
+
|
|
276
|
+
if (targetId === tip) throw new HistoryError("already-there", "already at that entry");
|
|
277
|
+
const path = pathOf(history);
|
|
278
|
+
const index = path.findIndex((entry) => entry.id === targetId);
|
|
279
|
+
|
|
280
|
+
if (index === -1) {
|
|
281
|
+
throw new HistoryError(
|
|
282
|
+
"off-path",
|
|
283
|
+
`"${targetId}" is not on branch "${history.branch}" - switch branch first`,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
const abandoned = path.slice(index + 1).map((entry) => entry.id);
|
|
287
|
+
const moved: SessionHistory = {
|
|
288
|
+
...history,
|
|
289
|
+
tips: { ...history.tips, [history.branch]: targetId },
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
if (options.summary === undefined) return moved;
|
|
293
|
+
|
|
294
|
+
return appendEntry(moved, {
|
|
295
|
+
type: "branch-summary",
|
|
296
|
+
text: options.summary,
|
|
297
|
+
abandoned,
|
|
298
|
+
createdAt: options.createdAt ?? new Date().toISOString(),
|
|
299
|
+
}).history;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* A fork's history: the current path copied as one branch, keeping comments
|
|
304
|
+
* and labels on it, dropping verdicts and the reviewer's edits, with the tip
|
|
305
|
+
* at the copied head. The kept entries are chained anew so no entry points at
|
|
306
|
+
* one that was dropped.
|
|
307
|
+
*/
|
|
308
|
+
export function forkHistory(history: SessionHistory): SessionHistory {
|
|
309
|
+
const kept: SessionEntry[] = [];
|
|
310
|
+
const labels: Record<string, string> = {};
|
|
311
|
+
|
|
312
|
+
for (const entry of pathOf(history)) {
|
|
313
|
+
if (entry.type === "verdict" || (entry.type === "revision" && entry.by === "reviewer"))
|
|
314
|
+
continue;
|
|
315
|
+
kept.push({ ...entry, parentId: kept.at(-1)?.id ?? null });
|
|
316
|
+
const label = history.labels[entry.id];
|
|
317
|
+
|
|
318
|
+
if (label !== undefined) labels[entry.id] = label;
|
|
319
|
+
}
|
|
320
|
+
const tip = kept.at(-1);
|
|
321
|
+
|
|
322
|
+
if (!tip) throw new HistoryError("not-a-revision", "nothing to fork");
|
|
323
|
+
|
|
324
|
+
return { entries: kept, tips: { [MAIN_BRANCH]: tip.id }, branch: MAIN_BRANCH, labels };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* The history as a share carries it: one branch's path and nothing else, so a
|
|
329
|
+
* collaborator sees that branch wherever the owner stands. Labels on the path
|
|
330
|
+
* travel; other branches and their entries do not.
|
|
331
|
+
*/
|
|
332
|
+
export function followBranch(history: SessionHistory, branch: string): SessionHistory {
|
|
333
|
+
const entries = pathOf(history, tipOf(history, branch));
|
|
334
|
+
const labels: Record<string, string> = {};
|
|
335
|
+
|
|
336
|
+
for (const entry of entries) {
|
|
337
|
+
const label = history.labels[entry.id];
|
|
338
|
+
|
|
339
|
+
if (label !== undefined) labels[entry.id] = label;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return { entries, tips: { [branch]: entries.at(-1)!.id }, branch, labels };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** The comment removals a history records, in entry order: what a merge carries across a share. */
|
|
346
|
+
export function removalEntries(
|
|
347
|
+
history: SessionHistory,
|
|
348
|
+
): Array<SessionEntry & { type: "comment-removed" }> {
|
|
349
|
+
return history.entries.filter(
|
|
350
|
+
(entry): entry is SessionEntry & { type: "comment-removed" } =>
|
|
351
|
+
entry.type === "comment-removed",
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/* -------------------------------------------------------------- migration */
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* A linear session as a one-branch tree: its revisions chained on `main` as
|
|
359
|
+
* agent revisions, its comments attached after the revision that was current
|
|
360
|
+
* when they were made, its verdict (when resolved) after the last revision.
|
|
361
|
+
* Deterministic: the same record migrates to the same ids.
|
|
362
|
+
*/
|
|
363
|
+
export function historyFromLinear(
|
|
364
|
+
session: Pick<ReviewSession, "id" | "revisions" | "annotations" | "verdict" | "createdAt">,
|
|
365
|
+
): SessionHistory {
|
|
366
|
+
const entries: SessionEntry[] = [];
|
|
367
|
+
let parentId: string | null = null;
|
|
368
|
+
const push = (entry: DistributiveOmit<SessionEntry, "parentId">): void => {
|
|
369
|
+
// SAFETY: only the parent pointer is added; the union member is untouched
|
|
370
|
+
const full = { ...entry, parentId } as SessionEntry;
|
|
371
|
+
|
|
372
|
+
entries.push(full);
|
|
373
|
+
parentId = full.id;
|
|
374
|
+
};
|
|
375
|
+
const revisions = [...session.revisions].sort((left, right) => left.revision - right.revision);
|
|
376
|
+
const comments = [...session.annotations].sort((left, right) =>
|
|
377
|
+
left.createdAt.localeCompare(right.createdAt),
|
|
378
|
+
);
|
|
379
|
+
let nextComment = 0;
|
|
380
|
+
|
|
381
|
+
revisions.forEach((revision, index) => {
|
|
382
|
+
push({
|
|
383
|
+
id: `${session.id}_rev${revision.revision}`,
|
|
384
|
+
type: "revision",
|
|
385
|
+
by: "agent",
|
|
386
|
+
content: revision.content,
|
|
387
|
+
createdAt: revision.submittedAt,
|
|
388
|
+
});
|
|
389
|
+
const until = revisions[index + 1]?.submittedAt;
|
|
390
|
+
|
|
391
|
+
while (
|
|
392
|
+
nextComment < comments.length &&
|
|
393
|
+
(until === undefined || comments[nextComment]!.createdAt < until)
|
|
394
|
+
) {
|
|
395
|
+
const annotation: Annotation = comments[nextComment]!;
|
|
396
|
+
|
|
397
|
+
push({
|
|
398
|
+
id: `${session.id}_com_${annotation.id}`,
|
|
399
|
+
type: "comment",
|
|
400
|
+
annotationId: annotation.id,
|
|
401
|
+
createdAt: annotation.createdAt,
|
|
402
|
+
});
|
|
403
|
+
nextComment++;
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
if (session.verdict) {
|
|
407
|
+
push({
|
|
408
|
+
id: `${session.id}_verdict`,
|
|
409
|
+
type: "verdict",
|
|
410
|
+
verdict: session.verdict,
|
|
411
|
+
createdAt: session.verdict.resolvedAt,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
const tip = entries.at(-1);
|
|
415
|
+
|
|
416
|
+
if (!tip) throw new HistoryError("not-a-revision", `session ${session.id} has no revision`);
|
|
417
|
+
|
|
418
|
+
return { entries, tips: { [MAIN_BRANCH]: tip.id }, branch: MAIN_BRANCH, labels: {} };
|
|
419
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
appendEntry,
|
|
4
|
+
createBranch,
|
|
5
|
+
historyFromLinear,
|
|
6
|
+
navigateTo,
|
|
7
|
+
switchBranch,
|
|
8
|
+
type SessionHistory,
|
|
9
|
+
} from "./history";
|
|
10
|
+
import { applyPathView, viewFollowing, viewOfPath } from "./path-view";
|
|
11
|
+
import type { Annotation, ReviewSession } from "./types";
|
|
12
|
+
|
|
13
|
+
const AT = "2026-09-01T10:00:00.000Z";
|
|
14
|
+
|
|
15
|
+
function annotation(id: string): Annotation {
|
|
16
|
+
return {
|
|
17
|
+
id,
|
|
18
|
+
kind: "comment",
|
|
19
|
+
anchor: { quote: "q", prefix: "", suffix: "" },
|
|
20
|
+
body: id,
|
|
21
|
+
createdAt: AT,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** rev1 (agent) -> comment a1 -> reviewer edit -> comment a2, all on main. */
|
|
26
|
+
function threaded(): SessionHistory {
|
|
27
|
+
let history = historyFromLinear({
|
|
28
|
+
id: "ses_1",
|
|
29
|
+
revisions: [{ revision: 1, content: "Plan v1", submittedAt: AT }],
|
|
30
|
+
annotations: [],
|
|
31
|
+
verdict: null,
|
|
32
|
+
createdAt: AT,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
history = appendEntry(history, { type: "comment", annotationId: "a1", createdAt: AT }).history;
|
|
36
|
+
history = appendEntry(history, {
|
|
37
|
+
type: "revision",
|
|
38
|
+
by: "reviewer",
|
|
39
|
+
content: "Plan v1 edited",
|
|
40
|
+
createdAt: AT,
|
|
41
|
+
}).history;
|
|
42
|
+
history = appendEntry(history, { type: "comment", annotationId: "a2", createdAt: AT }).history;
|
|
43
|
+
|
|
44
|
+
return history;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("viewOfPath", () => {
|
|
48
|
+
test("the artifact is the agent's last revision and the reviewer's head is the working copy", () => {
|
|
49
|
+
// Act
|
|
50
|
+
const view = viewOfPath(threaded(), [annotation("a1"), annotation("a2")]);
|
|
51
|
+
|
|
52
|
+
// Assert
|
|
53
|
+
expect(view.content).toBe("Plan v1");
|
|
54
|
+
expect(view.workingCopy).toBe("Plan v1 edited");
|
|
55
|
+
expect(view.annotations.map((entry) => entry.id)).toEqual(["a1", "a2"]);
|
|
56
|
+
expect(view.shelvedAnnotations).toEqual([]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("navigating back shelves the comments the path no longer reaches, and forward again unshelves them", () => {
|
|
60
|
+
// Arrange: a branch that stays at the full path, main moved back to the first comment
|
|
61
|
+
let history = createBranch(threaded(), "full");
|
|
62
|
+
const known = [annotation("a1"), annotation("a2")];
|
|
63
|
+
|
|
64
|
+
history = switchBranch(history, "main");
|
|
65
|
+
history = navigateTo(history, history.entries[1]!.id);
|
|
66
|
+
|
|
67
|
+
// Act
|
|
68
|
+
const back = viewOfPath(history, known);
|
|
69
|
+
const forward = viewOfPath(
|
|
70
|
+
switchBranch(history, "full"),
|
|
71
|
+
back.annotations.concat(back.shelvedAnnotations),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
// Assert
|
|
75
|
+
expect(back.workingCopy).toBeUndefined();
|
|
76
|
+
expect(back.annotations.map((entry) => entry.id)).toEqual(["a1"]);
|
|
77
|
+
expect(back.shelvedAnnotations.map((entry) => entry.id)).toEqual(["a2"]);
|
|
78
|
+
expect(forward.annotations.map((entry) => entry.id)).toEqual(["a1", "a2"]);
|
|
79
|
+
expect(forward.shelvedAnnotations).toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("a removed comment stays shelved, not deleted", () => {
|
|
83
|
+
// Arrange
|
|
84
|
+
let history = threaded();
|
|
85
|
+
|
|
86
|
+
history = appendEntry(history, {
|
|
87
|
+
type: "comment-removed",
|
|
88
|
+
annotationId: "a1",
|
|
89
|
+
createdAt: AT,
|
|
90
|
+
}).history;
|
|
91
|
+
|
|
92
|
+
// Act
|
|
93
|
+
const view = viewOfPath(history, [annotation("a1"), annotation("a2")]);
|
|
94
|
+
|
|
95
|
+
// Assert
|
|
96
|
+
expect(view.annotations.map((entry) => entry.id)).toEqual(["a2"]);
|
|
97
|
+
expect(view.shelvedAnnotations.map((entry) => entry.id)).toEqual(["a1"]);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("applyPathView", () => {
|
|
102
|
+
test("writes the view into the record and removes the fields the view leaves empty", () => {
|
|
103
|
+
// Arrange
|
|
104
|
+
const session: ReviewSession = {
|
|
105
|
+
schemaVersion: "1",
|
|
106
|
+
id: "ses_1",
|
|
107
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
108
|
+
artifact: { type: "plan", content: "old", meta: {} },
|
|
109
|
+
revisions: [],
|
|
110
|
+
annotations: [annotation("a2")],
|
|
111
|
+
shelvedAnnotations: [annotation("a1")],
|
|
112
|
+
workingCopy: "edited",
|
|
113
|
+
verdict: null,
|
|
114
|
+
status: "pending",
|
|
115
|
+
createdAt: AT,
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// Act
|
|
119
|
+
applyPathView(session, {
|
|
120
|
+
content: "Plan v1",
|
|
121
|
+
workingCopy: undefined,
|
|
122
|
+
annotations: [annotation("a1"), annotation("a2")],
|
|
123
|
+
shelvedAnnotations: [],
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Assert
|
|
127
|
+
expect(session.artifact.content).toBe("Plan v1");
|
|
128
|
+
expect(session.annotations.map((entry) => entry.id)).toEqual(["a1", "a2"]);
|
|
129
|
+
expect("workingCopy" in session).toBe(false);
|
|
130
|
+
expect("shelvedAnnotations" in session).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("viewFollowing", () => {
|
|
135
|
+
test("a share carries one branch's path: its entries, tip, labels, and open comments, nothing shelved", () => {
|
|
136
|
+
// Arrange: main has a1 and an edit; a branch off a1 has its own comment; the owner stands on the branch
|
|
137
|
+
let history = threaded();
|
|
138
|
+
|
|
139
|
+
history = navigateTo(history, history.entries[1]!.id);
|
|
140
|
+
history = createBranch(history, "alt");
|
|
141
|
+
history = appendEntry(history, { type: "comment", annotationId: "b1", createdAt: AT }).history;
|
|
142
|
+
const session: ReviewSession = {
|
|
143
|
+
schemaVersion: "1",
|
|
144
|
+
id: "ses_1",
|
|
145
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
146
|
+
artifact: { type: "plan", content: "Plan v1", meta: {} },
|
|
147
|
+
revisions: [],
|
|
148
|
+
annotations: [annotation("a1"), annotation("b1")],
|
|
149
|
+
shelvedAnnotations: [annotation("a2")],
|
|
150
|
+
workingCopy: "Plan v1 edited",
|
|
151
|
+
history,
|
|
152
|
+
verdict: null,
|
|
153
|
+
status: "pending",
|
|
154
|
+
createdAt: AT,
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// Act
|
|
158
|
+
const shared = viewFollowing(session);
|
|
159
|
+
|
|
160
|
+
// Assert: main's path only, back at the first comment, no working copy, no shelf, the branch named
|
|
161
|
+
expect(shared.shareBranch).toBe("main");
|
|
162
|
+
expect(shared.history!.tips).toEqual({ main: history.entries[1]!.id });
|
|
163
|
+
expect(shared.history!.entries.map((entry) => entry.type)).toEqual(["revision", "comment"]);
|
|
164
|
+
expect(shared.annotations.map((entry) => entry.id)).toEqual(["a1"]);
|
|
165
|
+
expect("shelvedAnnotations" in shared).toBe(false);
|
|
166
|
+
expect("workingCopy" in shared).toBe(false);
|
|
167
|
+
// the owner's record is untouched
|
|
168
|
+
expect(session.annotations.map((entry) => entry.id)).toEqual(["a1", "b1"]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("a followed branch that no longer exists falls back to main", () => {
|
|
172
|
+
// Arrange: a plain main-only history, asked to follow a branch that is not there
|
|
173
|
+
const history = historyFromLinear({
|
|
174
|
+
id: "ses_1",
|
|
175
|
+
revisions: [{ revision: 1, content: "Plan v1", submittedAt: AT }],
|
|
176
|
+
annotations: [],
|
|
177
|
+
verdict: null,
|
|
178
|
+
createdAt: AT,
|
|
179
|
+
});
|
|
180
|
+
const session: ReviewSession = {
|
|
181
|
+
schemaVersion: "1",
|
|
182
|
+
id: "ses_1",
|
|
183
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
184
|
+
artifact: { type: "plan", content: "Plan v1", meta: {} },
|
|
185
|
+
revisions: [],
|
|
186
|
+
annotations: [],
|
|
187
|
+
shareBranch: "gone",
|
|
188
|
+
history,
|
|
189
|
+
verdict: null,
|
|
190
|
+
status: "pending",
|
|
191
|
+
createdAt: AT,
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// Act
|
|
195
|
+
const shared = viewFollowing(session);
|
|
196
|
+
|
|
197
|
+
// Assert: it does not throw, and falls back to main
|
|
198
|
+
expect(shared.shareBranch).toBe("main");
|
|
199
|
+
expect(shared.history!.branch).toBe("main");
|
|
200
|
+
});
|
|
201
|
+
});
|