refactor: reuse workspace lifecycle for child repos
This commit is contained in:
parent
a81b293d96
commit
3f10578c1b
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1076,12 +1076,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)
|
||||
}
|
||||
|
|
@ -1107,7 +1103,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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1115,36 +1111,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
|
||||
|
|
@ -1175,18 +1152,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 == "" {
|
||||
|
|
@ -1196,10 +1177,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)
|
||||
|
|
@ -1216,7 +1193,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)
|
||||
|
|
@ -1798,6 +1775,7 @@ func workspaceInfoFromRepoInfo(info ports.WorkspaceRepoInfo) ports.WorkspaceInfo
|
|||
Branch: info.Branch,
|
||||
SessionID: info.SessionID,
|
||||
ProjectID: info.ProjectID,
|
||||
RepoPath: info.RepoPath,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -337,7 +337,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
|
||||
}
|
||||
|
|
@ -345,51 +352,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)
|
||||
|
|
@ -399,17 +378,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 {
|
||||
|
|
@ -837,16 +833,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{
|
||||
|
|
@ -861,33 +882,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"})
|
||||
|
|
@ -998,7 +1001,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)
|
||||
}
|
||||
|
|
@ -2133,7 +2136,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)
|
||||
|
|
@ -2442,13 +2445,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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue