diff --git a/server/cmd/multica/main.go b/server/cmd/multica/main.go index 2a89aee320..cf2ca2e03a 100644 --- a/server/cmd/multica/main.go +++ b/server/cmd/multica/main.go @@ -34,7 +34,7 @@ func init() { rootCmd.PersistentFlags().String("server-url", "", "Multica server URL (env: MULTICA_SERVER_URL)") rootCmd.PersistentFlags().String("workspace-id", "", "Workspace ID (env: MULTICA_WORKSPACE_ID)") - rootCmd.PersistentFlags().String("profile", "", "Configuration profile name (e.g. dev) — isolates config, daemon state, and workspaces") + rootCmd.PersistentFlags().String("profile", "", "Configuration profile name (e.g. dev) — isolates config, daemon state, and health port (workspaces are shared across profiles; set MULTICA_WORKSPACES_ROOT to override)") // Core commands issueCmd.GroupID = groupCore diff --git a/server/internal/daemon/workspaces_migrate.go b/server/internal/daemon/workspaces_migrate.go index a17ba89f89..de29988c28 100644 --- a/server/internal/daemon/workspaces_migrate.go +++ b/server/internal/daemon/workspaces_migrate.go @@ -60,11 +60,26 @@ func MigrateLegacyWorkspacesRoot(cfg Config, logger *slog.Logger) { "legacy", legacyRoot, "target", defaultRoot) } -// migrateWorkspacesRoot moves every immediate child of legacyRoot into -// targetRoot. Children whose name already exists at the target are left -// untouched in legacyRoot (we never overwrite). If, after the move pass, -// legacyRoot is empty, it is removed; otherwise it is kept so the user can -// reconcile the leftovers manually. +// migrateWorkspacesRoot moves the contents of legacyRoot into targetRoot. +// +// The on-disk layout is `///...`, so when a +// `` directory already exists at the target (because Web/CLI +// has run a task there) we still want to relocate the per-task subdirs from +// the legacy `` rather than abandon them. The walk therefore +// merges one level deep: +// +// - If the legacy entry name does not exist at the target, the whole entry +// is renamed in one syscall (fast path; covers fresh workspaces and the +// `.repos` cache). +// - Otherwise, if both sides are directories AND the entry name does not +// start with `.` (i.e. it looks like a workspace_id, not a hidden file +// like `.repos`), recurse and rename each task_short subdir whose name +// is free at the target. Conflicting task_short subdirs stay in legacy +// for manual reconciliation. +// - Other conflicts (file vs dir, dotfiles, non-dirs) are left untouched. +// +// If, after the pass, legacyRoot is empty, it is removed; otherwise it stays +// so the user can reconcile leftovers. func migrateWorkspacesRoot(legacyRoot, targetRoot string) error { if err := os.MkdirAll(targetRoot, 0o755); err != nil { return fmt.Errorf("ensure target root: %w", err) @@ -76,24 +91,36 @@ func migrateWorkspacesRoot(legacyRoot, targetRoot string) error { } var firstErr error + recordErr := func(err error) { + if firstErr == nil { + firstErr = err + } + } + for _, e := range entries { src := filepath.Join(legacyRoot, e.Name()) dst := filepath.Join(targetRoot, e.Name()) - if _, err := os.Lstat(dst); err == nil { - // Target already exists — leave src in place to avoid clobbering. - continue - } else if !errors.Is(err, fs.ErrNotExist) { - if firstErr == nil { - firstErr = fmt.Errorf("stat target %s: %w", dst, err) + dstInfo, err := os.Lstat(dst) + if errors.Is(err, fs.ErrNotExist) { + if err := os.Rename(src, dst); err != nil { + recordErr(fmt.Errorf("rename %s -> %s: %w", src, dst, err)) } continue } + if err != nil { + recordErr(fmt.Errorf("stat target %s: %w", dst, err)) + continue + } - if err := os.Rename(src, dst); err != nil { - if firstErr == nil { - firstErr = fmt.Errorf("rename %s -> %s: %w", src, dst, err) - } + // Only merge into existing workspace_id-shaped dirs (skip dotfiles + // like `.repos` to avoid stomping shared cache state). + if !e.IsDir() || !dstInfo.IsDir() || strings.HasPrefix(e.Name(), ".") { + continue + } + + if err := mergeTaskDirs(src, dst); err != nil { + recordErr(err) } } @@ -108,3 +135,35 @@ func migrateWorkspacesRoot(legacyRoot, targetRoot string) error { } return nil } + +// mergeTaskDirs renames every task_short subdir in legacyWS into targetWS, +// skipping (leaving in legacyWS) any subdir whose name already exists at the +// target. Removes legacyWS afterwards if it ends up empty. +func mergeTaskDirs(legacyWS, targetWS string) error { + subs, err := os.ReadDir(legacyWS) + if err != nil { + return fmt.Errorf("read legacy workspace dir %s: %w", legacyWS, err) + } + var firstErr error + for _, sub := range subs { + src := filepath.Join(legacyWS, sub.Name()) + dst := filepath.Join(targetWS, sub.Name()) + if _, err := os.Lstat(dst); err == nil { + continue + } else if !errors.Is(err, fs.ErrNotExist) { + if firstErr == nil { + firstErr = fmt.Errorf("stat target %s: %w", dst, err) + } + continue + } + if err := os.Rename(src, dst); err != nil { + if firstErr == nil { + firstErr = fmt.Errorf("rename %s -> %s: %w", src, dst, err) + } + } + } + if remaining, err := os.ReadDir(legacyWS); err == nil && len(remaining) == 0 { + _ = os.Remove(legacyWS) + } + return firstErr +} diff --git a/server/internal/daemon/workspaces_migrate_test.go b/server/internal/daemon/workspaces_migrate_test.go index f49f7aecba..fd4e567613 100644 --- a/server/internal/daemon/workspaces_migrate_test.go +++ b/server/internal/daemon/workspaces_migrate_test.go @@ -128,18 +128,50 @@ func TestMigrateLegacyWorkspacesRoot_NoLegacyDirNoop(t *testing.T) { } } -func TestMigrateLegacyWorkspacesRoot_PreservesExistingTargetEntry(t *testing.T) { +func TestMigrateLegacyWorkspacesRoot_MergesNonConflictingTasksUnderSameWorkspace(t *testing.T) { + home := withFakeHome(t) + profile := "desktop-api.multica.ai" + + legacy := filepath.Join(home, "multica_workspaces_"+profile) + target := filepath.Join(home, "multica_workspaces") + + // Same workspace_id present in both roots, but different task_short subdirs. + // This is the realistic case: Web/CLI created task-web at the new path, the + // Desktop daemon created task-desktop at the old per-profile path. Both + // must end up under target//. + mustWriteFile(t, filepath.Join(target, "ws-uuid", "task-web", "marker"), "from-web") + mustWriteFile(t, filepath.Join(legacy, "ws-uuid", "task-desktop", "marker"), "from-desktop") + + cfg := Config{ + Profile: profile, + WorkspacesRoot: target, + } + MigrateLegacyWorkspacesRoot(cfg, newSilentLogger()) + + if b, err := os.ReadFile(filepath.Join(target, "ws-uuid", "task-web", "marker")); err != nil || string(b) != "from-web" { + t.Fatalf("pre-existing target task should be preserved, got %q err=%v", string(b), err) + } + if b, err := os.ReadFile(filepath.Join(target, "ws-uuid", "task-desktop", "marker")); err != nil || string(b) != "from-desktop" { + t.Fatalf("legacy task should be merged into target, got %q err=%v", string(b), err) + } + // Legacy root and its workspace dir should be cleaned up since everything migrated. + if _, err := os.Stat(legacy); !os.IsNotExist(err) { + t.Fatalf("legacy root should be removed after full migration, err=%v", err) + } +} + +func TestMigrateLegacyWorkspacesRoot_PreservesConflictingTaskShort(t *testing.T) { home := withFakeHome(t) profile := "desktop-foo" legacy := filepath.Join(home, "multica_workspaces_"+profile) target := filepath.Join(home, "multica_workspaces") - // Same workspace UUID exists in both. The migration must not overwrite the - // target — it must leave the legacy copy in place for manual reconciliation. + // Same workspace AND same task_short on both sides. We must not overwrite. mustWriteFile(t, filepath.Join(legacy, "ws-uuid", "task-x", "marker"), "from-legacy") - mustWriteFile(t, filepath.Join(target, "ws-uuid", "task-y", "marker"), "from-target") - // Distinct entry that should be moved cleanly. + mustWriteFile(t, filepath.Join(target, "ws-uuid", "task-x", "marker"), "from-target") + // Plus a non-conflicting task_short under the same workspace and a fresh workspace. + mustWriteFile(t, filepath.Join(legacy, "ws-uuid", "task-y", "marker"), "merged") mustWriteFile(t, filepath.Join(legacy, "ws-uuid-other", "task-z", "marker"), "moved") cfg := Config{ @@ -148,24 +180,61 @@ func TestMigrateLegacyWorkspacesRoot_PreservesExistingTargetEntry(t *testing.T) } MigrateLegacyWorkspacesRoot(cfg, newSilentLogger()) - // Conflicting entry stays in legacy; target version preserved. - if b, err := os.ReadFile(filepath.Join(target, "ws-uuid", "task-y", "marker")); err != nil || string(b) != "from-target" { - t.Fatalf("target entry should be preserved, got %q err=%v", string(b), err) + // Same task_short on both sides → target wins, legacy copy stays put. + if b, err := os.ReadFile(filepath.Join(target, "ws-uuid", "task-x", "marker")); err != nil || string(b) != "from-target" { + t.Fatalf("target task_short should be preserved, got %q err=%v", string(b), err) } if b, err := os.ReadFile(filepath.Join(legacy, "ws-uuid", "task-x", "marker")); err != nil || string(b) != "from-legacy" { - t.Fatalf("conflicting legacy entry should remain in place, got %q err=%v", string(b), err) + t.Fatalf("conflicting legacy task_short should remain in place, got %q err=%v", string(b), err) } - // Distinct entry was moved. + // Non-conflicting task_short under same workspace was merged. + if b, err := os.ReadFile(filepath.Join(target, "ws-uuid", "task-y", "marker")); err != nil || string(b) != "merged" { + t.Fatalf("non-conflicting task_short should be merged into target, got %q err=%v", string(b), err) + } + + // Fresh workspace was renamed wholesale. if _, err := os.Stat(filepath.Join(legacy, "ws-uuid-other")); !os.IsNotExist(err) { - t.Fatalf("non-conflicting legacy entry should be moved, err=%v", err) + t.Fatalf("fresh legacy workspace should be moved, err=%v", err) } if b, err := os.ReadFile(filepath.Join(target, "ws-uuid-other", "task-z", "marker")); err != nil || string(b) != "moved" { - t.Fatalf("non-conflicting entry should arrive at target, got %q err=%v", string(b), err) + t.Fatalf("fresh workspace should arrive at target, got %q err=%v", string(b), err) } - // Legacy root retained because it still has a residual entry. + // Legacy root retained because the conflicting task_short still lives there. + if _, err := os.Stat(filepath.Join(legacy, "ws-uuid", "task-x")); err != nil { + t.Fatalf("legacy conflicting task_short should remain, err=%v", err) + } if _, err := os.Stat(legacy); err != nil { t.Fatalf("legacy root should be retained when residual entries exist: %v", err) } } + +func TestMigrateLegacyWorkspacesRoot_LeavesDotfileConflictAlone(t *testing.T) { + home := withFakeHome(t) + profile := "desktop-foo" + + legacy := filepath.Join(home, "multica_workspaces_"+profile) + target := filepath.Join(home, "multica_workspaces") + + // Both have a `.repos` cache. Don't merge into existing cache (just + // leave the legacy copy; the daemon will lazily repopulate target). + mustWriteFile(t, filepath.Join(legacy, ".repos", "github.com", "foo", "HEAD"), "legacy") + mustWriteFile(t, filepath.Join(target, ".repos", "github.com", "bar", "HEAD"), "target") + + cfg := Config{ + Profile: profile, + WorkspacesRoot: target, + } + MigrateLegacyWorkspacesRoot(cfg, newSilentLogger()) + + if _, err := os.Stat(filepath.Join(legacy, ".repos", "github.com", "foo", "HEAD")); err != nil { + t.Fatalf("legacy dotfile dir should be left in place when target dotfile exists: %v", err) + } + if _, err := os.Stat(filepath.Join(target, ".repos", "github.com", "bar", "HEAD")); err != nil { + t.Fatalf("target dotfile dir should be untouched: %v", err) + } + if _, err := os.Stat(filepath.Join(target, ".repos", "github.com", "foo")); !os.IsNotExist(err) { + t.Fatalf("target dotfile dir should not be merged into, err=%v", err) + } +}