Files
beardrive/internal/webapp/restore.go
T
4ff92c56ab feat(history): group agent runs and restore any version (BEA-6) (#69)
* feat(history): group agent runs and restore any version (BEA-6)

BearDrive recorded everything and could restore nothing. Now every version
of a file has a Restore button — in the hub's History view and as
`bdrive restore` — and the changes one agent run made read as one card
instead of N loose rows.

Restore is a NEW put op pointing at the old blob: journals are never
rewritten, so one-writer-per-journal holds and peers converge on the
restore like any other edit. The hub reuses RemoteSource.Commit (the
upload commit minus the upload); the CLI writes the bytes into the working
folder and lets the ordinary cycle journal them, so the sync engine gains
no new write path.

Grouping is a pure frontend group-by on (note, device) over the existing
/history response — no journal or API change.

Known gap, stated in the UI and the docs: nothing in the hub writes a
delete op yet, so a file a run *created* cannot be un-created.

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

* fix(history): don't repeat a run's note on every row in its card

UI pass on the real hub: inside a run card the note is the card's header, so
printing it again on each row said the same thing N times. The header now
carries the note (linkified, so an agent's session link still opens) and the
collapse control is its own button rather than the whole header — the link
could not live inside a button.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 17:15:46 +09:00

88 lines
2.7 KiB
Go

package webapp
import (
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/runbear-io/beardrive/internal/journal"
)
// Restore puts an old version of a file back — as a NEW op, never by editing
// history. The blob is already in the store (they are retained forever), so
// this is the upload commit minus the upload: find the historical op, journal
// a put pointing at the same blob, done. Every device then converges on it
// like any other change, and the restore is itself restorable.
//
// What it deliberately is not: removing the offending ops. That would break
// one-writer-per-journal, strand peers that already replayed them, and
// corrupt the push cursor.
// handleRestore serves POST /api/p/<id>/restore {path, sha}.
func (s *Server) handleRestore(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"`
SHA string `json:"sha"`
}
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
}
if !blobRe.MatchString(req.SHA) {
http.Error(w, "sha must be 64 lowercase hex chars", http.StatusBadRequest)
return
}
all, err := rs.loadOps(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
// The sha must be a version OF THIS PATH: without this, restore would
// paste any blob in the store onto any path.
var found *journal.Op
for i := range all {
if op := &all[i]; op.Kind == journal.KindPut && op.Path == p && op.Blob == req.SHA {
found = op
break
}
}
if found == nil {
http.Error(w, "no such version of that file", http.StatusNotFound)
return
}
// The blob is already stored, so a restore adds 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
}
note := fmt.Sprintf("restore %s@%s", p, req.SHA[:8])
// Size comes from the historical op, never from the request body.
if err := rs.Commit(r.Context(), p, req.SHA, found.Size, s.requestUser(r), note); err != nil {
code := http.StatusBadGateway
if err == errBlobMissing {
code = http.StatusConflict
}
http.Error(w, fmt.Sprintf("restore: %v", err), code)
return
}
s.quota().RecordUsage(org, 0)
v.invalidate()
writeJSON(w, map[string]any{"ok": true, "blob": req.SHA, "size": found.Size})
}