feat(webapp): undo a whole agent run from the run card (#156)

* feat(webapp): undo a whole agent run from the run card

The run card was grouped for this and stopped one button short: every row
inside it carried an action, the header carried none, so reverting a bad run
meant clicking file by file and hoping you got them all.

POST /api/p/<id>/undo-run works out, for every path the run touched, the op
that puts it back — a put at the pre-run blob, or a delete for a file the run
created — and writes them all in ONE journal append. That is the atomicity
argument, not an optimization: one Put of one object either lands or it does
not, so there is no half-undone run to report. appendOps is the batch write
every path in the package now goes through; appendOp is its single-op call.

Selection is by the journal an op was READ FROM, never op.Device — that field
is arbitrary JSON any member with write access can put in their own journal,
and the card attributes rows the same way. The note form additionally requires
an empty Session, because runs.ts can never file a session-carrying op under a
note-keyed card.

Append-only throughout: the run's own ops are never edited or removed, so
one-writer-per-journal and deterministic replay both survive. The undo's ops
carry a note naming the run, so the undo is itself a run card you can undo.

The confirm asks the server for the file list rather than deriving it from the
loaded feed (paged and filterable, so a client-computed list is wrong exactly
when the run is old), lists every path with its action, and names the one thing
that can burn someone: a file a teammate changed after the run is reverted too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(webapp): the undo confirm names the paths it will not write

planUndo already refuses a path the hub's own upload door would refuse — a
peer can push one under .bdrive/ or with a control character in it — but the
dialog listed only what the undo WOULD do, which reads as "all of it".

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 Lee (Sungwon)
2026-08-18 14:04:35 -07:00
committed by GitHub
co-authored by Claude Opus 5
parent 6f0f474903
commit eb01953729
20 changed files with 1392 additions and 148 deletions
+1 -1
View File
@@ -260,7 +260,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. To un-create a file a run *created*, use **undo — remove file** on that row in the hub's History view |
| `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 — or **undo this run** in the run card's header to put back every file that run touched at once |
| `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 (always a NEW project; `--name` overrides the archive's); history and authorship carry over. Refuses an archive whose journals reference content it doesn't hold (`--allow-incomplete` overrides). 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) |
+1
View File
@@ -97,6 +97,7 @@ classDiagram
note for components "NewProjectDialog replaced ProjectNav's name-only modalPrompt: name + starting point, POSTing {name, template}. Its options come from useConfig()'s `templates`, never a hardcoded list, so a hub shipping another template needs no frontend change; the initial selection is options[0].value — the same array element the RECOMMENDED badge indexes, so the badged row and the checked row are one row by construction (on a template-less hub that row is 'I already have a folder', which still creates an empty project). modal.tsx keeps its one-field API — teaching it about choices would tax every other caller"
note for components "HistoryFilters drives the SERVER (?q=/?user=/?since=/?until= on the history API), never the loaded page — filtering what is on screen would lie about everything below the fold and break next_cursor. Its state is Route.filters, so a narrowed feed is linkable, survives reload, and Back undoes it; the author list accumulates across fetches, because filtering by one author leaves only their rows loaded"
note for components "FileView's transformHTML now drops `data:image/svg` from any rendered img and any `data:` href from any rendered link — goldmark admits them, and an inline SVG is a document rather than a picture (the same property the server's sandboxInline walls off). Insights builds its per-device folder bag with Object.create(null), since folder names come off a peer's journal and one named __proto__ silently emptied the matrix. style.css sets unicode-bidi isolate-override on the peer-authored strings a reader is expected to CHECK (listing rows, breadcrumb, history path/note/device) — journal.SafeText refuses the bidi CONTROLS, but a single strong-RTL LETTER is legal and still reorders a row"
note for components "HistoryView's RunGroup header carries the run-wide undo (POST undo-run, gated by the same write permission as the per-row restore/remove). It asks the SERVER for the file list first (preview: true) rather than deriving it from the loaded feed — that window is paged and filterable, so a client-computed list is wrong exactly when the run is old. modal.tsx's Confirm.message widened from string to ReactNode for it (the prompt's one-field API is untouched), so the dialog can show every path, its action, and the &quot;changed after this run&quot; warning inline"
note for components "components/ui — shadcn/ui primitives (Radix, copied in), themed from BearDrive tokens in tw.css; rendered markdown is transformed as a string before mounting, link clicks delegated on the container — never patch the dangerouslySetInnerHTML subtree"
class lib {
+13 -1
View File
@@ -65,6 +65,7 @@ classDiagram
-loadSourcedOps(ctx) []sourcedOp
-cacheJournals(keep, misses, parsed, sizes)
-appendOp(ctx, op)
-appendOps(ctx, ops) ONE read-modify-write
}
class sourcedOp {
+Op journal.Op
@@ -116,7 +117,16 @@ classDiagram
}
note for DirectUploader "BlobSize replaced HasBlob: in direct mode the server never sees the bytes, so the CALLER's declared size was the only number it had to quota-check and journal — and the caller picks it. Size now comes from storage, and the commit journals and charges that"
note for DirectUploader "Commit's note is &quot;&quot; for an upload and &quot;restore &lt;path&gt;@&lt;sha8&gt;&quot; 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."
note for RemoteSource "Every write ends at appendOps: stamp Seq/Lamport/Time + this server's Identity across the batch, append N ops to journal/&lt;own-device&gt;.jsonl in ONE read-modify-write (appendOp is the single-op call). 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. The batch is not an optimization: ONE Put of ONE object either lands or it does not, which is the whole atomicity argument for undoRunDoor — a loop of appendOp there would leave half a run reverted with nothing to report it."
class undoRunDoor {
<<Server, POST /api/p/id/undo-run>>
planUndo(sourced, undoSel) undoPlan
undoSel Device From-journal, Session xor Note
undoPlan Ops, Actions, Skipped, After, Refused
preview plan only, no write, no quota
}
note for undoRunDoor "The run-wide form of restore+remove: for every path the run touched, the op that puts it back — a put at the pre-run blob, or a delete for a file the run created. Selection is by sourcedOp.From (the journal, which /store gates), NEVER op.Device, and the note form additionally requires Session == &quot;&quot; because runs.ts can never file a session-carrying op under a note-keyed card. Append-only: the run's own ops are never touched. Same PermWrite + CheckWrite(org,0) gates as its two siblings; the undo's ops carry a note naming the run, so the undo is itself a run card you can undo."
class journalDoor {
<<Server, /api/p/id/store/*>>
@@ -470,6 +480,8 @@ classDiagram
DeviceRegistry ..> DeviceInfo
DeviceRegistry *-- devKey : (account, id)
RemoteSource ..> sourcedOp : attribution comes from the journal key
undoRunDoor ..> sourcedOp : selects a run by the journal it was read from
undoRunDoor ..> RemoteSource : appendOps — the whole run in one Put
RemoteSource *-- cachedJournal : parsed ops, keyed on size+mtime
ReadLedger ..> ReadStat
ReadLedger ..> SessionRead
+86
View File
@@ -2,9 +2,12 @@ package syncer
import (
"context"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -113,3 +116,86 @@ func TestReadOnlyServerClientStillPulls(t *testing.T) {
t.Fatal("client should still pull from a read-only server")
}
}
// Undoing a whole agent run at the hub converges like any other change: the
// hub journals the undo under its OWN device, and every other device
// materializes the pre-run content on its next cycle. The repo's convention
// is that a sync feature without a multi-device test is untested where it
// matters — this is that test for BEA-82.
func TestUndoRunConverges(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) // the agent's machine
b := newDevice(t, "devb", remote.Prefixed(storage, p.ID)) // a teammate
// Before the run.
write(t, a.Folder, "notes/plan.md", "the plan, as written by a human")
cycle(t, a)
cycle(t, b)
if read(t, b.Folder, "notes/plan.md") != "the plan, as written by a human" {
t.Fatal("b never got the pre-run content")
}
// The run: one file rewritten, one created, both stamped with the session
// id the agent hook sets.
time.Sleep(10 * time.Millisecond)
a.SessionID = "run-8f21e4"
write(t, a.Folder, "notes/plan.md", "REWRITTEN BY THE AGENT")
write(t, a.Folder, "notes/scratch.md", "invented by the agent")
cycle(t, a)
a.SessionID = ""
cycle(t, b)
if read(t, b.Folder, "notes/scratch.md") != "invented by the agent" {
t.Fatal("b never saw the run")
}
// Undo the whole run from the hub.
body := strings.NewReader(`{"session":"run-8f21e4","device":"deva"}`)
resp, err := http.Post(ts.URL+"/api/p/"+p.ID+"/undo-run", "application/json", body)
if err != nil {
t.Fatal(err)
}
out, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("undo-run: %d %s", resp.StatusCode, out)
}
// The teammate converges on the pre-run state without doing anything but
// syncing, and so does the device that made the mess.
time.Sleep(10 * time.Millisecond)
for _, d := range []*Session{b, a} {
cycle(t, d)
if got := read(t, d.Folder, "notes/plan.md"); got != "the plan, as written by a human" {
t.Fatalf("%s has %q after the undo, want the pre-run content", d.Device.ID, got)
}
if _, err := os.Stat(filepath.Join(d.Folder, "notes", "scratch.md")); !os.IsNotExist(err) {
t.Fatalf("%s still has the file the run created", d.Device.ID)
}
}
// The undo is append-only: the agent's own journal still holds every op
// it ever wrote, and the undo lives in the hub's.
rc, err := storage.Get(context.Background(), p.ID+"/journal/deva.jsonl")
if err != nil {
t.Fatal(err)
}
devaJournal, _ := io.ReadAll(rc)
rc.Close()
// The run's ops are still there, session id and all: an undo appends, it
// never rewrites the journal it is undoing.
if !strings.Contains(string(devaJournal), "run-8f21e4") ||
!strings.Contains(string(devaJournal), "notes/scratch.md") {
t.Fatalf("the undo edited the run's own journal — it must only ever append to the hub's:\n%s", devaJournal)
}
if _, err := storage.Get(context.Background(), p.ID+"/journal/hubdev.jsonl"); err != nil {
t.Fatalf("the hub journaled the undo somewhere other than its own journal: %v", err)
}
}
@@ -1,5 +1,5 @@
import { test, expect } from "@playwright/test";
import { login, wikiId } from "./helpers";
import { login, wikiId, expectToast, READER } from "./helpers";
/* One agent run, both halves (BEA-98). History used to show only what a run
CHANGED; the reads lived in a daily aggregate with no session dimension and
@@ -43,3 +43,83 @@ test("a read-only row opens the file it names", async ({ page }) => {
await page.locator(".hrun-read", { hasText: "index.md" }).click();
await expect(page).toHaveURL(new RegExp(`/${pid}/index.md`));
});
/* BEA-82: the run-wide verb the card was grouped for. Every row inside the
card already had an action; the header had none, so reverting a bad run
meant clicking file by file and hoping you got them all.
Driven against the seeded run (session 8f21e4 on device `seed`: one file
rewritten, one created) and left exactly as it was found — the last step
undoes the undo, which is also the point: the undo is itself a run card. */
test("undoing a whole run puts every file it touched back", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
// Somebody edits a file the run created, AFTER the run. The confirm has to
// say that this change is about to be overwritten too — the one thing in
// this feature that can burn a teammate.
await page.request.put(`/api/p/${pid}/upload/content?path=runbook.md`, {
data: "# Runbook\n\nEdited by a teammate after the run.\n",
});
await page.goto(`/${pid}/history`);
const card = page.locator(".hrun", { hasText: "claude-code session 8f21e4" }).first();
await expect(card).toBeVisible();
// The header carries the action; the run's own rows still carry theirs.
await card.locator(".hrun-undo").click();
const modal = page.locator(".modal");
await expect(modal).toContainText("Undo this run?");
// Every path the run touched, with what will happen to each.
const rows = modal.locator(".undo-row");
await expect(rows).toHaveCount(2);
await expect(rows.filter({ hasText: "notes/readme.md" })).toContainText("restore to pre-run version");
await expect(rows.filter({ hasText: "runbook.md" })).toContainText("remove (the run created it)");
// ...and the warning, named out loud.
await expect(modal.locator(".undo-warn")).toContainText("changed by someone else after this run");
// It writes to every synced device, so Cancel has to mean nothing happened.
await modal.getByRole("button", { name: "Cancel" }).click();
await page.goto(`/${pid}/runbook.md`);
await expect(page.locator("#content")).toContainText("Edited by a teammate");
await page.goto(`/${pid}/history`);
await card.locator(".hrun-undo").click();
await page.locator(".modal .danger-btn").click();
await expectToast(page, /Undid 2 files/);
// The file the run edited holds its pre-run content again, and the file it
// created is gone.
await page.goto(`/${pid}/notes/readme.md`);
await expect(page.locator("#content")).toContainText("Nested folder content");
await page.goto(`/${pid}/runbook.md`);
await expect(page.locator("#content")).toContainText("isn't in this project");
// The undo is itself a run card — same note on every op it wrote — so it
// carries the same button and walks the whole thing back.
await page.goto(`/${pid}/history`);
const undoCard = page.locator(".hrun", { hasText: "undo run 8f21e4" }).first();
await expect(undoCard).toBeVisible();
await undoCard.locator(".hrun-undo").click();
await page.locator(".modal .danger-btn").click();
await expectToast(page, /Undid 2 files/);
await page.goto(`/${pid}/notes/readme.md`);
await expect(page.locator("#content")).toContainText("Rewritten during the agent run");
// Put the fixture back: the teammate's post-run edit above is the one thing
// the round trip legitimately restored, and later specs read this file.
await page.request.put(`/api/p/${pid}/upload/content?path=runbook.md`, {
data: "# Runbook\n\nCreated during the agent run.\n",
});
});
// A write action, so a read-only member gets no button rather than one that
// 403s — the same rule the per-row restore and remove follow.
test("a read-only member sees no undo button on a run card", async ({ page }) => {
await login(page, READER);
const pid = await wikiId(page);
await page.goto(`/${pid}/history`);
const card = page.locator(".hrun").first();
await expect(card).toBeVisible();
await expect(card.locator(".hrun-undo")).toHaveCount(0);
});
+12
View File
@@ -245,3 +245,15 @@ export interface UploadPlan {
method?: string;
headers?: Record<string, string>;
}
// POST .../undo-run (handleUndoRun, undorun.go). The same shape answers a
// `preview: true` call and the real one, so the dialog and the result read
// from one type — and the plan the dialog showed is recomputed server-side
// before anything is written.
export interface UndoPlan {
ok: boolean;
undone: { path: string; action: "restore" | "remove" }[];
skipped: string[]; // already at their pre-run content: nothing to write
changed_after: string[]; // someone landed a change on this path after the run
refused: string[]; // a path the hub's own upload door would refuse
}
+95 -1
View File
@@ -9,7 +9,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { atLeast } from "../api/types";
import { getJSON, postJSON } from "../api/http";
import type { Project, ServerConfig } from "../api/types";
import type { Project, ServerConfig, UndoPlan } from "../api/types";
import { useHeat, useTree } from "../hooks/useBrowse";
import { useShares } from "../hooks/useHub";
import { urlForPath, urlForView, type Route } from "../router";
@@ -30,6 +30,7 @@ import { Palette, type PaletteItem } from "../components/Palette";
import { ConnectGuide } from "../components/ConnectGuide";
import { Insights, useInsightsDevices } from "../components/Insights";
import { HistoryView, historyTitle } from "../components/HistoryView";
import type { Run } from "../lib/runs";
import { VersionBanner } from "../components/VersionBanner";
// The hub's six share-time credential rules, in words. Only one caller
@@ -340,6 +341,98 @@ export default function Browser(props: {
[apiBase, qc],
);
/* ---- undo a whole agent run ----
The run-wide form of the two above, and the reason the feed groups runs
at all: reverting a bad run used to mean clicking file by file and hoping
you got them all.
Two calls, both to the same endpoint. The first (`preview: true`) writes
nothing and asks the SERVER which paths the run touched and what would
happen to each — the loaded window is paged and filterable, so a list
computed from what is on screen is wrong exactly when the run is old or
filtered. The second does it, recomputing the plan server-side rather
than trusting the one the dialog showed; an op that lands between the two
makes the confirm one op stale, never the write wrong.
The warning block is the one thing here that can burn someone: a path a
teammate changed AFTER the run is reverted too. That is the model
(last-writer-wins, and per-row restore already behaves this way), so the
dialog says it out loud instead of the undo being a surprise. */
const [undoingRun, setUndoingRun] = useState("");
const onUndoRun = useCallback(
async (run: Run) => {
const id = run.session || run.note;
const sel = run.session
? { session: run.session, device: run.entries[0]?.device?.id }
: { note: run.note, device: run.entries[0]?.device?.id };
setUndoingRun(id);
try {
const plan = await postJSON<UndoPlan>(apiBase + "undo-run", { ...sel, preview: true });
const after = new Set(plan.changed_after);
if (!plan.undone.length) {
toast("Nothing to undo — every file this run touched already holds its pre-run content.");
return;
}
const ok = await modalConfirm(
"Undo this run?",
<>
<div>
{run.note || id} {plan.undone.length} file
{plan.undone.length === 1 ? "" : "s"}
</div>
<div className="undo-list">
{plan.undone.map((a) => (
<div className="undo-row" key={a.path}>
<span className="undo-path">{a.path}</span>
{after.has(a.path) && <span className="undo-after">changed after this run</span>}
<span className="undo-what">
{a.action === "remove" ? "remove (the run created it)" : "restore to pre-run version"}
</span>
</div>
))}
</div>
{after.size > 0 && (
<div className="undo-warn">
{after.size} file{after.size === 1 ? " was" : "s were"} changed by someone else after
this run. Undoing overwrites {after.size === 1 ? "that change" : "those changes"} too.
</div>
)}
{plan.skipped.length > 0 && (
<div>
{plan.skipped.length} already hold{plan.skipped.length === 1 ? "s" : ""} its pre-run
content and will be left alone.
</div>
)}
{/* Never silently drop a category: a path the hub's own upload
door refuses is left out of the undo, and a dialog that
listed only what it WILL do would read as "all of it". */}
{plan.refused.length > 0 && (
<div>
{plan.refused.length} path{plan.refused.length === 1 ? "" : "s"} can't be written by
the hub and will be left alone: {plan.refused.join(", ")}.
</div>
)}
</>,
"Undo run",
true,
);
if (!ok) return;
const done = await postJSON<UndoPlan>(apiBase + "undo-run", sel);
qc.invalidateQueries({ queryKey: ["history", apiBase] });
qc.invalidateQueries({ queryKey: ["tree", apiBase] });
qc.invalidateQueries({ queryKey: ["render", apiBase] });
qc.invalidateQueries({ queryKey: ["text"] });
const skipped = done.skipped.length ? `, skipped ${done.skipped.length} (already current)` : "";
toast(`Undid ${done.undone.length} file${done.undone.length === 1 ? "" : "s"}${skipped}.`);
} catch (err) {
toast("Undo failed: " + (err as Error).message, true);
} finally {
setUndoingRun("");
}
},
[apiBase, qc],
);
const historyNow = useCallback(() => {
if (!path) return openHistory("");
openHistory(isDir ? path + "/" : path);
@@ -446,6 +539,7 @@ export default function Browser(props: {
onRendered={onRendered}
restore={canRestore ? { onRestore, busy: restoring } : undefined}
remove={canRestore ? { onRemove, busy: removing } : undefined}
undoRun={canRestore ? { onUndoRun, busy: undoingRun } : undefined}
filters={route.filters}
/* push, not replace: a filter is a navigation, and Back undoes it */
onFilters={(f) => navigate(urlForView("history", project?.id, route.viewTarget || "", f))}
@@ -9,6 +9,17 @@ import { groupRuns, runFileCount, type Run } from "../lib/runs";
import { HistoryFilters, authorsOf } from "./HistoryFilters";
import { historyFilterQuery, hasHistoryFilters, type HistoryFilters as Filters } from "../router";
// Undoing a WHOLE run — the run-wide form of restore/remove, and the only
// action the card header carries. Absent when the viewer can't write, like
// its two per-row siblings, so a read-only member never sees a button that
// 403s. The card hands over the run itself, not a file list: which paths are
// reverted is worked out server-side, because this window is paged and
// filtered and a client-computed list is wrong exactly when the run is old.
export type UndoRunAction = {
onUndoRun: (run: Run) => void;
busy?: string; // the session (or note) currently in flight
};
/* ---- history ----
Every change ever made, straight from the journals: who (account), when,
from which device (name, OS, IP as the server saw it). The route stores
@@ -30,12 +41,13 @@ export function HistoryView(props: {
onRendered?: () => void;
restore?: RestoreAction;
remove?: RemoveAction;
undoRun?: UndoRunAction;
// Reader filters, straight from the URL. Applied server-side, so they
// narrow the whole feed and not just the loaded page.
filters?: Filters;
onFilters?: (f: Filters) => void;
}) {
const { apiBase, target, isFolder, onMeta, onRendered, restore, remove, filters } = props;
const { apiBase, target, isFolder, onMeta, onRendered, restore, remove, undoRun, filters } = props;
const q = !target
? { prefix: "" }
: isFolder(target)
@@ -150,6 +162,7 @@ export function HistoryView(props: {
recreates={recreates}
restore={restore}
remove={remove}
undoRun={undoRun}
/>
) : (
<HistoryRow
@@ -197,6 +210,7 @@ function RunGroup({
recreates,
restore,
remove,
undoRun,
}: {
run: Run;
onOpen: (path: string, version?: string) => void;
@@ -206,6 +220,7 @@ function RunGroup({
recreates: (i: number) => boolean;
restore?: RestoreAction;
remove?: RemoveAction;
undoRun?: UndoRunAction;
}) {
const [open, setOpen] = useState(true);
const first = run.entries[0];
@@ -236,6 +251,7 @@ function RunGroup({
// Distinct paths, not ops: repeat edits to one file must not inflate the
// one number that sizes a run (BEA-39). Every op is still a row below.
const n = runFileCount(run);
const undoing = !!undoRun?.busy && undoRun.busy === (run.session || run.note);
return (
<div className={"hrun" + (open ? " open" : "")}>
<div className="hrun-head">
@@ -259,6 +275,22 @@ function RunGroup({
{dev ? " · " + dev : ""}
</span>
<span className="hrun-time">{span}</span>
{/* The one action the header carries. Every row inside the card
already has its own; this is the verb the card was grouped for —
reverting a run file by file and hoping you got them all is what
it replaces. onUndoRun confirms before anything is written. */}
{undoRun && (
<button
type="button"
className="hrun-undo"
disabled={undoing}
title="Put every file this run touched back the way it was"
onClick={() => undoRun.onUndoRun(run)}
>
<Icon name="hist" />
{undoing ? "undoing…" : "undo this run"}
</button>
)}
</div>
{open && (
<div className="hrun-body">
+9 -4
View File
@@ -1,4 +1,4 @@
import { useRef, useState, useSyncExternalStore } from "react";
import { type ReactNode, useRef, useState, useSyncExternalStore } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -26,7 +26,11 @@ type Prompt = {
type Confirm = {
kind: "confirm";
title: string;
message: string;
// A node, not a string: the run-wide undo has to SHOW the file list and the
// "changed after this run" warning it is asking about, and a confirm whose
// text can't hold them would push that list somewhere the user has to go
// find. Every existing caller passes a string, which is a ReactNode.
message: ReactNode;
confirmLabel: string;
danger: boolean;
resolve: (v: boolean) => void;
@@ -54,7 +58,7 @@ export function modalPrompt(
export function modalConfirm(
title: string,
message: string,
message: ReactNode,
confirmLabel = "Confirm",
danger = false,
): Promise<boolean> {
@@ -170,7 +174,8 @@ function ConfirmBody({ m }: { m: Confirm }) {
<DialogTitle asChild>
<h3>{m.title}</h3>
</DialogTitle>
<p className="modal-msg">{m.message}</p>
{/* a div, not a p: a p may not legally contain the path list */}
<div className="modal-msg">{m.message}</div>
<div className="modal-actions">
<Button variant="subtle" onClick={() => done(false)} autoFocus={m.danger}>
Cancel
+30 -3
View File
@@ -785,11 +785,32 @@ a.ai-main:hover { color: var(--accent); }
.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%; }
/* The run's identity is the one thing in this header that must stay
readable: with the undo button added, a header that shrinks everything
proportionally truncated "claude-code session 8f21e4" down to
"claude-code session …" the note and the meta are not equally
disposable. The note holds its size (still capped at 46%) and the meta,
which every row inside the card repeats anyway, absorbs the shrink. */
.hrun-note { flex-shrink: 0; 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-meta { min-width: 0; 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; }
/* The card's one action. Styled like the per-row verbs it generalizes, and
destructive on hover like remove: an undo takes content away. */
.hrun-undo { display: inline-flex; align-items: center; gap: 4px; flex: none; 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; }
.hrun-undo:hover { color: var(--del); border-color: rgba(242, 109, 109, .38); background: var(--hover); }
.hrun-undo:disabled { opacity: .5; cursor: default; }
.hrun-undo .ico { width: 12px; height: 12px; }
/* The confirm's file list: one row per path, action right-aligned, and the
paths scroll rather than growing the dialog past the viewport. */
.undo-list { margin: 10px 0; max-height: 40vh; overflow-y: auto; border: 1px solid var(--border); border-radius: 6px; }
.undo-row { display: flex; align-items: baseline; gap: 10px; padding: 5px 9px; font-size: 12.5px; }
.undo-row + .undo-row { border-top: 1px solid var(--border); }
.undo-row .undo-path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.undo-row .undo-what { margin-left: auto; flex: none; color: var(--text-faint); font-size: 11.5px; }
.undo-row .undo-after { flex: none; color: var(--del); font-size: 10px; text-transform: uppercase; letter-spacing: .06em; font-weight: 600; }
.undo-warn { color: var(--del); }
/* 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; }
@@ -970,8 +991,14 @@ a.ai-main:hover { color: var(--accent); }
the two fields the row exists to carry. Let it wrap instead; `order: 1`
drops the meta below the note while the time stays on the first row. */
.hrun-head { flex-wrap: wrap; row-gap: 4px; }
.hrun-note { max-width: none; white-space: normal; overflow: visible; }
/* flex-shrink comes back here: the desktop rule pins the note's size so
the undo button can't truncate the run's identity, but a wrapping header
that also refuses to shrink pushes a long note past the card edge. */
.hrun-note { flex-shrink: 1; max-width: none; white-space: normal; overflow: visible; }
.hrun-meta { order: 1; flex: 1 1 100%; white-space: normal; overflow: visible; }
/* The header's own action wraps onto the meta line rather than squeezing
the time out of the row. */
.hrun-undo { order: 2; margin-left: auto; min-height: 32px; }
.ai-btn, .ai-del { height: auto; min-height: 44px; padding: 0 12px; }
/* react-table renders rows as `display: table-row`, which makes every
flex rule above inert and lets the last column (Remove) fall outside
+18 -2
View File
@@ -23,16 +23,32 @@ import (
// exists but that a warm request touches no journal bytes, and that the feed it
// produces is the one the uncached code produced.
// countBackend counts the reads and lists the hub makes through it, per key.
// countBackend counts the reads, writes and lists the hub makes through it,
// per key.
type countBackend struct {
remote.Backend
mu sync.Mutex
gets map[string]int
puts map[string]int
lists int
}
func newCountBackend(be remote.Backend) *countBackend {
return &countBackend{Backend: be, gets: map[string]int{}}
return &countBackend{Backend: be, gets: map[string]int{}, puts: map[string]int{}}
}
func (b *countBackend) Put(ctx context.Context, key string, r io.Reader, size int64) error {
b.mu.Lock()
b.puts[key]++
b.mu.Unlock()
return b.Backend.Put(ctx, key, r, size)
}
// putsTo is how many times one key has been written.
func (b *countBackend) putsTo(key string) int {
b.mu.Lock()
defer b.mu.Unlock()
return b.puts[key]
}
func (b *countBackend) Get(ctx context.Context, key string) (io.ReadCloser, error) {
+3
View File
@@ -871,6 +871,9 @@ func (s *Server) Handler() http.Handler {
// 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))
// The run-wide form of the two above: one journal write that puts every
// path an agent run touched back where it was.
mux.HandleFunc("POST /api/p/{project}/undo-run", proj(PermWrite, s.handleUndoRun))
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,10 +5,10 @@
<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-Cg3Ijtd_.js"></script>
<script type="module" crossorigin src="/assets/index-NaccfD91.js"></script>
<link rel="modulepreload" crossorigin href="/assets/_commonjsHelpers-CqkleIqs.js">
<link rel="modulepreload" crossorigin href="/assets/mermaid-CP2pUOT9.js">
<link rel="stylesheet" crossorigin href="/assets/index-aFlVpSeL.css">
<link rel="stylesheet" crossorigin href="/assets/index-DXUkAW4x.css">
</head>
<body>
<div id="root"></div>
+296
View File
@@ -0,0 +1,296 @@
package webapp
import (
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"github.com/runbear-io/beardrive/internal/journal"
)
// Undo a whole agent run — the run-wide verb the run card was grouped for.
//
// Like restore and remove it is append-only: the run's ops are never edited
// or deleted (that would break one-writer-per-journal, strand peers that
// already replayed them, and corrupt the push cursor). What lands instead is
// one new op per path the run touched, putting the path back to the content it
// held just before the run — a put at the pre-run blob, or a delete for a file
// the run created.
//
// The whole batch goes down in ONE journal write (appendOps). That is the
// atomicity argument: one Put of one object either lands or it does not, so
// there is no half-undone run to report and no per-path rollback to design. A
// loop of appendOp here would silently make the feature partially-applicable
// with no error anywhere — the backend Put count in the tests is what protects
// it.
// undoSel identifies one run. Device is the journal the run's ops were READ
// FROM (sourcedOp.From), never op.Device: that field is arbitrary JSON any
// member with write access can put in their own journal, and the run card
// attributes rows by the journal key for exactly that reason (history.go).
// Exactly one of Session/Note is set — Session wherever the run has one, since
// a note is user-settable (`bdrive sync --note`) and could be forged to
// collide with a teammate's run.
type undoSel struct{ Device, Session, Note string }
// undoAction is one path's fate, for the confirm dialog and the response.
type undoAction struct {
Path string `json:"path"`
Action string `json:"action"` // "restore" or "remove"
}
// undoPlan is what an undo would do, computed server-side. The preview and the
// write both come from here, so the dialog cannot describe one thing and the
// write do another.
type undoPlan struct {
Ops []journal.Op // exactly what will be journaled
Actions []undoAction // the same ops, as the dialog reads them
Skipped []string // already at pre-run content — nothing to write
After []string // someone landed an op on this path after the run
Refused []string // a path the hub's own upload door would refuse
}
// planUndo works out, for every path the selected run touched, the op that
// puts it back where it was. sourced may be in any order; it is sorted here.
func planUndo(sourced []sourcedOp, sel undoSel) undoPlan {
ops := make([]journal.Op, len(sourced))
inRun := make([]bool, len(sourced))
for i, so := range sourced {
ops[i] = so.Op
inRun[i] = matchesRun(so, sel)
}
// One sort of an index permutation, so ops and inRun stay aligned.
order := make([]int, len(ops))
for i := range order {
order[i] = i
}
sort.SliceStable(order, func(a, b int) bool { return journal.Less(ops[order[a]], ops[order[b]]) })
// first/last positions (in sorted order) of the run's ops per path, and
// the ops that precede and follow them.
type span struct{ first, last int }
spans := map[string]span{}
for pos, i := range order {
if !inRun[i] {
continue
}
p := ops[i].Path
if s, ok := spans[p]; ok {
s.last = pos
spans[p] = s
continue
}
spans[p] = span{first: pos, last: pos}
}
// Current state, replayed over everything — the same fold the tree and
// the viewer serve, so "already at the pre-run content" means what a
// reader sees.
current := journal.Replay(ops)
plan := undoPlan{}
paths := make([]string, 0, len(spans))
for p := range spans {
paths = append(paths, p)
}
sort.Strings(paths) // a stable plan: the dialog and the journal agree run to run
for _, p := range paths {
s := spans[p]
// The hub must not journal what its own upload door refuses: a peer
// can push a path with a control character or under .bdrive/, and a
// hub that hands one back to every device has already lost.
if _, err := cleanUploadPath(p); err != nil {
plan.Refused = append(plan.Refused, p)
continue
}
// Anything on this path after the run's LAST op there is work the undo
// is about to overwrite — usually a teammate's. It is still undone
// (last-writer-wins is the model, and per-row restore already behaves
// this way), but the confirm has to say so out loud.
for _, i := range order[s.last+1:] {
if ops[i].Path == p {
plan.After = append(plan.After, p)
break
}
}
// The path's state just before the run: the newest op on it that sorts
// before the run's FIRST op there.
var before *journal.Op
for k := s.first - 1; k >= 0; k-- {
if op := &ops[order[k]]; op.Path == p {
before = op
break
}
}
now := current[p]
switch {
case before == nil || before.Kind == journal.KindDelete:
// The run created the file (or re-created a deleted one): undoing
// it means taking it away. Already gone → nothing to write.
if now.Blob == "" {
plan.Skipped = append(plan.Skipped, p)
continue
}
plan.Ops = append(plan.Ops, journal.Op{Kind: journal.KindDelete, Path: p})
plan.Actions = append(plan.Actions, undoAction{Path: p, Action: "remove"})
default:
// An earlier version exists. Restoring content that is already the
// file's content would put a +0 0 row in every teammate's history
// — the batch form of the 409 restore returns for a no-op version.
if now.Blob == before.Blob {
plan.Skipped = append(plan.Skipped, p)
continue
}
plan.Ops = append(plan.Ops, journal.Op{
Kind: journal.KindPut, Path: p,
Blob: before.Blob, Size: before.Size, Mode: before.Mode,
})
plan.Actions = append(plan.Actions, undoAction{Path: p, Action: "restore"})
}
}
return plan
}
// matchesRun is the selection rule, and it is groupRuns' key in Go. The
// empty-Session clause on the note form is load-bearing: runs.ts keys a
// session-carrying op as "s\0…" and can never file it under a note-keyed
// group, so a note-keyed undo that ignored it would revert ops the card
// never showed.
func matchesRun(so sourcedOp, sel undoSel) bool {
if so.From != sel.Device {
return false
}
if sel.Session != "" {
return so.Op.Session == sel.Session
}
return so.Op.Note == sel.Note && so.Op.Session == ""
}
// undoNote is the note the undo's own ops carry. Built from the MATCHED ops,
// never from the request body: what lands in every teammate's history row then
// provably came through the /store door's SafeText gate. Written under the
// hub's own device with no session, so the undo groups as its own run card —
// itself undoable.
func undoNote(sel undoSel) string {
if sel.Session != "" {
return "undo run " + sel.Session
}
return "undo run " + sel.Note
}
// handleUndoRun serves POST /api/p/<id>/undo-run
// {session|note, device, preview}.
func (s *Server) handleUndoRun(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 {
Session string `json:"session"`
Note string `json:"note"`
Device string `json:"device"`
Preview bool `json:"preview"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
return
}
// Both halves of the run identity, or neither — the same rule the other
// run-identity route applies (reads.go's ?session=&device=). A session-only
// or note-only undo would revert every device's ops that happen to share
// the string.
if !deviceIDRe.MatchString(req.Device) {
http.Error(w, "device must be given and be a valid device id", http.StatusBadRequest)
return
}
if (req.Session == "") == (req.Note == "") {
http.Error(w, "give exactly one of session or note", http.StatusBadRequest)
return
}
for _, f := range []string{req.Session, req.Note} {
if len(f) > 512 || !journal.SafeText(f) {
http.Error(w, "invalid session or note", http.StatusBadRequest)
return
}
}
sel := undoSel{Device: req.Device, Session: req.Session, Note: req.Note}
sourced, err := rs.loadSourcedOps(r.Context())
if err != nil {
storageErr(w, http.StatusBadGateway, "history is temporarily unavailable", err)
return
}
plan := planUndo(sourced, sel)
if len(plan.Ops) == 0 && len(plan.Skipped) == 0 && len(plan.Refused) == 0 {
http.Error(w, "no such run", http.StatusNotFound)
return
}
if req.Preview {
writeJSON(w, undoResponse(plan))
return
}
// An undo stores no bytes — every blob it points at is already in the
// store — but an org whose plan is blocked must still be blocked from
// writing, exactly like restore and remove.
org := s.orgOf(r.PathValue("project"))
if err := s.quota().CheckWrite(org, 0); err != nil {
http.Error(w, err.Error(), http.StatusRequestEntityTooLarge)
return
}
// Blobs before the journal. They are all already stored, so this is a
// check rather than an upload — but a missing one fails the WHOLE undo
// instead of writing a run that points at content no peer can fetch.
seen := map[string]bool{}
for _, op := range plan.Ops {
if op.Kind != journal.KindPut || seen[op.Blob] {
continue
}
seen[op.Blob] = true
if _, ok, err := rs.blobStat(r.Context(), op.Blob); err != nil {
http.Error(w, fmt.Sprintf("undo run: %v", err), http.StatusBadGateway)
return
} else if !ok {
http.Error(w, "content for "+op.Path+" is no longer in the store", http.StatusConflict)
return
}
}
who := s.requestUser(r)
note := undoNote(sel)
for i := range plan.Ops {
plan.Ops[i].User, plan.Ops[i].UserName, plan.Ops[i].Note = who.Email, who.Name, note
}
if err := rs.appendOps(r.Context(), plan.Ops); err != nil {
http.Error(w, fmt.Sprintf("undo run: %v", err), http.StatusBadGateway)
return
}
s.quota().RecordUsage(org, 0)
v.invalidate()
writeJSON(w, undoResponse(plan))
}
// undoResponse is the one shape both the preview and the write answer with.
// Empty lists, never nulls the client has to special-case.
func undoResponse(plan undoPlan) map[string]any {
if plan.Actions == nil {
plan.Actions = []undoAction{}
}
return map[string]any{
"ok": true,
"undone": plan.Actions,
"skipped": orEmpty(plan.Skipped),
"changed_after": orEmpty(plan.After),
"refused": orEmpty(plan.Refused),
}
}
func orEmpty(s []string) []string {
if s == nil {
return []string{}
}
return s
}
+534
View File
@@ -0,0 +1,534 @@
package webapp
import (
"math"
"net/http"
"os"
"path/filepath"
"testing"
"time"
"github.com/runbear-io/beardrive/internal/journal"
"github.com/runbear-io/beardrive/internal/remote"
)
// ---- the planner ----
// sop is a sourcedOp built the way loadSourcedOps builds one: the journal it
// was read FROM is the attribution, and everything inside the op is just what
// the pusher wrote there.
func sop(from string, op journal.Op) sourcedOp {
if op.Device == "" {
op.Device = from
}
return sourcedOp{Op: op, From: from}
}
// seq stamps ops with an increasing (seq, lamport, time) so journal.Less
// orders them in the order they are written, like a real journal.
type seqf struct {
lam int64
seq map[string]int64
t0 time.Time
}
func newSeqf() *seqf {
return &seqf{seq: map[string]int64{}, t0: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}
}
func (s *seqf) put(from, path, blob string, mut ...func(*journal.Op)) sourcedOp {
return s.op(from, journal.Op{Kind: journal.KindPut, Path: path, Blob: blob, Size: int64(len(blob)), Mode: 0o644}, mut...)
}
func (s *seqf) del(from, path string, mut ...func(*journal.Op)) sourcedOp {
return s.op(from, journal.Op{Kind: journal.KindDelete, Path: path}, mut...)
}
func (s *seqf) op(from string, op journal.Op, mut ...func(*journal.Op)) sourcedOp {
s.lam++
s.seq[from]++
op.Lamport, op.Seq, op.Time = s.lam, s.seq[from], s.t0.Add(time.Duration(s.lam)*time.Minute)
for _, m := range mut {
m(&op)
}
return sop(from, op)
}
func inSession(id string) func(*journal.Op) {
return func(o *journal.Op) { o.Session = id; o.Note = "claude-code session " + id }
}
// The four rows of the spec's table, in one run: a file the run edited comes
// back to its pre-run blob, a file it created is removed, a file it deleted
// comes back, and a path already at its pre-run content is skipped rather
// than written.
func TestPlanUndoActions(t *testing.T) {
s := newSeqf()
ops := []sourcedOp{
s.put("dev1", "edited.md", "v1"),
s.put("dev1", "deleted.md", "keepme"),
s.put("dev1", "noop.md", "same"),
// the run
s.put("dev1", "edited.md", "v2", inSession("run1")),
s.put("dev1", "created.md", "brand new", inSession("run1")),
s.del("dev1", "deleted.md", inSession("run1")),
s.put("dev1", "noop.md", "changed", inSession("run1")),
// someone put noop.md back to its pre-run content afterwards
s.put("dev1", "noop.md", "same"),
}
plan := planUndo(ops, undoSel{Device: "dev1", Session: "run1"})
want := map[string]struct {
action string
blob string
}{
"created.md": {"remove", ""},
"deleted.md": {"restore", "keepme"},
"edited.md": {"restore", "v1"},
}
if len(plan.Ops) != len(want) {
t.Fatalf("plan wrote %d ops, want %d: %+v", len(plan.Ops), len(want), plan.Ops)
}
for i, op := range plan.Ops {
w, ok := want[op.Path]
if !ok {
t.Fatalf("unexpected path in plan: %s", op.Path)
}
if plan.Actions[i].Path != op.Path {
t.Fatalf("action %d = %+v, does not line up with op %+v", i, plan.Actions[i], op)
}
switch w.action {
case "remove":
if op.Kind != journal.KindDelete || plan.Actions[i].Action != "remove" {
t.Fatalf("%s = %+v / %+v, want a delete", op.Path, op, plan.Actions[i])
}
case "restore":
if op.Kind != journal.KindPut || op.Blob != w.blob || plan.Actions[i].Action != "restore" {
t.Fatalf("%s = %+v, want a put of %q", op.Path, op, w.blob)
}
if op.Size != int64(len(w.blob)) || op.Mode != 0o644 {
t.Fatalf("%s size/mode came from somewhere other than the historical op: %+v", op.Path, op)
}
}
}
if len(plan.Skipped) != 1 || plan.Skipped[0] != "noop.md" {
t.Fatalf("skipped = %v, want [noop.md]", plan.Skipped)
}
// noop.md is also the path someone touched after the run.
if len(plan.After) != 1 || plan.After[0] != "noop.md" {
t.Fatalf("changed-after = %v, want [noop.md]", plan.After)
}
// Emitted sorted by path, so the dialog and the journal read the same
// every time.
for i := 1; i < len(plan.Ops); i++ {
if plan.Ops[i-1].Path > plan.Ops[i].Path {
t.Fatalf("plan is not sorted by path: %+v", plan.Ops)
}
}
}
// A path nobody has touched since the run produces no warning at all — the
// dialog's whole warning block is absent at zero.
func TestPlanUndoNoChangedAfter(t *testing.T) {
s := newSeqf()
plan := planUndo([]sourcedOp{
s.put("dev1", "a.md", "v1"),
s.put("dev1", "a.md", "v2", inSession("run1")),
}, undoSel{Device: "dev1", Session: "run1"})
if len(plan.After) != 0 {
t.Fatalf("changed-after = %v, want empty", plan.After)
}
}
// The empty-Session clause on the note form: runs.ts keys a session-carrying
// op as "s\0…" and can never file it under a note-keyed group, so an undo
// that ignored the clause would revert ops the card never showed.
func TestUndoRunNoteKeyedIgnoresSessionOps(t *testing.T) {
s := newSeqf()
note := "nightly docs pass"
plan := planUndo([]sourcedOp{
s.put("dev1", "old.md", "v1"),
s.put("dev1", "sessioned.md", "v1"),
// legacy run: a note, no session
s.put("dev1", "old.md", "v2", func(o *journal.Op) { o.Note = note }),
// same note, but it carries a session — a different card entirely
s.put("dev1", "sessioned.md", "v2", func(o *journal.Op) { o.Note, o.Session = note, "run1" }),
}, undoSel{Device: "dev1", Note: note})
if len(plan.Ops) != 1 || plan.Ops[0].Path != "old.md" {
t.Fatalf("note-keyed undo = %+v, want only old.md", plan.Ops)
}
}
// Selection is by the journal an op was READ FROM, never by the op's own
// Device field: that field is arbitrary JSON any member with write access can
// put in their own journal.
func TestUndoRunSelectsByJournalKey(t *testing.T) {
s := newSeqf()
ops := []sourcedOp{
s.put("dev1", "mine.md", "v1"),
s.put("dev2", "theirs.md", "v1"),
// dev1's real run op
s.put("dev1", "mine.md", "v2", inSession("run1")),
// dev2 forges dev1's device id AND session inside its own journal
s.op("dev2", journal.Op{
Kind: journal.KindPut, Path: "theirs.md", Blob: "v2", Size: 2, Mode: 0o644,
Device: "dev1",
}, inSession("run1")),
}
plan := planUndo(ops, undoSel{Device: "dev1", Session: "run1"})
if len(plan.Ops) != 1 || plan.Ops[0].Path != "mine.md" {
t.Fatalf("plan = %+v, want only dev1's own journal's op", plan.Ops)
}
}
// A path the hub's own upload door would refuse never gets journaled by the
// undo — a peer can push one, and a hub that hands it back to every device
// has already lost.
func TestPlanUndoRefusesReservedPaths(t *testing.T) {
s := newSeqf()
plan := planUndo([]sourcedOp{
s.put("dev1", ".bdrive/config.json", "v1"),
s.put("dev1", ".bdrive/config.json", "v2", inSession("run1")),
s.put("dev1", "ok.md", "v1"),
s.put("dev1", "ok.md", "v2", inSession("run1")),
}, undoSel{Device: "dev1", Session: "run1"})
if len(plan.Ops) != 1 || plan.Ops[0].Path != "ok.md" {
t.Fatalf("plan = %+v, want only ok.md", plan.Ops)
}
if len(plan.Refused) != 1 || plan.Refused[0] != ".bdrive/config.json" {
t.Fatalf("refused = %v", plan.Refused)
}
}
// ---- the endpoint ----
// runHub seeds a project with a two-file agent run on device "seed", and
// returns the handler, the API base, and the project's storage dir.
func runHub(t *testing.T, srv *Server, p Project, root string) (http.Handler, string, string) {
t.Helper()
dir := filepath.Join(root, p.ID)
f := newFakeRemoteAt(t, dir)
f.put("seed", "notes/readme.md", "before the run")
f.append("seed", journal.Op{
Kind: journal.KindPut, Path: "notes/readme.md", Blob: shaOf("during the run"),
Size: int64(len("during the run")), Mode: 0o644,
Note: "claude-code session 8f21e4", Session: "8f21e4",
})
writeBlob(t, dir, "during the run")
f.append("seed", journal.Op{
Kind: journal.KindPut, Path: "runbook.md", Blob: shaOf("created by the run"),
Size: int64(len("created by the run")), Mode: 0o644,
Note: "claude-code session 8f21e4", Session: "8f21e4",
})
writeBlob(t, dir, "created by the run")
return srv.Handler(), "/api/p/" + p.ID + "/", dir
}
type undoResp struct {
OK bool `json:"ok"`
Undone []undoAction `json:"undone"`
Skipped []string `json:"skipped"`
ChangedAfter []string `json:"changed_after"`
Refused []string `json:"refused"`
}
// The whole verb, end to end: the edited file goes back to its pre-run
// content and the created file is gone — in ONE write to the hub's journal.
func TestUndoRunOneJournalWrite(t *testing.T) {
var cb *countBackend
srv, p, root := newHub(t, true, func(be remote.Backend) remote.Backend {
cb = newCountBackend(be)
return cb
})
h, base, dir := runHub(t, srv, p, root)
key := "journal/" + webDevice.ID + ".jsonl"
before := cb.putsTo(p.ID + "/" + key)
rec := do(t, h, "POST", base+"undo-run", map[string]any{"session": "8f21e4", "device": "seed"})
var out undoResp
mustJSON(t, rec, &out)
if !out.OK || len(out.Undone) != 2 {
t.Fatalf("undo = %+v, want 2 actions", out)
}
byPath := map[string]string{}
for _, a := range out.Undone {
byPath[a.Path] = a.Action
}
if byPath["notes/readme.md"] != "restore" || byPath["runbook.md"] != "remove" {
t.Fatalf("actions = %+v", out.Undone)
}
// N paths, exactly one Put of our journal. Asserted by counting backend
// calls, not by reading the result: a loop of appendOp answers 200 too.
if got := cb.putsTo(p.ID+"/"+key) - before; got != 1 {
t.Fatalf("undo of 2 paths made %d journal Puts, want exactly 1", got)
}
if rec := do(t, h, "GET", base+"file?path=notes/readme.md", nil); rec.Body.String() != "before the run" {
t.Fatalf("edited file after undo = %q", rec.Body)
}
if rec := do(t, h, "GET", base+"file?path=runbook.md", nil); rec.Code != http.StatusNotFound {
t.Fatalf("created file after undo: %d, want 404", rec.Code)
}
// Only the hub's own journal moved — the run's ops are never edited or
// removed.
js := journalsAt(t, dir)
if _, ok := js[webDevice.ID+".jsonl"]; !ok {
t.Fatalf("the hub wrote no journal of its own: %v", js)
}
seed := js["seed.jsonl"]
if n := countLines(seed); n != 3 {
t.Fatalf("the run's own journal now has %d lines, want its original 3", n)
}
// The undo's ops carry a note naming the run, so it renders as its own
// run card — itself undoable.
entries := historyOf(t, h, base, "notes/readme.md")
if entries[0].Note != "undo run 8f21e4" {
t.Fatalf("undo note = %q", entries[0].Note)
}
if entries[0].Session != "" {
t.Fatalf("the undo carries a session (%q) — it must group as a note-keyed card", entries[0].Session)
}
}
// A preview describes the write without making one.
func TestUndoRunPreviewWritesNothing(t *testing.T) {
srv, p, root := newHub(t, true, nil)
h, base, dir := runHub(t, srv, p, root)
before := journalsAt(t, dir)
rec := do(t, h, "POST", base+"undo-run", map[string]any{"session": "8f21e4", "device": "seed", "preview": true})
var out undoResp
mustJSON(t, rec, &out)
if len(out.Undone) != 2 {
t.Fatalf("preview = %+v, want the same 2 actions", out)
}
if got := journalsAt(t, dir); len(got) != len(before) {
t.Fatalf("a preview wrote a journal: %v → %v", before, got)
}
for name, data := range before {
if journalsAt(t, dir)[name] != data {
t.Fatalf("a preview changed journal %s", name)
}
}
}
// Both halves of the run identity, or neither — and nothing reaches a
// journal on the way to a 400.
func TestUndoRunBadRequests(t *testing.T) {
srv, p, root := newHub(t, true, nil)
h, base, dir := runHub(t, srv, p, root)
before := journalsAt(t, dir)
for _, body := range []map[string]any{
{"session": "8f21e4"}, // no device
{"device": "seed"}, // neither session nor note
{"device": "seed", "session": "8f21e4", "note": "n"}, // both
{"device": "not a device id", "session": "8f21e4"}, // malformed device
{"device": "seed", "session": "bad\x00session"}, // control character
{"device": "seed", "note": string(make([]byte, 1024))}, // over the cap
} {
if rec := do(t, h, "POST", base+"undo-run", body); rec.Code != http.StatusBadRequest {
t.Fatalf("undo %v: %d %s, want 400", body, rec.Code, rec.Body)
}
}
// A run nobody ever wrote is a 404, not an empty success.
if rec := do(t, h, "POST", base+"undo-run", map[string]any{"device": "seed", "session": "nosuchrun"}); rec.Code != http.StatusNotFound {
t.Fatalf("unknown run: %d %s, want 404", rec.Code, rec.Body)
}
for name, data := range before {
if journalsAt(t, dir)[name] != data {
t.Fatalf("a refused undo wrote journal %s", name)
}
}
}
// Write permission, like its two siblings: an outsider and a read-only member
// both get 403.
func TestUndoRunPermissions(t *testing.T) {
h, srv, c, p, root := permHubAt(t)
if err := srv.Projects.SetPerm(p.ID, "carol@x.io", PermRead); err != nil {
t.Fatal(err)
}
dir := filepath.Join(root, p.ID)
f := newFakeRemoteAt(t, dir)
f.put("seed", "a.md", "v1")
f.append("seed", journal.Op{
Kind: journal.KindPut, Path: "a.md", Blob: shaOf("v2"), Size: 2, Mode: 0o644,
Note: "claude-code session r1", Session: "r1",
})
writeBlob(t, dir, "v2")
base := "/api/p/" + p.ID + "/"
body := map[string]any{"session": "r1", "device": "seed"}
for _, who := range []string{"dave", "carol"} { // outsider, read-only member
if rec := doAs(t, h, "POST", base+"undo-run", body, c[who]); rec.Code != http.StatusForbidden {
t.Fatalf("%s undo: %d %s, want 403", who, rec.Code, rec.Body)
}
}
// A preview is still a write route: it reads the whole journal set and
// names every path in the project's history.
if rec := doAs(t, h, "POST", base+"undo-run", map[string]any{"session": "r1", "device": "seed", "preview": true}, c["carol"]); rec.Code != http.StatusForbidden {
t.Fatalf("read-only preview: %d, want 403", rec.Code)
}
if rec := doAs(t, h, "POST", base+"undo-run", body, c["bob"]); rec.Code != 200 {
t.Fatalf("member with write: %d %s", rec.Code, rec.Body)
}
}
// An undo stores no bytes, but an org whose plan is blocked must still be
// blocked from writing.
func TestUndoRunQuota(t *testing.T) {
h, srv, c, p, root := permHubAt(t)
q := &recQuota{}
srv.Quota = q
dir := filepath.Join(root, p.ID)
f := newFakeRemoteAt(t, dir)
f.put("seed", "a.md", "v1")
f.append("seed", journal.Op{
Kind: journal.KindPut, Path: "a.md", Blob: shaOf("v2"), Size: 2, Mode: 0o644,
Note: "claude-code session r1", Session: "r1",
})
writeBlob(t, dir, "v2")
base := "/api/p/" + p.ID + "/"
body := map[string]any{"session": "r1", "device": "seed"}
before := journalsAt(t, dir)
q.denyW = true
rec := doAs(t, h, "POST", base+"undo-run", body, c["alice"])
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("blocked plan: %d %s, want 413", rec.Code, rec.Body)
}
for name, data := range before {
if journalsAt(t, dir)[name] != data {
t.Fatalf("a quota-blocked undo wrote journal %s", name)
}
}
if len(q.writes) != 1 || q.writes[0].bytes != 0 {
t.Fatalf("quota calls = %+v, want one CheckWrite of 0 bytes", q.writes)
}
q.denyW = false
if rec := doAs(t, h, "POST", base+"undo-run", body, c["alice"]); rec.Code != 200 {
t.Fatalf("unblocked undo: %d %s", rec.Code, rec.Body)
}
if len(q.usage) != 1 {
t.Fatalf("usage = %+v, want one RecordUsage", q.usage)
}
}
// ---- appendOps ----
// N ops, one read-modify-write. This is the atomicity argument for undo-run:
// one Put of one object either lands or it does not.
func TestAppendOpsOneWrite(t *testing.T) {
var cb *countBackend
srv, p, _ := newHub(t, true, func(be remote.Backend) remote.Backend {
cb = newCountBackend(be)
return cb
})
rs := &RemoteSource{Backend: remote.Prefixed(srv.Root, p.ID+"/"), Device: webDevice}
key := p.ID + "/journal/" + webDevice.ID + ".jsonl"
ops := make([]journal.Op, 5)
for i := range ops {
ops[i] = journal.Op{Kind: journal.KindDelete, Path: string(rune('a'+i)) + ".md"}
}
if err := rs.appendOps(t.Context(), ops); err != nil {
t.Fatal(err)
}
if got := cb.putsTo(key); got != 1 {
t.Fatalf("5 ops made %d Puts, want 1", got)
}
all, err := rs.loadOps(t.Context())
if err != nil {
t.Fatal(err)
}
if len(all) != 5 {
t.Fatalf("journal holds %d ops, want 5", len(all))
}
// An empty batch never rewrites the key: a Put of identical bytes still
// bumps Modified and invalidates every reader's journal cache.
if err := rs.appendOps(t.Context(), nil); err != nil {
t.Fatal(err)
}
if got := cb.putsTo(key); got != 1 {
t.Fatalf("an empty batch wrote the journal (%d Puts)", got)
}
}
// Seq and Lamport increase across a batch, and saturate rather than wrap when
// a peer has already claimed MaxInt64.
func TestAppendOpsOrdering(t *testing.T) {
srv, p, root := newHub(t, true, nil)
dir := filepath.Join(root, p.ID)
newFakeRemoteAt(t, dir)
rs := &RemoteSource{Backend: remote.Prefixed(srv.Root, p.ID+"/"), Device: webDevice}
ops := make([]journal.Op, 4)
for i := range ops {
ops[i] = journal.Op{Kind: journal.KindDelete, Path: string(rune('a'+i)) + ".md"}
}
if err := rs.appendOps(t.Context(), ops); err != nil {
t.Fatal(err)
}
for i := 1; i < len(ops); i++ {
if ops[i].Seq <= ops[i-1].Seq || ops[i].Lamport <= ops[i-1].Lamport {
t.Fatalf("op %d = (seq %d, lamport %d) after (seq %d, lamport %d)",
i, ops[i].Seq, ops[i].Lamport, ops[i-1].Seq, ops[i-1].Seq)
}
}
// A peer pushes MaxInt64. Every remaining lamport saturates there — and
// journal.Less still totally orders them through (time, device, seq), so
// replay stays deterministic.
f := &fakeRemote{t: t, dir: dir, seq: map[string]int64{}}
f.append("peer", journal.Op{Kind: journal.KindDelete, Path: "z.md", Lamport: math.MaxInt64})
// fakeRemote assigns its own lamport, so write the op straight in.
if err := journal.Append(filepath.Join(dir, "journal", "peer2.jsonl"), []journal.Op{{
Seq: 1, Lamport: math.MaxInt64, Time: time.Now().UTC(),
Device: "peer2", Kind: journal.KindDelete, Path: "zz.md",
}}); err != nil {
t.Fatal(err)
}
more := []journal.Op{
{Kind: journal.KindDelete, Path: "m1.md"},
{Kind: journal.KindDelete, Path: "m2.md"},
}
if err := rs.appendOps(t.Context(), more); err != nil {
t.Fatal(err)
}
for i, op := range more {
if op.Lamport != math.MaxInt64 {
t.Fatalf("op %d lamport = %d, want it saturated at MaxInt64 (a wrap is MinInt64)", i, op.Lamport)
}
}
if more[1].Seq <= more[0].Seq {
t.Fatalf("seq stopped increasing at lamport saturation: %d then %d", more[0].Seq, more[1].Seq)
}
}
// ---- helpers ----
func countLines(s string) int {
n := 0
for _, c := range s {
if c == '\n' {
n++
}
}
return n
}
// writeBlob stores content under its own sha, the way an upload would —
// fakeRemote.append journals an op without one.
func writeBlob(t *testing.T, dir, content string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, "blobs", shaOf(content)), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
+35 -9
View File
@@ -173,6 +173,25 @@ func nextLamport(cur int64) int64 {
// 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 {
return r.appendOps(ctx, []journal.Op{op})
}
// appendOps is the same write for N ops at once, in ONE read-modify-write.
// Every journal write in this package goes through it — appendOp is the
// single-op call — so there is exactly one place that stamps the hub's
// identity and exactly one that rewrites the key.
//
// The batch is not an optimization detail: it is what makes a multi-path
// write atomic. One Put of one object either lands or it does not, so a
// run-wide undo (undorun.go) can never leave half a run reverted. A caller
// that loops appendOp instead gets N whole-journal round trips AND a
// partially-applied write with nothing to report it.
func (r *RemoteSource) appendOps(ctx context.Context, ops []journal.Op) error {
// Never rewrite an unchanged journal: a Put of identical bytes still
// bumps Modified, which invalidates every reader's journal cache.
if len(ops) == 0 {
return nil
}
r.upmu.Lock()
defer r.upmu.Unlock()
@@ -187,14 +206,21 @@ func (r *RemoteSource) appendOp(ctx context.Context, op journal.Op) error {
mySeq = max(mySeq, prev.Seq)
}
}
// Saturating, like the client's tickLamport. maxLamport is taken over
// every journal the hub can see, members' included, and int64 addition
// wraps: one pushed op carrying MaxInt64 made the hub's next lamport
// MinInt64 — recomputed on every commit, so every later browser upload in
// the project silently lost last-writer-wins while commit still answered
// 200.
op.Seq, op.Lamport, op.Time = mySeq+1, nextLamport(maxLamport), time.Now().UTC()
op.Device, op.DeviceName, op.Author = r.Device.ID, r.Device.Name, r.Device.Author
now := time.Now().UTC()
lam := maxLamport
for i := range ops {
// Saturating, like the client's tickLamport. maxLamport is taken over
// every journal the hub can see, members' included, and int64 addition
// wraps: one pushed op carrying MaxInt64 made the hub's next lamport
// MinInt64 — recomputed on every commit, so every later browser upload
// in the project silently lost last-writer-wins while commit still
// answered 200. At saturation the batch's lamports stop increasing and
// journal.Less orders the rest through (time, device, seq), which is
// still a total order — so replay stays deterministic.
lam = nextLamport(lam)
ops[i].Seq, ops[i].Lamport, ops[i].Time = mySeq+int64(i)+1, lam, now
ops[i].Device, ops[i].DeviceName, ops[i].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
@@ -214,7 +240,7 @@ func (r *RemoteSource) appendOp(ctx context.Context, op journal.Op) error {
return err
}
}
line, err := journal.Marshal([]journal.Op{op})
line, err := journal.Marshal(ops)
if err != nil {
return err
}
@@ -66,6 +66,26 @@ Two things worth knowing:
the note is free text anyone can set with `bdrive sync --note`, so joining on
it would let one person's changes attach to another person's card.
### Undoing a whole run
The card's header carries **Undo this run**: one click puts back every file
that run touched. A file it edited returns to the content it had just before
the run; a file it created is removed. The confirm lists every path with what
will happen to it before anything is written, so you can read the whole thing
and cancel.
Two things it says out loud, because they are the ones that can surprise you:
- **A file someone changed after the run is reverted too**, and the confirm
counts them. That is the same last-writer-wins rule the rest of BearDrive
follows, but it is worth seeing before you click.
- **A file already holding its pre-run content is skipped**, not written —
reported as skipped rather than as a failure.
Nothing is erased. The undo is new changes appended to history like any other,
written in a single batch, so it is itself a run card you can undo. Undoing
needs write access on the project; a read-only member sees no button.
Per-session detail is kept for 30 days by default
(`reads.session_retention_days`, see [Hub config](/reference/hub-config/));
after that the run card shows changes only. Read *counts* are unaffected — they