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:
YHH authored and GitHub committed 2026-09-28 11:41:32 -07:00
commit 8f7e4a0748
16 files changed
+510 -26

No files matched your search

+1 -1
View File
@@ -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
+11 -1
View File
@@ -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
View File
@@ -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)()
+21 -4
View File
@@ -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
}
+44
View File
@@ -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
}
+215
View File
@@ -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)
}
}
}
+3 -1
View File
@@ -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
View File
@@ -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 {
+17
View File
@@ -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
+12
View File
@@ -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
+6 -5
View File
@@ -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
+86
View File
@@ -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
}
+63
View File
@@ -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)
}
}
+2 -2
View File
@@ -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"),
}
}
+3
View File
@@ -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)