From 17aeb57eb010b3b3204dabdabb5e9d8b3bbe4b10 Mon Sep 17 00:00:00 2001 From: Aditi Chauhan Date: Wed, 1 Jul 2026 14:09:53 +0530 Subject: [PATCH] refactor: reuse workspace lifecycle for child repos --- .../workspace/gitworktree/workspace.go | 194 ++++-------------- .../workspace/gitworktree/workspace_test.go | 13 +- backend/internal/ports/outbound.go | 13 +- backend/internal/session_manager/manager.go | 58 ++---- .../internal/session_manager/manager_test.go | 149 +++++++------- 5 files changed, 152 insertions(+), 275 deletions(-) diff --git a/backend/internal/adapters/workspace/gitworktree/workspace.go b/backend/internal/adapters/workspace/gitworktree/workspace.go index c3b6bf9be..c1c7d8ed0 100644 --- a/backend/internal/adapters/workspace/gitworktree/workspace.go +++ b/backend/internal/adapters/workspace/gitworktree/workspace.go @@ -253,153 +253,13 @@ func (w *Workspace) DestroyWorkspaceProject(ctx context.Context, info ports.Work return firstErr } -// DestroyWorkspaceProjectWorktree removes one repo worktree from a workspace -// project using that repo's canonical path. It preserves the same dirty-worktree -// refusal contract as Destroy. -func (w *Workspace) DestroyWorkspaceProjectWorktree(ctx context.Context, info ports.WorkspaceRepoInfo) error { - if info.RepoPath == "" { - return fmt.Errorf("gitworktree: missing repo path for worktree %q", info.Path) - } - if info.Path == "" { - return fmt.Errorf("%w: empty path", ErrUnsafePath) - } - repo, err := physicalAbs(info.RepoPath) - if err != nil { - return fmt.Errorf("gitworktree: repo path: %w", err) - } - path, err := w.validateManagedPath(info.Path) - if err != nil { - return err - } - _, removeErr := w.run(ctx, w.binary, worktreeRemoveArgs(repo, path)...) - if _, err := w.run(ctx, w.binary, worktreePruneArgs(repo)...); err != nil { - return fmt.Errorf("gitworktree: worktree prune: %w", err) - } - records, err := w.listRecords(ctx, repo) - if err != nil { - return err - } - if _, ok := findWorktree(records, path); ok { - if removeErr != nil { - dirty, statusErr := w.isDirty(ctx, path) - if statusErr == nil && dirty { - return fmt.Errorf("gitworktree: refusing to remove %q: %w (worktree remove: %w)", path, ports.ErrWorkspaceDirty, removeErr) - } - if statusErr != nil { - return fmt.Errorf("gitworktree: refusing to remove %q: path is still registered after git worktree prune (worktree remove: %w; dirty probe: %w)", path, removeErr, statusErr) - } - return fmt.Errorf("gitworktree: refusing to remove %q: path is still registered after git worktree prune (worktree remove: %w)", path, removeErr) - } - return fmt.Errorf("gitworktree: refusing to remove %q: path is still registered after git worktree prune", path) - } - if err := os.RemoveAll(path); err != nil { - return fmt.Errorf("gitworktree: remove unregistered path %q: %w", path, err) - } - return nil -} - -// ForceDestroyWorkspaceProjectWorktree force-removes one repo worktree after -// its work has been preserved. -func (w *Workspace) ForceDestroyWorkspaceProjectWorktree(ctx context.Context, info ports.WorkspaceRepoInfo) error { - if info.RepoPath == "" { - return fmt.Errorf("gitworktree: missing repo path for worktree %q", info.Path) - } - if info.Path == "" { - return fmt.Errorf("%w: empty path", ErrUnsafePath) - } - repo, err := physicalAbs(info.RepoPath) - if err != nil { - return fmt.Errorf("gitworktree: repo path: %w", err) - } - path, err := w.validateManagedPath(info.Path) - if err != nil { - return err - } - return w.forceDestroyPath(ctx, repo, path) -} - -// RestoreWorkspaceProjectWorktree re-attaches one repo worktree for a workspace -// project using the repo path captured from project registration. -func (w *Workspace) RestoreWorkspaceProjectWorktree(ctx context.Context, info ports.WorkspaceRepoInfo) (ports.WorkspaceRepoInfo, error) { - if info.RepoPath == "" { - return ports.WorkspaceRepoInfo{}, fmt.Errorf("gitworktree: missing repo path for worktree %q", info.Path) - } - if info.Path == "" { - return ports.WorkspaceRepoInfo{}, fmt.Errorf("%w: empty path", ErrUnsafePath) - } - if info.Branch == "" { - return ports.WorkspaceRepoInfo{}, errors.New("gitworktree: branch is required") - } - repo, err := physicalAbs(info.RepoPath) - if err != nil { - return ports.WorkspaceRepoInfo{}, fmt.Errorf("gitworktree: repo path: %w", err) - } - path, err := w.validateManagedPath(info.Path) - if err != nil { - return ports.WorkspaceRepoInfo{}, err - } - records, err := w.listRecords(ctx, repo) - if err != nil { - return ports.WorkspaceRepoInfo{}, err - } - out := info - if rec, ok := findWorktree(records, path); ok { - if rec.Branch != "" { - out.Branch = rec.Branch - } - out.Path = path - out.RepoPath = repo - return out, nil - } - if nonEmpty, err := pathExistsNonEmpty(path); err != nil { - return ports.WorkspaceRepoInfo{}, err - } else if nonEmpty { - if _, err := moveStrayPathAside(path); err != nil { - return ports.WorkspaceRepoInfo{}, err - } - } - if err := w.validateBranch(ctx, repo, info.Branch); err != nil { - return ports.WorkspaceRepoInfo{}, err - } - if err := w.addWorktree(ctx, repo, path, info.Branch, ""); err != nil { - return ports.WorkspaceRepoInfo{}, err - } - out.Path = path - out.RepoPath = repo - return out, nil -} - -// StashWorkspaceProjectWorktree captures uncommitted work for one workspace -// project repo worktree. -func (w *Workspace) StashWorkspaceProjectWorktree(ctx context.Context, info ports.WorkspaceRepoInfo) (string, error) { - return w.StashUncommitted(ctx, workspaceInfoFromRepoInfo(info)) -} - -// ApplyWorkspaceProjectPreserved replays a preserve ref into one workspace -// project repo worktree. -func (w *Workspace) ApplyWorkspaceProjectPreserved(ctx context.Context, info ports.WorkspaceRepoInfo, ref string) error { - return w.ApplyPreserved(ctx, workspaceInfoFromRepoInfo(info), ref) -} - -func workspaceInfoFromRepoInfo(info ports.WorkspaceRepoInfo) ports.WorkspaceInfo { - return ports.WorkspaceInfo{ - Path: info.Path, - Branch: info.Branch, - SessionID: info.SessionID, - ProjectID: info.ProjectID, - } -} - // Destroy removes the session's worktree and prunes it from the repo, refusing // (rather than force-deleting) if git still has the path registered afterwards. func (w *Workspace) Destroy(ctx context.Context, info ports.WorkspaceInfo) error { - if info.ProjectID == "" { - return errors.New("gitworktree: project id is required") - } if info.Path == "" { return fmt.Errorf("%w: empty path", ErrUnsafePath) } - repo, err := w.repoPath(info.ProjectID) + repo, err := w.repoPathForInfo(info) if err != nil { return err } @@ -448,13 +308,10 @@ func (w *Workspace) Destroy(ctx context.Context, info ports.WorkspaceInfo) error // discards agent work. For interactive teardown (ao session kill, ao cleanup) // use Destroy, which refuses dirty worktrees via ErrWorkspaceDirty. func (w *Workspace) ForceDestroy(ctx context.Context, info ports.WorkspaceInfo) error { - if info.ProjectID == "" { - return errors.New("gitworktree: project id is required") - } if info.Path == "" { return fmt.Errorf("%w: empty path", ErrUnsafePath) } - repo, err := w.repoPath(info.ProjectID) + repo, err := w.repoPathForInfo(info) if err != nil { return err } @@ -664,11 +521,11 @@ func (w *Workspace) Restore(ctx context.Context, cfg ports.WorkspaceConfig) (por if err := validateConfig(cfg); err != nil { return ports.WorkspaceInfo{}, err } - repo, err := w.repoPath(cfg.ProjectID) + repo, err := w.repoPathForConfig(cfg) if err != nil { return ports.WorkspaceInfo{}, err } - path, err := w.managedPath(cfg) + path, err := w.restorePath(cfg) if err != nil { return ports.WorkspaceInfo{}, err } @@ -681,12 +538,17 @@ func (w *Workspace) Restore(ctx context.Context, cfg ports.WorkspaceConfig) (por if branch == "" { branch = cfg.Branch } - return ports.WorkspaceInfo{Path: path, Branch: branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID}, nil + return ports.WorkspaceInfo{Path: path, Branch: branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID, RepoPath: repo}, nil } if nonEmpty, err := pathExistsNonEmpty(path); err != nil { return ports.WorkspaceInfo{}, err } else if nonEmpty { - return ports.WorkspaceInfo{}, fmt.Errorf("gitworktree: refusing to restore %q: path exists and is not a registered worktree", path) + if cfg.Path == "" { + return ports.WorkspaceInfo{}, fmt.Errorf("gitworktree: refusing to restore %q: path exists and is not a registered worktree", path) + } + if _, err := moveStrayPathAside(path); err != nil { + return ports.WorkspaceInfo{}, err + } } if err := w.validateBranch(ctx, repo, cfg.Branch); err != nil { return ports.WorkspaceInfo{}, err @@ -694,7 +556,7 @@ func (w *Workspace) Restore(ctx context.Context, cfg ports.WorkspaceConfig) (por if err := w.addWorktree(ctx, repo, path, cfg.Branch, cfg.BaseBranch); err != nil { return ports.WorkspaceInfo{}, err } - return ports.WorkspaceInfo{Path: path, Branch: cfg.Branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID}, nil + return ports.WorkspaceInfo{Path: path, Branch: cfg.Branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID, RepoPath: repo}, nil } func (w *Workspace) existingWorktree(ctx context.Context, repo, path string, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, bool, error) { @@ -935,6 +797,31 @@ func (w *Workspace) repoPath(project domain.ProjectID) (string, error) { return abs, nil } +func (w *Workspace) repoPathForInfo(info ports.WorkspaceInfo) (string, error) { + if info.RepoPath != "" { + repo, err := physicalAbs(info.RepoPath) + if err != nil { + return "", fmt.Errorf("gitworktree: repo path: %w", err) + } + return repo, nil + } + if info.ProjectID == "" { + return "", errors.New("gitworktree: project id is required") + } + return w.repoPath(info.ProjectID) +} + +func (w *Workspace) repoPathForConfig(cfg ports.WorkspaceConfig) (string, error) { + if cfg.RepoPath != "" { + repo, err := physicalAbs(cfg.RepoPath) + if err != nil { + return "", fmt.Errorf("gitworktree: repo path: %w", err) + } + return repo, nil + } + return w.repoPath(cfg.ProjectID) +} + func physicalAbs(path string) (string, error) { abs, err := filepath.Abs(path) if err != nil { @@ -1042,6 +929,13 @@ func (w *Workspace) managedPath(cfg ports.WorkspaceConfig) (string, error) { return w.validateManagedPath(path) } +func (w *Workspace) restorePath(cfg ports.WorkspaceConfig) (string, error) { + if cfg.Path != "" { + return w.validateManagedPath(cfg.Path) + } + return w.managedPath(cfg) +} + // resolvedSessionPrefix returns cfg.SessionPrefix when set, otherwise the first // 12 characters of the project ID (matching the display-prefix convention). func resolvedSessionPrefix(cfg ports.WorkspaceConfig) string { diff --git a/backend/internal/adapters/workspace/gitworktree/workspace_test.go b/backend/internal/adapters/workspace/gitworktree/workspace_test.go index d0cb4ed6e..cf676ee11 100644 --- a/backend/internal/adapters/workspace/gitworktree/workspace_test.go +++ b/backend/internal/adapters/workspace/gitworktree/workspace_test.go @@ -282,7 +282,7 @@ func TestRestoreRefusesNonEmptyUnregisteredPath(t *testing.T) { } } -func TestRestoreWorkspaceProjectWorktreeMovesStrayPathAside(t *testing.T) { +func TestRestoreWithRepoPathMovesStrayPathAside(t *testing.T) { root := t.TempDir() repo := t.TempDir() ws, err := New(Options{ManagedRoot: root, RepoResolver: StaticRepoResolver{"proj": repo}}) @@ -317,16 +317,15 @@ func TestRestoreWorkspaceProjectWorktreeMovesStrayPathAside(t *testing.T) { } } - info, err := ws.RestoreWorkspaceProjectWorktree(context.Background(), ports.WorkspaceRepoInfo{ - RepoName: "api", + info, err := ws.Restore(context.Background(), ports.WorkspaceConfig{ + ProjectID: "proj", + SessionID: "proj-1", + Branch: "ao/proj-1", RepoPath: repo, Path: path, - Branch: "ao/proj-1", - SessionID: "proj-1", - ProjectID: "proj", }) if err != nil { - t.Fatalf("RestoreWorkspaceProjectWorktree: %v", err) + t.Fatalf("Restore: %v", err) } if info.Path != path || addPath != path { t.Fatalf("restored path=%q addPath=%q, want %q", info.Path, addPath, path) diff --git a/backend/internal/ports/outbound.go b/backend/internal/ports/outbound.go index d5635e287..3cbfeb4b1 100644 --- a/backend/internal/ports/outbound.go +++ b/backend/internal/ports/outbound.go @@ -148,11 +148,6 @@ type Workspace interface { type WorkspaceProject interface { CreateWorkspaceProject(ctx context.Context, cfg WorkspaceProjectConfig) (WorkspaceProjectInfo, error) DestroyWorkspaceProject(ctx context.Context, info WorkspaceProjectInfo) error - DestroyWorkspaceProjectWorktree(ctx context.Context, info WorkspaceRepoInfo) error - ForceDestroyWorkspaceProjectWorktree(ctx context.Context, info WorkspaceRepoInfo) error - RestoreWorkspaceProjectWorktree(ctx context.Context, info WorkspaceRepoInfo) (WorkspaceRepoInfo, error) - StashWorkspaceProjectWorktree(ctx context.Context, info WorkspaceRepoInfo) (ref string, err error) - ApplyWorkspaceProjectPreserved(ctx context.Context, info WorkspaceRepoInfo, ref string) error } // Workspace-level sentinels surfaced through Create/Restore/Destroy so callers @@ -195,6 +190,10 @@ type WorkspaceConfig struct { // BaseBranch is the per-project default branch new session branches are // created from. Empty falls back to the workspace adapter's own default. BaseBranch string + // RepoPath optionally overrides ProjectID-based repo resolution. + RepoPath string + // Path optionally supplies an existing managed worktree path for restore. + Path string } // WorkspaceInfo describes a created workspace — where it lives and its branch. @@ -203,6 +202,10 @@ type WorkspaceInfo struct { Branch string SessionID domain.SessionID ProjectID domain.ProjectID + // RepoPath optionally overrides ProjectID-based repo resolution. It is used + // when the normal workspace lifecycle primitives operate on one child repo + // inside a workspace project. + RepoPath string } // WorkspaceProjectConfig describes a multi-repo workspace session. RootRepoPath diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index b32d61427..e47c1ef25 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -1022,12 +1022,8 @@ func (m *Manager) sessionWorktreeRowsToRepoInfos(ctx context.Context, project do } func (m *Manager) saveAndTeardownWorkspaceProject(ctx context.Context, rec domain.SessionRecord, rows []ports.WorkspaceRepoInfo, destroyRuntime bool) error { - adapter, ok := m.workspace.(ports.WorkspaceProject) - if !ok { - return errors.New("workspace project lifecycle is not supported by workspace adapter") - } for _, row := range rows { - ref, err := adapter.StashWorkspaceProjectWorktree(ctx, row) + ref, err := m.workspace.StashUncommitted(ctx, workspaceInfoFromRepoInfo(row)) if err != nil { return fmt.Errorf("save %s repo %s: stash: %w", rec.ID, row.RepoName, err) } @@ -1053,7 +1049,7 @@ func (m *Manager) saveAndTeardownWorkspaceProject(ctx context.Context, rec domai } } for i := len(rows) - 1; i >= 0; i-- { - if err := adapter.ForceDestroyWorkspaceProjectWorktree(ctx, rows[i]); err != nil { + if err := m.workspace.ForceDestroy(ctx, workspaceInfoFromRepoInfo(rows[i])); err != nil { m.logger.Warn("save-teardown-all: force destroy failed", "sessionID", rec.ID, "repo", rows[i].RepoName, "error", err) } } @@ -1061,36 +1057,17 @@ func (m *Manager) saveAndTeardownWorkspaceProject(ctx context.Context, rec domai } func (m *Manager) destroyWorkspaceProjectRows(ctx context.Context, rows []ports.WorkspaceRepoInfo) (bool, error) { - adapter, ok := m.workspace.(ports.WorkspaceProject) - if !ok { - return false, errors.New("workspace project lifecycle is not supported by workspace adapter") - } cleaned := false var firstErr error for i := len(rows) - 1; i >= 0; i-- { if rows[i].Path == "" { continue } - if err := adapter.DestroyWorkspaceProjectWorktree(ctx, rows[i]); err != nil { + info := workspaceInfoFromRepoInfo(rows[i]) + if err := m.workspace.Destroy(ctx, info); err != nil { preservedRef := "" if errors.Is(err, ports.ErrWorkspaceDirty) { - ref, preserveErr := adapter.StashWorkspaceProjectWorktree(ctx, rows[i]) - if preserveErr == nil { - preservedRef = ref - if stateErr := m.upsertWorkspaceProjectRowState(ctx, rows[i], "unavailable", ref); stateErr != nil { - preserveErr = stateErr - } - } - if preserveErr == nil { - forceErr := adapter.ForceDestroyWorkspaceProjectWorktree(ctx, rows[i]) - if forceErr == nil { - cleaned = true - continue - } - err = forceErr - } else { - err = preserveErr - } + return cleaned, err } if stateErr := m.upsertWorkspaceProjectRowState(ctx, rows[i], "retry_remove", preservedRef); stateErr != nil && firstErr == nil { firstErr = stateErr @@ -1121,18 +1098,22 @@ func (m *Manager) upsertWorkspaceProjectRowState(ctx context.Context, row ports. } func (m *Manager) restoreWorkspaceProjectRows(ctx context.Context, rows []ports.WorkspaceRepoInfo) (ports.WorkspaceRepoInfo, error) { - adapter, ok := m.workspace.(ports.WorkspaceProject) - if !ok { - return ports.WorkspaceRepoInfo{}, errors.New("workspace project lifecycle is not supported by workspace adapter") - } var root ports.WorkspaceRepoInfo for _, row := range rows { - restored, err := adapter.RestoreWorkspaceProjectWorktree(ctx, row) + restored, err := m.workspace.Restore(ctx, ports.WorkspaceConfig{ + ProjectID: row.ProjectID, + SessionID: row.SessionID, + Branch: row.Branch, + RepoPath: row.RepoPath, + Path: row.Path, + }) if err != nil { return ports.WorkspaceRepoInfo{}, fmt.Errorf("repo %s: %w", row.RepoName, err) } - if restored.RepoName == domain.RootWorkspaceRepoName { - root = restored + row.Path = restored.Path + row.Branch = restored.Branch + if row.RepoName == domain.RootWorkspaceRepoName { + root = row } } if root.Path == "" { @@ -1142,10 +1123,6 @@ func (m *Manager) restoreWorkspaceProjectRows(ctx context.Context, rows []ports. } func (m *Manager) applyWorkspaceProjectPreserved(ctx context.Context, rows []ports.WorkspaceRepoInfo) { - adapter, ok := m.workspace.(ports.WorkspaceProject) - if !ok { - return - } for _, row := range rows { var preserveRef string sessionRows, err := m.store.ListSessionWorktrees(ctx, row.SessionID) @@ -1162,7 +1139,7 @@ func (m *Manager) applyWorkspaceProjectPreserved(ctx context.Context, rows []por if preserveRef == "" { continue } - if applyErr := adapter.ApplyWorkspaceProjectPreserved(ctx, row, preserveRef); applyErr != nil { + if applyErr := m.workspace.ApplyPreserved(ctx, workspaceInfoFromRepoInfo(row), preserveRef); applyErr != nil { if errors.Is(applyErr, ports.ErrPreservedConflict) { m.logger.Warn("restore-all: apply preserved produced conflicts; agent relaunched with conflict markers in place", "sessionID", row.SessionID, "repo", row.RepoName, "ref", preserveRef, "error", applyErr) @@ -1722,6 +1699,7 @@ func workspaceInfoFromRepoInfo(info ports.WorkspaceRepoInfo) ports.WorkspaceInfo Branch: info.Branch, SessionID: info.SessionID, ProjectID: info.ProjectID, + RepoPath: info.RepoPath, } } diff --git a/backend/internal/session_manager/manager_test.go b/backend/internal/session_manager/manager_test.go index 40bcf5493..b8d0047b2 100644 --- a/backend/internal/session_manager/manager_test.go +++ b/backend/internal/session_manager/manager_test.go @@ -333,7 +333,14 @@ func (w *fakeWorkspace) CreateWorkspaceProject(_ context.Context, cfg ports.Work } return out, nil } -func (w *fakeWorkspace) Destroy(context.Context, ports.WorkspaceInfo) error { +func (w *fakeWorkspace) Destroy(_ context.Context, info ports.WorkspaceInfo) error { + if info.RepoPath != "" { + entry := "Destroy:" + fakeWorkspaceRepoName(info) + w.calls = append(w.calls, entry) + if w.sharedLog != nil { + *w.sharedLog = append(*w.sharedLog, entry) + } + } w.destroyed++ return w.destroyErr } @@ -341,51 +348,23 @@ func (w *fakeWorkspace) DestroyWorkspaceProject(context.Context, ports.Workspace w.projectDestroyed++ return w.destroyErr } -func (w *fakeWorkspace) DestroyWorkspaceProjectWorktree(_ context.Context, info ports.WorkspaceRepoInfo) error { - entry := "DestroyWorkspaceProjectWorktree:" + info.RepoName - w.calls = append(w.calls, entry) - if w.sharedLog != nil { - *w.sharedLog = append(*w.sharedLog, entry) - } - return w.destroyErr -} -func (w *fakeWorkspace) ForceDestroyWorkspaceProjectWorktree(_ context.Context, info ports.WorkspaceRepoInfo) error { - entry := "ForceDestroyWorkspaceProjectWorktree:" + info.RepoName - w.calls = append(w.calls, entry) - if w.sharedLog != nil { - *w.sharedLog = append(*w.sharedLog, entry) - } - return w.forceDestroyErr -} -func (w *fakeWorkspace) RestoreWorkspaceProjectWorktree(_ context.Context, info ports.WorkspaceRepoInfo) (ports.WorkspaceRepoInfo, error) { - entry := "RestoreWorkspaceProjectWorktree:" + info.RepoName - w.calls = append(w.calls, entry) - return info, nil -} -func (w *fakeWorkspace) StashWorkspaceProjectWorktree(_ context.Context, info ports.WorkspaceRepoInfo) (string, error) { - w.stashCalls++ - entry := "StashWorkspaceProjectWorktree:" + info.RepoName - w.calls = append(w.calls, entry) - if w.sharedLog != nil { - *w.sharedLog = append(*w.sharedLog, entry) - } - if w.stashErr != nil { - return "", w.stashErr - } - if w.stashRef == "" { - return "", nil - } - return w.stashRef + "/" + info.RepoName, nil -} -func (w *fakeWorkspace) ApplyWorkspaceProjectPreserved(_ context.Context, info ports.WorkspaceRepoInfo, ref string) error { - w.calls = append(w.calls, "ApplyWorkspaceProjectPreserved:"+info.RepoName+":"+ref) - return w.applyErr -} func (w *fakeWorkspace) Restore(ctx context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) { + if cfg.RepoPath != "" { + entry := "Restore:" + fakeWorkspaceRepoName(ports.WorkspaceInfo{ + Path: cfg.Path, + SessionID: cfg.SessionID, + RepoPath: cfg.RepoPath, + }) + w.calls = append(w.calls, entry) + return ports.WorkspaceInfo{Path: cfg.Path, Branch: cfg.Branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID, RepoPath: cfg.RepoPath}, nil + } return w.Create(ctx, cfg) } func (w *fakeWorkspace) ForceDestroy(_ context.Context, info ports.WorkspaceInfo) error { entry := "ForceDestroy:" + string(info.SessionID) + if info.RepoPath != "" { + entry = "ForceDestroy:" + fakeWorkspaceRepoName(info) + } w.calls = append(w.calls, entry) if w.sharedLog != nil { *w.sharedLog = append(*w.sharedLog, entry) @@ -395,17 +374,34 @@ func (w *fakeWorkspace) ForceDestroy(_ context.Context, info ports.WorkspaceInfo func (w *fakeWorkspace) StashUncommitted(_ context.Context, info ports.WorkspaceInfo) (string, error) { w.stashCalls++ entry := "StashUncommitted:" + string(info.SessionID) + if info.RepoPath != "" { + entry = "StashUncommitted:" + fakeWorkspaceRepoName(info) + } w.calls = append(w.calls, entry) if w.sharedLog != nil { *w.sharedLog = append(*w.sharedLog, entry) } - return w.stashRef, w.stashErr + if w.stashErr != nil || w.stashRef == "" || info.RepoPath == "" { + return w.stashRef, w.stashErr + } + return w.stashRef + "/" + fakeWorkspaceRepoName(info), nil } func (w *fakeWorkspace) ApplyPreserved(_ context.Context, info ports.WorkspaceInfo, ref string) error { - w.calls = append(w.calls, "ApplyPreserved:"+string(info.SessionID)) + entry := "ApplyPreserved:" + string(info.SessionID) + if info.RepoPath != "" { + entry = "ApplyPreserved:" + fakeWorkspaceRepoName(info) + ":" + ref + } + w.calls = append(w.calls, entry) return w.applyErr } +func fakeWorkspaceRepoName(info ports.WorkspaceInfo) string { + if filepath.Base(info.Path) == string(info.SessionID) { + return domain.RootWorkspaceRepoName + } + return filepath.Base(info.Path) +} + type fakeMessenger struct{ msgs []string } func (m *fakeMessenger) Send(_ context.Context, _ domain.SessionID, msg string) error { @@ -836,16 +832,41 @@ func TestKill_WorkspaceProjectDestroysChildrenBeforeRoot(t *testing.T) { if rt.destroyed != 1 { t.Fatalf("runtime destroy calls = %d, want 1", rt.destroyed) } - want := []string{"DestroyWorkspaceProjectWorktree:api", "DestroyWorkspaceProjectWorktree:__root__"} + want := []string{"Destroy:api", "Destroy:__root__"} if got := ws.calls; strings.Join(got, ",") != strings.Join(want, ",") { t.Fatalf("destroy order = %v, want %v", got, want) } } -func TestKill_WorkspaceProjectDirtyRowsArePreservedAndForceRemoved(t *testing.T) { +func TestKill_WorkspaceProjectSkipsUnregisteredChildRows(t *testing.T) { + m, st, _, ws := newManager() + st.projects["mer"] = domain.ProjectRecord{ID: "mer", Path: "/repo/mer", Kind: domain.ProjectKindWorkspace, Config: testRoleAgents()} + st.workspaceRepo["mer"] = []domain.WorkspaceRepoRecord{{Name: "api", RelativePath: "api"}} + st.sessions["mer-1"] = domain.SessionRecord{ + ID: "mer-1", + ProjectID: "mer", + Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1", RuntimeHandleID: "h1"}, + Activity: domain.Activity{State: domain.ActivityActive}, + } + st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{ + {SessionID: "mer-1", RepoName: domain.RootWorkspaceRepoName, Branch: "ao/mer-1", WorktreePath: "/ws/mer-1"}, + {SessionID: "mer-1", RepoName: "old-api", Branch: "ao/mer-1", WorktreePath: "/ws/mer-1/old-api"}, + {SessionID: "mer-1", RepoName: "api", Branch: "ao/mer-1", WorktreePath: "/ws/mer-1/api"}, + } + + freed, err := m.Kill(ctx, "mer-1") + if err != nil || !freed { + t.Fatalf("freed=%v err=%v", freed, err) + } + want := []string{"Destroy:api", "Destroy:__root__"} + if got := ws.calls; strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("destroy calls = %v, want %v", got, want) + } +} + +func TestKill_WorkspaceProjectDirtyRowRefusesRemoval(t *testing.T) { m, st, _, ws := newManager() ws.destroyErr = fmt.Errorf("dirty: %w", ports.ErrWorkspaceDirty) - ws.stashRef = "refs/ao/preserved/mer-1" st.projects["mer"] = domain.ProjectRecord{ID: "mer", Path: "/repo/mer", Kind: domain.ProjectKindWorkspace, Config: testRoleAgents()} st.workspaceRepo["mer"] = []domain.WorkspaceRepoRecord{{Name: "api", RelativePath: "api"}} st.sessions["mer-1"] = domain.SessionRecord{ @@ -860,33 +881,15 @@ func TestKill_WorkspaceProjectDirtyRowsArePreservedAndForceRemoved(t *testing.T) } freed, err := m.Kill(ctx, "mer-1") - if err != nil || !freed { - t.Fatalf("freed=%v err=%v, want dirty rows preserved and removed", freed, err) - } - want := []string{ - "DestroyWorkspaceProjectWorktree:api", - "StashWorkspaceProjectWorktree:api", - "ForceDestroyWorkspaceProjectWorktree:api", - "DestroyWorkspaceProjectWorktree:__root__", - "StashWorkspaceProjectWorktree:__root__", - "ForceDestroyWorkspaceProjectWorktree:__root__", + if err != nil || freed { + t.Fatalf("freed=%v err=%v, want dirty row to preserve workspace", freed, err) } + want := []string{"Destroy:api"} if got := ws.calls; strings.Join(got, ",") != strings.Join(want, ",") { t.Fatalf("calls = %v, want %v", got, want) } - refs := map[string]string{} - states := map[string]string{} - for _, row := range st.worktrees["mer-1"] { - refs[row.RepoName] = row.PreservedRef - states[row.RepoName] = row.State - } - if refs["api"] != "refs/ao/preserved/mer-1/api" || refs[domain.RootWorkspaceRepoName] != "refs/ao/preserved/mer-1/__root__" { - t.Fatalf("preserved refs = %v", refs) - } - if states["api"] != "unavailable" || states[domain.RootWorkspaceRepoName] != "unavailable" { - t.Fatalf("states = %v, want unavailable rows so RestoreAll does not resurrect killed session", states) - } } + func TestRestore_ReopensTerminal(t *testing.T) { m, st, rt, _ := newManager() seedTerminal(st, "mer-1", domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "b", AgentSessionID: "agent-x"}) @@ -997,7 +1000,7 @@ func TestCleanup_WorkspaceProjectDestroysChildrenBeforeRoot(t *testing.T) { if len(res.Cleaned) != 1 || res.Cleaned[0] != "mer-1" { t.Fatalf("cleaned = %v, want mer-1", res.Cleaned) } - want := []string{"DestroyWorkspaceProjectWorktree:api", "DestroyWorkspaceProjectWorktree:__root__"} + want := []string{"Destroy:api", "Destroy:__root__"} if got := ws.calls; strings.Join(got, ",") != strings.Join(want, ",") { t.Fatalf("destroy order = %v, want %v", got, want) } @@ -2002,7 +2005,7 @@ func TestSaveAndTeardownAll_WorkspaceProjectPreservesEachRepoAndRemovesChildrenF if refs[domain.RootWorkspaceRepoName] != "refs/ao/preserved/mer-1/__root__" || refs["api"] != "refs/ao/preserved/mer-1/api" { t.Fatalf("preserved refs = %v", refs) } - wantSuffix := []string{"ForceDestroyWorkspaceProjectWorktree:api", "ForceDestroyWorkspaceProjectWorktree:__root__"} + wantSuffix := []string{"ForceDestroy:api", "ForceDestroy:__root__"} gotSuffix := ws.calls[len(ws.calls)-2:] if strings.Join(gotSuffix, ",") != strings.Join(wantSuffix, ",") { t.Fatalf("force destroy suffix = %v, want %v; all calls %v", gotSuffix, wantSuffix, ws.calls) @@ -2284,13 +2287,13 @@ func TestRestoreAll_WorkspaceProjectRestoresAndAppliesEachRepo(t *testing.T) { if err := m.RestoreAll(ctx); err != nil { t.Fatalf("RestoreAll err = %v", err) } - wantPrefix := []string{"RestoreWorkspaceProjectWorktree:__root__", "RestoreWorkspaceProjectWorktree:api"} + wantPrefix := []string{"Restore:__root__", "Restore:api"} if got := ws.calls[:2]; strings.Join(got, ",") != strings.Join(wantPrefix, ",") { t.Fatalf("restore prefix = %v, want %v; all calls %v", got, wantPrefix, ws.calls) } applied := strings.Join(ws.calls, ",") - if !strings.Contains(applied, "ApplyWorkspaceProjectPreserved:__root__:refs/ao/preserved/mer-1") || - !strings.Contains(applied, "ApplyWorkspaceProjectPreserved:api:refs/ao/preserved/mer-1") { + if !strings.Contains(applied, "ApplyPreserved:__root__:refs/ao/preserved/mer-1") || + !strings.Contains(applied, "ApplyPreserved:api:refs/ao/preserved/mer-1") { t.Fatalf("apply calls missing, got %v", ws.calls) } if rt.created != 1 {