Files
Snow Lee (Sungwon)andGitHub 5f1ac98dae feat(hub): see what each agent session read, not just what it changed (BEA-98) (#135)
History showed what an agent run CHANGED. What it read lived in a daily
aggregate with no session dimension, so the two could not be joined and
nobody could answer "when my agent answered, what did it look at — and was
it the fresh version or archive/retired-spec.md?".

The join is one string carried through four places: hook -> spool -> hub ->
run card. A run card now marks each change the run also read, lists the
files it read and never touched, and says on screen why a read can be
missing.

The three landmines the issue asks be named here:

1. Op.Note is USER-SETTABLE (`bdrive sync --note`), so joining reads to
   writes on the note string would let any member with write access forge a
   note that collides with a teammate's run card and hang their reads off
   it. Fixed by adding journal.Op.Session — set only by `bdrive sync
   --hook`, never by --note — and joining on that. The note stays settable
   and stays untrusted; the join simply never reads it. Op.Session is
   additive JSONL and, like Mtime, is never an input to Less or Replay, so
   replay determinism is untouched and older ops carry "".

   The read half has the same hole one step further on: POST /reads takes
   the session id from the CLIENT, so a member could report reads under a
   teammate's session and paint files onto their card. Every session row is
   therefore pinned to the ownsDevice-validated device, and the query
   requires ?session= AND ?device= together — a forged row can only be found
   under the forger's own device, which MayActAs guarantees is never
   somebody else's.

2. BUCKET CARDINALITY. Putting the session in the read_stats key would take
   a 2k-file project from ~2k to ~100k rows/day, into a table ReadLedger
   loads whole at boot and full-scans on every heat request, hub-wide — so
   it would slow the Dashboard for projects that never ran an agent. This
   is the escape hatch the spec itself names, taken up front: session rows
   live in their own read_sessions repo, outside ReadLedger.byKey. No
   read_stats PK migration, no change to the resident-row count, ?by=device
   byte-identical. They get their own retention (session_retention_days,
   default 30) which DELETES rather than folds — no heat total was ever
   derived from them.

3. READS ARE RECORDED ONLY FOR PATHS IN THE CURRENT REPLAY, so a session
   that read a file it then deleted shows a change with no read. That is by
   design, and the run card says so in its footer rather than leaving it to
   read as a bug.

Privacy ruling, written into internal/webapp/reads.go before anything
serves it: a session id appears only in History responses on the op that
carries it, and as a ?session= filter INPUT. It is never enumerated — no
listing endpoint, no session column in /heat output, nothing new in
?by=device.

Also: PendingReads now dedupes on (path, session), not path alone. Two
agent sessions on one device between syncs used to collapse into one event
carrying whichever session flushed last — one session's reads silently
credited to another.

Tests: journal round-trip + Less-ignores-Session; the forge test (`sync
--note "claude-code session <someone-else's>"` leaves Session empty); a
multi-device syncer test carrying the session through convergence; spool
per-session dedup; hub round-trip, cross-device forge, query contract and
non-enumeration; db_conformance on file, sqlite AND postgres; runs.ts
grouping incl. legacy fallback; a Playwright spec on the seeded run card.
2026-08-11 04:18:53 +09:00

166 lines
5.1 KiB
Go

package syncer
import (
"context"
"fmt"
"sync"
"testing"
"github.com/runbear-io/beardrive/internal/remote"
)
// readReportingRemote wraps a backend with the hub's ReadReporter capability,
// standing in for the https:// backend in the multi-device harness.
type readReportingRemote struct {
remote.Backend
mu sync.Mutex
fail bool
reports [][]remote.ReadEvent
}
func (r *readReportingRemote) ReportReads(_ context.Context, reads []remote.ReadEvent) error {
r.mu.Lock()
defer r.mu.Unlock()
if r.fail {
return fmt.Errorf("hub unreachable")
}
cp := make([]remote.ReadEvent, len(reads))
copy(cp, reads)
r.reports = append(r.reports, cp)
return nil
}
func (r *readReportingRemote) setFail(v bool) {
r.mu.Lock()
defer r.mu.Unlock()
r.fail = v
}
func (r *readReportingRemote) all() [][]remote.ReadEvent {
r.mu.Lock()
defer r.mu.Unlock()
return r.reports
}
// TestAgentReadReporting drives the read spool through real sync cycles: the
// queued reads flush to a reporting hub (deduped), survive an unreachable hub
// and retry, and never disturb the sync result itself.
func TestAgentReadReporting(t *testing.T) {
hub := &readReportingRemote{Backend: sharedRemote(t)}
a := newDevice(t, "deva", hub)
write(t, a.Folder, "wiki/a.md", "content")
// The agent read a.md twice and b.md once before this cycle.
a.Store.LogRead("wiki/a.md", "")
a.Store.LogRead("wiki/a.md", "")
a.Store.LogRead("b.md", "")
res := cycle(t, a)
if !res.Pushed {
t.Fatal("cycle should have pushed")
}
reports := hub.all()
if len(reports) != 1 || len(reports[0]) != 2 {
t.Fatalf("reports = %+v, want one deduped batch of 2", reports)
}
if reports[0][0].Path != "wiki/a.md" || reports[0][1].Path != "b.md" {
t.Fatalf("batch = %+v", reports[0])
}
// Drained: an idle cycle reports nothing.
cycle(t, a)
if len(hub.all()) != 1 {
t.Fatal("empty spool still produced a report")
}
// Hub down: the cycle still succeeds and the batch stays queued.
hub.setFail(true)
a.Store.LogRead("wiki/a.md", "")
if res := cycle(t, a); res.Offline {
t.Fatal("a failed read report must not mark the cycle offline")
}
if len(hub.all()) != 1 {
t.Fatal("failed report should not have landed")
}
// Hub back: the next cycle retries the same batch.
hub.setFail(false)
cycle(t, a)
reports = hub.all()
if len(reports) != 2 || len(reports[1]) != 1 || reports[1][0].Path != "wiki/a.md" {
t.Fatalf("retry reports = %+v", reports)
}
// A backend without the capability (plain object store) is untouched by
// queued reads: the cycle runs, the spool just keeps waiting.
b := newDevice(t, "devb", sharedRemote(t))
write(t, b.Folder, "x.md", "x")
b.Store.LogRead("x.md", "")
cycle(t, b)
if evs, err := b.Store.PendingReads(); err != nil || len(evs) != 1 {
t.Fatalf("spool on a hubless device = %v, %v; want the read still queued", evs, err)
}
}
// TestSessionCarriesThroughTwoDevices is the multi-device shape of the join:
// a device syncing under an agent session stamps that session onto every op
// it commits AND onto every read it reports, its peer converges on ops that
// carry the id, and a device with no session leaves both empty — so a run
// card can never claim another device's work.
func TestSessionCarriesThroughTwoDevices(t *testing.T) {
shared := sharedRemote(t)
hubA := &readReportingRemote{Backend: shared}
hubB := &readReportingRemote{Backend: shared}
a := newDevice(t, "deva", hubA)
b := newDevice(t, "devb", hubB)
// Device A works inside an agent session: it reads two files and writes one.
a.SessionID = "8f21e4"
write(t, a.Folder, "wiki/a.md", "written by the run")
a.Store.LogRead("wiki/a.md", "8f21e4")
a.Store.LogRead("wiki/reference.md", "8f21e4")
cycle(t, a)
if reports := hubA.all(); len(reports) != 1 || len(reports[0]) != 2 {
t.Fatalf("reports = %+v, want one batch of 2", reports)
} else {
for _, e := range reports[0] {
if e.Session != "8f21e4" {
t.Fatalf("reported read %+v lost its session", e)
}
}
}
// Device B, no session at all: its own op carries none, and the read it
// reports carries none — nothing of B's can land on A's card.
write(t, b.Folder, "wiki/b.md", "written by a human")
b.Store.LogRead("wiki/b.md", "")
cycle(t, b)
if reports := hubB.all(); len(reports) != 1 || reports[0][0].Session != "" {
t.Fatalf("sessionless device reported %+v, want an empty session", reports)
}
// Both peers converge, and each op keeps the session of the device that
// wrote it — replay does not touch the field.
cycle(t, a)
cycle(t, b)
for _, d := range []*Session{a, b} {
ops, err := d.Store.AllOps()
if err != nil {
t.Fatal(err)
}
seen := map[string]string{}
for _, op := range ops {
seen[op.Path] = op.Session
}
if seen["wiki/a.md"] != "8f21e4" {
t.Errorf("%s sees wiki/a.md session %q, want 8f21e4", d.Device.ID, seen["wiki/a.md"])
}
if seen["wiki/b.md"] != "" {
t.Errorf("%s sees wiki/b.md session %q, want empty", d.Device.ID, seen["wiki/b.md"])
}
}
// Convergence itself: both folders hold both files.
if got, want := snapshotDir(t, a.Folder), snapshotDir(t, b.Folder); len(got) != len(want) {
t.Fatalf("folders diverged: %v vs %v", got, want)
}
}