Files
kandev/apps/backend/internal/task/service/service.go
T
2026-08-02 17:25:55 +01:00

463 lines
20 KiB
Go

package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/repository"
wfmodels "github.com/kandev/kandev/internal/workflow/models"
"github.com/kandev/kandev/internal/worktree"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
// 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
}
type worktreeReferenceGuard interface {
CountActiveWorktreeReferences(ctx context.Context, worktreeID string, excludeSessionIDs []string) (int, error)
ReleaseWorktreeReference(ctx context.Context, wt *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
// RegisterExecutionStopOwner records exact teardown ownership before a
// terminal session mutation. It never replaces the explicit stop call.
RegisterExecutionStopOwner(sessionID, executionID string, force bool)
}
// TaskResourceCleanupActivityGate serializes durable cleanup with install-wide maintenance.
type TaskResourceCleanupActivityGate interface {
AcquireTaskResourceCleanup(context.Context) (TaskResourceCleanupActivityLease, error)
}
type TaskResourceCleanupActivityLease interface {
Release()
}
// ProviderDefaultBranchProber resolves a provider repo's default branch
// (e.g. "main" / "master") without requiring a local clone. Used by
// AddBranchToTask to satisfy the worktree-create precondition synchronously,
// since add_branch does not trigger the executor-side backfillRepoDefaultBranch
// path. Default implementation (cmd/kandev) shells out to
// `git ls-remote --symref`; tests inject a stub via SetProviderDefaultBranchProber.
//
// Implementations MUST honour ctx cancellation so a slow / hung remote does not
// stall the calling MCP tool. Returns ("", error) on probe failure — callers
// fall through to the explicit "cannot resolve base_branch" rejection rather
// than persisting an empty-default row.
type ProviderDefaultBranchProber interface {
ProbeDefaultBranch(ctx context.Context, provider, owner, name string) (string, error)
}
// BranchMaterializer creates a worktree on disk and persists a
// task_session_worktrees row for a newly added task_repository row, without
// restarting the agent. Used by AddBranchToTask so MCP-driven "add a branch
// to this task" actually surfaces the new worktree in the UI on the next
// poll, rather than waiting for a session relaunch.
//
// The implementation lives in cmd/kandev (it needs worktree.Manager, the
// session/env repos, and the repository entity layer) — the service layer
// only knows the abstract capability.
// AgentBaseBranchPusher pushes an updated per-repo base-branch map to the
// agentctl instance(s) of any running execution for a task. Used by
// UpdateRepositoryBaseBranch so the changes-panel "Compare against" picker
// updates BaseCommit / Ahead / Behind live, not just at next session start.
// Implementations must be no-op-safe when the task has no running execution.
type AgentBaseBranchPusher interface {
PushBaseBranchesForTask(ctx context.Context, taskID string, branches map[string]string)
}
type BranchMaterializer interface {
// MaterializeBranch creates the worktree for a freshly inserted
// task_repositories row. Best-effort: when no active session exists yet
// the implementation may choose to no-op and let the next session launch
// create the worktree via the standard multi-repo prepare path.
MaterializeBranch(ctx context.Context, taskID, taskRepositoryID string) (*BranchMaterializationResult, error)
}
// BranchMaterializationResult describes the live worktree created for a
// branch attachment. A nil result with a nil error means materialization was
// intentionally deferred until the next session launch; callers must check
// for a nil result rather than empty path fields.
type BranchMaterializationResult struct {
WorktreePath string
TaskWorkspacePath string
}
// 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
}
// WorkspaceBootstrapper owns the atomic persistence of a standard Kanban
// workspace and its initial workflow state.
type WorkspaceBootstrapper interface {
CreateWorkspaceWithKanban(ctx context.Context, workspace *models.Workspace) (*models.Workflow, error)
}
// WorkspaceDefaultsInitializer persists integration defaults after a
// workspace row exists and before its creation event is published.
type WorkspaceDefaultsInitializer interface {
InitializeWorkspaceDefaults(ctx context.Context, workspaceID 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)
}
// workflowStepLister is an optional extension used to find WIP steps that
// pull work from a feeder when new work arrives in that feeder.
type workflowStepLister interface {
ListStepsByWorkflow(ctx context.Context, workflowID string) ([]*wfmodels.WorkflowStep, error)
}
// PRTaskResolver resolves which tasks are associated with a GitHub PR number.
// Implemented by the github service; injected so the task service can surface a
// task by its PR number in search without coupling to the github schema.
type PRTaskResolver interface {
FindTaskIDsByPRNumber(ctx context.Context, workspaceID string, prNumber int) ([]string, 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")
ErrWIPLimitExceeded = wfmodels.ErrWIPLimitExceeded
ErrInvalidRepositorySettings = errors.New("invalid repository settings")
ErrInvalidExecutorConfig = errors.New("invalid executor config")
// Workspace-source sentinels are the service boundary consumed by the HTTP
// and MCP adapters. Keep categories stable rather than making callers parse
// a validation or runtime error string.
ErrInvalidWorkspaceSource = errors.New("invalid workspace source")
ErrWorkspaceSourceConflict = errors.New("workspace source conflict")
ErrWorkspaceSourceActive = errors.New("workspace source task is active")
ErrUnsupportedWorkspaceSource = errors.New("unsupported workspace source")
ErrWorkspaceSourceMaterialize = errors.New("workspace source materialization failed")
)
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
WorkspaceFolders repository.TaskWorkspaceFolderRepository
Workflows repository.WorkflowRepository
Messages repository.MessageRepository
Turns repository.TurnRepository
Sessions repository.SessionRepository
GitSnapshots repository.GitSnapshotRepository
RepoEntities repository.RepositoryEntityRepository
RepositoryCleanup repository.RepositoryCleanupRepository
Executors repository.ExecutorRepository
Environments repository.EnvironmentRepository
TaskEnvironments repository.TaskEnvironmentRepository
Reviews repository.ReviewRepository
ResourceCleanups repository.TaskResourceCleanupRepository
StatusSummaries repository.TaskStatusSummaryRepository
}
// Service provides task business logic
type Service struct {
workspaces repository.WorkspaceRepository
tasks repository.TaskRepository
taskRepos repository.TaskRepoRepository
workspaceFolders repository.TaskWorkspaceFolderRepository
workflows repository.WorkflowRepository
messages repository.MessageRepository
turns repository.TurnRepository
sessions repository.SessionRepository
gitSnapshots repository.GitSnapshotRepository
repoEntities repository.RepositoryEntityRepository
repositoryCleanup repository.RepositoryCleanupRepository
executors repository.ExecutorRepository
environments repository.EnvironmentRepository
taskEnvironments repository.TaskEnvironmentRepository
reviews repository.ReviewRepository
resourceCleanups repository.TaskResourceCleanupRepository
statusSummaries repository.TaskStatusSummaryRepository
statusSummaryPRs TaskStatusSummaryPRReader
eventBus bus.EventBus
logger *logger.Logger
discoveryConfig RepositoryDiscoveryConfig
worktreeCleanup WorktreeCleanup
executionStopper TaskExecutionStopper
contextWindowResetter func(context.Context, string) error
cleanupActivity TaskResourceCleanupActivityGate
branchMaterializer BranchMaterializer
workspaceSourceMaterializer WorkspaceSourceMaterializer
workspaceSourceLocksMu sync.Mutex
workspaceSourceLocks map[string]*sync.Mutex
providerProber ProviderDefaultBranchProber
gitArchiveCapture GitArchiveCapture
workflowStepCreator WorkflowStepCreator
workspaceBootstrapper WorkspaceBootstrapper
workflowStepGetter WorkflowStepGetter
startStepResolver StartStepResolver
prTaskResolver PRTaskResolver
quickChatDir string // Directory for quick-chat workspaces (e.g., ~/.kandev/quick-chat)
branchFetcher *branchFetcher
envDestroyer EnvironmentDestroyer
sessionRunningChecker SessionRunningChecker
remoteBranchLister RemoteBranchLister
repoCloneLocation RepoCloneLocation
blockers BlockerRepository
comments CommentRepository
baseBranchPusher AgentBaseBranchPusher
runtimeOverridesMu sync.Mutex
workspaceDefaultsInitializer WorkspaceDefaultsInitializer
// foregroundActivity resolves the live fine-grained busy substate of a RUNNING
// session (satisfied by the orchestrator). Used to compute the task-level
// MOST-ACTIVE-WINS activity aggregate carried on task.updated events. Optional.
foregroundActivity ForegroundActivityProvider
// taskActivityMu guards lastTaskActivity, the last task-level activity aggregate
// emitted per task. It bounds live-propagation task.updated emissions to an
// actual change of the aggregated three-state value.
taskActivityMu sync.Mutex
lastTaskActivity map[string]v1.ForegroundActivity
lastTaskSubagentCount map[string]int
// taskPublicationMu guards the per-task FIFO dispatchers. It is held only
// while enqueueing/dequeueing; repository reads and synchronous EventBus
// delivery always happen after it is released.
taskPublicationMu sync.Mutex
taskPublications map[string]*taskPublicationQueue
// cleanupDoneForTest lets unit tests wait for async cleanup; nil in production.
cleanupDoneForTest chan struct{}
cleanupWorkerMu sync.Mutex
cleanupWorkerCancel context.CancelFunc
cleanupWorkerWG sync.WaitGroup
cleanupWorkerWake chan struct{}
cleanupRunsMu sync.Mutex
cleanupRuns map[*taskResourceCleanupRun]struct{}
// repoResolveMu serializes the check-then-create sections of
// FindOrCreateRepository and FindOrCreateRepositoryByLocalPath so two
// resolvers racing to register the same not-yet-known repository (by
// provider identity or by canonical local_path) converge on a single row
// instead of each inserting a duplicate. Covers concurrent requests
// within this backend process only — this backend is single-process per
// SQLite database, so that is the complete threat model today.
repoResolveMu sync.Mutex
}
// 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,
workspaceFolders: repos.WorkspaceFolders,
workflows: repos.Workflows,
messages: repos.Messages,
turns: repos.Turns,
sessions: repos.Sessions,
gitSnapshots: repos.GitSnapshots,
repoEntities: repos.RepoEntities,
repositoryCleanup: repos.RepositoryCleanup,
executors: repos.Executors,
environments: repos.Environments,
taskEnvironments: repos.TaskEnvironments,
reviews: repos.Reviews,
resourceCleanups: repos.ResourceCleanups,
statusSummaries: repos.StatusSummaries,
eventBus: eventBus,
logger: log,
discoveryConfig: discoveryConfig,
branchFetcher: newBranchFetcher(log.Zap()),
lastTaskActivity: make(map[string]v1.ForegroundActivity),
lastTaskSubagentCount: make(map[string]int),
}
}
// SetWorktreeCleanup sets the worktree cleanup handler for task deletion.
func (s *Service) SetWorktreeCleanup(cleanup WorktreeCleanup) {
s.worktreeCleanup = cleanup
}
func (s *Service) setCleanupDoneForTestHook(ch chan struct{}) {
s.cleanupDoneForTest = ch
}
// SetBranchMaterializer wires the mid-session worktree materializer for
// AddBranchToTask. Optional — when unset, MCP add_branch only inserts the
// task_repositories row and the worktree appears on next session launch.
func (s *Service) SetBranchMaterializer(m BranchMaterializer) {
s.branchMaterializer = m
}
func (s *Service) SetWorkspaceSourceMaterializer(m WorkspaceSourceMaterializer) {
s.workspaceSourceMaterializer = m
}
// SetAgentBaseBranchPusher wires the live-update push for
// UpdateRepositoryBaseBranch. Optional — when unset, the persisted DB value
// is the source of truth and the new base branch takes effect at next
// session launch.
func (s *Service) SetAgentBaseBranchPusher(p AgentBaseBranchPusher) {
s.baseBranchPusher = p
}
// SetProviderDefaultBranchProber wires the synchronous default-branch probe
// used by AddBranchToTask's GitHub-URL resolution. Optional — when unset,
// add_branch with a provider URL and no base_branch falls through to the
// "cannot resolve base_branch" rejection instead of persisting an empty row.
func (s *Service) SetProviderDefaultBranchProber(p ProviderDefaultBranchProber) {
s.providerProber = p
}
// SetExecutionStopper wires the task execution stopper (orchestrator).
func (s *Service) SetExecutionStopper(stopper TaskExecutionStopper) {
s.executionStopper = stopper
}
// SetContextWindowResetter wires the guarded context-window reset callback
// owned by the orchestrator. It is optional for isolated task-service users;
// those callers fall back to clearing the session metadata directly.
func (s *Service) SetContextWindowResetter(resetter func(context.Context, string) error) {
s.contextWindowResetter = resetter
}
func (s *Service) resetContextWindow(ctx context.Context, sessionID string) error {
if s.contextWindowResetter != nil {
return s.contextWindowResetter(ctx, sessionID)
}
return s.sessions.SetSessionMetadataKey(ctx, sessionID, models.SessionMetaKeyContextWindow, nil)
}
func (s *Service) SetTaskResourceCleanupActivityGate(gate TaskResourceCleanupActivityGate) {
s.cleanupActivity = gate
}
// 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
}
func (s *Service) SetWorkspaceBootstrapper(bootstrapper WorkspaceBootstrapper) {
s.workspaceBootstrapper = bootstrapper
}
// SetWorkspaceDefaultsInitializer wires the integration initializer used by
// workspace creation. It is optional so the task service remains usable in
// deployments without GitHub.
func (s *Service) SetWorkspaceDefaultsInitializer(initializer WorkspaceDefaultsInitializer) {
s.workspaceDefaultsInitializer = initializer
}
// 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
}
// SetPRTaskResolver wires the GitHub PR→task resolver for PR-number search.
// Optional — when unset, search by PR number is a no-op.
func (s *Service) SetPRTaskResolver(resolver PRTaskResolver) {
s.prTaskResolver = resolver
}
// SetQuickChatDir sets the directory for quick-chat workspaces.
// When set, task cleanup deletes the session directory under this path for all tasks.
func (s *Service) SetQuickChatDir(dir string) {
s.quickChatDir = dir
}
// RemoteBranchLister fetches branches from a provider's remote (e.g. GitHub
// API) without needing a local clone. Used by ListBranches so a repo that is
// registered as remote ("Remote" badge in the UI) can serve branches before
// or even without the orchestrator finishing its clone.
type RemoteBranchLister interface {
ListRepoBranches(ctx context.Context, workspaceID, owner, repo string) ([]Branch, error)
}
// SetRemoteBranchLister wires the remote branch source. Currently only GitHub
// is plumbed; other providers can be added by extending the adapter.
func (s *Service) SetRemoteBranchLister(lister RemoteBranchLister) {
s.remoteBranchLister = lister
}
// RepoCloneLocation reports the base path the orchestrator clones repos into
// (e.g. ~/.kandev/repos or KANDEV_REPOCLONE_BASEPATH). Listing local branches
// for a cloned repo requires that path to be allow-listed by
// discoveryRoots(); without this hook clones to a custom basepath silently
// fall outside the allow-list and branch listing returns no results.
type RepoCloneLocation interface {
ExpandedBasePath() (string, error)
}
// SetRepoCloneLocation wires the cloner so its base path is treated as an
// implicit discovery root.
func (s *Service) SetRepoCloneLocation(loc RepoCloneLocation) {
s.repoCloneLocation = loc
}