Files
kandev/apps/backend/internal/backendapp/worktree.go
T
2026-06-20 19:40:15 +01:00

210 lines
7.5 KiB
Go

package backendapp
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"strings"
"time"
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
"github.com/kandev/kandev/internal/common/config"
"github.com/kandev/kandev/internal/common/constants"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/db"
"github.com/kandev/kandev/internal/task/models"
taskservice "github.com/kandev/kandev/internal/task/service"
"github.com/kandev/kandev/internal/worktree"
)
// taskServiceAdapter adapts the task service to the worktree.TaskService interface.
type taskServiceAdapter struct {
svc *taskservice.Service
}
func (a *taskServiceAdapter) CreateMessage(ctx context.Context, req *worktree.CreateMessageRequest) (*models.Message, error) {
// Convert worktree.CreateMessageRequest to taskservice.CreateMessageRequest
return a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{
TaskSessionID: req.TaskSessionID,
TaskID: req.TaskID,
TurnID: req.TurnID,
Content: req.Content,
AuthorType: req.AuthorType,
AuthorID: req.AuthorID,
RequestsInput: req.RequestsInput,
Type: req.Type,
Metadata: req.Metadata,
})
}
func (a *taskServiceAdapter) UpdateMessage(ctx context.Context, message *models.Message) error {
return a.svc.UpdateMessage(ctx, message)
}
// bootMsgAdapter adapts the task service to the lifecycle.BootMessageService interface.
type bootMsgAdapter struct {
svc *taskservice.Service
}
func (a *bootMsgAdapter) CreateMessage(ctx context.Context, req *lifecycle.BootMessageRequest) (*models.Message, error) {
return a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{
TaskSessionID: req.TaskSessionID,
TaskID: req.TaskID,
Content: req.Content,
AuthorType: req.AuthorType,
Type: req.Type,
Metadata: req.Metadata,
})
}
func (a *bootMsgAdapter) UpdateMessage(ctx context.Context, message *models.Message) error {
return a.svc.UpdateMessage(ctx, message)
}
func provideWorktreeManager(dbPool *db.Pool, cfg *config.Config, log *logger.Logger, lifecycleMgr *lifecycle.Manager, taskSvc *taskservice.Service) (*worktree.Manager, *worktree.Recreator, func() error, error) {
manager, cleanup, err := worktree.Provide(dbPool.Writer(), dbPool.Reader(), cfg, log)
if err != nil {
return nil, nil, nil, err
}
if lifecycleMgr != nil {
lifecycleMgr.SetWorktreeManager(manager)
lifecycleMgr.SetBootMessageService(&bootMsgAdapter{svc: taskSvc})
}
taskSvc.SetWorktreeCleanup(manager)
if lifecycleMgr != nil {
taskSvc.SetEnvironmentDestroyer(&environmentDestroyerAdapter{
lifecycle: lifecycleMgr,
worktrees: manager,
})
}
// Wire script message handler with adapters
taskSvcAdapter := &taskServiceAdapter{svc: taskSvc}
scriptHandler := worktree.NewDefaultScriptMessageHandler(
log,
taskSvcAdapter,
constants.SetupScriptTimeout,
)
repoAdapter := worktree.NewRepositoryAdapter(taskSvc)
manager.SetScriptMessageHandler(scriptHandler)
manager.SetRepositoryProvider(repoAdapter)
// Create recreator for orchestrator to use during session resume
recreator := worktree.NewRecreator(manager)
return manager, recreator, cleanup, nil
}
// environmentDestroyerAdapter implements taskservice.EnvironmentDestroyer by
// delegating to the lifecycle Manager (for containers/sandboxes) and the worktree
// Manager (for worktrees). Branch is preserved on worktree removal so unpushed
// work is never silently dropped.
type environmentDestroyerAdapter struct {
lifecycle *lifecycle.Manager
worktrees *worktree.Manager
}
func (a *environmentDestroyerAdapter) DestroyContainer(ctx context.Context, containerID string) error {
return a.lifecycle.DestroyContainer(ctx, containerID)
}
func (a *environmentDestroyerAdapter) DestroySandbox(ctx context.Context, sandboxID, executionID string) error {
return a.lifecycle.DestroySandbox(ctx, sandboxID, executionID)
}
func (a *environmentDestroyerAdapter) DestroyWorktree(ctx context.Context, worktreeID string) error {
// removeBranch=false: preserve the branch so unpushed work isn't lost.
return a.worktrees.RemoveByID(ctx, worktreeID, false)
}
func (a *environmentDestroyerAdapter) GetContainerLiveStatus(ctx context.Context, containerID string) (*taskservice.ContainerLiveStatus, error) {
live, err := a.lifecycle.GetContainerLiveStatus(ctx, containerID)
if err != nil || live == nil {
return nil, err
}
out := &taskservice.ContainerLiveStatus{
ContainerID: live.ContainerID,
State: live.State,
Status: live.Status,
ExitCode: live.ExitCode,
Health: live.Health,
Missing: live.Missing,
}
if live.StartedAt != nil {
out.StartedAt = live.StartedAt.Format(time.RFC3339)
}
if live.FinishedAt != nil {
out.FinishedAt = live.FinishedAt.Format(time.RFC3339)
}
return out, nil
}
// pushBranchTimeout caps how long we'll wait for `git push` before treating
// the call as hung. Reset is invoked from the request thread; without a bound
// a stalled remote (auth prompt, packet loss, slow proxy) would block the
// entire HTTP handler indefinitely.
const pushBranchTimeout = 30 * time.Second
func (a *environmentDestroyerAdapter) PushEnvironmentBranch(ctx context.Context, env *models.TaskEnvironment) error {
// For host-side worktrees we can push directly. Container/sandbox workspaces
// would require an active agentctl client — not wired yet, surface a clear
// error so the user knows to push manually.
if env.WorktreePath == "" {
return fmt.Errorf("push-before-reset is not supported for this environment type; please push manually first")
}
pushCtx, cancel := context.WithTimeout(ctx, pushBranchTimeout)
defer cancel()
branch := strings.TrimSpace(env.WorktreeBranch)
var cmd *exec.Cmd
if branch == "" {
cmd = exec.CommandContext(pushCtx, "git", "push")
} else {
// Prefer the branch's configured upstream remote so this works for
// repos whose primary remote isn't called "origin" (e.g. fork
// workflows with "upstream"/"github"). Fall back to "origin" only
// when no upstream is set, matching the historical behaviour.
remote := detectBranchRemote(pushCtx, env.WorktreePath, branch)
cmd = exec.CommandContext(pushCtx, "git", "push", remote, branch)
}
cmd.Dir = env.WorktreePath
// Disable interactive credential prompts — without this, a missing
// credential helper can hang waiting on stdin even with the timeout above
// (signal delivery is gated behind the prompt read).
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
out, err := cmd.CombinedOutput()
if err != nil {
if errors.Is(pushCtx.Err(), context.DeadlineExceeded) {
return fmt.Errorf("git push timed out after %s in %s (branch %q); push manually and retry", pushBranchTimeout, env.WorktreePath, branch)
}
return fmt.Errorf("git push failed: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}
// defaultGitRemote is the conventional default remote name. Used as the
// fallback when a branch has no configured upstream and at the comment level
// when describing historical hard-coded behaviour.
const defaultGitRemote = "origin"
// detectBranchRemote reads the configured upstream remote for `branch` from
// the worktree's git config. Falls back to defaultGitRemote when no upstream
// is set (matching the historical hard-coded behaviour).
func detectBranchRemote(ctx context.Context, dir, branch string) string {
cmd := exec.CommandContext(ctx, "git", "config", "--get", "branch."+branch+".remote")
cmd.Dir = dir
out, err := cmd.Output()
if err != nil {
return defaultGitRemote
}
remote := strings.TrimSpace(string(out))
if remote == "" {
return defaultGitRemote
}
return remote
}