package lifecycle import ( "context" "fmt" "strings" "time" "go.uber.org/zap" "github.com/kandev/kandev/internal/agent/docker" "github.com/kandev/kandev/internal/agent/executor" agentctl "github.com/kandev/kandev/internal/agentctl/client" "github.com/kandev/kandev/internal/agentctl/server/process" "github.com/kandev/kandev/internal/common/logger" ) // getMetadataString retrieves a string value from metadata map. func getMetadataString(metadata map[string]interface{}, key string) string { if metadata == nil { return "" } if v, ok := metadata[key].(string); ok { return v } return "" } func getMetadataBool(metadata map[string]interface{}, key string) bool { if metadata == nil { return false } switch raw := metadata[key].(type) { case bool: return raw case string: return strings.EqualFold(strings.TrimSpace(raw), "true") default: return false } } // DockerExecutor implements Runtime for Docker-based agent execution. type DockerExecutor struct { containerMgr *ContainerManager docker *docker.Client logger *logger.Logger } // NewDockerExecutor creates a new Docker runtime. func NewDockerExecutor(dockerClient *docker.Client, log *logger.Logger) *DockerExecutor { return &DockerExecutor{ containerMgr: NewContainerManager(dockerClient, "", log), docker: dockerClient, logger: log.WithFields(zap.String("runtime", "docker")), } } func (r *DockerExecutor) Name() executor.Name { return executor.NameDocker } func (r *DockerExecutor) HealthCheck(ctx context.Context) error { // Check Docker is reachable by listing containers _, err := r.docker.ListContainers(ctx, map[string]string{}) if err != nil { return fmt.Errorf("docker not reachable: %w", err) } return nil } func (r *DockerExecutor) CreateInstance(ctx context.Context, req *ExecutorCreateRequest) (*ExecutorInstance, error) { // Extract runtime-specific values from metadata mainRepoGitDir := getMetadataString(req.Metadata, MetadataKeyMainRepoGitDir) worktreeID := getMetadataString(req.Metadata, MetadataKeyWorktreeID) worktreeBranch := getMetadataString(req.Metadata, MetadataKeyWorktreeBranch) // Convert ExecutorCreateRequest to ContainerConfig containerCfg := ContainerConfig{ AgentConfig: req.AgentConfig, WorkspacePath: req.WorkspacePath, TaskID: req.TaskID, SessionID: req.SessionID, InstanceID: req.InstanceID, MainRepoGitDir: mainRepoGitDir, Credentials: req.Env, // Env contains credentials from the caller McpServers: req.McpServers, } // Use ContainerManager to launch container containerID, client, err := r.containerMgr.LaunchContainer(ctx, containerCfg) if err != nil { return nil, fmt.Errorf("failed to launch container: %w", err) } // Get container IP for logging containerIP, _ := r.docker.GetContainerIP(ctx, containerID) // Build metadata metadata := make(map[string]interface{}) if worktreeID != "" { metadata["worktree_id"] = worktreeID metadata["worktree_path"] = req.WorkspacePath metadata["worktree_branch"] = worktreeBranch } r.logger.Info("docker instance created", zap.String("instance_id", req.InstanceID), zap.String("container_id", containerID), zap.String("container_ip", containerIP)) return &ExecutorInstance{ InstanceID: req.InstanceID, TaskID: req.TaskID, SessionID: req.SessionID, RuntimeName: string(r.Name()), Client: client, ContainerID: containerID, ContainerIP: containerIP, WorkspacePath: "/workspace", // Docker mounts workspace to /workspace Metadata: metadata, }, nil } func (r *DockerExecutor) StopInstance(ctx context.Context, instance *ExecutorInstance, force bool) error { if instance.ContainerID == "" { return nil // No container to stop } var err error if force { err = r.docker.KillContainer(ctx, instance.ContainerID, "SIGKILL") } else { err = r.docker.StopContainer(ctx, instance.ContainerID, 30*time.Second) } if err != nil { return fmt.Errorf("failed to stop container: %w", err) } return nil } func (r *DockerExecutor) RecoverInstances(ctx context.Context) ([]*ExecutorInstance, error) { // Find containers with kandev.managed label containers, err := r.docker.ListContainers(ctx, map[string]string{ "kandev.managed": "true", }) if err != nil { return nil, fmt.Errorf("failed to list containers: %w", err) } var recovered []*ExecutorInstance for _, ctr := range containers { // Only recover running containers if ctr.State != "running" { r.logger.Debug("skipping non-running container", zap.String("container_id", ctr.ID), zap.String("state", ctr.State)) continue } // Get container labels to extract instance info labels, err := r.docker.GetContainerLabels(ctx, ctr.ID) if err != nil { r.logger.Warn("failed to get container labels", zap.String("container_id", ctr.ID), zap.Error(err)) continue } instanceID := labels["kandev.instance_id"] taskID := labels["kandev.task_id"] sessionID := labels["kandev.session_id"] agentProfileID := labels["kandev.agent_profile_id"] if instanceID == "" || taskID == "" { r.logger.Warn("container missing required labels", zap.String("container_id", ctr.ID)) continue } // Get container IP containerIP, err := r.docker.GetContainerIP(ctx, ctr.ID) if err != nil { r.logger.Warn("failed to get container IP", zap.String("container_id", ctr.ID), zap.Error(err)) containerIP = "127.0.0.1" } // Query the control server to get the instance port ctl := agentctl.NewControlClient(containerIP, AgentCtlPort, r.logger) instanceInfo, err := ctl.GetInstance(ctx, instanceID) if err != nil { r.logger.Warn("failed to get instance info from container", zap.String("container_id", ctr.ID), zap.String("instance_id", instanceID), zap.Error(err)) continue } client := agentctl.NewClient(containerIP, instanceInfo.Port, r.logger, agentctl.WithExecutionID(instanceID), agentctl.WithSessionID(sessionID)) recovered = append(recovered, &ExecutorInstance{ InstanceID: instanceID, TaskID: taskID, SessionID: sessionID, RuntimeName: string(r.Name()), Client: client, ContainerID: ctr.ID, ContainerIP: containerIP, WorkspacePath: "/workspace", Metadata: map[string]interface{}{"agent_profile_id": agentProfileID}, }) r.logger.Info("recovered docker instance", zap.String("instance_id", instanceID), zap.String("task_id", taskID), zap.String("container_id", ctr.ID), zap.Int("instance_port", instanceInfo.Port)) } return recovered, nil } // GetInteractiveRunner returns nil for Docker runtime. // Passthrough mode is not supported in Docker-based execution. func (r *DockerExecutor) GetInteractiveRunner() *process.InteractiveRunner { return nil }