mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
* feat(history): group agent runs and restore any version (BEA-6) BearDrive recorded everything and could restore nothing. Now every version of a file has a Restore button — in the hub's History view and as `bdrive restore` — and the changes one agent run made read as one card instead of N loose rows. Restore is a NEW put op pointing at the old blob: journals are never rewritten, so one-writer-per-journal holds and peers converge on the restore like any other edit. The hub reuses RemoteSource.Commit (the upload commit minus the upload); the CLI writes the bytes into the working folder and lets the ordinary cycle journal them, so the sync engine gains no new write path. Grouping is a pure frontend group-by on (note, device) over the existing /history response — no journal or API change. Known gap, stated in the UI and the docs: nothing in the hub writes a delete op yet, so a file a run *created* cannot be un-created. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(history): don't repeat a run's note on every row in its card UI pass on the real hub: inside a run card the note is the card's header, so printing it again on each row said the same thing N times. The header now carries the note (linkified, so an agent's session link still opens) and the collapse control is its own button rather than the whole header — the link could not live inside a button. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
188 lines
5.0 KiB
Go
188 lines
5.0 KiB
Go
package syncer
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/runbear-io/beardrive/internal/journal"
|
|
)
|
|
|
|
func sha(content string) string {
|
|
sum := sha256.Sum256([]byte(content))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func journalBytes(t *testing.T, s *Session, device string) []byte {
|
|
t.Helper()
|
|
b, err := os.ReadFile(s.Store.JournalPath(device))
|
|
if err != nil {
|
|
t.Fatalf("read journal %s: %v", device, err)
|
|
}
|
|
return b
|
|
}
|
|
|
|
// The one that matters: a restore is an ordinary edit, so both devices
|
|
// converge on the restored content and NO journal is ever rewritten — the
|
|
// restoring device's own log grows by one op, everyone else's is untouched
|
|
// down to the byte.
|
|
func TestRestoreConvergesAcrossDevices(t *testing.T) {
|
|
be := sharedRemote(t)
|
|
a := newDevice(t, "deva", be)
|
|
b := newDevice(t, "devb", be)
|
|
|
|
write(t, a.Folder, "f.txt", "v1")
|
|
cycle(t, a)
|
|
cycle(t, b)
|
|
time.Sleep(10 * time.Millisecond) // ensure mtime moves
|
|
write(t, a.Folder, "f.txt", "v2 is longer")
|
|
cycle(t, a)
|
|
cycle(t, b)
|
|
if read(t, b.Folder, "f.txt") != "v2 is longer" {
|
|
t.Fatal("b did not receive v2")
|
|
}
|
|
|
|
beforeA := journalBytes(t, b, "deva")
|
|
mineBefore, err := b.Store.DeviceOps("devb")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
b.Note = "restore f.txt@" + sha("v1")[:8]
|
|
if err := b.Restore(context.Background(), "f.txt", sha("v1")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cycle(t, b)
|
|
cycle(t, a)
|
|
|
|
if got := read(t, b.Folder, "f.txt"); got != "v1" {
|
|
t.Fatalf("b after restore = %q, want v1", got)
|
|
}
|
|
if got := read(t, a.Folder, "f.txt"); got != "v1" {
|
|
t.Fatalf("a did not converge: %q", got)
|
|
}
|
|
if string(journalBytes(t, b, "deva")) != string(beforeA) {
|
|
t.Fatal("restore rewrote another device's journal")
|
|
}
|
|
mineAfter, err := b.Store.DeviceOps("devb")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(mineAfter) != len(mineBefore)+1 {
|
|
t.Fatalf("own journal ops %d → %d, want exactly one appended", len(mineBefore), len(mineAfter))
|
|
}
|
|
last := mineAfter[len(mineAfter)-1]
|
|
if last.Kind != journal.KindPut || last.Path != "f.txt" || last.Blob != sha("v1") {
|
|
t.Fatalf("restore op = %+v", last)
|
|
}
|
|
if last.Note != b.Note {
|
|
t.Fatalf("restore op note = %q, want %q", last.Note, b.Note)
|
|
}
|
|
}
|
|
|
|
// Restoring while offline still converges once the device is back: the
|
|
// restore is a local edit, so it is journaled now and pushed later.
|
|
func TestRestoreOfflineConverges(t *testing.T) {
|
|
be := sharedRemote(t)
|
|
a := newDevice(t, "deva", be)
|
|
b := newDevice(t, "devb", be)
|
|
|
|
write(t, a.Folder, "f.txt", "v1")
|
|
cycle(t, a)
|
|
cycle(t, b)
|
|
time.Sleep(10 * time.Millisecond)
|
|
write(t, a.Folder, "f.txt", "v2")
|
|
cycle(t, a)
|
|
cycle(t, b)
|
|
|
|
b.Backend = nil // hub unreachable
|
|
if err := b.Restore(context.Background(), "f.txt", sha("v1")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cycle(t, b)
|
|
if read(t, b.Folder, "f.txt") != "v1" {
|
|
t.Fatal("offline restore did not land")
|
|
}
|
|
|
|
b.Backend = be
|
|
cycle(t, b)
|
|
cycle(t, a)
|
|
if got := read(t, a.Folder, "f.txt"); got != "v1" {
|
|
t.Fatalf("a after b came back = %q, want v1", got)
|
|
}
|
|
}
|
|
|
|
// A deleted file comes back: restore is content-addressed, and the blob
|
|
// outlives the delete op.
|
|
func TestRestoreAfterDelete(t *testing.T) {
|
|
be := sharedRemote(t)
|
|
a := newDevice(t, "deva", be)
|
|
b := newDevice(t, "devb", be)
|
|
|
|
write(t, a.Folder, "f.txt", "keepme")
|
|
cycle(t, a)
|
|
cycle(t, b)
|
|
os.Remove(filepath.Join(a.Folder, "f.txt"))
|
|
cycle(t, a)
|
|
cycle(t, b)
|
|
if _, err := os.Stat(filepath.Join(b.Folder, "f.txt")); !os.IsNotExist(err) {
|
|
t.Fatal("delete did not reach b")
|
|
}
|
|
|
|
if err := b.Restore(context.Background(), "f.txt", sha("keepme")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cycle(t, b)
|
|
cycle(t, a)
|
|
if got := read(t, a.Folder, "f.txt"); got != "keepme" {
|
|
t.Fatalf("a after restore of a deleted file = %q", got)
|
|
}
|
|
}
|
|
|
|
// A path the project doesn't sync must fail loudly: the scan would drop the
|
|
// write and the user would be told "restored" with nothing happening.
|
|
func TestRestoreRefusesIgnoredPath(t *testing.T) {
|
|
be := sharedRemote(t)
|
|
a := newDevice(t, "deva", be)
|
|
write(t, a.Folder, "secret.env", "shh")
|
|
cycle(t, a)
|
|
write(t, a.Folder, IgnoreFile, "*.env\n")
|
|
cycle(t, a)
|
|
|
|
if err := a.Restore(context.Background(), "secret.env", sha("shh")); err == nil {
|
|
t.Fatal("restoring an ignored path should refuse")
|
|
}
|
|
}
|
|
|
|
// The content may live only on the hub — a device that never held that
|
|
// version fetches it, and verifies what it got.
|
|
func TestRestoreFetchesMissingBlob(t *testing.T) {
|
|
be := sharedRemote(t)
|
|
a := newDevice(t, "deva", be)
|
|
b := newDevice(t, "devb", be)
|
|
|
|
write(t, a.Folder, "f.txt", "v1")
|
|
cycle(t, a)
|
|
time.Sleep(10 * time.Millisecond)
|
|
write(t, a.Folder, "f.txt", "v2")
|
|
cycle(t, a)
|
|
cycle(t, b)
|
|
|
|
// Simulate a device that never held that version (joined late, or pruned
|
|
// its store): the bytes exist only on the hub.
|
|
os.Remove(b.Store.BlobPath(sha("v1")))
|
|
if b.Store.HasBlob(sha("v1")) {
|
|
t.Fatal("v1 should be gone from b's store")
|
|
}
|
|
if err := b.Restore(context.Background(), "f.txt", sha("v1")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if read(t, b.Folder, "f.txt") != "v1" {
|
|
t.Fatal("blob was not fetched from the hub")
|
|
}
|
|
}
|