Skip to content

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

Open
ssowonny wants to merge 2 commits into
mainfrom
bea-82-ph-idea-undo-a-whole-agent-run-from-the-run-card
Open

feat(webapp): undo a whole agent run from the run card#156
ssowonny wants to merge 2 commits into
mainfrom
bea-82-ph-idea-undo-a-whole-agent-run-from-the-run-card

Conversation

@ssowonny

Copy link
Copy Markdown
Contributor

TL;DR

  • The run card had a button-shaped hole in its header. Now it has the button: undo this run puts back every file that run touched, in one click.
  • The confirm shows the whole list first — and says out loud when a teammate changed one of those files after the run, because undoing overwrites that too.
  • Nothing is erased. The undo is new ops appended like any other change, so it's itself a run card you can undo.
  • All N paths land in one journal write, not N. That's the atomicity story, not a speed tweak — a half-undone run would have nothing to report it.
  • Known gap: this is the plan's two PRs in one (the appendOps batch is a prerequisite the verb can't ship without). Details below.

Closes BEA-82.


What it does

Reverting a bad agent run meant opening the card, clicking file by file, and hoping you got them all. The grouping was built expecting the run-wide verb — runs.ts says so in its own comment ("which is what a later run-wide restore needs") — and BEA-6 shipped everything except it.

For every distinct path the run touched, the server works out the op that puts it back:

The path's state just before the run What gets written
no earlier op, or the earlier op is a delete a delete — the run created it, so un-create it
an earlier put at blob X a put at blob X, with its size and mode
pre-run content already equals current content nothing; reported as skipped, not failed
a path the hub's own upload door would refuse nothing; reported as refused

A move the agent made needs no special handling — it's a delete of the old path plus a put of the new, both inside the run, so undoing both paths is already right. No buildMoveIndex here.

The three things that can't break

One Put is the entire atomicity argument. appendOp did a full read-modify-write of the hub journal per op, so a 50-file undo would have been 50 whole-journal round trips — and, worse, 50 chances to stop halfway. appendOps takes upmu once, does one loadOps, assigns Seq/Lamport across the batch, and does one Exists+Get+Put. One object either lands or it doesn't, so there is no half-rollback to report. appendOp is now just appendOps of one, so the package has exactly one write path.

If a later change ever loops appendOp inside the handler, the feature silently becomes partially-applicable with no error anywhere. TestUndoRunOneJournalWrite asserts the backend Put count, not the response — a loop answers 200 too. Please keep that assertion.

Selection is by sourcedOp.From, never op.Device. An op's Device field is arbitrary JSON any member with write access can put in their own journal; the journal key is the part /store's ownJournal gates, which is why History already attributes rows that way. Getting this wrong is invisible in every normal test, because on an honest journal the two are equal — so TestUndoRunSelectsByJournalKey writes a dishonest one: a second device forges dev1's device id and session inside its own journal, and the plan still reverts only dev1's real op.

The note form additionally requires Session == "". 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 this would revert ops the card never showed. Op.Note is user-settable via bdrive sync --note; Op.Session isn't. The endpoint takes the session wherever the run has one.

What you're accepting

A file a teammate changed after the run gets reverted too. This is the one genuine product judgment here — no code settles it. Per-row restore already overwrites current content without asking whose it is, and last-writer-wins is the model. But it's also the one place this feature can burn someone, so the confirm counts those paths, marks each one inline, and says it in words. At zero, the whole warning block is absent. It's one branch and one line of copy to flip if you'd rather it refused.

Anyone with write can undo anyone's run. restore and remove are both proj(PermWrite, …) and neither checks authorship; an author-scoped undo would be the only write in the hub that does. A read-only member sees no button and gets a 403 from the route, preview included.

The undo is itself a run card, and two undos of the same run merge into one card. They're the same run; the spec accepts it.

Preview → write is not transactional. Another op can land between the two calls. The write recomputes the plan server-side and never trusts the client's, so the cost is a confirm that was one op stale, never a wrong write.

Deviations from the reviewed plan

Two, both stated rather than silent:

  1. One PR, not two. The plan split this into "PR 1 — one Put instead of N" and "PR 2 — the verb", strict order. The build agent runs one branch and one PR per issue, and PR 2 must not reintroduce a per-op loop while waiting for PR 1 — so both land here. The refactor is still separable in review: appendOps and its two tests stand alone, and every existing caller goes through the single-op path unchanged.
  2. The confirm also names refused paths. The plan added Refused to the planner ("not in the spec but not optional") but described a dialog listing only what the undo would do. A dialog that silently drops a category reads as "all of it", so it gets a line. Second commit on the branch.

Everything else is as planned, including both of the plan's corrections to the spec (select by the journal, not op.Device; get the file list from the server, not the loaded feed — that window is paged and filterable, so a client-computed list is wrong exactly when the run is old).

What it looks like

Before After
run card before run card after

