6cd50ab841
Backend: - service_task_environments: fail closed when checkAnySessionRunning errors so a transient DB hiccup can't bypass the running-session guard and tear down a live container/sandbox; teardown is now truly best-effort with errors.Join across container/sandbox/worktree, so a failed Docker call doesn't permanently orphan the worktree on retry. Adds tests for the running-check error path and the multi-resource best-effort path. - executor_execute: propagate GetTaskEnvironmentByTaskID errors instead of silently launching a fresh environment that orphans existing containers/worktrees; gate environment reuse on matching ExecutorType so a profile switch doesn't pass stale PreviousExecutionID/container IDs to the wrong backend. - task_environment_handlers: reject malformed JSON on POST /tasks/:id/environment/reset (still allow empty body) so a destructive endpoint doesn't silently proceed with a bad payload. - worktree (PushEnvironmentBranch): bound git push with a 30s context timeout and disable interactive prompts via GIT_TERMINAL_PROMPT=0 so a stalled remote can't hang the request thread. - manager_environment: explicitly call ensureClient() before ContainerMgr() in DestroyContainer/GetContainerLiveStatus so a missing Docker daemon surfaces as "docker unavailable" rather than the generic "container manager not initialized". - container: WorkingDir always uses the in-container /workspace path rather than the host workspace path (Docker would happily start in an unrelated directory in host bind-mount mode); bootstrap now logs the prepare-script exit code so failures show up in container logs. - docker/client: CreateContainerInteractive honors ContainerConfig.Entrypoint so the same config struct produces consistent behavior on both creation paths. Frontend: - executor-settings-button: extract useTaskEnvironment hook (component shrinks below the 100-line limit); only treat 404 as empty env (other errors no longer wipe a valid environment); track "loaded once" via a ref so loadEnv doesn't depend on env state — that dep was creating a new function identity on every successful poll, cancelling the 3-second interval and turning the popover into a tight loop hammering /environment/live; copy-shell command uses sh instead of bash so it works on user-built minimal images. - task-reset-env-confirm-dialog: AlertDialogAction onClick calls e.preventDefault() so Radix's auto-close doesn't dismiss the dialog before the async reset completes (loading spinner now visible; close is parent-controlled via onOpenChange). - remote-credentials-card: track env method in selectedIds the same way files/gh_cli_token are tracked, and let initialChoice match either selectedIds OR envSecretId. First-time env-credential setup for an agent that exposes both files+env was broken: clicking the env radio never updated state, choice re-derived to "none", and the secret picker disappeared before the user could pick anything. - e2e/executor-reuse: select the acknowledge checkbox by label, not by positional last(), so adding another checkbox to the dialog can't silently target the wrong control. Format pass on prettier-flagged files; gofmt fixed two whitespace nits in lifecycle files.
119 lines
4.1 KiB
Go
119 lines
4.1 KiB
Go
package lifecycle
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/kandev/kandev/internal/agent/executor"
|
|
)
|
|
|
|
// ContainerLiveStatus is a runtime snapshot of a Docker container's state,
|
|
// suitable for surfacing in the Executor Settings popover.
|
|
type ContainerLiveStatus struct {
|
|
ContainerID string `json:"container_id"`
|
|
State string `json:"state"` // running, exited, paused, restarting, removing, dead, created
|
|
Status string `json:"status"` // human-readable, e.g. "Up 5 minutes"
|
|
StartedAt *time.Time `json:"started_at,omitempty"`
|
|
FinishedAt *time.Time `json:"finished_at,omitempty"`
|
|
ExitCode int `json:"exit_code,omitempty"`
|
|
Health string `json:"health,omitempty"`
|
|
Missing bool `json:"missing,omitempty"` // true when the container no longer exists
|
|
}
|
|
|
|
// DestroyContainer removes a Docker container (forcefully, along with its filesystem).
|
|
// Used by Reset Environment to tear down the container layer without touching worktrees
|
|
// or sprite sandboxes.
|
|
func (m *Manager) DestroyContainer(ctx context.Context, containerID string) error {
|
|
if containerID == "" {
|
|
return nil
|
|
}
|
|
backend, err := m.executorRegistry.GetBackend(executor.NameDocker)
|
|
if err != nil {
|
|
return fmt.Errorf("docker backend unavailable: %w", err)
|
|
}
|
|
dockerExec, ok := backend.(*DockerExecutor)
|
|
if !ok {
|
|
return fmt.Errorf("docker backend has unexpected type %T", backend)
|
|
}
|
|
// Force lazy initialization here. After a backend restart this method
|
|
// can be the first caller to touch Docker, and a missing daemon should
|
|
// surface as a clear "docker unavailable" error rather than a generic
|
|
// "container manager not initialized".
|
|
if _, _, err := dockerExec.ensureClient(); err != nil {
|
|
return fmt.Errorf("initialize docker backend: %w", err)
|
|
}
|
|
cm := dockerExec.ContainerMgr()
|
|
if cm == nil {
|
|
return fmt.Errorf("docker container manager not initialized")
|
|
}
|
|
return cm.RemoveContainer(ctx, containerID, true)
|
|
}
|
|
|
|
// GetContainerLiveStatus inspects a Docker container and returns a live
|
|
// snapshot. Returns Missing=true when the container has been removed.
|
|
func (m *Manager) GetContainerLiveStatus(ctx context.Context, containerID string) (*ContainerLiveStatus, error) {
|
|
if containerID == "" {
|
|
return nil, nil
|
|
}
|
|
backend, err := m.executorRegistry.GetBackend(executor.NameDocker)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("docker backend unavailable: %w", err)
|
|
}
|
|
dockerExec, ok := backend.(*DockerExecutor)
|
|
if !ok {
|
|
return nil, fmt.Errorf("docker backend has unexpected type %T", backend)
|
|
}
|
|
if _, _, err := dockerExec.ensureClient(); err != nil {
|
|
return nil, fmt.Errorf("initialize docker backend: %w", err)
|
|
}
|
|
cm := dockerExec.ContainerMgr()
|
|
if cm == nil {
|
|
return nil, fmt.Errorf("docker container manager not initialized")
|
|
}
|
|
info, err := cm.GetContainerInfo(ctx, containerID)
|
|
if err != nil {
|
|
// Treat any inspect failure as "missing" so the UI can surface it
|
|
// without bubbling a 500 to the popover.
|
|
return &ContainerLiveStatus{ContainerID: containerID, State: "missing", Missing: true}, nil
|
|
}
|
|
out := &ContainerLiveStatus{
|
|
ContainerID: containerID,
|
|
State: info.State,
|
|
Status: info.Status,
|
|
ExitCode: info.ExitCode,
|
|
Health: info.Health,
|
|
}
|
|
if !info.StartedAt.IsZero() {
|
|
t := info.StartedAt
|
|
out.StartedAt = &t
|
|
}
|
|
if !info.FinishedAt.IsZero() {
|
|
t := info.FinishedAt
|
|
out.FinishedAt = &t
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// DestroySandbox destroys a Sprites sandbox by name. The executionID is used to
|
|
// resolve a cached API token when one exists; if no token is cached, the sprite's
|
|
// metadata-resolver fallback kicks in.
|
|
func (m *Manager) DestroySandbox(ctx context.Context, sandboxID, executionID string) error {
|
|
if sandboxID == "" {
|
|
return nil
|
|
}
|
|
backend, err := m.executorRegistry.GetBackend(executor.NameSprites)
|
|
if err != nil {
|
|
return fmt.Errorf("sprites backend unavailable: %w", err)
|
|
}
|
|
spritesExec, ok := backend.(*SpritesExecutor)
|
|
if !ok {
|
|
return fmt.Errorf("sprites backend has unexpected type %T", backend)
|
|
}
|
|
instance := &ExecutorInstance{
|
|
InstanceID: executionID,
|
|
Metadata: map[string]interface{}{"sprite_name": sandboxID},
|
|
}
|
|
return spritesExec.StopInstance(ctx, instance, true)
|
|
}
|