mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
Phase 3 of the read heatmap: `bdrive read-log` parses any platform's hook event JSON from stdin and queues in-project file reads (mount-relative, ignore/include-filtered) in a per-volume spool — no network on the hook path. The sync cycle drains the spool best-effort to the hub's new POST /api/p/<id>/reads via the remote ReadReporter capability (https backend only); a failed report retries next cycle and never fails or offlines the cycle. `bdrive hooks install` now registers a third hook per platform on its read-tool matcher (claude Read, codex read_file best-effort, gemini read_file|read_many_files, hermes read_file), each idempotent on its own marker so sync-only configs upgrade in place. Agent reads land as agent traffic in the heat view, actor = device id. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P5cxPQdSGJnjXCYY9GeWXt
108 lines
3.2 KiB
Go
108 lines
3.2 KiB
Go
package store
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
)
|
|
|
|
// The read spool queues agent tool reads observed by `bdrive read-log` (the
|
|
// agent read hook) until a sync cycle drains it to the hub, where they count
|
|
// as agent traffic in the read heatmap. Hooks only append locally — no
|
|
// network on the hook path — and the flush is best-effort: offline, the
|
|
// spool just waits.
|
|
|
|
// ReadEvent is one observed read of a synced file (mount-relative path).
|
|
type ReadEvent struct {
|
|
Path string `json:"path"`
|
|
Time time.Time `json:"time"`
|
|
}
|
|
|
|
// readSpoolMax caps the spool: past it new events are dropped rather than
|
|
// letting an unreachable hub grow telemetry without bound.
|
|
const readSpoolMax = 1 << 20
|
|
|
|
// readReportMax bounds one drained batch to what the hub accepts per report.
|
|
const readReportMax = 4096
|
|
|
|
func (s *Store) readSpoolPath() string { return filepath.Join(s.dir, "reads.jsonl") }
|
|
func (s *Store) readFlushPath() string { return filepath.Join(s.dir, "reads-flushing.jsonl") }
|
|
|
|
// LogRead appends one read event to the spool. Single-line O_APPEND writes
|
|
// keep concurrent hook invocations from interleaving.
|
|
func (s *Store) LogRead(rel string) error {
|
|
if fi, err := os.Stat(s.readSpoolPath()); err == nil && fi.Size() > readSpoolMax {
|
|
return nil // spool full: drop, never grow unbounded
|
|
}
|
|
line, err := json.Marshal(ReadEvent{Path: rel, Time: time.Now().UTC()})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
f, err := os.OpenFile(s.readSpoolPath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
_, err = f.Write(append(line, '\n'))
|
|
return err
|
|
}
|
|
|
|
// PendingReads returns the queued batch awaiting report, deduplicated by path
|
|
// (latest time wins). The spool is rotated aside first, so events logged
|
|
// after this call land in a fresh spool; the batch survives until
|
|
// ClearPendingReads — a failed report is simply retried next cycle.
|
|
func (s *Store) PendingReads() ([]ReadEvent, error) {
|
|
if _, err := os.Stat(s.readFlushPath()); os.IsNotExist(err) {
|
|
if err := os.Rename(s.readSpoolPath(), s.readFlushPath()); err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, nil // nothing queued
|
|
}
|
|
return nil, err
|
|
}
|
|
}
|
|
data, err := os.ReadFile(s.readFlushPath())
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
latest := map[string]time.Time{}
|
|
var order []string
|
|
for _, line := range bytes.Split(data, []byte("\n")) {
|
|
if len(bytes.TrimSpace(line)) == 0 {
|
|
continue
|
|
}
|
|
var e ReadEvent
|
|
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 e.Time.After(latest[e.Path]) {
|
|
latest[e.Path] = e.Time
|
|
}
|
|
}
|
|
if len(order) > readReportMax {
|
|
order = order[len(order)-readReportMax:]
|
|
}
|
|
out := make([]ReadEvent, 0, len(order))
|
|
for _, p := range order {
|
|
out = append(out, ReadEvent{Path: p, Time: latest[p]})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ClearPendingReads drops the batch PendingReads returned, after a
|
|
// successful report.
|
|
func (s *Store) ClearPendingReads() error {
|
|
err := os.Remove(s.readFlushPath())
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|