feat: materialize workspace project sessions

This commit is contained in:
Aditi Chauhan 2026-06-27 12:55:34 +05:30
parent 6a7ba46078
commit d9bc1f1931
4 changed files with 579 additions and 29 deletions

View File

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

View File

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

View File

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

View File

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