fix(daemon): merge per-task during legacy workspaces migration

The first pass skipped a whole `<workspace_id>` directory when the
target already had it, which is exactly the case we care about: Web/CLI
ran a task on the unified path while Desktop ran one on the legacy
profile-suffixed path, so the two task_short subdirs need to merge.
Recurse one level for non-dotfile directory collisions and rename each
task_short subdir whose name is free at the target; conflicting
task_short subdirs (true overwrites) stay in legacy for the user to
reconcile. `.repos` and other dotfile caches keep the
no-merge-on-conflict behavior.

Also drop the stale "and workspaces" claim from the global --profile
help text — workspaces are now shared across profiles by default.
This commit is contained in:
Jiang Bohan
2026-04-28 17:37:23 +08:00
parent e15fc1a740
commit f75aa0ef85
3 changed files with 157 additions and 29 deletions

View File

@@ -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

View File

@@ -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 `<root>/<workspace_id>/<task_short>/...`, so when a
// `<workspace_id>` 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 `<workspace_id>` 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
}

View File

@@ -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/<ws>/.
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)
}
}