mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
History showed what an agent run CHANGED. What it read lived in a daily aggregate with no session dimension, so the two could not be joined and nobody could answer "when my agent answered, what did it look at — and was it the fresh version or archive/retired-spec.md?". The join is one string carried through four places: hook -> spool -> hub -> run card. A run card now marks each change the run also read, lists the files it read and never touched, and says on screen why a read can be missing. The three landmines the issue asks be named here: 1. Op.Note is USER-SETTABLE (`bdrive sync --note`), so joining reads to writes on the note string would let any member with write access forge a note that collides with a teammate's run card and hang their reads off it. Fixed by adding journal.Op.Session — set only by `bdrive sync --hook`, never by --note — and joining on that. The note stays settable and stays untrusted; the join simply never reads it. Op.Session is additive JSONL and, like Mtime, is never an input to Less or Replay, so replay determinism is untouched and older ops carry "". The read half has the same hole one step further on: POST /reads takes the session id from the CLIENT, so a member could report reads under a teammate's session and paint files onto their card. Every session row is therefore pinned to the ownsDevice-validated device, and the query requires ?session= AND ?device= together — a forged row can only be found under the forger's own device, which MayActAs guarantees is never somebody else's. 2. BUCKET CARDINALITY. Putting the session in the read_stats key would take a 2k-file project from ~2k to ~100k rows/day, into a table ReadLedger loads whole at boot and full-scans on every heat request, hub-wide — so it would slow the Dashboard for projects that never ran an agent. This is the escape hatch the spec itself names, taken up front: session rows live in their own read_sessions repo, outside ReadLedger.byKey. No read_stats PK migration, no change to the resident-row count, ?by=device byte-identical. They get their own retention (session_retention_days, default 30) which DELETES rather than folds — no heat total was ever derived from them. 3. READS ARE RECORDED ONLY FOR PATHS IN THE CURRENT REPLAY, so a session that read a file it then deleted shows a change with no read. That is by design, and the run card says so in its footer rather than leaving it to read as a bug. Privacy ruling, written into internal/webapp/reads.go before anything serves it: a session id appears only in History responses on the op that carries it, and as a ?session= filter INPUT. It is never enumerated — no listing endpoint, no session column in /heat output, nothing new in ?by=device. Also: PendingReads now dedupes on (path, session), not path alone. Two agent sessions on one device between syncs used to collapse into one event carrying whichever session flushed last — one session's reads silently credited to another. Tests: journal round-trip + Less-ignores-Session; the forge test (`sync --note "claude-code session <someone-else's>"` leaves Session empty); a multi-device syncer test carrying the session through convergence; spool per-session dedup; hub round-trip, cross-device forge, query contract and non-enumeration; db_conformance on file, sqlite AND postgres; runs.ts grouping incl. legacy fallback; a Playwright spec on the seeded run card.
104 lines
3.9 KiB
Go
104 lines
3.9 KiB
Go
// Package remote abstracts the cloud object store a volume syncs through.
|
|
// beardrive is provider-agnostic: any backend that can put/get/list immutable
|
|
// objects works. Built-in schemes:
|
|
//
|
|
// file:///abs/path local or network-drive directory (also used in tests)
|
|
// s3://bucket/prefix Amazon S3 (or S3-compatible via AWS_ENDPOINT_URL)
|
|
// gs://bucket/prefix Google Cloud Storage
|
|
// https://host:4173 a bdrive web server brokering one of the above —
|
|
// the device needs no storage credentials at all
|
|
//
|
|
// Remote layout: blobs/<sha256> for content, journal/<device>.jsonl for op
|
|
// logs. Each device writes only its own journal, so there are no concurrent
|
|
// writers per object and no server-side coordination is needed.
|
|
package remote
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// ErrForbidden marks a refusal by the hub's authorization — the device asked
|
|
// correctly and was told no, which is a different thing from being offline.
|
|
// The syncer keys its degraded states off it: a refused push means read-only
|
|
// (keep pulling), a refused pull means access is gone (pause, touch nothing).
|
|
var ErrForbidden = errors.New("forbidden")
|
|
|
|
type Object struct {
|
|
Key string
|
|
Size int64
|
|
// Modified is when the store last wrote this object, when the backend
|
|
// reports it (S3, GCS) and the zero time when it does not. It is how the
|
|
// hub decides an object can no longer change — see RemoteSource.verify.
|
|
Modified time.Time
|
|
}
|
|
|
|
// SignedPut is a presigned direct-upload request: whoever holds the URL can
|
|
// PUT that one object until Expires, without ever seeing storage credentials.
|
|
type SignedPut struct {
|
|
URL string // upload here
|
|
Method string // always "PUT"
|
|
Headers map[string]string // headers that must be sent verbatim (they are signed)
|
|
Expires time.Time
|
|
}
|
|
|
|
// PutSigner is implemented by backends that can mint presigned upload URLs
|
|
// so clients write to storage directly. Backends without that capability
|
|
// (file://) simply don't implement it, and callers fall back to uploading
|
|
// through the server.
|
|
type PutSigner interface {
|
|
SignPut(ctx context.Context, key string, size int64, ttl time.Duration) (*SignedPut, error)
|
|
}
|
|
|
|
type Backend interface {
|
|
Put(ctx context.Context, key string, r io.Reader, size int64) error
|
|
Get(ctx context.Context, key string) (io.ReadCloser, error)
|
|
List(ctx context.Context, prefix string) ([]Object, error)
|
|
Exists(ctx context.Context, key string) (bool, error)
|
|
Close() error
|
|
}
|
|
|
|
// ReadEvent is one agent file read reported to the hub for its read heatmap.
|
|
type ReadEvent struct {
|
|
Path string `json:"path"`
|
|
// Session is the agent session the read happened in, so the hub can join
|
|
// a run's reads to the writes journal.Op.Session carries. A client string
|
|
// — the hub pins each recorded row to the device it validated, never to
|
|
// anything in this body (see handleReadReport).
|
|
Session string `json:"session,omitempty"`
|
|
Time time.Time `json:"time,omitzero"`
|
|
}
|
|
|
|
// ReadReporter is the optional read-telemetry capability, in the PutSigner
|
|
// mold: backends that sync through a hub report the device's agent reads so
|
|
// the heat view can split human from agent traffic. Object-store backends
|
|
// simply don't implement it — there is no hub to tell.
|
|
type ReadReporter interface {
|
|
ReportReads(ctx context.Context, reads []ReadEvent) error
|
|
}
|
|
|
|
// Open creates a backend from a remote URL.
|
|
func Open(ctx context.Context, raw string) (Backend, error) {
|
|
u, err := url.Parse(raw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid remote %q: %w", raw, err)
|
|
}
|
|
switch u.Scheme {
|
|
case "file":
|
|
return newLocal(u.Path)
|
|
case "s3":
|
|
return newS3(ctx, u.Host, strings.Trim(u.Path, "/"))
|
|
case "gs":
|
|
return newGCS(ctx, u.Host, strings.Trim(u.Path, "/"))
|
|
case "http", "https":
|
|
return newHTTPBackend(raw)
|
|
default:
|
|
return nil, fmt.Errorf("unsupported remote scheme %q (supported: file://, s3://, gs://, https://)", u.Scheme)
|
|
}
|
|
}
|