package service import ( "context" "errors" "fmt" "net/url" "os" "regexp" "strings" "sync" "time" "github.com/google/uuid" "github.com/jmoiron/sqlx" "go.uber.org/zap" agentkubernetes "github.com/kandev/kandev/internal/agent/kubernetes" "github.com/kandev/kandev/internal/agentruntime" "github.com/kandev/kandev/internal/auth/authn" "github.com/kandev/kandev/internal/authz" "github.com/kandev/kandev/internal/common/securityutil" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/secrets" "github.com/kandev/kandev/internal/task/models" taskrepo "github.com/kandev/kandev/internal/task/repository" "github.com/kandev/kandev/internal/task/repository/repoerrors" "github.com/kandev/kandev/internal/worktree" "github.com/kandev/kandev/internal/worktree/copyfiles" ) const ( spritesTokenEnvKey = "SPRITES_API_TOKEN" workspaceDeletePageSize = 500 workspaceDeleteCleanupConcurrency = 8 ) type workflowDeleteTaskLister interface { ListTasksForDeletion(context.Context, string, string, int, int) ([]*models.Task, int, error) } var ErrWorkspaceConfirmNameMismatch = errors.New("confirm_name does not match workspace name") const maxRepositorySecretBindings = 100 const maxProviderScopeBytes = 512 var repositorySecretKeyPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) func normalizeProviderHost(provider, raw string) string { raw = strings.TrimSpace(raw) if raw == "" { if strings.EqualFold(strings.TrimSpace(provider), githubProviderName) { return githubProviderHost } return "" } if !strings.Contains(raw, "://") { raw = "https://" + raw } parsed, err := url.Parse(raw) if err != nil || parsed.Host == "" || parsed.User != nil { return "" } scheme := strings.ToLower(parsed.Scheme) if scheme != "http" && scheme != "https" { return "" } return scheme + "://" + strings.ToLower(parsed.Host) } func validateProviderScope(raw string) (string, error) { scope := strings.TrimSpace(raw) if scope == "" { return "", nil } if len(scope) > maxProviderScopeBytes || strings.ContainsRune(scope, '\x00') { return "", fmt.Errorf("%w: provider_scope is invalid", ErrInvalidRepositorySettings) } return scope, nil } // validateProviderScopeAndRepoIDPair enforces repoclone.Cloner's // WorkspaceProviderRepositoryPath invariant: a non-empty provider_scope // requires a paired provider_repo_id, because the scope-isolated clone // layout needs both to build a unique path. A bare provider_repo_id with no // scope is fine — that's the normal shape for every built-in provider // (GitHub, GitLab, Azure DevOps), none of which resolve a provider // connection scope — and falls through to the legacy owner/name layout. func validateProviderScopeAndRepoIDPair(scope, repoID string) error { if strings.TrimSpace(scope) != "" && strings.TrimSpace(repoID) == "" { return fmt.Errorf("%w: provider_scope requires a paired provider_repo_id", ErrInvalidRepositorySettings) } return nil } type workspaceDeleteTaskCleanup struct { task *models.Task sessions []*models.TaskSession worktrees []*worktree.Worktree stopTargets []taskStopTarget taskEnv *models.TaskEnvironment attachments []*models.TaskMessageAttachment cleanupJob *models.TaskResourceCleanupJob } type workspaceAttachmentLister interface { ListMessageAttachmentsByWorkspace(ctx context.Context, workspaceID string) ([]*models.TaskMessageAttachment, error) } type repositorySessionPruner interface { DeleteRepositoryIfNoActiveTaskSessions(ctx context.Context, id string) (bool, error) } // Workspace operations // CreateWorkspace creates a new workspace func (s *Service) CreateWorkspace(ctx context.Context, req *CreateWorkspaceRequest) (*models.Workspace, error) { // Authenticated callers own what they create; the request-body owner is // only honored for internal/synthetic callers (pre-auth compatibility). ownerID := req.OwnerID if userID, scoped := callerScope(ctx); scoped { ownerID = userID } workspace := &models.Workspace{ ID: uuid.New().String(), Name: req.Name, Description: req.Description, OwnerID: ownerID, DefaultExecutorID: normalizeOptionalID(req.DefaultExecutorID), DefaultEnvironmentID: normalizeOptionalID(req.DefaultEnvironmentID), DefaultAgentProfileID: normalizeOptionalID(req.DefaultAgentProfileID), DefaultConfigAgentProfileID: normalizeOptionalID(req.DefaultConfigAgentProfileID), // The tenant comes from the creating identity and from nowhere else. // There is no org field on the request on purpose: a caller must not // be able to place a workspace in another tenant. OrgID: callerOrgID(ctx), } placement, placementErr := s.placementFor(ctx, ownerID, workspace.OrgID) if placementErr != nil { return nil, placementErr } workspace.UnitID = placement var kanbanWorkflow *models.Workflow if req.BootstrapKanbanWorkflow { if s.workspaceBootstrapper == nil { err := errors.New("workspace bootstrapper is not configured") s.logger.Error("failed to create workspace with Kanban bootstrap", zap.Error(err)) return nil, err } workflow, err := s.workspaceBootstrapper.CreateWorkspaceWithKanban(ctx, workspace) if err != nil { s.logger.Error("failed to create workspace with Kanban bootstrap", zap.Error(err)) return nil, err } kanbanWorkflow = workflow } else if err := s.workspaces.CreateWorkspace(ctx, workspace); err != nil { s.logger.Error("failed to create workspace", zap.Error(err)) return nil, err } if s.workspaceDefaultsInitializer != nil { if err := s.workspaceDefaultsInitializer.InitializeWorkspaceDefaults(ctx, workspace.ID); err != nil { s.logger.Warn("failed to initialize workspace defaults", zap.String("workspace_id", workspace.ID), zap.Error(err)) } } if err := s.seedWorkspaceOwnerMember(ctx, workspace); err != nil { return nil, err } s.publishWorkspaceEvent(ctx, events.WorkspaceCreated, workspace) s.logger.Info("workspace created", zap.String("workspace_id", workspace.ID), zap.String("name", workspace.Name)) if kanbanWorkflow != nil { s.publishWorkflowEvent(ctx, events.WorkflowCreated, kanbanWorkflow) s.logger.Info("workflow created", zap.String("workflow_id", kanbanWorkflow.ID), zap.String("name", kanbanWorkflow.Name)) } return workspace, nil } // GetWorkspace retrieves a workspace by ID func (s *Service) GetWorkspace(ctx context.Context, id string) (*models.Workspace, error) { workspace, err := s.workspaces.GetWorkspace(ctx, id) if err != nil { return nil, err } if scoped := s.workspaceDecision(ctx, workspace); !scoped.CanRead() { return nil, repoerrors.ErrWorkspaceNotFound } return workspace, nil } // UpdateWorkspace updates an existing workspace func (s *Service) UpdateWorkspace(ctx context.Context, id string, req *UpdateWorkspaceRequest) (*models.Workspace, error) { workspace, err := s.workspaces.GetWorkspace(ctx, id) if err != nil { return nil, err } if err := s.requireWorkspaceManage(ctx, workspace); err != nil { return nil, err } if req.UnitID != nil { if err := s.moveWorkspaceToUnit(ctx, workspace, *req.UnitID); err != nil { return nil, err } } if req.Name != nil { workspace.Name = *req.Name } if req.Description != nil { workspace.Description = *req.Description } if req.DefaultExecutorID != nil { workspace.DefaultExecutorID = normalizeOptionalID(req.DefaultExecutorID) } if req.DefaultEnvironmentID != nil { workspace.DefaultEnvironmentID = normalizeOptionalID(req.DefaultEnvironmentID) } if req.DefaultAgentProfileID != nil { workspace.DefaultAgentProfileID = normalizeOptionalID(req.DefaultAgentProfileID) } if req.DefaultConfigAgentProfileID != nil { workspace.DefaultConfigAgentProfileID = normalizeOptionalID(req.DefaultConfigAgentProfileID) } workspace.UpdatedAt = time.Now().UTC() if err := s.workspaces.UpdateWorkspace(ctx, workspace); err != nil { s.logger.Error("failed to update workspace", zap.String("workspace_id", id), zap.Error(err)) return nil, err } s.publishWorkspaceEvent(ctx, events.WorkspaceUpdated, workspace) s.logger.Info("workspace updated", zap.String("workspace_id", workspace.ID)) return workspace, nil } // DeleteWorkspace deletes a workspace func (s *Service) DeleteWorkspace(ctx context.Context, id string) error { workspace, err := s.workspaces.GetWorkspace(ctx, id) if err != nil { return err } if err := s.requireWorkspaceManage(ctx, workspace); err != nil { return err } return s.deleteWorkspace(ctx, workspace, nil) } // DeleteWorkspaceWithConfirmName deletes a workspace only when confirmName // matches the workspace name read for the cascade and final row delete. func (s *Service) DeleteWorkspaceWithConfirmName(ctx context.Context, id, confirmName string) error { workspace, err := s.workspaces.GetWorkspace(ctx, id) if err != nil { return err } if err := s.requireWorkspaceManage(ctx, workspace); err != nil { return err } if confirmName != workspace.Name { return ErrWorkspaceConfirmNameMismatch } return s.deleteWorkspace(ctx, workspace, &confirmName) } func (s *Service) prepareWorkspaceAttachmentCleanup(ctx context.Context, workspaceID string) (*models.TaskResourceCleanupJob, error) { attachmentRepo := s.attachments if attachmentRepo == nil && s.attachmentSvc != nil { attachmentRepo = s.attachmentSvc.repo } lister, ok := attachmentRepo.(workspaceAttachmentLister) if !ok { if attachmentRepo != nil { return nil, fmt.Errorf("workspace attachment repository cannot list attachments") } if s.resourceCleanups == nil && s.attachmentSvc != nil { return nil, fmt.Errorf("workspace attachment cleanup persistence is unavailable") } return nil, nil } attachments, err := lister.ListMessageAttachmentsByWorkspace(ctx, workspaceID) if err != nil { return nil, fmt.Errorf("list workspace attachments for cleanup: %w", err) } if len(attachments) == 0 { if s.resourceCleanups == nil && s.attachmentSvc == nil { return nil, fmt.Errorf("workspace attachment cleanup executor is unavailable") } return nil, nil } if s.resourceCleanups == nil { if s.attachmentSvc != nil { return nil, fmt.Errorf("workspace attachment cleanup persistence is unavailable") } return nil, fmt.Errorf("workspace attachment cleanup executor is unavailable") } if s.attachmentSvc == nil { return nil, fmt.Errorf("workspace attachment cleanup executor is unavailable") } return s.persistTaskResourceCleanup( ctx, "", models.TaskResourceCleanupTriggerWorkspaceDelete, newTaskResourceCleanupOperationID(models.TaskResourceCleanupTriggerWorkspaceDelete, "workspace-attachments:"+workspaceID), nil, nil, nil, attachments, taskEnvironmentCleanup{}, true, false, workspaceID, ) } // DeleteOrganizationWorkspaces removes every workspace in one organization // through the same lifecycle as an ordinary workspace deletion. Authorization // is performed by the operator-only organization controller before this // narrow internal seam is called. func (s *Service) DeleteOrganizationWorkspaces(ctx context.Context, orgID string) error { if orgID == "" { return nil } workspaces, err := s.workspaces.ListWorkspaces(ctx) if err != nil { return fmt.Errorf("list organization workspaces: %w", err) } for _, workspace := range workspaces { if workspace == nil || workspace.OrgID != orgID { continue } if err := s.deleteWorkspace(ctx, workspace, nil); err != nil { return fmt.Errorf("delete workspace %s: %w", workspace.ID, err) } } return nil } func (s *Service) deleteWorkspace(ctx context.Context, workspace *models.Workspace, confirmedName *string) error { var releaseAttachmentAdmission func() if s.attachmentSvc != nil { releaseAttachmentAdmission = s.attachmentSvc.beginWorkspaceDeletion(workspace.ID) defer releaseAttachmentAdmission() } tasks, err := s.listAllTasksForWorkspaceDelete(ctx, workspace.ID) if err != nil { return err } // Runtime cleanup needs task rows before the cascade removes them. workspaceAttachmentCleanup, err := s.prepareWorkspaceAttachmentCleanup(ctx, workspace.ID) if err != nil { return err } // Record canvas artifact cleanup before the workspace cascade removes its // task and workspace rows. This keeps the release ownership boundary // durable across a process stop between the two operations. if s.canvasCleanup != nil { if err := s.canvasCleanup.CleanupWorkspaceCanvases(ctx, workspace.ID); err != nil { return fmt.Errorf("cleanup workspace canvases before workspace delete: %w", err) } } cleanups := make([]workspaceDeleteTaskCleanup, 0, len(tasks)+1) if workspaceAttachmentCleanup != nil { cleanups = append(cleanups, workspaceDeleteTaskCleanup{cleanupJob: workspaceAttachmentCleanup}) } taskCleanups, err := s.prepareWorkspaceDeleteTaskCleanups(ctx, tasks) if err != nil { cancelErr := s.cancelWorkspaceDeleteTaskCleanupJobs(ctx, cleanups) return errors.Join(err, cancelErr) } cleanups = append(cleanups, taskCleanups...) var deletedWorkspaceAttachments []*models.TaskMessageAttachment var deletedTasks []*models.Task var deletedWorkflows []*models.Workflow transactionalCleanup, hasTransactionalCleanup := s.workspaceSecretDeleter.(secrets.WorkspaceSecretTransactionalDeleter) transactionalCascade, hasTransactionalCascade := s.workspaces.(transactionalWorkspaceCascade) useTransactionalCleanup := hasTransactionalCascade && (hasTransactionalCleanup || s.attachmentSvc != nil) switch { case useTransactionalCleanup: cleanup := func(cleanupCtx context.Context, tx *sqlx.Tx) error { if hasTransactionalCleanup { if err := transactionalCleanup.DeleteWorkspaceSecretsTx(cleanupCtx, tx, workspace.ID); err != nil { return err } } if s.attachmentSvc != nil { var err error deletedWorkspaceAttachments, err = s.attachmentSvc.DeleteWorkspaceAttachmentsTx( cleanupCtx, tx, workspace.ID, ) if err != nil { return err } } return nil } if confirmedName == nil { deletedTasks, deletedWorkflows, err = transactionalCascade.DeleteWorkspaceCascadeWithSecretCleanup(ctx, workspace.ID, cleanup) } else { deletedTasks, deletedWorkflows, err = transactionalCascade.DeleteWorkspaceCascadeWithNameAndSecretCleanup(ctx, workspace.ID, *confirmedName, cleanup) } case confirmedName == nil: deletedTasks, deletedWorkflows, err = s.workspaces.DeleteWorkspaceCascade(ctx, workspace.ID) default: deletedTasks, deletedWorkflows, err = s.workspaces.DeleteWorkspaceCascadeWithName(ctx, workspace.ID, *confirmedName) } if err != nil { cancelErr := s.cancelWorkspaceDeleteTaskCleanupJobs(ctx, cleanups) return s.mapWorkspaceDeleteError(workspace.ID, errors.Join(err, cancelErr)) } var postCommitErr error if s.attachmentSvc != nil { s.attachmentSvc.RemoveBytes(deletedWorkspaceAttachments) } if s.workspaceSecretDeleter != nil && (!hasTransactionalCleanup || !hasTransactionalCascade) { if err := s.workspaceSecretDeleter.DeleteWorkspaceSecrets(ctx, workspace.ID); err != nil { s.logger.Error("failed to delete workspace secrets", zap.String("workspace_id", workspace.ID), zap.Error(err)) postCommitErr = errors.Join(postCommitErr, err) } } var lateCleanupErr error cleanups, lateCleanupErr = s.appendWorkspaceDeleteMissingTaskCleanups(ctx, cleanups, deletedTasks) postCommitErr = errors.Join(postCommitErr, lateCleanupErr) s.publishWorkspaceDeleteChildEvents(ctx, deletedTasks, deletedWorkflows) s.runWorkspaceDeleteTaskCleanups(cleanups, deletedTasks) s.publishWorkspaceEvent(ctx, events.WorkspaceDeleted, workspace) s.logger.Info("workspace deleted", zap.String("workspace_id", workspace.ID)) if postCommitErr != nil { return &CascadePostCommitError{Err: postCommitErr} } return nil } func (s *Service) prepareWorkspaceDeleteTaskCleanups(ctx context.Context, tasks []*models.Task) ([]workspaceDeleteTaskCleanup, error) { cleanups := make([]workspaceDeleteTaskCleanup, 0, len(tasks)) for _, task := range tasks { if task == nil || task.ID == "" { continue } cleanup, err := s.prepareWorkspaceDeleteTaskCleanup(ctx, task) if err != nil { cancelErr := s.cancelWorkspaceDeleteTaskCleanupJobs(ctx, cleanups) return nil, errors.Join(err, cancelErr) } cleanups = append(cleanups, cleanup) } return cleanups, nil } func (s *Service) appendWorkspaceDeleteMissingTaskCleanups( ctx context.Context, cleanups []workspaceDeleteTaskCleanup, deletedTasks []*models.Task, ) ([]workspaceDeleteTaskCleanup, error) { prepared := make(map[string]struct{}, len(cleanups)) for _, cleanup := range cleanups { if cleanup.task != nil && cleanup.task.ID != "" { prepared[cleanup.task.ID] = struct{}{} } } var errs []error for _, task := range deletedTasks { if task == nil || task.ID == "" { continue } if _, ok := prepared[task.ID]; ok { continue } cleanup, err := s.prepareWorkspaceDeleteTaskCleanup(ctx, task) if err != nil { s.logger.Error("failed to prepare late workspace task cleanup", zap.String("task_id", task.ID), zap.Error(err)) errs = append(errs, fmt.Errorf("prepare late workspace task cleanup %q: %w", task.ID, err)) if job, persistErr := s.persistTaskResourceCleanup( ctx, task.ID, models.TaskResourceCleanupTriggerWorkspaceDelete, newTaskResourceCleanupOperationID(models.TaskResourceCleanupTriggerWorkspaceDelete, task.ID), nil, nil, nil, nil, taskEnvironmentCleanup{}, false, false, "", ); persistErr != nil { errs = append(errs, fmt.Errorf("persist late workspace task cleanup %q: %w", task.ID, persistErr)) } else if job != nil { cleanups = append(cleanups, workspaceDeleteTaskCleanup{task: task, cleanupJob: job}) } continue } cleanups = append(cleanups, cleanup) prepared[task.ID] = struct{}{} } return cleanups, errors.Join(errs...) } func (s *Service) prepareWorkspaceDeleteTaskCleanup(ctx context.Context, task *models.Task) (workspaceDeleteTaskCleanup, error) { worktrees, err := s.gatherWorktreesForDelete(ctx, task.ID) if err != nil { return workspaceDeleteTaskCleanup{}, fmt.Errorf("list worktrees for workspace delete task %q: %w", task.ID, err) } taskEnv, err := s.gatherTaskEnvironmentForCleanup(ctx, task.ID) if err != nil { return workspaceDeleteTaskCleanup{}, fmt.Errorf("lookup environment for workspace delete task %q: %w", task.ID, err) } var attachments []*models.TaskMessageAttachment attachmentRepo := s.attachments if attachmentRepo == nil && s.attachmentSvc != nil { attachmentRepo = s.attachmentSvc.repo } if attachmentRepo != nil { attachments, err = attachmentRepo.ListMessageAttachmentsByTask(ctx, task.ID) if err != nil { return workspaceDeleteTaskCleanup{}, fmt.Errorf("list attachments for workspace delete task %q: %w", task.ID, err) } } cleanup := workspaceDeleteTaskCleanup{task: task, worktrees: worktrees, taskEnv: taskEnv, attachments: attachments} cleanup.sessions, err = s.sessions.ListTaskSessions(ctx, task.ID) if err != nil { return workspaceDeleteTaskCleanup{}, fmt.Errorf("list task sessions for workspace delete task %q: %w", task.ID, err) } if s.executionStopper != nil { activeSessions, listErr := s.sessions.ListActiveTaskSessionsByTaskID(ctx, task.ID) if listErr != nil { return workspaceDeleteTaskCleanup{}, fmt.Errorf("list active sessions for workspace delete task %q: %w", task.ID, listErr) } cleanup.stopTargets, err = s.buildStopTargets(ctx, task.ID, activeSessions) if err != nil { return workspaceDeleteTaskCleanup{}, fmt.Errorf("list runtime cleanup inventory: %w", err) } } cleanup.cleanupJob, err = s.persistTaskResourceCleanup( ctx, task.ID, models.TaskResourceCleanupTriggerWorkspaceDelete, newTaskResourceCleanupOperationID(models.TaskResourceCleanupTriggerWorkspaceDelete, task.ID), cleanup.sessions, cleanup.worktrees, cleanup.stopTargets, cleanup.attachments, taskEnvironmentCleanup{env: cleanup.taskEnv, deleteRow: false}, true, true, "", ) return cleanup, err } func (s *Service) cancelWorkspaceDeleteTaskCleanupJobs(ctx context.Context, cleanups []workspaceDeleteTaskCleanup) error { jobIDs := make([]string, 0, len(cleanups)) for _, cleanup := range cleanups { if cleanup.cleanupJob != nil { jobIDs = append(jobIDs, cleanup.cleanupJob.ID) } } s.cancelTaskResourceCleanupRuns(jobIDs) transitionCtx, cancel := detachedCleanupTransitionContext(ctx) defer cancel() var cancellationErrs []error for _, cleanup := range cleanups { if cleanup.cleanupJob == nil || s.resourceCleanups == nil { continue } cas, ok := s.resourceCleanups.(taskResourceCleanupCancellationCAS) if !ok { cancellationErrs = append(cancellationErrs, fmt.Errorf("%w: cleanup repository cannot fence cancellation for %s", ErrCleanupCancellationRace, cleanup.cleanupJob.ID)) continue } cancelled, err := cas.CancelTaskResourceCleanupJobIfPending(transitionCtx, cleanup.cleanupJob.ID) if err != nil { cancellationErrs = append(cancellationErrs, err) } else if !cancelled { cancellationErrs = append(cancellationErrs, fmt.Errorf("%w: workspace-delete cleanup %s was claimed concurrently", ErrCleanupCancellationRace, cleanup.cleanupJob.ID)) } } if len(cancellationErrs) > 0 { return errors.Join(cancellationErrs...) } return nil } func (s *Service) publishWorkspaceDeleteChildEvents(ctx context.Context, tasks []*models.Task, workflows []*models.Workflow) { for _, task := range tasks { if task == nil || task.ID == "" { continue } s.publishTaskEvent(ctx, events.TaskDeleted, task, nil) } for _, workflow := range workflows { if workflow == nil || workflow.ID == "" { continue } s.publishWorkflowEvent(ctx, events.WorkflowDeleted, workflow) } } func (s *Service) runWorkspaceDeleteTaskCleanups(cleanups []workspaceDeleteTaskCleanup, deletedTasks []*models.Task) { jobs := s.workspaceDeleteTaskCleanupJobs(cleanups, deletedTasks) if len(jobs) == 0 { return } go s.runWorkspaceDeleteTaskCleanupJobs(jobs) } func (s *Service) workspaceDeleteTaskCleanupJobs( cleanups []workspaceDeleteTaskCleanup, deletedTasks []*models.Task, ) []workspaceDeleteTaskCleanup { deletedTaskIDs := make(map[string]struct{}, len(deletedTasks)) for _, task := range deletedTasks { if task != nil && task.ID != "" { deletedTaskIDs[task.ID] = struct{}{} } } jobs := make([]workspaceDeleteTaskCleanup, 0, len(cleanups)) for _, cleanup := range cleanups { if cleanup.task == nil { if cleanup.cleanupJob != nil { jobs = append(jobs, cleanup) } continue } if _, ok := deletedTaskIDs[cleanup.task.ID]; !ok { continue } hasCleanup := len(cleanup.stopTargets) > 0 || s.worktreeCleanup != nil || len(cleanup.sessions) > 0 || cleanup.task.IsEphemeral || cleanup.taskEnv != nil || len(cleanup.attachments) > 0 if !hasCleanup { continue } jobs = append(jobs, cleanup) } return jobs } func (s *Service) runWorkspaceDeleteTaskCleanupJobs(jobs []workspaceDeleteTaskCleanup) { workers := workspaceDeleteCleanupConcurrency if len(jobs) < workers { workers = len(jobs) } jobCh := make(chan workspaceDeleteTaskCleanup) var wg sync.WaitGroup wg.Add(workers) for i := 0; i < workers; i++ { go func() { defer wg.Done() for cleanup := range jobCh { s.runWorkspaceDeleteTaskCleanup(cleanup) } }() } for _, job := range jobs { jobCh <- job } close(jobCh) wg.Wait() } func (s *Service) runWorkspaceDeleteTaskCleanup(cleanup workspaceDeleteTaskCleanup) { if cleanup.cleanupJob != nil { transitionCtx, cancel := detachedCleanupTransitionContext(context.Background()) defer cancel() if err := s.StartPreparedTaskResourceCleanup( transitionCtx, cleanup.cleanupJob.OperationID, ); err != nil { s.logger.Warn("start workspace delete task cleanup job", zap.String("job_id", cleanup.cleanupJob.ID), zap.String("task_id", cleanup.cleanupJob.TaskID), zap.Error(err)) } return } envCleanup := taskEnvironmentCleanup{env: cleanup.taskEnv, deleteRow: false} s.runTaskCleanup(cleanup.task.ID, cleanup.sessions, cleanup.worktrees, cleanup.stopTargets, envCleanup, true, "task deleted", "failed to stop session on task delete", "task cleanup completed") } func (s *Service) mapWorkspaceDeleteError(id string, err error) error { if err == nil { return nil } if errors.Is(err, taskrepo.ErrWorkspaceNameMismatch) { return ErrWorkspaceConfirmNameMismatch } s.logger.Error("failed to delete workspace", zap.String("workspace_id", id), zap.Error(err)) return err } func (s *Service) listAllTasksForWorkspaceDelete(ctx context.Context, workspaceID string) ([]*models.Task, error) { var all []*models.Task for page := 1; ; page++ { tasks, total, err := s.tasks.ListTasksByWorkspace( ctx, workspaceID, "", "", "", page, workspaceDeletePageSize, "", true, true, false, false, ) if err != nil { return nil, fmt.Errorf("list workspace tasks: %w", err) } all = append(all, tasks...) if len(all) >= total || len(tasks) == 0 { return all, nil } } } func normalizeOptionalID(value *string) *string { if value == nil { return nil } trimmed := strings.TrimSpace(*value) if trimmed == "" { return nil } return &trimmed } // ListWorkspaces returns the workspaces visible to the ctx identity: all of // them for internal/synthetic callers, only owned (plus pre-auth unowned) // rows for authenticated users. func (s *Service) ListWorkspaces(ctx context.Context) ([]*models.Workspace, error) { workspaces, err := s.workspaces.ListWorkspaces(ctx) if err != nil { return nil, err } return s.filterWorkspacesForCaller(ctx, workspaces), nil } // Workflow operations // CreateWorkflow creates a new workflow func (s *Service) CreateWorkflow(ctx context.Context, req *CreateWorkflowRequest) (*models.Workflow, error) { if err := s.AuthorizeWorkspaceScope(ctx, req.WorkspaceID, authz.ScopeRepositoryManage); err != nil { return nil, err } workflow := &models.Workflow{ ID: uuid.New().String(), WorkspaceID: req.WorkspaceID, Name: req.Name, Description: req.Description, Prompt: req.Prompt, WorkflowTemplateID: req.WorkflowTemplateID, Hidden: req.Hidden, } if err := s.workflows.CreateWorkflow(ctx, workflow); err != nil { s.logger.Error("failed to create workflow", zap.Error(err)) return nil, err } // Create workflow steps from template if specified if req.WorkflowTemplateID != nil && *req.WorkflowTemplateID != "" && s.workflowStepCreator != nil { if err := s.workflowStepCreator.CreateStepsFromTemplate(ctx, workflow.ID, *req.WorkflowTemplateID); err != nil { s.logger.Error("failed to create workflow steps from template", zap.String("workflow_id", workflow.ID), zap.String("template_id", *req.WorkflowTemplateID), zap.Error(err)) // Don't fail workflow creation, just log the error } } s.publishWorkflowEvent(ctx, events.WorkflowCreated, workflow) s.logger.Info("workflow created", zap.String("workflow_id", workflow.ID), zap.String("name", workflow.Name)) return workflow, nil } // GetWorkflow retrieves a workflow by ID func (s *Service) GetWorkflow(ctx context.Context, id string) (*models.Workflow, error) { if err := s.authorizeWorkflowID(ctx, id); err != nil { return nil, err } return s.workflows.GetWorkflow(ctx, id) } // UpdateWorkflow updates an existing workflow func (s *Service) UpdateWorkflow(ctx context.Context, id string, req *UpdateWorkflowRequest) (*models.Workflow, error) { if err := s.authorizeWorkflowID(ctx, id); err != nil { return nil, err } workflow, err := s.workflows.GetWorkflow(ctx, id) if err != nil { return nil, err } if req.Name != nil { workflow.Name = *req.Name } if req.Description != nil { workflow.Description = *req.Description } if req.Prompt != nil { workflow.Prompt = *req.Prompt } if req.AgentProfileID != nil { workflow.AgentProfileID = strings.TrimSpace(*req.AgentProfileID) } workflow.UpdatedAt = time.Now().UTC() if err := s.workflows.UpdateWorkflow(ctx, workflow); err != nil { s.logger.Error("failed to update workflow", zap.String("workflow_id", id), zap.Error(err)) return nil, err } s.publishWorkflowEvent(ctx, events.WorkflowUpdated, workflow) s.logger.Info("workflow updated", zap.String("workflow_id", workflow.ID)) return workflow, nil } // SetWorkflowHidden flips the hidden flag on a workflow. Used by system // flows (e.g. improve-kandev) to heal records created before Hidden was // honored on insert. func (s *Service) SetWorkflowHidden(ctx context.Context, id string, hidden bool) error { workflow, err := s.workflows.GetWorkflow(ctx, id) if err != nil { return err } if workflow.Hidden == hidden { return nil } workflow.Hidden = hidden workflow.UpdatedAt = time.Now().UTC() if err := s.workflows.UpdateWorkflow(ctx, workflow); err != nil { s.logger.Error("failed to update workflow hidden flag", zap.String("workflow_id", id), zap.Error(err)) return err } s.publishWorkflowEvent(ctx, events.WorkflowUpdated, workflow) return nil } // SetWorkflowSource stamps a workflow's provenance ("manual" | "github") and // the repo-relative file path it was synced from. Used by workflow-sync to // mark workflows it owns. func (s *Service) SetWorkflowSource(ctx context.Context, id, source, sourcePath string) error { workflow, err := s.workflows.GetWorkflow(ctx, id) if err != nil { return err } if workflow.Source == source && workflow.SourcePath == sourcePath { return nil } workflow.Source = source workflow.SourcePath = sourcePath workflow.UpdatedAt = time.Now().UTC() if err := s.workflows.UpdateWorkflow(ctx, workflow); err != nil { s.logger.Error("failed to update workflow source", zap.String("workflow_id", id), zap.Error(err)) return err } s.publishWorkflowEvent(ctx, events.WorkflowUpdated, workflow) return nil } // DeleteWorkflow deletes a workflow, archiving its remaining tasks first so // they do not linger as orphan rows pointing at a workflow_id that no longer // exists (the tasks.workflow_id FK was dropped to support empty workflow_id // on ephemeral tasks, so SQLite cannot cascade for us). func (s *Service) listWorkflowTasksForDelete( ctx context.Context, workflow *models.Workflow, ) ([]*models.Task, error) { var all []*models.Task for page := 1; ; page++ { lister, ok := s.tasks.(workflowDeleteTaskLister) if !ok { return nil, errors.New("task repository cannot enumerate all task origins for workflow deletion") } tasks, total, err := lister.ListTasksForDeletion( ctx, workflow.WorkspaceID, workflow.ID, page, workspaceDeletePageSize, ) if err != nil { return nil, fmt.Errorf("list tasks for workflow delete cascade: %w", err) } all = append(all, tasks...) if len(tasks) == 0 || len(all) >= total { return all, nil } } } func (s *Service) DeleteWorkflow(ctx context.Context, id string) error { if err := s.authorizeWorkflowID(ctx, id); err != nil { return err } workflow, err := s.workflows.GetWorkflow(ctx, id) if err != nil { return err } if workflow == nil { return fmt.Errorf("workflow not found: %s", id) } tasks, err := s.listWorkflowTasksForDelete(ctx, workflow) if err != nil { s.logger.Error("failed to list tasks for workflow delete cascade", zap.String("workflow_id", id), zap.Error(err)) return err } archived := 0 var postCommitErr error for _, task := range tasks { if task == nil || task.WorkspaceID != workflow.WorkspaceID { taskID, taskWorkspaceID := "", "" if task != nil { taskID = task.ID taskWorkspaceID = task.WorkspaceID } s.logger.Warn("skipping task outside workflow workspace during workflow delete cascade", zap.String("workflow_id", id), zap.String("workflow_workspace_id", workflow.WorkspaceID), zap.String("task_id", taskID), zap.String("task_workspace_id", taskWorkspaceID)) continue } var archiveErr error if s.workflowTaskArchiveCoordinator != nil { outcome, err := s.workflowTaskArchiveCoordinator.ArchiveTaskTree(ctx, task.ID, false) if outcome != nil && len(outcome.ArchivedTaskIDs) > 0 { archived += len(outcome.ArchivedTaskIDs) } var cascadePostCommitErr *CascadePostCommitError if errors.As(err, &cascadePostCommitErr) { s.logger.Warn("workflow task archived with post-commit lifecycle errors", zap.String("workflow_id", id), zap.String("task_id", task.ID), zap.Error(err)) postCommitErr = errors.Join(postCommitErr, err) continue } } else { archiveErr = s.ArchiveTask(ctx, task.ID) if errors.Is(archiveErr, ErrTaskAlreadyArchived) { continue } } if archiveErr != nil { err = archiveErr if errors.Is(err, ErrTaskAlreadyArchived) { continue } s.logger.Error("failed to archive task during workflow delete cascade", zap.String("workflow_id", id), zap.String("task_id", task.ID), zap.Error(err)) return err } if s.workflowTaskArchiveCoordinator == nil { archived++ } } if err := s.workflows.DeleteWorkflow(ctx, id); err != nil { s.logger.Error("failed to delete workflow", zap.String("workflow_id", id), zap.Error(err)) return err } s.publishWorkflowEvent(ctx, events.WorkflowDeleted, workflow) s.logger.Info("workflow deleted", zap.String("workflow_id", id), zap.Int("archived_tasks", archived)) if postCommitErr != nil { return &CascadePostCommitError{Err: postCommitErr} } return nil } // ListWorkflows returns workflows for a workspace, excluding hidden ones by default. // Pass includeHidden=true to include system-only flows like Improve Kandev. func (s *Service) ListWorkflows(ctx context.Context, workspaceID string, includeHidden bool) ([]*models.Workflow, error) { if err := s.authorizeWorkspaceID(ctx, workspaceID); err != nil { return nil, err } return s.workflows.ListWorkflows(ctx, workspaceID, includeHidden) } // GetOfficeWorkflowIDs returns the set of workflow IDs that are office workflows // (referenced by any workspace's office_workflow_id column). func (s *Service) GetOfficeWorkflowIDs(ctx context.Context) map[string]struct{} { workspaces, err := s.workspaces.ListWorkspaces(ctx) if err != nil { return nil } ids := make(map[string]struct{}) for _, ws := range workspaces { if ws.OfficeWorkflowID != "" { ids[ws.OfficeWorkflowID] = struct{}{} } } return ids } // ReorderWorkflows updates sort_order for workflows within a workspace. func (s *Service) ReorderWorkflows(ctx context.Context, workspaceID string, workflowIDs []string) error { if err := s.authorizeWorkspaceID(ctx, workspaceID); err != nil { return err } if err := s.workflows.ReorderWorkflows(ctx, workspaceID, workflowIDs); err != nil { s.logger.Error("failed to reorder workflows", zap.String("workspace_id", workspaceID), zap.Error(err)) return err } s.logger.Info("reordered workflows", zap.String("workspace_id", workspaceID), zap.Int("count", len(workflowIDs))) return nil } // Repository operations func (s *Service) CreateRepository(ctx context.Context, req *CreateRepositoryRequest) (*models.Repository, error) { localPath, err := canonicalRepositoryLocalPath(req.LocalPath) if err != nil { return nil, err } return s.createRepository(ctx, req, localPath, true) } func (s *Service) createRepositoryWithCanonicalPath( ctx context.Context, req *CreateRepositoryRequest, ) (*models.Repository, error) { return s.createRepository(ctx, req, req.LocalPath, false) } func (s *Service) createRepository( ctx context.Context, req *CreateRepositoryRequest, localPath string, resolveProvider bool, ) (*models.Repository, error) { if err := s.AuthorizeWorkspaceScope(ctx, req.WorkspaceID, authz.ScopeRepositoryManage); err != nil { return nil, err } sourceType := req.SourceType if sourceType == "" { sourceType = sourceTypeLocal } if req.DefaultBranch != "" && !securityutil.IsValidDefaultBranchName(req.DefaultBranch) { return nil, fmt.Errorf("%w: invalid default branch", ErrInvalidRepositorySettings) } prefix := strings.TrimSpace(req.WorktreeBranchPrefix) if err := worktree.ValidateBranchPrefix(prefix); err != nil { return nil, fmt.Errorf("%w: %s", ErrInvalidRepositorySettings, err) } if prefix == "" { prefix = worktree.DefaultBranchPrefix } template := worktree.NormalizeBranchNameTemplate(req.WorktreeBranchTemplate) if err := worktree.ValidateBranchNameTemplate(template); err != nil { return nil, fmt.Errorf("%w: %s", ErrInvalidRepositorySettings, err) } pullBeforeWorktree := true if req.PullBeforeWorktree != nil { pullBeforeWorktree = *req.PullBeforeWorktree } if err := copyfiles.ValidateSpec(req.CopyFiles); err != nil { return nil, fmt.Errorf("%w: %s", ErrInvalidRepositorySettings, err) } bindings, err := s.validateRepositorySecretBindings(ctx, req.WorkspaceID, req.SecretBindings) if err != nil { return nil, err } providerScope, err := validateProviderScope(req.ProviderScope) if err != nil { return nil, err } repository := &models.Repository{ ID: uuid.New().String(), WorkspaceID: req.WorkspaceID, Name: req.Name, SourceType: sourceType, LocalPath: localPath, Provider: req.Provider, ProviderRepoID: req.ProviderRepoID, ProviderHost: normalizeProviderHost(req.Provider, req.ProviderHost), ProviderScope: providerScope, ProviderOwner: req.ProviderOwner, ProviderName: req.ProviderName, RemoteURL: req.RemoteURL, DefaultBranch: req.DefaultBranch, WorktreeBranchPrefix: prefix, WorktreeBranchTemplate: template, PullBeforeWorktree: pullBeforeWorktree, SetupScript: req.SetupScript, CleanupScript: req.CleanupScript, DevScript: req.DevScript, CopyFiles: req.CopyFiles, SecretBindings: bindings, } if resolveProvider { resolveRepositoryProviderIdentity(repository) } if err := validateProviderScopeAndRepoIDPair(repository.ProviderScope, repository.ProviderRepoID); err != nil { return nil, err } if mutator, ok := s.repoEntities.(taskrepo.RepositorySecretBindingMutator); ok { if err := mutator.CreateRepositoryWithSecretBindings(ctx, repository, bindings); err != nil { s.logger.Error("failed to create repository", zap.Error(err)) return nil, err } } else if len(bindings) > 0 { return nil, fmt.Errorf("%w: repository secret bindings are unavailable", ErrInvalidRepositorySettings) } else if err := s.repoEntities.CreateRepository(ctx, repository); err != nil { s.logger.Error("failed to create repository", zap.Error(err)) return nil, err } s.publishRepositoryEvent(ctx, events.RepositoryCreated, repository) s.logger.Info("repository created", zap.String("repository_id", repository.ID)) return repository, nil } // resolveRepositoryProviderIdentity fills missing provider metadata from a // local repository origin. RemoteURL is resolved independently of the // provider/host/owner/name fields: those are only tagged for the well-known // github.com/gitlab.com hosts (see ResolveGitRemoteProviderIdentity), but // self-hosted GitLab/GitHub Enterprise instances still need a populated // RemoteURL so downstream identity matching (e.g. GitLab MR-task linking) // has something to compare against instead of failing closed. // // canonicalCloneOrigin is tried first because it produces the exact // provider-canonical clone URL (e.g. the ".git"-suffixed GitHub/GitLab.com // form) that other code, including test fixtures rewriting Git's clone // transport via "insteadOf" config, matches against verbatim. The broader // ResolveGitRemoteIdentity-based fallback only runs when canonicalCloneOrigin // doesn't recognize the host (e.g. a self-hosted GitLab/GitHub Enterprise // instance), since it doesn't guarantee a byte-identical canonical form. func resolveRepositoryProviderIdentity(repository *models.Repository) { if repository.LocalPath == "" { return } if repository.Provider == "" || repository.ProviderHost == "" { p, h, o, n := ResolveGitRemoteProviderIdentity(repository.LocalPath) if repository.Provider == "" { repository.Provider = p } if repository.Provider != "" && (strings.HasPrefix(h, "http://") || strings.HasPrefix(h, "https://")) { repository.ProviderHost = h repository.ProviderOwner = o repository.ProviderName = n } } if repository.RemoteURL == "" { if origin := canonicalCloneOrigin(repository.LocalPath); origin != "" { repository.RemoteURL = origin } else if origin, owner, name := ResolveGitRemoteIdentity(repository.LocalPath); origin != "" && owner != "" && name != "" { repository.RemoteURL = origin + "/" + owner + "/" + name } } } // canonicalCloneOrigin returns a credential-free clone URL for a local // checkout's origin. Invalid and unsupported origins deliberately remain // empty so host-local-only repositories cannot leak credentials into storage. func canonicalCloneOrigin(localPath string) string { raw, err := readGitRemoteOriginURL(localPath) if err != nil || raw == "" { return "" } _, _, _, canonical, err := parseRemoteRepositoryURL(raw, "") if err != nil { return "" } return canonical } func (s *Service) GetRepository(ctx context.Context, id string) (*models.Repository, error) { repo, err := s.repoEntities.GetRepository(ctx, id) if err != nil { return nil, err } if repo != nil { if err := s.authorizeWorkspaceID(ctx, repo.WorkspaceID); err != nil { return nil, repoerrors.ErrRepositoryNotFound } } return repo, nil } // GetRepositoryByProviderInfo looks up a repository by workspace and provider identity. // Returns nil (with nil error) when no matching repository exists. func (s *Service) GetRepositoryByProviderInfo(ctx context.Context, workspaceID, provider, host, owner, name string) (*models.Repository, error) { workspaceID = strings.TrimSpace(workspaceID) provider = strings.TrimSpace(provider) return s.repoEntities.GetRepositoryByProviderIdentity(ctx, models.ProviderRepositoryIdentity{ WorkspaceID: workspaceID, Provider: provider, Host: normalizeProviderHost(provider, host), Owner: strings.TrimSpace(owner), Name: strings.TrimSpace(name), }) } // FindOrCreateRepository looks up a repository by provider info, creating one if not found. // If the repository exists but has no LocalPath and the request provides one, updates LocalPath. // // Returns created=true only when CreateRepository was invoked by this call. // Callers that register cleanup on the new row (e.g. add_branch_to_task's // orphan-rollback path) must gate it on that flag instead of inferring // ownership from a workspace snapshot — a concurrent request can win the // create race between snapshot and lookup, so a snapshot-miss does NOT // mean this call created the row. func (s *Service) FindOrCreateRepository(ctx context.Context, req *FindOrCreateRepositoryRequest) (*models.Repository, bool, error) { s.repoResolveMu.Lock() defer s.repoResolveMu.Unlock() req.WorkspaceID = strings.TrimSpace(req.WorkspaceID) req.Provider = strings.TrimSpace(req.Provider) req.ProviderRepoID = strings.TrimSpace(req.ProviderRepoID) providerScope, err := validateProviderScope(req.ProviderScope) if err != nil { return nil, false, err } req.ProviderScope = providerScope req.ProviderOwner = strings.TrimSpace(req.ProviderOwner) req.ProviderName = strings.TrimSpace(req.ProviderName) req.RemoteURL = strings.TrimSpace(req.RemoteURL) req.ProviderHost = normalizeProviderHost(req.Provider, req.ProviderHost) existing, err := s.repoEntities.GetRepositoryByProviderIdentity(ctx, models.ProviderRepositoryIdentity{ WorkspaceID: req.WorkspaceID, Provider: req.Provider, Scope: req.ProviderScope, RepositoryID: req.ProviderRepoID, Host: req.ProviderHost, Owner: req.ProviderOwner, Name: req.ProviderName, }) if err != nil { return nil, false, fmt.Errorf("lookup repository: %w", err) } if existing != nil { replacement, replacementCreated, replacementErr := s.replaceTaskWorktreeRepositoryMatch(ctx, req.WorkspaceID, existing) if replacementErr != nil { return nil, false, replacementErr } existing = replacement dirty := false if existing.LocalPath == "" && req.LocalPath != "" { localPath, pathErr := canonicalRepositoryLocalPath(req.LocalPath) if pathErr != nil { return nil, false, pathErr } existing.LocalPath = localPath dirty = true } if existing.ProviderHost == "" && req.ProviderHost != "" { existing.ProviderHost = normalizeProviderHost(req.Provider, req.ProviderHost) dirty = true } if existing.ProviderScope == "" && req.ProviderScope != "" { existing.ProviderScope = req.ProviderScope dirty = true } // Backfill default_branch when the caller carries one and the existing // row is still empty. Lets the synchronous add_branch probe persist its // answer onto a previously-empty Repository row (e.g. one created by // an earlier create_task that left default_branch unset). Validated // here directly: this value reaches git argv (worktree fetch/pull via // FallbackBaseBranch) on paths that never go through ancestry.Check, // so that defense-in-depth guard does not cover it. Invalid input // skips only this field's backfill rather than failing the whole // find-or-create call, since this is a best-effort backfill. if existing.DefaultBranch == "" && req.DefaultBranch != "" { if securityutil.IsValidDefaultBranchName(req.DefaultBranch) { existing.DefaultBranch = req.DefaultBranch dirty = true } else { s.logger.Warn("skipping default_branch backfill: invalid branch name", zap.String("repository_id", existing.ID)) } } if existing.RemoteURL == "" && req.RemoteURL != "" { existing.RemoteURL = req.RemoteURL dirty = true } if existing.ProviderRepoID == "" && req.ProviderRepoID != "" { existing.ProviderRepoID = req.ProviderRepoID dirty = true } if dirty { if updateErr := s.repoEntities.UpdateRepository(ctx, existing); updateErr != nil { s.logger.Warn("failed to backfill repository fields", zap.String("repository_id", existing.ID), zap.Error(updateErr)) } } return existing, replacementCreated, nil } name := fmt.Sprintf("%s/%s", req.ProviderOwner, req.ProviderName) created, createErr := s.CreateRepository(ctx, &CreateRepositoryRequest{ WorkspaceID: req.WorkspaceID, Name: name, SourceType: sourceTypeProvider, LocalPath: req.LocalPath, Provider: req.Provider, ProviderRepoID: req.ProviderRepoID, ProviderHost: req.ProviderHost, ProviderScope: req.ProviderScope, ProviderOwner: req.ProviderOwner, ProviderName: req.ProviderName, RemoteURL: req.RemoteURL, DefaultBranch: req.DefaultBranch, }) if createErr != nil { return nil, false, createErr } return created, true, nil } // FindOrCreateRepositoryByLocalPath looks up a repository by its canonical // local_path within workspaceID, creating one from req if none exists. // Mirrors FindOrCreateRepository's provider-identity flow: the lookup and the // insert both happen while holding repoResolveMu, so two resolvers racing to // register the same not-yet-known on-disk repo (e.g. two task-creation // requests naming the same local_path) converge on one row instead of each // inserting its own. canonicalPath must already be resolved (see // resolveExplicitLocalRepositoryPath); pass "" when canonicalization failed // or was skipped, which disables the lookup and always creates — matching // prior behavior for that edge case, since there is no reliable identity to // dedupe against. // // Returns created=true only when this call inserted the new row. func (s *Service) FindOrCreateRepositoryByLocalPath( ctx context.Context, workspaceID, canonicalPath string, req *CreateRepositoryRequest, ) (*models.Repository, bool, error) { s.repoResolveMu.Lock() defer s.repoResolveMu.Unlock() if canonicalPath != "" { existing, err := s.repoEntities.GetRepositoryByLocalPath(ctx, workspaceID, canonicalPath) if err != nil { return nil, false, fmt.Errorf("lookup repository by local path: %w", err) } if existing != nil { replacement, replacementCreated, replaceErr := s.replaceTaskWorktreeRepositoryMatch(ctx, workspaceID, existing) if replaceErr != nil { return nil, false, replaceErr } return replacement, replacementCreated, nil } } created, createErr := s.CreateRepository(ctx, req) if createErr != nil { return nil, false, createErr } return created, true, nil } func (s *Service) UpdateRepository(ctx context.Context, id string, req *UpdateRepositoryRequest) (*models.Repository, error) { repository, err := s.repoEntities.GetRepository(ctx, id) if err != nil { return nil, err } if repository != nil { if err := s.AuthorizeWorkspaceScope(ctx, repository.WorkspaceID, authz.ScopeRepositoryManage); err != nil { return nil, repoerrors.ErrRepositoryNotFound } } updates := *req if req.LocalPath != nil { localPath, pathErr := canonicalRepositoryLocalPath(*req.LocalPath) if pathErr != nil { return nil, pathErr } updates.LocalPath = &localPath } if err := applyRepositoryUpdates(repository, &updates); err != nil { return nil, err } var replacement []models.RepositorySecretBinding if req.SecretBindings != nil { replacement, err = s.validateRepositorySecretBindings(ctx, repository.WorkspaceID, *req.SecretBindings) if err != nil { return nil, err } repository.SecretBindings = replacement } repository.UpdatedAt = time.Now().UTC() if req.SecretBindings != nil { mutator, ok := s.repoEntities.(taskrepo.RepositorySecretBindingMutator) if !ok { return nil, fmt.Errorf("%w: repository secret bindings are unavailable", ErrInvalidRepositorySettings) } if err := mutator.UpdateRepositoryWithSecretBindings(ctx, repository, replacement); err != nil { s.logger.Error("failed to update repository", zap.String("repository_id", id), zap.Error(err)) return nil, err } } else if err := s.repoEntities.UpdateRepository(ctx, repository); err != nil { s.logger.Error("failed to update repository", zap.String("repository_id", id), zap.Error(err)) return nil, err } s.publishRepositoryEvent(ctx, events.RepositoryUpdated, repository) s.logger.Info("repository updated", zap.String("repository_id", repository.ID)) return repository, nil } func (s *Service) validateRepositorySecretBindings( ctx context.Context, workspaceID string, inputs []RepositorySecretBindingInput, ) ([]models.RepositorySecretBinding, error) { if len(inputs) > maxRepositorySecretBindings { return nil, fmt.Errorf("%w: at most %d secret bindings are allowed", ErrInvalidRepositorySettings, maxRepositorySecretBindings) } if len(inputs) == 0 { return nil, nil } catalog, ok := s.secretStore.(secrets.ScopedSecretStore) if !ok { return nil, fmt.Errorf("%w: secret binding validation is unavailable", ErrInvalidRepositorySettings) } seen := make(map[string]struct{}, len(inputs)) bindings := make([]models.RepositorySecretBinding, 0, len(inputs)) for _, input := range inputs { key := strings.TrimSpace(input.Key) if key != input.Key || key == "" || len(key) > 256 || !repositorySecretKeyPattern.MatchString(key) || key == "TASK_DESCRIPTION" || strings.HasPrefix(key, "KANDEV_") { return nil, fmt.Errorf("%w: invalid secret binding key", ErrInvalidRepositorySettings) } if _, exists := seen[key]; exists { return nil, fmt.Errorf("%w: duplicate secret binding key", ErrInvalidRepositorySettings) } seen[key] = struct{}{} secretID := strings.TrimSpace(input.SecretID) if secretID == "" { return nil, fmt.Errorf("%w: secret binding reference is required", ErrInvalidRepositorySettings) } if _, err := catalog.GetForWorkspace(ctx, secretID, workspaceID); err != nil { return nil, fmt.Errorf("%w: secret binding reference is not available", ErrInvalidRepositorySettings) } bindings = append(bindings, models.RepositorySecretBinding{Key: key, SecretID: secretID}) } return bindings, nil } func canonicalRepositoryLocalPath(localPath string) (string, error) { if localPath == "" { return "", nil } canonicalPath, _, err := resolveExplicitLocalRepositoryPath(localPath) if err != nil { return "", fmt.Errorf("%w: %w", ErrInvalidRepositorySettings, err) } return canonicalPath, nil } // applyRepositoryUpdates applies the non-nil fields from req onto repository. // Returns an error if the WorktreeBranchPrefix is invalid. func applyRepositoryUpdates(repository *models.Repository, req *UpdateRepositoryRequest) error { if req.Name != nil { repository.Name = *req.Name } if req.SourceType != nil { repository.SourceType = *req.SourceType } if req.LocalPath != nil { repository.LocalPath = *req.LocalPath } if req.Provider != nil { repository.Provider = *req.Provider } if req.ProviderRepoID != nil { repository.ProviderRepoID = *req.ProviderRepoID } if req.ProviderHost != nil { repository.ProviderHost = normalizeProviderHost(repository.Provider, *req.ProviderHost) } if req.ProviderScope != nil { providerScope, err := validateProviderScope(*req.ProviderScope) if err != nil { return err } repository.ProviderScope = providerScope } if req.ProviderOwner != nil { repository.ProviderOwner = *req.ProviderOwner } if req.ProviderName != nil { repository.ProviderName = *req.ProviderName } if req.RemoteURL != nil { repository.RemoteURL = strings.TrimSpace(*req.RemoteURL) } if req.DefaultBranch != nil { if *req.DefaultBranch != "" && !securityutil.IsValidDefaultBranchName(*req.DefaultBranch) { return fmt.Errorf("%w: invalid default branch", ErrInvalidRepositorySettings) } repository.DefaultBranch = *req.DefaultBranch } if req.WorktreeBranchPrefix != nil { prefix := strings.TrimSpace(*req.WorktreeBranchPrefix) if err := worktree.ValidateBranchPrefix(prefix); err != nil { return fmt.Errorf("%w: %s", ErrInvalidRepositorySettings, err) } repository.WorktreeBranchPrefix = prefix } if req.WorktreeBranchTemplate != nil { template := worktree.NormalizeBranchNameTemplate(*req.WorktreeBranchTemplate) if err := worktree.ValidateBranchNameTemplate(template); err != nil { return fmt.Errorf("%w: %s", ErrInvalidRepositorySettings, err) } repository.WorktreeBranchTemplate = template } if req.PullBeforeWorktree != nil { repository.PullBeforeWorktree = *req.PullBeforeWorktree } if req.SetupScript != nil { repository.SetupScript = *req.SetupScript } if req.CleanupScript != nil { repository.CleanupScript = *req.CleanupScript } if req.DevScript != nil { repository.DevScript = *req.DevScript } if req.CopyFiles != nil { if err := copyfiles.ValidateSpec(*req.CopyFiles); err != nil { return fmt.Errorf("%w: %s", ErrInvalidRepositorySettings, err) } repository.CopyFiles = *req.CopyFiles } if err := validateProviderScopeAndRepoIDPair(repository.ProviderScope, repository.ProviderRepoID); err != nil { return err } return nil } func (s *Service) DeleteRepository(ctx context.Context, id string) error { repository, err := s.repoEntities.GetRepository(ctx, id) if err != nil { return err } if repository != nil { if err := s.AuthorizeWorkspaceScope(ctx, repository.WorkspaceID, authz.ScopeRepositoryManage); err != nil { if IsForbidden(err) { return err } return repoerrors.ErrRepositoryNotFound } } active, err := s.sessions.HasActiveTaskSessionsByRepository(ctx, id) if err != nil { s.logger.Error("failed to check active agent sessions for repository", zap.String("repository_id", id), zap.Error(err)) return err } if active { return ErrActiveTaskSessions } // Captured before the delete, because deleting prunes the membership rows // that identify the affected sets. affectedSetIDs := s.repositorySetIDsHolding(ctx, id) if err := s.repoEntities.DeleteRepository(ctx, id); err != nil { s.logger.Error("failed to delete repository", zap.String("repository_id", id), zap.Error(err)) return err } s.publishRepositoryEvent(ctx, events.RepositoryDeleted, repository) // A client that already loaded the workspace only reacts to repository_set.* // events, so without this it keeps offering the deleted member until reload. s.publishRepositorySetsAfterMembershipChange(ctx, affectedSetIDs) s.logger.Info("repository deleted", zap.String("repository_id", id)) return nil } func (s *Service) ListRepositories(ctx context.Context, workspaceID string) ([]*models.Repository, error) { if err := s.authorizeWorkspaceID(ctx, workspaceID); err != nil { return nil, err } repositories, err := s.repoEntities.ListRepositories(ctx, workspaceID) if err != nil { return nil, err } live := make([]*models.Repository, 0, len(repositories)) pruner, canPrune := s.repoEntities.(repositorySessionPruner) for _, repository := range repositories { if repository == nil || repository.SourceType != sourceTypeLocal || !s.isKandevTaskWorktreeRepository(repository) { live = append(live, repository) continue } if _, statErr := os.Stat(repository.LocalPath); !errors.Is(statErr, os.ErrNotExist) { live = append(live, repository) continue } if !canPrune { live = append(live, repository) continue } deleted, err := pruner.DeleteRepositoryIfNoActiveTaskSessions(ctx, repository.ID) if err != nil { s.logger.Warn("failed to prune missing task worktree repository", zap.String("repository_id", repository.ID), zap.String("local_path", repository.LocalPath), zap.Error(err)) live = append(live, repository) continue } if deleted { s.publishRepositoryEvent(ctx, events.RepositoryDeleted, repository) continue } current, getErr := s.repoEntities.GetRepository(ctx, repository.ID) if getErr == nil { live = append(live, current) continue } if !errors.Is(getErr, taskrepo.ErrRepositoryNotFound) { s.logger.Warn("failed to re-read retained task worktree repository, using cached value", zap.String("repository_id", repository.ID), zap.Error(getErr)) live = append(live, repository) } } return dedupeRepositoriesByIdentity(live), nil } // repositoryIdentityKey returns a stable dedup key for repo: local_path when // set (a local checkout is identified by where it lives on disk), otherwise // the provider identity tuple when host/owner/name are all present (a // provider-backed repo is identified by where it lives upstream), otherwise // the row's own ID. A missing provider_host (legacy rows, or self-managed // providers we've never normalized a host for) means we cannot tell two // same-namespace repos on different unknown hosts apart, so those rows fail // closed to their own ID instead of risking a false-positive collapse. // Placeholder rows with neither local_path nor provider also fall back to ID // so they never collide with one another. func repositoryIdentityKey(repo *models.Repository) string { if repo.LocalPath != "" { return "local\x00" + repo.LocalPath } if repo.Provider != "" && repo.ProviderScope != "" && repo.ProviderRepoID != "" { return "provider-scope\x00" + repo.Provider + "\x00" + repo.ProviderScope + "\x00" + repo.ProviderRepoID } if repo.Provider != "" && repo.ProviderHost != "" && repo.ProviderOwner != "" && repo.ProviderName != "" { return "provider\x00" + repo.Provider + "\x00" + repo.ProviderHost + "\x00" + repo.ProviderOwner + "\x00" + repo.ProviderName } return "id\x00" + repo.ID } // dedupeRepositoriesByIdentity collapses rows that share a // repositoryIdentityKey — e.g. two rows for the same local_path left behind // by a resolver race, or the same provider repo registered twice — down to // one. Keeps the earliest-created row per key (ties broken by the smaller // ID), matching the winner FindOrCreateRepository / // FindOrCreateRepositoryByLocalPath resolve future references to via // GetRepositoryByProviderInfo / GetRepositoryByLocalPath's // `ORDER BY created_at ASC, id ASC` — so callers do not add a // task_repositories link the UI would then de-list. This is a read-time // safety net: it hides pre-existing duplicate rows from callers without // touching the underlying table, so a caller that still deletes by ID (e.g. // DeleteRepository) must use the ID this function returned, not one filtered // out. Preserves the relative order of first occurrence. func dedupeRepositoriesByIdentity(repos []*models.Repository) []*models.Repository { winners := make(map[string]*models.Repository, len(repos)) for _, repo := range repos { if repo == nil { continue } key := repositoryIdentityKey(repo) current, ok := winners[key] if !ok || repo.CreatedAt.Before(current.CreatedAt) || (repo.CreatedAt.Equal(current.CreatedAt) && repo.ID < current.ID) { winners[key] = repo } } deduped := make([]*models.Repository, 0, len(winners)) seen := make(map[string]struct{}, len(winners)) for _, repo := range repos { if repo == nil { continue } key := repositoryIdentityKey(repo) if _, done := seen[key]; done { continue } seen[key] = struct{}{} deduped = append(deduped, winners[key]) } return deduped } // CountActiveSessionsByRepository returns the number of agent sessions in an // active state (CREATED / STARTING / RUNNING / WAITING_FOR_INPUT) that are // attached to the given repository. Used by the UI to warn users before they // attempt to delete a repository that would otherwise be blocked by // DeleteRepository's ErrActiveTaskSessions sentinel. func (s *Service) CountActiveSessionsByRepository(ctx context.Context, id string) (int, error) { if err := s.authorizeRepositoryID(ctx, id); err != nil { return 0, err } if _, err := s.repoEntities.GetRepository(ctx, id); err != nil { return 0, err } return s.sessions.CountActiveTaskSessionsByRepository(ctx, id) } // Repository script operations func (s *Service) CreateRepositoryScript(ctx context.Context, req *CreateRepositoryScriptRequest) (*models.RepositoryScript, error) { if err := s.authorizeRepositoryID(ctx, req.RepositoryID); err != nil { return nil, err } script := &models.RepositoryScript{ ID: uuid.New().String(), RepositoryID: req.RepositoryID, Name: req.Name, Command: req.Command, Position: req.Position, } if err := s.repoEntities.CreateRepositoryScript(ctx, script); err != nil { s.logger.Error("failed to create repository script", zap.Error(err)) return nil, err } s.publishRepositoryScriptEvent(ctx, events.RepositoryScriptCreated, script) s.logger.Info("repository script created", zap.String("script_id", script.ID)) return script, nil } func (s *Service) GetRepositoryScript(ctx context.Context, id string) (*models.RepositoryScript, error) { script, err := s.repoEntities.GetRepositoryScript(ctx, id) if err != nil { return nil, err } if err := s.authorizeRepositoryID(ctx, script.RepositoryID); err != nil { return nil, err } return script, nil } func (s *Service) UpdateRepositoryScript(ctx context.Context, id string, req *UpdateRepositoryScriptRequest) (*models.RepositoryScript, error) { script, err := s.repoEntities.GetRepositoryScript(ctx, id) if err != nil { return nil, err } if err := s.authorizeRepositoryID(ctx, script.RepositoryID); err != nil { return nil, err } if req.Name != nil { script.Name = *req.Name } if req.Command != nil { script.Command = *req.Command } if req.Position != nil { script.Position = *req.Position } script.UpdatedAt = time.Now().UTC() if err := s.repoEntities.UpdateRepositoryScript(ctx, script); err != nil { s.logger.Error("failed to update repository script", zap.String("script_id", id), zap.Error(err)) return nil, err } s.publishRepositoryScriptEvent(ctx, events.RepositoryScriptUpdated, script) s.logger.Info("repository script updated", zap.String("script_id", script.ID)) return script, nil } func (s *Service) DeleteRepositoryScript(ctx context.Context, id string) error { script, err := s.repoEntities.GetRepositoryScript(ctx, id) if err != nil { return err } if err := s.authorizeRepositoryID(ctx, script.RepositoryID); err != nil { return err } if err := s.repoEntities.DeleteRepositoryScript(ctx, id); err != nil { s.logger.Error("failed to delete repository script", zap.String("script_id", id), zap.Error(err)) return err } s.publishRepositoryScriptEvent(ctx, events.RepositoryScriptDeleted, script) s.logger.Info("repository script deleted", zap.String("script_id", id)) return nil } func (s *Service) ListRepositoryScripts(ctx context.Context, repositoryID string) ([]*models.RepositoryScript, error) { if err := s.authorizeRepositoryID(ctx, repositoryID); err != nil { return nil, err } return s.repoEntities.ListRepositoryScripts(ctx, repositoryID) } // ListScriptsByRepositoryIDs returns scripts for multiple repositories in a single query. func (s *Service) ListScriptsByRepositoryIDs(ctx context.Context, repoIDs []string) (map[string][]*models.RepositoryScript, error) { return s.repoEntities.ListScriptsByRepositoryIDs(ctx, repoIDs) } // Executor operations var ErrKubernetesAdminRequired = errors.New("administrator identity required for Kubernetes settings") func requireKubernetesAdmin(ctx context.Context) error { identity, ok := authn.IdentityFromContext(ctx) if !ok || !identity.IsAdmin() { return ErrKubernetesAdminRequired } return nil } func validateExecutorForType(executorType models.ExecutorType, config map[string]string) error { if err := validateExecutorConfig(config); err != nil { return err } if executorType != models.ExecutorTypeKubernetes { return nil } if _, err := agentkubernetes.ParseExecutorConfig(config); err != nil { return fmt.Errorf("%w: %w", ErrInvalidExecutorConfig, err) } return nil } func validateKubernetesProfileConfig(config map[string]string) error { if _, err := agentkubernetes.ParseProfileConfig(config); err != nil { return fmt.Errorf("%w: %w", ErrInvalidExecutorConfig, err) } return nil } func (s *Service) CreateExecutor(ctx context.Context, req *CreateExecutorRequest) (*models.Executor, error) { if req.Type == models.ExecutorTypeKubernetes { if err := requireKubernetesAdmin(ctx); err != nil { return nil, err } } if err := validateExecutorForType(req.Type, req.Config); err != nil { return nil, err } executor := &models.Executor{ ID: uuid.New().String(), Name: req.Name, Type: req.Type, Status: req.Status, IsSystem: req.IsSystem, Resumable: req.Resumable, Config: req.Config, } if err := s.executors.CreateExecutor(ctx, executor); err != nil { return nil, err } s.publishExecutorEvent(ctx, events.ExecutorCreated, executor) s.notifyExecutorSaved(ctx, nil, executor) return executor, nil } // notifyExecutorSaved forwards a committed executor create/update to the // wired ExecutorSaveObserver, if any. before is nil on create. func (s *Service) notifyExecutorSaved(ctx context.Context, before, after *models.Executor) { if s.executorSaveObserver == nil { return } s.executorSaveObserver.OnExecutorSaved(ctx, before, after) } func (s *Service) GetExecutor(ctx context.Context, id string) (*models.Executor, error) { return s.executors.GetExecutor(ctx, id) } func (s *Service) UpdateExecutor(ctx context.Context, id string, req *UpdateExecutorRequest) (*models.Executor, error) { executor, err := s.executors.GetExecutor(ctx, id) if err != nil { return nil, err } targetType := executor.Type if req.Type != nil { targetType = *req.Type } if executor.Type == models.ExecutorTypeKubernetes || targetType == models.ExecutorTypeKubernetes { if err := requireKubernetesAdmin(ctx); err != nil { return nil, err } } if err := validateExecutorUpdateRequest(executor, req); err != nil { return nil, err } if req.Type != nil && *req.Type != executor.Type && (executor.Type == models.ExecutorTypeKubernetes || *req.Type == models.ExecutorTypeKubernetes) { retained, inventoryErr := s.hasExecutorRunningInventory(ctx, id) if inventoryErr != nil { s.logger.Error("failed to check retained Kubernetes inventory before executor type change", zap.String("executor_id", id), zap.Error(inventoryErr)) return nil, inventoryErr } if retained { return nil, ErrActiveTaskSessions } } before := *executor applyExecutorUpdates(executor, req) executor.UpdatedAt = time.Now().UTC() if err := s.executors.UpdateExecutor(ctx, executor); err != nil { return nil, err } s.publishExecutorEvent(ctx, events.ExecutorUpdated, executor) s.notifyExecutorSaved(ctx, &before, executor) return executor, nil } // validateExecutorUpdateRequest validates config and system executor constraints. func validateExecutorUpdateRequest(executor *models.Executor, req *UpdateExecutorRequest) error { targetType := executor.Type if req.Type != nil { targetType = *req.Type } if req.Config != nil { if err := validateExecutorForType(targetType, req.Config); err != nil { return err } } else if targetType == models.ExecutorTypeKubernetes { if err := validateExecutorForType(targetType, executor.Config); err != nil { return err } } if !executor.IsSystem { return nil } if req.Name != nil && *req.Name != executor.Name { return fmt.Errorf("system executors cannot be modified") } if req.Type != nil && *req.Type != executor.Type { return fmt.Errorf("system executors cannot be modified") } if req.Status != nil && *req.Status != executor.Status { return fmt.Errorf("system executors cannot be modified") } if req.Resumable != nil && *req.Resumable != executor.Resumable { return fmt.Errorf("system executors cannot be modified") } return nil } // applyExecutorUpdates copies non-nil request fields onto the executor model. func applyExecutorUpdates(executor *models.Executor, req *UpdateExecutorRequest) { if req.Name != nil { executor.Name = *req.Name } if req.Type != nil { executor.Type = *req.Type } if req.Status != nil { executor.Status = *req.Status } if req.Resumable != nil { executor.Resumable = *req.Resumable } if req.Config != nil { executor.Config = req.Config } } func (s *Service) DeleteExecutor(ctx context.Context, id string) error { executor, err := s.executors.GetExecutor(ctx, id) if err != nil { return err } if executor.Type == models.ExecutorTypeKubernetes { if err := requireKubernetesAdmin(ctx); err != nil { return err } } if executor.IsSystem { return fmt.Errorf("system executors cannot be deleted") } active, err := s.sessions.HasActiveTaskSessionsByExecutor(ctx, id) if err != nil { s.logger.Error("failed to check active agent sessions for executor", zap.String("executor_id", id), zap.Error(err)) return err } if active { return ErrActiveTaskSessions } if executor.Type == models.ExecutorTypeKubernetes { retained, inventoryErr := s.hasExecutorRunningInventory(ctx, id) if inventoryErr != nil { s.logger.Error("failed to check retained Kubernetes inventory for executor", zap.String("executor_id", id), zap.Error(inventoryErr)) return inventoryErr } if retained { return ErrActiveTaskSessions } } if err := s.executors.DeleteExecutor(ctx, id); err != nil { return err } s.publishExecutorEvent(ctx, events.ExecutorDeleted, executor) return nil } func (s *Service) hasExecutorRunningInventory(ctx context.Context, executorID string) (bool, error) { if retained, err := s.hasKubernetesEnvironmentInventory(ctx, executorID); err != nil || retained { return retained, err } running, err := s.executors.ListExecutorsRunning(ctx) if err != nil { return false, err } for _, row := range running { if row == nil { continue } currentID := strings.TrimSpace(row.ExecutorID) recordedID, recordedOK := row.Metadata[agentkubernetes.MetadataKeyResourceExecutorID].(string) recordedID = strings.TrimSpace(recordedID) if currentID == executorID { return true, nil } if recordedID == executorID { return true, nil } // Only two matching, nonempty associations can prove that a Kubernetes // row belongs to some other executor. Anything less must block cleanup. if row.Runtime == agentruntime.RuntimeKubernetes && (currentID == "" || !recordedOK || recordedID == "" || currentID != recordedID) { return true, nil } } return false, nil } func (s *Service) ListExecutors(ctx context.Context) ([]*models.Executor, error) { return s.executors.ListExecutors(ctx) } // Executor Profile operations func (s *Service) CreateExecutorProfile(ctx context.Context, req *CreateExecutorProfileRequest) (*models.ExecutorProfile, error) { if req.Name == "" { return nil, fmt.Errorf("profile name is required") } if req.ExecutorID == "" { return nil, fmt.Errorf("executor_id is required") } // Verify executor exists executor, err := s.executors.GetExecutor(ctx, req.ExecutorID) if err != nil { return nil, fmt.Errorf("executor not found: %w", err) } if executor.Type == models.ExecutorTypeKubernetes { if err := requireKubernetesAdmin(ctx); err != nil { return nil, err } if err := validateKubernetesProfileConfig(req.Config); err != nil { return nil, err } } if err := s.validateGlobalProfileEnvRefs(ctx, req.EnvVars); err != nil { return nil, err } if executor.Type == models.ExecutorTypeSprites && !hasSpritesToken(req.EnvVars) { return nil, fmt.Errorf("sprites profiles require %s env var", spritesTokenEnvKey) } profile := &models.ExecutorProfile{ ExecutorID: req.ExecutorID, Name: req.Name, McpPolicy: req.McpPolicy, Config: req.Config, PrepareScript: req.PrepareScript, CleanupScript: req.CleanupScript, EnvVars: req.EnvVars, } if err := s.executors.CreateExecutorProfile(ctx, profile); err != nil { return nil, err } s.publishExecutorProfileEvent(ctx, events.ExecutorProfileCreated, profile) return profile, nil } func (s *Service) GetExecutorProfile(ctx context.Context, id string) (*models.ExecutorProfile, error) { return s.executors.GetExecutorProfile(ctx, id) } func (s *Service) UpdateExecutorProfile(ctx context.Context, id string, req *UpdateExecutorProfileRequest) (*models.ExecutorProfile, error) { profile, err := s.executors.GetExecutorProfile(ctx, id) if err != nil { return nil, err } executor, err := s.executors.GetExecutor(ctx, profile.ExecutorID) if err != nil { return nil, err } if executor.Type == models.ExecutorTypeKubernetes { if err := requireKubernetesAdmin(ctx); err != nil { return nil, err } config := profile.Config if req.Config != nil { config = req.Config } if err := validateKubernetesProfileConfig(config); err != nil { return nil, err } } if req.Name != nil { profile.Name = *req.Name } if req.McpPolicy != nil { profile.McpPolicy = *req.McpPolicy } if req.Config != nil { profile.Config = req.Config } if req.PrepareScript != nil { profile.PrepareScript = *req.PrepareScript } if req.CleanupScript != nil { profile.CleanupScript = *req.CleanupScript } if req.EnvVars != nil { if err := s.validateGlobalProfileEnvRefs(ctx, req.EnvVars); err != nil { return nil, err } if executor.Type == models.ExecutorTypeSprites { req.EnvVars = mergeSpritesTokenEnvVars(profile.EnvVars, req.EnvVars) } profile.EnvVars = req.EnvVars } var updateErr error if req.ExpectedUpdatedAt != nil { updateErr = s.executors.UpdateExecutorProfileIfUnmodified(ctx, profile, *req.ExpectedUpdatedAt) } else { updateErr = s.executors.UpdateExecutorProfile(ctx, profile) } if updateErr != nil { return nil, updateErr } s.publishExecutorProfileEvent(ctx, events.ExecutorProfileUpdated, profile) return profile, nil } func (s *Service) validateGlobalProfileEnvRefs(ctx context.Context, envVars []models.ProfileEnvVar) error { if s.secretStore == nil { return nil } for i, envVar := range envVars { if strings.TrimSpace(envVar.SecretID) == "" { continue } if err := secrets.ValidateGlobalReference(ctx, s.secretStore, envVar.SecretID); err != nil { return fmt.Errorf("profile env_vars[%d] secret must be global", i) } } return nil } func mergeSpritesTokenEnvVars(existing, incoming []models.ProfileEnvVar) []models.ProfileEnvVar { merged := make([]models.ProfileEnvVar, 0, len(incoming)+1) var existingToken models.ProfileEnvVar hasExistingToken := false hasIncomingToken := false explicitRemove := false for _, ev := range existing { if strings.TrimSpace(ev.Key) != spritesTokenEnvKey { continue } if strings.TrimSpace(ev.SecretID) == "" && strings.TrimSpace(ev.Value) == "" { continue } existingToken = ev hasExistingToken = true break } for _, ev := range incoming { if strings.TrimSpace(ev.Key) != spritesTokenEnvKey { merged = append(merged, ev) continue } if strings.TrimSpace(ev.SecretID) == "" && strings.TrimSpace(ev.Value) == "" { explicitRemove = true continue } hasIncomingToken = true merged = append(merged, ev) } if !hasIncomingToken && !explicitRemove && hasExistingToken { merged = append(merged, existingToken) } return merged } func hasSpritesToken(envVars []models.ProfileEnvVar) bool { for _, ev := range envVars { if strings.TrimSpace(ev.Key) != spritesTokenEnvKey { continue } if strings.TrimSpace(ev.SecretID) != "" || strings.TrimSpace(ev.Value) != "" { return true } } return false } func (s *Service) DeleteExecutorProfile(ctx context.Context, id string) error { profile, err := s.executors.GetExecutorProfile(ctx, id) if err != nil { return err } executor, err := s.executors.GetExecutor(ctx, profile.ExecutorID) if err != nil && !errors.Is(err, models.ErrExecutorNotFound) { return err } if executor != nil && executor.Type == models.ExecutorTypeKubernetes { if err := requireKubernetesAdmin(ctx); err != nil { return err } } if err := s.executors.DeleteExecutorProfile(ctx, id); err != nil { return err } s.publishExecutorProfileEvent(ctx, events.ExecutorProfileDeleted, profile) return nil } func (s *Service) ListExecutorProfiles(ctx context.Context, executorID string) ([]*models.ExecutorProfile, error) { return s.executors.ListExecutorProfiles(ctx, executorID) } func (s *Service) ListAllExecutorProfiles(ctx context.Context) ([]*models.ExecutorProfile, error) { return s.executors.ListAllExecutorProfiles(ctx) } // Environment operations func (s *Service) CreateEnvironment(ctx context.Context, req *CreateEnvironmentRequest) (*models.Environment, error) { environment := &models.Environment{ ID: uuid.New().String(), Name: req.Name, Kind: req.Kind, IsSystem: false, WorktreeRoot: req.WorktreeRoot, ImageTag: req.ImageTag, Dockerfile: req.Dockerfile, BuildConfig: req.BuildConfig, } if err := s.environments.CreateEnvironment(ctx, environment); err != nil { return nil, err } s.publishEnvironmentEvent(ctx, events.EnvironmentCreated, environment) return environment, nil } func (s *Service) GetEnvironment(ctx context.Context, id string) (*models.Environment, error) { return s.environments.GetEnvironment(ctx, id) } func (s *Service) UpdateEnvironment(ctx context.Context, id string, req *UpdateEnvironmentRequest) (*models.Environment, error) { environment, err := s.environments.GetEnvironment(ctx, id) if err != nil { return nil, err } if environment.IsSystem { if req.Name != nil || req.Kind != nil || req.ImageTag != nil || req.Dockerfile != nil || req.BuildConfig != nil { return nil, fmt.Errorf("system environments can only update the worktree root") } } if req.Name != nil { environment.Name = *req.Name } if req.Kind != nil { environment.Kind = *req.Kind } if req.WorktreeRoot != nil { environment.WorktreeRoot = *req.WorktreeRoot } if req.ImageTag != nil { environment.ImageTag = *req.ImageTag } if req.Dockerfile != nil { environment.Dockerfile = *req.Dockerfile } if req.BuildConfig != nil { environment.BuildConfig = req.BuildConfig } environment.UpdatedAt = time.Now().UTC() if err := s.environments.UpdateEnvironment(ctx, environment); err != nil { return nil, err } s.publishEnvironmentEvent(ctx, events.EnvironmentUpdated, environment) return environment, nil } func (s *Service) DeleteEnvironment(ctx context.Context, id string) error { environment, err := s.environments.GetEnvironment(ctx, id) if err != nil { return err } if environment.IsSystem { return fmt.Errorf("system environments cannot be deleted") } active, err := s.sessions.HasActiveTaskSessionsByEnvironment(ctx, id) if err != nil { s.logger.Error("failed to check active agent sessions for environment", zap.String("environment_id", id), zap.Error(err)) return err } if active { return ErrActiveTaskSessions } if err := s.environments.DeleteEnvironment(ctx, id); err != nil { return err } s.publishEnvironmentEvent(ctx, events.EnvironmentDeleted, environment) return nil } func (s *Service) ListEnvironments(ctx context.Context) ([]*models.Environment, error) { return s.environments.ListEnvironments(ctx) }