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 }