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

118 lines
4.1 KiB
Go

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.
//
// syncer.Result.Inbound carries the same events and is NOT a duplicate to be
// deleted. The post_sync hook fires from the cycle that materialized, so it
// wants the batch in-process; DrainInbound is destructive and single-consumer,
// so a second drainer would silently empty the agent hook's context about half
// the time. Different lifetimes, different consumers — keep both.
// 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
}