@cueloop/daemon 0.1.0-alpha.64 → 0.1.0-alpha.66
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 +5 -3
- package/src/api.test.ts +479 -1
- package/src/api.ts +452 -27
- package/src/capabilities.test.ts +28 -9
- package/src/capabilities.ts +52 -19
- package/src/client.ts +126 -18
- package/src/curate.ts +109 -0
- package/src/diff-hot-reload.test.ts +16 -0
- package/src/herdr-pane.test.ts +1 -1
- package/src/paths.ts +5 -0
- package/src/protocol.ts +3 -0
- package/src/review.ts +25 -4
- package/src/server.test.ts +229 -2
- package/src/server.ts +90 -7
- package/src/share-blob.test.ts +51 -1
- package/src/store.conformance.test.ts +41 -0
- package/src/store.ts +89 -12
- package/src/testing/store-conformance.ts +194 -0
- package/src/validate.test.ts +61 -1
- package/src/validate.ts +103 -1
package/src/api.ts
CHANGED
|
@@ -7,29 +7,56 @@
|
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
9
|
SCHEMA_VERSION,
|
|
10
|
+
appendEntry,
|
|
11
|
+
applyPathView,
|
|
12
|
+
createBranch,
|
|
13
|
+
cutBlock,
|
|
10
14
|
feedbackForSession,
|
|
15
|
+
forkHistory,
|
|
16
|
+
historyFromLinear,
|
|
17
|
+
HistoryError,
|
|
18
|
+
labelTip,
|
|
19
|
+
navigateTo,
|
|
20
|
+
recaptureMainHead,
|
|
21
|
+
viewOfPath,
|
|
11
22
|
isAddressed,
|
|
12
23
|
isAgentNote,
|
|
13
24
|
isMarkdownArtifact,
|
|
25
|
+
MAIN_BRANCH,
|
|
14
26
|
parseBlocks,
|
|
15
27
|
registerParticipant,
|
|
16
28
|
resolveAnchor,
|
|
29
|
+
isBlockCut,
|
|
30
|
+
restoreBlock,
|
|
31
|
+
switchBranch,
|
|
17
32
|
verdictAllows,
|
|
18
33
|
type Annotation,
|
|
34
|
+
type HunkRejection,
|
|
35
|
+
type NewEntry,
|
|
19
36
|
type Artifact,
|
|
20
37
|
type Identity,
|
|
21
38
|
type ReviewSession,
|
|
39
|
+
type SessionHistory,
|
|
22
40
|
type Verdict,
|
|
23
41
|
type VerdictKind,
|
|
24
42
|
type WorkspaceKey,
|
|
25
43
|
} from "@cueloop/schema";
|
|
26
|
-
import {
|
|
44
|
+
import { curateDiff } from "./curate";
|
|
45
|
+
import { SessionStore, withHistory } from "./store";
|
|
27
46
|
import { pruneExpiredSessions, resolveCleanupPeriodDays } from "./retention";
|
|
28
47
|
import { HerdrTabStore, type HerdrTabHandle } from "./herdr-tab-store";
|
|
29
48
|
import { DiffWatcher } from "./diff-watcher";
|
|
30
49
|
import { workingTreeDiff } from "./working-tree";
|
|
31
50
|
import { DaemonError } from "./errors";
|
|
32
51
|
|
|
52
|
+
/** What a share hands back: the notes and names it collected, and the removals it recorded. */
|
|
53
|
+
export interface SharedMerge {
|
|
54
|
+
annotations: Annotation[];
|
|
55
|
+
participants?: Identity[];
|
|
56
|
+
/** Removal entries by id; a merge applies each once and never rewrites what it already holds. */
|
|
57
|
+
removals?: Array<{ id: string; annotationId: string; createdAt: string }>;
|
|
58
|
+
}
|
|
59
|
+
|
|
33
60
|
export type EventName =
|
|
34
61
|
| "session.created"
|
|
35
62
|
| "session.updated"
|
|
@@ -40,6 +67,8 @@ export type EventName =
|
|
|
40
67
|
export interface DaemonEvent {
|
|
41
68
|
event: EventName;
|
|
42
69
|
sessionId: string;
|
|
70
|
+
/** The history entry the change appended, when it appended one. */
|
|
71
|
+
entryId?: string;
|
|
43
72
|
}
|
|
44
73
|
|
|
45
74
|
type EventListener = (event: DaemonEvent) => void;
|
|
@@ -89,8 +118,11 @@ export class DaemonCore {
|
|
|
89
118
|
return () => this.listeners.delete(listener);
|
|
90
119
|
}
|
|
91
120
|
|
|
92
|
-
private emit(event: EventName, sessionId: string): void {
|
|
93
|
-
|
|
121
|
+
private emit(event: EventName, sessionId: string, entryId?: string): void {
|
|
122
|
+
const frame: DaemonEvent =
|
|
123
|
+
entryId === undefined ? { event, sessionId } : { event, sessionId, entryId };
|
|
124
|
+
|
|
125
|
+
for (const listener of this.listeners) listener(frame);
|
|
94
126
|
}
|
|
95
127
|
|
|
96
128
|
/** True when nothing awaits a verdict - drives idle-exit. */
|
|
@@ -102,7 +134,7 @@ export class DaemonCore {
|
|
|
102
134
|
const now = new Date().toISOString();
|
|
103
135
|
const session: ReviewSession = {
|
|
104
136
|
schemaVersion: SCHEMA_VERSION,
|
|
105
|
-
id:
|
|
137
|
+
id: this.newSessionId(now),
|
|
106
138
|
workspace: params.workspace,
|
|
107
139
|
artifact: params.artifact,
|
|
108
140
|
revisions: [{ revision: 1, content: params.artifact.content, submittedAt: now }],
|
|
@@ -112,6 +144,7 @@ export class DaemonCore {
|
|
|
112
144
|
createdAt: now,
|
|
113
145
|
};
|
|
114
146
|
|
|
147
|
+
session.history = historyFromLinear(session);
|
|
115
148
|
this.store.upsert(session);
|
|
116
149
|
this.watchIfDiffSession(session);
|
|
117
150
|
this.emit("session.created", session.id);
|
|
@@ -120,6 +153,10 @@ export class DaemonCore {
|
|
|
120
153
|
return session;
|
|
121
154
|
}
|
|
122
155
|
|
|
156
|
+
private newSessionId(now: string): string {
|
|
157
|
+
return `ses_${now.replace(/\D/g, "").slice(0, 14)}_${(++this.seq).toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
123
160
|
sessionGet(id: string): ReviewSession {
|
|
124
161
|
const session = this.store.get(id);
|
|
125
162
|
|
|
@@ -189,8 +226,16 @@ export class DaemonCore {
|
|
|
189
226
|
const existing = session.annotations.findIndex((candidate) => candidate.id === annotation.id);
|
|
190
227
|
const full: Annotation = { ...annotation, createdAt: new Date().toISOString() };
|
|
191
228
|
|
|
192
|
-
|
|
193
|
-
|
|
229
|
+
let entryId: string | undefined;
|
|
230
|
+
|
|
231
|
+
if (existing === -1) {
|
|
232
|
+
session.annotations.push(full);
|
|
233
|
+
entryId = this.record(session, {
|
|
234
|
+
type: "comment",
|
|
235
|
+
annotationId: full.id,
|
|
236
|
+
createdAt: full.createdAt,
|
|
237
|
+
});
|
|
238
|
+
} else
|
|
194
239
|
session.annotations[existing] = {
|
|
195
240
|
...full,
|
|
196
241
|
createdAt: session.annotations[existing]!.createdAt,
|
|
@@ -202,15 +247,47 @@ export class DaemonCore {
|
|
|
202
247
|
authorName,
|
|
203
248
|
).participants;
|
|
204
249
|
this.store.upsert(session);
|
|
205
|
-
this.emit("session.updated", id);
|
|
250
|
+
this.emit("session.updated", id, entryId);
|
|
206
251
|
|
|
207
252
|
return session;
|
|
208
253
|
}
|
|
209
254
|
|
|
210
|
-
|
|
255
|
+
/**
|
|
256
|
+
* Remove a comment. With `onBehalfOf`, the caller is a collaborator or an
|
|
257
|
+
* agent acting as that author and may remove only that author's comments;
|
|
258
|
+
* the owner (no `onBehalfOf`) may remove any.
|
|
259
|
+
*/
|
|
260
|
+
sessionRemoveAnnotation(id: string, annotationId: string, onBehalfOf?: string): ReviewSession {
|
|
211
261
|
const session = this.mutable(id);
|
|
262
|
+
const target = session.annotations.find((candidate) => candidate.id === annotationId);
|
|
212
263
|
|
|
264
|
+
if (onBehalfOf !== undefined && target !== undefined && target.author !== onBehalfOf) {
|
|
265
|
+
throw new DaemonError("forbidden", `${onBehalfOf} cannot remove another author's comment`);
|
|
266
|
+
}
|
|
213
267
|
session.annotations = session.annotations.filter((candidate) => candidate.id !== annotationId);
|
|
268
|
+
let entryId: string | undefined;
|
|
269
|
+
|
|
270
|
+
if (target !== undefined) {
|
|
271
|
+
// nothing is deleted: a navigate back before the removal shows it again
|
|
272
|
+
session.shelvedAnnotations = [...(session.shelvedAnnotations ?? []), target];
|
|
273
|
+
entryId = this.record(session, {
|
|
274
|
+
type: "comment-removed",
|
|
275
|
+
annotationId,
|
|
276
|
+
createdAt: new Date().toISOString(),
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
this.store.upsert(session);
|
|
281
|
+
this.emit("session.updated", id, entryId);
|
|
282
|
+
|
|
283
|
+
return session;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Register a display name for a participant; how a collaborator or an agent names itself. */
|
|
287
|
+
sessionSetParticipantName(id: string, author: string, name: string): ReviewSession {
|
|
288
|
+
const session = this.mutable(id);
|
|
289
|
+
|
|
290
|
+
session.participants = registerParticipant(session, author, name).participants;
|
|
214
291
|
this.store.upsert(session);
|
|
215
292
|
this.emit("session.updated", id);
|
|
216
293
|
|
|
@@ -220,12 +297,10 @@ export class DaemonCore {
|
|
|
220
297
|
/** The reviewer's working copy; undefined clears it (revert all edits). */
|
|
221
298
|
sessionSetWorkingCopy(id: string, workingCopy: string | undefined): ReviewSession {
|
|
222
299
|
const session = this.mutable(id);
|
|
300
|
+
const entryId = this.applyWorkingCopy(session, workingCopy);
|
|
223
301
|
|
|
224
|
-
if (workingCopy === undefined || workingCopy === session.artifact.content)
|
|
225
|
-
delete session.workingCopy;
|
|
226
|
-
else session.workingCopy = workingCopy;
|
|
227
302
|
this.store.upsert(session);
|
|
228
|
-
this.emit("session.updated", id);
|
|
303
|
+
this.emit("session.updated", id, entryId);
|
|
229
304
|
|
|
230
305
|
return session;
|
|
231
306
|
}
|
|
@@ -268,21 +343,125 @@ export class DaemonCore {
|
|
|
268
343
|
}
|
|
269
344
|
|
|
270
345
|
/**
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
* registry union by id with the incoming identity winning (a collaborator is
|
|
274
|
-
* the authority on their own name). This is how a teammate's name reaches the
|
|
275
|
-
* planner after a pull, alongside their notes.
|
|
346
|
+
* Cut one block of the reviewer's working copy - the `blockIndex`-th block
|
|
347
|
+
* of the working text - so it serializes into the diff the agent receives.
|
|
276
348
|
*/
|
|
277
|
-
|
|
278
|
-
id
|
|
279
|
-
|
|
280
|
-
|
|
349
|
+
sessionCutBlock(id: string, blockIndex: number): ReviewSession {
|
|
350
|
+
const session = this.mutable(id);
|
|
351
|
+
const working = session.workingCopy ?? session.artifact.content;
|
|
352
|
+
const block = parseBlocks(working)[blockIndex];
|
|
353
|
+
|
|
354
|
+
if (!block)
|
|
355
|
+
throw new DaemonError("invalid_params", `no block ${blockIndex} in the working copy`);
|
|
356
|
+
const entryId = this.applyWorkingCopy(session, cutBlock(working, block));
|
|
357
|
+
|
|
358
|
+
this.store.upsert(session);
|
|
359
|
+
this.emit("session.updated", id, entryId);
|
|
360
|
+
|
|
361
|
+
return session;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Re-insert a cut block - the `baseBlockIndex`-th block of the submitted
|
|
366
|
+
* revision - into the working copy before `line` (default: the end). A copy
|
|
367
|
+
* that reads as the submitted revision again is dropped, not stored.
|
|
368
|
+
*/
|
|
369
|
+
sessionRestoreBlock(id: string, baseBlockIndex: number, line?: number): ReviewSession {
|
|
370
|
+
const session = this.mutable(id);
|
|
371
|
+
const base = session.artifact.content;
|
|
372
|
+
const block = parseBlocks(base)[baseBlockIndex];
|
|
373
|
+
|
|
374
|
+
if (!block) {
|
|
375
|
+
throw new DaemonError(
|
|
376
|
+
"invalid_params",
|
|
377
|
+
`no block ${baseBlockIndex} in the submitted revision`,
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
const working = session.workingCopy ?? base;
|
|
381
|
+
|
|
382
|
+
if (!isBlockCut(base, working, block)) {
|
|
383
|
+
throw new DaemonError(
|
|
384
|
+
"invalid_params",
|
|
385
|
+
`block ${baseBlockIndex} of the submitted revision is present in the working copy`,
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
const beforeLine = Math.min(line ?? working.split("\n").length, working.split("\n").length);
|
|
389
|
+
const entryId = this.applyWorkingCopy(session, restoreBlock(base, working, block, beforeLine));
|
|
390
|
+
|
|
391
|
+
this.store.upsert(session);
|
|
392
|
+
this.emit("session.updated", id, entryId);
|
|
393
|
+
|
|
394
|
+
return session;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Replace a diff review's reject decisions; the working copy becomes the
|
|
399
|
+
* patch they leave, or clears when nothing is rejected. Needs the full file
|
|
400
|
+
* contents a working-tree diff carries; a PR diff cannot be curated.
|
|
401
|
+
*/
|
|
402
|
+
sessionCurate(id: string, rejections: HunkRejection[]): ReviewSession {
|
|
281
403
|
const session = this.mutable(id);
|
|
282
|
-
const known = new Set(session.annotations.map((annotation) => annotation.id));
|
|
283
404
|
|
|
284
|
-
|
|
285
|
-
|
|
405
|
+
if (session.artifact.type !== "diff") {
|
|
406
|
+
throw new DaemonError("invalid_params", "only a diff review is curated by hunk");
|
|
407
|
+
}
|
|
408
|
+
if (!session.artifact.files) {
|
|
409
|
+
throw new DaemonError("invalid_params", "hunk curation needs full file contents");
|
|
410
|
+
}
|
|
411
|
+
if (rejections.length === 0) delete session.curation;
|
|
412
|
+
else session.curation = rejections;
|
|
413
|
+
const entryId = this.applyWorkingCopy(
|
|
414
|
+
session,
|
|
415
|
+
rejections.length === 0 ? undefined : curateDiff(session.artifact.files, rejections),
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
this.store.upsert(session);
|
|
419
|
+
this.emit("session.updated", id, entryId);
|
|
420
|
+
|
|
421
|
+
return session;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Merge a share's state back into the local session. Comments union by
|
|
426
|
+
* annotation id with existing ones (the planner's) winning; removals union by
|
|
427
|
+
* entry id, each shelving its comment once; the participant registry unions
|
|
428
|
+
* by id with the incoming identity winning (a collaborator is the authority
|
|
429
|
+
* on their own name). Everything lands on the branch the share follows, and
|
|
430
|
+
* the record then shows its current path again, wherever the owner stands.
|
|
431
|
+
*/
|
|
432
|
+
sessionMergeShared(id: string, incoming: SharedMerge): ReviewSession {
|
|
433
|
+
const session = this.mutable(id);
|
|
434
|
+
const branch = this.shareBranchOf(session);
|
|
435
|
+
const known = new Set(
|
|
436
|
+
[...session.annotations, ...(session.shelvedAnnotations ?? [])].map(
|
|
437
|
+
(annotation) => annotation.id,
|
|
438
|
+
),
|
|
439
|
+
);
|
|
440
|
+
let changed = false;
|
|
441
|
+
|
|
442
|
+
for (const annotation of incoming.annotations) {
|
|
443
|
+
if (known.has(annotation.id)) continue;
|
|
444
|
+
known.add(annotation.id);
|
|
445
|
+
session.annotations.push(annotation);
|
|
446
|
+
this.recordOn(session, branch, {
|
|
447
|
+
type: "comment",
|
|
448
|
+
annotationId: annotation.id,
|
|
449
|
+
createdAt: annotation.createdAt,
|
|
450
|
+
});
|
|
451
|
+
changed = true;
|
|
452
|
+
}
|
|
453
|
+
const recorded = new Set((session.history?.entries ?? []).map((entry) => entry.id));
|
|
454
|
+
|
|
455
|
+
for (const removal of incoming.removals ?? []) {
|
|
456
|
+
if (recorded.has(removal.id) || !known.has(removal.annotationId)) continue;
|
|
457
|
+
this.recordOn(session, branch, {
|
|
458
|
+
id: removal.id,
|
|
459
|
+
type: "comment-removed",
|
|
460
|
+
annotationId: removal.annotationId,
|
|
461
|
+
createdAt: removal.createdAt,
|
|
462
|
+
});
|
|
463
|
+
changed = true;
|
|
464
|
+
}
|
|
286
465
|
if (incoming.participants?.length) {
|
|
287
466
|
const registry = new Map(
|
|
288
467
|
(session.participants ?? []).map((participant) => [participant.id, participant]),
|
|
@@ -291,7 +470,8 @@ export class DaemonCore {
|
|
|
291
470
|
for (const participant of incoming.participants) registry.set(participant.id, participant);
|
|
292
471
|
session.participants = [...registry.values()];
|
|
293
472
|
}
|
|
294
|
-
this.
|
|
473
|
+
if (changed && session.history) this.refreshView(session, session.history);
|
|
474
|
+
else this.store.upsert(session);
|
|
295
475
|
this.emit("session.updated", id);
|
|
296
476
|
|
|
297
477
|
return session;
|
|
@@ -308,6 +488,11 @@ export class DaemonCore {
|
|
|
308
488
|
|
|
309
489
|
session.verdict = verdict;
|
|
310
490
|
session.status = "resolved";
|
|
491
|
+
const entryId = this.record(session, {
|
|
492
|
+
type: "verdict",
|
|
493
|
+
verdict,
|
|
494
|
+
createdAt: verdict.resolvedAt,
|
|
495
|
+
});
|
|
311
496
|
this.store.upsert(session);
|
|
312
497
|
// a resolved diff review is frozen; stop hot-reloading its working tree
|
|
313
498
|
this.unwatchIfDiffSession(session);
|
|
@@ -315,7 +500,7 @@ export class DaemonCore {
|
|
|
315
500
|
|
|
316
501
|
this.waiters.delete(id);
|
|
317
502
|
for (const parkedWaiter of parked) parkedWaiter(session);
|
|
318
|
-
this.emit("session.resolved", id);
|
|
503
|
+
this.emit("session.resolved", id, entryId);
|
|
319
504
|
this.emit("inbox.changed", id);
|
|
320
505
|
|
|
321
506
|
return session;
|
|
@@ -342,11 +527,27 @@ export class DaemonCore {
|
|
|
342
527
|
|
|
343
528
|
session.revisions.push({ revision: revisionNumber, content, submittedAt: now });
|
|
344
529
|
session.artifact = { ...session.artifact, content };
|
|
530
|
+
// the agent's revision lands on main wherever its tip sits; the artifact
|
|
531
|
+
// shows the head of the branch the reviewer is on
|
|
532
|
+
const entryId = this.recordOnMain(session, {
|
|
533
|
+
type: "revision",
|
|
534
|
+
by: "agent",
|
|
535
|
+
content,
|
|
536
|
+
createdAt: now,
|
|
537
|
+
});
|
|
345
538
|
delete session.workingCopy;
|
|
346
539
|
session.verdict = null;
|
|
347
540
|
session.status = "pending";
|
|
348
541
|
|
|
542
|
+
// a reported root comment addresses its whole discussion: replies are
|
|
543
|
+
// never listed on their own in the feedback document
|
|
349
544
|
const reportedIds = new Set(addressedAnnotationIds);
|
|
545
|
+
|
|
546
|
+
for (const annotation of session.annotations) {
|
|
547
|
+
if (annotation.replyTo !== undefined && reportedIds.has(annotation.replyTo)) {
|
|
548
|
+
reportedIds.add(annotation.id);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
350
551
|
// drift assist applies to markdown artifacts (plan, reply) only: a diff
|
|
351
552
|
// revision is a whole new patch, where a vanished quote says nothing about
|
|
352
553
|
// the feedback
|
|
@@ -365,12 +566,115 @@ export class DaemonCore {
|
|
|
365
566
|
}
|
|
366
567
|
|
|
367
568
|
this.store.upsert(session);
|
|
368
|
-
this.emit("session.revised", id);
|
|
569
|
+
this.emit("session.revised", id, entryId);
|
|
369
570
|
this.emit("inbox.changed", id);
|
|
370
571
|
|
|
371
572
|
return session;
|
|
372
573
|
}
|
|
373
574
|
|
|
575
|
+
/**
|
|
576
|
+
* Move a branch's tip back to an entry on its path - the current branch, or
|
|
577
|
+
* `branch` after switching to it. The entries after it stay in the tree; a
|
|
578
|
+
* summary records them as a branch-summary entry at the target. The view
|
|
579
|
+
* follows the path: on `main`, the agent's next revision lands there.
|
|
580
|
+
*/
|
|
581
|
+
sessionNavigate(id: string, entryId: string, summary?: string, branch?: string): ReviewSession {
|
|
582
|
+
const session = this.mutable(id);
|
|
583
|
+
const moved = this.tree(id, () => {
|
|
584
|
+
const history = this.historyOf(session);
|
|
585
|
+
const standing = branch === undefined ? history : switchBranch(history, branch);
|
|
586
|
+
|
|
587
|
+
return navigateTo(standing, entryId, summary === undefined ? {} : { summary });
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
this.refreshView(session, moved);
|
|
591
|
+
const tip = moved.tips[moved.branch];
|
|
592
|
+
|
|
593
|
+
this.emit("session.updated", id, tip === entryId ? undefined : tip);
|
|
594
|
+
|
|
595
|
+
return session;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** Start a branch at the current tip and switch to it; `main` stays where it is. */
|
|
599
|
+
sessionBranch(id: string, name: string): ReviewSession {
|
|
600
|
+
const session = this.mutable(id);
|
|
601
|
+
|
|
602
|
+
this.refreshView(
|
|
603
|
+
session,
|
|
604
|
+
this.tree(id, () => createBranch(this.historyOf(session), name)),
|
|
605
|
+
);
|
|
606
|
+
this.emit("session.updated", id);
|
|
607
|
+
|
|
608
|
+
return session;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
sessionSwitch(id: string, branch: string): ReviewSession {
|
|
612
|
+
const session = this.mutable(id);
|
|
613
|
+
|
|
614
|
+
this.refreshView(
|
|
615
|
+
session,
|
|
616
|
+
this.tree(id, () => switchBranch(this.historyOf(session), branch)),
|
|
617
|
+
);
|
|
618
|
+
this.emit("session.updated", id);
|
|
619
|
+
|
|
620
|
+
return session;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** Name the current tip as a checkpoint to navigate back to. */
|
|
624
|
+
sessionLabel(id: string, label: string): ReviewSession {
|
|
625
|
+
const session = this.mutable(id);
|
|
626
|
+
|
|
627
|
+
session.history = labelTip(this.historyOf(session), label);
|
|
628
|
+
this.store.upsert(session);
|
|
629
|
+
this.emit("session.updated", id);
|
|
630
|
+
|
|
631
|
+
return session;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Copy the current path into a new pending session: its revisions, open
|
|
636
|
+
* comments, labels, and participant names travel; verdicts, edits, and the
|
|
637
|
+
* share do not. A resolved session can be forked.
|
|
638
|
+
*/
|
|
639
|
+
sessionFork(id: string): ReviewSession {
|
|
640
|
+
const source = this.sessionGet(id);
|
|
641
|
+
const history = this.tree(id, () => forkHistory(this.historyOf(source)));
|
|
642
|
+
const now = new Date().toISOString();
|
|
643
|
+
const known = [...source.annotations, ...(source.shelvedAnnotations ?? [])];
|
|
644
|
+
const view = viewOfPath(history, known);
|
|
645
|
+
const fork: ReviewSession = {
|
|
646
|
+
schemaVersion: SCHEMA_VERSION,
|
|
647
|
+
id: this.newSessionId(now),
|
|
648
|
+
workspace: source.workspace,
|
|
649
|
+
artifact: { ...source.artifact, content: view.content },
|
|
650
|
+
revisions: history.entries
|
|
651
|
+
.filter((entry) => entry.type === "revision")
|
|
652
|
+
.map((entry, index) => ({
|
|
653
|
+
revision: index + 1,
|
|
654
|
+
content: entry.content,
|
|
655
|
+
submittedAt: entry.createdAt,
|
|
656
|
+
})),
|
|
657
|
+
annotations: [],
|
|
658
|
+
history,
|
|
659
|
+
verdict: null,
|
|
660
|
+
status: "pending",
|
|
661
|
+
createdAt: now,
|
|
662
|
+
parentSessionId: id,
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
fork.annotations = view.annotations.map((annotation) =>
|
|
666
|
+
forkedAnnotation(annotation, source, fork),
|
|
667
|
+
);
|
|
668
|
+
if (source.participants)
|
|
669
|
+
fork.participants = source.participants.map((identity) => ({ ...identity }));
|
|
670
|
+
this.store.upsert(fork);
|
|
671
|
+
this.watchIfDiffSession(fork);
|
|
672
|
+
this.emit("session.created", fork.id);
|
|
673
|
+
this.emit("inbox.changed", fork.id);
|
|
674
|
+
|
|
675
|
+
return fork;
|
|
676
|
+
}
|
|
677
|
+
|
|
374
678
|
/** Re-capture a diff session's working tree; broadcasts session.updated only when the patch moved. */
|
|
375
679
|
async sessionRefreshDiff(id: string): Promise<{ changed: boolean }> {
|
|
376
680
|
const session = this.mutable(id);
|
|
@@ -392,6 +696,10 @@ export class DaemonCore {
|
|
|
392
696
|
return { changed: false };
|
|
393
697
|
if (diff.patch === current.artifact.content) return { changed: false };
|
|
394
698
|
current.artifact = { ...current.artifact, content: diff.patch, files: diff.files };
|
|
699
|
+
// the history keeps showing the same revision, re-captured, so a later tree move derives this patch
|
|
700
|
+
const history = withHistory(current).history;
|
|
701
|
+
|
|
702
|
+
if (history) current.history = recaptureMainHead(history, diff.patch);
|
|
395
703
|
this.store.upsert(current);
|
|
396
704
|
this.emit("session.updated", id);
|
|
397
705
|
|
|
@@ -424,6 +732,99 @@ export class DaemonCore {
|
|
|
424
732
|
this.diffWatcher.untrackDiffRepo(session.workspace.repoRoot, session.id);
|
|
425
733
|
}
|
|
426
734
|
|
|
735
|
+
/**
|
|
736
|
+
* Set or clear the working copy and, when the reviewer's text changed, record
|
|
737
|
+
* it as a reviewer revision on the current branch. Returns that entry's id.
|
|
738
|
+
*/
|
|
739
|
+
private applyWorkingCopy(
|
|
740
|
+
session: ReviewSession,
|
|
741
|
+
workingCopy: string | undefined,
|
|
742
|
+
): string | undefined {
|
|
743
|
+
const before = session.workingCopy ?? session.artifact.content;
|
|
744
|
+
const next =
|
|
745
|
+
workingCopy === undefined || workingCopy === session.artifact.content
|
|
746
|
+
? undefined
|
|
747
|
+
: workingCopy;
|
|
748
|
+
|
|
749
|
+
if (next === undefined) delete session.workingCopy;
|
|
750
|
+
else session.workingCopy = next;
|
|
751
|
+
const after = next ?? session.artifact.content;
|
|
752
|
+
|
|
753
|
+
if (after === before) return undefined;
|
|
754
|
+
|
|
755
|
+
return this.record(session, {
|
|
756
|
+
type: "revision",
|
|
757
|
+
by: "reviewer",
|
|
758
|
+
content: after,
|
|
759
|
+
createdAt: new Date().toISOString(),
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/** The session's history; a record without a revision has none and cannot be moved through. */
|
|
764
|
+
private historyOf(session: ReviewSession): SessionHistory {
|
|
765
|
+
const history = withHistory(session).history;
|
|
766
|
+
|
|
767
|
+
if (!history) throw new DaemonError("invalid_params", `session ${session.id} has no history`);
|
|
768
|
+
|
|
769
|
+
return history;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/** Run a tree operation; a refused move is the caller's mistake, not the daemon's. */
|
|
773
|
+
private tree(id: string, operation: () => SessionHistory): SessionHistory {
|
|
774
|
+
try {
|
|
775
|
+
return operation();
|
|
776
|
+
} catch (error) {
|
|
777
|
+
if (error instanceof HistoryError) throw new DaemonError("invalid_params", error.message);
|
|
778
|
+
throw error;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/** Store a moved history and make the record show its active path. */
|
|
783
|
+
private refreshView(session: ReviewSession, history: SessionHistory): void {
|
|
784
|
+
session.history = history;
|
|
785
|
+
applyPathView(
|
|
786
|
+
session,
|
|
787
|
+
viewOfPath(history, [...session.annotations, ...(session.shelvedAnnotations ?? [])]),
|
|
788
|
+
);
|
|
789
|
+
this.store.upsert(session);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/** Append an entry on the session's current branch; a session without a head has no history to extend. */
|
|
793
|
+
private record(session: ReviewSession, entry: NewEntry): string | undefined {
|
|
794
|
+
const history = withHistory(session).history;
|
|
795
|
+
|
|
796
|
+
if (!history) return undefined;
|
|
797
|
+
const appended = appendEntry(history, entry);
|
|
798
|
+
|
|
799
|
+
session.history = appended.history;
|
|
800
|
+
|
|
801
|
+
return appended.entry.id;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/** Append an entry on main, leaving the reviewer's current branch where it is. */
|
|
805
|
+
private recordOnMain(session: ReviewSession, entry: NewEntry): string | undefined {
|
|
806
|
+
return this.recordOn(session, MAIN_BRANCH, entry);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/** Append an entry on a named branch, leaving the reviewer's current branch where it is. */
|
|
810
|
+
private recordOn(session: ReviewSession, branch: string, entry: NewEntry): string | undefined {
|
|
811
|
+
const history = withHistory(session).history;
|
|
812
|
+
|
|
813
|
+
if (!history) return undefined;
|
|
814
|
+
const appended = appendEntry(switchBranch(history, branch), entry);
|
|
815
|
+
|
|
816
|
+
session.history = { ...appended.history, branch: history.branch };
|
|
817
|
+
|
|
818
|
+
return appended.entry.id;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/** The branch a share follows; a share made before branches existed follows main. */
|
|
822
|
+
private shareBranchOf(session: ReviewSession): string {
|
|
823
|
+
const branch = session.shareBranch ?? MAIN_BRANCH;
|
|
824
|
+
|
|
825
|
+
return session.history?.tips[branch] === undefined ? MAIN_BRANCH : branch;
|
|
826
|
+
}
|
|
827
|
+
|
|
427
828
|
private mutable(id: string): ReviewSession {
|
|
428
829
|
const session = this.sessionGet(id);
|
|
429
830
|
|
|
@@ -436,6 +837,30 @@ export class DaemonCore {
|
|
|
436
837
|
|
|
437
838
|
export { DaemonError };
|
|
438
839
|
|
|
840
|
+
/**
|
|
841
|
+
* A comment as the fork carries it. An addressed mark names a revision by the
|
|
842
|
+
* source's numbering; the fork numbers its own, so the mark follows the
|
|
843
|
+
* revision's text, and a comment addressed by a revision off the fork's path
|
|
844
|
+
* is open again.
|
|
845
|
+
*/
|
|
846
|
+
function forkedAnnotation(
|
|
847
|
+
annotation: Annotation,
|
|
848
|
+
source: ReviewSession,
|
|
849
|
+
fork: ReviewSession,
|
|
850
|
+
): Annotation {
|
|
851
|
+
const { resolution, ...open } = annotation;
|
|
852
|
+
|
|
853
|
+
if (resolution === undefined) return { ...annotation };
|
|
854
|
+
const addressedBy = source.revisions.find(
|
|
855
|
+
(revision) => revision.revision === resolution.revision,
|
|
856
|
+
);
|
|
857
|
+
const inFork = fork.revisions.find((revision) => revision.content === addressedBy?.content);
|
|
858
|
+
|
|
859
|
+
return inFork === undefined
|
|
860
|
+
? open
|
|
861
|
+
: { ...annotation, resolution: { ...resolution, revision: inFork.revision } };
|
|
862
|
+
}
|
|
863
|
+
|
|
439
864
|
/** A pending diff session: the state that warrants hot-reload watching of its working tree. */
|
|
440
865
|
function isLiveDiffSession(session: ReviewSession): boolean {
|
|
441
866
|
return session.status === "pending" && session.artifact.type === "diff";
|
package/src/capabilities.test.ts
CHANGED
|
@@ -1,28 +1,47 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { asDaemonRole, roleAllowsMethod } from "./capabilities";
|
|
2
|
+
import { asDaemonRole, DEFAULT_ROLE, roleAllowsMethod } from "./capabilities";
|
|
3
|
+
import type { MethodName } from "./validate";
|
|
3
4
|
|
|
4
5
|
describe("roleAllowsMethod", () => {
|
|
5
|
-
test("the owner may call every
|
|
6
|
+
test("the owner may call every primitive", () => {
|
|
6
7
|
// Assert
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
const owned: MethodName[] = ["session.resolve", "session.submitRevision", "session.delete"];
|
|
9
|
+
|
|
10
|
+
for (const method of owned) expect(roleAllowsMethod("owner", method)).toBe(true);
|
|
9
11
|
});
|
|
10
12
|
|
|
11
13
|
test("a capped role may read and annotate but not escalate", () => {
|
|
12
14
|
// Assert - allowed
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
const open: MethodName[] = ["session.get", "session.list", "session.wait", "session.annotate"];
|
|
16
|
+
|
|
17
|
+
for (const method of open) expect(roleAllowsMethod("agent", method)).toBe(true);
|
|
15
18
|
// Assert - denied
|
|
16
|
-
|
|
19
|
+
const owned: MethodName[] = [
|
|
17
20
|
"session.resolve",
|
|
18
21
|
"session.submitRevision",
|
|
19
22
|
"session.setWorkingCopy",
|
|
20
23
|
"session.delete",
|
|
21
24
|
"session.setShareId",
|
|
22
25
|
"session.refreshDiff",
|
|
23
|
-
|
|
24
|
-
|
|
26
|
+
"session.create",
|
|
27
|
+
"session.navigate",
|
|
28
|
+
"session.branch",
|
|
29
|
+
"session.switch",
|
|
30
|
+
"session.label",
|
|
31
|
+
"session.fork",
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
for (const method of owned) expect(roleAllowsMethod("agent", method)).toBe(false);
|
|
25
35
|
expect(roleAllowsMethod("collaborator", "session.resolve")).toBe(false);
|
|
36
|
+
// removal and naming are open to every role; the primitive itself scopes a
|
|
37
|
+
// non-owner to the author it acts as
|
|
38
|
+
expect(roleAllowsMethod("agent", "session.removeAnnotation")).toBe(true);
|
|
39
|
+
expect(roleAllowsMethod("agent", "session.setParticipantName")).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("a connection starts as a collaborator, never as the owner", () => {
|
|
43
|
+
// Assert
|
|
44
|
+
expect(DEFAULT_ROLE).toBe("collaborator");
|
|
26
45
|
});
|
|
27
46
|
});
|
|
28
47
|
|