Files
kandev/apps/backend/internal/task/service/service.go
T
José Almeida 9b82595cfd feat(backend): add task-level reset environment action
Users sometimes need a clean slate — broken packages, stale caches, a
container that drifted into a bad state. Reset Environment is the explicit
escape hatch: tear down the recorded container, sandbox, and/or worktree
for a task and delete the TaskEnvironment row so the next session starts
fresh.

- service.ResetTaskEnvironment(taskID, ResetOptions) is the entry point.
  ResetOptions.PushBranch optionally git-pushes the workspace branch
  before teardown so committed work isn't dropped on the floor; a failed
  push aborts the reset and leaves the environment intact.
- A new EnvironmentDestroyer interface in the task service hides the
  per-resource teardown behind a single seam, implemented in cmd/kandev
  by delegating to lifecycle.Manager (containers, sprites) and
  worktree.Manager (worktrees, branch preserved).
- lifecycle.Manager.DestroyContainer / DestroySandbox dispatch to the
  Docker and Sprites executor backends without leaking model types into
  the lifecycle package.
- Reset is blocked while any session on the task still has an
  ExecutorRunning row (returns ErrSessionRunning → HTTP 409). Per-resource
  teardown is best-effort but the DB row is only removed once everything
  is confirmed gone or already missing, so a partial failure is retryable
  by clicking Reset again.
- Endpoint: POST /api/v1/tasks/:id/environment/reset.
2026-05-04 21:08:51 +01:00

201 lines
7.6 KiB
Go

