mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
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:
co-authored by
Claude Opus 5
parent
31472c56e7
commit
4dfe6f44f0
@@ -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) |
|
||||
|
||||
@@ -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 <path>@<sha8>\" 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/<own-device>.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>>
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -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; }
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
+1
-1
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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user