Files
4dfe6f44f0 feat(history): undo a file an agent run created (BEA-35) (#82)
History could restore an edit or a deletion, but a file a run CREATED was
the one thing it couldn't reverse — the ADDED row said so in copy and
offered no button. The missing capability was a hub-written delete op:
restore.go only ever journaled puts.

POST /api/p/<id>/remove journals exactly one journal.KindDelete op under
the hub's own device identity, behind restore's gates (gateUpload,
PermWrite, cleanUploadPath, quota CheckWrite/RecordUsage) plus a volume-
snapshot existence check so the API 404s on what the tree doesn't show.
Commit's journal-append tail moves into RemoteSource.appendOp, which both
writes now share — one writer per journal, unchanged.

The ADDED-in-a-run row gets an "undo — remove file" control that confirms
first (it reaches every synced device), and the DELETED row it leaves
behind restores the file with its original bytes.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:11:20 +09:00

69 lines
1.9 KiB
Go

package syncer
import (
"bytes"
"context"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"github.com/runbear-io/beardrive/internal/remote"
)
// The whole point of BEA-35: a file an agent run created can be un-created
// from the hub's History view, and the removal reaches every device like any
// other change. The hub writes ONE delete op into its OWN journal — the
// device's log is untouched, so one-writer-per-journal survives.
func TestHubRemoveReachesDevices(t *testing.T) {
storage := sharedRemote(t)
ts, p := newHub(t, storage, true)
viaServer, err := remote.Open(context.Background(), ts.URL+"/p/"+p.ID)
if err != nil {
t.Fatal(err)
}
defer viaServer.Close()
a := newDevice(t, "deva", viaServer)
b := newDevice(t, "devb", remote.Prefixed(storage, p.ID))
write(t, a.Folder, "ideas.md", "an agent dumped this")
cycle(t, a)
cycle(t, b)
if read(t, b.Folder, "ideas.md") != "an agent dumped this" {
t.Fatal("b never received the file")
}
ownBefore := journalBytes(t, a, "deva")
post(t, ts.URL+"/api/p/"+p.ID+"/remove", `{"path":"ideas.md"}`)
res := cycle(t, a)
if res.PulledOps != 1 {
t.Fatalf("a pulled %d ops, want the hub's one delete", res.PulledOps)
}
if _, err := os.Stat(filepath.Join(a.Folder, "ideas.md")); !os.IsNotExist(err) {
t.Fatal("the hub's removal did not unlink the file on a")
}
if !bytes.Equal(journalBytes(t, a, "deva"), ownBefore) {
t.Fatal("the hub's removal rewrote the device's own journal")
}
// and onward to a device that talks to storage directly
cycle(t, b)
if _, err := os.Stat(filepath.Join(b.Folder, "ideas.md")); !os.IsNotExist(err) {
t.Fatal("the removal did not reach the direct-to-storage device")
}
}
func post(t *testing.T, url, body string) {
t.Helper()
res, err := http.Post(url, "application/json", strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != 200 {
t.Fatalf("POST %s: %d", url, res.StatusCode)
}
}