package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/internal/task/repository"
wfmodels "github.com/kandev/kandev/internal/workflow/models"
"github.com/kandev/kandev/internal/worktree"
)
// WorktreeCleanup provides worktree cleanup on task deletion.
type WorktreeCleanup interface {
// OnTaskDeleted is called when a task is deleted to clean up its worktree.
OnTaskDeleted(ctx context.Context, taskID string) error
}
// WorktreeProvider extends WorktreeCleanup with query capabilities.
// Implementations that support this can be type-asserted from WorktreeCleanup.
type WorktreeProvider interface {
WorktreeCleanup
// GetAllByTaskID returns all worktrees associated with a task.
GetAllByTaskID(ctx context.Context, taskID string) ([]*worktree.Worktree, error)
}
// WorktreeBatchCleaner extends WorktreeProvider with batch cleanup.
type WorktreeBatchCleaner interface {
WorktreeProvider
// CleanupWorktrees removes multiple worktrees in a single operation.
CleanupWorktrees(ctx context.Context, worktrees []*worktree.Worktree) error
}
// TaskExecutionStopper stops active task execution (agent session + instance).
type TaskExecutionStopper interface {
StopTask(ctx context.Context, taskID, reason string, force bool) error
StopSession(ctx context.Context, sessionID, reason string, force bool) error
StopExecution(ctx context.Context, executionID, reason string, force bool) error
}
// GitArchiveCapture captures git state (commits, cumulative diff) when a task is archived.
// This allows preserving the final git state of a session for historical purposes.
type GitArchiveCapture interface {
// CaptureArchiveSnapshot captures the git state for a session before archiving.
// Returns nil if capture is not possible (e.g., agent not running).
CaptureArchiveSnapshot(ctx context.Context, sessionID string) error
}
// WorkflowStepCreator creates workflow steps from a template for a workflow.
type WorkflowStepCreator interface {
CreateStepsFromTemplate(ctx context.Context, workflowID, templateID string) error
}
// WorkflowStepGetter retrieves workflow step information.
type WorkflowStepGetter interface {
GetStep(ctx context.Context, stepID string) (*wfmodels.WorkflowStep, error)
// GetNextStepByPosition returns the next step after the given position for a workflow.
// Returns nil if there is no next step (i.e., current step is the last one).
GetNextStepByPosition(ctx context.Context, workflowID string, currentPosition int) (*wfmodels.WorkflowStep, error)
}
// StartStepResolver resolves the starting step for a workflow.
type StartStepResolver interface {
ResolveStartStep(ctx context.Context, workflowID string) (string, error)
ResolveFirstStep(ctx context.Context, workflowID string) (string, error)
}
var (
ErrActiveTaskSessions = errors.New("active agent sessions exist")
ErrInvalidRepositorySettings = errors.New("invalid repository settings")
ErrInvalidExecutorConfig = errors.New("invalid executor config")
)
func validateExecutorConfig(config map[string]string) error {
if config == nil {
return nil
}
policy := strings.TrimSpace(config["mcp_policy"])
if policy == "" {
return nil
}
var decoded any
if err := json.Unmarshal([]byte(policy), &decoded); err != nil {
return fmt.Errorf("%w: mcp_policy must be valid JSON", ErrInvalidExecutorConfig)
}
if _, ok := decoded.(map[string]any); !ok {
return fmt.Errorf("%w: mcp_policy must be a JSON object", ErrInvalidExecutorConfig)
}
return nil
}
// Repos holds the repository sub-interfaces used by the task service.
type Repos struct {
Workspaces repository.WorkspaceRepository
Tasks repository.TaskRepository
TaskRepos repository.TaskRepoRepository
Workflows repository.WorkflowRepository
Messages repository.MessageRepository
Turns repository.TurnRepository
Sessions repository.SessionRepository
GitSnapshots repository.GitSnapshotRepository
RepoEntities repository.RepositoryEntityRepository
Executors repository.ExecutorRepository
Environments repository.EnvironmentRepository
TaskEnvironments repository.TaskEnvironmentRepository
Reviews repository.ReviewRepository
}
// Service provides task business logic
type Service struct {
workspaces repository.WorkspaceRepository
tasks repository.TaskRepository
taskRepos repository.TaskRepoRepository
workflows repository.WorkflowRepository
messages repository.MessageRepository
turns repository.TurnRepository
sessions repository.SessionRepository
gitSnapshots repository.GitSnapshotRepository
repoEntities repository.RepositoryEntityRepository
executors repository.ExecutorRepository
environments repository.EnvironmentRepository
taskEnvironments repository.TaskEnvironmentRepository
reviews repository.ReviewRepository
eventBus bus.EventBus
logger *logger.Logger
discoveryConfig RepositoryDiscoveryConfig
worktreeCleanup WorktreeCleanup
executionStopper TaskExecutionStopper
gitArchiveCapture GitArchiveCapture
workflowStepCreator WorkflowStepCreator
workflowStepGetter WorkflowStepGetter
startStepResolver StartStepResolver
quickChatDir string // Directory for quick-chat workspaces (e.g., ~/.kandev/quick-chat)
branchFetcher *branchFetcher
envDestroyer EnvironmentDestroyer
sessionRunningChecker SessionRunningChecker
}
// NewService creates a new task service
func NewService(repos Repos, eventBus bus.EventBus, log *logger.Logger, discoveryConfig RepositoryDiscoveryConfig) *Service {
return &Service{
workspaces: repos.Workspaces,
tasks: repos.Tasks,
taskRepos: repos.TaskRepos,
workflows: repos.Workflows,
messages: repos.Messages,
turns: repos.Turns,
sessions: repos.Sessions,
gitSnapshots: repos.GitSnapshots,
repoEntities: repos.RepoEntities,
executors: repos.Executors,
environments: repos.Environments,
taskEnvironments: repos.TaskEnvironments,
reviews: repos.Reviews,
eventBus: eventBus,
logger: log,
discoveryConfig: discoveryConfig,
branchFetcher: newBranchFetcher(log.Zap()),
}
}
// SetWorktreeCleanup sets the worktree cleanup handler for task deletion.
func (s *Service) SetWorktreeCleanup(cleanup WorktreeCleanup) {
s.worktreeCleanup = cleanup
}
// SetExecutionStopper wires the task execution stopper (orchestrator).
func (s *Service) SetExecutionStopper(stopper TaskExecutionStopper) {
s.executionStopper = stopper
}
// SetGitArchiveCapture wires the git archive capture handler.
func (s *Service) SetGitArchiveCapture(capture GitArchiveCapture) {
s.gitArchiveCapture = capture
}
// SetWorkflowStepCreator wires the workflow step creator for workflow creation.
func (s *Service) SetWorkflowStepCreator(creator WorkflowStepCreator) {
s.workflowStepCreator = creator
}
// SetWorkflowStepGetter wires the workflow step getter for MoveTask.
func (s *Service) SetWorkflowStepGetter(getter WorkflowStepGetter) {
s.workflowStepGetter = getter
}
// SetStartStepResolver wires the start step resolver for CreateTask.
func (s *Service) SetStartStepResolver(resolver StartStepResolver) {
s.startStepResolver = resolver
}
// SetQuickChatDir sets the directory for quick-chat workspaces.
// When set, ephemeral task cleanup will delete the session directory under this path.
func (s *Service) SetQuickChatDir(dir string) {
s.quickChatDir = dir
}