mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
feat(sync): agents hear what teammates changed before they overwrite it (BEA-127) (#144)
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2875e033be
commit
5623113ff7
@@ -84,7 +84,13 @@ a single JSON object, so the formula carries **every** mount as a `prefix →
|
||||
URL` pair — the prefix being the mount's path as the agent sees it from the
|
||||
session's folder, or an empty prefix with the session's own subpath baked into
|
||||
the URL when the session runs inside the mount; emitting only the first mount
|
||||
hung one project's paths on another project's base URL. Then an
|
||||
hung one project's paths on another project's base URL. The same context also
|
||||
names what teammates changed since the last turn ("re-read before editing"),
|
||||
drained from the **inbound spool** (`internal/store/inbound.go`, a near-copy of
|
||||
the read spool): `materialize` appends every path it writes or removes, and the
|
||||
hook drains it *after* its own cycle — a `Result` field would report nothing,
|
||||
because the daemon has usually materialized the peer's change seconds earlier.
|
||||
Advisory only: nothing blocks a write. Then an
|
||||
async push on PostToolUse Write/Edit, and `bdrive read-log` on
|
||||
Read/Grep/Bash for the read heatmap. The inline hook commands
|
||||
`internal/agenthooks` writes must stay a fast no-op outside BearDrive folders
|
||||
|
||||
@@ -116,9 +116,11 @@ classDiagram
|
||||
+SaveNote / LoadNote
|
||||
+LogRead(rel, session) read spool
|
||||
+PendingReads dedup on path+session
|
||||
+LogInbound / DrainInbound
|
||||
+Lock() flock
|
||||
}
|
||||
note for Store "internal/store — ~/.bdrive/volumes/mount-id: content-addressed blobs, per-device journal copies, state cache, paused marker (free funcs Paused/SetPaused, no flock)"
|
||||
note for Store "inbound.jsonl is the read spool's twin, running the other way: materialize appends every path it wrote or removed for a peer, and `sync --hook` drains it into the turn's context (re-read before editing). A spool and not a Result field because the daemon usually materializes the change seconds before the turn starts, so the hook's own cycle sees nothing. Capped, best-effort, never fails a cycle"
|
||||
|
||||
class Op {
|
||||
+Seq +Lamport +Time +Device
|
||||
|
||||
+4
-2
@@ -105,8 +105,10 @@ list in .bdrive/config.json is never pruned against either.`,
|
||||
if err != nil || !ok || syncBlocked(proj) != "" {
|
||||
continue
|
||||
}
|
||||
if base, ok := runHookSync(cmd, target, sessionID, hookLabel); ok {
|
||||
links = append(links, hookLinkFor(folder, target, base))
|
||||
if h, ok := runHookSync(cmd, target, sessionID, hookLabel); ok {
|
||||
link := hookLinkFor(folder, target, h.base)
|
||||
link.paths = h.paths
|
||||
links = append(links, link)
|
||||
}
|
||||
}
|
||||
emitHookContext(cmd, links)
|
||||
|
||||
+92
-8
@@ -9,6 +9,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/runbear-io/beardrive/internal/store"
|
||||
)
|
||||
|
||||
// `bdrive sync --hook <label>` is the agent-hook flavor of sync, run by the
|
||||
@@ -33,12 +35,19 @@ import (
|
||||
const hookNoteTTL = 30 * time.Minute
|
||||
|
||||
// hookLink pairs the path prefix an agent writes with the hub URL that
|
||||
// prefix maps to.
|
||||
// prefix maps to, and carries what this mount pulled in since the last turn.
|
||||
type hookLink struct {
|
||||
prefix string // "wiki/", or "" when the hook ran at or inside the mount
|
||||
base string // https://hub/<project-id>[/<the run folder's subpath>]
|
||||
sub string // the run folder's mount-relative path, "" at or above the mount
|
||||
paths []store.InboundEvent
|
||||
}
|
||||
|
||||
// hookChangedMax caps the changed-file list the turn pays for. Past it the
|
||||
// tail is a count — the first cycle on a fresh mount materializes the whole
|
||||
// project, and no turn should carry that.
|
||||
const hookChangedMax = 20
|
||||
|
||||
// hookSessionID reads the platform's event JSON from stdin — once per run,
|
||||
// since stdin can only be consumed once and the sync loop may cover several
|
||||
// mounts.
|
||||
@@ -59,11 +68,19 @@ func eventSessionID(data []byte) string {
|
||||
return event.SessionID
|
||||
}
|
||||
|
||||
// runHookSync syncs one mount and reports its hub base URL, if it has one.
|
||||
func runHookSync(cmd *cobra.Command, target, sessionID, label string) (string, bool) {
|
||||
// hookSync is one mount's contribution to the turn: where its files live on
|
||||
// the hub, and which of them moved since the last turn.
|
||||
type hookSync struct {
|
||||
base string
|
||||
paths []store.InboundEvent
|
||||
}
|
||||
|
||||
// runHookSync syncs one mount and reports its hub base URL, if it has one,
|
||||
// plus the peer changes waiting for this turn.
|
||||
func runHookSync(cmd *cobra.Command, target, sessionID, label string) (hookSync, bool) {
|
||||
sess, proj, err := openSession(cmd.Context(), target, true)
|
||||
if err != nil {
|
||||
return "", false // not a mount / no session: fast no-op
|
||||
return hookSync{}, false // not a mount / no session: fast no-op
|
||||
}
|
||||
defer closeSession(sess)
|
||||
|
||||
@@ -83,13 +100,19 @@ func runHookSync(cmd *cobra.Command, target, sessionID, label string) (string, b
|
||||
// The pull. Offline is fine — the link formula below is still valid
|
||||
// for teammates who are online.
|
||||
if _, err := sess.Cycle(cmd.Context()); err != nil {
|
||||
return "", false // never break the turn
|
||||
return hookSync{}, false // never break the turn
|
||||
}
|
||||
// Drained after the cycle, not from its Result: in the ordinary case the
|
||||
// daemon materialized the peer's change seconds ago, so this cycle saw
|
||||
// nothing and the spool is where the record is. Errors are ignored — the
|
||||
// links matter more than the list.
|
||||
paths, _ := sess.Store.DrainInbound()
|
||||
|
||||
server, projectID, err := splitHubRemote(proj.Remote)
|
||||
if err != nil {
|
||||
return "", false // non-hub remote: nothing to link to
|
||||
return hookSync{}, false // non-hub remote: nothing to link to
|
||||
}
|
||||
return server + "/" + projectID, true
|
||||
return hookSync{base: server + "/" + projectID, paths: paths}, true
|
||||
}
|
||||
|
||||
// hookLinkFor places one mount relative to the folder the hook ran in.
|
||||
@@ -114,7 +137,10 @@ func hookLinkFor(folder, target, base string) hookLink {
|
||||
if err != nil {
|
||||
return hookLink{base: base}
|
||||
}
|
||||
return hookLink{base: base + "/" + encodePathSegments(filepath.ToSlash(sub))}
|
||||
return hookLink{
|
||||
base: base + "/" + encodePathSegments(filepath.ToSlash(sub)),
|
||||
sub: filepath.ToSlash(sub),
|
||||
}
|
||||
default:
|
||||
return hookLink{prefix: rel + "/", base: base}
|
||||
}
|
||||
@@ -158,6 +184,10 @@ func emitHookContext(cmd *cobra.Command, links []hookLink) {
|
||||
tail, strings.Join(parts, ", "))
|
||||
}
|
||||
|
||||
if changed := hookChanged(links); changed != "" {
|
||||
context += " " + changed
|
||||
}
|
||||
|
||||
out := map[string]any{
|
||||
"hookSpecificOutput": map[string]any{
|
||||
"hookEventName": "UserPromptSubmit",
|
||||
@@ -170,3 +200,57 @@ func emitHookContext(cmd *cobra.Command, links []hookLink) {
|
||||
}
|
||||
fmt.Fprintln(cmd.OutOrStdout(), string(enc))
|
||||
}
|
||||
|
||||
// hookChanged renders what teammates' devices pulled in since the last turn:
|
||||
// the whole point of the spool, and the only defense an agent has against
|
||||
// rewriting a file that moved underneath it. Advisory — nothing blocks.
|
||||
//
|
||||
// Each path is translated into what the agent sees from the folder the hook
|
||||
// ran in, using the same placement hookLinkFor computed for the links: a
|
||||
// mount below that folder prepends its prefix, a run inside a mount strips
|
||||
// its own subpath (and paths outside it are not the agent's to re-read).
|
||||
func hookChanged(links []hookLink) string {
|
||||
var paths []string
|
||||
over := 0
|
||||
for _, l := range links {
|
||||
for _, e := range l.paths {
|
||||
p, ok := hookAgentPath(l, e.Path)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if len(paths) >= hookChangedMax {
|
||||
over++
|
||||
continue
|
||||
}
|
||||
if e.Deleted {
|
||||
p += " (deleted)"
|
||||
}
|
||||
paths = append(paths, "`"+p+"`")
|
||||
}
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
return ""
|
||||
}
|
||||
s := "Changed since your last turn by a teammate or another device — re-read before editing: " + strings.Join(paths, ", ")
|
||||
if over > 0 {
|
||||
s += fmt.Sprintf(", +%d more", over)
|
||||
}
|
||||
return s + "."
|
||||
}
|
||||
|
||||
// hookAgentPath maps one mount-relative spool path to the path an agent
|
||||
// writes, reporting false for paths the agent cannot reach from here.
|
||||
func hookAgentPath(l hookLink, path string) (string, bool) {
|
||||
switch {
|
||||
case l.prefix != "":
|
||||
return l.prefix + path, true
|
||||
case l.sub != "":
|
||||
// The session runs inside the mount: its own subpath is implicit in
|
||||
// every path it writes, so strip it — and a sibling folder's file is
|
||||
// outside this session's view entirely.
|
||||
rest, ok := strings.CutPrefix(path, l.sub+"/")
|
||||
return rest, ok
|
||||
default:
|
||||
return path, true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -305,3 +307,139 @@ func TestSyncRefusesUnenrolledAndPaused(t *testing.T) {
|
||||
t.Fatalf("paused sync error = %v, want a paused message", err)
|
||||
}
|
||||
}
|
||||
|
||||
// seedInbound pretends an earlier cycle — the daemon's, in the ordinary case
|
||||
// — materialized these paths on this mount.
|
||||
func seedInbound(t *testing.T, proj config.Project, paths ...string) {
|
||||
t.Helper()
|
||||
vdir, err := config.VolumeDir(proj.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, err := store.Open(vdir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, p := range paths {
|
||||
deleted := strings.HasPrefix(p, "-")
|
||||
if err := st.LogInbound(strings.TrimPrefix(p, "-"), deleted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The whole point of the spool: a path materialized by an EARLIER cycle (the
|
||||
// daemon's) is still reported by the hook, whose own cycle sees nothing. A
|
||||
// Result field would report nothing here.
|
||||
func TestSyncHookModeReportsInboundChanges(t *testing.T) {
|
||||
t.Setenv("BDRIVE_HOME", t.TempDir())
|
||||
root := t.TempDir()
|
||||
root, _ = filepath.EvalSymlinks(root)
|
||||
proj := mountAt(t, root, "wiki", "https://hub.example.com/p/p-12345678")
|
||||
|
||||
seedInbound(t, proj, "notes/readme.md", "-old.md")
|
||||
|
||||
got := runHook(t, filepath.Join(root, "wiki"))
|
||||
for _, want := range []string{
|
||||
"re-read before editing",
|
||||
"`notes/readme.md`",
|
||||
"`old.md (deleted)`",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("hook output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// The drain cleared: a second run with no peer activity says nothing.
|
||||
if again := runHook(t, filepath.Join(root, "wiki")); strings.Contains(again, "re-read before editing") {
|
||||
t.Errorf("second run repeated the changed list:\n%s", again)
|
||||
}
|
||||
}
|
||||
|
||||
// Each path carries its own mount's prefix — never another mount's.
|
||||
func TestSyncHookModeInboundMultipleMounts(t *testing.T) {
|
||||
t.Setenv("BDRIVE_HOME", t.TempDir())
|
||||
root := t.TempDir()
|
||||
root, _ = filepath.EvalSymlinks(root)
|
||||
a := mountAt(t, root, "projA", "https://hub.example.com/p/p-aaaaaaaa")
|
||||
b := mountAt(t, root, "projB", "https://hub.example.com/p/p-bbbbbbbb")
|
||||
seedInbound(t, a, "notes/a.md")
|
||||
seedInbound(t, b, "notes/b.md")
|
||||
|
||||
got := runHook(t, root)
|
||||
for _, want := range []string{"`projA/notes/a.md`", "`projB/notes/b.md`"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("hook output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A session started inside a mount sees paths relative to its own directory:
|
||||
// its subpath is stripped, and a sibling folder's file is not its to re-read.
|
||||
func TestSyncHookModeInboundInsideMount(t *testing.T) {
|
||||
t.Setenv("BDRIVE_HOME", t.TempDir())
|
||||
root := t.TempDir()
|
||||
root, _ = filepath.EvalSymlinks(root)
|
||||
proj := mountAt(t, root, "wiki", "https://hub.example.com/p/p-12345678")
|
||||
sub := filepath.Join(root, "wiki", "docs", "notes")
|
||||
if err := os.MkdirAll(sub, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seedInbound(t, proj, "docs/notes/mine.md", "elsewhere/theirs.md")
|
||||
|
||||
got := runHook(t, sub)
|
||||
if !strings.Contains(got, "`mine.md`") {
|
||||
t.Errorf("path under the session folder not stripped to what the agent sees:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "theirs.md") {
|
||||
t.Errorf("path outside the session folder must not be listed:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The first cycle on a fresh mount materializes everything; the turn must not
|
||||
// carry the whole project.
|
||||
func TestSyncHookModeInboundCap(t *testing.T) {
|
||||
t.Setenv("BDRIVE_HOME", t.TempDir())
|
||||
root := t.TempDir()
|
||||
root, _ = filepath.EvalSymlinks(root)
|
||||
proj := mountAt(t, root, "wiki", "https://hub.example.com/p/p-12345678")
|
||||
var paths []string
|
||||
for i := 0; i < hookChangedMax+5; i++ {
|
||||
paths = append(paths, fmt.Sprintf("f%02d.md", i))
|
||||
}
|
||||
seedInbound(t, proj, paths...)
|
||||
|
||||
got := runHook(t, filepath.Join(root, "wiki"))
|
||||
if !strings.Contains(got, "+5 more") {
|
||||
t.Errorf("capped list missing its tail:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "f24.md") {
|
||||
t.Errorf("list rendered past the cap:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// An unreadable spool leaves the turn intact: exit 0, valid JSON, links still
|
||||
// emitted.
|
||||
func TestSyncHookModeInboundSpoolUnreadable(t *testing.T) {
|
||||
t.Setenv("BDRIVE_HOME", t.TempDir())
|
||||
root := t.TempDir()
|
||||
root, _ = filepath.EvalSymlinks(root)
|
||||
proj := mountAt(t, root, "wiki", "https://hub.example.com/p/p-12345678")
|
||||
vdir, err := config.VolumeDir(proj.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A directory where the spool should be: every read of it fails.
|
||||
if err := os.MkdirAll(filepath.Join(vdir, "inbound.jsonl"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := runHook(t, filepath.Join(root, "wiki"))
|
||||
if !strings.Contains(got, `"hookSpecificOutput"`) || !strings.Contains(got, "https://hub.example.com/p-12345678") {
|
||||
t.Errorf("unreadable spool broke the turn's context:\n%s", got)
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal([]byte(got), &out); err != nil {
|
||||
t.Fatalf("hook emitted invalid JSON: %v\n%s", err, got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The inbound spool queues the paths a cycle materialized from peers, until
|
||||
// the agent hook (`bdrive sync --hook`) drains it into the turn's context —
|
||||
// "these changed since your last turn, re-read before editing". It is a spool
|
||||
// rather than a field on Result because the daemon usually materializes the
|
||||
// peer's change seconds before the turn starts, so the hook's own cycle
|
||||
// reports nothing: the record has to outlive the cycle that made it.
|
||||
|
||||
// InboundEvent is one path a cycle wrote or removed on a peer's behalf
|
||||
// (mount-relative).
|
||||
type InboundEvent struct {
|
||||
Path string `json:"path"`
|
||||
Deleted bool `json:"deleted,omitempty"`
|
||||
Time time.Time `json:"time"`
|
||||
}
|
||||
|
||||
// inboundSpoolMax caps the spool: a machine with no agent hooks never drains,
|
||||
// so past the cap new events are dropped rather than growing without bound.
|
||||
const inboundSpoolMax = 1 << 20
|
||||
|
||||
// inboundDrainMax bounds one drained batch; the hook renders far fewer.
|
||||
const inboundDrainMax = 4096
|
||||
|
||||
func (s *Store) inboundSpoolPath() string { return filepath.Join(s.dir, "inbound.jsonl") }
|
||||
func (s *Store) inboundDrainPath() string { return filepath.Join(s.dir, "inbound-draining.jsonl") }
|
||||
|
||||
// LogInbound appends one materialized path to the spool. Single-line O_APPEND
|
||||
// writes keep the daemon and a concurrent CLI cycle from interleaving.
|
||||
func (s *Store) LogInbound(rel string, deleted bool) error {
|
||||
if fi, err := os.Stat(s.inboundSpoolPath()); err == nil && fi.Size() > inboundSpoolMax {
|
||||
return nil // spool full: drop, never grow unbounded
|
||||
}
|
||||
line, err := json.Marshal(InboundEvent{Path: rel, Deleted: deleted, Time: time.Now().UTC()})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 0600: the spool is a list of this project's file paths.
|
||||
f, err := os.OpenFile(s.inboundSpoolPath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = f.Write(append(line, '\n'))
|
||||
return err
|
||||
}
|
||||
|
||||
// DrainInbound returns the queued batch and clears it, deduplicated by path
|
||||
// (latest event wins, so a path written and then deleted reports as deleted).
|
||||
// The spool is rotated aside first, so events logged during the drain land in
|
||||
// a fresh spool — the drain runs outside the volume flock, and a daemon on
|
||||
// the same mount may be appending.
|
||||
//
|
||||
// One call, unlike PendingReads/ClearPendingReads: those are two steps
|
||||
// because a read report can fail over the network and must be retried, and
|
||||
// rendering a string onto stdout cannot.
|
||||
func (s *Store) DrainInbound() ([]InboundEvent, error) {
|
||||
if _, err := os.Stat(s.inboundDrainPath()); os.IsNotExist(err) {
|
||||
if err := os.Rename(s.inboundSpoolPath(), s.inboundDrainPath()); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil // nothing queued
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
data, err := os.ReadFile(s.inboundDrainPath())
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
// Unreadable: drop it rather than wedging every future drain behind
|
||||
// it. Losing a turn's list is the cheaper failure.
|
||||
os.Remove(s.inboundDrainPath())
|
||||
return nil, err
|
||||
}
|
||||
defer os.Remove(s.inboundDrainPath())
|
||||
|
||||
latest := map[string]InboundEvent{}
|
||||
var order []string
|
||||
for _, line := range bytes.Split(data, []byte("\n")) {
|
||||
if len(bytes.TrimSpace(line)) == 0 {
|
||||
continue
|
||||
}
|
||||
var e InboundEvent
|
||||
if json.Unmarshal(line, &e) != nil || e.Path == "" {
|
||||
continue // torn or corrupt line; drop it
|
||||
}
|
||||
if _, ok := latest[e.Path]; !ok {
|
||||
order = append(order, e.Path)
|
||||
}
|
||||
if prev, ok := latest[e.Path]; !ok || !e.Time.Before(prev.Time) {
|
||||
latest[e.Path] = e
|
||||
}
|
||||
}
|
||||
if len(order) > inboundDrainMax {
|
||||
order = order[len(order)-inboundDrainMax:]
|
||||
}
|
||||
out := make([]InboundEvent, 0, len(order))
|
||||
for _, p := range order {
|
||||
out = append(out, latest[p])
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1129,6 +1129,11 @@ func (s *Session) materialize(target map[string]journal.FileState, cache map[str
|
||||
continue
|
||||
}
|
||||
pruneEmptyDirs(s.Folder, filepath.Dir(abs))
|
||||
// Tell the next agent turn what vanished under it. Best-effort:
|
||||
// a spool failure must never fail a cycle. Only the branch that
|
||||
// actually unlinked a file logs — the paths below it were already
|
||||
// gone locally, so nothing changed under the agent.
|
||||
_ = s.Store.LogInbound(rel, true)
|
||||
}
|
||||
delete(cache, rel)
|
||||
changed++
|
||||
@@ -1171,6 +1176,15 @@ func (s *Session) materializeFile(rel string, want journal.FileState, cache map[
|
||||
return false, err
|
||||
}
|
||||
cache[rel] = store.CachedFile{Blob: want.Blob, Size: fi.Size(), Mode: want.Mode, MTimeNS: fi.ModTime().UnixNano()}
|
||||
// Spool it for the next agent turn ("changed since your last turn"). Only
|
||||
// peer content reaches here — a local edit is journaled by the scan and
|
||||
// lands in the cache, so the compare above short-circuits before any
|
||||
// write. Best-effort: a spool failure must never fail a cycle.
|
||||
//
|
||||
// Known trade: the first cycle on a fresh mount materializes the whole
|
||||
// project, so that one turn's list is "everything" — bounded by the
|
||||
// hook's render cap, and cheaper than special-casing an empty cache.
|
||||
_ = s.Store.LogInbound(rel, false)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -830,3 +830,84 @@ func TestRenameConvergesAsPutPlusDelete(t *testing.T) {
|
||||
t.Fatalf("the halves landed %v apart — wider than the hub's pairing window", d)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundSpool is the test that matters for the turn-start warning: the
|
||||
// paths a cycle materializes from a peer are spooled for the next agent turn,
|
||||
// this device's own edits never are, and the drain clears.
|
||||
func TestInboundSpool(t *testing.T) {
|
||||
be := sharedRemote(t)
|
||||
a := newDevice(t, "deva", be)
|
||||
b := newDevice(t, "devb", be)
|
||||
|
||||
// B writes, A pulls: A's spool names the path.
|
||||
write(t, b.Folder, "notes/readme.md", "from b")
|
||||
cycle(t, b)
|
||||
cycle(t, a)
|
||||
evs, err := a.Store.DrainInbound()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(evs) != 1 || evs[0].Path != "notes/readme.md" || evs[0].Deleted {
|
||||
t.Fatalf("a inbound = %+v, want notes/readme.md written", evs)
|
||||
}
|
||||
|
||||
// A's own edit is not inbound — it is scanned, not materialized.
|
||||
write(t, a.Folder, "local.md", "mine")
|
||||
cycle(t, a)
|
||||
if evs, _ := a.Store.DrainInbound(); len(evs) != 0 {
|
||||
t.Fatalf("a inbound = %+v, want own edits absent", evs)
|
||||
}
|
||||
|
||||
// A peer delete is reported as removed, not as changed.
|
||||
cycle(t, b)
|
||||
os.Remove(filepath.Join(b.Folder, "notes", "readme.md"))
|
||||
cycle(t, b)
|
||||
cycle(t, a)
|
||||
evs, err = a.Store.DrainInbound()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(evs) != 1 || evs[0].Path != "notes/readme.md" || !evs[0].Deleted {
|
||||
t.Fatalf("a inbound = %+v, want notes/readme.md deleted", evs)
|
||||
}
|
||||
|
||||
// The drain cleared: a quiet cycle reports nothing.
|
||||
cycle(t, a)
|
||||
if evs, _ := a.Store.DrainInbound(); len(evs) != 0 {
|
||||
t.Fatalf("a inbound = %+v, want empty after a quiet cycle", evs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundSpoolOutlivesItsCycle is the reason this is a spool and not a
|
||||
// Result field: in the ordinary case the daemon materializes a peer's change
|
||||
// seconds before the turn starts, so the cycle the agent hook runs sees
|
||||
// nothing. A second Session on the same volume — which is what the daemon and
|
||||
// the hook are — still finds the path waiting.
|
||||
func TestInboundSpoolOutlivesItsCycle(t *testing.T) {
|
||||
be := sharedRemote(t)
|
||||
a := newDevice(t, "deva", be)
|
||||
b := newDevice(t, "devb", be)
|
||||
|
||||
write(t, b.Folder, "notes/readme.md", "from b")
|
||||
cycle(t, b)
|
||||
|
||||
// The "daemon" cycle materializes it.
|
||||
res := cycle(t, a)
|
||||
if res.Materialized != 1 {
|
||||
t.Fatalf("Materialized = %d, want 1", res.Materialized)
|
||||
}
|
||||
|
||||
// A later, quiet cycle — the hook's — reports nothing itself...
|
||||
later := &Session{Folder: a.Folder, Store: a.Store, Device: a.Device, Backend: be}
|
||||
if res := cycle(t, later); res.Materialized != 0 {
|
||||
t.Fatalf("second cycle Materialized = %d, want 0", res.Materialized)
|
||||
}
|
||||
// ...but the spool still names what arrived.
|
||||
evs, err := later.Store.DrainInbound()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(evs) != 1 || evs[0].Path != "notes/readme.md" {
|
||||
t.Fatalf("inbound = %+v, want notes/readme.md from the earlier cycle", evs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,22 @@ bdrive log wiki # which areas are alive
|
||||
— and, if this device created the project, offer to draft `AGENTS.md` for the
|
||||
team.
|
||||
|
||||
## Your agent is told what moved
|
||||
|
||||
Six people's agents in one folder means an agent can start a turn holding a
|
||||
copy of a file a teammate's agent rewrote an hour ago. So it is told: at turn
|
||||
start, the sync hook names the files that arrived from teammates since the
|
||||
last turn — "changed since your last turn, re-read before editing" — with
|
||||
deletions marked.
|
||||
|
||||
It is advisory. Nothing blocks, nothing prompts, no write is refused; the
|
||||
agent gets the fact and decides. That needs the turn-start hooks `bdrive init`
|
||||
registers ([Hooks in detail](/manual/hooks/)) — without them nothing drains
|
||||
the list, and the agent hears nothing.
|
||||
|
||||
The list is capped, so the first turn after joining a project names some of
|
||||
what arrived rather than the whole project.
|
||||
|
||||
## What belongs in shared memory
|
||||
|
||||
Good candidates are the things that are expensive to rediscover and cheap to
|
||||
|
||||
@@ -21,7 +21,7 @@ One binary, `bdrive` — the CLI, the sync daemon, and the web server.
|
||||
| `bdrive forget <path>...` | Stop syncing a path and remove it from the hub. Adds the rule to `.bdriveignore` (which syncs) and prunes in one step. Local files are never touched, here or on teammates' devices |
|
||||
| `bdrive url [path]` | Internal hub link for a file or folder — sign-in and membership required. `--sync` pushes first; no argument gives the project home. Computed locally |
|
||||
| `bdrive share <file>` | Public URL for a synced file. `--list`, `--revoke`, `--expires` (the hub's Share dialog can also set an expiry on an existing link). Refuses a file whose first 1 MiB holds credential-shaped strings — `--force` shares it anyway |
|
||||
| `bdrive sync [folder]` | Run one sync cycle now. Refuses folders this device never `init`ed and folders paused by `bdrive stop`. `--note <text>` stamps session context onto changes; `--note-ttl` (default 30m) bounds it. `--prune` also removes from the hub what `.bdriveignore` now excludes (files stay on disk everywhere). `--hook <label>` is agent-hook plumbing |
|
||||
| `bdrive sync [folder]` | Run one sync cycle now. Refuses folders this device never `init`ed and folders paused by `bdrive stop`. `--note <text>` stamps session context onto changes; `--note-ttl` (default 30m) bounds it. `--prune` also removes from the hub what `.bdriveignore` now excludes (files stay on disk everywhere). `--hook <label>` is agent-hook plumbing: it also reports the files teammates changed since the agent's last turn |
|
||||
| `bdrive hooks [install\|uninstall]` | Register turn-boundary sync hooks in each detected agent platform's user config — once per machine, covering every folder. Run automatically by `bdrive init`; idempotent; `--agent` overrides detection. `uninstall` removes only BearDrive's own hook entries |
|
||||
| `bdrive read-log [folder]` | Hook plumbing: queue agent file reads for the hub's read heatmap. Registered by `bdrive hooks install` |
|
||||
| `bdrive status [folder]` | Projects, daemon state, pending changes |
|
||||
|
||||
Reference in New Issue
Block a user