diff --git a/.omc/project-memory.json b/.omc/project-memory.json new file mode 100644 index 0000000..89fb487 --- /dev/null +++ b/.omc/project-memory.json @@ -0,0 +1,123 @@ +{ + "version": "1.0.0", + "lastScanned": 1781278954384, + "projectRoot": "/Users/ssowonny/Workspace/runbear/sfs", + "techStack": { + "languages": [ + { + "name": "Go", + "version": null, + "confidence": "high", + "markers": [ + "go.mod" + ] + } + ], + "frameworks": [], + "packageManager": "go", + "runtime": null + }, + "build": { + "buildCommand": null, + "testCommand": null, + "lintCommand": null, + "devCommand": null, + "scripts": {} + }, + "conventions": { + "namingStyle": null, + "importStyle": null, + "testPattern": null, + "fileOrganization": null + }, + "structure": { + "isMonorepo": false, + "workspaces": [], + "mainDirectories": [], + "gitBranches": null + }, + "customNotes": [], + "directoryMap": { + "cmd": { + "path": "cmd", + "purpose": null, + "fileCount": 0, + "lastAccessed": 1781278954347, + "keyFiles": [] + }, + "internal": { + "path": "internal", + "purpose": null, + "fileCount": 0, + "lastAccessed": 1781278954360, + "keyFiles": [] + }, + "packaging": { + "path": "packaging", + "purpose": null, + "fileCount": 0, + "lastAccessed": 1781278954360, + "keyFiles": [] + }, + "internal/config": { + "path": "internal/config", + "purpose": "Configuration files", + "fileCount": 1, + "lastAccessed": 1781278954367, + "keyFiles": [ + "config.go" + ] + } + }, + "hotPaths": [ + { + "path": "internal/daemon/daemon.go", + "accessCount": 4, + "lastAccessed": 1781279115286, + "type": "file" + }, + { + "path": "internal/syncer/syncer.go", + "accessCount": 3, + "lastAccessed": 1781279091569, + "type": "file" + }, + { + "path": "internal/config/config.go", + "accessCount": 2, + "lastAccessed": 1781279069157, + "type": "file" + }, + { + "path": "cmd/sfs/cmds.go", + "accessCount": 2, + "lastAccessed": 1781279129227, + "type": "file" + }, + { + "path": "internal/store/store.go", + "accessCount": 1, + "lastAccessed": 1781279075626, + "type": "file" + }, + { + "path": "cmd/sfs/mount.go", + "accessCount": 1, + "lastAccessed": 1781279125661, + "type": "file" + }, + { + "path": "internal/store/store_test.go", + "accessCount": 1, + "lastAccessed": 1781279133287, + "type": "file" + }, + { + "path": "internal/syncer/syncer_test.go", + "accessCount": 1, + "lastAccessed": 1781279139436, + "type": "file" + } + ], + "userDirectives": [] +} \ No newline at end of file diff --git a/.omc/state/agent-replay-13a4d75d-aea2-4712-afd8-85e749c3a3b8.jsonl b/.omc/state/agent-replay-13a4d75d-aea2-4712-afd8-85e749c3a3b8.jsonl new file mode 100644 index 0000000..e91a718 --- /dev/null +++ b/.omc/state/agent-replay-13a4d75d-aea2-4712-afd8-85e749c3a3b8.jsonl @@ -0,0 +1 @@ +{"t":0,"agent":"system","event":"keyword_detected","keyword":"wiki"} diff --git a/.omc/state/hud-stdin-cache.json b/.omc/state/hud-stdin-cache.json new file mode 100644 index 0000000..8290d3e --- /dev/null +++ b/.omc/state/hud-stdin-cache.json @@ -0,0 +1 @@ +{"session_id":"d0d876ca-992f-4ad4-98d3-105e3371e069","transcript_path":"/Users/ssowonny/.claude/projects/-Users-ssowonny-Workspace-runbear-sfs/d0d876ca-992f-4ad4-98d3-105e3371e069.jsonl","cwd":"/Users/ssowonny/Workspace/runbear/sfs","effort":{"level":"xhigh"},"model":{"id":"claude-opus-4-7[1m]","display_name":"Opus 4.7 (1M context)"},"workspace":{"current_dir":"/Users/ssowonny/Workspace/runbear/sfs","project_dir":"/Users/ssowonny/Workspace/runbear/sfs","added_dirs":[]},"version":"2.1.141","output_style":{"name":"default"},"cost":{"total_cost_usd":0,"total_duration_ms":294,"total_api_duration_ms":0,"total_lines_added":0,"total_lines_removed":0},"context_window":{"total_input_tokens":0,"total_output_tokens":0,"context_window_size":1000000,"current_usage":null,"used_percentage":null,"remaining_percentage":null},"exceeds_200k_tokens":false,"fast_mode":false,"thinking":{"enabled":true}} \ No newline at end of file diff --git a/.omc/state/idle-notif-cooldown.json b/.omc/state/idle-notif-cooldown.json new file mode 100644 index 0000000..3bfbb8e --- /dev/null +++ b/.omc/state/idle-notif-cooldown.json @@ -0,0 +1,3 @@ +{ + "lastSentAt": "2026-06-12T15:37:47.380Z" +} \ No newline at end of file diff --git a/.omc/state/last-tool-error.json b/.omc/state/last-tool-error.json new file mode 100644 index 0000000..2e4ebd5 --- /dev/null +++ b/.omc/state/last-tool-error.json @@ -0,0 +1,7 @@ +{ + "tool_name": "Bash", + "tool_input_preview": "{\"command\":\"gcloud storage buckets create gs://runbear-wiki --project=runbear-local --location=us-west1 --uniform-bucket-level-access 2>&1 && gcloud config set project runbear-local 2>&1 | tail -1\",\"d...", + "error": "Exit code 1\nCreating gs://runbear-wiki/...\nERROR: (gcloud.storage.buckets.create) HTTPError 403: snow@runbear.io does not have storage.buckets.create access to the Google Cloud project. Permission 'storage.buckets.create' denied on resource '//storage.googleapis.com/projects/_/buckets/runbear-wiki' (or it may not exist). This command is authenticated as snow@runbear.io which is the active account specified by the [core/account] property.", + "timestamp": "2026-06-12T15:32:54.007Z", + "retry_count": 1 +} \ No newline at end of file diff --git a/README.md b/README.md index 3077b12..d8e058b 100644 --- a/README.md +++ b/README.md @@ -129,8 +129,9 @@ working folder ←materialize/scan→ local volume store ←push/pull→ obj Concurrent edits keep the last writer at the path; the loser is preserved as a conflict-copy file by the device that detects the overlap. - A per-mount **daemon** scans the folder every few seconds (cheap - size+mtime check) and exchanges with the remote every ~30s — or - immediately after local edits. + size+mtime check) and exchanges with the remote every ~10s — or + immediately after local edits. Tune with --scan-interval and + --remote-interval on `sfs mnt`. ### What sfs does not sync diff --git a/cmd/sfs/cmds.go b/cmd/sfs/cmds.go index 242813a..9b4c353 100644 --- a/cmd/sfs/cmds.go +++ b/cmd/sfs/cmds.go @@ -86,7 +86,7 @@ func statusCmd() *cobra.Command { if err != nil { return err } - if pid, ok := daemon.Running(vdir); ok { + if pid, ok := daemon.Running(vdir, config.MountID(folder)); ok { fmt.Printf(" daemon: running (pid %d)\n", pid) } else { fmt.Printf(" daemon: stopped\n") @@ -95,7 +95,7 @@ func statusCmd() *cobra.Command { if err != nil { continue } - cache, err := sess.Store.LoadCache() + cache, err := sess.Store.LoadCache(config.MountID(folder)) if err == nil { var total int64 for _, c := range cache { @@ -245,7 +245,7 @@ func daemonCmd() *cobra.Command { }, } run.Flags().DurationVar(&scanInterval, "scan-interval", 3*time.Second, "local scan interval") - run.Flags().DurationVar(&remoteInterval, "remote-interval", 30*time.Second, "remote sync interval") + run.Flags().DurationVar(&remoteInterval, "remote-interval", 10*time.Second, "remote sync interval") c.AddCommand(run) return c } diff --git a/cmd/sfs/mount.go b/cmd/sfs/mount.go index c966e2c..7a2d45b 100644 --- a/cmd/sfs/mount.go +++ b/cmd/sfs/mount.go @@ -113,7 +113,7 @@ daemon keeps the folder in sync until "sfs umnt".`, c.Flags().StringVarP(&volume, "volume", "v", "", "volume name (default: folder basename)") c.Flags().BoolVarP(&foreground, "foreground", "f", false, "run the sync daemon in the foreground") c.Flags().DurationVar(&scanInterval, "scan-interval", 3*time.Second, "how often to scan the folder for local changes") - c.Flags().DurationVar(&remoteInterval, "remote-interval", 30*time.Second, "how often to sync with the remote") + c.Flags().DurationVar(&remoteInterval, "remote-interval", 10*time.Second, "how often to sync with the remote") return c } @@ -142,7 +142,7 @@ volume data under ~/.sfs/volumes is still kept).`, if err != nil { return err } - stopped, err := daemon.Stop(vdir) + stopped, err := daemon.Stop(vdir, config.MountID(folder)) if err != nil { return err } diff --git a/internal/config/config.go b/internal/config/config.go index 0bc2eb7..3d06ac9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,6 +5,7 @@ package config import ( "crypto/rand" + "crypto/sha256" "encoding/hex" "encoding/json" "os" @@ -136,6 +137,15 @@ func SaveMounts(m map[string]MountInfo) error { return writeJSON(p, m) } +// MountID derives a stable identifier for a mounted folder. One volume can +// be mounted at several folders (even on the same device); everything +// folder-specific — the sync daemon and the materialization cache — is keyed +// by this ID, while blobs and journals stay shared per volume. +func MountID(folder string) string { + sum := sha256.Sum256([]byte(folder)) + return hex.EncodeToString(sum[:])[:12] +} + // VolumeDir returns (and creates parents for) the local store dir of a volume. func VolumeDir(volume string) (string, error) { home, err := Home() diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 7bf1a5c..a3dadcf 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -26,12 +26,19 @@ import ( "github.com/runbear-io/sfs/internal/syncer" ) -func PidPath(volDir string) string { return filepath.Join(volDir, "daemon.pid") } -func LogPath(volDir string) string { return filepath.Join(volDir, "daemon.log") } +// Daemons are per mount, not per volume: one volume may be mounted at +// several folders (each gets its own daemon), so pid/log files are keyed by +// the mount ID. +func PidPath(volDir, mountID string) string { + return filepath.Join(volDir, "daemon-"+mountID+".pid") +} +func LogPath(volDir, mountID string) string { + return filepath.Join(volDir, "daemon-"+mountID+".log") +} -// Running reports the daemon pid for a volume if one is alive. -func Running(volDir string) (int, bool) { - data, err := os.ReadFile(PidPath(volDir)) +// Running reports the daemon pid for a mount if one is alive. +func Running(volDir, mountID string) (int, bool) { + data, err := os.ReadFile(PidPath(volDir, mountID)) if err != nil { return 0, false } @@ -47,14 +54,15 @@ func Running(volDir string) (int, bool) { // Start launches a detached daemon for the folder (no-op if already running). func Start(folder, volDir string, scanInterval, remoteInterval time.Duration) (int, error) { - if pid, ok := Running(volDir); ok { + mountID := config.MountID(folder) + if pid, ok := Running(volDir, mountID); ok { return pid, nil } exe, err := os.Executable() if err != nil { return 0, err } - logf, err := os.OpenFile(LogPath(volDir), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + logf, err := os.OpenFile(LogPath(volDir, mountID), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) if err != nil { return 0, err } @@ -69,17 +77,17 @@ func Start(folder, volDir string, scanInterval, remoteInterval time.Duration) (i return 0, err } pid := cmd.Process.Pid - if err := os.WriteFile(PidPath(volDir), []byte(strconv.Itoa(pid)+"\n"), 0o644); err != nil { + if err := os.WriteFile(PidPath(volDir, mountID), []byte(strconv.Itoa(pid)+"\n"), 0o644); err != nil { return pid, err } return pid, cmd.Process.Release() } -// Stop terminates the daemon for a volume and waits for it to exit. -func Stop(volDir string) (bool, error) { - pid, ok := Running(volDir) +// Stop terminates the daemon for a mount and waits for it to exit. +func Stop(volDir, mountID string) (bool, error) { + pid, ok := Running(volDir, mountID) if !ok { - os.Remove(PidPath(volDir)) + os.Remove(PidPath(volDir, mountID)) return false, nil } if err := syscall.Kill(pid, syscall.SIGTERM); err != nil { @@ -88,13 +96,13 @@ func Stop(volDir string) (bool, error) { deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { if err := syscall.Kill(pid, 0); err != nil { - os.Remove(PidPath(volDir)) + os.Remove(PidPath(volDir, mountID)) return true, nil } time.Sleep(100 * time.Millisecond) } syscall.Kill(pid, syscall.SIGKILL) - os.Remove(PidPath(volDir)) + os.Remove(PidPath(volDir, mountID)) return true, nil } @@ -124,10 +132,11 @@ func Run(folder string, scanInterval, remoteInterval time.Duration) error { if err != nil { return err } - if err := os.WriteFile(PidPath(volDir), []byte(strconv.Itoa(os.Getpid())+"\n"), 0o644); err != nil { + mountID := config.MountID(folder) + if err := os.WriteFile(PidPath(volDir, mountID), []byte(strconv.Itoa(os.Getpid())+"\n"), 0o644); err != nil { return err } - defer os.Remove(PidPath(volDir)) + defer os.Remove(PidPath(volDir, mountID)) log.Printf("daemon started: folder=%s volume=%s remote=%q device=%s(%s) scan=%s sync=%s", folder, mi.Volume, mi.Remote, dev.Name, dev.ID, scanInterval, remoteInterval) diff --git a/internal/store/store.go b/internal/store/store.go index 41da4d6..ff5916a 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -154,7 +154,8 @@ func (s *Store) AllOps() ([]journal.Op, error) { // CachedFile records what sfs last wrote to / observed in the working folder // for a path. Size+MTimeNS make change detection cheap; Blob ties it back to -// content. +// content. The cache is per mount (one volume can be materialized into +// several folders, each with its own stat fingerprints). type CachedFile struct { Blob string `json:"blob"` Size int64 `json:"size"` @@ -162,16 +163,20 @@ type CachedFile struct { MTimeNS int64 `json:"mtime_ns"` } -func (s *Store) LoadCache() (map[string]CachedFile, error) { +func (s *Store) cachePath(mountID string) string { + return filepath.Join(s.dir, "state-"+mountID+".json") +} + +func (s *Store) LoadCache(mountID string) (map[string]CachedFile, error) { out := map[string]CachedFile{} - if err := readJSON(filepath.Join(s.dir, "state.json"), &out); err != nil { + if err := readJSON(s.cachePath(mountID), &out); err != nil { return nil, err } return out, nil } -func (s *Store) SaveCache(c map[string]CachedFile) error { - return WriteJSONAtomic(filepath.Join(s.dir, "state.json"), c) +func (s *Store) SaveCache(mountID string, c map[string]CachedFile) error { + return WriteJSONAtomic(s.cachePath(mountID), c) } // ---- sync state (sync.json) ---- diff --git a/internal/store/store_test.go b/internal/store/store_test.go index a1169d1..c42f9e8 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -61,13 +61,18 @@ func TestJournalAndState(t *testing.T) { } cache := map[string]CachedFile{"f": {Blob: "b", Size: 1, MTimeNS: 42}} - if err := s.SaveCache(cache); err != nil { + if err := s.SaveCache("m1", cache); err != nil { t.Fatal(err) } - got, err := s.LoadCache() + got, err := s.LoadCache("m1") if err != nil || got["f"].MTimeNS != 42 { t.Fatalf("cache roundtrip: %v %v", got, err) } + // caches are isolated per mount + other, err := s.LoadCache("m2") + if err != nil || len(other) != 0 { + t.Fatalf("mount caches must be isolated: %v %v", other, err) + } st := SyncState{Lamport: 7, PushedOps: 3} if err := s.SaveSync(st); err != nil { diff --git a/internal/syncer/syncer.go b/internal/syncer/syncer.go index 91134d8..9e0165c 100644 --- a/internal/syncer/syncer.go +++ b/internal/syncer/syncer.go @@ -32,11 +32,19 @@ import ( // Session ties a working folder to its volume store and (optionally) remote. type Session struct { Folder string + MountID string // distinguishes folders sharing one volume; see config.MountID Store *store.Store Device config.Device Backend remote.Backend // nil = work offline } +func (s *Session) mountID() string { + if s.MountID != "" { + return s.MountID + } + return config.MountID(s.Folder) +} + // Result summarizes one sync cycle. type Result struct { LocalOps int // local changes committed to the journal @@ -68,7 +76,7 @@ func (s *Session) Cycle(ctx context.Context) (*Result, error) { defer unlock() res := &Result{} - cache, err := s.Store.LoadCache() + cache, err := s.Store.LoadCache(s.mountID()) if err != nil { return nil, fmt.Errorf("load state: %w", err) } @@ -147,7 +155,7 @@ func (s *Session) Cycle(ctx context.Context) (*Result, error) { } } - if err := s.Store.SaveCache(cache); err != nil { + if err := s.Store.SaveCache(s.mountID(), cache); err != nil { return nil, err } if err := s.Store.SaveSync(st); err != nil { diff --git a/internal/syncer/syncer_test.go b/internal/syncer/syncer_test.go index 044c629..556100d 100644 --- a/internal/syncer/syncer_test.go +++ b/internal/syncer/syncer_test.go @@ -245,6 +245,38 @@ func TestOfflineThenReconnect(t *testing.T) { } } +func TestSameVolumeMountedAtTwoFolders(t *testing.T) { + // One device mounts the same volume at two folders (e.g. ./shared in two + // repos). They share the store (blobs+journals) but have separate mount + // caches, and content propagates between them even with no remote. + st, err := store.Open(filepath.Join(t.TempDir(), "volume")) + if err != nil { + t.Fatal(err) + } + dev := config.Device{ID: "dev1", Name: "dev1", Author: "dev1@test"} + m1 := &Session{Folder: t.TempDir(), MountID: "mount1", Store: st, Device: dev} + m2 := &Session{Folder: t.TempDir(), MountID: "mount2", Store: st, Device: dev} + + write(t, m1.Folder, "shared.md", "from folder one") + cycle(t, m1) + res := cycle(t, m2) + if res.Materialized != 1 { + t.Fatalf("folder two should materialize the file: %+v", res) + } + if read(t, m2.Folder, "shared.md") != "from folder one" { + t.Fatal("content did not propagate between mounts") + } + + // edit in folder two propagates back + time.Sleep(10 * time.Millisecond) + write(t, m2.Folder, "shared.md", "edited in folder two") + cycle(t, m2) + cycle(t, m1) + if read(t, m1.Folder, "shared.md") != "edited in folder two" { + t.Fatal("edit did not propagate back to folder one") + } +} + func TestExecutableBitPreserved(t *testing.T) { be := sharedRemote(t) a := newDevice(t, "deva", be)