Files
kandev/apps/backend/internal/task/service/service_resources.go
T
João Salavisa be9821fc55 fix(task): surface add_branch_to_task failures instead of silent orphans (#1264)
* fix(worktree): promote FallbackBaseBranch when BaseBranch is empty

CreateRequest.Validate previously hard-rejected an empty BaseBranch even
when the caller supplied a FallbackBaseBranch. The fallback is plumbed
specifically so a missing primary branch can degrade into the repo's
default — this lets that path actually fire.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(task): probe provider default branch in add_branch resolve path

Adds an opt-in ProviderDefaultBranchProber seam (impl wired in cmd/kandev)
and threads a ResolveProviderDefaults flag through TaskRepositoryInput so
the add_branch_to_task GitHub-URL path can resolve a real default_branch
before writing the Repository row. Also backfills the existing repo's
default_branch when the probe succeeds and the workspace already had a row
with an empty value. The create_task path keeps its zero-default contract
(executor still backfills after clone).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(task): surface add_branch_to_task failures instead of silent orphans

AddBranchToTask used to return a success envelope even when the
post-insert worktree materialize step failed on a live task. The
task_repositories row stayed in place with no on-disk worktree, and
provider-URL flows also left an orphan repositories row behind.

Three coordinated changes:
- Gate empty base_branch after resolution with a typed error so no row
  is written when nothing can be materialized.
- Track whether the repository row was created by this call and roll it
  back on any subsequent failure.
- materializeBranch now returns its error; on a live worktree-executor
  task, AddBranchToTask rolls back the task_repositories row and surfaces
  the failure. Pre-launch tasks keep the legacy best-effort path so the
  next session launch still materializes via the multi-repo prepare flow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(mcp): classify add_branch base_branch resolution failure as validation

AddBranchToTask now returns a "cannot resolve base_branch" error when the
provider repo's default branch could not be probed. Map it to
ErrorCodeValidation so MCP agents see a user-fixable input issue rather
than an opaque internal error.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(task): defer task.updated until after worktree materializes

Publishing task.updated before materializeBranch ran meant a failed
materialize on a live task would push the new task_repository row to WS
clients and then silently roll it back, leaving stale UI state until the
next poll. Move the event after materialize success so the bus only sees
durable rows. Test now also asserts no task.updated fires on the rollback
path. Renames stubProber.lastProvid to lastProviderArg for clarity.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(task): emit repository.deleted on add_branch orphan rollback

Cleanup callback previously called repoEntities.DeleteRepository directly,
which soft-deletes the row but bypasses Service.DeleteRepository's event
publish. The create path emitted repository.created, so skipping the
delete event left WS subscribers and frontend caches holding a phantom
row after a resolve-then-fail sequence. Route through the service method
so the events stay symmetric. Test now asserts the symmetry.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(task): skip provider probe when workspace already has the repo

resolveRepoInput's add_branch probe fired on every add_branch_to_task
call with a github_url, even when the workspace already had the
provider repo registered with a non-empty default_branch. The existing
row's value would win seconds later via the repo.DefaultBranch fallback,
so the remote git ls-remote round-trip was pure waste — and unnecessarily
hit network on every call. Peek at the workspace registry first and
short-circuit when a usable default_branch is already on file.

Also adds a QA-tier test file covering probe-error fallthrough, bad URL
rejection, registered-repo probe skipping, empty-default backfill, and
the repository_id fast path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(task): detach rollback paths from caller ctx, log probe-lookup errors

Two adjacent robustness fixes flagged on PR #1264:

1. resolveRepoInput silently discarded the GetRepositoryByProviderInfo
   error before the provider probe. A transient DB failure was
   indistinguishable from a clean "not found" lookup, so the code would
   pay for a network probe instead of failing fast. Surface the error in
   logs and skip the probe — FindOrCreateRepository below already hits
   the same DB and will return the real cause.

2. The repository-create cleanup callback in resolveBranchAddRepoRef and
   the task_repositories rollback in AddBranchToTask both captured the
   caller ctx for their DELETE queries. Rollback often runs precisely
   because that ctx was cancelled (timeout, client disconnect), and
   reusing it would kill the cleanup and orphan the row we just created.
   Use context.WithoutCancel so the rollback survives ctx death.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(task): close orphan-row gaps from caller cancel and concurrent create

Two findings from Claude's review on PR #1264:

1. (blocker) materializeBranch called taskAlreadyLaunched(ctx, ...) after
   MaterializeBranch returned, using the same caller context that often
   failed the materialize in the first place. A cancelled ctx made the
   post-failure DB lookup return (nil, err), taskAlreadyLaunched returned
   false, and the live-task rollback path silently demoted itself to the
   pre-launch best-effort branch — leaving the orphan task_repositories
   row the PR exists to prevent. Snapshot the launch state BEFORE
   MaterializeBranch (launch state can't change during the synchronous
   call) so the rollback decision is anchored to a live-ctx answer.

2. (suggestion) resolveBranchAddRepoRef inferred "this call created the
   repo" by comparing the resolved ID against a workspace ListRepositories
   snapshot taken before FindOrCreateRepository. A concurrent add_branch
   for the same GitHub URL could create the row between snapshot and
   lookup; the second request would treat it as "not in my snapshot, must
   be mine" and register cleanup against the other request's data. On any
   later failure, cleanup would DELETE a repository this call didn't
   create and emit a spurious repository.deleted event.

   Thread an explicit `created bool` out of CreateRepository through
   FindOrCreateRepository → resolveRepoInput → ResolveRepositoryRef so
   the cleanup callback fires only when this call physically inserted
   the row. The flag is set at the CreateRepository / FindOrCreateRepository
   sites themselves and cannot be spoofed by concurrent activity.

resolveRepoInput's branches were extracted into resolveRepoInputGitHub /
resolveRepoInputLocal helpers to keep the parent function inside the
cyclomatic-complexity and cognitive-complexity budgets after the signature
change.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: retrigger cubic CI

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 15:43:41 +01:00

899 lines
30 KiB
Go

package service
import (
"context"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/worktree"
)
const spritesTokenEnvKey = "SPRITES_API_TOKEN"
// Workspace operations
// CreateWorkspace creates a new workspace
func (s *Service) CreateWorkspace(ctx context.Context, req *CreateWorkspaceRequest) (*models.Workspace, error) {
workspace := &models.Workspace{
ID: uuid.New().String(),
Name: req.Name,
Description: req.Description,
OwnerID: req.OwnerID,
DefaultExecutorID: normalizeOptionalID(req.DefaultExecutorID),
DefaultEnvironmentID: normalizeOptionalID(req.DefaultEnvironmentID),
DefaultAgentProfileID: normalizeOptionalID(req.DefaultAgentProfileID),
DefaultConfigAgentProfileID: normalizeOptionalID(req.DefaultConfigAgentProfileID),
}
if err := s.workspaces.CreateWorkspace(ctx, workspace); err != nil {
s.logger.Error("failed to create workspace", zap.Error(err))
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))
return workspace, nil
}
// GetWorkspace retrieves a workspace by ID
func (s *Service) GetWorkspace(ctx context.Context, id string) (*models.Workspace, error) {
return s.workspaces.GetWorkspace(ctx, id)
}
// 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 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.workspaces.DeleteWorkspace(ctx, id); err != nil {
s.logger.Error("failed to delete workspace", zap.String("workspace_id", id), zap.Error(err))
return err
}
s.publishWorkspaceEvent(ctx, events.WorkspaceDeleted, workspace)
s.logger.Info("workspace deleted", zap.String("workspace_id", id))
return nil
}
func normalizeOptionalID(value *string) *string {
if value == nil {
return nil
}
trimmed := strings.TrimSpace(*value)
if trimmed == "" {
return nil
}
return &trimmed
}
// ListWorkspaces returns all workspaces
func (s *Service) ListWorkspaces(ctx context.Context) ([]*models.Workspace, error) {
return s.workspaces.ListWorkspaces(ctx)
}
// Workflow operations
// CreateWorkflow creates a new workflow
func (s *Service) CreateWorkflow(ctx context.Context, req *CreateWorkflowRequest) (*models.Workflow, error) {
workflow := &models.Workflow{
ID: uuid.New().String(),
WorkspaceID: req.WorkspaceID,
Name: req.Name,
Description: req.Description,
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) {
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) {
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.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
}
// DeleteWorkflow deletes a workflow
func (s *Service) DeleteWorkflow(ctx context.Context, id string) error {
workflow, err := s.workflows.GetWorkflow(ctx, id)
if err != nil {
return err
}
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))
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) {
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.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) {
sourceType := req.SourceType
if sourceType == "" {
sourceType = "local"
}
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
}
pullBeforeWorktree := true
if req.PullBeforeWorktree != nil {
pullBeforeWorktree = *req.PullBeforeWorktree
}
repository := &models.Repository{
ID: uuid.New().String(),
WorkspaceID: req.WorkspaceID,
Name: req.Name,
SourceType: sourceType,
LocalPath: req.LocalPath,
Provider: req.Provider,
ProviderRepoID: req.ProviderRepoID,
ProviderOwner: req.ProviderOwner,
ProviderName: req.ProviderName,
DefaultBranch: req.DefaultBranch,
WorktreeBranchPrefix: prefix,
PullBeforeWorktree: pullBeforeWorktree,
SetupScript: req.SetupScript,
CleanupScript: req.CleanupScript,
DevScript: req.DevScript,
CopyFiles: req.CopyFiles,
}
// Auto-detect GitHub provider info from git remote if not provided
if repository.Provider == "" && repository.LocalPath != "" {
if p, o, n := ResolveGitRemoteProvider(repository.LocalPath); p != "" {
repository.Provider = p
repository.ProviderOwner = o
repository.ProviderName = n
}
}
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
}
func (s *Service) GetRepository(ctx context.Context, id string) (*models.Repository, error) {
return s.repoEntities.GetRepository(ctx, id)
}
// 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, owner, name string) (*models.Repository, error) {
return s.repoEntities.GetRepositoryByProviderInfo(ctx, workspaceID, provider, owner, 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) {
existing, err := s.repoEntities.GetRepositoryByProviderInfo(ctx, req.WorkspaceID, req.Provider, req.ProviderOwner, req.ProviderName)
if err != nil {
return nil, false, fmt.Errorf("lookup repository: %w", err)
}
if existing != nil {
dirty := false
if existing.LocalPath == "" && req.LocalPath != "" {
existing.LocalPath = req.LocalPath
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).
if existing.DefaultBranch == "" && req.DefaultBranch != "" {
existing.DefaultBranch = req.DefaultBranch
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, false, nil
}
name := fmt.Sprintf("%s/%s", req.ProviderOwner, req.ProviderName)
created, createErr := s.CreateRepository(ctx, &CreateRepositoryRequest{
WorkspaceID: req.WorkspaceID,
Name: name,
SourceType: "provider",
LocalPath: req.LocalPath,
Provider: req.Provider,
ProviderOwner: req.ProviderOwner,
ProviderName: req.ProviderName,
DefaultBranch: req.DefaultBranch,
})
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 err := applyRepositoryUpdates(repository, req); err != nil {
return nil, err
}
repository.UpdatedAt = time.Now().UTC()
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
}
// 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.ProviderOwner != nil {
repository.ProviderOwner = *req.ProviderOwner
}
if req.ProviderName != nil {
repository.ProviderName = *req.ProviderName
}
if req.DefaultBranch != nil {
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.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 {
repository.CopyFiles = *req.CopyFiles
}
return nil
}
func (s *Service) DeleteRepository(ctx context.Context, id string) error {
repository, err := s.repoEntities.GetRepository(ctx, id)
if err != nil {
return err
}
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
}
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)
s.logger.Info("repository deleted", zap.String("repository_id", id))
return nil
}
func (s *Service) ListRepositories(ctx context.Context, workspaceID string) ([]*models.Repository, error) {
return s.repoEntities.ListRepositories(ctx, workspaceID)
}
// 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.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) {
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) {
return s.repoEntities.GetRepositoryScript(ctx, id)
}
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 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.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) {
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
func (s *Service) CreateExecutor(ctx context.Context, req *CreateExecutorRequest) (*models.Executor, error) {
if err := validateExecutorConfig(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)
return executor, nil
}
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
}
if err := validateExecutorUpdateRequest(executor, req); err != nil {
return nil, err
}
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)
return executor, nil
}
// validateExecutorUpdateRequest validates config and system executor constraints.
func validateExecutorUpdateRequest(executor *models.Executor, req *UpdateExecutorRequest) error {
if req.Config != nil {
if err := validateExecutorConfig(req.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.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 err := s.executors.DeleteExecutor(ctx, id); err != nil {
return err
}
s.publishExecutorEvent(ctx, events.ExecutorDeleted, executor)
return 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.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 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 executor.Type == models.ExecutorTypeSprites {
req.EnvVars = mergeSpritesTokenEnvVars(profile.EnvVars, req.EnvVars)
}
profile.EnvVars = req.EnvVars
}
if err := s.executors.UpdateExecutorProfile(ctx, profile); err != nil {
return nil, err
}
s.publishExecutorProfileEvent(ctx, events.ExecutorProfileUpdated, profile)
return profile, 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
}
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)
}