@cueloop/daemon 0.1.0-alpha.64 → 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/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 { SessionStore } from "./store";
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
- for (const listener of this.listeners) listener({ event, sessionId });
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: `ses_${now.replace(/\D/g, "").slice(0, 14)}_${(++this.seq).toString(36)}${Math.random().toString(36).slice(2, 6)}`,
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
- if (existing === -1) session.annotations.push(full);
193
- else
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
- sessionRemoveAnnotation(id: string, annotationId: string): ReviewSession {
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
- * Merge a share's collaborator state back into the local session: annotations
272
- * union by id with existing ones (the planner's) winning, and the participant
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
- sessionMergeShared(
278
- id: string,
279
- incoming: { annotations: Annotation[]; participants?: Identity[] },
280
- ): ReviewSession {
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
- for (const annotation of incoming.annotations)
285
- if (!known.has(annotation.id)) session.annotations.push(annotation);
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.store.upsert(session);
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";
@@ -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 verb", () => {
6
+ test("the owner may call every primitive", () => {
6
7
  // Assert
7
- for (const method of ["session.resolve", "session.submitRevision", "session.delete"])
8
- expect(roleAllowsMethod("owner", method)).toBe(true);
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
- for (const method of ["session.get", "session.list", "session.wait", "session.annotate"])
14
- expect(roleAllowsMethod("agent", method)).toBe(true);
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
- for (const method of [
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
- expect(roleAllowsMethod("agent", method)).toBe(false);
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