Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions architecture/cli-sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ classDiagram
+Account config.Settings
+Backend remote.Backend
+Note string
+SessionID string
+Prune bool
+OnProgress func
+Cycle(ctx) Result
Expand Down Expand Up @@ -107,7 +108,8 @@ classDiagram
+LoadCache / SaveCache mountID
+LoadSync / SaveSync
+SaveNote / LoadNote
+PendingReads read spool
+LogRead(rel, session) read spool
+PendingReads dedup on path+session
+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)"
Expand All @@ -117,9 +119,10 @@ classDiagram
+Author +User +UserName
+Kind put or delete
+Path +Blob +Size +Mode +Note
+Session agent session, hook-set
+Mtime when the file was written
}
note for Op "internal/journal — Less orders by (lamport, time, device, seq); Replay folds to LWW-per-path state; each device writes only its own journal. Mtime is display-only (bdrive log shows it, falling back to Time) and never feeds Less or Replay"
note for Op "internal/journal — Less orders by (lamport, time, device, seq); Replay folds to LWW-per-path state; each device writes only its own journal. Mtime is display-only (bdrive log shows it, falling back to Time) and never feeds Less or Replay. Session holds the same standing: set only by `bdrive sync --hook` (never by --note, which any member can spell), display/join-only, and the key History run cards group on — a note is forgeable, a session id is not"
note for Op "Op now owns its own JSON: a Path that is not valid UTF-8 rides as a base64 `path_raw` sidecar and is restored only when the lossy form still matches, so one line can never name two different files on two readers. Less falls through to Kind/Path/Blob/Size/Mode, making the order TOTAL — two ops can no longer tie and replay differently per device. Parse skips an undecodable line and drops an unknown Kind instead of failing the whole journal"

class Backend {
Expand Down
23 changes: 20 additions & 3 deletions architecture/webapp-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,15 +295,23 @@ classDiagram

class ReadLedger {
-repo ReadRepo
-retention
-byKey, dirty, seen
-sessions SessionReadRepo
-retention, sessionRetention
-byKey, dirty, seen, pendingSess
+Record(...)
+RecordSession(project, session, device, path)
+Heat(project, prefix, days)
+SessionPaths(project, session, device)
+WithSessions(repo, days)
+ShareOpens(project)
}
class ReadStat {
+Project +Path +Day +Kind +Actor +Count +Last
}
class SessionRead {
+Project +Session +Device +Path +Last
}
note for SessionRead "One row per (session, device, path) — the per-session detail a History run card joins its writes to, on the un-forgeable Op.Session and never on the note. Deliberately OUTSIDE ReadLedger.byKey: that map is loaded whole at boot and full-scanned by Heat on every request, hub-wide, so session cardinality in it would slow the Dashboard for projects that never ran an agent. Device is always the ownsDevice-validated id, never a client field, so a report naming someone else's session can only ever be found under the forger's own device. Its own, much shorter retention (session_retention_days, default 30) DELETES rather than folds — no heat total was ever derived from it"
class HeatEntry {
+Human +Agent +Share +Readers +LastRead
}
Expand Down Expand Up @@ -415,6 +423,7 @@ classDiagram
RemoteSource ..> sourcedOp : attribution comes from the journal key
RemoteSource *-- cachedJournal : parsed ops, keyed on size+mtime
ReadLedger ..> ReadStat
ReadLedger ..> SessionRead
ReadLedger ..> HeatEntry
ReadLedger ..> ShareOpen
ShareDB ..> ShareOpen : shares list joins the open count per path
Expand Down Expand Up @@ -446,6 +455,7 @@ classDiagram
+Shares() ShareRepo
+Devices() DeviceRepo
+Reads() ReadRepo
+SessionReads() SessionReadRepo
+Close()
}

Expand Down Expand Up @@ -500,14 +510,19 @@ classDiagram
storable / storableMap
checkAccount checkToken checkProject
checkOrg checkInvite checkShare
checkDevice checkReadStat
checkDevice checkReadStat checkSessionRead
}
note for storable "Called at the top of every repo write in BOTH backends. A NUL byte or invalid UTF-8 in a name is accepted by JSON and rejected by Postgres, so the file backend used to persist rows the SQL backend would refuse — the same hub, migrated, would silently lose them. Refusing at one gate makes the two backends agree on what is storable"
class ReadRepo {
<<interface>>
+Load() +PutBatch +DeleteBatch
}
note for ReadRepo "batch-oriented: one flush = one write"
class SessionReadRepo {
<<interface>>
+PutBatch +ListBySession +PruneBefore
}
note for SessionReadRepo "read_sessions / sessions.json — never Load()ed whole; queried by (project, session, device) and pruned by date, which is what keeps the boot load and Heat's scan the size they are today"

