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>
This commit is contained in:
Snow W. Lee (Sungwon)
2026-07-29 17:15:46 +09:00
committed by GitHub
co-authored by Claude Opus 5
parent dcd0517e92
commit 4ff92c56ab
28 changed files with 1307 additions and 167 deletions
+4
View File
@@ -108,6 +108,9 @@ echo "remember this" > memory.md
# See what changed, who changed it, and from which device
bdrive log
# An agent clobbered a file? Put the old version back (as a new change)
bdrive restore memory.md
# Check sync state and the daemon
bdrive status
@@ -153,6 +156,7 @@ hub's own storage, never something a syncing client points at directly:
| `bdrive read-log [folder]` | Hook plumbing: queue agent file reads from a hook event (JSON on stdin) for the hub's read heatmap — native reads, grep matches, and files named in shell commands; drained on the next sync. Registered by `bdrive hooks install` |
| `bdrive status [folder]` | Projects, daemon state, pending changes |
| `bdrive log [folder] [-p path] [-n N]` | Change history: account, device, time, file |
| `bdrive restore <file> [version]` | Put an earlier version of a file back, as a new change (`--list` shows the versions; no version = the previous one). Nothing is erased and it syncs everywhere like any edit. A file that was *created* can't be un-created yet |
| `bdrive export [folder]` | Export the whole project — every device's journal, all blobs, full history — from its hub to a portable `.tar.gz` (`-o` names the file) |
| `bdrive import <archive>` | Import an export archive as a new project on the hub you're logged into (`--name` overrides); history and authorship carry over. Move projects between hubs — cloud → self-hosted or back — with `export` + `login` + `import` |
| `bdrive web [folder \| storage-root-url]` | Web server: viewer (rendered markdown, downloads, history), uploads, multi-project sync hub |
+3 -1
View File
@@ -22,7 +22,9 @@ classDiagram
+Prune bool
+OnProgress func
+Cycle(ctx) Result
+Restore(ctx, path, sha) error
}
note for Session "Restore writes a historical blob back into the working folder as an ordinary edit (fetching it from the hub when this device never held it) — the next Cycle journals it like any other change; it takes no lock and appends to no journal itself"
note for Session "internal/syncer — scan → commit local ops → pull peer journals → preserve conflicts → refresh rules → prune → materialize → push blobs then own journal"
note for Session "Prune (bdrive forget / sync --prune, never the daemon) journals a delete for every replayed path the SHARED ignore rules exclude — the include scope is per-device and must never prune it"
@@ -113,7 +115,7 @@ classDiagram
class Commands {
init login logout
sync stop scope forget status log
url share export import
restore url share export import
web daemon hooks read-log skill
hook-approve PreToolUse
}
+2 -1
View File
@@ -58,8 +58,9 @@ classDiagram
<<interface>>
+SignBlobPut(ctx, blob, size, ttl)
+HasBlob(ctx, blob)
+Commit(ctx, path, blob, size, who)
+Commit(ctx, path, blob, size, who, note)
}
note for DirectUploader "Commit's note is \"\" for an upload and \"restore &lt;path&gt;@&lt;sha8&gt;\" for POST /api/p/{id}/restore — which is the upload commit minus the upload: find the historical op for (path, sha), journal a NEW put at its blob. Never rewrites a journal."
class Backend {
<<interface>>
+1
View File
@@ -61,6 +61,7 @@ everything keeps working offline; changes sync when the remote is reachable.`,
skillCmd(),
statusCmd(),
logCmd(),
restoreCmd(),
exportCmd(),
importCmd(),
webCmd(),
+199
View File
@@ -0,0 +1,199 @@
package main
import (
"fmt"
"io"
"path/filepath"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/runbear-io/beardrive/internal/config"
"github.com/runbear-io/beardrive/internal/journal"
"github.com/runbear-io/beardrive/internal/syncer"
)
// restoreNoteTTL keeps the restore note short-lived: it also stamps whatever
// the daemon happens to commit next, so it must not outlive the restore by
// much.
const restoreNoteTTL = 2 * time.Minute
func restoreCmd() *cobra.Command {
var list bool
c := &cobra.Command{
Use: "restore <file> [version]",
Short: "Put an earlier version of a file back",
Long: `Restore an earlier version of a file: bdrive writes those bytes back as a
NEW change. Nothing is erased the versions in between stay in the history,
the restore itself shows up in "bdrive log", and it syncs to every device and
teammate like any other edit (so it can be restored away from too).
With no version, restores the one immediately before the current content. A
version is a short content hash from "bdrive log" or --list; any unambiguous
prefix works.
Known gap: a file that was created (rather than edited) can't be un-created
yet restore puts content back, it does not delete.`,
Example: ` bdrive restore docs/spec.md # the previous version
bdrive restore docs/spec.md --list # what versions exist
bdrive restore docs/spec.md a3f9c1e2 # a specific one`,
Args: cobra.RangeArgs(1, 2),
RunE: func(cmd *cobra.Command, args []string) error {
abs, err := filepath.Abs(args[0])
if err != nil {
return err
}
root, _, err := findProject(filepath.Dir(abs))
if err != nil {
return err
}
rel, err := filepath.Rel(root, abs)
if err != nil || strings.HasPrefix(rel, "..") {
return fmt.Errorf("%s is outside the project at %s", abs, root)
}
rel = filepath.ToSlash(rel)
// Restoring ends in a sync cycle, so it answers to the same gate
// `bdrive sync` does: it must not enroll this device or resume a
// project someone paused. Only `bdrive init` does that.
proj, ok, err := config.LoadProject(root)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("%s is not a beardrive project (run `bdrive init` there first)", root)
}
switch syncBlocked(proj) {
case "init":
return fmt.Errorf("%s is not synced on this device yet (run `bdrive init` there to connect it)", root)
case "paused":
return fmt.Errorf("syncing is paused for %s (run `bdrive init` there to resume)", root)
}
sess, _, err := openSession(cmd.Context(), root, true)
if err != nil {
return err
}
defer closeSession(sess)
all, err := syncer.LogEntries(sess.Store, "", 0) // newest first
if err != nil {
return err
}
versions := versionsOf(all, rel)
if len(versions) == 0 {
return fmt.Errorf("no history for %s", rel)
}
if list {
printVersions(cmd.OutOrStdout(), versions, currentBlob(all, rel))
return nil
}
want := ""
if len(args) == 2 {
want = args[1]
}
op, err := pickVersion(versions, currentBlob(all, rel), want)
if err != nil {
return err
}
note := fmt.Sprintf("restore %s@%s", rel, shortSHA(op.Blob))
// Persist the note so a daemon that wins the race to scan the file
// stamps it too — otherwise the restore lands in history unlabeled.
if err := sess.Store.SaveNote(note, restoreNoteTTL); err != nil {
return err
}
sess.Note = note
if err := sess.Restore(cmd.Context(), rel, op.Blob); err != nil {
return err
}
if _, err := sess.Cycle(cmd.Context()); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "restored %s to the version from %s (%s, %s)\n",
rel, op.Time.Local().Format("2006-01-02 15:04:05"), shortSHA(op.Blob), humanBytes(op.Size))
return nil
},
}
c.Flags().BoolVar(&list, "list", false, "list this file's versions instead of restoring one")
return c
}
func shortSHA(sha string) string {
if len(sha) > 8 {
return sha[:8]
}
return sha
}
// versionsOf returns the puts for exactly this path, newest first.
// LogEntries' own path filter also matches directories and prefixes, so a
// caller that wants one file must re-filter — restoring the wrong file would
// be the worst possible bug in this command.
func versionsOf(all []journal.Op, path string) []journal.Op {
var out []journal.Op
for _, op := range all {
if op.Kind == journal.KindPut && op.Path == path {
out = append(out, op)
}
}
return out
}
// currentBlob is the content the file holds right now, "" when it is deleted
// (or never existed).
func currentBlob(all []journal.Op, path string) string {
return journal.Replay(all)[path].Blob
}
// pickVersion resolves the version to restore. want is a (short) content
// hash, or "" for "the one before what the file says now" — which, when the
// file is currently deleted, is simply its last content: that is what makes
// restoring a deleted file work.
func pickVersion(versions []journal.Op, current, want string) (journal.Op, error) {
if want == "" {
for _, op := range versions {
if op.Blob != current {
return op, nil
}
}
return journal.Op{}, fmt.Errorf("no earlier version to restore — this is the only content this file has had")
}
want = strings.ToLower(want)
var match journal.Op
blobs := map[string]bool{}
for _, op := range versions {
if strings.HasPrefix(op.Blob, want) && !blobs[op.Blob] {
blobs[op.Blob] = true
if len(blobs) == 1 {
match = op
}
}
}
switch len(blobs) {
case 0:
return journal.Op{}, fmt.Errorf("no version of this file starts with %q (try --list)", want)
case 1:
return match, nil
default:
return journal.Op{}, fmt.Errorf("%q matches %d versions — use more characters", want, len(blobs))
}
}
func printVersions(w io.Writer, versions []journal.Op, current string) {
for _, op := range versions {
who := op.UserName
if who == "" {
who = op.User
}
if who == "" {
who = op.Author
}
mark := " "
if op.Blob == current {
mark = "* "
}
fmt.Fprintf(w, "%s%s %s %8s %s on %s\n", mark, shortSHA(op.Blob),
op.Time.Local().Format("2006-01-02 15:04:05"), humanBytes(op.Size), who, op.DeviceName)
}
}
+135
View File
@@ -0,0 +1,135 @@
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])
}
}
+75
View File
@@ -0,0 +1,75 @@
package syncer
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/runbear-io/beardrive/internal/config"
"github.com/runbear-io/beardrive/internal/journal"
)
// Restore writes the historical version sha of path back into the working
// folder as an ordinary local edit. The next Cycle journals it like any other
// change — nothing here appends to a journal, and no journal is ever
// rewritten. Restoring is exactly the edit a human could have made by hand,
// which is why the sync engine needs no new write path for it.
//
// It does not take the volume flock: Cycle does, and holding it here would
// deadlock the caller that runs both.
func (s *Session) Restore(ctx context.Context, path, sha string) error {
proj, _, err := config.LoadProject(s.Folder)
if err != nil {
return err
}
filter, err := loadFilter(s.Folder, proj.Include)
if err != nil {
return fmt.Errorf("load %s: %w", IgnoreFile, err)
}
// Without this the scan would silently drop the write and the user would
// be told "restored" with nothing happening.
if filter.Skip(path) || neverSync(path) {
return fmt.Errorf("%s is excluded from syncing here (see %s and this project's scope)", path, IgnoreFile)
}
if err := s.fetchBlob(ctx, sha); err != nil {
return err
}
abs := filepath.Join(s.Folder, filepath.FromSlash(path))
// writeFile is the same atomic .bdrive-tmp-* + rename materialize uses, so
// a daemon cycle landing mid-write can never journal a partial file.
return s.writeFile(abs, journal.FileState{Blob: sha, Mode: fileMode(abs)})
}
// fetchBlob makes sure the content is in the local store, pulling it from the
// remote when this device never held that version.
func (s *Session) fetchBlob(ctx context.Context, sha string) error {
if s.Store.HasBlob(sha) {
return nil
}
if s.Backend == nil {
return fmt.Errorf("that version isn't on this device and the hub is unreachable")
}
rc, err := s.Backend.Get(ctx, "blobs/"+sha)
if err != nil {
return fmt.Errorf("fetch version: %w", err)
}
defer rc.Close()
got, _, err := s.Store.PutBlobReader(rc)
if err != nil {
return err
}
if got != sha {
return fmt.Errorf("version %s arrived corrupt (hashed to %s)", sha, got)
}
return nil
}
// fileMode keeps the file's current permissions when it still exists —
// restoring content is not a reason to reset the mode.
func fileMode(abs string) uint32 {
if fi, err := os.Stat(abs); err == nil {
return uint32(fi.Mode().Perm())
}
return 0o644
}
+187
View File
@@ -0,0 +1,187 @@
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")
}
}
+7
View File
@@ -161,6 +161,13 @@ func seedE2E(t *testing.T, state, prefix, projectID string) {
"\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82"
put("assets/logo.png", png, 24*time.Hour)
ops[2].Note = "expanded the guide — https://claude.ai/session/e2e" // the one row with a note expander
// One agent run that touched two files — the history feed groups it into
// a single card. One file it edited (restorable) and one it created
// (which restore cannot undo yet, and says so).
put("notes/readme.md", "# Notes\n\nRewritten during the agent run.\n", 90*time.Minute)
put("runbook.md", "# Runbook\n\nCreated during the agent run.\n", 90*time.Minute)
ops[len(ops)-1].Note = "claude-code session 8f21e4"
ops[len(ops)-2].Note = "claude-code session 8f21e4"
// A second version of the same binary, so the history diff has a
// predecessor to refuse to diff (the "binary — no diff" path).
put("assets/logo.png", png+"\x00trailing", 3*time.Hour)
@@ -422,6 +422,75 @@ test("delete rows have no version to open, so they stay unclickable", async ({ p
await expect(page).toHaveURL(`/${pid}/history/scratch.md`);
});
// BEA-6: one agent run is one card, and any version can be put back.
test("history groups one agent run into a single card", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/history`);
const run = page.locator(".hrun");
await expect(run).toHaveCount(1);
await expect(run.locator(".hrun-note")).toHaveText("claude-code session 8f21e4");
await expect(run.locator(".hrun-meta")).toContainText("2 files");
await expect(run.locator(".hrun-meta")).toContainText("seed-agent");
// Both of the run's changes live inside the card...
await expect(run.locator(".hentry")).toHaveCount(2);
await expect(run.locator('.hentry:has-text("runbook.md")')).toBeVisible();
// ...and note-less changes are still bare rows, exactly as before.
await expect(page.locator(".history > .hentry").first()).toBeVisible();
await expect(page.locator(".history > .hentry .hrun-note")).toHaveCount(0);
// The note is not repeated on every row inside the card.
await expect(run.locator(".hnote")).toHaveCount(0);
// The card collapses without navigating.
await run.locator(".hrun-toggle").click();
await expect(run.locator(".hentry")).toHaveCount(0);
await expect(page).toHaveURL(`/${pid}/history`);
});
test("a file the run created says why it can't be undone", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/history`);
const created = page.locator('.hrun .hentry.add:has-text("runbook.md")');
await expect(created).toBeVisible();
await expect(created.locator(".hrestore-btn")).toHaveCount(0);
await expect(created.locator(".hrestore-gap")).toContainText("created by this run");
// The file it edited does offer one.
await expect(
page.locator('.hrun .hentry.edit:has-text("notes/readme.md") .hrestore-btn'),
).toBeVisible();
});
test("restoring an old version brings its content back", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
// Its own file, with its own two versions — restoring is a real write, so
// it must not disturb what the rest of the suite reads.
const path = "restore-me.md";
const url = `/api/p/${pid}/upload/content?path=${path}`;
await page.request.put(url, { data: "# Restore me\n\nThe good version.\n" });
await page.request.put(url, { data: "# Restore me\n\nClobbered by an agent.\n" });
await page.goto(`/${pid}/history/${path}`);
const older = page.locator(".hentry.add"); // the first version
await expect(older).toBeVisible();
await older.locator(".hrestore-btn").click();
await expectToast(page, /Restored restore-me\.md/);
// The restore is itself a change, and the file serves the old bytes again.
await expect(page.locator(".history .hentry")).toHaveCount(3);
await expect(page.locator(".history .hentry").first()).toContainText("restore restore-me.md@");
await page.goto(`/${pid}/${path}`);
await expect(page.locator("#content")).toContainText("The good version");
});
test("a read-only member gets no restore buttons", async ({ page }) => {
await login(page, READER);
const pid = await wikiId(page);
await page.goto(`/${pid}/history`);
await expect(page.locator(".history .hentry").first()).toBeVisible();
await expect(page.locator(".hrestore-btn")).toHaveCount(0);
});
// BEA-26: the row was already an address for its version — but a bare
// role="button" div announces that to nobody, so a persona whose whole fear
// is "an agent quietly rewrote my doc" concludes recovery is impossible.
-25
View File
@@ -1,25 +0,0 @@
import { chromium } from "@playwright/test";
const OUT = process.argv[2], B = "http://localhost:8993";
const b = await chromium.launch();
async function as(email) {
const c = await b.newContext({ viewport: { width: 1280, height: 860 } });
const p = await c.newPage();
await p.goto(B + "/"); await p.waitForURL(/auth\/login/);
await p.fill('input[name="email"]', email); await p.fill('input[name="password"]', "e2e-pass-1");
await p.click("form button"); await p.waitForSelector("#sidebar");
return p;
}
const r = await as("reader@example.com");
const pid = (await (await r.request.get(B+"/api/projects")).json()).projects.find(x=>x.name==="wiki").id;
await r.goto(`${B}/${pid}/dashboard`); await r.waitForTimeout(1200);
console.log("reader crumb:", await r.locator("#crumb").innerText());
console.log("reader treemap count:", await r.locator(".in-treemap").count());
const m = await as("member@example.com");
await m.goto(`${B}/${pid}/notes`); await m.waitForTimeout(800);
await m.click("#more-btn"); await m.waitForTimeout(300);
console.log("member ⋯ items:", await m.locator("#more-menu .more-item").allInnerTexts());
await m.screenshot({ path: `${OUT}/after-05-member-more-menu.png` });
const res = await m.request.get(`${B}/api/p/${pid}/heat`);
const body = await res.text();
console.log("member /heat:", res.status(), "identity leak:", /@|token|device_id/.test(body));
await b.close();
+2
View File
@@ -27,6 +27,8 @@ function errorFor(status: number, body: string): string {
return raw ? raw[0].toUpperCase() + raw.slice(1) : "That is managed outside this hub.";
case 404:
return "That is gone — it may have been removed already.";
case 413:
return "This project is over its plan limit.";
case 429:
return "Too many requests. Give it a moment.";
default:
@@ -8,6 +8,7 @@ import {
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { atLeast } from "../api/types";
import { postJSON } from "../api/http";
import type { Project, ServerConfig } from "../api/types";
import { useHeat, useTree } from "../hooks/useBrowse";
import { useShares } from "../hooks/useHub";
@@ -198,6 +199,31 @@ export default function Browser(props: {
}
}, [apiBase, path, refreshShares]);
/* ---- restore ----
Putting an old version back is a write, so a read-only member sees no
Restore button rather than one that 403s. The restore itself is a new
change: the tree, the file, and the history feed all move. */
const [restoring, setRestoring] = useState("");
const canRestore = hub && !!project && atLeast(project?.perm, "write");
const onRestore = useCallback(
async (p: string, sha: string) => {
setRestoring(p + sha);
try {
await postJSON(apiBase + "restore", { path: p, sha });
qc.invalidateQueries({ queryKey: ["history", apiBase] });
qc.invalidateQueries({ queryKey: ["tree", apiBase] });
qc.invalidateQueries({ queryKey: ["render", apiBase, p] });
qc.invalidateQueries({ queryKey: ["text"] });
toast("Restored " + p + " — it syncs to every device like any other change.");
} catch (err) {
toast("Restore failed: " + (err as Error).message, true);
} finally {
setRestoring("");
}
},
[apiBase, qc],
);
const historyNow = useCallback(() => {
if (!path) return openHistory("");
openHistory(isDir ? path + "/" : path);
@@ -281,6 +307,7 @@ export default function Browser(props: {
onOpen={openPath}
onMeta={setMeta}
onRendered={onRendered}
restore={canRestore ? { onRestore, busy: restoring } : undefined}
/>
);
} else if (path) {
@@ -14,11 +14,41 @@ import { DiffView } from "./DiffView";
own control and its own aria-expanded. */
const KIND_LABEL: Record<string, string> = { add: "added", edit: "edited", delete: "deleted" };
// Putting an old version back. Absent when the viewer can't write (or this
// isn't a hub project), in which case no Restore button is drawn at all —
// better than one that 403s.
export type RestoreAction = {
onRestore: (path: string, sha: string) => void;
busy?: string; // path+sha currently in flight
};
// Linkify http(s) URLs (e.g. a Claude session link); everything else stays
// plain text — notes are user/agent input, never markup. Shared with the run
// card's header, which shows the same note.
export function NoteText({ text }: { text: string }) {
return (
<>
{text.split(/(https?:\/\/\S+)/).map((tok, i) =>
/^https?:\/\//.test(tok) ? (
<a key={i} href={tok} target="_blank" rel="noopener">
{tok}
</a>
) : (
tok
),
)}
</>
);
}
export function HistoryRow({
entry: e,
apiBase,
onOpen,
diff,
restore,
restoreSha,
inRun,
}: {
entry: HistoryEntry;
// Its own prop, not something nested in `diff`: the version controls below
@@ -31,6 +61,13 @@ export function HistoryRow({
// is unambiguous. `prev` is the sha of the entry before this one on the
// same path; absent means this is the first version.
diff?: { apiBase: string; prev?: string };
restore?: RestoreAction;
// The version this row puts back: its own bytes, or — for a delete — the
// content it removed. The view computes it, since it needs the whole feed.
restoreSha?: string;
// Inside a run card, where "this run created the file" is a statement we
// can actually make.
inRun?: boolean;
}) {
const [noteOpen, setNoteOpen] = useState(false);
const [diffOpen, setDiffOpen] = useState(false);
@@ -40,6 +77,14 @@ export function HistoryRow({
const clickable = kind !== "delete";
// A delete has no content, and a first version has nothing behind it.
const diffable = !!diff && kind !== "delete" && !!e.blob;
// Inside a run card an "add" is a file the run CREATED: putting its bytes
// back would be a no-op, and removing it is the thing we can't do yet — so
// that row explains itself instead of offering a button. Everywhere else
// (the per-file version list) a first version is very much restorable —
// it is usually the version someone wants back.
const createdByRun = !!inRun && kind === "add";
const restorable = !!restore && !!restoreSha && !createdByRun;
const busy = !!restore?.busy && restore.busy === e.path + restoreSha;
// The row already *is* a link to its version — but a bare div says so to
// nobody, so the version gets visible handles too (BEA-26). Gated on
// content, never on `diff`, or the subtree and folder feeds lose them.
@@ -74,8 +119,36 @@ export function HistoryRow({
<span className="hwho">{who}</span>
<span className="hdev">{dev}</span>
<span className="hsize">{e.size ? humanSize(e.size) : ""}</span>
{/* Restoring is not navigating, so the click stops here same rule
the note and diff controls follow. */}
{restorable && (
<button
type="button"
className="hrestore-btn"
disabled={busy}
title={"Put this version of " + e.path + " back as a new change"}
onClick={(ev) => {
ev.stopPropagation();
restore!.onRestore(e.path, restoreSha!);
}}
onKeyDown={(ev) => ev.stopPropagation()}
>
<Icon name="hist" />
{busy ? "restoring…" : "restore"}
</button>
)}
{/* Never a missing button with no explanation: nothing in the hub
writes a delete op yet, so a file a run created can't be
un-created. Say so where the button would have been. */}
{restore && createdByRun && (
<span className="hrestore-gap" title="Restore puts old content back; it can't remove a file yet.">
created by this run can't be undone yet
</span>
)}
</div>
{e.note && (
{/* Inside a run card the note is the card's header repeating it on
every row says the same thing N times. */}
{e.note && !inRun && (
<div
className={"hnote" + (noteOpen ? " open" : "")}
tabIndex={0}
@@ -95,18 +168,7 @@ export function HistoryRow({
}
}}
>
{/* Linkify http(s) URLs (e.g. a Claude session link); everything
else stays plain text notes are user/agent input, never
markup. */}
{e.note.split(/(https?:\/\/\S+)/).map((tok, i) =>
/^https?:\/\//.test(tok) ? (
<a key={i} href={tok} target="_blank" rel="noopener">
{tok}
</a>
) : (
tok
),
)}
<NoteText text={e.note} />
</div>
)}
{/* Each control is its own: acting on a row must not double as
+24
View File
@@ -655,6 +655,30 @@ a.ai-main:hover { color: var(--accent); }
.hnote a { color: var(--accent-bright); text-decoration: none; }
.hnote a:hover { text-decoration: underline; }
/* ---- run groups (one agent session = one card) ---- */
.hrun { border: 1px solid var(--border); border-radius: var(--r-card); background: var(--bg-side); margin: 10px 0; overflow: hidden; }
.hrun-head { display: flex; align-items: center; gap: 9px; width: 100%; padding: 9px 12px; color: var(--text); font-size: 12.5px; }
.hrun-toggle { display: flex; flex: none; padding: 2px; border: none; border-radius: 4px; background: none; color: var(--text-faint); cursor: pointer; }
.hrun-toggle:hover { color: var(--text); background: var(--hover); }
.hrun-toggle .ico { width: 13px; height: 13px; }
.hrun-note { font-weight: 560; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 46%; }
.hrun-note a { color: var(--accent-bright); text-decoration: none; }
.hrun-note a:hover { text-decoration: underline; }
.hrun-meta { color: var(--text-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hrun-time { margin-left: auto; flex: none; color: var(--text-faint); font-variant-numeric: tabular-nums; }
/* Rows inside a card don't repeat the card's own border or note. */
.hrun-body { border-top: 1px solid var(--border); }
.hrun-body .hentry:last-child { border-bottom: none; }
/* ---- restore ---- */
.hrestore-btn { display: inline-flex; align-items: center; gap: 4px; margin-left: auto; padding: 2px 8px 2px 5px; border: 1px solid var(--border); border-radius: 5px; background: none; color: var(--text-faint); font: inherit; font-size: 12px; cursor: pointer; }
.hrestore-btn:hover { color: var(--accent-bright); border-color: var(--border-2); background: var(--hover); }
.hrestore-btn:disabled { opacity: .5; cursor: default; }
.hrestore-btn .ico { width: 12px; height: 12px; }
/* The gap has to explain itself: nothing writes a delete op yet, so a file
a run created cannot be un-created. */
.hrestore-gap { margin-left: auto; color: var(--text-ghost); font-size: 11.5px; }
/* ---- a row's controls: diff (per-file only) + this version's handles ---- */
.hactions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 6px 0 0 23px; }
.hdiff-btn, .hver-btn { display: inline-flex; align-items: center; gap: 4px; padding: 2px 7px 2px 4px; border: 1px solid var(--border); border-radius: 5px; background: none; color: var(--text-faint); font: inherit; font-size: 12px; cursor: pointer; text-decoration: none; }
+1
View File
@@ -143,6 +143,7 @@ func TestReadOnlyMemberRoutes(t *testing.T) {
{"PUT", base + "store/object?key=journal/d.jsonl", []byte("{}")},
{"POST", base + "store/sign", map[string]any{"key": "blobs/" + strings.Repeat("a", 64), "size": 1}},
{"POST", base + "shares", map[string]string{"path": "x.md"}},
{"POST", base + "restore", map[string]string{"path": "x.md", "sha": strings.Repeat("a", 64)}},
{"PATCH", "/api/projects/" + p.ID, map[string]string{"name": "nope"}},
{"DELETE", "/api/projects/" + p.ID, nil},
{"PUT", base + "permissions", map[string]string{"default": "read"}},
+87
View File
@@ -0,0 +1,87 @@
package webapp
import (
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/runbear-io/beardrive/internal/journal"
)
// Restore puts an old version of a file back — as a NEW op, never by editing
// history. The blob is already in the store (they are retained forever), so
// this is the upload commit minus the upload: find the historical op, journal
// a put pointing at the same blob, done. Every device then converges on it
// like any other change, and the restore is itself restorable.
//
// What it deliberately is not: removing the offending ops. That would break
// one-writer-per-journal, strand peers that already replayed them, and
// corrupt the push cursor.
// handleRestore serves POST /api/p/<id>/restore {path, sha}.
func (s *Server) handleRestore(v *volume, w http.ResponseWriter, r *http.Request) {
up := s.gateUpload(v, w) // a read-only hub stays read-only
if up == nil {
return
}
rs := storeSource(v, w)
if rs == nil {
return
}
var req struct {
Path string `json:"path"`
SHA string `json:"sha"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
return
}
p, err := cleanUploadPath(req.Path)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if !blobRe.MatchString(req.SHA) {
http.Error(w, "sha must be 64 lowercase hex chars", http.StatusBadRequest)
return
}
all, err := rs.loadOps(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
// The sha must be a version OF THIS PATH: without this, restore would
// paste any blob in the store onto any path.
var found *journal.Op
for i := range all {
if op := &all[i]; op.Kind == journal.KindPut && op.Path == p && op.Blob == req.SHA {
found = op
break
}
}
if found == nil {
http.Error(w, "no such version of that file", http.StatusNotFound)
return
}
// The blob is already stored, so a restore adds no bytes — but an org
// whose plan is blocked must still be blocked from writing.
org := s.orgOf(r.PathValue("project"))
if err := s.quota().CheckWrite(org, 0); err != nil {
http.Error(w, err.Error(), http.StatusRequestEntityTooLarge)
return
}
note := fmt.Sprintf("restore %s@%s", p, req.SHA[:8])
// Size comes from the historical op, never from the request body.
if err := rs.Commit(r.Context(), p, req.SHA, found.Size, s.requestUser(r), note); err != nil {
code := http.StatusBadGateway
if err == errBlobMissing {
code = http.StatusConflict
}
http.Error(w, fmt.Sprintf("restore: %v", err), code)
return
}
s.quota().RecordUsage(org, 0)
v.invalidate()
writeJSON(w, map[string]any{"ok": true, "blob": req.SHA, "size": found.Size})
}
+232
View File
@@ -0,0 +1,232 @@
package webapp
import (
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"github.com/runbear-io/beardrive/internal/journal"
)
// journalsAt reads every journal file under a project's storage prefix, so a
// test can prove a write touched only the server's own.
func journalsAt(t *testing.T, dir string) map[string]string {
t.Helper()
entries, err := os.ReadDir(filepath.Join(dir, "journal"))
if err != nil {
t.Fatal(err)
}
out := map[string]string{}
for _, e := range entries {
b, err := os.ReadFile(filepath.Join(dir, "journal", e.Name()))
if err != nil {
t.Fatal(err)
}
out[e.Name()] = string(b)
}
return out
}
func historyOf(t *testing.T, h http.Handler, base, path string) []HistoryEntry {
t.Helper()
rec := do(t, h, "GET", base+"history?path="+path, nil)
var out struct {
Entries []HistoryEntry `json:"entries"`
}
mustJSON(t, rec, &out)
return out.Entries
}
// Restoring appends a new put pointing at the old blob: the file serves the
// historical content again, and the restore itself is visible in history.
func TestRestoreWritesNewOp(t *testing.T) {
srv, p, root := newHub(t, true, nil)
dir := filepath.Join(root, p.ID)
f := newFakeRemoteAt(t, dir)
f.put("dev1", "notes/plan.md", "v1")
f.put("dev1", "notes/plan.md", "v2 longer")
h := srv.Handler()
base := "/api/p/" + p.ID + "/"
rec := do(t, h, "POST", base+"restore", map[string]string{"path": "notes/plan.md", "sha": shaOf("v1")})
var out struct {
OK bool `json:"ok"`
Blob string `json:"blob"`
Size int64 `json:"size"`
}
mustJSON(t, rec, &out)
if !out.OK || out.Blob != shaOf("v1") || out.Size != int64(len("v1")) {
t.Fatalf("restore response = %+v", out)
}
if rec := do(t, h, "GET", base+"file?path=notes/plan.md", nil); rec.Body.String() != "v1" {
t.Fatalf("file after restore = %q, want v1", rec.Body)
}
entries := historyOf(t, h, base, "notes/plan.md")
if len(entries) != 3 {
t.Fatalf("history = %d entries, want 3", len(entries))
}
newest := entries[0]
want := "restore notes/plan.md@" + shaOf("v1")[:8]
if newest.Note != want {
t.Fatalf("restore note = %q, want %q", newest.Note, want)
}
if newest.Blob != shaOf("v1") || newest.Size != int64(len("v1")) {
t.Fatalf("restore entry = %+v", newest)
}
}
// A blob that exists but was never a version of THIS path must not be
// pasteable onto it.
func TestRestoreUnknownVersion(t *testing.T) {
srv, p, root := newHub(t, true, nil)
dir := filepath.Join(root, p.ID)
f := newFakeRemoteAt(t, dir)
f.put("dev1", "a.md", "mine")
f.put("dev1", "b.md", "someone else's")
h := srv.Handler()
base := "/api/p/" + p.ID + "/"
before := journalsAt(t, dir)
for _, sha := range []string{shaOf("b.md content that is elsewhere"), shaOf("someone else's")} {
rec := do(t, h, "POST", base+"restore", map[string]string{"path": "a.md", "sha": sha})
if rec.Code != http.StatusNotFound {
t.Fatalf("restore of a foreign blob: %d %s, want 404", rec.Code, rec.Body)
}
}
// a malformed sha never reaches the journals either
if rec := do(t, h, "POST", base+"restore", map[string]string{"path": "a.md", "sha": "nope"}); rec.Code != http.StatusBadRequest {
t.Fatalf("bad sha: %d, want 400", rec.Code)
}
if got := journalsAt(t, dir); len(got) != len(before) {
t.Fatalf("a refused restore wrote a journal: %v → %v", before, got)
}
for name, data := range before {
if got := journalsAt(t, dir)[name]; got != data {
t.Fatalf("journal %s changed on a refused restore", name)
}
}
}
// One writer per journal: the hub appends to its own key and to nothing else.
func TestRestoreOnlyTouchesOwnJournal(t *testing.T) {
srv, p, root := newHub(t, true, nil)
dir := filepath.Join(root, p.ID)
f := newFakeRemoteAt(t, dir)
f.put("dev1", "f.md", "v1")
f.put("dev2", "f.md", "v2")
h := srv.Handler()
base := "/api/p/" + p.ID + "/"
before := journalsAt(t, dir)
rec := do(t, h, "POST", base+"restore", map[string]string{"path": "f.md", "sha": shaOf("v1")})
if rec.Code != 200 {
t.Fatalf("restore: %d %s", rec.Code, rec.Body)
}
after := journalsAt(t, dir)
for _, dev := range []string{"dev1.jsonl", "dev2.jsonl"} {
if after[dev] != before[dev] {
t.Fatalf("restore rewrote %s", dev)
}
}
own := after[webDevice.ID+".jsonl"]
if own == "" || !strings.HasPrefix(own, before[webDevice.ID+".jsonl"]) {
t.Fatal("the server's own journal was not appended to")
}
ops, err := journal.Parse([]byte(own))
if err != nil || len(ops) != 1 {
t.Fatalf("own journal ops = %d (%v), want 1", len(ops), err)
}
}
// A blocked plan blocks restores too, even though a restore stores no new
// bytes — and nothing is journaled when it does.
func TestRestoreQuotaBlocked(t *testing.T) {
srv, p, root := newHub(t, true, nil)
dir := filepath.Join(root, p.ID)
f := newFakeRemoteAt(t, dir)
f.put("dev1", "f.md", "v1")
f.put("dev1", "f.md", "v2")
q := &recQuota{denyW: true}
srv.Quota = q
h := srv.Handler()
base := "/api/p/" + p.ID + "/"
before := journalsAt(t, dir)
rec := do(t, h, "POST", base+"restore", map[string]string{"path": "f.md", "sha": shaOf("v1")})
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("blocked restore: %d %s, want 413", rec.Code, rec.Body)
}
if len(q.usage) != 0 {
t.Fatalf("a denied restore recorded usage: %+v", q.usage)
}
if len(q.writes) != 1 || q.writes[0].bytes != 0 {
t.Fatalf("CheckWrite calls = %+v, want one for 0 bytes", q.writes)
}
for name, data := range journalsAt(t, dir) {
if data != before[name] {
t.Fatalf("denied restore wrote journal %s", name)
}
}
// allowed again: it goes through, and records zero bytes
q.denyW = false
if rec := do(t, h, "POST", base+"restore", map[string]string{"path": "f.md", "sha": shaOf("v1")}); rec.Code != 200 {
t.Fatalf("restore after unblocking: %d %s", rec.Code, rec.Body)
}
if len(q.usage) != 1 || q.usage[0].bytes != 0 {
t.Fatalf("RecordUsage = %+v, want one call for 0 bytes", q.usage)
}
}
// A deleted file comes back: the blob outlives the delete op.
func TestRestoreAfterDelete(t *testing.T) {
srv, p, root := newHub(t, true, nil)
dir := filepath.Join(root, p.ID)
f := newFakeRemoteAt(t, dir)
f.put("dev1", "gone.md", "still here")
f.del("dev1", "gone.md")
h := srv.Handler()
base := "/api/p/" + p.ID + "/"
if rec := do(t, h, "GET", base+"tree", nil); strings.Contains(rec.Body.String(), "gone.md") {
t.Fatal("file should be deleted before the restore")
}
rec := do(t, h, "POST", base+"restore", map[string]string{"path": "gone.md", "sha": shaOf("still here")})
if rec.Code != 200 {
t.Fatalf("restore: %d %s", rec.Code, rec.Body)
}
if rec := do(t, h, "GET", base+"tree", nil); !strings.Contains(rec.Body.String(), "gone.md") {
t.Fatalf("file did not come back: %s", rec.Body)
}
if rec := do(t, h, "GET", base+"file?path=gone.md", nil); rec.Body.String() != "still here" {
t.Fatalf("restored content = %q", rec.Body)
}
}
// Restore is a write: a hub running without --upload stays read-only.
func TestRestoreNeedsUploadsEnabled(t *testing.T) {
srv, p, root := newHub(t, false, nil)
f := newFakeRemoteAt(t, filepath.Join(root, p.ID))
f.put("dev1", "f.md", "v1")
h := srv.Handler()
rec := do(t, h, "POST", "/api/p/"+p.ID+"/restore", map[string]string{"path": "f.md", "sha": shaOf("v1")})
if rec.Code != http.StatusForbidden {
t.Fatalf("restore on a read-only hub: %d %s, want 403", rec.Code, rec.Body)
}
}
// The single-volume (plain folder) server has no journal to look a version
// up in, so it has no restore route at all.
func TestRestoreNotOnSingleVolume(t *testing.T) {
f := newFakeRemote(t)
f.put("dev1", "f.md", "v1")
h := f.uploadServer(nil).Handler()
rec := do(t, h, "POST", "/api/restore", map[string]string{"path": "f.md", "sha": shaOf("v1")})
if rec.Code < 400 {
t.Fatalf("single-volume restore: %d, want no such route", rec.Code)
}
}
+3
View File
@@ -366,6 +366,9 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/p/{project}/history", proj(PermRead, s.handleHistory))
mux.HandleFunc("GET /api/p/{project}/blob", proj(PermRead, s.handleBlob))
// Restore needs a journal to look the version up in, so it exists only
// per project — never on the single-volume (DirSource) prefix.
mux.HandleFunc("POST /api/p/{project}/restore", proj(PermWrite, s.handleRestore))
mux.HandleFunc("GET /api/p/{project}/heat", proj(PermRead, s.handleHeat))
mux.HandleFunc("POST /api/p/{project}/reads", proj(PermRead, s.handleReadReport))
mux.HandleFunc("POST /api/p/{project}/shares", proj(PermWrite, s.handleShareCreate))
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BearDrive</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='%23f5a623'><rect x='4' y='4' width='5.6' height='24'/><rect x='11.2' y='4' width='14.4' height='11.2'/><rect x='11.2' y='16.8' width='16.8' height='11.2'/></svg>">
<script type="module" crossorigin src="/assets/index-D3Zwa1cX.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C4JfQXVN.css">
<script type="module" crossorigin src="/assets/index-CBxy_alH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-ozNOEdCg.css">
</head>
<body>
<div id="root"></div>
+7 -4
View File
@@ -45,7 +45,9 @@ type DirectUploader interface {
Uploader
SignBlobPut(ctx context.Context, blob string, size int64, ttl time.Duration) (*remote.SignedPut, error)
HasBlob(ctx context.Context, blob string) (bool, error)
Commit(ctx context.Context, path, blob string, size int64, who User) error
// note rides along on the journaled op — "" for an ordinary upload,
// "restore <path>@<sha8>" when the write is a restore.
Commit(ctx context.Context, path, blob string, size int64, who User, note string) error
}
// ---- RemoteSource: writes go to the object store + our own journal ----
@@ -85,7 +87,7 @@ func (r *RemoteSource) Upload(ctx context.Context, p string, src io.Reader, _ in
if err := r.Backend.Put(ctx, "blobs/"+blob, tmp, size); err != nil {
return fmt.Errorf("push blob: %w", err)
}
return r.Commit(ctx, p, blob, size, who)
return r.Commit(ctx, p, blob, size, who, "")
}
// Commit appends a put op for path→blob to this server's own journal. It
@@ -93,7 +95,7 @@ func (r *RemoteSource) Upload(ctx context.Context, p string, src io.Reader, _ in
// whose content is missing). Only this server writes this journal key, so
// the read-modify-write below has a single writer; upmu serializes it across
// concurrent requests.
func (r *RemoteSource) Commit(ctx context.Context, p, blob string, size int64, who User) error {
func (r *RemoteSource) Commit(ctx context.Context, p, blob string, size int64, who User, note string) error {
if r.Device.ID == "" {
return fmt.Errorf("no device identity configured for uploads")
}
@@ -124,6 +126,7 @@ func (r *RemoteSource) Commit(ctx context.Context, p, blob string, size int64, w
Device: r.Device.ID, DeviceName: r.Device.Name, Author: r.Device.Author,
User: who.Email, UserName: who.Name,
Kind: journal.KindPut, Path: p, Blob: blob, Size: size, Mode: 0o644,
Note: note,
}
// Read-modify-write of our own journal. A transient read error must fail
@@ -330,7 +333,7 @@ func (s *Server) handleUploadCommit(v *volume, w http.ResponseWriter, r *http.Re
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if err := direct.Commit(r.Context(), req.Path, req.SHA256, req.Size, s.requestUser(r)); err != nil {
if err := direct.Commit(r.Context(), req.Path, req.SHA256, req.Size, s.requestUser(r), ""); err != nil {
code := http.StatusBadGateway
if err == errBlobMissing {
code = http.StatusConflict
+21
View File
@@ -26,6 +26,7 @@ Use this skill whenever the user is working with the `bdrive` CLI: initializing
| Record agent file reads (hook plumbing) | `bdrive read-log [<folder>]` — parses a hook event JSON from stdin and queues in-project reads locally (native reads, grep matches, and files named in shell commands); drained to the hub on the next sync as agent traffic in the read heatmap. Registered automatically by `bdrive hooks install`; rarely run by hand |
| Mounts + daemon + pending state | `bdrive status [<folder>]` |
| Change history | `bdrive log [<folder>] [-p path] [-n N]` |
| Undo a change to a file | `bdrive restore <file> [<version>]` — writes an earlier version back as a NEW change (never rewrites history); no version = the previous one, `--list` shows them. Also in the hub's History view. Cannot un-create a file a run created (see below) |
| Move a project to a different hub (cloud ↔ self-hosted) | `bdrive export [<folder>]` writes a portable `.tar.gz` of the whole project — every device's journal and every blob, so full history and authorship travel. Then `bdrive login <other-hub>` and `bdrive import <archive>` recreates it there as a new project (`--name` overrides; target must be empty); connect folders with `bdrive init --project <id>`. Sync first so the export is complete. |
| This device's identity | `bdrive whoami` |
| Sign this device in (once per device) | `bdrive login [url]` — bare form targets BearDrive Cloud (beardrive.ai): signing up there auto-creates a free personal workspace, no questions asked. Self-hosting teams pass their hub URL instead. Opens the sign-in page in a browser (sign-up available there); the terminal completes on its own and stores a per-device token. `--device` prints a code to approve from any browser (SSH/headless), and login falls back to that code flow automatically when there is no TTY (agent shells, CI) or no browser opens; `--status` shows server + account. Password reset: "Forgot password?" on the sign-in page (emailed via the server's SMTP config, or the link appears in the server log). **Switch hubs** with `bdrive login <new-url>`, then re-run `bdrive init` in each folder. |
@@ -503,6 +504,26 @@ Answers:
History is content-addressed — overwritten and deleted files are still in the log, with blobs retained under `~/.bdrive/volumes/<mount-id>/blobs/`.
### `bdrive restore <file> [version]`
Undo a bad change — an agent (maybe you) rewrote a file and the old content was better.
```sh
bdrive restore docs/spec.md # the version before the current content
bdrive restore docs/spec.md --list # short hash, time, size, who — pick one
bdrive restore docs/spec.md a3f9c1e2 # that version (any unique short-hash prefix)
```
```
restored docs/spec.md to the version from 2026-07-28 14:01 (a3f9c1e2, 12.0 KB)
```
Restore writes the old bytes back as a **new change**: nothing is erased, the versions in between stay in the history, the restore itself appears in `bdrive log` (note `restore <path>@<sha8>`), and it syncs to every device and teammate like any ordinary edit — so a restore can itself be restored away from. A file whose latest op is a delete comes back. The hub's History view has the same per-version Restore button.
**Known gap:** restore puts content back; it cannot remove a file yet, so a file that a run *created* cannot be un-created. Delete it normally and let the next sync carry that.
An ignored/out-of-scope path is refused rather than silently dropped by the next scan; a path with no history exits non-zero and writes nothing.
### `bdrive whoami`
```
@@ -25,6 +25,7 @@ One binary, `bdrive` — the CLI, the sync daemon, and the web server.
| `bdrive read-log [folder]` | Hook plumbing: queue agent file reads for the hub's read heatmap. Registered by `bdrive hooks install` |
| `bdrive status [folder]` | Projects, daemon state, pending changes |
| `bdrive log [folder] [-p path] [-n N]` | Change history: account, device, time, file |
| `bdrive restore <file> [version]` | Put an earlier version of a file back, as a new change. No version restores the previous one; `--list` shows the versions with their short hashes |
| `bdrive export [folder]` | Export the whole project — all devices' history and content — to a portable `.tar.gz` (`-o` names the file) |
| `bdrive import <archive>` | Import an export archive as a new project on the hub you're logged into (`--name` overrides the archive's name) |
| `bdrive web [folder \| storage-root-url]` | Web server: viewer, uploads, multi-project sync hub |
@@ -69,6 +70,28 @@ Stamps session context — an agent session id, say — onto changes. It shows u
`bdrive log` and hub history, and keeps applying to daemon-committed changes
until `--note-ttl` expires.
### `bdrive restore` — undoing a change
An agent rewrote a file you liked. Put the old bytes back:
```
$ bdrive restore docs/spec.md
restored docs/spec.md to the version from 2026-07-28 14:01 (a3f9c1e2, 12.0 KB)
$ bdrive restore docs/spec.md --list # short hash, time, size, who
$ bdrive restore docs/spec.md a3f9c1e2 # a specific version (any unique prefix)
```
Restoring writes those bytes back as a **new change**. Nothing is erased: the
versions in between stay in the history, the restore itself shows up in
`bdrive log` and the hub's History view, and it syncs to every device and
teammate like any other edit — so you can restore away from a restore. The hub
has the same button on every history row.
**Known gap:** restore puts content back; it cannot yet remove a file, so a
file that a run *created* cannot be un-created. Delete it yourself and let the
next sync carry that.
### `bdrive forget` and `bdrive sync --prune` — cleaning up the hub
Adding a rule to `.bdriveignore` only stops *future* uploads. Anything that