Files
beardrive/internal/webapp/journalcache_test.go
T
eb01953729 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>
2026-08-18 14:04:35 -07:00

249 lines
7.3 KiB
Go

package webapp
import (
"context"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/runbear-io/beardrive/internal/remote"
)
// History folded every journal of a project on EVERY request — 8-10s on a real
// project, and paging made it worse rather than better, since each "load more"
// re-downloaded and re-parsed the lot (BEA-85). Journals only grow, so the
// (Size, Modified) that List already reports proves a parse is still current.
//
// These tests pin the cache from the outside: what matters is not that a map
// 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, 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{}, 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) {
b.mu.Lock()
b.gets[key]++
b.mu.Unlock()
return b.Backend.Get(ctx, key)
}
func (b *countBackend) List(ctx context.Context, prefix string) ([]remote.Object, error) {
b.mu.Lock()
b.lists++
b.mu.Unlock()
return b.Backend.List(ctx, prefix)
}
// journalGets is how many times a journal — any journal — has been fetched.
func (b *countBackend) journalGets() int {
b.mu.Lock()
defer b.mu.Unlock()
n := 0
for k, c := range b.gets {
if strings.Contains(k, "journal/") {
n += c
}
}
return n
}
func (b *countBackend) getsFor(dev string) int {
b.mu.Lock()
defer b.mu.Unlock()
n := 0
for k, c := range b.gets {
if strings.HasSuffix(k, "journal/"+dev+".jsonl") {
n += c
}
}
return n
}
func (b *countBackend) listCount() int {
b.mu.Lock()
defer b.mu.Unlock()
return b.lists
}
// cachedHub is a hub over a counting backend with a seeded three-device
// project, plus the handler and the base URL for its history route.
func cachedHub(t *testing.T) (*Server, *countBackend, *fakeRemote, http.Handler, string) {
t.Helper()
var cb *countBackend
srv, p, root := newHub(t, false, func(be remote.Backend) remote.Backend {
cb = newCountBackend(be)
return cb
})
f := newFakeRemoteAt(t, filepath.Join(root, p.ID))
f.putAs("dev1", "alice@x.io", "Alice", "notes/plan.md", "v1")
f.putAs("dev1", "alice@x.io", "Alice", "notes/plan.md", "v2 longer")
f.putAs("dev2", "bob@x.io", "Bob", "notes/other.md", "bob's file")
f.del("dev2", "notes/other.md")
f.putAs("dev3", "carol@x.io", "Carol", "readme.md", "top")
return srv, cb, f, srv.Handler(), "/api/p/" + p.ID + "/"
}
func TestHistoryReusesParsedJournals(t *testing.T) {
_, cb, _, h, base := cachedHub(t)
if rec := do(t, h, "GET", base+"history", nil); rec.Code != 200 {
t.Fatalf("cold history: %d %s", rec.Code, rec.Body)
}
cold, lists := cb.journalGets(), cb.listCount()
if cold < 3 {
t.Fatalf("cold load fetched %d journals, want at least 3", cold)
}
for i := 0; i < 3; i++ {
if rec := do(t, h, "GET", base+"history", nil); rec.Code != 200 {
t.Fatalf("warm history: %d %s", rec.Code, rec.Body)
}
}
if got := cb.journalGets(); got != cold {
t.Fatalf("warm requests fetched %d journals, want 0 beyond the cold %d", got-cold, cold)
}
// The List is the round trip a warm request still pays: it is what proves
// nothing changed, so it must NOT be cached away.
if cb.listCount() <= lists {
t.Fatalf("list count %d did not advance past %d", cb.listCount(), lists)
}
}
func TestJournalCacheInvalidatesOnePush(t *testing.T) {
_, cb, f, h, base := cachedHub(t)
do(t, h, "GET", base+"history", nil)
before1, before2, before3 := cb.getsFor("dev1"), cb.getsFor("dev2"), cb.getsFor("dev3")
f.putAs("dev2", "bob@x.io", "Bob", "notes/other.md", "bob is back")
if rec := do(t, h, "GET", base+"history", nil); rec.Code != 200 {
t.Fatalf("history after push: %d %s", rec.Code, rec.Body)
}
if got := cb.getsFor("dev2"); got != before2+1 {
t.Fatalf("dev2 fetched %d times, want %d", got, before2+1)
}
if cb.getsFor("dev1") != before1 || cb.getsFor("dev3") != before3 {
t.Fatalf("untouched journals re-fetched: dev1 %d→%d, dev3 %d→%d",
before1, cb.getsFor("dev1"), before3, cb.getsFor("dev3"))
}
}
func TestJournalCachePagingRefetchesNothing(t *testing.T) {
_, cb, _, h, base := cachedHub(t)
rec := do(t, h, "GET", base+"history?n=2", nil)
if rec.Code != 200 {
t.Fatalf("page 1: %d %s", rec.Code, rec.Body)
}
var page struct {
Entries []HistoryEntry `json:"entries"`
Next string `json:"next_cursor"`
}
mustJSON(t, rec, &page)
if page.Next == "" {
t.Fatalf("no cursor to page with: %s", rec.Body)
}
after := cb.journalGets()
for cursor := page.Next; cursor != ""; cursor = page.Next {
page.Next = ""
mustJSON(t, do(t, h, "GET", base+"history?n=2&cursor="+url.QueryEscape(cursor), nil), &page)
}
if got := cb.journalGets(); got != after {
t.Fatalf("paging re-fetched %d journals", got-after)
}
}
// The criterion that matters most: the cache must be invisible in the output.
func TestHistoryOutputUnchangedByCache(t *testing.T) {
srv, _, _, h, base := cachedHub(t)
urls := []string{
"history", "history?path=notes/plan.md", "history?prefix=notes",
"history?n=2", "history?user=alice@x.io",
}
warm := make([]string, len(urls))
for i, u := range urls {
do(t, h, "GET", base+u, nil) // cold
warm[i] = do(t, h, "GET", base+u, nil).Body.String()
}
// Drop everything the cache holds; the next request is the uncached code
// path, byte for byte.
_, v, err := srv.projectVolume(strings.TrimSuffix(strings.TrimPrefix(base, "/api/p/"), "/"))
if err != nil {
t.Fatal(err)
}
rs := v.source.(*RemoteSource)
rs.jmu.Lock()
rs.jcache, rs.jbytes = nil, 0
rs.jmu.Unlock()
for i, u := range urls {
if got := do(t, h, "GET", base+u, nil).Body.String(); got != warm[i] {
t.Fatalf("%s differs with and without the cache:\ncached: %s\nfresh: %s", u, warm[i], got)
}
}
}
// A journal the remote no longer has must not stay resident, or a hub leaks a
// map entry per device that ever synced anything.
func TestJournalCacheDropsVanishedJournals(t *testing.T) {
srv, _, f, h, base := cachedHub(t)
do(t, h, "GET", base+"history", nil)
_, v, err := srv.projectVolume(strings.TrimSuffix(strings.TrimPrefix(base, "/api/p/"), "/"))
if err != nil {
t.Fatal(err)
}
rs := v.source.(*RemoteSource)
if err := os.Remove(filepath.Join(f.dir, "journal", "dev3.jsonl")); err != nil {
t.Fatal(err)
}
do(t, h, "GET", base+"history", nil)
rs.jmu.Lock()
defer rs.jmu.Unlock()
if _, ok := rs.jcache["journal/dev3.jsonl"]; ok {
t.Fatalf("cache kept a journal the remote no longer has: %v", rs.jcache)
}
// The running total is what the ceiling is enforced against, so a prune or
// a re-fetch that forgets to subtract would quietly disable the cache.
var sum int64
for _, c := range rs.jcache {
sum += c.bytes
}
if rs.jbytes != sum {
t.Fatalf("byte total %d, cached entries sum to %d", rs.jbytes, sum)
}
}