class fileMetaStore {
JSON files, atomic rewrite per change
Expand All @@ -530,6 +545,7 @@ classDiagram
MetaStore *-- ShareRepo
MetaStore *-- DeviceRepo
MetaStore *-- ReadRepo
MetaStore *-- SessionReadRepo

class BuiltinAuth
class ProjectDB
Expand All @@ -544,6 +560,7 @@ classDiagram
ShareDB o-- ShareRepo
DeviceRegistry o-- DeviceRepo
ReadLedger o-- ReadRepo
ReadLedger o-- SessionReadRepo

BuiltinAuth *-- versionGate
ProjectDB *-- versionGate
Expand Down
14 changes: 14 additions & 0 deletions cmd/bdrive/hooksync.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ type hookLink struct {
// mounts.
func hookSessionID(cmd *cobra.Command) string {
data, _ := io.ReadAll(io.LimitReader(cmd.InOrStdin(), 1<<20))
return eventSessionID(data)
}

// eventSessionID is that parse over an already-read payload — `bdrive
// read-log` consumes the same stdin for its own reasons and tags every read
// it spools with the same id, which is what lets the hub join a run's reads
// to its writes.
func eventSessionID(data []byte) string {
var event struct {
SessionID string `json:"session_id"`
}
Expand All @@ -64,6 +72,12 @@ func runHookSync(cmd *cobra.Command, target, sessionID, label string) (string, b
if err := sess.Store.SaveNote(note, hookNoteTTL); err == nil {
sess.Note = note
}
// The hook is the ONLY writer of Op.Session — `bdrive sync --note`
// cannot reach it, which is what makes a run card's identity
// un-forgeable. Unlike the note it is not persisted with a TTL: a
// later daemon scan should not credit its own changes to a session
// that has moved on.
sess.SessionID = sessionID
}

// The pull. Offline is fine — the link formula below is still valid
Expand Down
12 changes: 8 additions & 4 deletions cmd/bdrive/readlog.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,18 +41,22 @@ to run it by hand.`,
return nil
}
data, _ := io.ReadAll(io.LimitReader(cmd.InOrStdin(), 1<<20))
// Parsed once, like syncCmd hoists it: stdin is already drained
// here, and logReads runs once per mount.
session := eventSessionID(data)
// The session's directory is rarely the mount root, so reads are
// attributed to whichever mount actually contains them.
for _, target := range syncTargets(folder) {
logReads(target, data)
logReads(target, data, session)
}
return nil
},
}
}

// logReads spools the reads from one hook event that fall inside one mount.
func logReads(folder string, data []byte) {
// logReads spools the reads from one hook event that fall inside one mount,
// tagged with the agent session they happened in (see store.ReadEvent).
func logReads(folder string, data []byte, session string) {
// LoadProject, not ResolveMount: a hook must never enroll this
// device (registry self-heal) — and syncBlocked keeps a paused
// or never-inited project's spool from even being created.
Expand Down Expand Up @@ -89,7 +93,7 @@ func logReads(folder string, data []byte) {
if filter.Skip(rel) {
continue // not part of the project (ignore/include rules)
}
st.LogRead(rel) // best-effort; the hook must never fail the turn
st.LogRead(rel, session) // best-effort; the hook must never fail the turn
}
}

Expand Down
6 changes: 6 additions & 0 deletions cmd/bdrive/readlog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,12 @@ func TestReadLogCommand(t *testing.T) {
if len(evs) != 1 || evs[0].Path != "wiki/a.md" {
t.Fatalf("spool = %+v, want just the in-project read, mount-relative", evs)
}
// The same session id `bdrive sync --hook` stamps onto the writes, off
// the same stdin payload — it is what lets the hub join this read to the
// run card that turn produced.
if evs[0].Session != "abc" {
t.Fatalf("spooled read Session = %q, want the event's session_id", evs[0].Session)
}
}

// read-log fires on every agent tool call in every folder, so it must be
Expand Down
117 changes: 117 additions & 0 deletions cmd/bdrive/session_stamp_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package main

import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"

"github.com/runbear-io/beardrive/internal/config"
"github.com/runbear-io/beardrive/internal/journal"
)

// journalOps reads the ops this device wrote for a project.
func journalOps(t *testing.T, projectID, deviceID string) []journal.Op {
t.Helper()
vdir, err := config.VolumeDir(projectID)
if err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(vdir, "journal", deviceID+".jsonl"))
if err != nil {
t.Fatal(err)
}
ops, err := journal.Parse(data)
if err != nil {
t.Fatal(err)
}
return ops
}

// stampFixture is a mount with one file, ready to commit.
func stampFixture(t *testing.T) (folder string, proj config.Project) {
t.Helper()
t.Setenv("BDRIVE_HOME", t.TempDir())
folder = t.TempDir()
folder, _ = filepath.EvalSymlinks(folder)
var err error
proj, err = config.SaveProject(folder, config.Project{
Volume: "wiki",
Remote: "https://hub.example.com/p/p-12345678", // unreachable: the cycle degrades offline, the scan still commits
})
if err != nil {
t.Fatal(err)
}
if _, _, err := config.EnrollMount(folder); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(folder, "a.md"), []byte("hello\n"), 0o644); err != nil {
t.Fatal(err)
}
return folder, proj
}

func thisDevice(t *testing.T) string {
t.Helper()
dev, err := config.LoadDevice()
if err != nil {
t.Fatal(err)
}
return dev.ID
}

// The hook sets BOTH the note and the session id — the note is the label a
// reader sees, the session is the key a run card joins its reads on.
func TestHookStampsSession(t *testing.T) {
folder, proj := stampFixture(t)

c := syncCmd()
c.SetOut(&bytes.Buffer{})
c.SetIn(strings.NewReader(`{"session_id":"sess-42"}`))
c.SetArgs([]string{folder, "--hook", "claude-code"})
if err := c.Execute(); err != nil {
t.Fatalf("hook mode must never fail: %v", err)
}

ops := journalOps(t, proj.ID, thisDevice(t))
if len(ops) == 0 {
t.Fatal("the hook run committed nothing")
}
for _, op := range ops {
if op.Session != "sess-42" {
t.Errorf("op %q Session = %q, want sess-42", op.Path, op.Session)
}
if op.Note != "claude-code session sess-42" {
t.Errorf("op %q Note = %q", op.Path, op.Note)
}
}
}

// Landmine 1, tested explicitly: Op.Note is user-settable, so `bdrive sync
// --note` can spell out any other member's session card verbatim. It must
// still produce an EMPTY Op.Session, so nothing it writes can attach to that
// member's run — the join reads the session, never the note.
func TestSyncNoteCannotForgeASession(t *testing.T) {
folder, proj := stampFixture(t)

c := syncCmd()
c.SetOut(&bytes.Buffer{})
c.SetArgs([]string{folder, "--note", "claude-code session sess-42"})
if err := c.Execute(); err != nil {
t.Fatalf("sync: %v", err)
}

ops := journalOps(t, proj.ID, thisDevice(t))
if len(ops) == 0 {
t.Fatal("the sync committed nothing")
}
for _, op := range ops {
if op.Session != "" {
t.Errorf("--note forged a session id on %q: %q", op.Path, op.Session)
}
if op.Note != "claude-code session sess-42" {
t.Errorf("op %q Note = %q, want the note to still be settable", op.Path, op.Note)
}
}
}
16 changes: 14 additions & 2 deletions cmd/bdrive/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ type webConfig struct {
Reads *struct {
Enabled *bool `json:"enabled,omitempty"` // default true
RetentionDays int `json:"retention_days,omitempty"` // default 400; older days fold into all-time
// SessionRetentionDays bounds the per-session read detail behind
// History's run cards (which files an agent session read). Default
// 30, and deliberately much shorter than RetentionDays: this is
// event-shaped rather than aggregate, and rows past it are deleted,
// which changes no heat total.
SessionRetentionDays int `json:"session_retention_days,omitempty"`
} `json:"reads,omitempty"`
}

Expand Down Expand Up @@ -380,23 +386,29 @@ credentials); otherwise it is relayed through this server.`,
return fmt.Errorf("open share registry: %w", err)
}
srv.Shares = shares
readsOn, retention := true, 0
readsOn, retention, sessRetention := true, 0, 0
if cfg.Reads != nil {
if cfg.Reads.Enabled != nil {
readsOn = *cfg.Reads.Enabled
}
retention = cfg.Reads.RetentionDays
sessRetention = cfg.Reads.SessionRetentionDays
}
if readsOn {
var reads *webapp.ReadLedger
var sessions webapp.SessionReadRepo
if meta != nil {
reads, err = webapp.NewReadLedger(meta.Reads(), retention)
sessions = meta.SessionReads()
} else {
reads, err = webapp.OpenReadLedger(filepath.Join(filepath.Dir(projectsDB), "reads.json"), retention)
dir := filepath.Dir(projectsDB)
reads, err = webapp.OpenReadLedger(filepath.Join(dir, "reads.json"), retention)
sessions = webapp.OpenSessionReadRepo(filepath.Join(dir, "sessions.json"))
}
if err != nil {
return fmt.Errorf("open read ledger: %w", err)
}
reads.WithSessions(sessions, sessRetention)
defer reads.Close()
srv.Reads = reads
}
Expand Down
12 changes: 12 additions & 0 deletions internal/journal/journal.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,18 @@ type Op struct {
Size int64 `json:"size,omitempty"`
Mode uint32 `json:"mode,omitempty"` // permission bits
Note string `json:"note,omitempty"` // e.g. "conflict copy of <path>"
// Session is the agent session this op was committed during, set ONLY by
// the agent sync hook (`bdrive sync --hook`). Display/join only — never an
// input to Less or Replay, exactly like Mtime below, so replay stays
// deterministic and ops written before this field existed simply carry "".
//
// It exists because Note is user-settable (`bdrive sync --note`): joining
// a run's reads to its writes on the note string would let any member with
// write access forge a note that collides with a teammate's session and
// hang their reads off it. This field is the un-forgeable half of that
// pair, so the join reads it and never the note.
Session string `json:"session,omitempty"`

// Mtime is when the file was last written, as opposed to Time, which is
// when the op was committed. Display only — never an input to Less or
// Replay, since it comes from the filesystem and can be anything.
Expand Down
Loading
Loading