mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
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>
118 lines
4.1 KiB
Go
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
|
|
}
|