Files
edfe46c0aa post_sync: run a local command when teammates' changes land (#163)
Inbound sync was invisible to the machine it landed on — a local index,
cache or notifier had to poll. A `post_sync` command in the folder's own
.bdrive/config.json now runs once per cycle that applied peer changes,
with the batch as JSON on stdin.

The batch rides out on a new Result.Inbound rather than the inbound
spool: DrainInbound is destructive and `bdrive sync --hook` is its only
consumer, so a second drainer would silently empty the agent's
"teammates changed X" context. Both are kept, and both comments now say
why.

Cycle becomes a thin wrapper over cycleLocked so the hook is spawned
after the volume flock drops — a property of the code shape, not a rule
each of the seven call sites has to remember.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 11:04:23 -07:00

93 lines
2.7 KiB
Go

package syncer
import (
"encoding/json"
"io"
"log"
"os"
"os/exec"
"github.com/runbear-io/beardrive/internal/config"
)
// postSyncPayload is what the hook command reads on stdin.
type postSyncPayload struct {
Project string `json:"project"`
Folder string `json:"folder"`
Changed []postSyncChanged `json:"changed"`
}
type postSyncChanged struct {
Path string `json:"path"`
Op string `json:"op"` // "write" | "delete"
}
// firePostSync runs the folder's post_sync command once, for a cycle that
// materialized at least one path on a peer's behalf. It is called from Cycle
// AFTER the volume flock is released, so a hook that runs a bdrive command
// completes instead of deadlocking.
//
// Nothing it does can fail the cycle: a missing command, a non-zero exit, or a
// hook that never returns is logged (daemon.log for the daemon, stderr for a
// one-shot CLI cycle) and forgotten.
func (s *Session) firePostSync(res *Result) {
if len(res.Inbound) == 0 {
return // inbound only: a scan-and-push cycle fires nothing
}
proj, ok, err := config.LoadProject(s.Folder)
if err != nil || !ok || proj.PostSync == "" {
return // off unless configured
}
payload := postSyncPayload{Project: s.mountID(), Folder: s.Folder}
for _, e := range res.Inbound {
op := "write"
if e.Deleted {
op = "delete"
}
payload.Changed = append(payload.Changed, postSyncChanged{Path: e.Path, Op: op})
}
body, err := json.Marshal(payload)
if err != nil {
log.Printf("post_sync: %v", err)
return
}
// stdin is an unlinked temp FILE, not a bytes.Reader: os/exec feeds a
// non-*os.File stdin through a pipe served by a goroutine in the PARENT,
// and a one-shot `bdrive sync` exits before the child has read it —
// delivering truncated JSON. A file descriptor is handed straight to the
// child, so the parent may exit immediately.
f, err := os.CreateTemp("", "bdrive-postsync-")
if err != nil {
log.Printf("post_sync: %v", err)
return
}
os.Remove(f.Name()) // the fd is the only handle left
defer f.Close()
if _, err := f.Write(body); err != nil {
log.Printf("post_sync: %v", err)
return
}
if _, err := f.Seek(0, io.SeekStart); err != nil {
log.Printf("post_sync: %v", err)
return
}
// ponytail: no single-flight guard and no timeout — a hook that hangs
// while inbound keeps arriving accumulates children. The cycle is
// unaffected either way; add coalescing here if a real hook turns out to
// be slow.
cmd := exec.Command("sh", "-c", proj.PostSync)
cmd.Dir, cmd.Stdin = s.Folder, f
if err := cmd.Start(); err != nil {
log.Printf("post_sync: %v", err)
return
}
go func() {
if err := cmd.Wait(); err != nil {
log.Printf("post_sync exited: %v", err)
}
}()
}