Files
beardrive/internal/syncer/flows_test.go
Snow LeeandClaude Fable 5 c1f0d0f8ee feat(sync): session-linked notes — stamp changes with the agent session
`bdrive sync --note <text>` stamps session context onto every op the cycle
commits and persists it in the volume store (note.json, --note-ttl default
30m) so the daemon's own scans stamp it too — winning the race between
one-shot hook syncs and the 3s daemon scan. The plugin sync hook extracts
session_id from hook stdin JSON and passes it automatically, so history
links every change to the Claude Code session that made it. Conflict-copy
ops keep their own note; expired/cleared notes stop applying.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P5cxPQdSGJnjXCYY9GeWXt
2026-07-11 10:56:53 -07:00

278 lines
9.5 KiB
Go

package syncer
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/runbear-io/beardrive/internal/config"
"github.com/runbear-io/beardrive/internal/journal"
"github.com/runbear-io/beardrive/internal/remote"
"github.com/runbear-io/beardrive/internal/store"
)
// End-to-end scenarios for the init knowledge flows documented in
// plugin/commands/init.md and the beardrive skill ("Connecting knowledge
// tooling"): a shared subfolder carved out of a repo, a teammate connecting
// over pre-existing local content, and a nested mount (e.g. a team knowledge
// folder inside a personal brain that is itself a mount) syncing through its
// own project.
// deviceAt is newDevice with an explicit working folder, for topologies where
// the folder's location matters (nested mounts).
func deviceAt(t *testing.T, name, folder string, backend remote.Backend) *Session {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "volume"))
if err != nil {
t.Fatal(err)
}
return &Session{
Folder: folder,
Store: st,
Device: config.Device{ID: name, Name: name, Author: name + "@test"},
Backend: backend,
}
}
func conflictFiles(t *testing.T, folder string) []string {
t.Helper()
var out []string
err := filepath.WalkDir(folder, func(p string, d os.DirEntry, err error) error {
if err == nil && !d.IsDir() && strings.Contains(d.Name(), ".bdrive-conflict-") {
out = append(out, p)
}
return nil
})
if err != nil {
t.Fatal(err)
}
return out
}
// A repo shares only its knowledge subfolder (`bdrive init --shared Wiki`);
// a teammate connects from their own checkout with the same scope. Wiki
// content flows both ways; each side's code never leaves its machine.
func TestSharedSubfolderScopeBothDevices(t *testing.T) {
be := sharedRemote(t)
a := newDevice(t, "deva", be)
b := newDevice(t, "devb", be)
write(t, a.Folder, ".bdrive/config.json", `{"include": ["Wiki/"]}`)
write(t, a.Folder, "Wiki/home.md", "welcome")
write(t, a.Folder, "src/code.go", "package main")
cycle(t, a)
write(t, b.Folder, ".bdrive/config.json", `{"include": ["Wiki/"]}`)
write(t, b.Folder, "main.py", "print('local only')")
res := cycle(t, b)
if res.LocalOps != 0 {
t.Fatalf("b journaled %d ops for out-of-scope files, want 0", res.LocalOps)
}
if got := read(t, b.Folder, "Wiki/home.md"); got != "welcome" {
t.Fatalf("Wiki/home.md = %q, want welcome", got)
}
// Wiki edits flow back; code never crosses in either direction.
write(t, b.Folder, "Wiki/home.md", "welcome v2")
cycle(t, b)
cycle(t, a)
if got := read(t, a.Folder, "Wiki/home.md"); got != "welcome v2" {
t.Fatalf("a Wiki/home.md = %q, want welcome v2", got)
}
for folder, absent := range map[string]string{a.Folder: "main.py", b.Folder: "src/code.go"} {
if _, err := os.Stat(filepath.Join(folder, absent)); !os.IsNotExist(err) {
t.Fatalf("%s leaked across devices", absent)
}
}
}
// The git-handoff teammate story: after the first user connects docs/, a
// teammate's checkout already holds the same files. Connecting must converge
// with zero conflict copies (identical content is adopted, not duplicated).
func TestConnectWithIdenticalLocalContent(t *testing.T) {
be := sharedRemote(t)
a := newDevice(t, "deva", be)
write(t, a.Folder, "docs/guide.md", "v1")
write(t, a.Folder, "docs/setup.md", "steps")
cycle(t, a)
b := newDevice(t, "devb", be)
write(t, b.Folder, "docs/guide.md", "v1")
write(t, b.Folder, "docs/setup.md", "steps")
cycle(t, b)
cycle(t, a)
cycle(t, b)
for _, s := range []*Session{a, b} {
if got := read(t, s.Folder, "docs/guide.md"); got != "v1" {
t.Fatalf("guide.md = %q, want v1", got)
}
if c := conflictFiles(t, s.Folder); len(c) != 0 {
t.Fatalf("identical content produced conflict copies: %v", c)
}
}
}
// Same story with a stale divergent copy: nothing is silently lost — the
// devices converge on one version and the other survives as a conflict copy.
func TestConnectWithDivergentLocalContent(t *testing.T) {
be := sharedRemote(t)
a := newDevice(t, "deva", be)
write(t, a.Folder, "docs/guide.md", "hub version")
cycle(t, a)
b := newDevice(t, "devb", be)
time.Sleep(10 * time.Millisecond)
write(t, b.Folder, "docs/guide.md", "stale local version")
cycle(t, b)
cycle(t, a)
cycle(t, b)
av, bv := read(t, a.Folder, "docs/guide.md"), read(t, b.Folder, "docs/guide.md")
if av != bv {
t.Fatalf("devices diverged: %q vs %q", av, bv)
}
survived := map[string]bool{av: true}
for _, s := range []*Session{a, b} {
for _, p := range conflictFiles(t, s.Folder) {
rel, _ := filepath.Rel(s.Folder, p)
survived[read(t, s.Folder, rel)] = true
}
}
if !survived["hub version"] || !survived["stale local version"] {
t.Fatalf("a version was silently lost; surviving: %v", survived)
}
}
// The follower-brain topology: a personal folder is a mount on one project
// while a team knowledge folder nested inside it is a mount on another.
// Each project sees only its own files, in both directions, even as both
// actively sync.
func TestNestedMountSyncsIndependently(t *testing.T) {
personal := sharedRemote(t)
team := sharedRemote(t)
// Alice: personal brain mount with a nested team mount inside it.
aliceRoot := t.TempDir()
alice := deviceAt(t, "alice", aliceRoot, personal)
aliceTeam := deviceAt(t, "alice-team", filepath.Join(aliceRoot, "team"), team)
write(t, aliceRoot, "private.md", "my captures")
write(t, aliceRoot, "team/.bdrive/config.json", `{"mount_id":"m-team"}`)
write(t, aliceRoot, "team/plan.md", "roadmap v1")
cycle(t, alice)
cycle(t, aliceTeam)
// Bob syncs only the team project; Alice's laptop syncs only personal.
bobTeam := newDevice(t, "bob-team", team)
cycle(t, bobTeam)
if got := read(t, bobTeam.Folder, "plan.md"); got != "roadmap v1" {
t.Fatalf("team plan.md = %q, want roadmap v1", got)
}
if _, err := os.Stat(filepath.Join(bobTeam.Folder, "private.md")); !os.IsNotExist(err) {
t.Fatal("personal file leaked into the team project")
}
laptop := newDevice(t, "alice-laptop", personal)
cycle(t, laptop)
if got := read(t, laptop.Folder, "private.md"); got != "my captures" {
t.Fatalf("private.md = %q, want my captures", got)
}
if _, err := os.Stat(filepath.Join(laptop.Folder, "team")); !os.IsNotExist(err) {
t.Fatal("team folder leaked into the personal project")
}
// Bob's team edit reaches Alice's nested mount; her personal project
// must not journal the change it can see on disk.
write(t, bobTeam.Folder, "plan.md", "roadmap v2")
cycle(t, bobTeam)
cycle(t, aliceTeam)
if got := read(t, aliceRoot, "team/plan.md"); got != "roadmap v2" {
t.Fatalf("alice team/plan.md = %q, want roadmap v2", got)
}
if res := cycle(t, alice); res.LocalOps != 0 {
t.Fatalf("personal mount journaled %d ops for nested team content, want 0", res.LocalOps)
}
}
// The conflict-copy filename must keep matching the glob documented for the
// OKF validation hook (SKILL.md): validate alone can't see conflict copies,
// so agents check `*.bdrive-conflict-*` — renaming the pattern breaks them.
func TestConflictCopyNameMatchesDocumentedGlob(t *testing.T) {
name := conflictName("Wiki/page.md", "Snow's MacBook", time.Now())
ok, err := filepath.Match("*.bdrive-conflict-*", filepath.Base(name))
if err != nil || !ok {
t.Fatalf("conflict copy %q no longer matches the documented glob *.bdrive-conflict-*", name)
}
if strings.ContainsAny(filepath.Base(name), " '") {
t.Fatalf("conflict copy name %q should sanitize device names", name)
}
}
// Session-linked notes: a note set for the session (explicitly, or persisted
// in the store by `bdrive sync --note`) is stamped onto every op that scan
// commits, travels through the remote to peers, and expires with its TTL —
// so history can link each change to the agent session that made it.
func TestSessionNoteStampsOps(t *testing.T) {
be := sharedRemote(t)
a := newDevice(t, "deva", be)
b := newDevice(t, "devb", be)
// Explicit note on the session (one-shot `bdrive sync --note`).
a.Note = "claude-code session s-123"
write(t, a.Folder, "wiki/plan.md", "v1")
cycle(t, a)
cycle(t, b)
ops, err := b.Store.DeviceOps("deva")
if err != nil || len(ops) != 1 {
t.Fatalf("peer copy of deva journal: %v (%d ops)", err, len(ops))
}
if ops[0].Note != "claude-code session s-123" {
t.Fatalf("note on peer = %q", ops[0].Note)
}
// Persisted note: an empty Session.Note falls back to the store note, the
// path the daemon takes after a one-shot sync persisted it.
a.Note = ""
if err := a.Store.SaveNote("claude-code session s-456", time.Hour); err != nil {
t.Fatal(err)
}
write(t, a.Folder, "wiki/plan.md", "v2")
cycle(t, a)
ops, _ = a.Store.DeviceOps("deva")
if got := ops[len(ops)-1].Note; got != "claude-code session s-456" {
t.Fatalf("persisted-note op = %q", got)
}
// Deletes carry the note too.
if err := os.Remove(filepath.Join(a.Folder, "wiki", "plan.md")); err != nil {
t.Fatal(err)
}
cycle(t, a)
ops, _ = a.Store.DeviceOps("deva")
last := ops[len(ops)-1]
if last.Kind != journal.KindDelete || last.Note != "claude-code session s-456" {
t.Fatalf("delete op = %+v", last)
}
// An expired note stops applying: later edits stay unlabeled.
if err := a.Store.SaveNote("stale", time.Millisecond); err != nil {
t.Fatal(err)
}
time.Sleep(10 * time.Millisecond)
write(t, a.Folder, "wiki/new.md", "x")
cycle(t, a)
ops, _ = a.Store.DeviceOps("deva")
if got := ops[len(ops)-1].Note; got != "" {
t.Fatalf("expired note leaked onto op: %q", got)
}
// Clearing removes the note file entirely.
if err := a.Store.SaveNote("", 0); err != nil {
t.Fatal(err)
}
if got := a.Store.LoadNote(); got != "" {
t.Fatalf("cleared note = %q", got)
}
}