mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Merge pull request #11190 from KHG420/codex/1x-project-state-collision-10682
fix(desktop): isolate newly added project slug collisions
This commit is contained in:
16 files changed
+510
-26
No files matched your search
+1
-1
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+3
-1
@@ -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)()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
+22
-11
@@ -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 {
|
||||
|
||||
@@ -52,6 +52,23 @@ non-destructively when `<Reasonix home>/.env` is missing them.
|
||||
`<state root>` defaults to `<Reasonix home>`. 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
|
||||
`<state root>/projects/<workspace slug>/` 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
|
||||
`<state root>/projects/@<SHA-256 of its absolute root>/`; 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 `<state root>/projects/<workspace slug>/` 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
|
||||
|
||||
@@ -43,6 +43,18 @@ Legacy 迁移、OS home 约定目录扫描以及其他 fallback 路径都会跳
|
||||
`<state root>` 默认等于 `<Reasonix home>`;只有设置 `REASONIX_STATE_HOME`
|
||||
时才会不同。
|
||||
|
||||
Desktop 在将新项目写入 `desktop-projects.json` 时检测项目目录名冲突。只有另一个已登记
|
||||
项目仍实际指向同一个旧目录时,才给新项目分配目录。已有项目和从旧工作区记录导入的
|
||||
项目都继续使用原来的 `<state root>/projects/<workspace slug>/` 目录;原项目在冲突
|
||||
项目分配到别处后移除并重新加入,也仍使用该旧目录。只有满足上述冲突条件的新项目使用
|
||||
`<state root>/projects/@<项目绝对路径的 SHA-256>/`;其中的
|
||||
`.workspace-root` 文件记录目录归属。会话、Topic 和项目记忆路径都遵循该归属记录。
|
||||
读取项目列表不会创建或修改归属记录,也不会移动已有文件。如果两个项目在此修复之前就
|
||||
已经以同一个 slug 登记,其历史共享文件仍保留在原目录,因为旧目录无法证明每个文件的
|
||||
所属项目。
|
||||
Studio 目前只解析 `<state root>/projects/<workspace slug>/`,不会读取 `.workspace-root`;
|
||||
因此新分配目录的项目状态暂时不会与 Studio 共享,直到 Studio 支持此归属记录。
|
||||
|
||||
Desktop Topic 的标题、标题来源、创建时间和自动标题状态以这些 SQLite 文件为权威存储。
|
||||
首次访问时,Desktop 会导入项目 `.reasonix/` 目录(或全局 Reasonix 目录)中的旧
|
||||
`desktop-topic-*.json`。检测到旧文件的 scope 会继续镜像旧格式以支持降级;全新 scope
|
||||
|
||||
@@ -512,8 +512,9 @@ func StatsDir() string {
|
||||
}
|
||||
|
||||
// ProjectSessionDir is the per-workspace session directory the desktop sidebar
|
||||
// lists: <state root>/projects/<slug>/sessions. Empty when either the state root
|
||||
// or workspaceRoot doesn't resolve.
|
||||
// lists. Existing projects use <state root>/projects/<slug>/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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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/<slug>/memory
|
||||
Dir string // ...reasonix/projects/<project directory>/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"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user