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>
This commit is contained in:
Snow Lee (Sungwon)
2026-07-31 07:11:20 +09:00
committed by GitHub
co-authored by Claude Opus 5
parent 31472c56e7
commit 4dfe6f44f0
20 changed files with 620 additions and 163 deletions
+1 -1
View File
@@ -237,7 +237,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 — newest first by the time shown, which is when the file was written (ops recorded before this was tracked, and deletes, show their sync time instead) |
| `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 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. To un-create a file a run *created*, use **undo — remove file** on that row in the hub's History view |
| `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 serve [folder \| storage-root-url]` | Web server: viewer (rendered markdown, downloads, history), uploads, multi-project sync hub (`bdrive web` is a deprecated alias) |
+3
View File
@@ -50,6 +50,8 @@ classDiagram
class RemoteSource {
+Backend remote.Backend
+Device Identity
+Remove(ctx, path, who, note)
-appendOp(ctx, op)
}
class Uploader {
<<interface>>
@@ -62,6 +64,7 @@ classDiagram
+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."
note for RemoteSource "Every write ends at appendOp: stamp Seq/Lamport/Time + this server's Identity, append ONE op to journal/&lt;own-device&gt;.jsonl. Commit does that for a put; Remove (POST /api/p/{id}/remove, restore's gates + a snapshot existence check) does it for a delete — the only server path that takes a file away, and itself undone by restoring the DELETED row."
class Backend {
<<interface>>
+3 -2
View File
@@ -33,8 +33,9 @@ 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.`,
Restore puts content back; it does not delete. To un-create a file a run
created, use the undo button on that row in the hub's History view or just
delete the file here and let the next sync carry it.`,
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`,
+3
View File
@@ -26,6 +26,9 @@ func newHub(t *testing.T, storage remote.Backend, upload bool) (*httptest.Server
}
srv := &webapp.Server{
Root: storage, Projects: db, Refresh: 0,
// The hub journals its own ops (browser uploads, restore, remove)
// under this identity — a separate writer from every device's.
Device: webapp.Identity{ID: "hubdev", Name: "hub", Author: "hub@test"},
Upload: webapp.UploadConfig{Enabled: upload},
}
ts := httptest.NewServer(srv.Handler())
+68
View File
@@ -0,0 +1,68 @@
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)
}
}
+30 -4
View File
@@ -456,18 +456,43 @@ test("history groups one agent run into a single card", async ({ page }) => {
await expect(page).toHaveURL(`/${pid}/history`);
});
test("a file the run created says why it can't be undone", async ({ page }) => {
// BEA-35: the one thing history couldn't reverse was a file a run CREATED.
// Undo removes it (via a delete op the hub journals), and the DELETED row it
// leaves behind restores it — so the round trip is what the test asserts, and
// the seeded run is left exactly as it was found.
test("a file the run created can be undone, and comes back", 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();
// An add has no old bytes to put back — its undo is a removal.
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(created.locator(".hremove-btn")).toBeVisible();
// The file it edited still offers a restore.
await expect(
page.locator('.hrun .hentry.edit:has-text("notes/readme.md") .hrestore-btn'),
).toBeVisible();
// It reaches every device, so it always asks first — and Cancel means no.
await created.locator(".hremove-btn").click();
const modal = page.locator(".modal");
await expect(modal).toContainText("Remove runbook.md?");
await expect(modal).toContainText("every synced device");
await modal.getByRole("button", { name: "Cancel" }).click();
await expect(page.locator(`.hentry.delete:has-text("runbook.md")`)).toHaveCount(0);
await created.locator(".hremove-btn").click();
await page.locator(".modal .danger-btn").click();
await expectToast(page, /Removed runbook\.md/);
const gone = page.locator('.history > .hentry.delete:has-text("runbook.md")').first();
await expect(gone).toBeVisible();
// ...and the delete row puts it back, bytes and all.
await gone.locator(".hrestore-btn").click();
await expectToast(page, /Restored runbook\.md/);
await page.goto(`/${pid}/runbook.md`);
await expect(page.locator("#content")).toContainText("Created during the agent run");
});
test("restoring an old version brings its content back", async ({ page }) => {
@@ -492,12 +517,13 @@ test("restoring an old version brings its content back", async ({ page }) => {
await expect(page.locator("#content")).toContainText("The good version");
});
test("a read-only member gets no restore buttons", async ({ page }) => {
test("a read-only member gets no restore or remove 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);
await expect(page.locator(".hremove-btn")).toHaveCount(0);
});
// BEA-26: the row was already an address for its version — but a bare
@@ -16,6 +16,7 @@ import { urlForPath, urlForView, type Route } from "../router";
import { currentNavType, navigate, useLocationPath } from "../nav";
import { HTML_EXT, PDF_EXT, copyText } from "../util";
import { toast } from "../toast";
import { modalConfirm } from "../modal";
import { onSearchRequest } from "../search";
import { track } from "../analytics";
import { AppShell, Icon, Page, Topbar, closeSidebarOnMobile, type PageWidth } from "../components/shell";
@@ -228,6 +229,39 @@ export default function Browser(props: {
[apiBase, qc],
);
/* ---- undo a file a run created ----
The other half of restore: it takes a file away, on every synced device,
so it always asks first. History keeps it the DELETED row it leaves
restores it back which is what the confirm says. */
const [removing, setRemoving] = useState("");
const onRemove = useCallback(
async (p: string) => {
if (
!(await modalConfirm(
"Remove " + p + "?",
"It disappears from every synced device. History keeps it — you can restore it from the DELETED row afterwards.",
"Remove file",
true,
))
)
return;
setRemoving(p);
try {
await postJSON(apiBase + "remove", { path: p });
qc.invalidateQueries({ queryKey: ["history", apiBase] });
qc.invalidateQueries({ queryKey: ["tree", apiBase] });
qc.invalidateQueries({ queryKey: ["render", apiBase, p] });
qc.invalidateQueries({ queryKey: ["text"] });
toast("Removed " + p + " — it syncs to every device like any other change.");
} catch (err) {
toast("Remove failed: " + (err as Error).message, true);
} finally {
setRemoving("");
}
},
[apiBase, qc],
);
const historyNow = useCallback(() => {
if (!path) return openHistory("");
openHistory(isDir ? path + "/" : path);
@@ -312,6 +346,7 @@ export default function Browser(props: {
onMeta={setMeta}
onRendered={onRendered}
restore={canRestore ? { onRestore, busy: restoring } : undefined}
remove={canRestore ? { onRemove, busy: removing } : undefined}
/>
);
} else if (path) {
@@ -22,6 +22,14 @@ export type RestoreAction = {
busy?: string; // path+sha currently in flight
};
// Un-creating a file a run added — restore's other half, and the only op the
// hub writes that takes content away, so it always goes through a
// confirmation the caller owns. Same visibility rule as RestoreAction.
export type RemoveAction = {
onRemove: (path: string) => void;
busy?: string; // path 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.
@@ -47,6 +55,7 @@ export function HistoryRow({
onOpen,
diff,
restore,
remove,
restoreSha,
inRun,
}: {
@@ -62,6 +71,9 @@ export function HistoryRow({
// same path; absent means this is the first version.
diff?: { apiBase: string; prev?: string };
restore?: RestoreAction;
// Only ever offered on an add inside a run card, where "this run created
// the file" is a statement we can make.
remove?: RemoveAction;
// 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;
@@ -78,13 +90,14 @@ export function HistoryRow({
// 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.
// back would be a no-op, so the undo it wants is a removal instead.
// 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 removable = !!remove && createdByRun;
const busy = !!restore?.busy && restore.busy === e.path + restoreSha;
const removing = !!remove?.busy && remove.busy === e.path;
// 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.
@@ -137,13 +150,24 @@ export function HistoryRow({
{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>
{/* The run created this file, so the undo is a removal, not a
restore. It reaches every synced device, so onRemove confirms
before it fires. */}
{removable && (
<button
type="button"
className="hremove-btn"
disabled={removing}
title={"Remove " + e.path + " — this run created it"}
onClick={(ev) => {
ev.stopPropagation();
remove!.onRemove(e.path);
}}
onKeyDown={(ev) => ev.stopPropagation()}
>
<Icon name="trash" />
{removing ? "removing…" : "undo — remove file"}
</button>
)}
</div>
{/* Inside a run card the note is the card's header repeating it on
+7 -7
View File
@@ -670,14 +670,14 @@ a.ai-main:hover { color: var(--accent); }
.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; }
/* ---- restore / remove ---- */
.hrestore-btn, .hremove-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; }
/* Removing takes a file away on every device, so its hover reads as danger
rather than as the neighbouring restore. */
.hremove-btn:hover { color: var(--del); border-color: rgba(242, 109, 109, .38); background: var(--hover); }
.hrestore-btn:disabled, .hremove-btn:disabled { opacity: .5; cursor: default; }
.hrestore-btn .ico, .hremove-btn .ico { width: 12px; height: 12px; }
/* ---- 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; }
+2
View File
@@ -192,6 +192,8 @@ func TestOrgWallsProjectRoutes(t *testing.T) {
{"POST", base + "upload/init", map[string]any{"path": "x.md", "sha256": strings.Repeat("a", 64), "size": 1}},
{"PUT", base + "upload/content?path=x.md", []byte("hi")},
{"POST", base + "upload/commit", map[string]any{"path": "x.md", "sha256": strings.Repeat("a", 64), "size": 1}},
{"POST", base + "restore", map[string]any{"path": "x.md", "sha": strings.Repeat("a", 64)}},
{"POST", base + "remove", map[string]any{"path": "x.md"}},
{"GET", base + "store/list?prefix=journal/", nil},
{"GET", base + "store/object?key=journal/d.jsonl", nil},
{"GET", base + "store/exists?key=journal/d.jsonl", nil},
+84
View File
@@ -0,0 +1,84 @@
package webapp
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/runbear-io/beardrive/internal/journal"
)
// Remove un-creates a file — the other half of restore. Like restore it is a
// NEW op, never an edit to history: a delete op journaled under this server's
// own device, which every device then materializes like any other change. The
// blob stays in the store forever, so the resulting DELETED row restores the
// file straight back.
//
// Removing the offending ops instead is ruled out for the same reasons
// restore.go gives: it would break one-writer-per-journal, strand peers that
// already replayed them, and corrupt the push cursor.
// Remove appends a delete op for p to this server's own journal. A delete
// references no content, so there is no blob to push first.
func (r *RemoteSource) Remove(ctx context.Context, p string, who User, note string) error {
if r.Device.ID == "" {
return fmt.Errorf("no device identity configured for uploads")
}
return r.appendOp(ctx, journal.Op{
Kind: journal.KindDelete, Path: p,
User: who.Email, UserName: who.Name, Note: note,
})
}
// handleRemove serves POST /api/p/<id>/remove {path}.
func (s *Server) handleRemove(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"`
}
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
}
// The volume snapshot is the same map the tree and viewer serve, so the
// API agrees with what the caller was looking at. A stale-snapshot 404 is
// a harmless retry; a second, hand-rolled replay could disagree and
// delete the wrong thing.
snap, err := v.snapshot(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
if _, ok := snap.files[p]; !ok {
http.Error(w, "no such file", http.StatusNotFound)
return
}
// A delete stores 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
}
if err := rs.Remove(r.Context(), p, s.requestUser(r), "remove "+p); err != nil {
http.Error(w, fmt.Sprintf("remove: %v", err), http.StatusBadGateway)
return
}
s.quota().RecordUsage(org, 0)
v.invalidate()
writeJSON(w, map[string]any{"ok": true, "path": p})
}
+203
View File
@@ -0,0 +1,203 @@
package webapp
import (
"net/http"
"path/filepath"
"strings"
"testing"
"github.com/runbear-io/beardrive/internal/journal"
)
// Removing a file journals one delete op under the hub's own device: the file
// leaves the tree, history gains a delete row, and no other device's journal
// is touched.
func TestRemoveWritesDeleteOp(t *testing.T) {
srv, p, root := newHub(t, true, nil)
dir := filepath.Join(root, p.ID)
f := newFakeRemoteAt(t, dir)
f.put("dev1", "ideas.md", "an agent wrote this")
f.put("dev2", "keep.md", "untouched")
h := srv.Handler()
base := "/api/p/" + p.ID + "/"
before := journalsAt(t, dir)
rec := do(t, h, "POST", base+"remove", map[string]string{"path": "ideas.md"})
var out struct {
OK bool `json:"ok"`
Path string `json:"path"`
}
mustJSON(t, rec, &out)
if !out.OK || out.Path != "ideas.md" {
t.Fatalf("remove response = %+v", out)
}
if rec := do(t, h, "GET", base+"tree", nil); strings.Contains(rec.Body.String(), "ideas.md") {
t.Fatalf("removed file still in the tree: %s", rec.Body)
}
entries := historyOf(t, h, base, "ideas.md")
if len(entries) != 2 {
t.Fatalf("history = %d entries, want 2", len(entries))
}
if newest := entries[0]; newest.Kind != string(journal.KindDelete) || newest.Note != "remove ideas.md" {
t.Fatalf("newest entry = %+v, want a delete noted %q", newest, "remove ideas.md")
}
// One writer per journal: only our own key grew, by exactly one op.
after := journalsAt(t, dir)
for _, dev := range []string{"dev1.jsonl", "dev2.jsonl"} {
if after[dev] != before[dev] {
t.Fatalf("remove 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)
}
if op := ops[0]; op.Kind != journal.KindDelete || op.Path != "ideas.md" || op.Blob != "" || op.Size != 0 {
t.Fatalf("journaled op = %+v, want a contentless delete of ideas.md", op)
}
}
// A path that is not in the current state is a 404, and writes nothing.
func TestRemoveUnknownPath(t *testing.T) {
srv, p, root := newHub(t, true, nil)
dir := filepath.Join(root, p.ID)
f := newFakeRemoteAt(t, dir)
f.put("dev1", "here.md", "v1")
f.put("dev1", "gone.md", "v1")
f.del("dev1", "gone.md")
h := srv.Handler()
base := "/api/p/" + p.ID + "/"
before := journalsAt(t, dir)
// never existed, and already deleted — both are "not a file right now"
for _, path := range []string{"nope.md", "gone.md"} {
rec := do(t, h, "POST", base+"remove", map[string]string{"path": path})
if rec.Code != http.StatusNotFound {
t.Fatalf("remove %q: %d %s, want 404", path, rec.Code, rec.Body)
}
}
assertJournalsUnchanged(t, dir, before)
}
// Path validation is the upload validator, so traversal and reserved names
// never reach the journal.
func TestRemoveBadPath(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")
h := srv.Handler()
base := "/api/p/" + p.ID + "/"
before := journalsAt(t, dir)
for _, path := range []string{"", "../x", "/abs", "x/", ".bdrive", "a/../b"} {
rec := do(t, h, "POST", base+"remove", map[string]string{"path": path})
if rec.Code != http.StatusBadRequest {
t.Fatalf("remove %q: %d %s, want 400", path, rec.Code, rec.Body)
}
}
assertJournalsUnchanged(t, dir, before)
}
// Remove is a write: a hub running without --upload stays read-only.
func TestRemoveNeedsUploadsEnabled(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+"/remove", map[string]string{"path": "f.md"})
if rec.Code != http.StatusForbidden {
t.Fatalf("remove on a read-only hub: %d %s, want 403", rec.Code, rec.Body)
}
}
// A blocked plan blocks removes too, even though a delete stores no bytes.
func TestRemoveQuotaBlocked(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")
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+"remove", map[string]string{"path": "f.md"})
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("blocked remove: %d %s, want 413", rec.Code, rec.Body)
}
if len(q.usage) != 0 {
t.Fatalf("a denied remove 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)
}
assertJournalsUnchanged(t, dir, before)
q.denyW = false
if rec := do(t, h, "POST", base+"remove", map[string]string{"path": "f.md"}); rec.Code != 200 {
t.Fatalf("remove 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)
}
}
// The round trip the UI promises: undo removes the file, and the DELETED row
// it leaves behind restores the original bytes.
func TestRemoveThenRestore(t *testing.T) {
srv, p, root := newHub(t, true, nil)
f := newFakeRemoteAt(t, filepath.Join(root, p.ID))
f.put("dev1", "ideas.md", "the bytes an agent wrote")
h := srv.Handler()
base := "/api/p/" + p.ID + "/"
if rec := do(t, h, "POST", base+"remove", map[string]string{"path": "ideas.md"}); rec.Code != 200 {
t.Fatalf("remove: %d %s", rec.Code, rec.Body)
}
// The delete row's restore target is the content it removed — the same
// predecessor lookup the history view does.
entries := historyOf(t, h, base, "ideas.md")
if len(entries) != 2 || entries[1].Blob != shaOf("the bytes an agent wrote") {
t.Fatalf("history after remove = %+v", entries)
}
if rec := do(t, h, "POST", base+"restore", map[string]string{"path": "ideas.md", "sha": entries[1].Blob}); rec.Code != 200 {
t.Fatalf("restore: %d %s", rec.Code, rec.Body)
}
if rec := do(t, h, "GET", base+"file?path=ideas.md", nil); rec.Body.String() != "the bytes an agent wrote" {
t.Fatalf("restored content = %q", rec.Body)
}
}
// The single-volume (plain folder) server has no journal to write a delete
// into, so it has no remove route at all.
func TestRemoveNotOnSingleVolume(t *testing.T) {
f := newFakeRemote(t)
f.put("dev1", "f.md", "v1")
h := f.uploadServer(nil).Handler()
rec := do(t, h, "POST", "/api/remove", map[string]string{"path": "f.md"})
if rec.Code < 400 {
t.Fatalf("single-volume remove: %d, want no such route", rec.Code)
}
}
func assertJournalsUnchanged(t *testing.T, dir string, before map[string]string) {
t.Helper()
after := journalsAt(t, dir)
if len(after) != len(before) {
t.Fatalf("a refused remove wrote a journal: %v → %v", before, after)
}
for name, data := range before {
if after[name] != data {
t.Fatalf("journal %s changed on a refused remove", name)
}
}
}
+3 -1
View File
@@ -395,8 +395,10 @@ 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.
// per project — never on the single-volume (DirSource) prefix. Remove
// writes to that same journal, so it lives here too.
mux.HandleFunc("POST /api/p/{project}/restore", proj(PermWrite, s.handleRestore))
mux.HandleFunc("POST /api/p/{project}/remove", proj(PermWrite, s.handleRemove))
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
+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-d1aFxMDF.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dyv4ewL7.css">
<script type="module" crossorigin src="/assets/index-VzV_DORD.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C34J-2cQ.css">
</head>
<body>
<div id="root"></div>
+15 -11
View File
@@ -106,7 +106,16 @@ func (r *RemoteSource) Commit(ctx context.Context, p, blob string, size int64, w
if !ok {
return errBlobMissing
}
return r.appendOp(ctx, journal.Op{
Kind: journal.KindPut, Path: p, Blob: blob, Size: size, Mode: 0o644,
User: who.Email, UserName: who.Name, Note: note,
})
}
// appendOp stamps op with this server's identity and ordering and appends it
// to this server's own journal. Callers fill in Kind/Path and the content
// fields; Seq, Lamport, Time and the device fields belong to us.
func (r *RemoteSource) appendOp(ctx context.Context, op journal.Op) error {
r.upmu.Lock()
defer r.upmu.Unlock()
@@ -115,19 +124,14 @@ func (r *RemoteSource) Commit(ctx context.Context, p, blob string, size int64, w
return err
}
var maxLamport, mySeq int64
for _, op := range all {
maxLamport = max(maxLamport, op.Lamport)
if op.Device == r.Device.ID {
mySeq = max(mySeq, op.Seq)
for _, prev := range all {
maxLamport = max(maxLamport, prev.Lamport)
if prev.Device == r.Device.ID {
mySeq = max(mySeq, prev.Seq)
}
}
op := journal.Op{
Seq: mySeq + 1, Lamport: maxLamport + 1, Time: time.Now().UTC(),
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,
}
op.Seq, op.Lamport, op.Time = mySeq+1, maxLamport+1, time.Now().UTC()
op.Device, op.DeviceName, op.Author = r.Device.ID, r.Device.Name, r.Device.Author
// Read-modify-write of our own journal. A transient read error must fail
// the commit — treating it as "no journal yet" would rewrite the key
+5 -3
View File
@@ -89,9 +89,11 @@ versions in between stay in the history, the restore itself shows up in
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.
**Restore puts content back; it does not delete.** To un-create a file an agent
run *created*, open that run in the hub's History view and use the row's
**undo — remove file** button (it asks first: the file leaves every synced
device, and the DELETED row it leaves behind restores it). From the CLI, delete
the file yourself and let the next sync carry that.
### `bdrive forget` and `bdrive sync --prune` — cleaning up the hub