diff --git a/.design/project-log/2026-06-03-fix-workspace-file-browser-path.md b/.design/project-log/2026-06-03-fix-workspace-file-browser-path.md new file mode 100644 index 000000000..8a8458775 --- /dev/null +++ b/.design/project-log/2026-06-03-fix-workspace-file-browser-path.md @@ -0,0 +1,34 @@ +# Fix: Workspace file browser path resolution (Issue #130) + +**Date:** 2026-06-03 +**PR:** #132 +**Issue:** #130 + +## Problem + +The Hub UI workspace file browser was showing the wrong directory contents. The `hubManagedProjectPath()` function resolved workspace paths to `~/.scion/projects//` instead of `~/.scion/groves//`. + +The three relevant directories per project: +1. `~/.scion/groves//` — actual git checkout, mounted as `/workspace` in agents (correct target) +2. `~/.scion/projects//` — project metadata + Telegram plugin downloads (what the UI was showing) +3. `~/.scion/grove-configs/__/` — agent configs and shared-dirs + +## Root Cause + +`hubManagedProjectPath()` checked `projects/` first, fell back to `groves/`, and defaulted to `projects/`. This was backwards — the git checkout (what agents actually work in) lives under `groves/`. + +## Fix + +Reversed the lookup priority in `hubManagedProjectPath()`: +1. Check `groves/` first (preferred — actual workspace) +2. Fall back to `projects/` (backward compatibility) +3. Default to `groves/` when neither has content + +## Files Changed + +- `pkg/hub/handlers.go` — reversed path resolution priority +- `pkg/hub/handlers_project_test.go` — updated existing test, added 3 new test cases + +## Observations + +- The `pkg/config` test suite has a pre-existing failure (`TestEnsureHubReady_GlobalFallbackWithHubEnabled`) caused by leaked `SCION_*` environment variables in the container. This is unrelated to this change and passes when those env vars are cleared. diff --git a/pkg/hub/handlers.go b/pkg/hub/handlers.go index 4e1b45067..16bbf51b9 100644 --- a/pkg/hub/handlers.go +++ b/pkg/hub/handlers.go @@ -3763,21 +3763,26 @@ func (s *Server) createProjectMembersGroupAndPolicy(ctx context.Context, project } // hubManagedProjectPath returns the filesystem path for a hub-managed project workspace. +// It prefers groves/ (the actual git checkout mounted as /workspace in agents) +// over projects/ (which only contains project metadata). func hubManagedProjectPath(slug string) (string, error) { + if slug == "" { + return "", fmt.Errorf("project slug must not be empty") + } globalDir, err := config.GetGlobalDir() if err != nil { return "", fmt.Errorf("failed to get global dir: %w", err) } - newPath := filepath.Join(globalDir, "projects", slug) - if hasWorkspaceContent(newPath) { - return newPath, nil + grovesPath := filepath.Join(globalDir, "groves", slug) + if hasWorkspaceContent(grovesPath) { + return grovesPath, nil } - oldPath := filepath.Join(globalDir, "groves", slug) - if hasWorkspaceContent(oldPath) { - return oldPath, nil + projectsPath := filepath.Join(globalDir, "projects", slug) + if hasWorkspaceContent(projectsPath) { + return projectsPath, nil } - // Neither has content — return new path (will be created on demand) - return newPath, nil + // Neither has content — return groves path (will be created on demand) + return grovesPath, nil } // hasWorkspaceContent returns true if dir exists and contains meaningful diff --git a/pkg/hub/handlers_project_test.go b/pkg/hub/handlers_project_test.go index 2663f605f..2b86e4448 100644 --- a/pkg/hub/handlers_project_test.go +++ b/pkg/hub/handlers_project_test.go @@ -44,32 +44,83 @@ func TestHubManagedProjectPath(t *testing.T) { homeDir, err := os.UserHomeDir() require.NoError(t, err) - expected := filepath.Join(homeDir, ".scion", "projects", "my-test-project") + // Default (no content in either dir) should resolve to groves/ — + // that's where the actual git checkout lives (mounted as /workspace in agents). + expected := filepath.Join(homeDir, ".scion", "groves", "my-test-project") assert.Equal(t, expected, path) } -func TestHubManagedProjectPath_EmptyProjectsFallsBackToGroves(t *testing.T) { +func TestHubManagedProjectPath_PrefersGrovesOverProjects(t *testing.T) { // Use a temp directory as HOME to avoid polluting real ~/.scion tmpHome := t.TempDir() t.Setenv("HOME", tmpHome) - slug := "empty-projects-grove" + slug := "both-dirs-exist" globalDir := filepath.Join(tmpHome, ".scion") - // Create projects/{slug} with only infrastructure dirs (no real content) + // Create both directories with workspace content projectsDir := filepath.Join(globalDir, "projects", slug) - require.NoError(t, os.MkdirAll(filepath.Join(projectsDir, "shared-dirs"), 0755)) - require.NoError(t, os.MkdirAll(filepath.Join(projectsDir, ".scion"), 0755)) + require.NoError(t, os.MkdirAll(projectsDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(projectsDir, "metadata.json"), []byte("{}"), 0644)) - // Create groves/{slug} with actual workspace content grovesDir := filepath.Join(globalDir, "groves", slug) require.NoError(t, os.MkdirAll(grovesDir, 0755)) require.NoError(t, os.WriteFile(filepath.Join(grovesDir, "README.md"), []byte("# workspace"), 0644)) - // hubManagedProjectPath should fall back to groves/ since projects/ has no real content + // hubManagedProjectPath should prefer groves/ — that's the actual git checkout + path, err := hubManagedProjectPath(slug) + require.NoError(t, err) + assert.Equal(t, grovesDir, path, "should prefer groves path over projects path") +} + +func TestHubManagedProjectPath_FallsBackToProjectsWhenGrovesEmpty(t *testing.T) { + // Use a temp directory as HOME to avoid polluting real ~/.scion + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + slug := "groves-empty-projects-has-content" + globalDir := filepath.Join(tmpHome, ".scion") + + // Create groves/{slug} with only infrastructure dirs (no real content) + grovesDir := filepath.Join(globalDir, "groves", slug) + require.NoError(t, os.MkdirAll(filepath.Join(grovesDir, ".scion"), 0755)) + + // Create projects/{slug} with actual workspace content + projectsDir := filepath.Join(globalDir, "projects", slug) + require.NoError(t, os.MkdirAll(projectsDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(projectsDir, "README.md"), []byte("# workspace"), 0644)) + + // hubManagedProjectPath should fall back to projects/ since groves/ has no real content + path, err := hubManagedProjectPath(slug) + require.NoError(t, err) + assert.Equal(t, projectsDir, path, "should fall back to projects path when groves dir only contains infrastructure dirs") +} + +func TestHubManagedProjectPath_DefaultsToGrovesWhenNeitherHasContent(t *testing.T) { + // Use a temp directory as HOME to avoid polluting real ~/.scion + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + slug := "neither-has-content" + globalDir := filepath.Join(tmpHome, ".scion") + + // Create both directories with only infrastructure dirs + grovesDir := filepath.Join(globalDir, "groves", slug) + require.NoError(t, os.MkdirAll(filepath.Join(grovesDir, ".scion"), 0755)) + + projectsDir := filepath.Join(globalDir, "projects", slug) + require.NoError(t, os.MkdirAll(filepath.Join(projectsDir, "shared-dirs"), 0755)) + + // When neither has content, should default to groves/ path, err := hubManagedProjectPath(slug) require.NoError(t, err) - assert.Equal(t, grovesDir, path, "should fall back to groves path when projects dir only contains infrastructure dirs") + assert.Equal(t, grovesDir, path, "should default to groves path when neither dir has workspace content") +} + +func TestHubManagedProjectPath_EmptySlug(t *testing.T) { + _, err := hubManagedProjectPath("") + require.Error(t, err, "empty slug should return an error") + assert.Contains(t, err.Error(), "slug must not be empty") } func TestCreateProject_HubManaged_NoGitRemote(t *testing.T) {