709 lines
27 KiB
Go
709 lines
27 KiB
Go
package backendapp
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/kandev/kandev/internal/orchestrator"
|
|
"github.com/kandev/kandev/internal/repoclone"
|
|
"github.com/kandev/kandev/internal/task/models"
|
|
taskservice "github.com/kandev/kandev/internal/task/service"
|
|
"github.com/kandev/kandev/internal/worktree"
|
|
)
|
|
|
|
// ErrRemoteRepositoryLocatorUnavailable prevents host-local repository paths
|
|
// from crossing an executor boundary that can only clone remote locators.
|
|
var ErrRemoteRepositoryLocatorUnavailable = errors.New("remote repository locator unavailable")
|
|
|
|
const legacyLocalPCExecutor = "local_pc"
|
|
|
|
// workspaceSourceMaterializerRepo is intentionally limited to the durable
|
|
// state needed after the service has transactionally persisted a batch.
|
|
type workspaceSourceMaterializerRepo interface {
|
|
GetTaskEnvironmentByTaskID(context.Context, string) (*models.TaskEnvironment, error)
|
|
UpdateTaskEnvironment(context.Context, *models.TaskEnvironment) error
|
|
ListTaskSessions(context.Context, string) ([]*models.TaskSession, error)
|
|
ListTaskRepositories(context.Context, string) ([]*models.TaskRepository, error)
|
|
ListTaskWorkspaceFolders(context.Context, string) ([]*models.TaskWorkspaceFolder, error)
|
|
GetRepository(context.Context, string) (*models.Repository, error)
|
|
}
|
|
|
|
// workspaceSourceMaterializer is the host-only materialization boundary. It
|
|
// creates only Kandev-owned directory entries; each source itself remains a
|
|
// live user-owned directory and is never copied, removed, or modified.
|
|
type workspaceSourceMaterializer struct {
|
|
repo workspaceSourceMaterializerRepo
|
|
worktreeMgr *worktree.Manager
|
|
branches *branchMaterializer
|
|
rescanner interface {
|
|
RebindWorkspaceForSession(context.Context, string, string, ...[]string) error
|
|
}
|
|
remoteMaterializer interface {
|
|
MaterializeRepositoriesForEnvironment(context.Context, string, []lifecycle.WorkspaceRepositoryMaterialization) ([]string, error)
|
|
}
|
|
hostCloner orchestrator.RepositoryHostCloner
|
|
logger *logger.Logger
|
|
}
|
|
|
|
type workspaceSourceMaterializationState struct {
|
|
environment *models.TaskEnvironment
|
|
sessions []*models.TaskSession
|
|
repositories []*models.TaskRepository
|
|
folders []*models.TaskWorkspaceFolder
|
|
entities map[string]*models.Repository
|
|
}
|
|
|
|
type hostWorkspaceMaterialization struct {
|
|
root string
|
|
oldPath string
|
|
rootExisted bool
|
|
knownWorktree map[string]bool
|
|
priorRoots []string
|
|
postRoots []string
|
|
linkUndo []ownedDirectoryLinkUndo
|
|
}
|
|
|
|
type ownedDirectoryLinkUndo struct {
|
|
Path string
|
|
PriorTarget string
|
|
}
|
|
|
|
func materializedLinkOwner(taskID, taskDirName string) worktree.OwnedDirectoryLinkOwner {
|
|
return worktree.OwnedDirectoryLinkOwner{TaskID: taskID, TaskDirName: taskDirName}
|
|
}
|
|
|
|
func newWorkspaceSourceMaterializer(repo workspaceSourceMaterializerRepo, mgr *worktree.Manager, lc *lifecycle.Manager, log *logger.Logger) *workspaceSourceMaterializer {
|
|
var rescanner interface {
|
|
RebindWorkspaceForSession(context.Context, string, string, ...[]string) error
|
|
}
|
|
if lc != nil {
|
|
rescanner = lc
|
|
}
|
|
materializer := &workspaceSourceMaterializer{repo: repo, worktreeMgr: mgr, rescanner: rescanner, remoteMaterializer: lc, logger: log.WithFields(zap.String("component", "workspace_source_materializer"))}
|
|
if branchRepo, ok := repo.(branchMaterializerRepo); ok {
|
|
materializer.branches = newBranchMaterializer(branchRepo, mgr, lc, log)
|
|
}
|
|
return materializer
|
|
}
|
|
|
|
// SetHostRepositoryCloner wires the authenticated orchestrator clone seam
|
|
// after its repository and workspace credential dependencies are configured.
|
|
func (m *workspaceSourceMaterializer) SetHostRepositoryCloner(cloner orchestrator.RepositoryHostCloner) {
|
|
if m != nil {
|
|
m.hostCloner = cloner
|
|
}
|
|
}
|
|
|
|
func (m *workspaceSourceMaterializer) MaterializeWorkspaceSources(ctx context.Context, taskID string, batch *models.WorkspaceSourceBatch) (*taskservice.WorkspaceSourceMaterializationResult, error) {
|
|
if m == nil || m.repo == nil || m.worktreeMgr == nil || batch == nil {
|
|
return &taskservice.WorkspaceSourceMaterializationResult{}, nil
|
|
}
|
|
state, err := m.loadMaterializationState(ctx, taskID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if state == nil || state.environment == nil {
|
|
return &taskservice.WorkspaceSourceMaterializationResult{}, nil
|
|
}
|
|
if !isHostWorkspaceExecutor(state.environment.ExecutorType) {
|
|
return m.materializeRemoteWorkspaceSources(ctx, taskID, state, batch)
|
|
}
|
|
return m.materializeHostWorkspaceSources(ctx, taskID, state, batch)
|
|
}
|
|
|
|
func (m *workspaceSourceMaterializer) materializeHostWorkspaceSources(ctx context.Context, taskID string, state *workspaceSourceMaterializationState, batch *models.WorkspaceSourceBatch) (_ *taskservice.WorkspaceSourceMaterializationResult, err error) {
|
|
if err := m.ensureHostRepositoryPaths(ctx, taskID, state); err != nil {
|
|
return nil, err
|
|
}
|
|
materialization, err := m.prepareHostWorkspaceMaterialization(ctx, taskID, state, batch)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
adoptedSessions := make([]*models.TaskSession, 0, len(state.sessions))
|
|
defer func() {
|
|
if err == nil {
|
|
return
|
|
}
|
|
if rollbackErr := m.rollbackHostWorkspaceMaterialization(ctx, taskID, state, materialization, adoptedSessions); rollbackErr != nil {
|
|
err = fmt.Errorf("%w; restore adopted session workspaces: %v", err, rollbackErr)
|
|
}
|
|
}()
|
|
branchMaterializations, materialized, materializeErr := m.materializeHostRuntime(ctx, taskID, materialization.root, state, batch)
|
|
materialization.linkUndo = append(materialization.linkUndo, materialized...)
|
|
if materializeErr != nil {
|
|
return nil, materializeErr
|
|
}
|
|
if state.environment.WorkspacePath != materialization.root {
|
|
state.environment.WorkspacePath, state.environment.UpdatedAt = materialization.root, time.Now().UTC()
|
|
if err = m.repo.UpdateTaskEnvironment(ctx, state.environment); err != nil {
|
|
return nil, fmt.Errorf("persist task workspace path: %w", err)
|
|
}
|
|
}
|
|
ids, adopted, adoptErr := m.adoptSessionWorkspaces(ctx, state.sessions, materialization.root, materialization.postRoots)
|
|
adoptedSessions = append(adoptedSessions, adopted...)
|
|
if adoptErr != nil {
|
|
return nil, adoptErr
|
|
}
|
|
for _, materialization := range branchMaterializations {
|
|
m.branches.finalize(materialization, ctx)
|
|
}
|
|
return &taskservice.WorkspaceSourceMaterializationResult{WorkspacePath: materialization.root, SessionIDs: ids}, nil
|
|
}
|
|
|
|
func (m *workspaceSourceMaterializer) prepareHostWorkspaceMaterialization(ctx context.Context, taskID string, state *workspaceSourceMaterializationState, batch *models.WorkspaceSourceBatch) (*hostWorkspaceMaterialization, error) {
|
|
postRoots, err := canonicalWorkspaceSourceRoots(state, batch, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
priorRoots, err := canonicalWorkspaceSourceRoots(state, batch, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
root, err := m.worktreeMgr.TaskRoot(state.environment.TaskDirName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve owned task root: %w", err)
|
|
}
|
|
worktrees, err := m.worktreeMgr.GetAllByTaskID(ctx, taskID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("snapshot task worktrees: %w", err)
|
|
}
|
|
return &hostWorkspaceMaterialization{
|
|
root: root, oldPath: state.environment.WorkspacePath, rootExisted: pathExists(root),
|
|
knownWorktree: worktreeIDs(worktrees), priorRoots: priorRoots, postRoots: postRoots,
|
|
linkUndo: make([]ownedDirectoryLinkUndo, 0, len(batch.Sources)),
|
|
}, nil
|
|
}
|
|
|
|
func worktreeIDs(worktrees []*worktree.Worktree) map[string]bool {
|
|
ids := make(map[string]bool, len(worktrees))
|
|
for _, wt := range worktrees {
|
|
if wt != nil {
|
|
ids[wt.ID] = true
|
|
}
|
|
}
|
|
return ids
|
|
}
|
|
|
|
func (m *workspaceSourceMaterializer) materializeHostRuntime(ctx context.Context, taskID, root string, state *workspaceSourceMaterializationState, batch *models.WorkspaceSourceBatch) ([]*branchMaterialization, []ownedDirectoryLinkUndo, error) {
|
|
owner := materializedLinkOwner(taskID, state.environment.TaskDirName)
|
|
if state.environment.ExecutorType == string(models.ExecutorTypeWorktree) {
|
|
created, materializations, err := m.materializeWorktreeSources(ctx, taskID, root, batch, state.folders, owner)
|
|
return materializations, created, err
|
|
}
|
|
entries, err := localWorkspaceEntries(state.repositories, state.folders, state.entities, batch)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
created, err := materializeDirectoryLinks(root, entries, "workspace source", owner)
|
|
return nil, created, err
|
|
}
|
|
|
|
func materializeDirectoryLinks(root string, entries map[string]string, description string, owner worktree.OwnedDirectoryLinkOwner) ([]ownedDirectoryLinkUndo, error) {
|
|
created := make([]ownedDirectoryLinkUndo, 0, len(entries))
|
|
for name, target := range entries {
|
|
result, err := worktree.EnsureOwnedDirectoryLink(root, name, target, owner)
|
|
if err != nil {
|
|
return created, fmt.Errorf("link %s %q: %w", description, name, err)
|
|
}
|
|
if result.Created {
|
|
created = append(created, ownedDirectoryLinkUndo{Path: result.Path, PriorTarget: result.PriorTarget})
|
|
}
|
|
}
|
|
return created, nil
|
|
}
|
|
|
|
func (m *workspaceSourceMaterializer) adoptSessionWorkspaces(ctx context.Context, sessions []*models.TaskSession, root string, sourceRoots []string) ([]string, []*models.TaskSession, error) {
|
|
ids := make([]string, 0, len(sessions))
|
|
adopted := make([]*models.TaskSession, 0, len(sessions))
|
|
for _, session := range sessions {
|
|
if m.rescanner != nil {
|
|
if err := m.rescanner.RebindWorkspaceForSession(ctx, session.ID, root, sourceRoots); err != nil {
|
|
return nil, adopted, fmt.Errorf("adopt workspace for session %s: %w", session.ID, err)
|
|
}
|
|
adopted = append(adopted, session)
|
|
}
|
|
ids = append(ids, session.ID)
|
|
}
|
|
return ids, adopted, nil
|
|
}
|
|
|
|
func (m *workspaceSourceMaterializer) rollbackHostWorkspaceMaterialization(ctx context.Context, taskID string, state *workspaceSourceMaterializationState, materialization *hostWorkspaceMaterialization, adopted []*models.TaskSession) error {
|
|
rollbackErr := m.restoreSessionWorkspaces(ctx, adopted, materialization.oldPath, materialization.priorRoots)
|
|
if err := rollbackOwnedDirectoryLinks(materialization.linkUndo); err != nil {
|
|
rollbackErr = errors.Join(rollbackErr, err)
|
|
}
|
|
m.cleanupNewWorktrees(ctx, taskID, materialization.knownWorktree)
|
|
if !materialization.rootExisted {
|
|
_ = os.Remove(materialization.root)
|
|
}
|
|
if state.environment.WorkspacePath != materialization.oldPath {
|
|
state.environment.WorkspacePath = materialization.oldPath
|
|
state.environment.UpdatedAt = time.Now().UTC()
|
|
_ = m.repo.UpdateTaskEnvironment(context.WithoutCancel(ctx), state.environment)
|
|
}
|
|
return rollbackErr
|
|
}
|
|
|
|
func rollbackOwnedDirectoryLinks(undo []ownedDirectoryLinkUndo) error {
|
|
var rollbackErr error
|
|
for index := len(undo) - 1; index >= 0; index-- {
|
|
if err := rollbackOwnedDirectoryLink(undo[index]); err != nil {
|
|
rollbackErr = errors.Join(rollbackErr, err)
|
|
}
|
|
}
|
|
return rollbackErr
|
|
}
|
|
|
|
func rollbackOwnedDirectoryLink(undo ownedDirectoryLinkUndo) error {
|
|
if undo.PriorTarget == "" {
|
|
if err := worktree.RemoveOwnedDirectoryLink(filepath.Dir(undo.Path), filepath.Base(undo.Path)); err != nil {
|
|
return fmt.Errorf("remove materialized link %q: %w", undo.Path, err)
|
|
}
|
|
return nil
|
|
}
|
|
if err := worktree.RestoreOwnedDirectoryLink(filepath.Dir(undo.Path), filepath.Base(undo.Path), undo.PriorTarget); err != nil {
|
|
return fmt.Errorf("restore repointed link %q: %w", undo.Path, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *workspaceSourceMaterializer) cleanupNewWorktrees(ctx context.Context, taskID string, known map[string]bool) {
|
|
worktrees, err := m.worktreeMgr.GetAllByTaskID(context.WithoutCancel(ctx), taskID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
created := make([]*worktree.Worktree, 0)
|
|
for _, wt := range worktrees {
|
|
if wt != nil && !known[wt.ID] {
|
|
created = append(created, wt)
|
|
}
|
|
}
|
|
if len(created) > 0 {
|
|
_ = m.worktreeMgr.CleanupWorktrees(context.WithoutCancel(ctx), created)
|
|
}
|
|
}
|
|
|
|
func (m *workspaceSourceMaterializer) restoreSessionWorkspaces(ctx context.Context, sessions []*models.TaskSession, workspacePath string, sourceRoots []string) error {
|
|
if m.rescanner == nil || len(sessions) == 0 {
|
|
return nil
|
|
}
|
|
rollbackCtx := context.WithoutCancel(ctx)
|
|
var rollbackErr error
|
|
for index := len(sessions) - 1; index >= 0; index-- {
|
|
session := sessions[index]
|
|
if err := m.rescanner.RebindWorkspaceForSession(rollbackCtx, session.ID, workspacePath, sourceRoots); err != nil {
|
|
rollbackErr = errors.Join(rollbackErr, fmt.Errorf("session %s: %w", session.ID, err))
|
|
}
|
|
}
|
|
return rollbackErr
|
|
}
|
|
|
|
func canonicalWorkspaceSourceRoots(state *workspaceSourceMaterializationState, batch *models.WorkspaceSourceBatch, includeBatch bool) ([]string, error) {
|
|
batchRepositories, batchFolders := workspaceSourceBatchIDs(batch)
|
|
collector := newWorkspaceSourceRootCollector(len(state.repositories) + len(state.folders))
|
|
if err := collector.addRepositories(state.repositories, state.entities, batchRepositories, includeBatch); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := collector.addFolders(state.folders, batchFolders, includeBatch); err != nil {
|
|
return nil, err
|
|
}
|
|
if includeBatch {
|
|
if err := collector.addUnpersistedBatchFolders(batch, batchFolders); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return collector.roots, nil
|
|
}
|
|
|
|
type workspaceSourceRootCollector struct {
|
|
roots []string
|
|
seen map[string]struct{}
|
|
}
|
|
|
|
func newWorkspaceSourceRootCollector(capacity int) *workspaceSourceRootCollector {
|
|
return &workspaceSourceRootCollector{roots: make([]string, 0, capacity), seen: make(map[string]struct{}, capacity)}
|
|
}
|
|
|
|
func (c *workspaceSourceRootCollector) addRepositories(repositories []*models.TaskRepository, entities map[string]*models.Repository, batchIDs map[string]bool, includeBatch bool) error {
|
|
for _, taskRepository := range repositories {
|
|
if !includeBatch && batchIDs[taskRepository.ID] {
|
|
continue
|
|
}
|
|
if repository := entities[taskRepository.RepositoryID]; repository != nil {
|
|
if err := c.add(repository.LocalPath); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *workspaceSourceRootCollector) addFolders(folders []*models.TaskWorkspaceFolder, batchIDs map[string]bool, includeBatch bool) error {
|
|
for _, folder := range folders {
|
|
if !includeBatch && batchIDs[folder.ID] {
|
|
continue
|
|
}
|
|
if err := c.add(folder.LocalPath); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *workspaceSourceRootCollector) addUnpersistedBatchFolders(batch *models.WorkspaceSourceBatch, batchIDs map[string]bool) error {
|
|
for _, source := range batch.Sources {
|
|
if source.Folder != nil && !batchIDs[source.Folder.ID] {
|
|
if err := c.add(source.Folder.LocalPath); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *workspaceSourceRootCollector) add(path string) error {
|
|
resolved, err := filepath.EvalSymlinks(filepath.Clean(path))
|
|
if err != nil {
|
|
return fmt.Errorf("resolve workspace source root %q: %w", path, err)
|
|
}
|
|
info, err := os.Stat(resolved)
|
|
if err != nil || !info.IsDir() {
|
|
return fmt.Errorf("workspace source root is not a directory: %s", path)
|
|
}
|
|
if _, exists := c.seen[resolved]; !exists {
|
|
c.seen[resolved] = struct{}{}
|
|
c.roots = append(c.roots, resolved)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func workspaceSourceBatchIDs(batch *models.WorkspaceSourceBatch) (map[string]bool, map[string]bool) {
|
|
repositories, folders := map[string]bool{}, map[string]bool{}
|
|
if batch == nil {
|
|
return repositories, folders
|
|
}
|
|
for _, source := range batch.Sources {
|
|
if source.Repository != nil && source.Repository.ID != "" {
|
|
repositories[source.Repository.ID] = true
|
|
}
|
|
if source.Folder != nil && source.Folder.ID != "" {
|
|
folders[source.Folder.ID] = true
|
|
}
|
|
}
|
|
return repositories, folders
|
|
}
|
|
|
|
func (m *workspaceSourceMaterializer) ensureHostRepositoryPaths(
|
|
ctx context.Context, taskID string, state *workspaceSourceMaterializationState,
|
|
) error {
|
|
sessionID := workspaceSourceCredentialSessionID(state.sessions)
|
|
for _, taskRepository := range state.repositories {
|
|
repository := state.entities[taskRepository.RepositoryID]
|
|
if repository == nil || repository.LocalPath != "" {
|
|
continue
|
|
}
|
|
if repository.ProviderOwner == "" || repository.ProviderName == "" {
|
|
return fmt.Errorf("repository %q source path is missing", taskRepository.RepositoryID)
|
|
}
|
|
if m.hostCloner == nil {
|
|
return fmt.Errorf("host repository cloner is unavailable for %q", repository.Name)
|
|
}
|
|
if sessionID == "" {
|
|
return fmt.Errorf("active task session is unavailable for repository %q", repository.Name)
|
|
}
|
|
path, err := m.hostCloner.EnsureRepositoryClonedForSession(ctx, taskID, sessionID, repository)
|
|
if err != nil {
|
|
return fmt.Errorf("clone repository %q: %w", repository.Name, err)
|
|
}
|
|
if path == "" {
|
|
return fmt.Errorf("repository %q clone produced no local path", repository.Name)
|
|
}
|
|
repository.LocalPath = path
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func workspaceSourceCredentialSessionID(sessions []*models.TaskSession) string {
|
|
for _, session := range sessions {
|
|
if session != nil && session.IsPrimary && session.ID != "" {
|
|
return session.ID
|
|
}
|
|
}
|
|
for _, session := range sessions {
|
|
if session != nil && session.ID != "" {
|
|
return session.ID
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (m *workspaceSourceMaterializer) loadMaterializationState(ctx context.Context, taskID string) (*workspaceSourceMaterializationState, error) {
|
|
environment, err := m.repo.GetTaskEnvironmentByTaskID(ctx, taskID)
|
|
if err != nil || environment == nil {
|
|
return nil, err
|
|
}
|
|
sessions, err := m.repo.ListTaskSessions(ctx, taskID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
repositories, err := m.repo.ListTaskRepositories(ctx, taskID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
folders, err := m.repo.ListTaskWorkspaceFolders(ctx, taskID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
entities := make(map[string]*models.Repository, len(repositories))
|
|
for _, taskRepository := range repositories {
|
|
entity, err := m.repo.GetRepository(ctx, taskRepository.RepositoryID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve repository source: %w", err)
|
|
}
|
|
if entity == nil {
|
|
return nil, fmt.Errorf("resolve repository source: repository %q not found", taskRepository.RepositoryID)
|
|
}
|
|
entities[taskRepository.RepositoryID] = entity
|
|
}
|
|
return &workspaceSourceMaterializationState{environment: environment, sessions: sessions, repositories: repositories, folders: folders, entities: entities}, nil
|
|
}
|
|
|
|
func (m *workspaceSourceMaterializer) materializeRemoteWorkspaceSources(ctx context.Context, taskID string, state *workspaceSourceMaterializationState, batch *models.WorkspaceSourceBatch) (*taskservice.WorkspaceSourceMaterializationResult, error) {
|
|
if m.remoteMaterializer == nil {
|
|
return nil, fmt.Errorf("remote workspace materializer is unavailable")
|
|
}
|
|
projection, err := buildRemoteWorkspaceRepositoryBatch(batch, state.entities)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ids, err := m.remoteMaterializer.MaterializeRepositoriesForEnvironment(ctx, state.environment.ID, projection)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &taskservice.WorkspaceSourceMaterializationResult{WorkspacePath: state.environment.WorkspacePath, SessionIDs: ids}, nil
|
|
}
|
|
|
|
// buildRemoteWorkspaceRepositoryBatch projects only the sources created by
|
|
// this attachment operation. Existing durable siblings belong in a fresh
|
|
// launch/resume projection, but revalidating them in a live execution would
|
|
// reject normal agent commits that advance their HEAD after attachment.
|
|
func buildRemoteWorkspaceRepositoryBatch(batch *models.WorkspaceSourceBatch, repositoryByID map[string]*models.Repository) ([]lifecycle.WorkspaceRepositoryMaterialization, error) {
|
|
if batch == nil {
|
|
return nil, nil
|
|
}
|
|
projection := make([]lifecycle.WorkspaceRepositoryMaterialization, 0, len(batch.Sources))
|
|
for _, source := range batch.Sources {
|
|
if source.Repository == nil {
|
|
continue
|
|
}
|
|
repository := repositoryByID[source.Repository.RepositoryID]
|
|
if repository == nil {
|
|
return nil, fmt.Errorf("%w: repository %q", ErrRemoteRepositoryLocatorUnavailable, source.Repository.RepositoryID)
|
|
}
|
|
locator := remoteRepositoryLocator(repository)
|
|
if locator == "" {
|
|
return nil, fmt.Errorf("%w: repository %q", ErrRemoteRepositoryLocatorUnavailable, repository.Name)
|
|
}
|
|
branch := source.Repository.CheckoutBranch
|
|
if branch == "" {
|
|
branch = source.Repository.BaseBranch
|
|
}
|
|
if branch == "" {
|
|
return nil, fmt.Errorf("%w: repository %q has no checkout ref", ErrRemoteRepositoryLocatorUnavailable, repository.Name)
|
|
}
|
|
name, err := taskservice.WorkspaceSourceRuntimeEntryName("remote", repository, source.Repository)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: repository %q has unsafe runtime name", ErrRemoteRepositoryLocatorUnavailable, repository.Name)
|
|
}
|
|
projection = append(projection, lifecycle.WorkspaceRepositoryMaterialization{RepositoryURL: locator, Destination: name, BaseBranch: source.Repository.BaseBranch, CheckoutBranch: source.Repository.CheckoutBranch})
|
|
}
|
|
return projection, nil
|
|
}
|
|
|
|
func buildRemoteWorkspaceRepositories(taskRepositories []*models.TaskRepository, repositoryByID map[string]*models.Repository) ([]lifecycle.WorkspaceRepositoryMaterialization, error) {
|
|
projection := make([]lifecycle.WorkspaceRepositoryMaterialization, 0, len(taskRepositories))
|
|
// The primary repository is already represented by the executor workspace
|
|
// root. Materialize only durable siblings in their collision-checked names.
|
|
for index, taskRepository := range taskRepositories {
|
|
if index == 0 {
|
|
continue
|
|
}
|
|
repository := repositoryByID[taskRepository.RepositoryID]
|
|
if repository == nil {
|
|
return nil, fmt.Errorf("%w: repository %q", ErrRemoteRepositoryLocatorUnavailable, taskRepository.RepositoryID)
|
|
}
|
|
locator := remoteRepositoryLocator(repository)
|
|
if locator == "" {
|
|
return nil, fmt.Errorf("%w: repository %q", ErrRemoteRepositoryLocatorUnavailable, repository.Name)
|
|
}
|
|
branch := taskRepository.CheckoutBranch
|
|
if branch == "" {
|
|
branch = taskRepository.BaseBranch
|
|
}
|
|
if branch == "" {
|
|
return nil, fmt.Errorf("%w: repository %q has no checkout ref", ErrRemoteRepositoryLocatorUnavailable, repository.Name)
|
|
}
|
|
name, err := taskservice.WorkspaceSourceRuntimeEntryName("remote", repository, taskRepository)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: repository %q has unsafe runtime name", ErrRemoteRepositoryLocatorUnavailable, repository.Name)
|
|
}
|
|
projection = append(projection, lifecycle.WorkspaceRepositoryMaterialization{RepositoryURL: locator, Destination: name, BaseBranch: taskRepository.BaseBranch, CheckoutBranch: taskRepository.CheckoutBranch})
|
|
}
|
|
return projection, nil
|
|
}
|
|
|
|
func remoteRepositoryLocator(repository *models.Repository) string {
|
|
locator := strings.TrimSpace(repository.RemoteURL)
|
|
if locator == "" {
|
|
locator = providerRepositoryLocator(repository)
|
|
}
|
|
if locator == "" || strings.HasPrefix(locator, "/") || strings.HasPrefix(locator, "file:") {
|
|
return ""
|
|
}
|
|
if strings.HasPrefix(locator, "git@") {
|
|
return locator
|
|
}
|
|
parsed, err := url.Parse(locator)
|
|
if err != nil || parsed.Host == "" || (parsed.Scheme != "https" && parsed.Scheme != "http" && parsed.Scheme != "ssh" && parsed.Scheme != "git") {
|
|
return ""
|
|
}
|
|
return locator
|
|
}
|
|
|
|
func providerRepositoryLocator(repository *models.Repository) string {
|
|
if repository.ProviderOwner == "" || repository.ProviderName == "" {
|
|
return ""
|
|
}
|
|
if strings.EqualFold(repository.Provider, "gitlab") && strings.TrimSpace(repository.ProviderHost) == "" {
|
|
return ""
|
|
}
|
|
locator, err := repoclone.CloneURLWithHost(repository.Provider, repository.ProviderHost, repository.ProviderOwner, repository.ProviderName, repoclone.ProtocolHTTPS)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return locator
|
|
}
|
|
|
|
func isHostWorkspaceExecutor(executorType string) bool {
|
|
return executorType == string(models.ExecutorTypeLocal) || executorType == legacyLocalPCExecutor || executorType == string(models.ExecutorTypeWorktree)
|
|
}
|
|
|
|
func (m *workspaceSourceMaterializer) materializeWorktreeSources(ctx context.Context, taskID, root string, batch *models.WorkspaceSourceBatch, folders []*models.TaskWorkspaceFolder, owner worktree.OwnedDirectoryLinkOwner) ([]ownedDirectoryLinkUndo, []*branchMaterialization, error) {
|
|
materializations := make([]*branchMaterialization, 0, len(batch.Sources))
|
|
for _, source := range batch.Sources {
|
|
if source.Repository != nil {
|
|
if m.branches == nil {
|
|
return nil, nil, fmt.Errorf("worktree repository materializer is unavailable")
|
|
}
|
|
materialization, err := m.branches.materializeUnfinalized(ctx, taskID, source.Repository.ID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if materialization != nil {
|
|
materializations = append(materializations, materialization)
|
|
}
|
|
}
|
|
}
|
|
entries, err := workspaceFolderEntries(folders, batch)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
created := make([]ownedDirectoryLinkUndo, 0, len(entries))
|
|
for name, target := range entries {
|
|
result, err := worktree.EnsureOwnedDirectoryLink(root, name, target, owner)
|
|
if err != nil {
|
|
return created, nil, fmt.Errorf("link worktree folder %q: %w", name, err)
|
|
}
|
|
if result.Created {
|
|
created = append(created, ownedDirectoryLinkUndo{Path: result.Path, PriorTarget: result.PriorTarget})
|
|
}
|
|
}
|
|
return created, materializations, nil
|
|
}
|
|
|
|
func localWorkspaceEntries(repos []*models.TaskRepository, folders []*models.TaskWorkspaceFolder, entities map[string]*models.Repository, batch *models.WorkspaceSourceBatch) (map[string]string, error) {
|
|
entries := map[string]string{}
|
|
add := func(name, target string) error {
|
|
if _, exists := entries[name]; exists {
|
|
return fmt.Errorf("workspace runtime entry %q collides", name)
|
|
}
|
|
entries[name] = target
|
|
return nil
|
|
}
|
|
for _, tr := range repos {
|
|
repository := entities[tr.RepositoryID]
|
|
name, err := taskservice.WorkspaceSourceRuntimeEntryName(string(models.ExecutorTypeLocal), repository, tr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := add(name, repository.LocalPath); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
folderEntries, err := workspaceFolderEntries(folders, batch)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for name, target := range folderEntries {
|
|
if err := add(name, target); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return entries, nil
|
|
}
|
|
|
|
// workspaceFolderEntries treats durable folder rows as the source of truth.
|
|
// The attachment service persists a batch before materializing it, so the
|
|
// matching batch row is already represented in folders and must not collide
|
|
// with itself. Unpersisted batch folders remain included and collision-checked.
|
|
func workspaceFolderEntries(folders []*models.TaskWorkspaceFolder, batch *models.WorkspaceSourceBatch) (map[string]string, error) {
|
|
entries := make(map[string]string, len(folders))
|
|
persistedIDs := make(map[string]struct{}, len(folders))
|
|
add := func(folder *models.TaskWorkspaceFolder) error {
|
|
if _, exists := entries[folder.DisplayName]; exists {
|
|
return fmt.Errorf("workspace runtime entry %q collides", folder.DisplayName)
|
|
}
|
|
entries[folder.DisplayName] = folder.LocalPath
|
|
return nil
|
|
}
|
|
for _, folder := range folders {
|
|
if folder == nil {
|
|
continue
|
|
}
|
|
if folder.ID != "" {
|
|
persistedIDs[folder.ID] = struct{}{}
|
|
}
|
|
if err := add(folder); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if batch == nil {
|
|
return entries, nil
|
|
}
|
|
for _, source := range batch.Sources {
|
|
folder := source.Folder
|
|
if folder == nil {
|
|
continue
|
|
}
|
|
if _, persisted := persistedIDs[folder.ID]; persisted && folder.ID != "" {
|
|
continue
|
|
}
|
|
if err := add(folder); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return entries, nil
|
|
}
|
|
|
|
func pathExists(path string) bool { _, err := os.Lstat(path); return err == nil }
|