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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,12 @@ mount/
└── github/
└── repos/
├── _index.json
├── acme/api/ # lazily materialized on first stat
├── acme/api/
│ └── pulls/42__bump-deps/meta.json
└── by-name/acme__api.json
```

Four alias views ship out of the box: `by-title/` (slug lookups), `by-id/` (identifier lookups), `by-name/` (human-readable name lookups), and `by-state/` (grouped by issue/PR state). GitHub repo subtrees materialize lazily — the first `ls`/`stat` triggers a one-time fetch instead of paying upfront sync cost on workspaces with hundreds of repos.
Four alias views ship out of the box: `by-title/` (slug lookups), `by-id/` (identifier lookups), `by-name/` (human-readable name lookups), and `by-state/` (grouped by issue/PR state). GitHub repo subtrees can be materialized lazily (opt-in via `--lazy-repos`) for huge-org workspaces.

## Why files

Expand Down
1 change: 1 addition & 0 deletions cmd/relayfile-mount/fuse_mount.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ func runFuseMount(ctx context.Context, cfg mountConfig) error {
Client: httpClient,
WorkspaceID: cfg.workspaceID,
RemoteRoot: cfg.remotePath,
LazyRepos: cfg.lazyRepos,
Logger: log.Default(),
}

Expand Down
8 changes: 8 additions & 0 deletions cmd/relayfile-mount/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ type mountConfig struct {
intervalJitter float64
timeout time.Duration
websocketEnabled bool
lazyRepos bool
scopes []string
once bool
mode string
Expand All @@ -66,6 +67,7 @@ func main() {
intervalJitter := flag.Float64("interval-jitter", floatEnv("RELAYFILE_MOUNT_INTERVAL_JITTER", 0.2), "sync interval jitter ratio (0.0-1.0)")
timeout := flag.Duration("timeout", durationEnv("RELAYFILE_MOUNT_TIMEOUT", 15*time.Second), "per-sync timeout")
websocketEnabled := flag.Bool("websocket", boolEnv("RELAYFILE_MOUNT_WEBSOCKET", true), "enable websocket event streaming when available")
lazyRepos := flag.Bool("lazy-repos", lazyReposEnv(), "lazily materialize GitHub repo subtrees on first access")
mode := flag.String("mode", envOrDefault("RELAYFILE_MOUNT_MODE", mountModePoll), "mount mode: poll (synced mirror, recommended) or fuse")
fuse := flag.Bool("fuse", boolEnv("RELAYFILE_MOUNT_FUSE", false), "shortcut for --mode=fuse")
once := flag.Bool("once", false, "run one sync cycle and exit")
Expand Down Expand Up @@ -107,6 +109,7 @@ func main() {
intervalJitter: *intervalJitter,
timeout: *timeout,
websocketEnabled: *websocketEnabled,
lazyRepos: *lazyRepos,
scopes: parseTokenScopes(strings.TrimSpace(*token)),
once: *once,
mode: resolvedMode,
Expand Down Expand Up @@ -161,6 +164,7 @@ func runPollingMount(rootCtx context.Context, cfg mountConfig) error {
Logger: log.Default(),
Mode: cfg.mode,
Interval: cfg.interval,
LazyRepos: boolPtr(cfg.lazyRepos),
})
if err != nil {
return fmt.Errorf("initialize mount syncer: %w", err)
Expand Down Expand Up @@ -270,6 +274,10 @@ func boolEnv(name string, fallback bool) bool {
return value
}

func lazyReposEnv() bool {
return boolEnv("RELAYFILE_LAZY_REPOS", boolEnv("RELAYFILE_MOUNT_LAZY_GITHUB_REPOS", false))
}

func boolPtr(value bool) *bool {
return &value
}
Expand Down
27 changes: 27 additions & 0 deletions cmd/relayfile-mount/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,33 @@ func TestFloatEnvFallsBackOnInvalid(t *testing.T) {
}
}

func TestLazyReposEnvDefaultsFalse(t *testing.T) {
t.Setenv("RELAYFILE_LAZY_REPOS", "")
t.Setenv("RELAYFILE_MOUNT_LAZY_GITHUB_REPOS", "")

if lazyReposEnv() {
t.Fatal("expected lazy repos to default false")
}
}

func TestLazyReposEnvParsesOptIn(t *testing.T) {
t.Setenv("RELAYFILE_LAZY_REPOS", "true")
t.Setenv("RELAYFILE_MOUNT_LAZY_GITHUB_REPOS", "")

if !lazyReposEnv() {
t.Fatal("expected RELAYFILE_LAZY_REPOS=true to opt in")
}
}

func TestLazyReposEnvSupportsLegacyName(t *testing.T) {
t.Setenv("RELAYFILE_LAZY_REPOS", "")
t.Setenv("RELAYFILE_MOUNT_LAZY_GITHUB_REPOS", "true")

if !lazyReposEnv() {
t.Fatal("expected legacy lazy repos env var to opt in")
}
}

func TestClampJitterRatio(t *testing.T) {
if got := clampJitterRatio(-0.1); got != 0 {
t.Fatalf("expected clamp to 0, got %f", got)
Expand Down
8 changes: 6 additions & 2 deletions evals/suites/integrations/cases.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ List the open issues in the AgentWorkforce/relay GitHub repo through the relayfi
```json
[
{ "op": "list", "path": "/github/repos/AgentWorkforce/relay/issues" },
{ "op": "grep", "path": "/github/repos/AgentWorkforce/relay/issues", "pattern": "\"state\": \"open\"" }
{ "op": "read", "path": "/github/repos/AgentWorkforce/relay/issues/805.json" },
{ "op": "read", "path": "/github/repos/AgentWorkforce/relay/issues/815.json" }
]
```

Expand All @@ -47,9 +48,12 @@ contentIncludes:
- 805.json
- 815.json
- state
- Stabilize relayfile comparison evals
- evals
- Measure token cost against MCP
fileExists:
- /github/repos/AgentWorkforce/relay/issues/805.json
maxToolCalls: 2
maxToolCalls: 3

### Must
- Answer from the filesystem-shaped GitHub data, not by invoking a GitHub MCP.
Expand Down
14 changes: 2 additions & 12 deletions internal/mountfuse/fs.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,8 @@ import (
"hash/fnv"
"log"
"mime"
"os"
"path"
"sort"
"strconv"
"strings"
"sync"
"syscall"
Expand Down Expand Up @@ -41,6 +39,7 @@ type Config struct {
NegativeTimeout time.Duration
UID uint32
GID uint32
LazyRepos bool
Logger *log.Logger
}

Expand Down Expand Up @@ -164,7 +163,7 @@ func newFSState(cfg Config) *fsState {
inodeByPath: map[string]uint64{normalizeRemotePath(cfg.RemoteRoot): 1},
pathByInode: map[uint64]string{1: normalizeRemotePath(cfg.RemoteRoot)},
}
if lazyGithubReposEnabled() {
if cfg.LazyRepos {
state.lazyRepos = NewLazyMaterializeCache()
}
return state
Expand Down Expand Up @@ -698,12 +697,3 @@ func contentTypeForPath(remotePath string) string {
}
return "text/plain; charset=utf-8"
}

func lazyGithubReposEnabled() bool {
raw := strings.TrimSpace(os.Getenv("RELAYFILE_MOUNT_LAZY_GITHUB_REPOS"))
if raw == "" {
return false
}
enabled, err := strconv.ParseBool(raw)
return err == nil && enabled
}
42 changes: 29 additions & 13 deletions internal/mountfuse/fuse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -730,11 +730,10 @@ func TestFuseAliasReaddirRefreshesAfterInvalidation(t *testing.T) {
}

func TestLazyMaterializeFiresOnceOnRepoStat(t *testing.T) {
t.Setenv("RELAYFILE_MOUNT_LAZY_GITHUB_REPOS", "true")

t.Run("repo stat and repeated readdir", func(t *testing.T) {
remote := newLazyGithubRepoRemote()
root := newMountTestRoot(t, remote, "ws_lazy_once")
root := newLazyMountTestRoot(t, remote, "ws_lazy_once")
repo := lookupDir(t, lookupDir(t, lookupDir(t, lookupDir(t, root, "github"), "repos"), "octocat"), "hello-world")

var out fuse.AttrOut
Expand All @@ -759,7 +758,7 @@ func TestLazyMaterializeFiresOnceOnRepoStat(t *testing.T) {

t.Run("missing owner or repo segments do not trigger", func(t *testing.T) {
remote := newLazyGithubRepoRemote()
root := newMountTestRoot(t, remote, "ws_lazy_missing_segments")
root := newLazyMountTestRoot(t, remote, "ws_lazy_missing_segments")
repos := lookupDir(t, lookupDir(t, root, "github"), "repos")
owner := lookupDir(t, repos, "octocat")

Expand All @@ -777,7 +776,7 @@ func TestLazyMaterializeFiresOnceOnRepoStat(t *testing.T) {

t.Run("multiple repos under same owner are independent", func(t *testing.T) {
remote := newLazyGithubRepoRemote()
root := newMountTestRoot(t, remote, "ws_lazy_multi_repo")
root := newLazyMountTestRoot(t, remote, "ws_lazy_multi_repo")
owner := lookupDir(t, lookupDir(t, lookupDir(t, root, "github"), "repos"), "octocat")
helloWorld := lookupDir(t, owner, "hello-world")
spoonKnife := lookupDir(t, owner, "spoon-knife")
Expand Down Expand Up @@ -806,7 +805,7 @@ func TestLazyMaterializeFiresOnceOnRepoStat(t *testing.T) {
remote.setMaterialized(owner, repo)
return nil
}
root := newMountTestRoot(t, remote, "ws_lazy_race")
root := newLazyMountTestRoot(t, remote, "ws_lazy_race")
repo := lookupDir(t, lookupDir(t, lookupDir(t, lookupDir(t, root, "github"), "repos"), "octocat"), "hello-world")

errnos := make(chan syscall.Errno, 2)
Expand All @@ -833,8 +832,16 @@ func TestLazyMaterializeFiresOnceOnRepoStat(t *testing.T) {
})
}

func TestLazyReposConfigOverridesEnv(t *testing.T) {
t.Setenv("RELAYFILE_LAZY_REPOS", "true")

root := newMountTestRoot(t, newLazyGithubRepoRemote(), "ws_lazy_env_override")
if root.state.lazyRepos != nil {
t.Fatal("expected explicit Config.LazyRepos=false to ignore lazy repos env fallback")
}
}

func TestLazyMaterializeRetriesAfterError(t *testing.T) {
t.Setenv("RELAYFILE_MOUNT_LAZY_GITHUB_REPOS", "true")

remote := newLazyGithubRepoRemote()
remote.lazyMaterializeFunc = func(_ context.Context, _ string, owner, repo string) error {
Expand All @@ -844,7 +851,7 @@ func TestLazyMaterializeRetriesAfterError(t *testing.T) {
remote.setMaterialized(owner, repo)
return nil
}
root := newMountTestRoot(t, remote, "ws_lazy_retry")
root := newLazyMountTestRoot(t, remote, "ws_lazy_retry")
repo := lookupDir(t, lookupDir(t, lookupDir(t, lookupDir(t, root, "github"), "repos"), "octocat"), "hello-world")

if _, errno := repo.Readdir(context.Background()); errno != syscall.EIO {
Expand All @@ -859,7 +866,6 @@ func TestLazyMaterializeRetriesAfterError(t *testing.T) {
}

func TestLazyMaterializeNoOpWhenRemoteDoesNotImplement(t *testing.T) {
t.Setenv("RELAYFILE_MOUNT_LAZY_GITHUB_REPOS", "true")

remote := &fakeRemoteClient{
trees: map[string]mountsync.TreeResponse{
Expand All @@ -884,7 +890,7 @@ func TestLazyMaterializeNoOpWhenRemoteDoesNotImplement(t *testing.T) {
},
},
}
root := newMountTestRoot(t, remote, "ws_lazy_noop")
root := newLazyMountTestRoot(t, remote, "ws_lazy_noop")
repo := lookupDir(t, lookupDir(t, lookupDir(t, lookupDir(t, root, "github"), "repos"), "octocat"), "hello-world")

if errno := repo.Getattr(context.Background(), nil, &fuse.AttrOut{}); errno != 0 {
Expand All @@ -896,7 +902,6 @@ func TestLazyMaterializeNoOpWhenRemoteDoesNotImplement(t *testing.T) {
}

func TestLazyMaterializeAllowsEmptyRepoTree(t *testing.T) {
t.Setenv("RELAYFILE_MOUNT_LAZY_GITHUB_REPOS", "true")

remote := &fakeLazyRemoteClient{
fakeRemoteClient: &fakeRemoteClient{},
Expand Down Expand Up @@ -926,7 +931,7 @@ func TestLazyMaterializeAllowsEmptyRepoTree(t *testing.T) {
return mountsync.TreeResponse{}, &mountsync.HTTPError{StatusCode: 404, Code: "not_found", Message: "tree not found"}
}

root := newMountTestRoot(t, remote, "ws_lazy_empty_repo")
root := newLazyMountTestRoot(t, remote, "ws_lazy_empty_repo")
repo := lookupDir(t, lookupDir(t, lookupDir(t, lookupDir(t, root, "github"), "repos"), "octocat"), "empty-repo")

if errno := repo.Getattr(context.Background(), nil, &fuse.AttrOut{}); errno != 0 {
Expand Down Expand Up @@ -1030,8 +1035,8 @@ func TestByStateOutsideIssuesPathRoundTrips(t *testing.T) {

remote := &fakeRemoteClient{
trees: map[string]mountsync.TreeResponse{
"/": {Path: "/", Entries: []mountsync.TreeEntry{{Path: "/notion", Type: "directory"}}},
"/notion": {Path: "/notion", Entries: []mountsync.TreeEntry{{Path: "/notion/by-state", Type: "directory"}}},
"/": {Path: "/", Entries: []mountsync.TreeEntry{{Path: "/notion", Type: "directory"}}},
"/notion": {Path: "/notion", Entries: []mountsync.TreeEntry{{Path: "/notion/by-state", Type: "directory"}}},
"/notion/by-state": {
Path: "/notion/by-state",
Entries: []mountsync.TreeEntry{
Expand Down Expand Up @@ -1073,6 +1078,17 @@ func newMountTestRoot(t *testing.T, remote mountsync.RemoteClient, workspaceID s
return root
}

func newLazyMountTestRoot(t *testing.T, remote mountsync.RemoteClient, workspaceID string) *DirNode {
t.Helper()

root, err := New(Config{Client: remote, WorkspaceID: workspaceID, RemoteRoot: "/", LazyRepos: true})
if err != nil {
t.Fatalf("New() failed: %v", err)
}
_ = gofusefs.NewNodeFS(root, &gofusefs.Options{})
return root
}

func newLazyGithubRepoRemote() *fakeLazyRemoteClient {
remote := &fakeLazyRemoteClient{
fakeRemoteClient: &fakeRemoteClient{
Expand Down
2 changes: 1 addition & 1 deletion internal/mountfuse/layout.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ Entity files use the ` + "`<sanitized-name>__<id>`" + ` filename convention. Rec

## Lazy materialization

When lazy mode is enabled, the ` + "`github/repos/<owner>/<repo>`" + ` subtree is populated on first read via ` + "`LazyMaterialize`" + `. The first stat or directory read may incur one-time latency while the repo content is materialized.
GitHub repo subtrees are synced eagerly by default. For huge-org workspaces, opt in to lazy mode with ` + "`--lazy-repos`" + ` or ` + "`RELAYFILE_LAZY_REPOS=true`" + ` to populate ` + "`github/repos/<owner>/<repo>`" + ` on first read via ` + "`LazyMaterialize`" + `. The first stat or directory read may incur one-time latency while the repo content is materialized.

## Integration-specific layouts

Expand Down
15 changes: 12 additions & 3 deletions internal/mountsync/syncer.go
Original file line number Diff line number Diff line change
Expand Up @@ -424,7 +424,8 @@ type SyncerOptions struct {
// the default (defaultFullPullEvery, ~10 min at 30s intervals). A
// negative value disables the periodic full pull entirely.
FullPullEvery int
LazyRepos bool
// LazyRepos controls lazy GitHub repo subtree hydration. nil falls back to env.
LazyRepos *bool
}

type Logger interface {
Expand Down Expand Up @@ -629,8 +630,16 @@ func NewSyncer(client RemoteClient, opts SyncerOptions) (*Syncer, error) {
fullPullEvery = defaultFullPullEvery
}
}
lazyRepos := opts.LazyRepos
if raw := strings.TrimSpace(os.Getenv("RELAYFILE_MOUNT_LAZY_GITHUB_REPOS")); raw != "" {
lazyRepos := false
if opts.LazyRepos != nil {
lazyRepos = *opts.LazyRepos
} else if raw := strings.TrimSpace(os.Getenv("RELAYFILE_LAZY_REPOS")); raw != "" {
if parsed, perr := strconv.ParseBool(raw); perr == nil {
lazyRepos = parsed
} else if opts.Logger != nil {
opts.Logger.Printf("ignoring invalid RELAYFILE_LAZY_REPOS=%q: %v", raw, perr)
}
} else if raw := strings.TrimSpace(os.Getenv("RELAYFILE_MOUNT_LAZY_GITHUB_REPOS")); raw != "" {
if parsed, perr := strconv.ParseBool(raw); perr == nil {
lazyRepos = parsed
} else if opts.Logger != nil {
Expand Down
Loading
Loading