mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
A teammate's agent rewrites a file and yours never hears about it — the only trace is a `.bdrive-conflict-*` nobody opens. Now the turn-start hook names the paths that arrived since the last turn: "re-read before editing". The record lives in a spool (`internal/store/inbound.go`), not in Cycle's Result, because the daemon usually materializes a peer's change seconds before the turn starts — so the hook's own cycle sees nothing. materialize appends every path it writes or removes, `bdrive sync --hook` drains it after its cycle and renders it under each mount's own prefix (stripping the session subpath when the run is inside a mount, dropping paths outside it). Advisory only: nothing blocks, nothing prompts, no per-Write remote call. The spool is capped, 0600, in the volume dir, and best-effort everywhere — a spool failure never fails a cycle or a turn. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
119 lines
3.1 KiB
Go
119 lines
3.1 KiB
Go
package store
|
|
|
|
import (
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestInboundSpool(t *testing.T) {
|
|
s := openTestStore(t)
|
|
|
|
// Nothing queued: no batch, no error.
|
|
if evs, err := s.DrainInbound(); err != nil || len(evs) != 0 {
|
|
t.Fatalf("empty spool = %v, %v", evs, err)
|
|
}
|
|
|
|
if err := s.LogInbound("wiki/a.md", false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.LogInbound("b.md", false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// A path written and then removed reports as deleted: latest wins.
|
|
if err := s.LogInbound("b.md", true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
evs, err := s.DrainInbound()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(evs) != 2 || evs[0].Path != "wiki/a.md" || evs[1].Path != "b.md" {
|
|
t.Fatalf("batch = %+v, want wiki/a.md + b.md", evs)
|
|
}
|
|
if evs[0].Deleted || !evs[1].Deleted {
|
|
t.Fatalf("batch = %+v, want b.md marked deleted", evs)
|
|
}
|
|
if evs[0].Time.IsZero() {
|
|
t.Fatal("events must carry their time")
|
|
}
|
|
|
|
// The drain clears: a second run with no activity in between reports
|
|
// nothing.
|
|
if again, err := s.DrainInbound(); err != nil || len(again) != 0 {
|
|
t.Fatalf("second drain = %+v, %v, want empty", again, err)
|
|
}
|
|
}
|
|
|
|
func TestInboundSpoolSurvivesCorruptLines(t *testing.T) {
|
|
s := openTestStore(t)
|
|
s.LogInbound("good.md", false)
|
|
f, err := os.OpenFile(s.inboundSpoolPath(), os.O_WRONLY|os.O_APPEND, 0o644)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f.WriteString(`{"path": "torn`) // a torn write
|
|
f.Close()
|
|
s.LogInbound("also-good.md", false)
|
|
evs, err := s.DrainInbound()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The torn line joins the next event's line; both are dropped, but the
|
|
// batch itself survives.
|
|
if len(evs) == 0 || evs[0].Path != "good.md" {
|
|
t.Fatalf("batch = %+v, want good.md to survive the torn line", evs)
|
|
}
|
|
}
|
|
|
|
func TestInboundSpoolCap(t *testing.T) {
|
|
s := openTestStore(t)
|
|
long := strings.Repeat("d", 1024)
|
|
for i := 0; i < 1100; i++ { // ~1.1 MB of events
|
|
if err := s.LogInbound(long+"/"+string(rune('a'+i%26))+".md", false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
fi, err := os.Stat(s.inboundSpoolPath())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if fi.Size() > inboundSpoolMax+4096 {
|
|
t.Fatalf("spool grew past its cap: %d bytes", fi.Size())
|
|
}
|
|
}
|
|
|
|
// The spool is a plain file in the volume dir at 0600 — never in the working
|
|
// folder, never synced.
|
|
func TestInboundSpoolPermissions(t *testing.T) {
|
|
s := openTestStore(t)
|
|
if err := s.LogInbound("a.md", false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fi, err := os.Stat(s.inboundSpoolPath())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if fi.Mode().Perm() != 0o600 {
|
|
t.Fatalf("spool mode = %v, want 0600", fi.Mode().Perm())
|
|
}
|
|
}
|
|
|
|
// An unreadable spool must not wedge every later drain behind it.
|
|
func TestInboundSpoolUnreadableRecovers(t *testing.T) {
|
|
s := openTestStore(t)
|
|
if err := os.MkdirAll(s.inboundSpoolPath(), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := s.DrainInbound(); err == nil {
|
|
t.Fatal("unreadable spool should report its error")
|
|
}
|
|
if err := s.LogInbound("a.md", false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
evs, err := s.DrainInbound()
|
|
if err != nil || len(evs) != 1 || evs[0].Path != "a.md" {
|
|
t.Fatalf("drain after failure = %+v, %v, want a.md", evs, err)
|
|
}
|
|
}
|