feat: materialize workspace project sessions
This commit is contained in:
parent
6a7ba46078
commit
d9bc1f1931
|
|
@ -84,6 +84,7 @@ type Workspace struct {
|
|||
type commandRunner func(ctx context.Context, binary string, args ...string) ([]byte, error)
|
||||
|
||||
var _ ports.Workspace = (*Workspace)(nil)
|
||||
var _ ports.WorkspaceProject = (*Workspace)(nil)
|
||||
|
||||
// New builds a gitworktree Workspace, validating that ManagedRoot and
|
||||
// RepoResolver are set and resolving the root to an absolute, symlink-free path.
|
||||
|
|
@ -143,6 +144,115 @@ func (w *Workspace) Create(ctx context.Context, cfg ports.WorkspaceConfig) (port
|
|||
return ports.WorkspaceInfo{Path: path, Branch: cfg.Branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID}, nil
|
||||
}
|
||||
|
||||
// CreateWorkspaceProject materialises a root-as-repo workspace session: the
|
||||
// parent repo worktree is created at the session root, then each registered
|
||||
// child repo is created at its relative path inside that root. All repos share
|
||||
// one branch name; if the requested branch already exists in any repo, one
|
||||
// suffixed branch that is free in every repo is selected and used everywhere.
|
||||
func (w *Workspace) CreateWorkspaceProject(ctx context.Context, cfg ports.WorkspaceProjectConfig) (ports.WorkspaceProjectInfo, error) {
|
||||
if err := validateWorkspaceProjectConfig(cfg); err != nil {
|
||||
return ports.WorkspaceProjectInfo{}, err
|
||||
}
|
||||
rootRepo, err := physicalAbs(cfg.RootRepoPath)
|
||||
if err != nil {
|
||||
return ports.WorkspaceProjectInfo{}, fmt.Errorf("gitworktree: root repo path: %w", err)
|
||||
}
|
||||
rootPath, err := w.managedPath(ports.WorkspaceConfig{
|
||||
ProjectID: cfg.ProjectID,
|
||||
SessionID: cfg.SessionID,
|
||||
Kind: cfg.Kind,
|
||||
SessionPrefix: cfg.SessionPrefix,
|
||||
Branch: firstNonEmpty(cfg.Branch, defaultSessionBranchName(cfg.SessionID)),
|
||||
})
|
||||
if err != nil {
|
||||
return ports.WorkspaceProjectInfo{}, err
|
||||
}
|
||||
repos := make([]workspaceProjectRepo, 0, len(cfg.Repos)+1)
|
||||
repos = append(repos, workspaceProjectRepo{
|
||||
name: domain.RootWorkspaceRepoName,
|
||||
repoPath: rootRepo,
|
||||
outputPath: rootPath,
|
||||
baseBranch: cfg.BaseBranch,
|
||||
})
|
||||
for _, child := range cfg.Repos {
|
||||
repoPath, err := physicalAbs(child.RepoPath)
|
||||
if err != nil {
|
||||
return ports.WorkspaceProjectInfo{}, fmt.Errorf("gitworktree: child repo %q path: %w", child.Name, err)
|
||||
}
|
||||
rel, err := cleanRelativePath(child.RelativePath)
|
||||
if err != nil {
|
||||
return ports.WorkspaceProjectInfo{}, fmt.Errorf("gitworktree: child repo %q: %w", child.Name, err)
|
||||
}
|
||||
outPath, err := w.validateManagedPath(filepath.Join(rootPath, filepath.FromSlash(rel)))
|
||||
if err != nil {
|
||||
return ports.WorkspaceProjectInfo{}, fmt.Errorf("gitworktree: child repo %q path: %w", child.Name, err)
|
||||
}
|
||||
repos = append(repos, workspaceProjectRepo{
|
||||
name: child.Name,
|
||||
relativePath: rel,
|
||||
repoPath: repoPath,
|
||||
outputPath: outPath,
|
||||
baseBranch: firstNonEmpty(child.BaseBranch, cfg.BaseBranch),
|
||||
})
|
||||
}
|
||||
branch, err := w.workspaceProjectBranch(ctx, repos, firstNonEmpty(cfg.Branch, defaultSessionBranchName(cfg.SessionID)))
|
||||
if err != nil {
|
||||
return ports.WorkspaceProjectInfo{}, err
|
||||
}
|
||||
created := make([]workspaceProjectRepo, 0, len(repos))
|
||||
out := ports.WorkspaceProjectInfo{Worktrees: make([]ports.WorkspaceRepoInfo, 0, len(repos))}
|
||||
for _, repo := range repos {
|
||||
baseSHA, err := w.createWorkspaceProjectRepo(ctx, repo, branch)
|
||||
if err != nil {
|
||||
for i := len(created) - 1; i >= 0; i-- {
|
||||
_ = w.forceDestroyPath(ctx, created[i].repoPath, created[i].outputPath)
|
||||
}
|
||||
return ports.WorkspaceProjectInfo{}, err
|
||||
}
|
||||
created = append(created, repo)
|
||||
info := ports.WorkspaceRepoInfo{
|
||||
RepoName: repo.name,
|
||||
RepoPath: repo.repoPath,
|
||||
Path: repo.outputPath,
|
||||
Branch: branch,
|
||||
BaseSHA: baseSHA,
|
||||
SessionID: cfg.SessionID,
|
||||
ProjectID: cfg.ProjectID,
|
||||
RelativePath: repo.relativePath,
|
||||
}
|
||||
out.Worktrees = append(out.Worktrees, info)
|
||||
if repo.name == domain.RootWorkspaceRepoName {
|
||||
out.Root = ports.WorkspaceInfo{Path: repo.outputPath, Branch: branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DestroyWorkspaceProject removes every worktree in a workspace project,
|
||||
// children first and the parent/root last. It uses the same force path as spawn
|
||||
// rollback because normal interactive cleanup still goes through Destroy and
|
||||
// the full dirty-preserve matrix is implemented separately.
|
||||
func (w *Workspace) DestroyWorkspaceProject(ctx context.Context, info ports.WorkspaceProjectInfo) error {
|
||||
var firstErr error
|
||||
for i := len(info.Worktrees) - 1; i >= 0; i-- {
|
||||
wt := info.Worktrees[i]
|
||||
if wt.Path == "" {
|
||||
continue
|
||||
}
|
||||
repoPath := wt.RepoPath
|
||||
if repoPath == "" {
|
||||
if firstErr == nil {
|
||||
firstErr = fmt.Errorf("gitworktree: missing repo path for worktree %q", wt.Path)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := w.forceDestroyPath(ctx, repoPath, wt.Path); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
|
@ -507,6 +617,95 @@ func (w *Workspace) addWorktree(ctx context.Context, repo, path, branch, baseBra
|
|||
return nil
|
||||
}
|
||||
|
||||
type workspaceProjectRepo struct {
|
||||
name string
|
||||
relativePath string
|
||||
repoPath string
|
||||
outputPath string
|
||||
baseBranch string
|
||||
}
|
||||
|
||||
func (w *Workspace) workspaceProjectBranch(ctx context.Context, repos []workspaceProjectRepo, requested string) (string, error) {
|
||||
branch := strings.TrimSpace(requested)
|
||||
if branch == "" {
|
||||
return "", errors.New("gitworktree: branch is required")
|
||||
}
|
||||
for i := 0; i < 100; i++ {
|
||||
candidate := branch
|
||||
if i > 0 {
|
||||
candidate = fmt.Sprintf("%s-%d", branch, i+1)
|
||||
}
|
||||
free, err := w.workspaceProjectBranchFree(ctx, repos, candidate)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if free {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("gitworktree: could not find free workspace branch for %q", branch)
|
||||
}
|
||||
|
||||
func (w *Workspace) workspaceProjectBranchFree(ctx context.Context, repos []workspaceProjectRepo, branch string) (bool, error) {
|
||||
for _, repo := range repos {
|
||||
if err := w.validateBranch(ctx, repo.repoPath, branch); err != nil {
|
||||
return false, err
|
||||
}
|
||||
exists, err := w.refExists(ctx, repo.repoPath, "refs/heads/"+branch)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if exists {
|
||||
return false, nil
|
||||
}
|
||||
records, err := w.listRecords(ctx, repo.repoPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if conflict, ok := findWorktreeByBranch(records, branch); ok && filepath.Clean(conflict.Path) != filepath.Clean(repo.outputPath) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (w *Workspace) createWorkspaceProjectRepo(ctx context.Context, repo workspaceProjectRepo, branch string) (string, error) {
|
||||
baseRef, err := w.resolveBaseRef(ctx, repo.repoPath, branch, repo.baseBranch)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNoBaseRef) {
|
||||
return "", fmt.Errorf("%w: %q has no local head, no remote, and no tag — run `git fetch` then retry", ErrBranchNotFetched, branch)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
baseSHA, err := w.revParse(ctx, repo.repoPath, baseRef)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := w.run(ctx, w.binary, worktreeAddNewBranchArgs(repo.repoPath, branch, repo.outputPath, baseRef)...); err != nil {
|
||||
return "", fmt.Errorf("gitworktree: workspace repo %q worktree add branch %q from %q: %w", repo.name, branch, baseRef, err)
|
||||
}
|
||||
return baseSHA, nil
|
||||
}
|
||||
|
||||
func (w *Workspace) forceDestroyPath(ctx context.Context, repo, path string) error {
|
||||
_, _ = w.run(ctx, w.binary, worktreeForceRemoveArgs(repo, path)...)
|
||||
if _, err := w.run(ctx, w.binary, worktreePruneArgs(repo)...); err != nil {
|
||||
return fmt.Errorf("gitworktree: worktree prune: %w", err)
|
||||
}
|
||||
if err := os.RemoveAll(path); err != nil {
|
||||
return fmt.Errorf("gitworktree: force remove path %q: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Workspace) revParse(ctx context.Context, repo, ref string) (string, error) {
|
||||
out, err := w.run(ctx, w.binary, "-C", repo, "rev-parse", "--verify", ref)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("gitworktree: rev-parse %q: %w", ref, err)
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
func (w *Workspace) validateBranch(ctx context.Context, repo, branch string) error {
|
||||
if _, err := w.run(ctx, w.binary, checkRefFormatBranchArgs(repo, branch)...); err != nil {
|
||||
return fmt.Errorf("%w: %q (%w)", ErrBranchInvalid, branch, err)
|
||||
|
|
@ -649,6 +848,37 @@ func validateConfig(cfg ports.WorkspaceConfig) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func validateWorkspaceProjectConfig(cfg ports.WorkspaceProjectConfig) error {
|
||||
if err := validateConfig(ports.WorkspaceConfig{
|
||||
ProjectID: cfg.ProjectID,
|
||||
SessionID: cfg.SessionID,
|
||||
Kind: cfg.Kind,
|
||||
SessionPrefix: cfg.SessionPrefix,
|
||||
Branch: firstNonEmpty(cfg.Branch, defaultSessionBranchName(cfg.SessionID)),
|
||||
BaseBranch: cfg.BaseBranch,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(cfg.RootRepoPath) == "" {
|
||||
return errors.New("gitworktree: root repo path is required")
|
||||
}
|
||||
for _, repo := range cfg.Repos {
|
||||
if strings.TrimSpace(repo.Name) == "" {
|
||||
return errors.New("gitworktree: child repo name is required")
|
||||
}
|
||||
if err := validatePathComponent("child repo name", repo.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(repo.RepoPath) == "" {
|
||||
return fmt.Errorf("gitworktree: child repo %q path is required", repo.Name)
|
||||
}
|
||||
if _, err := cleanRelativePath(repo.RelativePath); err != nil {
|
||||
return fmt.Errorf("gitworktree: child repo %q: %w", repo.Name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validatePathComponent rejects id values that could escape the managed root
|
||||
// once joined into a path. filepath.Join cleans `..` before validateManagedPath
|
||||
// runs, so a session id of "../other" would otherwise resolve back inside
|
||||
|
|
@ -688,6 +918,34 @@ func resolvedSessionPrefix(cfg ports.WorkspaceConfig) string {
|
|||
return id[:12]
|
||||
}
|
||||
|
||||
func defaultSessionBranchName(id domain.SessionID) string {
|
||||
return "ao/" + string(id)
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func cleanRelativePath(path string) (string, error) {
|
||||
rel := filepath.ToSlash(strings.TrimSpace(path))
|
||||
if rel == "" {
|
||||
return "", errors.New("relative path is required")
|
||||
}
|
||||
if strings.HasPrefix(rel, "/") {
|
||||
return "", fmt.Errorf("%w: relative path %q must not be absolute", ErrUnsafePath, path)
|
||||
}
|
||||
clean := filepath.ToSlash(filepath.Clean(filepath.FromSlash(rel)))
|
||||
if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") {
|
||||
return "", fmt.Errorf("%w: relative path %q escapes the workspace root", ErrUnsafePath, path)
|
||||
}
|
||||
return clean, nil
|
||||
}
|
||||
|
||||
func (w *Workspace) validateManagedPath(path string) (string, error) {
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("%w: empty path", ErrUnsafePath)
|
||||
|
|
|
|||
|
|
@ -141,6 +141,15 @@ type Workspace interface {
|
|||
ApplyPreserved(ctx context.Context, info WorkspaceInfo, ref string) error
|
||||
}
|
||||
|
||||
// WorkspaceProject is an optional extension for projects composed from a
|
||||
// root-as-repo parent plus child repositories. It materialises the parent
|
||||
// worktree at the session root and each child repo at its registered relative
|
||||
// path inside that root.
|
||||
type WorkspaceProject interface {
|
||||
CreateWorkspaceProject(ctx context.Context, cfg WorkspaceProjectConfig) (WorkspaceProjectInfo, error)
|
||||
DestroyWorkspaceProject(ctx context.Context, info WorkspaceProjectInfo) error
|
||||
}
|
||||
|
||||
// Workspace-level sentinels surfaced through Create/Restore/Destroy so callers
|
||||
// can map them to typed errors rather than collapsing every adapter failure
|
||||
// into an opaque 500. Adapters wrap these via fmt.Errorf("...: %w", sentinel).
|
||||
|
|
@ -190,3 +199,41 @@ type WorkspaceInfo struct {
|
|||
SessionID domain.SessionID
|
||||
ProjectID domain.ProjectID
|
||||
}
|
||||
|
||||
// WorkspaceProjectConfig describes a multi-repo workspace session. RootRepoPath
|
||||
// and child RepoPath values are absolute paths to the canonical repositories.
|
||||
type WorkspaceProjectConfig struct {
|
||||
ProjectID domain.ProjectID
|
||||
SessionID domain.SessionID
|
||||
Kind domain.SessionKind
|
||||
SessionPrefix string
|
||||
Branch string
|
||||
RootRepoPath string
|
||||
BaseBranch string
|
||||
Repos []WorkspaceProjectRepoConfig
|
||||
}
|
||||
|
||||
type WorkspaceProjectRepoConfig struct {
|
||||
Name string
|
||||
RelativePath string
|
||||
RepoPath string
|
||||
BaseBranch string
|
||||
}
|
||||
|
||||
// WorkspaceProjectInfo returns the root worktree plus every child worktree.
|
||||
// Worktrees are ordered root first, then children in creation order.
|
||||
type WorkspaceProjectInfo struct {
|
||||
Root WorkspaceInfo
|
||||
Worktrees []WorkspaceRepoInfo
|
||||
}
|
||||
|
||||
type WorkspaceRepoInfo struct {
|
||||
RepoName string
|
||||
RepoPath string
|
||||
Path string
|
||||
Branch string
|
||||
BaseSHA string
|
||||
SessionID domain.SessionID
|
||||
ProjectID domain.ProjectID
|
||||
RelativePath string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ type Store interface {
|
|||
// GetProject loads a project row so spawn can resolve its per-project agent
|
||||
// config into the launch command. ok=false means the project is unknown.
|
||||
GetProject(ctx context.Context, id string) (domain.ProjectRecord, bool, error)
|
||||
ListWorkspaceRepos(ctx context.Context, projectID string) ([]domain.WorkspaceRepoRecord, error)
|
||||
CreateSession(ctx context.Context, rec domain.SessionRecord) (domain.SessionRecord, error)
|
||||
GetSession(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error)
|
||||
ListSessions(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error)
|
||||
|
|
@ -221,16 +222,9 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess
|
|||
|
||||
branch := cfg.Branch
|
||||
if branch == "" {
|
||||
branch = defaultSessionBranch(id, cfg.Kind, sessionPrefix(project))
|
||||
branch = defaultSpawnBranch(id, cfg.Kind, sessionPrefix(project), project.Kind.WithDefault())
|
||||
}
|
||||
ws, err := m.workspace.Create(ctx, ports.WorkspaceConfig{
|
||||
ProjectID: cfg.ProjectID,
|
||||
SessionID: id,
|
||||
Kind: cfg.Kind,
|
||||
SessionPrefix: sessionPrefix(project),
|
||||
Branch: branch,
|
||||
BaseBranch: project.Config.WithDefaults().DefaultBranch,
|
||||
})
|
||||
ws, workspaceProject, err := m.createSessionWorkspace(ctx, project, cfg, id, branch)
|
||||
if err != nil {
|
||||
// Nothing observable exists yet — no worktree, no runtime — so the seed
|
||||
// row is deleted outright instead of accumulating as a terminated orphan
|
||||
|
|
@ -242,19 +236,19 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess
|
|||
// Per-project workspace provisioning: symlink shared files, then run any
|
||||
// post-create commands (e.g. `pnpm install`) before the agent launches.
|
||||
if err := m.provisionWorkspace(ctx, project, ws.Path); err != nil {
|
||||
_ = m.workspace.Destroy(ctx, ws)
|
||||
m.destroySpawnWorkspace(ctx, ws, workspaceProject)
|
||||
m.rollbackSpawnSeedRow(ctx, id)
|
||||
return domain.SessionRecord{}, fmt.Errorf("spawn %s: provision: %w", id, err)
|
||||
}
|
||||
|
||||
agent, ok := m.agents.Agent(cfg.Harness)
|
||||
if !ok {
|
||||
_ = m.workspace.Destroy(ctx, ws)
|
||||
m.destroySpawnWorkspace(ctx, ws, workspaceProject)
|
||||
m.rollbackSpawnSeedRow(ctx, id)
|
||||
return domain.SessionRecord{}, fmt.Errorf("spawn %s: no agent adapter for harness %q", id, cfg.Harness)
|
||||
}
|
||||
if err := m.prepareWorkspace(ctx, agent, id, ws.Path); err != nil {
|
||||
_ = m.workspace.Destroy(ctx, ws)
|
||||
m.destroySpawnWorkspace(ctx, ws, workspaceProject)
|
||||
m.rollbackSpawnSeedRow(ctx, id)
|
||||
return domain.SessionRecord{}, fmt.Errorf("spawn %s: %w", id, err)
|
||||
}
|
||||
|
|
@ -269,7 +263,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess
|
|||
Permissions: agentConfig.Permissions,
|
||||
})
|
||||
if err != nil {
|
||||
_ = m.workspace.Destroy(ctx, ws)
|
||||
m.destroySpawnWorkspace(ctx, ws, workspaceProject)
|
||||
m.rollbackSpawnSeedRow(ctx, id)
|
||||
return domain.SessionRecord{}, fmt.Errorf("spawn %s: launch command: %w", id, err)
|
||||
}
|
||||
|
|
@ -278,7 +272,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess
|
|||
// tmux happily creates a session+pane around a missing command, so an
|
||||
// unresolved binary would leak through as a "live" session that never ran.
|
||||
if err := m.validateAgentBinary(argv); err != nil {
|
||||
_ = m.workspace.Destroy(ctx, ws)
|
||||
m.destroySpawnWorkspace(ctx, ws, workspaceProject)
|
||||
m.rollbackSpawnSeedRow(ctx, id)
|
||||
return domain.SessionRecord{}, fmt.Errorf("spawn %s: %w", id, err)
|
||||
}
|
||||
|
|
@ -289,7 +283,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess
|
|||
Env: m.runtimeEnv(id, cfg.ProjectID, cfg.IssueID, project.Config.Env),
|
||||
})
|
||||
if err != nil {
|
||||
_ = m.workspace.Destroy(ctx, ws)
|
||||
m.destroySpawnWorkspace(ctx, ws, workspaceProject)
|
||||
m.rollbackSpawnSeedRow(ctx, id)
|
||||
return domain.SessionRecord{}, fmt.Errorf("spawn %s: runtime: %w", id, err)
|
||||
}
|
||||
|
|
@ -297,7 +291,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess
|
|||
metadata := domain.SessionMetadata{Branch: ws.Branch, WorkspacePath: ws.Path, RuntimeHandleID: handle.ID, Prompt: prompt}
|
||||
if err := m.lcm.MarkSpawned(ctx, id, metadata); err != nil {
|
||||
_ = m.runtime.Destroy(ctx, handle)
|
||||
_ = m.workspace.Destroy(ctx, ws)
|
||||
m.destroySpawnWorkspace(ctx, ws, workspaceProject)
|
||||
m.markSpawnFailedTerminated(ctx, id)
|
||||
return domain.SessionRecord{}, fmt.Errorf("spawn %s: completed: %w", id, err)
|
||||
}
|
||||
|
|
@ -320,6 +314,74 @@ func (m *Manager) loadProject(ctx context.Context, projectID domain.ProjectID) (
|
|||
return row, nil
|
||||
}
|
||||
|
||||
func (m *Manager) createSessionWorkspace(ctx context.Context, project domain.ProjectRecord, cfg ports.SpawnConfig, id domain.SessionID, branch string) (ports.WorkspaceInfo, *ports.WorkspaceProjectInfo, error) {
|
||||
if project.Kind.WithDefault() != domain.ProjectKindWorkspace {
|
||||
ws, err := m.workspace.Create(ctx, ports.WorkspaceConfig{
|
||||
ProjectID: cfg.ProjectID,
|
||||
SessionID: id,
|
||||
Kind: cfg.Kind,
|
||||
SessionPrefix: sessionPrefix(project),
|
||||
Branch: branch,
|
||||
BaseBranch: project.Config.WithDefaults().DefaultBranch,
|
||||
})
|
||||
return ws, nil, err
|
||||
}
|
||||
workspaceProject, ok := m.workspace.(ports.WorkspaceProject)
|
||||
if !ok {
|
||||
return ports.WorkspaceInfo{}, nil, errors.New("workspace project materialization is not supported by workspace adapter")
|
||||
}
|
||||
repos, err := m.store.ListWorkspaceRepos(ctx, project.ID)
|
||||
if err != nil {
|
||||
return ports.WorkspaceInfo{}, nil, err
|
||||
}
|
||||
childRepos := make([]ports.WorkspaceProjectRepoConfig, 0, len(repos))
|
||||
for _, repo := range repos {
|
||||
childRepos = append(childRepos, ports.WorkspaceProjectRepoConfig{
|
||||
Name: repo.Name,
|
||||
RelativePath: repo.RelativePath,
|
||||
RepoPath: filepath.Join(project.Path, filepath.FromSlash(repo.RelativePath)),
|
||||
BaseBranch: project.Config.WithDefaults().DefaultBranch,
|
||||
})
|
||||
}
|
||||
info, err := workspaceProject.CreateWorkspaceProject(ctx, ports.WorkspaceProjectConfig{
|
||||
ProjectID: cfg.ProjectID,
|
||||
SessionID: id,
|
||||
Kind: cfg.Kind,
|
||||
SessionPrefix: sessionPrefix(project),
|
||||
Branch: branch,
|
||||
RootRepoPath: project.Path,
|
||||
BaseBranch: project.Config.WithDefaults().DefaultBranch,
|
||||
Repos: childRepos,
|
||||
})
|
||||
if err != nil {
|
||||
return ports.WorkspaceInfo{}, nil, err
|
||||
}
|
||||
for _, wt := range info.Worktrees {
|
||||
if err := m.store.UpsertSessionWorktree(ctx, domain.SessionWorktreeRecord{
|
||||
SessionID: id,
|
||||
RepoName: wt.RepoName,
|
||||
Branch: wt.Branch,
|
||||
BaseSHA: wt.BaseSHA,
|
||||
WorktreePath: wt.Path,
|
||||
State: "active",
|
||||
}); err != nil {
|
||||
_ = workspaceProject.DestroyWorkspaceProject(ctx, info)
|
||||
return ports.WorkspaceInfo{}, nil, fmt.Errorf("record workspace worktree %q: %w", wt.RepoName, err)
|
||||
}
|
||||
}
|
||||
return info.Root, &info, nil
|
||||
}
|
||||
|
||||
func (m *Manager) destroySpawnWorkspace(ctx context.Context, ws ports.WorkspaceInfo, workspaceProject *ports.WorkspaceProjectInfo) {
|
||||
if workspaceProject != nil {
|
||||
if adapter, ok := m.workspace.(ports.WorkspaceProject); ok {
|
||||
_ = adapter.DestroyWorkspaceProject(ctx, *workspaceProject)
|
||||
return
|
||||
}
|
||||
}
|
||||
_ = m.workspace.Destroy(ctx, ws)
|
||||
}
|
||||
|
||||
// effectiveHarness resolves the harness for a spawn: an explicit harness wins;
|
||||
// otherwise the project's role override for the session kind applies. Empty is
|
||||
// invalid for new worker/orchestrator launches and is rejected by Spawn.
|
||||
|
|
@ -958,6 +1020,13 @@ func defaultSessionBranch(id domain.SessionID, kind domain.SessionKind, prefix s
|
|||
return "ao/" + string(id) + "/root"
|
||||
}
|
||||
|
||||
func defaultSpawnBranch(id domain.SessionID, kind domain.SessionKind, prefix string, projectKind domain.ProjectKind) string {
|
||||
if projectKind == domain.ProjectKindWorkspace {
|
||||
return "ao/" + string(id)
|
||||
}
|
||||
return defaultSessionBranch(id, kind, prefix)
|
||||
}
|
||||
|
||||
func buildPrompt(cfg ports.SpawnConfig) string {
|
||||
return cfg.Prompt
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,11 +20,13 @@ import (
|
|||
var ctx = context.Background()
|
||||
|
||||
type fakeStore struct {
|
||||
sessions map[domain.SessionID]domain.SessionRecord
|
||||
pr map[domain.SessionID]domain.PRFacts
|
||||
projects map[string]domain.ProjectRecord
|
||||
num int
|
||||
deleteErr error
|
||||
sessions map[domain.SessionID]domain.SessionRecord
|
||||
pr map[domain.SessionID]domain.PRFacts
|
||||
projects map[string]domain.ProjectRecord
|
||||
workspaceRepo map[string][]domain.WorkspaceRepoRecord
|
||||
num int
|
||||
deleteErr error
|
||||
upsertWTErr error
|
||||
// worktrees maps session ID to its saved worktree rows (shutdown-saved marker).
|
||||
worktrees map[domain.SessionID][]domain.SessionWorktreeRecord
|
||||
// sharedLog, when non-nil, receives an ordered call entry for each
|
||||
|
|
@ -34,16 +36,20 @@ type fakeStore struct {
|
|||
|
||||
func newFakeStore() *fakeStore {
|
||||
return &fakeStore{
|
||||
sessions: map[domain.SessionID]domain.SessionRecord{},
|
||||
pr: map[domain.SessionID]domain.PRFacts{},
|
||||
projects: map[string]domain.ProjectRecord{},
|
||||
worktrees: map[domain.SessionID][]domain.SessionWorktreeRecord{},
|
||||
sessions: map[domain.SessionID]domain.SessionRecord{},
|
||||
pr: map[domain.SessionID]domain.PRFacts{},
|
||||
projects: map[string]domain.ProjectRecord{},
|
||||
workspaceRepo: map[string][]domain.WorkspaceRepoRecord{},
|
||||
worktrees: map[domain.SessionID][]domain.SessionWorktreeRecord{},
|
||||
}
|
||||
}
|
||||
func (f *fakeStore) GetProject(_ context.Context, id string) (domain.ProjectRecord, bool, error) {
|
||||
r, ok := f.projects[id]
|
||||
return r, ok, nil
|
||||
}
|
||||
func (f *fakeStore) ListWorkspaceRepos(_ context.Context, projectID string) ([]domain.WorkspaceRepoRecord, error) {
|
||||
return f.workspaceRepo[projectID], nil
|
||||
}
|
||||
func (f *fakeStore) CreateSession(_ context.Context, rec domain.SessionRecord) (domain.SessionRecord, error) {
|
||||
f.num++
|
||||
rec.ID = domain.SessionID(fmt.Sprintf("%s-%d", rec.ProjectID, f.num))
|
||||
|
|
@ -96,6 +102,9 @@ func (f *fakeStore) GetDisplayPRFactsForSession(_ context.Context, id domain.Ses
|
|||
return domain.PRFacts{}, false, nil
|
||||
}
|
||||
func (f *fakeStore) UpsertSessionWorktree(_ context.Context, row domain.SessionWorktreeRecord) error {
|
||||
if f.upsertWTErr != nil {
|
||||
return f.upsertWTErr
|
||||
}
|
||||
if f.sharedLog != nil {
|
||||
*f.sharedLog = append(*f.sharedLog, "UpsertSessionWorktree:"+string(row.SessionID))
|
||||
}
|
||||
|
|
@ -248,10 +257,14 @@ type missingAgents struct{}
|
|||
func (missingAgents) Agent(domain.AgentHarness) (ports.Agent, bool) { return nil, false }
|
||||
|
||||
type fakeWorkspace struct {
|
||||
createErr error
|
||||
destroyErr error
|
||||
destroyed int
|
||||
lastCfg ports.WorkspaceConfig
|
||||
createErr error
|
||||
destroyErr error
|
||||
destroyed int
|
||||
lastCfg ports.WorkspaceConfig
|
||||
projectErr error
|
||||
projectDestroyed int
|
||||
lastProjectCfg ports.WorkspaceProjectConfig
|
||||
projectCreateInfo ports.WorkspaceProjectInfo
|
||||
// path, when set, is returned as the workspace path so provisioning tests
|
||||
// can point at a real temp directory.
|
||||
path string
|
||||
|
|
@ -280,10 +293,54 @@ func (w *fakeWorkspace) Create(_ context.Context, cfg ports.WorkspaceConfig) (po
|
|||
}
|
||||
return ports.WorkspaceInfo{Path: path, Branch: cfg.Branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID}, nil
|
||||
}
|
||||
func (w *fakeWorkspace) CreateWorkspaceProject(_ context.Context, cfg ports.WorkspaceProjectConfig) (ports.WorkspaceProjectInfo, error) {
|
||||
if w.projectErr != nil {
|
||||
return ports.WorkspaceProjectInfo{}, w.projectErr
|
||||
}
|
||||
w.lastProjectCfg = cfg
|
||||
if len(w.projectCreateInfo.Worktrees) > 0 {
|
||||
return w.projectCreateInfo, nil
|
||||
}
|
||||
rootPath := w.path
|
||||
if rootPath == "" {
|
||||
rootPath = "/ws/" + string(cfg.SessionID)
|
||||
}
|
||||
branch := cfg.Branch
|
||||
root := ports.WorkspaceInfo{Path: rootPath, Branch: branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID}
|
||||
out := ports.WorkspaceProjectInfo{
|
||||
Root: root,
|
||||
Worktrees: []ports.WorkspaceRepoInfo{{
|
||||
RepoName: domain.RootWorkspaceRepoName,
|
||||
RepoPath: cfg.RootRepoPath,
|
||||
Path: rootPath,
|
||||
Branch: branch,
|
||||
BaseSHA: "root-base",
|
||||
SessionID: cfg.SessionID,
|
||||
ProjectID: cfg.ProjectID,
|
||||
}},
|
||||
}
|
||||
for _, repo := range cfg.Repos {
|
||||
out.Worktrees = append(out.Worktrees, ports.WorkspaceRepoInfo{
|
||||
RepoName: repo.Name,
|
||||
RepoPath: repo.RepoPath,
|
||||
Path: filepath.Join(rootPath, filepath.FromSlash(repo.RelativePath)),
|
||||
Branch: branch,
|
||||
BaseSHA: repo.Name + "-base",
|
||||
SessionID: cfg.SessionID,
|
||||
ProjectID: cfg.ProjectID,
|
||||
RelativePath: repo.RelativePath,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (w *fakeWorkspace) Destroy(context.Context, ports.WorkspaceInfo) error {
|
||||
w.destroyed++
|
||||
return w.destroyErr
|
||||
}
|
||||
func (w *fakeWorkspace) DestroyWorkspaceProject(context.Context, ports.WorkspaceProjectInfo) error {
|
||||
w.projectDestroyed++
|
||||
return w.destroyErr
|
||||
}
|
||||
func (w *fakeWorkspace) Restore(ctx context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) {
|
||||
return w.Create(ctx, cfg)
|
||||
}
|
||||
|
|
@ -509,6 +566,125 @@ func TestSpawn_ParksRowTerminatedWhenSeedDeleteFails(t *testing.T) {
|
|||
t.Fatal("row must fall back to terminated when the seed delete fails")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawn_WorkspaceProjectRecordsRootAndChildWorktrees(t *testing.T) {
|
||||
st := newFakeStore()
|
||||
st.projects["mer"] = domain.ProjectRecord{
|
||||
ID: "mer",
|
||||
Path: "/repo/mer",
|
||||
Kind: domain.ProjectKindWorkspace,
|
||||
Config: testRoleAgents(),
|
||||
}
|
||||
st.workspaceRepo["mer"] = []domain.WorkspaceRepoRecord{
|
||||
{Name: "api", RelativePath: "services/api"},
|
||||
{Name: "web", RelativePath: "apps/web"},
|
||||
}
|
||||
rt := &fakeRuntime{}
|
||||
ws := &fakeWorkspace{path: "/managed/mer-1"}
|
||||
m := New(Deps{
|
||||
Runtime: rt, Agents: fakeAgents{}, Workspace: ws, Store: st,
|
||||
Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st},
|
||||
LookPath: func(string) (string, error) { return "/bin/true", nil },
|
||||
})
|
||||
|
||||
rec, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Metadata.WorkspacePath != "/managed/mer-1" {
|
||||
t.Fatalf("workspace path = %q, want root worktree path", rec.Metadata.WorkspacePath)
|
||||
}
|
||||
if rec.Metadata.Branch != "ao/mer-1" {
|
||||
t.Fatalf("workspace branch = %q, want ao/mer-1", rec.Metadata.Branch)
|
||||
}
|
||||
if got := ws.lastProjectCfg.RootRepoPath; got != "/repo/mer" {
|
||||
t.Fatalf("root repo path = %q, want /repo/mer", got)
|
||||
}
|
||||
if len(ws.lastProjectCfg.Repos) != 2 {
|
||||
t.Fatalf("child repo configs = %d, want 2", len(ws.lastProjectCfg.Repos))
|
||||
}
|
||||
if got := ws.lastProjectCfg.Repos[0].RepoPath; got != "/repo/mer/services/api" {
|
||||
t.Fatalf("api repo path = %q, want /repo/mer/services/api", got)
|
||||
}
|
||||
if got := ws.lastProjectCfg.Repos[1].RepoPath; got != "/repo/mer/apps/web" {
|
||||
t.Fatalf("web repo path = %q, want /repo/mer/apps/web", got)
|
||||
}
|
||||
rows := st.worktrees["mer-1"]
|
||||
if len(rows) != 3 {
|
||||
t.Fatalf("session worktree rows = %d, want 3: %#v", len(rows), rows)
|
||||
}
|
||||
want := map[string]string{
|
||||
domain.RootWorkspaceRepoName: "/managed/mer-1",
|
||||
"api": "/managed/mer-1/services/api",
|
||||
"web": "/managed/mer-1/apps/web",
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row.Branch != rec.Metadata.Branch {
|
||||
t.Fatalf("row %s branch = %q, want %q", row.RepoName, row.Branch, rec.Metadata.Branch)
|
||||
}
|
||||
if want[row.RepoName] != row.WorktreePath {
|
||||
t.Fatalf("row %s path = %q, want %q", row.RepoName, row.WorktreePath, want[row.RepoName])
|
||||
}
|
||||
if row.BaseSHA == "" {
|
||||
t.Fatalf("row %s missing base sha", row.RepoName)
|
||||
}
|
||||
}
|
||||
if rt.created != 1 {
|
||||
t.Fatal("runtime should be created")
|
||||
}
|
||||
if ws.destroyed != 0 || ws.projectDestroyed != 0 {
|
||||
t.Fatal("successful spawn should not destroy workspaces")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawn_WorkspaceProjectRollsBackAllWorktreesOnRuntimeFailure(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"}}
|
||||
m.runtime = &fakeRuntime{createErr: errors.New("boom")}
|
||||
if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); err == nil {
|
||||
t.Fatal("expected failure")
|
||||
}
|
||||
if ws.projectDestroyed != 1 {
|
||||
t.Fatalf("workspace project destroy calls = %d, want 1", ws.projectDestroyed)
|
||||
}
|
||||
if ws.destroyed != 0 {
|
||||
t.Fatalf("single-workspace destroy calls = %d, want 0", ws.destroyed)
|
||||
}
|
||||
if !st.sessions["mer-1"].IsTerminated {
|
||||
t.Fatal("orphaned spawn should be terminated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawn_WorkspaceProjectRollsBackWhenWorktreeRowsFail(t *testing.T) {
|
||||
m, st, rt, 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.upsertWTErr = errors.New("db locked")
|
||||
if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); err == nil || !strings.Contains(err.Error(), "record workspace worktree") {
|
||||
t.Fatalf("err = %v, want worktree row failure", err)
|
||||
}
|
||||
if ws.projectDestroyed != 1 {
|
||||
t.Fatalf("workspace project destroy calls = %d, want 1", ws.projectDestroyed)
|
||||
}
|
||||
if _, present := st.sessions["mer-1"]; present {
|
||||
t.Fatal("seed row should be deleted after workspace row failure")
|
||||
}
|
||||
if rt.created != 0 {
|
||||
t.Fatal("runtime.Create must not run when worktree row recording fails")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKill_TearsDownRuntimeAndWorkspace(t *testing.T) {
|
||||
m, st, rt, ws := newManager()
|
||||
st.sessions["mer-1"] = mkLive("mer-1")
|
||||
|
|
|
|||
Loading…
Reference in New Issue