The header's note used to have all the room it wanted. Adding the button made the header shrink everything proportionally, which truncated claude-code session 8f21e4 down to claude-code session … — so the note now holds its size (still capped at 46%) and the meta, which every row inside the card repeats anyway, absorbs the shrink. On mobile the button wraps onto its own line and flex-shrink comes back, since a wrapping header that refuses to shrink pushes a long note past the card edge.

The confirm …and when someone changed a file after the run
confirm confirm with warning
Mobile (375px)

run card on mobile

Architecture changes

architecture/webapp-server.md: RemoteSource gains appendOps (and the note at the bottom of the write path now says N ops in one read-modify-write, with appendOp as its single-op call). A new undoRunDoor joins restore/remove as the third write door, depending on sourcedOp for selection and on RemoteSource.appendOps for the write. Nothing was removed.

✅ added · ❌ removed (strikethrough) · unmarked = unchanged

flowchart TB
    RemoteSource["<div style='text-align:left'><b>RemoteSource</b><br/>+Backend remote.Backend<br/>+Device Identity<br/>+Remove(ctx, path, who, note)<br/>-loadSourcedOps(ctx) []sourcedOp<br/>-appendOp(ctx, op)<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ -appendOps(ctx, ops) ONE read-modify-write</span></div>"]
    sourcedOp["<div style='text-align:left'><b>sourcedOp</b><br/>+Op journal.Op<br/>+From journal key's device</div>"]
    undoRunDoor["<div style='text-align:left'><b>undoRunDoor</b><br/>POST /api/p/id/undo-run<br/>planUndo(sourced, undoSel) undoPlan<br/>undoSel: Device From-journal, Session xor Note<br/>undoPlan: Ops, Actions, Skipped, After, Refused<br/>preview: plan only, no write, no quota</div>"]
    RemoteSource -. "attribution comes from the journal key" .-> sourcedOp
    undoRunDoor -. "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ selects a run by the journal it was read from</span>" .-> sourcedOp
    undoRunDoor -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ appendOps - the whole run in one Put</span>" --> RemoteSource
    classDef added fill:#22c55e22,stroke:#22c55e,stroke-width:2px
    class undoRunDoor added
    linkStyle 1 stroke:#22c55e,stroke-width:2px
    linkStyle 2 stroke:#22c55e,stroke-width:2px
Loading

architecture/webapp-frontend.md gains a note only — no types or relationships changed there — recording that RunGroup's header carries the verb, that it asks the server for the file list rather than deriving it from the loaded feed, and that modal.tsx's Confirm.message widened from string to ReactNode for it (the prompt's one-field API is untouched, and every existing caller passes a string).

What was run

  • go build ./..., go vet ./..., go test ./... — all green.
  • 12 new Go tests: the planner's four table rows, TestUndoRunNoteKeyedIgnoresSessionOps, TestUndoRunSelectsByJournalKey, reserved paths, the 400 shapes and the 404, 403 for a non-member and a read-only member (preview too), the quota block via recQuota, TestUndoRunOneJournalWrite counting backend Puts, and TestAppendOpsOrdering for Seq/Lamport increase and MaxInt64 saturation (at saturation lamports stop increasing and journal.Less orders the rest through (time, device, seq) — so the assertion is non-decreasing-with-saturation, not strictly increasing).
  • TestUndoRunConverges in internal/syncer — real devices through a real hub over a file:// store, per the repo's "a sync feature without a multi-device test is untested where it matters". A writes two files with a session and cycles, B syncs, the test POSTs undo-run at the hub, and both devices materialize the pre-run content. It also reads deva.jsonl back to prove the run's own ops are still there, session id and all.
  • npm run e2e — 172 passed, 1 pre-existing skip. New coverage in session-run.spec.ts: the confirm's file list and warning, Cancel means nothing happened, the undo, and then undoing the undo card (which is both the acceptance criterion and how the spec leaves the shared fixture as it found it). Plus a read-only member seeing no button.
  • frontend/check-dist.shinternal/webapp/static is fresh.
  • Both changed architecture diagrams rendered through the app's own mermaid (the hub renders them natively) — no error notes.
  • UI driven by hand at 1280px and 375px; the header-truncation fix above came out of that pass, not a review comment.

Verdict

The endpoints, the run key, the grouping and the per-row verbs were all already where the spec said they were, so the risk here is concentrated in two places: the one-Put batch and the by-journal selection. Both have tests written to fail loudly if someone regresses them.

The one open loop, unchanged from the spec: undoing a run that a teammate's device produced is allowed, because nothing else in the hub is author-scoped. If you want it scoped, it's one branch in the planner.

Build session

cd $(git worktree list | grep bea-82-ph-idea-undo-a-whole-agent-run-from-the-run-card | awk '{print $1}') && claude --resume 69eeec78-2d74-4b91-bc87-8ede8e3c7fbf

(Only works on the machine that ran the build.)

ssowonny and others added 2 commits August 12, 2026 10:27
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>
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>
@ssowonny
ssowonny requested a review from thefron August 12, 2026 17:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant