mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
* 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>
88 lines
2.7 KiB
Go
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})
|
|
}
|