diff --git a/desktop/app.go b/desktop/app.go index 53a5a15814..2acb32ac49 100644 --- a/desktop/app.go +++ b/desktop/app.go @@ -4820,7 +4820,7 @@ func migrateLegacyWorkspacesIntoProjects() { if len(legacy) == 0 { return } - _ = updateProjectsFile(func(f *desktopProjectFile) (bool, error) { + _ = updateProjectsFilePreservingLegacyState(func(f *desktopProjectFile) (bool, error) { seen := make(map[string]bool, len(f.Projects)+len(legacy)) for _, p := range f.Projects { seen[p.Root] = true diff --git a/desktop/desktop_project_file_lock.go b/desktop/desktop_project_file_lock.go index 643a91deac..159a0437c7 100644 --- a/desktop/desktop_project_file_lock.go +++ b/desktop/desktop_project_file_lock.go @@ -2,6 +2,7 @@ package main import ( "context" + "fmt" "os" "path/filepath" "time" @@ -23,8 +24,12 @@ func acquireDesktopProjectsFileLock() (func(), error) { // updateProjectsFileCrossProcessLocked requires desktopProjectsFileMu and the // desktop-projects cross-process file lock. -func updateProjectsFileCrossProcessLocked(mutator func(*desktopProjectFile) (bool, error)) error { +func updateProjectsFileCrossProcessLocked(mutator func(*desktopProjectFile) (bool, error), assignCollisions bool) error { f := loadProjectsFile() + previous := make([]string, 0, len(f.Projects)) + for _, project := range f.Projects { + previous = append(previous, project.Root) + } changed, err := mutator(&f) if err != nil { return err @@ -32,5 +37,10 @@ func updateProjectsFileCrossProcessLocked(mutator func(*desktopProjectFile) (boo if !changed { return nil } + if assignCollisions { + if err := assignAddedProjectStateCollisions(previous, f.Projects); err != nil { + return fmt.Errorf("%w: %w", errProjectStateCollisionAssignment, err) + } + } return saveProjectsFile(f) } diff --git a/desktop/fork_tab.go b/desktop/fork_tab.go index a75c5d2707..01e27dbcfa 100644 --- a/desktop/fork_tab.go +++ b/desktop/fork_tab.go @@ -281,7 +281,9 @@ func (a *App) openForkedSessionTabWithWorkspace(sourceTab *WorkspaceTab, locator if err := prependTopicInProjectsFile(workspaceRoot, topicID, true); err != nil { slog.Warn("desktop: persist isolated fork topic", "workspace", workspaceRoot, "topic", topicID, "err", err) } - a.registerProjectRoot(workspaceRoot) + if err := a.registerProjectRoot(workspaceRoot); err != nil { + return forkedSessionTabOpen{}, err + } } if hook := forkTabBeforePublishHookForTest.Load(); hook != nil { (*hook)() diff --git a/desktop/project_root_registration.go b/desktop/project_root_registration.go index eb2bd029ff..f30a6cddf4 100644 --- a/desktop/project_root_registration.go +++ b/desktop/project_root_registration.go @@ -1,19 +1,36 @@ package main +import "errors" + // registerProjectRoot indexes workspaceRoot, realigns open tabs to its // canonical spelling, and discovers existing sessions once per process. -func (a *App) registerProjectRoot(workspaceRoot string) { - _ = addProject(workspaceRoot, "") +func (a *App) registerProjectRoot(workspaceRoot string) error { + if err := addProject(workspaceRoot, ""); errors.Is(err, errProjectStateCollisionAssignment) { + return err + } a.syncTabWorkspaceRootSpellings() root := normalizeProjectRoot(workspaceRoot) if root == "" { - return + return nil } registrationKey := projectRootKey(root) if _, loaded := a.catalogRegisteredProjectRoots.LoadOrStore(registrationKey, struct{}{}); loaded { - return + return nil } if !a.requestSessionCatalogReconcile(desktopSessionDir(root)) { a.catalogRegisteredProjectRoots.Delete(registrationKey) } + return nil +} + +func (a *App) beginRegisteredProjectRuntimeAdmission(tab *WorkspaceTab, scope, workspaceRoot string) (func(), error) { + release, err := a.beginChangedProjectRuntimeAdmission(tab, scope, workspaceRoot) + if err != nil { + return nil, err + } + if err := a.registerProjectRoot(workspaceRoot); err != nil { + release() + return nil, err + } + return release, nil } diff --git a/desktop/project_state_collision.go b/desktop/project_state_collision.go new file mode 100644 index 0000000000..e50b79a319 --- /dev/null +++ b/desktop/project_state_collision.go @@ -0,0 +1,44 @@ +package main + +import ( + "errors" + "reasonix/internal/config" +) + +var errProjectStateCollisionAssignment = errors.New("project state collision assignment") + +// assignAddedProjectStateCollisions assigns a new root only when a recorded +// project still uses the same legacy directory. Recorded roots and existing +// assignments keep their current directory. +func assignAddedProjectStateCollisions(previous []string, projects []desktopProject) error { + known := append([]string(nil), previous...) + userDir := config.MemoryUserDir() + for _, project := range projects { + root := normalizeProjectRoot(project.Root) + if root == "" { + continue + } + alreadyKnown := false + collision := false + for _, prior := range known { + if sameProjectRoot(prior, root) { + alreadyKnown = true + break + } + if config.WorkspaceSlug(prior) == config.WorkspaceSlug(root) && + config.ProjectStateDir(userDir, prior) == config.ProjectStateDir(userDir, root) { + collision = true + } + } + if alreadyKnown { + continue + } + if collision { + if err := config.AssignProjectStateCollision(userDir, root); err != nil { + return err + } + } + known = append(known, root) + } + return nil +} diff --git a/desktop/project_state_collision_test.go b/desktop/project_state_collision_test.go new file mode 100644 index 0000000000..a967a9acbf --- /dev/null +++ b/desktop/project_state_collision_test.go @@ -0,0 +1,215 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "testing" + + "reasonix/internal/config" + "reasonix/internal/memory" + "reasonix/internal/sessioncatalog" +) + +func TestNewCollidingProjectGetsOwnStateWithoutMovingExistingProject(t *testing.T) { + t.Setenv("REASONIX_HOME", t.TempDir()) + root := t.TempDir() + first := filepath.Join(root, "front-end", "app") + second := filepath.Join(root, "front", "end-app") + plain := filepath.Join(root, "backend") + for _, path := range []string{first, second, plain} { + if err := os.MkdirAll(path, 0o700); err != nil { + t.Fatal(err) + } + } + if err := addProject(first, ""); err != nil { + t.Fatal(err) + } + firstDir := config.ProjectSessionDir(first) + if err := os.MkdirAll(firstDir, 0o700); err != nil { + t.Fatal(err) + } + oldSession := filepath.Join(firstDir, "existing.jsonl") + if err := os.WriteFile(oldSession, []byte("existing session\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := addProject(second, ""); err != nil { + t.Fatal(err) + } + secondDir := config.ProjectSessionDir(second) + if secondDir == firstDir { + t.Fatalf("new project still shares %q", firstDir) + } + if got := config.ProjectSessionDir(first); got != firstDir { + t.Fatalf("existing project moved to %q", got) + } + if got, err := os.ReadFile(oldSession); err != nil || string(got) != "existing session\n" { + t.Fatalf("existing session changed: %q, %v", got, err) + } + if err := removeProject(first); err != nil { + t.Fatal(err) + } + if err := addProject(first, ""); err != nil { + t.Fatal(err) + } + if got := config.ProjectSessionDir(first); got != firstDir { + t.Fatalf("re-added original project moved to %q, want %q", got, firstDir) + } + if _, err := os.Stat(filepath.Join(secondDir, "existing.jsonl")); !os.IsNotExist(err) { + t.Fatalf("new project sees existing session: %v", err) + } + if err := addProject(plain, ""); err != nil { + t.Fatal(err) + } + wantPlain := filepath.Join(config.MemoryUserDir(), "projects", config.WorkspaceSlug(plain), "sessions") + if got := config.ProjectSessionDir(plain); got != wantPlain { + t.Fatalf("non-colliding project moved to %q, want %q", got, wantPlain) + } + if err := removeProject(second); err != nil { + t.Fatal(err) + } + if err := addProject(second, ""); err != nil { + t.Fatal(err) + } + if got := config.ProjectSessionDir(second); got != secondDir { + t.Fatalf("re-added project moved to %q, want %q", got, secondDir) + } + if got := config.ProjectSessionStoreDir(first); got == config.ProjectSessionStoreDir(second) { + t.Fatalf("canonical session stores overlap: %q", got) + } + if got := config.DesktopTopicStatePath(first); got == config.DesktopTopicStatePath(second) { + t.Fatalf("topic stores overlap: %q", got) + } + if got := memory.StoreFor(config.MemoryUserDir(), first).Dir; got == memory.StoreFor(config.MemoryUserDir(), second).Dir { + t.Fatalf("project memory stores overlap: %q", got) + } + if err := os.MkdirAll(secondDir, 0o700); err != nil { + t.Fatal(err) + } + firstSession := writeTopicSession(t, firstDir, "first.jsonl", "first-topic", "First", first) + secondSession := writeTopicSession(t, secondDir, "second.jsonl", "second-topic", "Second", second) + app := NewApp() + installSessionCatalogForTest(t, app, firstDir, "project", first) + reconcileSessionCatalogForTest(t, app, secondDir, "project", second) + for _, tc := range []struct{ root, dir, want, other string }{ + {first, firstDir, firstSession, secondSession}, + {second, secondDir, secondSession, firstSession}, + } { + page, err := app.sessionCatalog.Load().ListSessions(context.Background(), sessioncatalog.SessionPageRequest{ + Scope: "project", WorkspaceRoot: tc.root, Directory: tc.dir, Limit: 20, + }) + if err != nil { + t.Fatal(err) + } + found := false + for _, item := range page.Items { + if item.Path == tc.other { + t.Fatalf("project %q lists another project's session %q", tc.root, tc.other) + } + found = found || item.Path == tc.want + } + if !found { + t.Fatalf("project %q did not list its session %q: %+v", tc.root, tc.want, page.Items) + } + } +} + +func TestLegacyProjectImportsKeepExistingCollisionState(t *testing.T) { + for _, source := range []string{"workspace-list", "sidebar-recovery"} { + t.Run(source, func(t *testing.T) { + t.Setenv("REASONIX_HOME", t.TempDir()) + root := t.TempDir() + first := filepath.Join(root, "front-end", "app") + second := filepath.Join(root, "front", "end-app") + for _, path := range []string{first, second} { + if err := os.MkdirAll(path, 0o700); err != nil { + t.Fatal(err) + } + } + if err := addProject(first, ""); err != nil { + t.Fatal(err) + } + legacyDir := config.ProjectSessionDir(second) + if err := os.MkdirAll(legacyDir, 0o700); err != nil { + t.Fatal(err) + } + oldSession := filepath.Join(legacyDir, "legacy.jsonl") + if err := os.WriteFile(oldSession, []byte("legacy session\n"), 0o600); err != nil { + t.Fatal(err) + } + switch source { + case "workspace-list": + rememberWorkspace(second) + migrateLegacyWorkspacesIntoProjects() + case "sidebar-recovery": + if _, err := recoverLegacyProjectSidebarRoots(desktopTabsFile{Tabs: []desktopTabEntry{{Scope: "project", WorkspaceRoot: second}}}); err != nil { + t.Fatal(err) + } + } + if got := config.ProjectSessionDir(second); got != legacyDir { + t.Fatalf("imported project moved to %q, want %q", got, legacyDir) + } + if got, err := os.ReadFile(oldSession); err != nil || string(got) != "legacy session\n" { + t.Fatalf("imported session changed: %q, %v", got, err) + } + }) + } +} + +func TestProjectRegistrationKeepsOpeningOnProjectListLockFailure(t *testing.T) { + t.Setenv("REASONIX_HOME", t.TempDir()) + root := t.TempDir() + lockPath := filepath.Join(desktopConfigDir(), desktopProjectsFile+".lock") + if err := os.MkdirAll(lockPath, 0o700); err != nil { + t.Fatal(err) + } + app := NewApp() + if err := app.registerProjectRoot(root); err != nil { + t.Fatalf("project list lock failure blocked opening: %v", err) + } +} + +func TestCollidingProjectRegistrationFailsBeforeUsingUnownedState(t *testing.T) { + t.Setenv("REASONIX_HOME", t.TempDir()) + root := t.TempDir() + first := filepath.Join(root, "front-end", "app") + second := filepath.Join(root, "front", "end-app") + if err := addProject(first, ""); err != nil { + t.Fatal(err) + } + if err := config.AssignProjectStateCollision(config.MemoryUserDir(), second); err != nil { + t.Fatal(err) + } + assigned := config.ProjectStateDir(config.MemoryUserDir(), second) + if err := os.Remove(filepath.Join(assigned, ".workspace-root")); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(assigned, "orphan"), []byte("keep"), 0o600); err != nil { + t.Fatal(err) + } + if err := addProject(second, ""); err == nil { + t.Fatal("registration accepted an unowned, nonempty assigned directory") + } + if len(loadProjectsFile().Projects) != 1 { + t.Fatal("failed registration changed the saved project list") + } +} + +func TestPreviouslyRecordedCollisionKeepsBothLegacyPaths(t *testing.T) { + t.Setenv("REASONIX_HOME", t.TempDir()) + root := t.TempDir() + first := filepath.Join(root, "front-end", "app") + second := filepath.Join(root, "front", "end-app") + if err := saveProjectsFile(desktopProjectFile{Projects: []desktopProject{{Root: first}, {Root: second}}}); err != nil { + t.Fatal(err) + } + legacy := config.ProjectSessionDir(first) + for _, path := range []string{first, second} { + if err := addProject(path, ""); err != nil { + t.Fatal(err) + } + if got := config.ProjectSessionDir(path); got != legacy { + t.Fatalf("previously recorded project %q moved to %q", path, got) + } + } +} diff --git a/desktop/session_open_surface.go b/desktop/session_open_surface.go index 71b9a0b063..d30f89ad17 100644 --- a/desktop/session_open_surface.go +++ b/desktop/session_open_surface.go @@ -64,7 +64,9 @@ func (a *App) surfaceForCanonicalSession(ref session.SessionRef, workspace works defer releaseAdmission() if scope == "project" { saveWorkspace(workspaceRoot) - a.registerProjectRoot(workspaceRoot) + if err := a.registerProjectRoot(workspaceRoot); err != nil { + return nil, nil, false, err + } } model, toolApprovalMode := desktopNewSessionDefaults(scope, actualRoot) tab := &WorkspaceTab{ diff --git a/desktop/tabs.go b/desktop/tabs.go index 918275d4d6..c532140133 100644 --- a/desktop/tabs.go +++ b/desktop/tabs.go @@ -2369,7 +2369,9 @@ func (a *App) openTopicTabWithHead(scope, workspaceRoot, topicID, sessionPath, h defer releaseAdmission() if strings.TrimSpace(scope) == "project" { saveWorkspace(actualRoot) - a.registerProjectRoot(actualRoot) + if err := a.registerProjectRoot(actualRoot); err != nil { + return TabMeta{}, err + } } targetKey := sessionRuntimeKey(sessionPath) @@ -2665,7 +2667,9 @@ func (a *App) ensureBlankTab(scope, workspaceRoot string) (TabMeta, error) { defer releaseAdmission() if scope == "project" { saveWorkspace(workspaceRoot) - a.registerProjectRoot(workspaceRoot) + if err := a.registerProjectRoot(workspaceRoot); err != nil { + return TabMeta{}, err + } } defaultModel, defaultToolApprovalMode := desktopNewSessionDefaults(scope, actualRoot) @@ -4037,12 +4041,11 @@ func (a *App) applySessionBindingToTab(tab *WorkspaceTab, binding sessionBinding if workspaceRoot == "" { return } - releaseAdmission, err := a.beginChangedProjectRuntimeAdmission(tab, scope, workspaceRoot) + releaseAdmission, err := a.beginRegisteredProjectRuntimeAdmission(tab, scope, workspaceRoot) if err != nil { return } defer releaseAdmission() - a.registerProjectRoot(workspaceRoot) } else { scope = "global" workspaceRoot = globalTabWorkspaceRoot() @@ -4829,7 +4832,7 @@ func recoverLegacyProjectSidebarRoots(tabs desktopTabsFile) (bool, error) { } changed := false - err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) { + err := updateProjectsFilePreservingLegacyState(func(f *desktopProjectFile) (bool, error) { seen := map[string]bool{} for _, project := range f.Projects { root := normalizeProjectRoot(project.Root) @@ -4921,18 +4924,26 @@ func saveProjectsFile(f desktopProjectFile) error { } func updateProjectsFile(mutator func(*desktopProjectFile) (bool, error)) error { - desktopProjectsFileMu.Lock() - defer desktopProjectsFileMu.Unlock() - return updateProjectsFileLocked(mutator) + return updateProjectsFileWithCollisionAssignment(mutator, true) } -func updateProjectsFileLocked(mutator func(*desktopProjectFile) (bool, error)) error { +func updateProjectsFilePreservingLegacyState(mutator func(*desktopProjectFile) (bool, error)) error { + return updateProjectsFileWithCollisionAssignment(mutator, false) +} + +func updateProjectsFileWithCollisionAssignment(mutator func(*desktopProjectFile) (bool, error), assignCollisions bool) error { + desktopProjectsFileMu.Lock() + defer desktopProjectsFileMu.Unlock() + return updateProjectsFileLockedWithCollisionAssignment(mutator, assignCollisions) +} + +func updateProjectsFileLockedWithCollisionAssignment(mutator func(*desktopProjectFile) (bool, error), assignCollisions bool) error { release, err := acquireDesktopProjectsFileLock() if err != nil { return err } defer release() - return updateProjectsFileCrossProcessLocked(mutator) + return updateProjectsFileCrossProcessLocked(mutator, assignCollisions) } func prependTopicInProjectsFile(workspaceRoot, topicID string, ensureProject bool) error { @@ -5062,7 +5073,7 @@ func removeTopicFromProjectsFileCrossProcessLocked(topicID string) error { } } return changed, nil - }) + }, true) } func normalizeProjectRoot(root string) string { diff --git a/docs/CONFIG_PATHS.md b/docs/CONFIG_PATHS.md index 34cd097a55..b358cf002c 100644 --- a/docs/CONFIG_PATHS.md +++ b/docs/CONFIG_PATHS.md @@ -52,6 +52,23 @@ non-destructively when `/.env` is missing them. `` defaults to ``. It only differs when `REASONIX_STATE_HOME` is set. +Desktop detects a project-directory name collision when it saves a newly added +project in `desktop-projects.json`. A new assignment is made only when another +recorded project still resolves to the same legacy directory. Existing projects +and projects imported from older workspace records keep their current +`/projects//` directory. Re-adding the original +project after its colliding peer was assigned elsewhere also keeps that legacy +directory. Only a newly added project that meets the collision rule uses +`/projects/@/`; its `.workspace-root` +file records the assignment. Session, topic, and project-memory paths follow +that assignment. Listing a project never creates or changes an assignment, and +existing files are never moved. If two projects were already recorded with the +same slug before this fix, their historical shared files remain in place: the +old directory does not identify which project owns each file. +Studio currently resolves only `/projects//` and does +not read `.workspace-root`; a newly assigned project's state is therefore not +shared with Studio until Studio supports these assignments. + Desktop topic titles, title sources, creation times, and automatic-title state are authoritative in these SQLite files. On first access, Desktop imports the legacy `desktop-topic-*.json` files from a project's `.reasonix/` directory (or diff --git a/docs/CONFIG_PATHS.zh-CN.md b/docs/CONFIG_PATHS.zh-CN.md index 3d439ae83f..a450d9fa01 100644 --- a/docs/CONFIG_PATHS.zh-CN.md +++ b/docs/CONFIG_PATHS.zh-CN.md @@ -43,6 +43,18 @@ Legacy 迁移、OS home 约定目录扫描以及其他 fallback 路径都会跳 `` 默认等于 ``;只有设置 `REASONIX_STATE_HOME` 时才会不同。 +Desktop 在将新项目写入 `desktop-projects.json` 时检测项目目录名冲突。只有另一个已登记 +项目仍实际指向同一个旧目录时,才给新项目分配目录。已有项目和从旧工作区记录导入的 +项目都继续使用原来的 `/projects//` 目录;原项目在冲突 +项目分配到别处后移除并重新加入,也仍使用该旧目录。只有满足上述冲突条件的新项目使用 +`/projects/@<项目绝对路径的 SHA-256>/`;其中的 +`.workspace-root` 文件记录目录归属。会话、Topic 和项目记忆路径都遵循该归属记录。 +读取项目列表不会创建或修改归属记录,也不会移动已有文件。如果两个项目在此修复之前就 +已经以同一个 slug 登记,其历史共享文件仍保留在原目录,因为旧目录无法证明每个文件的 +所属项目。 +Studio 目前只解析 `/projects//`,不会读取 `.workspace-root`; +因此新分配目录的项目状态暂时不会与 Studio 共享,直到 Studio 支持此归属记录。 + Desktop Topic 的标题、标题来源、创建时间和自动标题状态以这些 SQLite 文件为权威存储。 首次访问时,Desktop 会导入项目 `.reasonix/` 目录(或全局 Reasonix 目录)中的旧 `desktop-topic-*.json`。检测到旧文件的 scope 会继续镜像旧格式以支持降级;全新 scope diff --git a/internal/config/paths.go b/internal/config/paths.go index a53529d763..55e2006de8 100644 --- a/internal/config/paths.go +++ b/internal/config/paths.go @@ -512,8 +512,9 @@ func StatsDir() string { } // ProjectSessionDir is the per-workspace session directory the desktop sidebar -// lists: /projects//sessions. Empty when either the state root -// or workspaceRoot doesn't resolve. +// lists. Existing projects use /projects//sessions; newly +// registered slug collisions use their assigned directory. Empty when either +// the state root or workspaceRoot doesn't resolve. func ProjectSessionDir(workspaceRoot string) string { base := MemoryUserDir() root := strings.TrimSpace(workspaceRoot) @@ -523,7 +524,7 @@ func ProjectSessionDir(workspaceRoot string) string { if abs, err := filepath.Abs(root); err == nil { root = abs } - return filepath.Join(base, "projects", WorkspaceSlug(root), "sessions") + return filepath.Join(ProjectStateDir(base, root), "sessions") } // ProjectSessionStoreDir is the per-workspace execution-v2 session root. @@ -536,7 +537,7 @@ func ProjectSessionStoreDir(workspaceRoot string) string { if abs, err := filepath.Abs(root); err == nil { root = abs } - return filepath.Join(base, "projects", WorkspaceSlug(root), "sessions-v4") + return filepath.Join(ProjectStateDir(base, root), "sessions-v4") } // DesktopTopicStatePath returns the authoritative SQLite path for Desktop @@ -554,7 +555,7 @@ func DesktopTopicStatePath(workspaceRoot string) string { if abs, err := filepath.Abs(root); err == nil { root = abs } - return filepath.Join(base, "projects", WorkspaceSlug(root), "desktop", "topic-state-v1.sqlite") + return filepath.Join(ProjectStateDir(base, root), "desktop", "topic-state-v1.sqlite") } // WorkspaceSlug flattens an absolute workspace path into the directory name diff --git a/internal/config/project_state_dir.go b/internal/config/project_state_dir.go new file mode 100644 index 0000000000..1d4885738c --- /dev/null +++ b/internal/config/project_state_dir.go @@ -0,0 +1,86 @@ +package config + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strings" + + "reasonix/internal/fileutil" +) + +const projectCollisionMarker = ".workspace-root" + +func projectStateRoot(workspaceRoot string) string { + root := strings.TrimSpace(workspaceRoot) + if root == "" { + return "" + } + if abs, err := filepath.Abs(root); err == nil { + root = abs + } + if runtimeGOOS == "windows" { + root = strings.ToLower(root) + } + return root +} + +func collisionProjectDir(userDir, root string) string { + sum := sha256.Sum256([]byte(root)) + return filepath.Join(userDir, "projects", "@"+hex.EncodeToString(sum[:])) +} + +// ProjectStateDir preserves the historical directory unless an explicit +// collision assignment was recorded when a project was added. Reads never +// claim a legacy directory or move existing state. +func ProjectStateDir(userDir, workspaceRoot string) string { + root := projectStateRoot(workspaceRoot) + if userDir == "" || root == "" { + return "" + } + legacy := filepath.Join(userDir, "projects", WorkspaceSlug(root)) + assigned := collisionProjectDir(userDir, root) + owner, err := os.ReadFile(filepath.Join(assigned, projectCollisionMarker)) + if err == nil && string(owner) == root+"\n" { + return assigned + } + return legacy +} + +// AssignProjectStateCollision records a new project's alternate directory. +// Callers must first establish a slug collision from their saved project list; +// this write is never made by ProjectStateDir or a read-only listing. +func AssignProjectStateCollision(userDir, workspaceRoot string) error { + root := projectStateRoot(workspaceRoot) + if userDir == "" || root == "" { + return fmt.Errorf("project state collision: missing state or workspace root") + } + dir := collisionProjectDir(userDir, root) + marker := filepath.Join(dir, projectCollisionMarker) + owner, err := os.ReadFile(marker) + if err == nil { + if string(owner) == root+"\n" { + return nil + } + return fmt.Errorf("project state collision: %s belongs to another workspace", dir) + } + if !os.IsNotExist(err) { + return fmt.Errorf("project state collision: read assignment: %w", err) + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("project state collision: create directory: %w", err) + } + entries, err := os.ReadDir(dir) + if err != nil { + return fmt.Errorf("project state collision: inspect directory: %w", err) + } + if len(entries) != 0 { + return fmt.Errorf("project state collision: unowned directory %s is not empty", dir) + } + if err := fileutil.AtomicWriteFileStrict(marker, []byte(root+"\n"), 0o600); err != nil { + return fmt.Errorf("project state collision: record assignment: %w", err) + } + return nil +} diff --git a/internal/config/project_state_dir_test.go b/internal/config/project_state_dir_test.go new file mode 100644 index 0000000000..3dd4844194 --- /dev/null +++ b/internal/config/project_state_dir_test.go @@ -0,0 +1,63 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestProjectStateCollisionAssignmentPreservesLegacyDirectory(t *testing.T) { + state := t.TempDir() + root := t.TempDir() + first := filepath.Join(root, "front-end", "app") + second := filepath.Join(root, "front", "end-app") + if WorkspaceSlug(first) != WorkspaceSlug(second) { + t.Fatalf("fixture paths do not collide: %q vs %q", first, second) + } + legacy := filepath.Join(state, "projects", WorkspaceSlug(first)) + if got := ProjectStateDir(state, first); got != legacy { + t.Fatalf("unassigned first project = %q, want %q", got, legacy) + } + if got := ProjectStateDir(state, second); got != legacy { + t.Fatalf("unassigned second project = %q, want %q", got, legacy) + } + if _, err := os.Stat(filepath.Join(state, "projects")); !os.IsNotExist(err) { + t.Fatalf("read-only path resolution created state: %v", err) + } + if err := os.MkdirAll(filepath.Join(legacy, "sessions"), 0o700); err != nil { + t.Fatal(err) + } + oldSession := filepath.Join(legacy, "sessions", "existing.jsonl") + if err := os.WriteFile(oldSession, []byte("existing session\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := AssignProjectStateCollision(state, second); err != nil { + t.Fatal(err) + } + if err := AssignProjectStateCollision(state, second); err != nil { + t.Fatalf("repeat assignment: %v", err) + } + newDir := ProjectStateDir(state, second) + if newDir == legacy || !strings.HasPrefix(filepath.Base(newDir), "@") { + t.Fatalf("colliding project directory = %q, want distinct assigned path", newDir) + } + if got := ProjectStateDir(state, first); got != legacy { + t.Fatalf("existing project moved to %q from %q", got, legacy) + } + if got, err := os.ReadFile(oldSession); err != nil || string(got) != "existing session\n" { + t.Fatalf("legacy session changed: %q, %v", got, err) + } + if _, err := os.Stat(filepath.Join(newDir, "sessions", "existing.jsonl")); !os.IsNotExist(err) { + t.Fatalf("new project sees old session: %v", err) + } +} + +func TestProjectStateNonCollidingRootKeepsHistoricalPath(t *testing.T) { + state := t.TempDir() + root := filepath.Join(t.TempDir(), "backend") + want := filepath.Join(state, "projects", WorkspaceSlug(root)) + if got := ProjectStateDir(state, root); got != want { + t.Fatalf("non-colliding project = %q, want %q", got, want) + } +} diff --git a/internal/memory/store.go b/internal/memory/store.go index 0c7473e877..f8092abcbe 100644 --- a/internal/memory/store.go +++ b/internal/memory/store.go @@ -27,7 +27,7 @@ import ( // current project or every project, while Type only classifies its contents. // List() and Index() merge both directories so every session sees the full set. type Store struct { - Dir string // ...reasonix/projects//memory + Dir string // ...reasonix/projects//memory GlobalDir string // ...reasonix/memory/global (shared across projects) } @@ -111,7 +111,7 @@ func StoreFor(userDir, cwd string) Store { return Store{} } return Store{ - Dir: filepath.Join(userDir, "projects", config.WorkspaceSlug(absOf(cwd)), "memory"), + Dir: filepath.Join(config.ProjectStateDir(userDir, absOf(cwd)), "memory"), GlobalDir: filepath.Join(userDir, "memory", "global"), } } diff --git a/internal/tool/builtin/session_guard.go b/internal/tool/builtin/session_guard.go index 0af15d99c3..7fcf192d5c 100644 --- a/internal/tool/builtin/session_guard.go +++ b/internal/tool/builtin/session_guard.go @@ -144,6 +144,9 @@ func (g SessionDataGuard) denies(abs string) bool { return false } parts := strings.Split(rel, string(filepath.Separator)) + if len(parts) == 2 && parts[1] == ".workspace-root" { + return !allowLiftsProtected(allow, target, filepath.Join(projects, parts[0], ".workspace-root")) + } if len(parts) < 2 || parts[1] != "sessions" { return false } diff --git a/internal/tool/builtin/session_guard_test.go b/internal/tool/builtin/session_guard_test.go index 60b0fc1d95..8f261169d0 100644 --- a/internal/tool/builtin/session_guard_test.go +++ b/internal/tool/builtin/session_guard_test.go @@ -38,6 +38,7 @@ func TestSessionDataGuardDeniesSessionStores(t *testing.T) { projectSession, filepath.Join(root, "sessions", "sub", "new.jsonl"), // not-yet-existing file under the store filepath.Join(root, "projects", "any-slug", "sessions", "x.jsonl.meta"), // CAS ledger sidecar + filepath.Join(root, "projects", "@assigned", ".workspace-root"), // project state ownership } { if err := g.Check(target); err == nil { t.Errorf("Check(%q) = nil, want session-data denial", target)