From c2974487dddb84c11072db7776e2d82b5e23830d Mon Sep 17 00:00:00 2001 From: xiexian <3468714225@qq.com> Date: Mon, 28 Sep 2026 23:12:33 +0800 Subject: [PATCH 1/3] fix(desktop): isolate newly registered project slug collisions --- desktop/desktop_project_file_lock.go | 7 + desktop/fork_tab.go | 4 +- desktop/project_root_registration.go | 23 ++- desktop/project_state_collision.go | 38 +++++ desktop/project_state_collision_test.go | 151 ++++++++++++++++++++ desktop/session_open_surface.go | 4 +- desktop/tabs.go | 11 +- docs/CONFIG_PATHS.md | 11 ++ docs/CONFIG_PATHS.zh-CN.md | 8 ++ internal/config/paths.go | 11 +- internal/config/project_state_dir.go | 86 +++++++++++ internal/config/project_state_dir_test.go | 63 ++++++++ internal/memory/store.go | 4 +- internal/tool/builtin/session_guard.go | 3 + internal/tool/builtin/session_guard_test.go | 1 + 15 files changed, 408 insertions(+), 17 deletions(-) create mode 100644 desktop/project_state_collision.go create mode 100644 desktop/project_state_collision_test.go create mode 100644 internal/config/project_state_dir.go create mode 100644 internal/config/project_state_dir_test.go diff --git a/desktop/desktop_project_file_lock.go b/desktop/desktop_project_file_lock.go index 643a91deac..588e22a613 100644 --- a/desktop/desktop_project_file_lock.go +++ b/desktop/desktop_project_file_lock.go @@ -25,6 +25,10 @@ func acquireDesktopProjectsFileLock() (func(), error) { // desktop-projects cross-process file lock. func updateProjectsFileCrossProcessLocked(mutator func(*desktopProjectFile) (bool, error)) 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 +36,8 @@ func updateProjectsFileCrossProcessLocked(mutator func(*desktopProjectFile) (boo if !changed { return nil } + if err := assignAddedProjectStateCollisions(previous, f.Projects); err != nil { + return 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..9046db2c65 100644 --- a/desktop/project_root_registration.go +++ b/desktop/project_root_registration.go @@ -2,18 +2,33 @@ package main // 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, ""); err != nil { + 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..155f0cc2a8 --- /dev/null +++ b/desktop/project_state_collision.go @@ -0,0 +1,38 @@ +package main + +import "reasonix/internal/config" + +// assignAddedProjectStateCollisions records a distinct path only for a newly +// registered project whose historical slug is already used by a recorded +// project. Roots already in the file keep their old directory, even when an +// older release let them overlap there. +func assignAddedProjectStateCollisions(previous []string, projects []desktopProject) error { + known := append([]string(nil), previous...) + 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) { + collision = true + } + } + if alreadyKnown { + continue + } + if collision { + if err := config.AssignProjectStateCollision(config.MemoryUserDir(), 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..65b83e57f7 --- /dev/null +++ b/desktop/project_state_collision_test.go @@ -0,0 +1,151 @@ +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 := 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 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..4dcb4d7c09 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() diff --git a/docs/CONFIG_PATHS.md b/docs/CONFIG_PATHS.md index 34cd097a55..47b9996b93 100644 --- a/docs/CONFIG_PATHS.md +++ b/docs/CONFIG_PATHS.md @@ -52,6 +52,17 @@ 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`. Existing projects and projects without a +collision keep their current `/projects//` directory. +Only the newly added colliding project 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. + 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..6f3a3fe781 100644 --- a/docs/CONFIG_PATHS.zh-CN.md +++ b/docs/CONFIG_PATHS.zh-CN.md @@ -43,6 +43,14 @@ Legacy 迁移、OS home 约定目录扫描以及其他 fallback 路径都会跳 `` 默认等于 ``;只有设置 `REASONIX_STATE_HOME` 时才会不同。 +Desktop 在将新项目写入 `desktop-projects.json` 时检测项目目录名冲突。已有项目及不冲突的 +项目继续使用原来的 `/projects//` 目录。只有新加入且发生 +冲突的项目使用 `/projects/@<项目绝对路径的 SHA-256>/`;其中的 +`.workspace-root` 文件记录目录归属。会话、Topic 和项目记忆路径都遵循该归属记录。 +读取项目列表不会创建或修改归属记录,也不会移动已有文件。如果两个项目在此修复之前就 +已经以同一个 slug 登记,其历史共享文件仍保留在原目录,因为旧目录无法证明每个文件的 +所属项目。 + 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) From 2835c52d2b9ff73e43347804050a51970992a591 Mon Sep 17 00:00:00 2001 From: xiexian <3468714225@qq.com> Date: Tue, 29 Sep 2026 01:19:38 +0800 Subject: [PATCH 2/3] fix(desktop): preserve legacy project state on re-add and import --- desktop/app.go | 2 +- desktop/desktop_project_file_lock.go | 9 ++-- desktop/project_root_registration.go | 4 +- desktop/project_state_collision.go | 20 +++++--- desktop/project_state_collision_test.go | 64 +++++++++++++++++++++++++ desktop/tabs.go | 20 ++++++-- docs/CONFIG_PATHS.md | 12 +++-- docs/CONFIG_PATHS.zh-CN.md | 10 ++-- 8 files changed, 119 insertions(+), 22 deletions(-) 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 588e22a613..685244834a 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,7 +24,7 @@ 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 { @@ -36,8 +37,10 @@ func updateProjectsFileCrossProcessLocked(mutator func(*desktopProjectFile) (boo if !changed { return nil } - if err := assignAddedProjectStateCollisions(previous, f.Projects); err != nil { - return err + if assignCollisions { + if err := assignAddedProjectStateCollisions(previous, f.Projects); err != nil { + return fmt.Errorf("%w: %v", errProjectStateCollisionAssignment, err) + } } return saveProjectsFile(f) } diff --git a/desktop/project_root_registration.go b/desktop/project_root_registration.go index 9046db2c65..f30a6cddf4 100644 --- a/desktop/project_root_registration.go +++ b/desktop/project_root_registration.go @@ -1,9 +1,11 @@ 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) error { - if err := addProject(workspaceRoot, ""); err != nil { + if err := addProject(workspaceRoot, ""); errors.Is(err, errProjectStateCollisionAssignment) { return err } a.syncTabWorkspaceRootSpellings() diff --git a/desktop/project_state_collision.go b/desktop/project_state_collision.go index 155f0cc2a8..e50b79a319 100644 --- a/desktop/project_state_collision.go +++ b/desktop/project_state_collision.go @@ -1,13 +1,18 @@ package main -import "reasonix/internal/config" +import ( + "errors" + "reasonix/internal/config" +) -// assignAddedProjectStateCollisions records a distinct path only for a newly -// registered project whose historical slug is already used by a recorded -// project. Roots already in the file keep their old directory, even when an -// older release let them overlap there. +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 == "" { @@ -20,7 +25,8 @@ func assignAddedProjectStateCollisions(previous []string, projects []desktopProj alreadyKnown = true break } - if config.WorkspaceSlug(prior) == config.WorkspaceSlug(root) { + if config.WorkspaceSlug(prior) == config.WorkspaceSlug(root) && + config.ProjectStateDir(userDir, prior) == config.ProjectStateDir(userDir, root) { collision = true } } @@ -28,7 +34,7 @@ func assignAddedProjectStateCollisions(previous []string, projects []desktopProj continue } if collision { - if err := config.AssignProjectStateCollision(config.MemoryUserDir(), root); err != nil { + if err := config.AssignProjectStateCollision(userDir, root); err != nil { return err } } diff --git a/desktop/project_state_collision_test.go b/desktop/project_state_collision_test.go index 65b83e57f7..a967a9acbf 100644 --- a/desktop/project_state_collision_test.go +++ b/desktop/project_state_collision_test.go @@ -46,6 +46,15 @@ func TestNewCollidingProjectGetsOwnStateWithoutMovingExistingProject(t *testing. 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) } @@ -105,6 +114,61 @@ func TestNewCollidingProjectGetsOwnStateWithoutMovingExistingProject(t *testing. } } +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() diff --git a/desktop/tabs.go b/desktop/tabs.go index 4dcb4d7c09..f970707e07 100644 --- a/desktop/tabs.go +++ b/desktop/tabs.go @@ -4832,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) @@ -4924,18 +4924,30 @@ func saveProjectsFile(f desktopProjectFile) error { } func updateProjectsFile(mutator func(*desktopProjectFile) (bool, error)) error { + return updateProjectsFileWithCollisionAssignment(mutator, true) +} + +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 updateProjectsFileLocked(mutator) + return updateProjectsFileLockedWithCollisionAssignment(mutator, assignCollisions) } func updateProjectsFileLocked(mutator func(*desktopProjectFile) (bool, error)) error { + return updateProjectsFileLockedWithCollisionAssignment(mutator, true) +} + +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 { @@ -5065,7 +5077,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 47b9996b93..b358cf002c 100644 --- a/docs/CONFIG_PATHS.md +++ b/docs/CONFIG_PATHS.md @@ -53,15 +53,21 @@ non-destructively when `/.env` is missing them. `REASONIX_STATE_HOME` is set. Desktop detects a project-directory name collision when it saves a newly added -project in `desktop-projects.json`. Existing projects and projects without a -collision keep their current `/projects//` directory. -Only the newly added colliding project uses +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 diff --git a/docs/CONFIG_PATHS.zh-CN.md b/docs/CONFIG_PATHS.zh-CN.md index 6f3a3fe781..a450d9fa01 100644 --- a/docs/CONFIG_PATHS.zh-CN.md +++ b/docs/CONFIG_PATHS.zh-CN.md @@ -43,13 +43,17 @@ Legacy 迁移、OS home 约定目录扫描以及其他 fallback 路径都会跳 `` 默认等于 ``;只有设置 `REASONIX_STATE_HOME` 时才会不同。 -Desktop 在将新项目写入 `desktop-projects.json` 时检测项目目录名冲突。已有项目及不冲突的 -项目继续使用原来的 `/projects//` 目录。只有新加入且发生 -冲突的项目使用 `/projects/@<项目绝对路径的 SHA-256>/`;其中的 +Desktop 在将新项目写入 `desktop-projects.json` 时检测项目目录名冲突。只有另一个已登记 +项目仍实际指向同一个旧目录时,才给新项目分配目录。已有项目和从旧工作区记录导入的 +项目都继续使用原来的 `/projects//` 目录;原项目在冲突 +项目分配到别处后移除并重新加入,也仍使用该旧目录。只有满足上述冲突条件的新项目使用 +`/projects/@<项目绝对路径的 SHA-256>/`;其中的 `.workspace-root` 文件记录目录归属。会话、Topic 和项目记忆路径都遵循该归属记录。 读取项目列表不会创建或修改归属记录,也不会移动已有文件。如果两个项目在此修复之前就 已经以同一个 slug 登记,其历史共享文件仍保留在原目录,因为旧目录无法证明每个文件的 所属项目。 +Studio 目前只解析 `/projects//`,不会读取 `.workspace-root`; +因此新分配目录的项目状态暂时不会与 Studio 共享,直到 Studio 支持此归属记录。 Desktop Topic 的标题、标题来源、创建时间和自动标题状态以这些 SQLite 文件为权威存储。 首次访问时,Desktop 会导入项目 `.reasonix/` 目录(或全局 Reasonix 目录)中的旧 From 3770cb385618533214100fc9d5b22a3e8cec7b01 Mon Sep 17 00:00:00 2001 From: xiexian <3468714225@qq.com> Date: Tue, 29 Sep 2026 01:30:41 +0800 Subject: [PATCH 3/3] fix(desktop): satisfy project update lint checks --- desktop/desktop_project_file_lock.go | 2 +- desktop/tabs.go | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/desktop/desktop_project_file_lock.go b/desktop/desktop_project_file_lock.go index 685244834a..159a0437c7 100644 --- a/desktop/desktop_project_file_lock.go +++ b/desktop/desktop_project_file_lock.go @@ -39,7 +39,7 @@ func updateProjectsFileCrossProcessLocked(mutator func(*desktopProjectFile) (boo } if assignCollisions { if err := assignAddedProjectStateCollisions(previous, f.Projects); err != nil { - return fmt.Errorf("%w: %v", errProjectStateCollisionAssignment, err) + return fmt.Errorf("%w: %w", errProjectStateCollisionAssignment, err) } } return saveProjectsFile(f) diff --git a/desktop/tabs.go b/desktop/tabs.go index f970707e07..c532140133 100644 --- a/desktop/tabs.go +++ b/desktop/tabs.go @@ -4937,10 +4937,6 @@ func updateProjectsFileWithCollisionAssignment(mutator func(*desktopProjectFile) return updateProjectsFileLockedWithCollisionAssignment(mutator, assignCollisions) } -func updateProjectsFileLocked(mutator func(*desktopProjectFile) (bool, error)) error { - return updateProjectsFileLockedWithCollisionAssignment(mutator, true) -} - func updateProjectsFileLockedWithCollisionAssignment(mutator func(*desktopProjectFile) (bool, error), assignCollisions bool) error { release, err := acquireDesktopProjectsFileLock() if err != nil {