feat(lifecycle): add Repositories list to LaunchRequest and EnvPrepareRequest
Phase 1.1 of multi-repository task support: extends the lifecycle types with explicit per-repo lists, while keeping the legacy single-repo top-level fields as the synthesized fallback. No caller behavior changes yet — preparers still see length-1 specs for single-repo launches. - LaunchRequest.Repositories []RepoLaunchSpec + RepoSpecs() helper. - EnvPrepareRequest.Repositories []RepoPrepareSpec + RepoSpecs() helper. - EnvPrepareResult.Worktrees []RepoWorktreeResult for per-repo outcomes. - runEnvironmentPreparer forwards req.Repositories into the prepare request, inheriting the request-level RepoSetupScript when each per-repo entry has none. - Tests cover explicit-list precedence, synthesized fallback, and repo-less requests. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user