@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/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.66",
|
|
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": {
|
|
@@ -28,13 +28,15 @@
|
|
|
28
28
|
"./retention": "./src/retention.ts",
|
|
29
29
|
"./herdr-pane": "./src/herdr-pane.ts",
|
|
30
30
|
"./herdr-split": "./src/herdr-split.ts",
|
|
31
|
-
"./share-blob": "./src/share-blob.ts"
|
|
31
|
+
"./share-blob": "./src/share-blob.ts",
|
|
32
|
+
"./curate": "./src/curate.ts"
|
|
32
33
|
},
|
|
33
34
|
"publishConfig": {
|
|
34
35
|
"access": "public"
|
|
35
36
|
},
|
|
36
37
|
"dependencies": {
|
|
37
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
38
|
+
"@cueloop/schema": "0.1.0-alpha.66",
|
|
39
|
+
"@pierre/diffs": "^1.3.6",
|
|
38
40
|
"valibot": "^1.4.2"
|
|
39
41
|
},
|
|
40
42
|
"engines": {
|
package/src/api.test.ts
CHANGED
|
@@ -4,7 +4,8 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { DaemonCore, verdictResponse } from "./api";
|
|
6
6
|
import { SessionStore } from "./store";
|
|
7
|
-
import type
|
|
7
|
+
import { derivePath, tipOf, type Artifact, type WorkspaceKey } from "@cueloop/schema";
|
|
8
|
+
import { MAX_BLOB_BYTES, packSessionBlob, unpackSessionBlob } from "./share-blob";
|
|
8
9
|
|
|
9
10
|
const WS: WorkspaceKey = { repoRoot: "/repo", branch: "main" };
|
|
10
11
|
const PLAN: Artifact = {
|
|
@@ -226,6 +227,30 @@ describe("revision marks addressed annotations", () => {
|
|
|
226
227
|
expect(second!.resolution).toBeUndefined();
|
|
227
228
|
});
|
|
228
229
|
|
|
230
|
+
test("a reported root comment addresses its replies as well", () => {
|
|
231
|
+
// Arrange: a discussion of a root and one reply, plus an unrelated comment
|
|
232
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
233
|
+
|
|
234
|
+
annotate(session.id, "root", "carefully");
|
|
235
|
+
core.sessionAnnotate(session.id, {
|
|
236
|
+
id: "reply",
|
|
237
|
+
kind: "comment",
|
|
238
|
+
anchor: { quote: "carefully", prefix: "", suffix: "" },
|
|
239
|
+
body: "agreed",
|
|
240
|
+
replyTo: "root",
|
|
241
|
+
});
|
|
242
|
+
annotate(session.id, "other", "Context");
|
|
243
|
+
|
|
244
|
+
// Act: the agent reports only the root
|
|
245
|
+
const revised = core.sessionSubmitRevision(session.id, PLAN.content + "\nMore.\n", ["root"]);
|
|
246
|
+
const byId = new Map(revised.annotations.map((annotation) => [annotation.id, annotation]));
|
|
247
|
+
|
|
248
|
+
// Assert
|
|
249
|
+
expect(byId.get("root")!.resolution).toEqual({ revision: 2, source: "agent" });
|
|
250
|
+
expect(byId.get("reply")!.resolution).toEqual({ revision: 2, source: "agent" });
|
|
251
|
+
expect(byId.get("other")!.resolution).toBeUndefined();
|
|
252
|
+
});
|
|
253
|
+
|
|
229
254
|
test("a plan annotation whose quoted text vanished is drift-addressed; a surviving quote stays open", () => {
|
|
230
255
|
// Arrange
|
|
231
256
|
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
@@ -400,3 +425,456 @@ describe("events", () => {
|
|
|
400
425
|
]);
|
|
401
426
|
});
|
|
402
427
|
});
|
|
428
|
+
|
|
429
|
+
describe("the session history records every write as an entry", () => {
|
|
430
|
+
const annotate = (sessionId: string, id: string, quote: string, body = "note") =>
|
|
431
|
+
core.sessionAnnotate(sessionId, {
|
|
432
|
+
id,
|
|
433
|
+
kind: "comment",
|
|
434
|
+
anchor: { quote, prefix: "", suffix: "" },
|
|
435
|
+
body,
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test("create, comment, remove, resolve, and revise append in order on main", () => {
|
|
439
|
+
// Arrange
|
|
440
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
441
|
+
|
|
442
|
+
// Assert: a fresh session is one root revision
|
|
443
|
+
expect(created.history?.entries.map((entry) => entry.type)).toEqual(["revision"]);
|
|
444
|
+
expect(created.history?.tips.main).toBe(created.history?.entries[0]!.id);
|
|
445
|
+
|
|
446
|
+
// Act
|
|
447
|
+
annotate(created.id, "a1", "carefully");
|
|
448
|
+
annotate(created.id, "a1", "carefully", "edited body");
|
|
449
|
+
annotate(created.id, "a2", "Context");
|
|
450
|
+
core.sessionRemoveAnnotation(created.id, "a2");
|
|
451
|
+
core.sessionRemoveAnnotation(created.id, "never-existed");
|
|
452
|
+
core.sessionResolve(created.id, "request_changes", "tighten");
|
|
453
|
+
const revised = core.sessionSubmitRevision(created.id, PLAN.content + "\nMore.\n", ["a1"]);
|
|
454
|
+
|
|
455
|
+
// Assert: an edit of an existing comment and a removal of an unknown id leave no entry
|
|
456
|
+
const history = revised.history!;
|
|
457
|
+
|
|
458
|
+
expect(history.entries.map((entry) => entry.type)).toEqual([
|
|
459
|
+
"revision",
|
|
460
|
+
"comment",
|
|
461
|
+
"comment",
|
|
462
|
+
"comment-removed",
|
|
463
|
+
"verdict",
|
|
464
|
+
"revision",
|
|
465
|
+
]);
|
|
466
|
+
expect(history.branch).toBe("main");
|
|
467
|
+
expect(history.tips.main).toBe(history.entries.at(-1)!.id);
|
|
468
|
+
// every entry chains on the one before
|
|
469
|
+
history.entries.forEach((entry, index) => {
|
|
470
|
+
expect(entry.parentId).toBe(index === 0 ? null : history.entries[index - 1]!.id);
|
|
471
|
+
});
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test("the head of the current path is the artifact, and the open comments are the path's", () => {
|
|
475
|
+
// Arrange
|
|
476
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
477
|
+
|
|
478
|
+
annotate(created.id, "a1", "carefully");
|
|
479
|
+
annotate(created.id, "a2", "Context");
|
|
480
|
+
core.sessionRemoveAnnotation(created.id, "a1");
|
|
481
|
+
const revised = core.sessionSubmitRevision(created.id, "# Plan v2\n");
|
|
482
|
+
|
|
483
|
+
// Act
|
|
484
|
+
const derived = derivePath(revised.history!);
|
|
485
|
+
|
|
486
|
+
// Assert
|
|
487
|
+
expect(derived.head.content).toBe(revised.artifact.content);
|
|
488
|
+
expect(derived.annotationIds).toEqual(["a2"]);
|
|
489
|
+
expect(derived.rounds).toBe(2);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test("a shared session's incoming comments become entries, and the blob round-trips with its history", () => {
|
|
493
|
+
// Arrange
|
|
494
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
495
|
+
|
|
496
|
+
// Act
|
|
497
|
+
const merged = core.sessionMergeShared(created.id, {
|
|
498
|
+
annotations: [
|
|
499
|
+
{
|
|
500
|
+
id: "ana_1",
|
|
501
|
+
kind: "comment",
|
|
502
|
+
anchor: { quote: "Context", prefix: "", suffix: "" },
|
|
503
|
+
body: "from ana",
|
|
504
|
+
author: "SHA256:ana",
|
|
505
|
+
createdAt: "2026-09-01T10:00:00.000Z",
|
|
506
|
+
},
|
|
507
|
+
],
|
|
508
|
+
});
|
|
509
|
+
const roundTripped = unpackSessionBlob(packSessionBlob(merged));
|
|
510
|
+
|
|
511
|
+
// Assert
|
|
512
|
+
expect(merged.history?.entries.at(-1)).toMatchObject({
|
|
513
|
+
type: "comment",
|
|
514
|
+
annotationId: "ana_1",
|
|
515
|
+
});
|
|
516
|
+
expect(roundTripped.history).toEqual(merged.history);
|
|
517
|
+
expect(packSessionBlob(merged).byteLength).toBeLessThan(MAX_BLOB_BYTES);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
test("a session written before histories existed gets one on the next boot", () => {
|
|
521
|
+
// Arrange: a record with no history on disk, as an earlier daemon left it
|
|
522
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
523
|
+
const legacy = { ...created };
|
|
524
|
+
|
|
525
|
+
delete legacy.history;
|
|
526
|
+
new SessionStore(home).upsert(legacy);
|
|
527
|
+
|
|
528
|
+
// Act
|
|
529
|
+
const rebooted = new DaemonCore(home);
|
|
530
|
+
const recovered = rebooted.sessionGet(created.id);
|
|
531
|
+
|
|
532
|
+
// Assert
|
|
533
|
+
expect(recovered.history?.entries.map((entry) => entry.type)).toEqual(["revision"]);
|
|
534
|
+
expect(derivePath(recovered.history!).head.content).toBe(PLAN.content);
|
|
535
|
+
});
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
describe("curation primitives", () => {
|
|
539
|
+
test("cutting a block records a reviewer revision; restoring it returns the copy to pristine", () => {
|
|
540
|
+
// Arrange: block 2 of the plan is the paragraph
|
|
541
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
542
|
+
|
|
543
|
+
// Act
|
|
544
|
+
const cut = core.sessionCutBlock(created.id, 2);
|
|
545
|
+
|
|
546
|
+
// Assert: the working copy lost the paragraph and the history gained the edit
|
|
547
|
+
expect(cut.workingCopy).not.toContain("Do the thing carefully.");
|
|
548
|
+
expect(cut.history!.entries.at(-1)).toMatchObject({ type: "revision", by: "reviewer" });
|
|
549
|
+
expect(derivePath(cut.history!).head.content).toBe(cut.workingCopy!);
|
|
550
|
+
|
|
551
|
+
// Act: put it back where it was (block 2 of the submitted revision, before the end)
|
|
552
|
+
const restored = core.sessionRestoreBlock(created.id, 2);
|
|
553
|
+
|
|
554
|
+
// Assert: pristine again, and that edit is on record too
|
|
555
|
+
expect(restored.workingCopy).toBeUndefined();
|
|
556
|
+
expect(derivePath(restored.history!).head.content).toBe(PLAN.content);
|
|
557
|
+
expect(restored.history!.entries.filter((entry) => entry.type === "revision")).toHaveLength(3);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
test("a block index outside the text is refused", () => {
|
|
561
|
+
// Arrange
|
|
562
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
563
|
+
|
|
564
|
+
// Assert
|
|
565
|
+
expect(() => core.sessionCutBlock(created.id, 99)).toThrow(/no block 99/);
|
|
566
|
+
expect(() => core.sessionRestoreBlock(created.id, 99)).toThrow(/no block 99/);
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
test("restoring a block that is present is refused rather than duplicated", () => {
|
|
570
|
+
// Arrange: nothing cut, then block 2 cut once
|
|
571
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
572
|
+
|
|
573
|
+
// Assert: a pristine copy has every block
|
|
574
|
+
expect(() => core.sessionRestoreBlock(created.id, 2)).toThrow(/present in the working copy/);
|
|
575
|
+
|
|
576
|
+
// Act
|
|
577
|
+
core.sessionCutBlock(created.id, 2);
|
|
578
|
+
const restored = core.sessionRestoreBlock(created.id, 2);
|
|
579
|
+
|
|
580
|
+
// Assert: one restore puts it back; a second would duplicate it
|
|
581
|
+
expect(restored.workingCopy).toBeUndefined();
|
|
582
|
+
expect(() => core.sessionRestoreBlock(created.id, 2)).toThrow(/present in the working copy/);
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
test("curating a diff stores the decisions and the patch they leave; no decisions clear both", () => {
|
|
586
|
+
// Arrange: one file with two separated changes
|
|
587
|
+
const oldContents = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n";
|
|
588
|
+
const newContents = "a\nB\nc\nd\ne\nf\ng\nh\ni\nJ\n";
|
|
589
|
+
const diff: Artifact = {
|
|
590
|
+
type: "diff",
|
|
591
|
+
content: "",
|
|
592
|
+
meta: {},
|
|
593
|
+
files: [{ path: "src/x.txt", oldContents, newContents, status: "modified" }],
|
|
594
|
+
};
|
|
595
|
+
const created = core.sessionCreate({ workspace: WS, artifact: diff });
|
|
596
|
+
|
|
597
|
+
// Act: reject the first hunk
|
|
598
|
+
const curated = core.sessionCurate(created.id, [{ path: "src/x.txt", hunkIndex: 0 }]);
|
|
599
|
+
|
|
600
|
+
// Assert: only the second change survives in the working copy
|
|
601
|
+
expect(curated.curation).toEqual([{ path: "src/x.txt", hunkIndex: 0 }]);
|
|
602
|
+
expect(curated.workingCopy).toContain("+J");
|
|
603
|
+
expect(curated.workingCopy).not.toContain("+B");
|
|
604
|
+
expect(curated.history!.entries.at(-1)).toMatchObject({ type: "revision", by: "reviewer" });
|
|
605
|
+
|
|
606
|
+
// Act
|
|
607
|
+
const cleared = core.sessionCurate(created.id, []);
|
|
608
|
+
|
|
609
|
+
// Assert
|
|
610
|
+
expect(cleared.curation).toBeUndefined();
|
|
611
|
+
expect(cleared.workingCopy).toBeUndefined();
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
test("a plan cannot be curated by hunk", () => {
|
|
615
|
+
// Arrange
|
|
616
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
617
|
+
|
|
618
|
+
// Assert
|
|
619
|
+
expect(() => core.sessionCurate(created.id, [])).toThrow(/only a diff review/);
|
|
620
|
+
});
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
describe("tree primitives", () => {
|
|
624
|
+
const comment = (id: string, body: string) => ({
|
|
625
|
+
id,
|
|
626
|
+
kind: "comment" as const,
|
|
627
|
+
anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
|
|
628
|
+
body,
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
test("navigating main back hides later comments and edits, and the agent's next revision lands there", () => {
|
|
632
|
+
// Arrange: comment, reviewer edit, second comment
|
|
633
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
634
|
+
|
|
635
|
+
core.sessionAnnotate(created.id, comment("a1", "first"));
|
|
636
|
+
const checkpoint = derivePath(core.sessionGet(created.id).history!);
|
|
637
|
+
const checkpointEntry = tipOf(core.sessionGet(created.id).history!);
|
|
638
|
+
|
|
639
|
+
core.sessionLabel(created.id, "after a1");
|
|
640
|
+
core.sessionSetWorkingCopy(created.id, "# Plan\n\n## Context\n\nDo it.\n");
|
|
641
|
+
core.sessionAnnotate(created.id, comment("a2", "second"));
|
|
642
|
+
|
|
643
|
+
// Act
|
|
644
|
+
const moved = core.sessionNavigate(created.id, checkpointEntry, "dropped the edit");
|
|
645
|
+
|
|
646
|
+
// Assert: the view is the checkpoint's, the abandoned segment is on record
|
|
647
|
+
expect(moved.workingCopy).toBeUndefined();
|
|
648
|
+
expect(moved.annotations.map((annotation) => annotation.id)).toEqual(["a1"]);
|
|
649
|
+
expect(moved.shelvedAnnotations!.map((annotation) => annotation.id)).toEqual(["a2"]);
|
|
650
|
+
expect(moved.history!.labels[checkpointEntry]).toBe("after a1");
|
|
651
|
+
expect(derivePath(moved.history!).summaries[0]!.abandoned).toHaveLength(2);
|
|
652
|
+
expect(checkpoint.annotationIds).toEqual(["a1"]);
|
|
653
|
+
|
|
654
|
+
// Act: the agent resubmits
|
|
655
|
+
const revised = core.sessionSubmitRevision(created.id, "# Plan\n\nRevised.\n");
|
|
656
|
+
|
|
657
|
+
// Assert: the revision chains after the summary on main
|
|
658
|
+
const path = derivePath(revised.history!);
|
|
659
|
+
|
|
660
|
+
expect(path.head.content).toBe("# Plan\n\nRevised.\n");
|
|
661
|
+
expect(revised.history!.entries.at(-1)!.parentId).toBe(
|
|
662
|
+
derivePath(moved.history!).summaries[0]!.id,
|
|
663
|
+
);
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
test("the feedback document renders from the tip's path", () => {
|
|
667
|
+
// Arrange: two comments, then main moved back between them
|
|
668
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
669
|
+
|
|
670
|
+
core.sessionAnnotate(created.id, comment("a1", "keep this one"));
|
|
671
|
+
const between = tipOf(core.sessionGet(created.id).history!);
|
|
672
|
+
|
|
673
|
+
core.sessionAnnotate(created.id, comment("a2", "not this one"));
|
|
674
|
+
core.sessionNavigate(created.id, between);
|
|
675
|
+
|
|
676
|
+
// Act
|
|
677
|
+
const resolved = core.sessionResolve(created.id, "request_changes", "one note");
|
|
678
|
+
|
|
679
|
+
// Assert
|
|
680
|
+
expect(resolved.verdict!.feedback).toContain("keep this one");
|
|
681
|
+
expect(resolved.verdict!.feedback).not.toContain("not this one");
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
test("a navigate names the branch to stand on, so a move on another branch is one request", () => {
|
|
685
|
+
// Arrange: alt has a comment; the owner stands on main
|
|
686
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
687
|
+
const revision = tipOf(created.history!);
|
|
688
|
+
|
|
689
|
+
core.sessionBranch(created.id, "alt");
|
|
690
|
+
core.sessionAnnotate(created.id, comment("a1", "on alt"));
|
|
691
|
+
core.sessionSwitch(created.id, "main");
|
|
692
|
+
|
|
693
|
+
// Act
|
|
694
|
+
const moved = core.sessionNavigate(created.id, revision, undefined, "alt");
|
|
695
|
+
|
|
696
|
+
// Assert: on alt, back at the revision, the comment shelved
|
|
697
|
+
expect(moved.history!.branch).toBe("alt");
|
|
698
|
+
expect(tipOf(moved.history!)).toBe(revision);
|
|
699
|
+
expect(moved.shelvedAnnotations!.map((annotation) => annotation.id)).toEqual(["a1"]);
|
|
700
|
+
expect(() => core.sessionNavigate(created.id, revision, undefined, "nope")).toThrow(
|
|
701
|
+
/no branch/,
|
|
702
|
+
);
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
test("a navigate to the tip or off the path is refused", () => {
|
|
706
|
+
// Arrange
|
|
707
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
708
|
+
const tip = tipOf(created.history!);
|
|
709
|
+
|
|
710
|
+
core.sessionBranch(created.id, "alt");
|
|
711
|
+
core.sessionAnnotate(created.id, comment("a1", "on alt"));
|
|
712
|
+
const onAlt = tipOf(core.sessionGet(created.id).history!, "alt");
|
|
713
|
+
|
|
714
|
+
core.sessionSwitch(created.id, "main");
|
|
715
|
+
|
|
716
|
+
// Assert
|
|
717
|
+
expect(() => core.sessionNavigate(created.id, tip)).toThrow(/already at/);
|
|
718
|
+
expect(() => core.sessionNavigate(created.id, onAlt)).toThrow(/not on branch "main"/);
|
|
719
|
+
expect(() => core.sessionNavigate(created.id, "e_nope")).toThrow(/no entry/);
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
test("branching keeps main where it is; switching shows each branch's own comments", () => {
|
|
723
|
+
// Arrange
|
|
724
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
725
|
+
|
|
726
|
+
core.sessionAnnotate(created.id, comment("shared", "on both"));
|
|
727
|
+
|
|
728
|
+
// Act
|
|
729
|
+
core.sessionBranch(created.id, "alt");
|
|
730
|
+
core.sessionAnnotate(created.id, comment("alt-only", "on alt"));
|
|
731
|
+
const onAlt = core
|
|
732
|
+
.sessionSwitch(created.id, "alt")
|
|
733
|
+
.annotations.map((annotation) => annotation.id);
|
|
734
|
+
const onMain = core.sessionSwitch(created.id, "main");
|
|
735
|
+
|
|
736
|
+
// Assert
|
|
737
|
+
expect(onAlt).toEqual(["shared", "alt-only"]);
|
|
738
|
+
expect(onMain.annotations.map((annotation) => annotation.id)).toEqual(["shared"]);
|
|
739
|
+
expect(onMain.shelvedAnnotations!.map((annotation) => annotation.id)).toEqual(["alt-only"]);
|
|
740
|
+
expect(() => core.sessionBranch(created.id, "alt")).toThrow(/exists/);
|
|
741
|
+
expect(() => core.sessionSwitch(created.id, "nope")).toThrow(/no branch/);
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
test("a removed comment is shelved, and a share merge does not bring it back", () => {
|
|
745
|
+
// Arrange
|
|
746
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
747
|
+
|
|
748
|
+
core.sessionAnnotate(created.id, comment("a1", "first"));
|
|
749
|
+
const removed = core.sessionRemoveAnnotation(created.id, "a1");
|
|
750
|
+
|
|
751
|
+
// Act
|
|
752
|
+
const merged = core.sessionMergeShared(created.id, {
|
|
753
|
+
annotations: [{ ...comment("a1", "first"), createdAt: "2026-01-01T00:00:00.000Z" }],
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
// Assert
|
|
757
|
+
expect(removed.shelvedAnnotations!.map((annotation) => annotation.id)).toEqual(["a1"]);
|
|
758
|
+
expect(merged.annotations).toEqual([]);
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
test("a share's removal entry hides the comment for the owner, once, by entry id", () => {
|
|
762
|
+
// Arrange: a collaborator's note pulled in earlier
|
|
763
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
764
|
+
const note = {
|
|
765
|
+
...comment("c1", "from a teammate"),
|
|
766
|
+
author: "SHA256:collab",
|
|
767
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
768
|
+
};
|
|
769
|
+
|
|
770
|
+
core.sessionMergeShared(created.id, { annotations: [note] });
|
|
771
|
+
const removal = { id: "e_share_rm", annotationId: "c1", createdAt: "2026-01-02T00:00:00.000Z" };
|
|
772
|
+
|
|
773
|
+
// Act: the share hands the removal back twice, as two pulls would
|
|
774
|
+
core.sessionMergeShared(created.id, { annotations: [], removals: [removal] });
|
|
775
|
+
const merged = core.sessionMergeShared(created.id, {
|
|
776
|
+
annotations: [note],
|
|
777
|
+
removals: [removal],
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
// Assert: gone from view, kept on the shelf, one removal entry with the share's id
|
|
781
|
+
expect(merged.annotations).toEqual([]);
|
|
782
|
+
expect(merged.shelvedAnnotations!.map((annotation) => annotation.id)).toEqual(["c1"]);
|
|
783
|
+
expect(merged.history!.entries.filter((entry) => entry.type === "comment-removed")).toEqual([
|
|
784
|
+
expect.objectContaining({ id: "e_share_rm", annotationId: "c1" }),
|
|
785
|
+
]);
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
test("a merge lands on the branch the share follows, wherever the owner stands", () => {
|
|
789
|
+
// Arrange: the owner works on a side branch while the share follows main
|
|
790
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
791
|
+
|
|
792
|
+
core.sessionBranch(created.id, "alt");
|
|
793
|
+
const note = {
|
|
794
|
+
...comment("c1", "from a teammate"),
|
|
795
|
+
author: "SHA256:collab",
|
|
796
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
797
|
+
};
|
|
798
|
+
|
|
799
|
+
// Act
|
|
800
|
+
const merged = core.sessionMergeShared(created.id, { annotations: [note] });
|
|
801
|
+
|
|
802
|
+
// Assert: main's path has the note, alt's does not show it
|
|
803
|
+
expect(merged.history!.branch).toBe("alt");
|
|
804
|
+
expect(merged.annotations).toEqual([]);
|
|
805
|
+
expect(merged.shelvedAnnotations!.map((annotation) => annotation.id)).toEqual(["c1"]);
|
|
806
|
+
expect(derivePath(merged.history!, tipOf(merged.history!, "main")).annotationIds).toEqual([
|
|
807
|
+
"c1",
|
|
808
|
+
]);
|
|
809
|
+
expect(
|
|
810
|
+
core.sessionSwitch(created.id, "main").annotations.map((annotation) => annotation.id),
|
|
811
|
+
).toEqual(["c1"]);
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
test("a fork is a new pending session on the copied path, without verdict, edits, or share", () => {
|
|
815
|
+
// Arrange: comment, edit, resolve, then a second round
|
|
816
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
817
|
+
|
|
818
|
+
core.sessionAnnotate(created.id, comment("a1", "first"), "Ana");
|
|
819
|
+
core.sessionSetShareId(created.id, "share-1");
|
|
820
|
+
core.sessionResolve(created.id, "request_changes", "again");
|
|
821
|
+
core.sessionSubmitRevision(created.id, "# Plan\n\nRound two.\n", ["a1"]);
|
|
822
|
+
core.sessionLabel(created.id, "round two");
|
|
823
|
+
core.sessionSetWorkingCopy(created.id, "# Plan\n\nRound two, edited.\n");
|
|
824
|
+
|
|
825
|
+
// Act
|
|
826
|
+
const fork = core.sessionFork(created.id);
|
|
827
|
+
|
|
828
|
+
// Assert
|
|
829
|
+
expect(fork.id).not.toBe(created.id);
|
|
830
|
+
expect(fork.parentSessionId).toBe(created.id);
|
|
831
|
+
expect(fork.status).toBe("pending");
|
|
832
|
+
expect(fork.verdict).toBeNull();
|
|
833
|
+
expect(fork.shareId).toBeUndefined();
|
|
834
|
+
expect(fork.workingCopy).toBeUndefined();
|
|
835
|
+
expect(fork.artifact.content).toBe("# Plan\n\nRound two.\n");
|
|
836
|
+
expect(fork.revisions.map((revision) => revision.revision)).toEqual([1, 2]);
|
|
837
|
+
expect(fork.annotations.map((annotation) => annotation.id)).toEqual(["a1"]);
|
|
838
|
+
expect(fork.participants).toEqual(
|
|
839
|
+
created.participants ?? core.sessionGet(created.id).participants,
|
|
840
|
+
);
|
|
841
|
+
expect(Object.values(fork.history!.labels)).toEqual(["round two"]);
|
|
842
|
+
expect(fork.history!.entries.map((entry) => entry.type)).toEqual([
|
|
843
|
+
"revision",
|
|
844
|
+
"comment",
|
|
845
|
+
"revision",
|
|
846
|
+
]);
|
|
847
|
+
expect(core.sessionList().map((session) => session.id)).toContain(fork.id);
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
test("a fork's addressed marks follow its own numbering; one addressed off the path reopens", () => {
|
|
851
|
+
// Arrange: a1 addressed by round two, main navigated back before round two, round three submitted
|
|
852
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
853
|
+
|
|
854
|
+
core.sessionAnnotate(created.id, comment("a1", "first"));
|
|
855
|
+
const beforeRoundTwo = tipOf(core.sessionGet(created.id).history!);
|
|
856
|
+
|
|
857
|
+
core.sessionSubmitRevision(created.id, "# Plan\n\nRound two.\n", ["a1"]);
|
|
858
|
+
core.sessionAnnotate(created.id, comment("a2", "second"));
|
|
859
|
+
core.sessionSubmitRevision(created.id, "# Plan\n\nRound three.\n", ["a2"]);
|
|
860
|
+
|
|
861
|
+
// Act: fork with both rounds on the path
|
|
862
|
+
const straight = core.sessionFork(created.id);
|
|
863
|
+
|
|
864
|
+
core.sessionNavigate(created.id, beforeRoundTwo);
|
|
865
|
+
core.sessionSubmitRevision(created.id, "# Plan\n\nRound four.\n");
|
|
866
|
+
// the source numbers this 4; on the fork's path it is the second revision
|
|
867
|
+
const rerouted = core.sessionFork(created.id);
|
|
868
|
+
|
|
869
|
+
// Assert
|
|
870
|
+
expect(straight.annotations.map((annotation) => annotation.resolution?.revision)).toEqual([
|
|
871
|
+
2, 3,
|
|
872
|
+
]);
|
|
873
|
+
expect(rerouted.revisions.map((revision) => revision.content)).toEqual([
|
|
874
|
+
PLAN.content,
|
|
875
|
+
"# Plan\n\nRound four.\n",
|
|
876
|
+
]);
|
|
877
|
+
expect(rerouted.annotations.map((annotation) => annotation.id)).toEqual(["a1"]);
|
|
878
|
+
expect(rerouted.annotations[0]!.resolution).toBeUndefined();
|
|
879
|
+
});
|
|
880
|
+
});
|