Files
4ff92c56ab feat(history): group agent runs and restore any version (BEA-6) (#69)
* 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>
2026-07-29 17:15:46 +09:00

136 lines
4.2 KiB
Go

package main
import (
"bytes"
"strings"
"testing"
"time"
"github.com/runbear-io/beardrive/internal/journal"
)
// ops builds a newest-first log like syncer.LogEntries returns.
func ops(specs ...[3]string) []journal.Op {
var out []journal.Op
base := time.Date(2026, 7, 28, 14, 0, 0, 0, time.UTC)
for i := len(specs) - 1; i >= 0; i-- { // specs are oldest-first; log is newest-first
s := specs[i]
op := journal.Op{Kind: s[0], Path: s[1], Blob: s[2], Time: base.Add(time.Duration(i) * time.Minute)}
if s[0] == journal.KindPut {
op.Size = 10
op.Lamport = int64(i + 1)
} else {
op.Lamport = int64(i + 1)
}
out = append(out, op)
}
return out
}
const (
v1 = "a3f9c1e2000000000000000000000000000000000000000000000000000000aa"
v2 = "b7d40000000000000000000000000000000000000000000000000000000000bb"
v3 = "a3f900000000000000000000000000000000000000000000000000000000cccc"
)
// The previous version is the last content that differs from what the file
// holds now — not simply "the op before this one", which is wrong as soon as
// a run wrote the same path twice.
func TestPickPreviousVersion(t *testing.T) {
log := ops(
[3]string{journal.KindPut, "f.md", v1},
[3]string{journal.KindPut, "f.md", v2},
[3]string{journal.KindPut, "f.md", v2}, // same content written twice
)
vs := versionsOf(log, "f.md")
got, err := pickVersion(vs, currentBlob(log, "f.md"), "")
if err != nil {
t.Fatal(err)
}
if got.Blob != v1 {
t.Fatalf("previous = %s, want v1 (%s)", got.Blob, v1)
}
}
// Latest op is a delete: the file has no current content, so "previous" is
// simply its last content — which is what makes restoring a deleted file work.
func TestPickPreviousAfterDelete(t *testing.T) {
log := ops(
[3]string{journal.KindPut, "f.md", v1},
[3]string{journal.KindPut, "f.md", v2},
[3]string{journal.KindDelete, "f.md", ""},
)
got, err := pickVersion(versionsOf(log, "f.md"), currentBlob(log, "f.md"), "")
if err != nil {
t.Fatal(err)
}
if got.Blob != v2 {
t.Fatalf("previous after delete = %s, want v2", got.Blob)
}
}
// The only version there has ever been is not restorable — say so instead of
// writing the same bytes back.
func TestPickPreviousWhenOnlyVersion(t *testing.T) {
log := ops([3]string{journal.KindPut, "f.md", v1})
if _, err := pickVersion(versionsOf(log, "f.md"), currentBlob(log, "f.md"), ""); err == nil {
t.Fatal("want an error when there is no earlier version")
}
}
func TestPickByShortSHA(t *testing.T) {
log := ops(
[3]string{journal.KindPut, "f.md", v1},
[3]string{journal.KindPut, "f.md", v3}, // shares the "a3f9" prefix with v1
[3]string{journal.KindPut, "f.md", v2},
)
vs, cur := versionsOf(log, "f.md"), currentBlob(log, "f.md")
got, err := pickVersion(vs, cur, "a3f9c1")
if err != nil || got.Blob != v1 {
t.Fatalf("unique prefix → %v, %v", got.Blob, err)
}
if _, err := pickVersion(vs, cur, "a3f9"); err == nil || !strings.Contains(err.Error(), "more characters") {
t.Fatalf("ambiguous prefix must refuse, got %v", err)
}
if _, err := pickVersion(vs, cur, "ffff"); err == nil {
t.Fatal("unknown prefix must error")
}
}
// LogEntries' path filter also matches directories and prefixes, so the
// command re-filters: restoring the wrong file would be the worst bug here.
func TestVersionsOfIsExactPath(t *testing.T) {
log := ops(
[3]string{journal.KindPut, "docs/f.md", v1},
[3]string{journal.KindPut, "docs/f.md.bak", v2},
[3]string{journal.KindPut, "docs", v3},
)
vs := versionsOf(log, "docs/f.md")
if len(vs) != 1 || vs[0].Blob != v1 {
t.Fatalf("versionsOf = %+v, want just docs/f.md", vs)
}
if len(versionsOf(log, "nope.md")) != 0 {
t.Fatal("a path with no history must yield no versions")
}
}
func TestPrintVersionsMarksCurrent(t *testing.T) {
log := ops(
[3]string{journal.KindPut, "f.md", v1},
[3]string{journal.KindPut, "f.md", v2},
)
var out bytes.Buffer
printVersions(&out, versionsOf(log, "f.md"), currentBlob(log, "f.md"))
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
if len(lines) != 2 {
t.Fatalf("listing = %d lines, want 2:\n%s", len(lines), out.String())
}
if !strings.HasPrefix(lines[0], "* "+v2[:8]) {
t.Fatalf("current version not marked: %q", lines[0])
}
if !strings.HasPrefix(lines[1], " "+v1[:8]) {
t.Fatalf("older version line = %q", lines[1])
}
}