fd79d6170f
* feat(tasks): multi-branch tasks — N (repo, branch) pairs per task Extends multi-repo into multi-branch so a single task can hold multiple worktrees on the same repo (one per branch). Sibling worktrees layout at <task>/<repo>/ + <task>/<repo>-<slug>/, dedicated PRWatch per branch, live agentctl tracker rescan + frontend store updates so the UI splits PRs / files / commits per branch without a session restart. MCP add_branch_to_task_kandev tool is shielded to the worktree executor. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(web): unify multi-branch repo label as <repo> · <branch> across panels PR Changes and Commits panels rendered repo labels differently for multi-branch tasks — Commits showed "kandev · feature/x" while PR Changes leaked the raw "kandev-feature-x" agentctl tracker tag. Reformat tracker-tagged repo names via formatRepoLabel using the longest known-repo prefix so kandev-cli-* doesn't collapse to kandev · cli-*. * fix(tasks): address multi-branch PR review — data races, slug collisions, MCP error codes Backend - agentctl process.Manager: add repoTrackersMu RWMutex guarding repoTrackers + workspaceTracker. Reads snapshot under RLock; RescanRepositories builds trackers first, takes Lock for swap/append. Validates newWorkDir via os.Stat before publishing cfg.WorkDir. - worktree.Manager: reject invalid explicit BranchSlug up-front; cache key uses sanitized wt.BranchSlug so read/write paths are symmetric. - worktree.store: narrow isDuplicateColumnError to "duplicate column name". - task service AddBranchToTask: slug-collision guard rejects feat/a vs feat-a. - orchestrator resolvePushRepo: two-pass exact-name match before subdir prefix fallback so "backend" doesn't swallow pushes for "backend-admin". - executor buildRepoSpecs: duplicate repos always get a non-empty BranchSlug (falls back to branch-N when both branch fields sanitize to empty). - MCP handler: classifyAddBranchError maps service errors to NOT_FOUND / CONFLICT / VALIDATION_ERROR; add ErrorCodeConflict constant. - MCP server: extract registerAddBranchToTaskTool, register in task mode only (external mode contract: config tools + create_task_kandev). - workspace_rescan handler: reject malformed JSON with 400 instead of silently rescanning the current root. - branch_materializer: split MaterializeBranch into prepare + finalize helpers to satisfy the 80-line cap. Frontend - existingPrUrl returns undefined when a task has >1 PR so per-repo View PR buttons take over (no silent linking to a sibling). - formatRepoLabel checks exact known-repo match before prefix splitting so "kandev-cli" isn't mislabeled as "kandev · cli". - Repo chip key now includes base_branch so siblings differing only on base render as distinct chips. Docs - multi-branch spec uniqueness wording aligned with the canonical 4-column key (task_id, repository_id, base_branch, checkout_branch). - worktree.CreateRequest.BranchSlug doc describes the sibling layout (<repo>-<slug>) instead of the obsolete nested form. * fix(tasks): scope slug-collision check to repo only + use errors.Is for ErrTaskNotFound - service_branches: drop base_branch from the slug-collision check. Worktree path is (repo, slug); two rows on the same repo cut from different bases still collide on disk. - service_branches: prepareBranchAdd wraps taskrepo.ErrTaskNotFound via fmt.Errorf with %w so classifiers don't have to substring-match the formatted task UUID. - mcp handlers: classifyAddBranchError uses errors.Is(err, ErrTaskNotFound). * fix(agentctl): sanitize rescan work_dir before os.Stat to clear CodeQL path-injection CodeQL flagged the rescan endpoint as a path-injection sink (the HTTP-supplied work_dir flowed straight into os.Stat and downstream scanRepositorySubdirs). Add validateRescanPath which: - requires the input to be a clean absolute path with no ".." segments - rejects anything that isn't equal to or an ancestor of the current cfg.WorkDir — the only legitimate use case is the branch materializer promoting <task>/<repo>/ to <task>/ Defense in depth: agentctl binds to localhost, but any colocated process could otherwise direct it at /etc or the user's home by POSTing /api/v1/workspace/rescan. * fix(agentctl): tighten validateRescanPath, close TOCTOU on existingTrackers, add tests - Drop the strings.Contains(clean, "..") guard. filepath.Clean already resolves traversal; the substring check rejects legitimate names like /home/u/..hidden without adding defense (the ancestor check via filepath.Rel is the real boundary). - Drop the rel == "." branch in the ancestor check — early return for clean == currentClean already covers identity. - Read existingTrackers inside the write-lock section (not under RLock then re-Lock) so two concurrent rescans can't both see 0 trackers and double-bootstrap the multi-repo set. - Add table-driven TestValidateRescanPath covering rejection paths (relative, descendant, sibling, traversal-cleaned, empty input), legitimate ancestor promotion, exact match, no-anchor first-launch, and a /home/u/..hidden dotted-segment case. * fix(agentctl): break taint flow into os.Stat — derive rescan path from cfg, not HTTP body CodeQL's go/path-injection trace still flagged manager_rescan.go after the prior sanitizer because the user-supplied newWorkDir flowed (after validation) into os.Stat and then into cfg.WorkDir → tracker paths → workspace_monitor os.Stat sinks. Replace validateRescanPath with resolveRescanPath: it returns a path DERIVED from currentWorkDir (no-op = currentClean, promotion = filepath.Dir(currentClean)) and uses newPath only for comparison, never as a source for the returned string. os.Stat now receives a path whose provenance is trusted manager config. Functionally the legitimate caller (branch materializer promoting <task>/<repo>/ to <task>/) is unchanged. * test(agentctl): make TestResolveRescanPath windows-portable The Linux Backend Tests job passed but the Backend (windows) job rejected every "absolute" case because filepath.IsAbs("/task/repo") is false on Windows (no volume letter). Build inputs via filepath.Join against absRoot() (filepath.Abs of "/" / "\\") so the same table covers POSIX (`/...`) and Windows (`C:\...`) without per-OS branches. Keep the traversal-after-Clean case POSIX-only since filepath.Clean is the shared primitive and that input form needs a volume to be considered absolute on Windows anyway. * fix(agentctl): serialize RescanRepositories + re-check trackers under write lock Two concurrent RescanRepositories calls could both observe an empty tracker set (between the snapshot read and the bootstrap branch) and both enter transitionToMultiRepoMode, leaking a duplicate bare-root tracker and duplicate per-repo trackers. Same race in appendNewRepoTrackers would double-append the same new child across both callers. Fix: - Add rescanMu sync.Mutex on Manager. Acquired for the full body of RescanRepositories so the snapshot → branch → mutate sequence is serialized. repoTrackersMu still guards individual field reads from unrelated goroutines (subscribe/unsubscribe/poll-mode). - appendNewRepoTrackers re-checks membership inside the write-lock before appending and drops already-present trackers (Stop + detach outside the lock). Defense in depth. - Test: add `dotted segment is legal as promotion` case so the ..hidden-segment promotion path stays exercised. --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
259 lines
10 KiB
Go
259 lines
10 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
|
|
}
|
|
|
|
// 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.
|
|
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) 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
|
|
branchMaterializer BranchMaterializer
|
|
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
|
|
remoteBranchLister RemoteBranchLister
|
|
repoCloneLocation RepoCloneLocation
|
|
blockers BlockerRepository
|
|
comments CommentRepository
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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, 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, 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
|
|
}
|