mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-01 01:16:17 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user