Files
kandev/apps/backend/internal/agent/handlers/shell_handlers.go
T
2026-05-16 20:07:57 +01:00

452 lines
15 KiB
Go

// Package handlers provides WebSocket and HTTP handlers for agent operations.
package handlers
import (
"context"
"errors"
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/common/scripts"
v1 "github.com/kandev/kandev/pkg/api/v1"
ws "github.com/kandev/kandev/pkg/websocket"
"go.uber.org/zap"
)
// errTaskEnvIDRequired is the canonical error string for missing
// task_environment_id on user-shell RPCs.
const errTaskEnvIDRequired = "task_environment_id is required"
// ShellHandlers provides WebSocket handlers for shell terminal operations.
//
// User-shell ops (user_shell.list/create/stop) are env-keyed: they take
// task_environment_id directly. Sessions in the same task share one env and
// therefore one shell list — there's no per-session shell state.
//
// Agent passthrough ops (shell.status/subscribe/input) stay session-keyed —
// they target the agent's own PTY, which is intrinsically session-scoped.
type ShellHandlers struct {
lifecycleMgr *lifecycle.Manager
scriptService scripts.ScriptService
logger *logger.Logger
}
// NewShellHandlers creates a new ShellHandlers instance
func NewShellHandlers(lifecycleMgr *lifecycle.Manager, scriptService scripts.ScriptService, log *logger.Logger) *ShellHandlers {
return &ShellHandlers{
lifecycleMgr: lifecycleMgr,
scriptService: scriptService,
logger: log.WithFields(zap.String("component", "shell_handlers")),
}
}
// RegisterHandlers registers shell handlers with the WebSocket dispatcher
func (h *ShellHandlers) RegisterHandlers(d *ws.Dispatcher) {
d.RegisterFunc(ws.ActionShellStatus, h.wsShellStatus)
d.RegisterFunc(ws.ActionShellSubscribe, h.wsShellSubscribe)
d.RegisterFunc(ws.ActionShellInput, h.wsShellInput)
d.RegisterFunc(ws.ActionUserShellList, h.wsUserShellList)
d.RegisterFunc(ws.ActionUserShellCreate, h.wsUserShellCreate)
d.RegisterFunc(ws.ActionUserShellStop, h.wsUserShellStop)
}
// ShellStatusRequest for shell.status action
type ShellStatusRequest struct {
SessionID string `json:"session_id"`
}
// ShellInputRequest for shell.input action
type ShellInputRequest struct {
SessionID string `json:"session_id"`
Data string `json:"data"`
}
// wsShellStatus returns the status of a shell session for a session
func (h *ShellHandlers) wsShellStatus(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req ShellStatusRequest
if err := msg.ParsePayload(&req); err != nil {
return nil, fmt.Errorf("invalid payload: %w", err)
}
if req.SessionID == "" {
return nil, fmt.Errorf("session_id is required")
}
// Get or create execution on-demand (survives backend restart)
execution, err := h.lifecycleMgr.GetOrEnsureExecution(ctx, req.SessionID)
if err != nil {
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
"available": false,
"error": "no agent running for this session: " + err.Error(),
})
}
// Get shell status from agentctl
client := execution.GetAgentCtlClient()
if client == nil {
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
"available": false,
"error": "agent client not available",
})
}
status, err := client.ShellStatus(ctx)
if err != nil {
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
"available": false,
"error": err.Error(),
})
}
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
"available": true,
"running": status.Running,
"pid": status.Pid,
"shell": status.Shell,
"cwd": status.Cwd,
"started_at": status.StartedAt,
})
}
// ShellSubscribeRequest for shell.subscribe action
type ShellSubscribeRequest struct {
SessionID string `json:"session_id"`
}
// wsShellSubscribe subscribes to shell output for a session.
// Shell output is streamed via the event bus (lifecycle manager handles this).
// This endpoint returns the buffered shell output for catchup.
func (h *ShellHandlers) wsShellSubscribe(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req ShellSubscribeRequest
if err := msg.ParsePayload(&req); err != nil {
return nil, fmt.Errorf("invalid payload: %w", err)
}
if req.SessionID == "" {
return nil, fmt.Errorf("session_id is required")
}
execution, err := h.ensureShellExecution(ctx, req.SessionID)
if err != nil {
return nil, err
}
// Get buffered output to include in response
// This ensures client gets current shell state without duplicate broadcasts
// Shell output streaming is handled by the lifecycle manager via event bus
buffer := ""
if client := execution.GetAgentCtlClient(); client != nil {
if b, err := client.ShellBuffer(ctx); err == nil {
buffer = b
}
}
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
"success": true,
"session_id": req.SessionID,
"buffer": buffer,
})
}
// wsShellInput sends input to a shell session
func (h *ShellHandlers) wsShellInput(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req ShellInputRequest
if err := msg.ParsePayload(&req); err != nil {
return nil, fmt.Errorf("invalid payload: %w", err)
}
if req.SessionID == "" {
return nil, fmt.Errorf("session_id is required")
}
execution, err := h.ensureShellExecution(ctx, req.SessionID)
if err != nil {
return nil, err
}
// Wait for the workspace stream to be ready with a timeout.
// This handles the race condition where client sends shell.input before
// the workspace stream is fully connected (e.g., joining a task too fast).
var workspaceStream = execution.GetWorkspaceStream()
if workspaceStream == nil {
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
time.Sleep(100 * time.Millisecond)
workspaceStream = execution.GetWorkspaceStream()
if workspaceStream != nil {
break
}
}
}
if workspaceStream == nil {
return nil, fmt.Errorf("workspace stream not ready for session %s", req.SessionID)
}
if err := workspaceStream.WriteShellInput(req.Data); err != nil {
return nil, fmt.Errorf("failed to send shell input: %w", err)
}
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
"success": true,
})
}
// shellLifecycle is the narrow slice of lifecycle.Manager that
// ensureShellExecution needs. *lifecycle.Manager satisfies it; tests can
// stub it without touching the rest of the manager.
type shellLifecycle interface {
GetOrEnsureExecution(ctx context.Context, sessionID string) (*lifecycle.AgentExecution, error)
CleanupStaleExecutionBySessionID(ctx context.Context, sessionID string) error
}
func (h *ShellHandlers) ensureShellExecution(ctx context.Context, sessionID string) (*lifecycle.AgentExecution, error) {
return ensureShellExecution(ctx, h.lifecycleMgr, h.logger, sessionID)
}
// ensureShellExecution returns a live shell execution for sessionID, recovering
// from a stale (Failed) execution by cleaning it up and re-launching once.
// Extracted as a free function so the recovery branch is directly testable
// without standing up a real lifecycle.Manager.
func ensureShellExecution(ctx context.Context, lifecycleMgr shellLifecycle, log *logger.Logger, sessionID string) (*lifecycle.AgentExecution, error) {
execution, err := lifecycleMgr.GetOrEnsureExecution(ctx, sessionID)
if err != nil {
log.Debug("failed to ensure shell execution",
zap.String("session_id", sessionID),
zap.Error(err))
return nil, fmt.Errorf("no agent running for session %s", sessionID)
}
if execution.Status != v1.AgentStatusFailed {
return execution, nil
}
if err := lifecycleMgr.CleanupStaleExecutionBySessionID(ctx, sessionID); err != nil {
return nil, fmt.Errorf("cleanup stale execution for session %s: %w", sessionID, err)
}
execution, err = lifecycleMgr.GetOrEnsureExecution(ctx, sessionID)
if err != nil {
return nil, fmt.Errorf("recover execution for session %s: %w", sessionID, err)
}
return execution, nil
}
// UserShellListRequest for user_shell.list action.
// User shells are env-scoped — sessions in the same task share one shell list.
type UserShellListRequest struct {
TaskEnvironmentID string `json:"task_environment_id"`
}
// wsUserShellList returns all running user shells for a task environment
func (h *ShellHandlers) wsUserShellList(_ context.Context, msg *ws.Message) (*ws.Message, error) {
var req UserShellListRequest
if err := msg.ParsePayload(&req); err != nil {
return nil, fmt.Errorf("invalid payload: %w", err)
}
if req.TaskEnvironmentID == "" {
return nil, errors.New(errTaskEnvIDRequired)
}
interactiveRunner := h.lifecycleMgr.GetInteractiveRunner()
if interactiveRunner == nil {
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
"shells": []interface{}{},
})
}
shells := interactiveRunner.ListUserShells(req.TaskEnvironmentID)
h.logger.Debug("listing user shells",
zap.String("task_environment_id", req.TaskEnvironmentID),
zap.Int("count", len(shells)))
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
"shells": shells,
})
}
// UserShellCreateRequest for user_shell.create action.
// Exactly one of (ScriptID) or (Command) may be set; if both are empty the
// handler creates a plain shell. When Command is set, Label is used as the
// terminal label (defaults to "Script" when omitted).
//
// User shells are env-scoped — TaskEnvironmentID is required.
type UserShellCreateRequest struct {
TaskEnvironmentID string `json:"task_environment_id"`
ScriptID string `json:"script_id,omitempty"`
Command string `json:"command,omitempty"`
Label string `json:"label,omitempty"`
}
// wsUserShellCreate creates a new user shell terminal and returns the assigned ID and label.
func (h *ShellHandlers) wsUserShellCreate(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req UserShellCreateRequest
if err := msg.ParsePayload(&req); err != nil {
return nil, fmt.Errorf("invalid payload: %w", err)
}
if req.TaskEnvironmentID == "" {
return nil, errors.New(errTaskEnvIDRequired)
}
interactiveRunner := h.lifecycleMgr.GetInteractiveRunner()
if interactiveRunner == nil {
return nil, fmt.Errorf("interactive runner not available")
}
label, command, err := h.resolveShellScript(ctx, &req)
if err != nil {
return nil, err
}
scopeID := req.TaskEnvironmentID
if command != "" {
terminalID := "script-" + uuid.New().String()
interactiveRunner.RegisterScriptShell(scopeID, terminalID, label, command)
h.logger.Info("created script terminal",
zap.String("task_environment_id", scopeID),
zap.String("terminal_id", terminalID),
zap.String("label", label),
zap.String("initial_command", command))
return ws.NewResponse(msg.ID, msg.Action, map[string]any{
"terminal_id": terminalID,
"label": label,
"closable": true,
"initial_command": command,
})
}
result := interactiveRunner.CreateUserShell(scopeID)
h.logger.Info("created user shell",
zap.String("task_environment_id", scopeID),
zap.String("terminal_id", result.TerminalID),
zap.String("label", result.Label),
zap.Bool("closable", result.Closable))
return ws.NewResponse(msg.ID, msg.Action, map[string]any{
"terminal_id": result.TerminalID,
"label": result.Label,
"closable": result.Closable,
"initial_command": "",
})
}
// resolveShellScript returns the (label, command) pair for a script terminal,
// or ("", "") when the request is for a plain shell.
func (h *ShellHandlers) resolveShellScript(
ctx context.Context, req *UserShellCreateRequest,
) (string, string, error) {
if req.ScriptID != "" {
if h.scriptService == nil {
return "", "", fmt.Errorf("script service not available")
}
script, err := h.scriptService.GetRepositoryScript(ctx, req.ScriptID)
if err != nil {
h.logger.Error("failed to get repository script",
zap.String("script_id", req.ScriptID), zap.Error(err))
return "", "", fmt.Errorf("invalid script ID: %w", err)
}
return script.Name, script.Command, nil
}
if req.Command != "" {
label := req.Label
if label == "" {
label = "Script"
}
return label, req.Command, nil
}
return "", "", nil
}
// UserShellStopRequest for user_shell.stop action.
// User shells are env-scoped — TaskEnvironmentID is required.
type UserShellStopRequest struct {
TaskEnvironmentID string `json:"task_environment_id"`
TerminalID string `json:"terminal_id"`
}
// wsUserShellStop stops a user shell terminal process
func (h *ShellHandlers) wsUserShellStop(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req UserShellStopRequest
if err := msg.ParsePayload(&req); err != nil {
return nil, fmt.Errorf("invalid payload: %w", err)
}
if req.TaskEnvironmentID == "" {
return nil, errors.New(errTaskEnvIDRequired)
}
if req.TerminalID == "" {
return nil, fmt.Errorf("terminal_id is required")
}
// Get the interactive runner
interactiveRunner := h.lifecycleMgr.GetInteractiveRunner()
if interactiveRunner == nil {
return nil, fmt.Errorf("interactive runner not available")
}
if err := interactiveRunner.StopUserShell(ctx, req.TaskEnvironmentID, req.TerminalID); err != nil {
h.logger.Warn("failed to stop user shell",
zap.String("task_environment_id", req.TaskEnvironmentID),
zap.String("terminal_id", req.TerminalID),
zap.Error(err))
// Don't return error - shell may already be stopped
}
h.logger.Info("user shell stopped",
zap.String("task_environment_id", req.TaskEnvironmentID),
zap.String("terminal_id", req.TerminalID))
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
"success": true,
})
}
// RegisterShellRoutes registers HTTP routes for shell operations.
// This is separate from RegisterHandlers which registers WebSocket handlers.
func RegisterShellRoutes(router *gin.Engine, lifecycleMgr *lifecycle.Manager, log *logger.Logger) {
handlers := NewShellHandlers(lifecycleMgr, nil, log)
api := router.Group("/api/v1")
api.GET("/environments/:id/terminals", handlers.httpListTerminals)
}
// httpListTerminals returns all terminals for a task environment (for SSR).
func (h *ShellHandlers) httpListTerminals(c *gin.Context) {
environmentID := c.Param("id")
if environmentID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": errTaskEnvIDRequired})
return
}
// Get the interactive runner
interactiveRunner := h.lifecycleMgr.GetInteractiveRunner()
if interactiveRunner == nil {
c.JSON(http.StatusOK, gin.H{"terminals": []interface{}{}})
return
}
shells := interactiveRunner.ListUserShells(environmentID)
h.logger.Debug("listing terminals via HTTP",
zap.String("task_environment_id", environmentID),
zap.Int("count", len(shells)))
// Transform to response format
terminals := make([]map[string]interface{}, len(shells))
for i, shell := range shells {
terminals[i] = map[string]interface{}{
"terminal_id": shell.TerminalID,
"process_id": shell.ProcessID,
"running": shell.Running,
"label": shell.Label,
"closable": shell.Closable,
"initial_command": shell.InitialCommand,
}
}
c.JSON(http.StatusOK, gin.H{"terminals": terminals})
}