diff --git a/apps/backend/internal/agent/lifecycle/env_preparer.go b/apps/backend/internal/agent/lifecycle/env_preparer.go index edee188546..e9b7a5b6d0 100644 --- a/apps/backend/internal/agent/lifecycle/env_preparer.go +++ b/apps/backend/internal/agent/lifecycle/env_preparer.go @@ -20,6 +20,23 @@ const ( PrepareStepSkipped PrepareStepStatus = "skipped" ) +// RepoPrepareSpec describes one repository for multi-repo environment preparation. +// Mirrors the per-repo prepare fields that EnvPrepareRequest historically +// carried at the top level. When EnvPrepareRequest.Repositories is non-empty, +// each entry produces one prepared worktree under the shared TaskDirName. +type RepoPrepareSpec struct { + RepositoryID string + RepositoryPath string + RepoName string + BaseBranch string + CheckoutBranch string + WorktreeID string + WorktreeBranch string + WorktreeBranchPrefix string + PullBeforeWorktree bool + RepoSetupScript string +} + // EnvPrepareRequest contains the parameters for environment preparation. type EnvPrepareRequest struct { TaskID string @@ -44,9 +61,40 @@ type EnvPrepareRequest struct { TaskDirName string // Per-task directory name within the workspace (e.g. "task-abc123") RepoName string // Repository slug used with TaskDirName to locate checkouts + // Repositories carries one entry per repository when the request is + // multi-repo. When non-empty it is the source of truth; the legacy + // single-repo top-level fields above are populated from Repositories[0] + // for callers that have not yet been updated. + Repositories []RepoPrepareSpec + Env map[string]string } +// RepoSpecs returns the per-repo prepare specs for this request. When +// Repositories is set it is returned verbatim; otherwise a length-1 list is +// synthesized from the legacy top-level single-repo fields. Returns an empty +// slice for repo-less requests. +func (r *EnvPrepareRequest) RepoSpecs() []RepoPrepareSpec { + if len(r.Repositories) > 0 { + return r.Repositories + } + if r.RepositoryID == "" && r.RepositoryPath == "" { + return nil + } + return []RepoPrepareSpec{{ + RepositoryID: r.RepositoryID, + RepositoryPath: r.RepositoryPath, + RepoName: r.RepoName, + BaseBranch: r.BaseBranch, + CheckoutBranch: r.CheckoutBranch, + WorktreeID: r.WorktreeID, + WorktreeBranch: r.WorktreeBranch, + WorktreeBranchPrefix: r.WorktreeBranchPrefix, + PullBeforeWorktree: r.PullBeforeWorktree, + RepoSetupScript: r.RepoSetupScript, + }} +} + // PrepareStep represents a single step in the preparation process. type PrepareStep struct { Name string `json:"name"` @@ -60,6 +108,18 @@ type PrepareStep struct { EndedAt *time.Time `json:"ended_at,omitempty"` } +// RepoWorktreeResult is the per-repository outcome of environment preparation. +// Populated by preparers that handle multi-repo launches; each entry corresponds +// to one RepoPrepareSpec from the request. +type RepoWorktreeResult struct { + RepositoryID string `json:"repository_id"` + WorktreeID string `json:"worktree_id,omitempty"` + WorktreeBranch string `json:"worktree_branch,omitempty"` + WorktreePath string `json:"worktree_path,omitempty"` + MainRepoGitDir string `json:"main_repo_git_dir,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` +} + // EnvPrepareResult contains the result of environment preparation. type EnvPrepareResult struct { Success bool `json:"success"` @@ -68,10 +128,15 @@ type EnvPrepareResult struct { ErrorMessage string `json:"error_message,omitempty"` Duration time.Duration `json:"duration"` - // Worktree fields (populated when worktree preparer runs) + // Worktree fields (populated when worktree preparer runs). + // Legacy single-worktree fields; for multi-repo results they mirror Worktrees[0]. WorktreeID string `json:"worktree_id,omitempty"` WorktreeBranch string `json:"worktree_branch,omitempty"` MainRepoGitDir string `json:"main_repo_git_dir,omitempty"` + + // Worktrees is the per-repository outcome list when the preparer ran in + // multi-repo mode. Empty for single-repo or repo-less results. + Worktrees []RepoWorktreeResult `json:"worktrees,omitempty"` } // PrepareProgressCallback is called when a preparation step changes status. diff --git a/apps/backend/internal/agent/lifecycle/manager_launch.go b/apps/backend/internal/agent/lifecycle/manager_launch.go index 6e75625bc5..bf75a5cd73 100644 --- a/apps/backend/internal/agent/lifecycle/manager_launch.go +++ b/apps/backend/internal/agent/lifecycle/manager_launch.go @@ -381,6 +381,30 @@ func (m *Manager) runEnvironmentPreparer( RepoName: req.RepoName, Env: req.Env, } + // Multi-repo: forward the repo list when the launch request carries one. + // Each per-repo entry inherits the request-level RepoSetupScript when its + // own is empty so single-repo callers continue to work unchanged. + if len(req.Repositories) > 0 { + specs := make([]RepoPrepareSpec, 0, len(req.Repositories)) + for _, r := range req.Repositories { + setup := r.RepoSetupScript + if setup == "" { + setup = repoSetupScript + } + specs = append(specs, RepoPrepareSpec{ + RepositoryID: r.RepositoryID, + RepositoryPath: r.RepositoryPath, + RepoName: r.RepoName, + BaseBranch: r.BaseBranch, + CheckoutBranch: r.CheckoutBranch, + WorktreeID: r.WorktreeID, + WorktreeBranchPrefix: r.WorktreeBranchPrefix, + PullBeforeWorktree: r.PullBeforeWorktree, + RepoSetupScript: setup, + }) + } + prepReq.Repositories = specs + } result, err := preparer.Prepare(ctx, prepReq, m.newProgressCallback(req.TaskID, req.SessionID)) if err != nil { diff --git a/apps/backend/internal/agent/lifecycle/types.go b/apps/backend/internal/agent/lifecycle/types.go index d9bd72f592..b7f02a029f 100644 --- a/apps/backend/internal/agent/lifecycle/types.go +++ b/apps/backend/internal/agent/lifecycle/types.go @@ -248,6 +248,24 @@ func (ae *AgentExecution) EndSessionSpan() { } } +// RepoLaunchSpec describes one repository for a multi-repo task launch. +// Mirrors the per-repo launch fields that LaunchRequest historically carried at +// the top level. When LaunchRequest.Repositories is set, each entry produces +// one prepared worktree under the shared TaskDirName. +type RepoLaunchSpec struct { + RepositoryID string + RepositoryPath string + RepositoryURL string // Clone URL for remote executors that need to clone + RepoName string // Repository name used as subdirectory inside TaskDirName + BaseBranch string + CheckoutBranch string + WorktreeID string // Existing worktree ID to reuse (skip creation if set) + WorktreeBranchPrefix string + PullBeforeWorktree bool + RepoSetupScript string // Repository-level setup script (optional) + RepoCleanupScript string // Repository-level cleanup script (optional) +} + // LaunchRequest contains parameters for launching an agent type LaunchRequest struct { TaskID string @@ -284,6 +302,35 @@ type LaunchRequest struct { // Task directory mode: place worktree at ~/.kandev/tasks/{TaskDirName}/{RepoName}/ TaskDirName string // Semantic task directory name (e.g. "fix-bug_ab12") RepoName string // Repository name used as subdirectory inside the task directory + + // Repositories carries one entry per repository when the launch is multi-repo. + // When non-empty it is the source of truth; the legacy single-repo top-level + // fields above are populated from Repositories[0] for callers that have not + // yet been updated. + Repositories []RepoLaunchSpec +} + +// RepoSpecs returns the per-repo launch specs for this request. When +// Repositories is set it is returned verbatim; otherwise a length-1 list is +// synthesized from the legacy top-level single-repo fields. Returns an empty +// slice for repo-less launches (e.g. quick chat). +func (r *LaunchRequest) RepoSpecs() []RepoLaunchSpec { + if len(r.Repositories) > 0 { + return r.Repositories + } + if r.RepositoryID == "" && r.RepositoryPath == "" { + return nil + } + return []RepoLaunchSpec{{ + RepositoryID: r.RepositoryID, + RepositoryPath: r.RepositoryPath, + RepoName: r.RepoName, + BaseBranch: r.BaseBranch, + CheckoutBranch: r.CheckoutBranch, + WorktreeID: r.WorktreeID, + WorktreeBranchPrefix: r.WorktreeBranchPrefix, + PullBeforeWorktree: r.PullBeforeWorktree, + }} } // MessageAttachment represents an image or file attachment for agent prompts. diff --git a/apps/backend/internal/agent/lifecycle/types_repospecs_test.go b/apps/backend/internal/agent/lifecycle/types_repospecs_test.go new file mode 100644 index 0000000000..b9cb9d1edb --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/types_repospecs_test.go @@ -0,0 +1,92 @@ +package lifecycle + +import "testing" + +func TestLaunchRequest_RepoSpecs_ReturnsExplicitListWhenSet(t *testing.T) { + req := &LaunchRequest{ + // Single-repo top-level fields are populated from Repositories[0] for + // backwards compat by callers, but RepoSpecs() must always prefer the + // explicit list when present. + RepositoryID: "legacy-repo", + RepositoryPath: "/legacy", + Repositories: []RepoLaunchSpec{ + {RepositoryID: "repo-a", RepositoryPath: "/a", BaseBranch: "main"}, + {RepositoryID: "repo-b", RepositoryPath: "/b", BaseBranch: "develop"}, + }, + } + specs := req.RepoSpecs() + if len(specs) != 2 { + t.Fatalf("expected 2 specs, got %d", len(specs)) + } + if specs[0].RepositoryID != "repo-a" || specs[1].RepositoryID != "repo-b" { + t.Errorf("unexpected order: %+v", specs) + } +} + +func TestLaunchRequest_RepoSpecs_SynthesizesFromLegacyFields(t *testing.T) { + req := &LaunchRequest{ + RepositoryID: "repo-x", + RepositoryPath: "/x", + BaseBranch: "main", + CheckoutBranch: "feature/y", + WorktreeID: "wt-1", + WorktreeBranchPrefix: "feat/", + PullBeforeWorktree: true, + RepoName: "x", + } + specs := req.RepoSpecs() + if len(specs) != 1 { + t.Fatalf("expected length-1 list synthesized, got %d", len(specs)) + } + got := specs[0] + if got.RepositoryID != "repo-x" || got.RepositoryPath != "/x" || + got.BaseBranch != "main" || got.CheckoutBranch != "feature/y" || + got.WorktreeID != "wt-1" || got.WorktreeBranchPrefix != "feat/" || + !got.PullBeforeWorktree || got.RepoName != "x" { + t.Errorf("synthesized spec mismatch: %+v", got) + } +} + +func TestLaunchRequest_RepoSpecs_NilForRepoLessLaunch(t *testing.T) { + req := &LaunchRequest{TaskID: "t1", SessionID: "s1"} + if specs := req.RepoSpecs(); specs != nil { + t.Errorf("expected nil for repo-less launch, got %v", specs) + } +} + +func TestEnvPrepareRequest_RepoSpecs_ExplicitWins(t *testing.T) { + req := &EnvPrepareRequest{ + RepositoryID: "legacy", + RepositoryPath: "/legacy", + Repositories: []RepoPrepareSpec{ + {RepositoryID: "a", RepositoryPath: "/a"}, + }, + } + specs := req.RepoSpecs() + if len(specs) != 1 || specs[0].RepositoryID != "a" { + t.Fatalf("expected explicit list to win; got %+v", specs) + } +} + +func TestEnvPrepareRequest_RepoSpecs_SynthesizedCarriesRepoSetupScript(t *testing.T) { + req := &EnvPrepareRequest{ + RepositoryID: "r1", + RepositoryPath: "/r1", + BaseBranch: "main", + RepoSetupScript: "make install", + } + specs := req.RepoSpecs() + if len(specs) != 1 { + t.Fatalf("expected 1 spec, got %d", len(specs)) + } + if specs[0].RepoSetupScript != "make install" { + t.Errorf("repo setup script not propagated: %+v", specs[0]) + } +} + +func TestEnvPrepareRequest_RepoSpecs_NilForRepoLess(t *testing.T) { + req := &EnvPrepareRequest{} + if specs := req.RepoSpecs(); specs != nil { + t.Errorf("expected nil, got %+v", specs) + } +}