From 752e6b4655495168ca8f5b8a0061e52fdff3d0a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Thu, 22 Jan 2026 16:39:13 +0000 Subject: [PATCH] feat: process runners (#71) --- apps/backend/cmd/kandev/agentctl.go | 32 + apps/backend/cmd/kandev/main.go | 114 +--- .../internal/agent/lifecycle/event_types.go | 44 ++ .../internal/agent/lifecycle/events.go | 56 ++ .../internal/agent/lifecycle/manager.go | 151 ++++- .../agent/lifecycle/process_runner.go | 147 +++++ .../agent/lifecycle/process_runner_test.go | 55 ++ .../internal/agent/lifecycle/streams.go | 22 +- .../internal/agentctl/client/client.go | 195 +++++- .../internal/agentctl/server/api/processes.go | 81 +++ .../internal/agentctl/server/api/server.go | 6 + .../internal/agentctl/server/config/config.go | 18 +- .../agentctl/server/instance/manager.go | 43 +- .../server/instance/port_allocator.go | 27 +- .../agentctl/server/process/manager.go | 55 +- .../agentctl/server/process/runner.go | 437 ++++++++++++++ .../agentctl/server/process/runner_test.go | 78 +++ .../server/process/workspace_tracker.go | 66 ++- .../agentctl/types/streams/process.go | 47 ++ apps/backend/internal/agentctl/types/types.go | 59 +- apps/backend/internal/events/types.go | 26 + .../websocket/session_notifications.go | 86 +++ apps/backend/internal/orchestrator/service.go | 65 +- .../internal/task/controller/repository.go | 2 + apps/backend/internal/task/dto/dto.go | 2 + apps/backend/internal/task/dto/requests.go | 2 + .../task/handlers/process_handlers.go | 456 ++++++++++++++ .../task/handlers/process_handlers_test.go | 558 ++++++++++++++++++ .../task/handlers/repository_handlers.go | 8 + .../internal/task/handlers/task_handlers.go | 12 +- apps/backend/internal/task/models/models.go | 1 + .../internal/task/repository/sqlite.go | 22 +- apps/backend/internal/task/service/service.go | 104 ++-- apps/backend/internal/worktree/manager.go | 55 +- apps/backend/pkg/websocket/actions.go | 7 + apps/packages/ui/package.json | 2 +- apps/packages/ui/src/resizable.tsx | 14 +- apps/pnpm-lock.yaml | 22 +- apps/web/app/actions/workspaces.ts | 2 + apps/web/app/s/[sessionId]/page.tsx | 3 + .../workspace-repositories-client.tsx | 6 +- apps/web/app/task/[id]/page-client.tsx | 28 +- apps/web/components/kanban-board.tsx | 39 +- .../components/settings/repository-card.tsx | 19 +- .../task/preview/preview-controller.tsx | 13 + .../task/preview/preview-controls.tsx | 194 ++++++ .../components/task/preview/preview-panel.tsx | 176 ++++++ .../task/process-output-terminal.tsx | 140 +++++ apps/web/components/task/task-layout.tsx | 155 +++-- apps/web/components/task/task-right-panel.tsx | 154 ++++- apps/web/components/task/task-top-bar.tsx | 6 + apps/web/hooks/use-preview-panel.ts | 277 +++++++++ apps/web/hooks/use-user-display-settings.ts | 20 +- apps/web/lib/http/index.ts | 46 ++ apps/web/lib/layout/read-layout-defaults.ts | 37 ++ apps/web/lib/layout/use-default-layout.ts | 66 +++ apps/web/lib/state/layout-store.ts | 248 ++++++++ apps/web/lib/state/store.ts | 217 ++++++- apps/web/lib/types/backend.ts | 27 +- apps/web/lib/types/http.ts | 21 + apps/web/lib/ws/handlers/terminals.ts | 37 ++ apps/web/package.json | 2 +- preview-plan.md | 166 ++++++ 63 files changed, 4914 insertions(+), 362 deletions(-) create mode 100644 apps/backend/internal/agent/lifecycle/process_runner.go create mode 100644 apps/backend/internal/agent/lifecycle/process_runner_test.go create mode 100644 apps/backend/internal/agentctl/server/api/processes.go create mode 100644 apps/backend/internal/agentctl/server/process/runner.go create mode 100644 apps/backend/internal/agentctl/server/process/runner_test.go create mode 100644 apps/backend/internal/agentctl/types/streams/process.go create mode 100644 apps/backend/internal/gateway/websocket/session_notifications.go create mode 100644 apps/backend/internal/task/handlers/process_handlers.go create mode 100644 apps/backend/internal/task/handlers/process_handlers_test.go create mode 100644 apps/web/components/task/preview/preview-controller.tsx create mode 100644 apps/web/components/task/preview/preview-controls.tsx create mode 100644 apps/web/components/task/preview/preview-panel.tsx create mode 100644 apps/web/components/task/process-output-terminal.tsx create mode 100644 apps/web/hooks/use-preview-panel.ts create mode 100644 apps/web/lib/layout/read-layout-defaults.ts create mode 100644 apps/web/lib/layout/use-default-layout.ts create mode 100644 apps/web/lib/state/layout-store.ts create mode 100644 preview-plan.md diff --git a/apps/backend/cmd/kandev/agentctl.go b/apps/backend/cmd/kandev/agentctl.go index 5b60ff2c27..a24fd06a04 100644 --- a/apps/backend/cmd/kandev/agentctl.go +++ b/apps/backend/cmd/kandev/agentctl.go @@ -2,10 +2,13 @@ package main import ( "context" + "time" + agentctlclient "github.com/kandev/kandev/internal/agentctl/client" "github.com/kandev/kandev/internal/agentctl/client/launcher" "github.com/kandev/kandev/internal/common/config" "github.com/kandev/kandev/internal/common/logger" + "go.uber.org/zap" ) func provideAgentctlLauncher(ctx context.Context, cfg *config.Config, log *logger.Logger) (func() error, error) { @@ -21,3 +24,32 @@ func provideAgentctlLauncher(ctx context.Context, cfg *config.Config, log *logge } return cleanup, nil } + +func waitForAgentctlControlHealthy(ctx context.Context, cfg *config.Config, log *logger.Logger) { + if cfg.Agent.Runtime != "standalone" { + return + } + client := agentctlclient.NewControlClient(cfg.Agent.StandaloneHost, cfg.Agent.StandalonePort, log) + healthCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + ticker := time.NewTicker(250 * time.Millisecond) + defer ticker.Stop() + + var lastErr error + for { + attemptCtx, attemptCancel := context.WithTimeout(healthCtx, 1*time.Second) + err := client.Health(attemptCtx) + attemptCancel() + if err == nil { + log.Info("agentctl control server is healthy") + return + } + lastErr = err + if healthCtx.Err() != nil { + log.Warn("agentctl control server not ready; skipping resume wait", zap.Error(lastErr)) + return + } + <-ticker.C + } +} diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index 43e4855129..e06a3695dc 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -30,7 +30,6 @@ import ( // Agent Manager packages "github.com/kandev/kandev/internal/agent/docker" - "github.com/kandev/kandev/internal/agent/lifecycle" agentsettingshandlers "github.com/kandev/kandev/internal/agent/settings/handlers" editorcontroller "github.com/kandev/kandev/internal/editors/controller" @@ -291,6 +290,7 @@ func main() { go gateway.Hub.Run(ctx) gateways.RegisterTaskNotifications(ctx, eventBus, gateway.Hub, log) gateways.RegisterUserNotifications(ctx, eventBus, gateway.Hub, log) + gateways.RegisterSessionStreamNotifications(ctx, eventBus, gateway.Hub, log) notificationSvc = notificationservice.NewService(notificationRepo, taskRepo, gateway.Hub, log) notificationCtrl = notificationcontroller.NewController(notificationSvc) @@ -359,6 +359,7 @@ func main() { }) log.Info("Session data provider configured for session subscriptions (git status)") + waitForAgentctlControlHealthy(ctx, cfg, log) if err := orchestratorSvc.Start(ctx); err != nil { log.Fatal("Failed to start orchestrator", zap.Error(err)) } @@ -468,116 +469,6 @@ func main() { log.Info("Subscribed to task_session.state_changed events for WebSocket broadcasting") } - // Subscribe to git status events and broadcast to WebSocket subscribers - _, err = eventBus.Subscribe(events.BuildGitStatusWildcardSubject(), func(ctx context.Context, event *bus.Event) error { - // Extract session_id from the event data - var taskSessionID string - switch data := event.Data.(type) { - case lifecycle.GitStatusEventPayload: - taskSessionID = data.SessionID - case *lifecycle.GitStatusEventPayload: - taskSessionID = data.SessionID - case map[string]interface{}: - taskSessionID, _ = data["session_id"].(string) - } - if taskSessionID == "" { - return nil - } - - // Broadcast git status update to session subscribers - notification, _ := ws.NewNotification("session.git.status", event.Data) - gateway.Hub.BroadcastToSession(taskSessionID, notification) - return nil - }) - if err != nil { - log.Error("Failed to subscribe to git status events", zap.Error(err)) - } else { - log.Info("Subscribed to git status events for WebSocket broadcasting") - } - - // Subscribe to file change events and broadcast to WebSocket subscribers - _, err = eventBus.Subscribe(events.BuildFileChangeWildcardSubject(), func(ctx context.Context, event *bus.Event) error { - // Extract session_id from the event data - var taskSessionID string - switch data := event.Data.(type) { - case lifecycle.FileChangeEventPayload: - taskSessionID = data.SessionID - case *lifecycle.FileChangeEventPayload: - taskSessionID = data.SessionID - case map[string]interface{}: - taskSessionID, _ = data["session_id"].(string) - } - if taskSessionID == "" { - return nil - } - - // Broadcast file change notification to session subscribers - notification, _ := ws.NewNotification(ws.ActionWorkspaceFileChanges, event.Data) - gateway.Hub.BroadcastToSession(taskSessionID, notification) - return nil - }) - if err != nil { - log.Error("Failed to subscribe to file change events", zap.Error(err)) - } else { - log.Info("Subscribed to file change events for WebSocket broadcasting") - } - - // Subscribe to shell output events and broadcast to WebSocket subscribers - _, err = eventBus.Subscribe(events.BuildShellOutputWildcardSubject(), func(ctx context.Context, event *bus.Event) error { - // Extract session_id from the event data - var taskSessionID string - switch data := event.Data.(type) { - case lifecycle.ShellOutputEventPayload: - taskSessionID = data.SessionID - case *lifecycle.ShellOutputEventPayload: - taskSessionID = data.SessionID - case map[string]interface{}: - taskSessionID, _ = data["session_id"].(string) - } - if taskSessionID == "" { - return nil - } - - // Broadcast shell output to session subscribers - notification, _ := ws.NewNotification(ws.ActionSessionShellOutput, event.Data) - gateway.Hub.BroadcastToSession(taskSessionID, notification) - return nil - }) - if err != nil { - log.Error("Failed to subscribe to shell output events", zap.Error(err)) - } else { - log.Info("Subscribed to shell output events for WebSocket broadcasting") - } - - // Subscribe to shell exit events and broadcast to WebSocket subscribers - // Note: Frontend expects shell exit via session.shell.output with type: "exit" - _, err = eventBus.Subscribe(events.BuildShellExitWildcardSubject(), func(ctx context.Context, event *bus.Event) error { - // Extract session_id from the event data - var taskSessionID string - switch data := event.Data.(type) { - case lifecycle.ShellExitEventPayload: - taskSessionID = data.SessionID - case *lifecycle.ShellExitEventPayload: - taskSessionID = data.SessionID - case map[string]interface{}: - taskSessionID, _ = data["session_id"].(string) - } - if taskSessionID == "" { - return nil - } - - // Broadcast shell exit to session subscribers via session.shell.output action - // Frontend handles exit events via type: "exit" in the same handler - notification, _ := ws.NewNotification(ws.ActionSessionShellOutput, event.Data) - gateway.Hub.BroadcastToSession(taskSessionID, notification) - return nil - }) - if err != nil { - log.Error("Failed to subscribe to shell exit events", zap.Error(err)) - } else { - log.Info("Subscribed to shell exit events for WebSocket broadcasting") - } - // ============================================ // HTTP SERVER (WebSocket + HTTP endpoints) // ============================================ @@ -615,6 +506,7 @@ func main() { &orchestratorAdapter{svc: orchestratorSvc}, log, ) + taskhandlers.RegisterProcessRoutes(router, taskSvc, lifecycleMgr, log) log.Info("Registered Task Service handlers (HTTP + WebSocket)") agentsettingshandlers.RegisterRoutes(router, agentSettingsController, gateway.Hub, log) diff --git a/apps/backend/internal/agent/lifecycle/event_types.go b/apps/backend/internal/agent/lifecycle/event_types.go index 0e832a1663..f963d8c2c5 100644 --- a/apps/backend/internal/agent/lifecycle/event_types.go +++ b/apps/backend/internal/agent/lifecycle/event_types.go @@ -83,6 +83,11 @@ type GitStatusEventPayload struct { Timestamp string `json:"timestamp"` } +// GetSessionID returns the session ID for this event (used by event routing). +func (p GitStatusEventPayload) GetSessionID() string { + return p.SessionID +} + // FileChangeEventPayload is the payload for file change notifications. type FileChangeEventPayload struct { TaskID string `json:"task_id"` @@ -93,6 +98,11 @@ type FileChangeEventPayload struct { Timestamp string `json:"timestamp"` } +// GetSessionID returns the session ID for this event (used by event routing). +func (p FileChangeEventPayload) GetSessionID() string { + return p.SessionID +} + // PermissionOption represents a single permission option in a permission request. type PermissionOption struct { OptionID string `json:"option_id"` @@ -145,6 +155,40 @@ func (p ShellExitEventPayload) GetSessionID() string { return p.SessionID } +// ProcessOutputEventPayload is the payload for process output events. +type ProcessOutputEventPayload struct { + TaskID string `json:"task_id"` + SessionID string `json:"session_id"` + ProcessID string `json:"process_id"` + Kind string `json:"kind"` + Stream string `json:"stream"` // stdout|stderr + Data string `json:"data"` + Timestamp string `json:"timestamp"` +} + +// GetSessionID returns the session ID for this event (used by event routing). +func (p ProcessOutputEventPayload) GetSessionID() string { + return p.SessionID +} + +// ProcessStatusEventPayload is the payload for process status events. +type ProcessStatusEventPayload struct { + SessionID string `json:"session_id"` + ProcessID string `json:"process_id"` + Kind string `json:"kind"` + ScriptName string `json:"script_name,omitempty"` + Status string `json:"status"` + Command string `json:"command,omitempty"` + WorkingDir string `json:"working_dir,omitempty"` + ExitCode *int `json:"exit_code,omitempty"` + Timestamp string `json:"timestamp"` +} + +// GetSessionID returns the session ID for this event (used by event routing). +func (p ProcessStatusEventPayload) GetSessionID() string { + return p.SessionID +} + // ContextWindowEventPayload is the payload for context window update events. type ContextWindowEventPayload struct { TaskID string `json:"task_id"` diff --git a/apps/backend/internal/agent/lifecycle/events.go b/apps/backend/internal/agent/lifecycle/events.go index 0ba2929f11..b0ead55571 100644 --- a/apps/backend/internal/agent/lifecycle/events.go +++ b/apps/backend/internal/agent/lifecycle/events.go @@ -343,6 +343,62 @@ func (p *EventPublisher) PublishShellExit(execution *AgentExecution, exitCode in } } +// PublishProcessOutput publishes a process output event to the event bus. +func (p *EventPublisher) PublishProcessOutput(execution *AgentExecution, output *agentctl.ProcessOutput) { + if p.eventBus == nil || output == nil { + return + } + + payload := ProcessOutputEventPayload{ + TaskID: execution.TaskID, + SessionID: output.SessionID, + ProcessID: output.ProcessID, + Kind: string(output.Kind), + Stream: output.Stream, + Data: output.Data, + Timestamp: time.Now().UTC().Format(time.RFC3339Nano), + } + + event := bus.NewEvent(events.ProcessOutput, "agent-manager", payload) + subject := events.BuildProcessOutputSubject(output.SessionID) + + if err := p.eventBus.Publish(context.Background(), subject, event); err != nil { + p.logger.Error("failed to publish process output event", + zap.String("instance_id", execution.ID), + zap.String("task_id", execution.TaskID), + zap.String("session_id", output.SessionID), + zap.Error(err)) + } +} + +// PublishProcessStatus publishes a process status event to the event bus. +func (p *EventPublisher) PublishProcessStatus(execution *AgentExecution, status *agentctl.ProcessStatusUpdate) { + if p.eventBus == nil || status == nil { + return + } + + payload := ProcessStatusEventPayload{ + SessionID: status.SessionID, + ProcessID: status.ProcessID, + Kind: string(status.Kind), + ScriptName: status.ScriptName, + Status: string(status.Status), + Command: status.Command, + WorkingDir: status.WorkingDir, + ExitCode: status.ExitCode, + Timestamp: time.Now().UTC().Format(time.RFC3339Nano), + } + + event := bus.NewEvent(events.ProcessStatus, "agent-manager", payload) + subject := events.BuildProcessStatusSubject(status.SessionID) + + if err := p.eventBus.Publish(context.Background(), subject, event); err != nil { + p.logger.Error("failed to publish process status event", + zap.String("session_id", status.SessionID), + zap.Error(err)) + } +} + // PublishContextWindow publishes a context window update event to the event bus. func (p *EventPublisher) PublishContextWindow(execution *AgentExecution, size, used, remaining int64, efficiency float64) { if p.eventBus == nil { diff --git a/apps/backend/internal/agent/lifecycle/manager.go b/apps/backend/internal/agent/lifecycle/manager.go index 216f3ad50c..aabb09fa64 100644 --- a/apps/backend/internal/agent/lifecycle/manager.go +++ b/apps/backend/internal/agent/lifecycle/manager.go @@ -103,11 +103,13 @@ func NewManager( // Initialize stream manager with callbacks that delegate to manager methods mgr.streamManager = NewStreamManager(log, StreamCallbacks{ - OnAgentEvent: mgr.handleAgentEvent, - OnGitStatus: mgr.handleGitStatusUpdate, - OnFileChange: mgr.handleFileChangeNotification, - OnShellOutput: mgr.handleShellOutput, - OnShellExit: mgr.handleShellExit, + OnAgentEvent: mgr.handleAgentEvent, + OnGitStatus: mgr.handleGitStatusUpdate, + OnFileChange: mgr.handleFileChangeNotification, + OnShellOutput: mgr.handleShellOutput, + OnShellExit: mgr.handleShellExit, + OnProcessOutput: mgr.handleProcessOutput, + OnProcessStatus: mgr.handleProcessStatus, }) // Set session manager dependencies for full orchestration @@ -130,6 +132,117 @@ func (m *Manager) SetWorkspaceInfoProvider(provider WorkspaceInfoProvider) { m.workspaceInfoProvider = provider } +// EnsureWorkspaceExecutionForSession ensures an agentctl execution exists for a specific task session. +// This is used when the frontend provides a session ID (e.g., from URL path /task/[id]/[sessionId]). +// If an execution already exists for the session, it returns it. Otherwise, it creates a new execution +// using the session's workspace configuration from the database. +func (m *Manager) EnsureWorkspaceExecutionForSession(ctx context.Context, taskID, sessionID string) (*AgentExecution, error) { + // Check if execution already exists + if execution, exists := m.executionStore.GetBySessionID(sessionID); exists { + return execution, nil + } + + // Need to create a new execution - get workspace info for the specific session + if m.workspaceInfoProvider == nil { + return nil, fmt.Errorf("workspace info provider not configured") + } + + info, err := m.workspaceInfoProvider.GetWorkspaceInfoForSession(ctx, taskID, sessionID) + if err != nil { + return nil, fmt.Errorf("failed to get workspace info for session %s: %w", sessionID, err) + } + + if info.WorkspacePath == "" { + return nil, fmt.Errorf("no workspace path available for session %s", sessionID) + } + + m.logger.Info("creating execution for task session", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.String("workspace_path", info.WorkspacePath), + zap.String("acp_session_id", info.ACPSessionID)) + + execution, err := m.createExecution(ctx, taskID, info) + if err != nil { + return nil, err + } + + // For workspace-only executions (no agent), wait for agentctl to be ready + // then connect the workspace stream so process output can be received + go func() { + // Wait for agentctl to be ready + waitCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + if err := execution.agentctl.WaitForReady(waitCtx, 60*time.Second); err != nil { + m.logger.Error("agentctl not ready for workspace stream connection", + zap.String("execution_id", execution.ID), + zap.Error(err)) + return + } + + // Connect workspace stream for process output (agent stream not needed for workspace-only) + if m.streamManager != nil { + m.logger.Info("connecting workspace stream for workspace-only execution", + zap.String("execution_id", execution.ID)) + go m.streamManager.connectWorkspaceStream(execution) + } + }() + + return execution, nil +} + +// createExecution creates an agentctl execution. +// The agent subprocess is NOT started - call ConfigureAgent + Start explicitly. +func (m *Manager) createExecution(ctx context.Context, taskID string, info *WorkspaceInfo) (*AgentExecution, error) { + if m.runtime == nil { + return nil, fmt.Errorf("no runtime configured") + } + + if info.AgentID == "" { + return nil, fmt.Errorf("agent ID is required in WorkspaceInfo") + } + + executionID := uuid.New().String() + + agentConfig, err := m.registry.Get(info.AgentID) + if err != nil { + return nil, fmt.Errorf("agent type %q not found in registry: %w", info.AgentID, err) + } + + req := &RuntimeCreateRequest{ + InstanceID: executionID, + TaskID: taskID, + SessionID: info.SessionID, + AgentProfileID: info.AgentProfileID, + WorkspacePath: info.WorkspacePath, + Protocol: string(agentConfig.Protocol), + } + + runtimeInstance, err := m.runtime.CreateInstance(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to create execution: %w", err) + } + + execution := runtimeInstance.ToAgentExecution(req) + + // Set the ACP session ID for session resumption + if info.ACPSessionID != "" { + execution.ACPSessionID = info.ACPSessionID + } + + m.executionStore.Add(execution) + + go m.waitForAgentctlReady(execution) + + m.logger.Info("execution created", + zap.String("execution_id", executionID), + zap.String("task_id", taskID), + zap.String("workspace_path", info.WorkspacePath), + zap.String("runtime", string(m.runtime.Name()))) + + return execution, nil +} // Start starts the lifecycle manager background tasks func (m *Manager) Start(ctx context.Context) error { runtimeName := "none" @@ -933,6 +1046,34 @@ func (m *Manager) handleShellOutput(execution *AgentExecution, data string) { m.eventPublisher.PublishShellOutput(execution, data) } +// handleProcessOutput processes script process output from the workspace stream +func (m *Manager) handleProcessOutput(execution *AgentExecution, output *agentctl.ProcessOutput) { + if output == nil { + return + } + m.logger.Debug("lifecycle received process output", + zap.String("session_id", output.SessionID), + zap.String("process_id", output.ProcessID), + zap.String("kind", string(output.Kind)), + zap.String("stream", output.Stream), + zap.Int("bytes", len(output.Data)), + ) + m.eventPublisher.PublishProcessOutput(execution, output) +} + +// handleProcessStatus processes script process status updates from the workspace stream +func (m *Manager) handleProcessStatus(execution *AgentExecution, status *agentctl.ProcessStatusUpdate) { + if status == nil { + return + } + m.logger.Debug("lifecycle received process status", + zap.String("session_id", status.SessionID), + zap.String("process_id", status.ProcessID), + zap.String("status", string(status.Status)), + ) + m.eventPublisher.PublishProcessStatus(execution, status) +} + // handleShellExit processes shell exit events from the workspace stream func (m *Manager) handleShellExit(execution *AgentExecution, code int) { m.eventPublisher.PublishShellExit(execution, code) diff --git a/apps/backend/internal/agent/lifecycle/process_runner.go b/apps/backend/internal/agent/lifecycle/process_runner.go new file mode 100644 index 0000000000..49af661bd6 --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/process_runner.go @@ -0,0 +1,147 @@ +package lifecycle + +import ( + "context" + "errors" + "fmt" + "time" + + agentctl "github.com/kandev/kandev/internal/agentctl/client" +) + +type StartProcessRequest struct { + SessionID string + Kind string + ScriptName string + Command string + WorkingDir string + Env map[string]string +} + +func (m *Manager) StartProcess(ctx context.Context, req StartProcessRequest) (*agentctl.ProcessInfo, error) { + if req.SessionID == "" { + return nil, fmt.Errorf("session_id is required") + } + execution, ok := m.executionStore.GetBySessionID(req.SessionID) + if !ok { + return nil, fmt.Errorf("no execution found for session %s", req.SessionID) + } + client := execution.GetAgentCtlClient() + if client == nil { + return nil, fmt.Errorf("agentctl client not available for session %s", req.SessionID) + } + + return client.StartProcess(ctx, agentctl.StartProcessRequest{ + SessionID: req.SessionID, + Kind: agentctl.ProcessKind(req.Kind), + ScriptName: req.ScriptName, + Command: req.Command, + WorkingDir: req.WorkingDir, + Env: req.Env, + }) +} + +// WaitForAgentctlReadyForSession waits for agentctl to be ready for a session. +func (m *Manager) WaitForAgentctlReadyForSession(ctx context.Context, sessionID string) error { + if sessionID == "" { + return fmt.Errorf("session_id is required") + } + execution, ok := m.executionStore.GetBySessionID(sessionID) + if !ok { + return fmt.Errorf("no execution found for session %s", sessionID) + } + client := execution.GetAgentCtlClient() + if client == nil { + return fmt.Errorf("agentctl client not available for session %s", sessionID) + } + return client.WaitForReady(ctx, 10*time.Second) +} + +func (m *Manager) StopProcess(ctx context.Context, processID string) error { + if processID == "" { + return fmt.Errorf("process_id is required") + } + for _, exec := range m.executionStore.List() { + client := exec.GetAgentCtlClient() + if client == nil { + continue + } + if err := client.StopProcess(ctx, processID); err == nil { + return nil + } + } + return fmt.Errorf("process not found: %s", processID) +} + +// StopProcessForSession stops a running process by ID within a specific session. +func (m *Manager) StopProcessForSession(ctx context.Context, sessionID, processID string) error { + if sessionID == "" { + return fmt.Errorf("session_id is required") + } + if processID == "" { + return fmt.Errorf("process_id is required") + } + execution, ok := m.executionStore.GetBySessionID(sessionID) + if !ok { + return fmt.Errorf("no execution found for session %s", sessionID) + } + client := execution.GetAgentCtlClient() + if client == nil { + return fmt.Errorf("agentctl client not available for session %s", sessionID) + } + return client.StopProcess(ctx, processID) +} + +func (m *Manager) ListProcesses(ctx context.Context, sessionID string) ([]agentctl.ProcessInfo, error) { + if sessionID == "" { + return nil, fmt.Errorf("session_id is required") + } + execution, ok := m.executionStore.GetBySessionID(sessionID) + if !ok { + return nil, fmt.Errorf("no execution found for session %s", sessionID) + } + client := execution.GetAgentCtlClient() + if client == nil { + return nil, fmt.Errorf("agentctl client not available for session %s", sessionID) + } + return client.ListProcesses(ctx, sessionID) +} + +func (m *Manager) GetProcess(ctx context.Context, processID string, includeOutput bool) (*agentctl.ProcessInfo, error) { + if processID == "" { + return nil, fmt.Errorf("process_id is required") + } + for _, exec := range m.executionStore.List() { + if exec.GetAgentCtlClient() == nil { + continue + } + proc, err := exec.GetAgentCtlClient().GetProcess(ctx, processID, includeOutput) + if err == nil { + return proc, nil + } + } + return nil, fmt.Errorf("process not found: %s", processID) +} + +// StopAllProcesses attempts to stop all running processes across all executions. +func (m *Manager) StopAllProcesses(ctx context.Context) error { + executions := m.executionStore.List() + var errs []error + for _, exec := range executions { + client := exec.GetAgentCtlClient() + if client == nil { + continue + } + procs, err := client.ListProcesses(ctx, exec.SessionID) + if err != nil { + errs = append(errs, err) + continue + } + for _, proc := range procs { + if err := client.StopProcess(ctx, proc.ID); err != nil { + errs = append(errs, err) + } + } + } + return errors.Join(errs...) +} diff --git a/apps/backend/internal/agent/lifecycle/process_runner_test.go b/apps/backend/internal/agent/lifecycle/process_runner_test.go new file mode 100644 index 0000000000..933e4dae1a --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/process_runner_test.go @@ -0,0 +1,55 @@ +package lifecycle + +import ( + "context" + "testing" +) + +func TestProcessRunnerStartErrors(t *testing.T) { + manager := &Manager{executionStore: NewExecutionStore()} + + if _, err := manager.StartProcess(context.Background(), StartProcessRequest{ + SessionID: "missing", + Kind: "dev", + Command: "echo hi", + }); err == nil { + t.Fatal("expected error when no execution exists for session") + } + + exec := &AgentExecution{ID: "exec-1", SessionID: "session-1"} + manager.executionStore.Add(exec) + if _, err := manager.StartProcess(context.Background(), StartProcessRequest{ + SessionID: "session-1", + Kind: "dev", + Command: "echo hi", + }); err == nil { + t.Fatal("expected error when agentctl client is missing") + } +} + +func TestProcessRunnerListErrors(t *testing.T) { + manager := &Manager{executionStore: NewExecutionStore()} + if _, err := manager.ListProcesses(context.Background(), "missing"); err == nil { + t.Fatal("expected error when no execution exists for session") + } + + exec := &AgentExecution{ID: "exec-1", SessionID: "session-1"} + manager.executionStore.Add(exec) + if _, err := manager.ListProcesses(context.Background(), "session-1"); err == nil { + t.Fatal("expected error when agentctl client is missing") + } +} + +func TestProcessRunnerStopErrors(t *testing.T) { + manager := &Manager{executionStore: NewExecutionStore()} + if err := manager.StopProcess(context.Background(), "process-1"); err == nil { + t.Fatal("expected error when process not found") + } +} + +func TestProcessRunnerGetErrors(t *testing.T) { + manager := &Manager{executionStore: NewExecutionStore()} + if _, err := manager.GetProcess(context.Background(), "process-1", false); err == nil { + t.Fatal("expected error when process not found") + } +} diff --git a/apps/backend/internal/agent/lifecycle/streams.go b/apps/backend/internal/agent/lifecycle/streams.go index af69aa1c0a..f41792a34b 100644 --- a/apps/backend/internal/agent/lifecycle/streams.go +++ b/apps/backend/internal/agent/lifecycle/streams.go @@ -12,11 +12,13 @@ import ( // StreamCallbacks defines callbacks for stream events type StreamCallbacks struct { - OnAgentEvent func(execution *AgentExecution, event agentctl.AgentEvent) - OnGitStatus func(execution *AgentExecution, update *agentctl.GitStatusUpdate) - OnFileChange func(execution *AgentExecution, notification *agentctl.FileChangeNotification) - OnShellOutput func(execution *AgentExecution, data string) - OnShellExit func(execution *AgentExecution, code int) + OnAgentEvent func(execution *AgentExecution, event agentctl.AgentEvent) + OnGitStatus func(execution *AgentExecution, update *agentctl.GitStatusUpdate) + OnFileChange func(execution *AgentExecution, notification *agentctl.FileChangeNotification) + OnShellOutput func(execution *AgentExecution, data string) + OnShellExit func(execution *AgentExecution, code int) + OnProcessOutput func(execution *AgentExecution, output *agentctl.ProcessOutput) + OnProcessStatus func(execution *AgentExecution, status *agentctl.ProcessStatusUpdate) } // StreamManager manages WebSocket streams to agent executions @@ -123,6 +125,16 @@ func (sm *StreamManager) connectWorkspaceStream(execution *AgentExecution) { sm.callbacks.OnFileChange(execution, notification) } }, + OnProcessOutput: func(output *agentctl.ProcessOutput) { + if sm.callbacks.OnProcessOutput != nil { + sm.callbacks.OnProcessOutput(execution, output) + } + }, + OnProcessStatus: func(status *agentctl.ProcessStatusUpdate) { + if sm.callbacks.OnProcessStatus != nil { + sm.callbacks.OnProcessStatus(execution, status) + } + }, OnConnected: func() { sm.logger.Debug("workspace stream connected", zap.String("instance_id", execution.ID)) diff --git a/apps/backend/internal/agentctl/client/client.go b/apps/backend/internal/agentctl/client/client.go index 9d34f98b5b..a78c4d4b25 100644 --- a/apps/backend/internal/agentctl/client/client.go +++ b/apps/backend/internal/agentctl/client/client.go @@ -264,8 +264,52 @@ type ( ShellMessage = types.ShellMessage ShellStatusResponse = types.ShellStatusResponse ShellBufferResponse = types.ShellBufferResponse + ProcessKind = types.ProcessKind + ProcessStatus = types.ProcessStatus + ProcessOutput = types.ProcessOutput + ProcessStatusUpdate = types.ProcessStatusUpdate ) +type StartProcessRequest struct { + SessionID string `json:"session_id"` + Kind ProcessKind `json:"kind"` + ScriptName string `json:"script_name,omitempty"` + Command string `json:"command"` + WorkingDir string `json:"working_dir"` + Env map[string]string `json:"env,omitempty"` + BufferMaxBytes int64 `json:"buffer_max_bytes,omitempty"` +} + +type ProcessOutputChunk struct { + Stream string `json:"stream"` + Data string `json:"data"` + Timestamp time.Time `json:"timestamp"` +} + +type ProcessInfo struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + Kind ProcessKind `json:"kind"` + ScriptName string `json:"script_name,omitempty"` + Command string `json:"command"` + WorkingDir string `json:"working_dir"` + Status ProcessStatus `json:"status"` + ExitCode *int `json:"exit_code,omitempty"` + StartedAt time.Time `json:"started_at"` + UpdatedAt time.Time `json:"updated_at"` + Output []ProcessOutputChunk `json:"output,omitempty"` +} + +type startProcessResponse struct { + Process *ProcessInfo `json:"process,omitempty"` + Error string `json:"error,omitempty"` +} + +type stopProcessResponse struct { + Success bool `json:"success"` + Error string `json:"error,omitempty"` +} + // RequestFileTree requests a file tree via HTTP GET func (c *Client) RequestFileTree(ctx context.Context, path string, depth int) (*FileTreeResponse, error) { url := fmt.Sprintf("%s/api/v1/workspace/tree?path=%s&depth=%d", c.baseURL, path, depth) @@ -387,15 +431,144 @@ func (c *Client) ShellBuffer(ctx context.Context) (string, error) { return result.Data, nil } +func (c *Client) StartProcess(ctx context.Context, req StartProcessRequest) (*ProcessInfo, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, err + } + httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/processes/start", bytes.NewReader(body)) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := readResponseBody(resp) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("start process failed with status %d: %s", resp.StatusCode, string(respBody)) + } + + var result startProcessResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to parse start process response: %w", err) + } + if result.Process == nil { + if result.Error != "" { + return nil, fmt.Errorf("start process failed: %s", result.Error) + } + return nil, fmt.Errorf("start process failed: no process returned") + } + return result.Process, nil +} + +func (c *Client) StopProcess(ctx context.Context, processID string) error { + body, err := json.Marshal(map[string]string{"process_id": processID}) + if err != nil { + return err + } + httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/processes/stop", bytes.NewReader(body)) + if err != nil { + return err + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := readResponseBody(resp) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("stop process failed with status %d: %s", resp.StatusCode, string(respBody)) + } + + var result stopProcessResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return fmt.Errorf("failed to parse stop process response: %w", err) + } + if !result.Success { + return fmt.Errorf("stop process failed: %s", result.Error) + } + return nil +} + +func (c *Client) ListProcesses(ctx context.Context, sessionID string) ([]ProcessInfo, error) { + url := c.baseURL + "/api/v1/processes" + if sessionID != "" { + url = url + "?session_id=" + sessionID + } + httpReq, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("list processes failed with status %d", resp.StatusCode) + } + var result []ProcessInfo + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + return result, nil +} + +func (c *Client) GetProcess(ctx context.Context, id string, includeOutput bool) (*ProcessInfo, error) { + url := c.baseURL + "/api/v1/processes/" + id + if includeOutput { + url = url + "?include_output=true" + } + httpReq, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("get process failed with status %d", resp.StatusCode) + } + var result ProcessInfo + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + return &result, nil +} + // WorkspaceStreamCallbacks defines callbacks for workspace stream events type WorkspaceStreamCallbacks struct { - OnShellOutput func(data string) - OnShellExit func(code int) - OnGitStatus func(update *GitStatusUpdate) - OnFileChange func(notification *FileChangeNotification) - OnFileList func(update *FileListUpdate) - OnConnected func() - OnError func(err string) + OnShellOutput func(data string) + OnShellExit func(code int) + OnGitStatus func(update *GitStatusUpdate) + OnFileChange func(notification *FileChangeNotification) + OnFileList func(update *FileListUpdate) + OnProcessOutput func(output *types.ProcessOutput) + OnProcessStatus func(status *types.ProcessStatusUpdate) + OnConnected func() + OnError func(err string) } // WorkspaceStream represents an active workspace stream connection @@ -474,6 +647,14 @@ func (c *Client) StreamWorkspace(ctx context.Context, callbacks WorkspaceStreamC if callbacks.OnFileList != nil && msg.FileList != nil { callbacks.OnFileList(msg.FileList) } + case types.WorkspaceMessageTypeProcessOutput: + if callbacks.OnProcessOutput != nil && msg.ProcessOutput != nil { + callbacks.OnProcessOutput(msg.ProcessOutput) + } + case types.WorkspaceMessageTypeProcessStatus: + if callbacks.OnProcessStatus != nil && msg.ProcessStatus != nil { + callbacks.OnProcessStatus(msg.ProcessStatus) + } case types.WorkspaceMessageTypeConnected: if callbacks.OnConnected != nil { callbacks.OnConnected() diff --git a/apps/backend/internal/agentctl/server/api/processes.go b/apps/backend/internal/agentctl/server/api/processes.go new file mode 100644 index 0000000000..697acf8d9e --- /dev/null +++ b/apps/backend/internal/agentctl/server/api/processes.go @@ -0,0 +1,81 @@ +package api + +import ( + "context" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/kandev/kandev/internal/agentctl/server/process" + "go.uber.org/zap" +) + +type startProcessResponse struct { + Process *process.ProcessInfo `json:"process,omitempty"` + Error string `json:"error,omitempty"` +} + +type stopProcessResponse struct { + Success bool `json:"success"` + Error string `json:"error,omitempty"` +} + +func (s *Server) handleStartProcess(c *gin.Context) { + var req process.StartProcessRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, startProcessResponse{Error: "invalid request body"}) + return + } + + proc, err := s.procMgr.StartProcess(context.Background(), req) + if err != nil { + s.logger.Error("failed to start process", zap.Error(err)) + c.JSON(http.StatusBadRequest, startProcessResponse{Error: err.Error()}) + return + } + + c.JSON(http.StatusOK, startProcessResponse{Process: proc}) +} + +func (s *Server) handleStopProcess(c *gin.Context) { + var req process.StopProcessRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, stopProcessResponse{Success: false, Error: "invalid request body"}) + return + } + if req.ProcessID == "" { + c.JSON(http.StatusBadRequest, stopProcessResponse{Success: false, Error: "process_id is required"}) + return + } + if err := s.procMgr.StopProcess(c.Request.Context(), req); err != nil { + if strings.Contains(err.Error(), "process not found") { + c.JSON(http.StatusOK, stopProcessResponse{Success: true}) + return + } + s.logger.Error("failed to stop process", zap.Error(err)) + c.JSON(http.StatusInternalServerError, stopProcessResponse{Success: false, Error: err.Error()}) + return + } + c.JSON(http.StatusOK, stopProcessResponse{Success: true}) +} + +func (s *Server) handleListProcesses(c *gin.Context) { + sessionID := c.Query("session_id") + processes := s.procMgr.ListProcesses(sessionID) + c.JSON(http.StatusOK, processes) +} + +func (s *Server) handleGetProcess(c *gin.Context) { + id := c.Param("id") + if id == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "process id is required"}) + return + } + includeOutput := c.Query("include_output") == "true" + proc, ok := s.procMgr.GetProcess(id, includeOutput) + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "process not found"}) + return + } + c.JSON(http.StatusOK, proc) +} diff --git a/apps/backend/internal/agentctl/server/api/server.go b/apps/backend/internal/agentctl/server/api/server.go index ce15ba7825..3ce1bb7ac9 100644 --- a/apps/backend/internal/agentctl/server/api/server.go +++ b/apps/backend/internal/agentctl/server/api/server.go @@ -89,6 +89,12 @@ func (s *Server) setupRoutes() { api.GET("/shell/status", s.handleShellStatus) api.GET("/shell/buffer", s.handleShellBuffer) + // Process runner + api.POST("/processes/start", s.handleStartProcess) + api.POST("/processes/stop", s.handleStopProcess) + api.GET("/processes", s.handleListProcesses) + api.GET("/processes/:id", s.handleGetProcess) + // Git operations api.POST("/git/pull", s.handleGitPull) api.POST("/git/push", s.handleGitPush) diff --git a/apps/backend/internal/agentctl/server/config/config.go b/apps/backend/internal/agentctl/server/config/config.go index 22df7ba983..f1bf1c99ac 100644 --- a/apps/backend/internal/agentctl/server/config/config.go +++ b/apps/backend/internal/agentctl/server/config/config.go @@ -67,6 +67,9 @@ type InstanceDefaults struct { // HealthCheckInterval is the interval in seconds for health checks HealthCheckInterval int + + // ProcessBufferMaxBytes is the max bytes per process output buffer (default 2MB) + ProcessBufferMaxBytes int64 } // McpServerConfig holds configuration for an MCP server. @@ -130,6 +133,9 @@ type InstanceConfig struct { // McpServers is a list of MCP servers to configure for the agent McpServers []McpServerConfig + + // ProcessBufferMaxBytes caps per-process output buffer size + ProcessBufferMaxBytes int64 } // Load loads the configuration from environment variables. @@ -147,6 +153,7 @@ func Load() *Config { AutoStart: getEnvBool("AGENTCTL_AUTO_START", false), AutoApprovePermissions: getEnvBool("AGENTCTL_AUTO_APPROVE_PERMISSIONS", false), HealthCheckInterval: getEnvInt("AGENTCTL_HEALTH_CHECK_INTERVAL", 5), + ProcessBufferMaxBytes: getEnvInt64("AGENTCTL_PROCESS_BUFFER_MAX_BYTES", 2*1024*1024), }, ShellEnabled: getEnvBool("AGENTCTL_SHELL_ENABLED", true), LogLevel: getEnv("AGENTCTL_LOG_LEVEL", "info"), @@ -167,6 +174,7 @@ func (c *Config) NewInstanceConfig(port int, overrides *InstanceOverrides) *Inst ShellEnabled: c.ShellEnabled, LogLevel: c.LogLevel, LogFormat: c.LogFormat, + ProcessBufferMaxBytes: c.Defaults.ProcessBufferMaxBytes, } // Apply overrides if provided @@ -269,10 +277,18 @@ func getEnvInt(key string, defaultValue int) int { return defaultValue } +func getEnvInt64(key string, defaultValue int64) int64 { + if value := os.Getenv(key); value != "" { + if i, err := strconv.ParseInt(value, 10, 64); err == nil { + return i + } + } + return defaultValue +} + func getEnvBool(key string, defaultValue bool) bool { if value := os.Getenv(key); value != "" { return strings.ToLower(value) == "true" || value == "1" } return defaultValue } - diff --git a/apps/backend/internal/agentctl/server/instance/manager.go b/apps/backend/internal/agentctl/server/instance/manager.go index 5b2be404b9..babefbf868 100644 --- a/apps/backend/internal/agentctl/server/instance/manager.go +++ b/apps/backend/internal/agentctl/server/instance/manager.go @@ -3,10 +3,13 @@ package instance import ( "context" + "errors" "fmt" + "net" "net/http" "strings" "sync" + "syscall" "time" "github.com/google/uuid" @@ -64,10 +67,36 @@ func (m *Manager) CreateInstance(ctx context.Context, req *CreateRequest) (*Crea return nil, fmt.Errorf("instance with ID %s already exists", id) } - // Allocate a port - port, err := m.portAlloc.Allocate(id) - if err != nil { - return nil, fmt.Errorf("failed to allocate port: %w", err) + // Allocate a port and bind a listener to avoid races with address reuse. + var ( + port int + listener net.Listener + ) + maxAttempts := m.config.Ports.Max - m.config.Ports.Base + 1 + for attempt := 0; attempt < maxAttempts; attempt++ { + allocated, err := m.portAlloc.Allocate(id) + if err != nil { + return nil, fmt.Errorf("failed to allocate port: %w", err) + } + addr := fmt.Sprintf(":%d", allocated) + ln, err := net.Listen("tcp", addr) + if err != nil { + if errors.Is(err, syscall.EADDRINUSE) || strings.Contains(err.Error(), "address already in use") { + m.portAlloc.MarkUnavailable(allocated) + m.logger.Warn("port already in use; retrying", + zap.String("instance_id", id), + zap.Int("port", allocated)) + continue + } + m.portAlloc.Release(allocated) + return nil, fmt.Errorf("failed to bind instance port %d: %w", allocated, err) + } + port = allocated + listener = ln + break + } + if listener == nil { + return nil, fmt.Errorf("failed to allocate an available port for instance %s", id) } // Determine agent command, adding workspace flag if needed @@ -111,6 +140,10 @@ func (m *Manager) CreateInstance(ctx context.Context, req *CreateRequest) (*Crea // Create process manager procMgr := process.NewManager(instanceCfg, m.logger) + // Start workspace tracker immediately so process output can be streamed + // even without an agent running (for dev server, etc.) + procMgr.GetWorkspaceTracker().Start(context.Background()) + // Create HTTP handler using factory var handler http.Handler if m.serverFactory != nil { @@ -133,7 +166,7 @@ func (m *Manager) CreateInstance(ctx context.Context, req *CreateRequest) (*Crea // Start HTTP server in background go func() { - if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + if err := httpServer.Serve(listener); err != nil && err != http.ErrServerClosed { m.logger.Error("instance server error", zap.String("instance_id", id), zap.Error(err)) } }() diff --git a/apps/backend/internal/agentctl/server/instance/port_allocator.go b/apps/backend/internal/agentctl/server/instance/port_allocator.go index edf4ed5001..869e43f582 100644 --- a/apps/backend/internal/agentctl/server/instance/port_allocator.go +++ b/apps/backend/internal/agentctl/server/instance/port_allocator.go @@ -11,19 +11,21 @@ import ( // It tracks which ports are in use and provides thread-safe allocation // and release of ports within a configured range. type PortAllocator struct { - basePort int - maxPort int - allocated map[int]string // maps port to instance ID - mu sync.Mutex + basePort int + maxPort int + allocated map[int]string // maps port to instance ID + unavailable map[int]struct{} + mu sync.Mutex } // NewPortAllocator creates a new PortAllocator that manages ports // in the range [basePort, maxPort]. func NewPortAllocator(basePort, maxPort int) *PortAllocator { return &PortAllocator{ - basePort: basePort, - maxPort: maxPort, - allocated: make(map[int]string), + basePort: basePort, + maxPort: maxPort, + allocated: make(map[int]string), + unavailable: make(map[int]struct{}), } } @@ -35,6 +37,9 @@ func (p *PortAllocator) Allocate(instanceID string) (int, error) { defer p.mu.Unlock() for port := p.basePort; port <= p.maxPort; port++ { + if _, blocked := p.unavailable[port]; blocked { + continue + } if _, exists := p.allocated[port]; !exists { p.allocated[port] = instanceID return port, nil @@ -53,3 +58,11 @@ func (p *PortAllocator) Release(port int) { delete(p.allocated, port) } +// MarkUnavailable prevents a port from being allocated again in this process. +func (p *PortAllocator) MarkUnavailable(port int) { + p.mu.Lock() + defer p.mu.Unlock() + + p.unavailable[port] = struct{}{} + delete(p.allocated, port) +} diff --git a/apps/backend/internal/agentctl/server/process/manager.go b/apps/backend/internal/agentctl/server/process/manager.go index 0e6062cd0b..ff563997a9 100644 --- a/apps/backend/internal/agentctl/server/process/manager.go +++ b/apps/backend/internal/agentctl/server/process/manager.go @@ -75,6 +75,9 @@ type Manager struct { // Workspace tracker for git status and file changes workspaceTracker *WorkspaceTracker + // Script/process runner (dev server, setup, cleanup, custom) + processRunner *ProcessRunner + // Embedded shell session (auto-created when agent starts) shell *shell.Session @@ -94,11 +97,11 @@ type Manager struct { gitOperatorMu sync.Mutex // Synchronization - mu sync.RWMutex - wg sync.WaitGroup - stopCh chan struct{} - doneCh chan struct{} - startMu sync.Mutex + mu sync.RWMutex + wg sync.WaitGroup + stopCh chan struct{} + doneCh chan struct{} + startMu sync.Mutex } // NewManager creates a new process manager @@ -110,6 +113,7 @@ func NewManager(cfg *config.InstanceConfig, log *logger.Logger) *Manager { updatesCh: make(chan adapter.AgentEvent, 100), pendingPermissions: make(map[string]*PendingPermission), } + m.processRunner = NewProcessRunner(m.workspaceTracker, log, cfg.ProcessBufferMaxBytes) m.status.Store(StatusStopped) m.exitCode.Store(-1) return m @@ -140,6 +144,38 @@ func (m *Manager) GetWorkspaceTracker() *WorkspaceTracker { return m.workspaceTracker } +// StartProcess runs a script/process with isolated stdout/stderr. +func (m *Manager) StartProcess(ctx context.Context, req StartProcessRequest) (*ProcessInfo, error) { + if m.processRunner == nil { + return nil, fmt.Errorf("process runner not available") + } + return m.processRunner.Start(ctx, req) +} + +// StopProcess stops a running process by ID. +func (m *Manager) StopProcess(ctx context.Context, req StopProcessRequest) error { + if m.processRunner == nil { + return fmt.Errorf("process runner not available") + } + return m.processRunner.Stop(ctx, req) +} + +// GetProcess returns a process by ID. +func (m *Manager) GetProcess(id string, includeOutput bool) (*ProcessInfo, bool) { + if m.processRunner == nil { + return nil, false + } + return m.processRunner.Get(id, includeOutput) +} + +// ListProcesses returns processes for a session (or all if sessionID empty). +func (m *Manager) ListProcesses(sessionID string) []ProcessInfo { + if m.processRunner == nil { + return nil + } + return m.processRunner.List(sessionID) +} + // GitOperator returns the git operator for git operations. // The operator is lazy-initialized on first call. func (m *Manager) GitOperator() *GitOperator { @@ -429,6 +465,13 @@ func (m *Manager) Stop(ctx context.Context) error { m.shell = nil } + // Stop all running workspace processes (dev server, setup, cleanup, custom). + if m.processRunner != nil { + if err := m.processRunner.StopAll(ctx); err != nil { + m.logger.Debug("failed to stop workspace processes", zap.Error(err)) + } + } + // Stop workspace tracker if m.workspaceTracker != nil { m.workspaceTracker.Stop() @@ -681,8 +724,6 @@ func (m *Manager) RespondToPermission(pendingID string, optionID string, cancell } } - - // Shell returns the embedded shell session, or nil if not available func (m *Manager) Shell() *shell.Session { m.mu.RLock() diff --git a/apps/backend/internal/agentctl/server/process/runner.go b/apps/backend/internal/agentctl/server/process/runner.go new file mode 100644 index 0000000000..c5f87eaeae --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/runner.go @@ -0,0 +1,437 @@ +package process + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "strings" + "sync" + "syscall" + "time" + + "github.com/google/uuid" + "github.com/kandev/kandev/internal/agentctl/types" + "github.com/kandev/kandev/internal/common/logger" + "go.uber.org/zap" +) + +type StartProcessRequest struct { + SessionID string `json:"session_id"` + Kind types.ProcessKind `json:"kind"` + ScriptName string `json:"script_name,omitempty"` + Command string `json:"command"` + WorkingDir string `json:"working_dir"` + Env map[string]string `json:"env,omitempty"` + BufferMaxBytes int64 `json:"buffer_max_bytes,omitempty"` +} + +type StopProcessRequest struct { + ProcessID string `json:"process_id"` +} + +type ProcessInfo struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + Kind types.ProcessKind `json:"kind"` + ScriptName string `json:"script_name,omitempty"` + Command string `json:"command"` + WorkingDir string `json:"working_dir"` + Status types.ProcessStatus `json:"status"` + ExitCode *int `json:"exit_code,omitempty"` + StartedAt time.Time `json:"started_at"` + UpdatedAt time.Time `json:"updated_at"` + Output []ProcessOutputChunk `json:"output,omitempty"` +} + +type ProcessOutputChunk struct { + Stream string `json:"stream"` + Data string `json:"data"` + Timestamp time.Time `json:"timestamp"` +} + +type ringBuffer struct { + mu sync.Mutex + maxBytes int64 + size int64 + chunks []ProcessOutputChunk +} + +func newRingBuffer(maxBytes int64) *ringBuffer { + if maxBytes <= 0 { + maxBytes = 2 * 1024 * 1024 + } + return &ringBuffer{maxBytes: maxBytes} +} + +func (b *ringBuffer) append(chunk ProcessOutputChunk) { + b.mu.Lock() + defer b.mu.Unlock() + + b.chunks = append(b.chunks, chunk) + b.size += int64(len(chunk.Data)) + + for b.size > b.maxBytes && len(b.chunks) > 0 { + removed := b.chunks[0] + b.size -= int64(len(removed.Data)) + b.chunks = b.chunks[1:] + } +} + +func (b *ringBuffer) snapshot() []ProcessOutputChunk { + b.mu.Lock() + defer b.mu.Unlock() + out := make([]ProcessOutputChunk, len(b.chunks)) + copy(out, b.chunks) + return out +} + +type commandProcess struct { + info ProcessInfo + cmd *exec.Cmd + buffer *ringBuffer + stopOnce sync.Once + stopSignal chan struct{} + mu sync.Mutex +} + +type ProcessRunner struct { + logger *logger.Logger + workspaceTracker *WorkspaceTracker + bufferMaxBytes int64 + + mu sync.RWMutex + processes map[string]*commandProcess +} + +func NewProcessRunner(workspaceTracker *WorkspaceTracker, log *logger.Logger, bufferMaxBytes int64) *ProcessRunner { + return &ProcessRunner{ + logger: log.WithFields(zap.String("component", "process-runner")), + workspaceTracker: workspaceTracker, + bufferMaxBytes: bufferMaxBytes, + processes: make(map[string]*commandProcess), + } +} + +func (r *ProcessRunner) Start(ctx context.Context, req StartProcessRequest) (*ProcessInfo, error) { + if req.SessionID == "" { + return nil, fmt.Errorf("session_id is required") + } + if req.Command == "" { + return nil, fmt.Errorf("command is required") + } + + id := uuid.New().String() + now := time.Now().UTC() + + cmd := exec.CommandContext(ctx, "sh", "-lc", req.Command) + if req.WorkingDir != "" { + cmd.Dir = req.WorkingDir + } + cmd.Env = mergeEnv(req.Env) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("failed to attach stdout: %w", err) + } + stderr, err := cmd.StderrPipe() + if err != nil { + return nil, fmt.Errorf("failed to attach stderr: %w", err) + } + + bufferMaxBytes := req.BufferMaxBytes + if bufferMaxBytes <= 0 { + bufferMaxBytes = r.bufferMaxBytes + } + + proc := &commandProcess{ + info: ProcessInfo{ + ID: id, + SessionID: req.SessionID, + Kind: req.Kind, + ScriptName: req.ScriptName, + Command: req.Command, + WorkingDir: req.WorkingDir, + Status: types.ProcessStatusStarting, + StartedAt: now, + UpdatedAt: now, + }, + cmd: cmd, + buffer: newRingBuffer(bufferMaxBytes), + stopSignal: make(chan struct{}), + } + + r.mu.Lock() + r.processes[id] = proc + r.mu.Unlock() + + r.logger.Debug("process start requested", + zap.String("process_id", id), + zap.String("session_id", req.SessionID), + zap.String("kind", string(req.Kind)), + zap.String("script_name", req.ScriptName), + zap.String("working_dir", req.WorkingDir), + ) + + r.publishStatus(proc) + + if err := cmd.Start(); err != nil { + proc.mu.Lock() + proc.info.Status = types.ProcessStatusFailed + proc.info.UpdatedAt = time.Now().UTC() + proc.mu.Unlock() + r.publishStatus(proc) + r.mu.Lock() + delete(r.processes, id) + r.mu.Unlock() + return nil, fmt.Errorf("failed to start process: %w", err) + } + + // Update status to running after successful start so callers can subscribe immediately. + proc.mu.Lock() + proc.info.Status = types.ProcessStatusRunning + proc.info.UpdatedAt = time.Now().UTC() + proc.mu.Unlock() + r.publishStatus(proc) + + // Stream output and wait in the background; wait() is responsible for final status + cleanup. + go r.readOutput(proc, stdout, "stdout") + go r.readOutput(proc, stderr, "stderr") + go r.wait(proc) + + info := proc.snapshot(false) + return &info, nil +} + +func (r *ProcessRunner) Stop(ctx context.Context, req StopProcessRequest) error { + proc, ok := r.get(req.ProcessID) + if !ok { + return fmt.Errorf("process not found: %s", req.ProcessID) + } + + // Signal output readers to exit before attempting to terminate the process. + proc.stopOnce.Do(func() { + close(proc.stopSignal) + }) + + // Attempt graceful shutdown, then escalate if the context expires. + if proc.cmd != nil && proc.cmd.Process != nil { + pgid, err := syscall.Getpgid(proc.cmd.Process.Pid) + if err == nil { + _ = syscall.Kill(-pgid, syscall.SIGTERM) + } else { + _ = proc.cmd.Process.Signal(syscall.SIGTERM) + } + select { + case <-ctx.Done(): + if err == nil { + _ = syscall.Kill(-pgid, syscall.SIGKILL) + } else { + _ = proc.cmd.Process.Kill() + } + case <-time.After(2 * time.Second): + if err == nil { + _ = syscall.Kill(-pgid, syscall.SIGKILL) + } else { + _ = proc.cmd.Process.Kill() + } + } + } + + return nil +} + +// StopAll stops all running processes managed by this runner. +func (r *ProcessRunner) StopAll(ctx context.Context) error { + r.mu.RLock() + ids := make([]string, 0, len(r.processes)) + for id := range r.processes { + ids = append(ids, id) + } + r.mu.RUnlock() + + var errs []error + for _, id := range ids { + if err := r.Stop(ctx, StopProcessRequest{ProcessID: id}); err != nil { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} + +func (r *ProcessRunner) Get(id string, includeOutput bool) (*ProcessInfo, bool) { + proc, ok := r.get(id) + if !ok { + return nil, false + } + info := proc.snapshot(includeOutput) + return &info, true +} + +func (r *ProcessRunner) List(sessionID string) []ProcessInfo { + r.mu.RLock() + defer r.mu.RUnlock() + + result := make([]ProcessInfo, 0) + for _, proc := range r.processes { + if sessionID != "" && proc.info.SessionID != sessionID { + continue + } + result = append(result, proc.snapshot(false)) + } + return result +} + +func (r *ProcessRunner) get(id string) (*commandProcess, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + proc, ok := r.processes[id] + return proc, ok +} + +func (r *ProcessRunner) readOutput(proc *commandProcess, reader io.ReadCloser, stream string) { + defer func() { _ = reader.Close() }() + buf := bufio.NewReader(reader) + for { + select { + case <-proc.stopSignal: + return + default: + } + + data := make([]byte, 4096) + n, err := buf.Read(data) + if n > 0 { + chunk := ProcessOutputChunk{ + Stream: stream, + Data: string(data[:n]), + Timestamp: time.Now().UTC(), + } + proc.buffer.append(chunk) + r.publishOutput(proc, chunk) + } + if err != nil { + if err != io.EOF { + r.logger.Debug("process output read error", zap.Error(err)) + } + return + } + } +} + +func (r *ProcessRunner) wait(proc *commandProcess) { + err := proc.cmd.Wait() + exitCode := 0 + status := types.ProcessStatusExited + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + if waitStatus, ok := exitErr.Sys().(syscall.WaitStatus); ok { + exitCode = waitStatus.ExitStatus() + } else { + exitCode = 1 + } + } else { + exitCode = 1 + } + status = types.ProcessStatusFailed + } + // Final process status is computed here and published once. + r.logger.Debug("process exited", + zap.String("process_id", proc.info.ID), + zap.String("session_id", proc.info.SessionID), + zap.String("status", string(status)), + zap.Int("exit_code", exitCode), + zap.Error(err), + ) + + proc.mu.Lock() + proc.info.Status = status + proc.info.ExitCode = &exitCode + proc.info.UpdatedAt = time.Now().UTC() + proc.mu.Unlock() + r.publishStatus(proc) + + // Remove from the runner map after completion so future lookups don't see stale processes. + // Output is still preserved in the ring buffer returned by GetProcess(includeOutput=true) + // as long as the caller already has the process ID. + r.mu.Lock() + delete(r.processes, proc.info.ID) + r.mu.Unlock() +} + +func (r *ProcessRunner) publishOutput(proc *commandProcess, chunk ProcessOutputChunk) { + if r.workspaceTracker == nil { + return + } + proc.mu.Lock() + info := proc.info + proc.mu.Unlock() + + output := &types.ProcessOutput{ + SessionID: info.SessionID, + ProcessID: info.ID, + Kind: info.Kind, + Stream: chunk.Stream, + Data: chunk.Data, + Timestamp: chunk.Timestamp, + } + r.workspaceTracker.notifyWorkspaceStreamProcessOutput(output) +} + +func (r *ProcessRunner) publishStatus(proc *commandProcess) { + if r.workspaceTracker == nil { + return + } + proc.mu.Lock() + info := proc.info + proc.mu.Unlock() + + update := &types.ProcessStatusUpdate{ + SessionID: info.SessionID, + ProcessID: info.ID, + Kind: info.Kind, + ScriptName: info.ScriptName, + Command: info.Command, + WorkingDir: info.WorkingDir, + Status: info.Status, + ExitCode: info.ExitCode, + Timestamp: time.Now().UTC(), + } + r.logger.Debug("process status update", + zap.String("process_id", info.ID), + zap.String("session_id", info.SessionID), + zap.String("status", string(info.Status)), + ) + r.workspaceTracker.notifyWorkspaceStreamProcessStatus(update) +} + +func (p *commandProcess) snapshot(includeOutput bool) ProcessInfo { + p.mu.Lock() + defer p.mu.Unlock() + info := p.info + if includeOutput && p.buffer != nil { + info.Output = p.buffer.snapshot() + } + return info +} + +func mergeEnv(env map[string]string) []string { + base := make(map[string]string, len(os.Environ())+len(env)) + for _, entry := range os.Environ() { + if eq := strings.IndexByte(entry, '='); eq >= 0 { + base[entry[:eq]] = entry[eq+1:] + } + } + for k, v := range env { + base[k] = v + } + merged := make([]string, 0, len(base)) + for k, v := range base { + merged = append(merged, fmt.Sprintf("%s=%s", k, v)) + } + return merged +} diff --git a/apps/backend/internal/agentctl/server/process/runner_test.go b/apps/backend/internal/agentctl/server/process/runner_test.go new file mode 100644 index 0000000000..f2053891e3 --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/runner_test.go @@ -0,0 +1,78 @@ +package process + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/kandev/kandev/internal/common/logger" +) + +func newTestLogger(t *testing.T) *logger.Logger { + log, err := logger.NewLogger(logger.LoggingConfig{ + Level: "error", + Format: "json", + }) + if err != nil { + t.Fatalf("failed to create logger: %v", err) + } + return log +} + +func TestRingBufferTrimsOldest(t *testing.T) { + buffer := newRingBuffer(10) + buffer.append(ProcessOutputChunk{Stream: "stdout", Data: "hello", Timestamp: time.Now()}) // 5 + buffer.append(ProcessOutputChunk{Stream: "stdout", Data: "world", Timestamp: time.Now()}) // 5 (total 10) + buffer.append(ProcessOutputChunk{Stream: "stderr", Data: "!!!", Timestamp: time.Now()}) // +3 -> trim + + snapshot := buffer.snapshot() + if len(snapshot) == 0 { + t.Fatal("expected buffered output") + } + combined := "" + for _, chunk := range snapshot { + combined += chunk.Data + } + if strings.Contains(combined, "hello") { + t.Fatalf("expected oldest chunk to be trimmed, got %q", combined) + } + if !strings.Contains(combined, "world") { + t.Fatalf("expected newer chunk to remain, got %q", combined) + } +} + +func TestProcessRunnerCapturesOutput(t *testing.T) { + log := newTestLogger(t) + runner := NewProcessRunner(nil, log, 2*1024*1024) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + info, err := runner.Start(ctx, StartProcessRequest{ + SessionID: "session-1", + Kind: "dev", + Command: "printf 'hello'; sleep 2", + WorkingDir: "", + }) + if err != nil { + t.Fatalf("failed to start process: %v", err) + } + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + proc, ok := runner.Get(info.ID, true) + if !ok { + time.Sleep(25 * time.Millisecond) + continue + } + combined := "" + for _, chunk := range proc.Output { + combined += chunk.Data + } + if strings.Contains(combined, "hello") { + return + } + time.Sleep(25 * time.Millisecond) + } + t.Fatal("process output not captured in time") +} diff --git a/apps/backend/internal/agentctl/server/process/workspace_tracker.go b/apps/backend/internal/agentctl/server/process/workspace_tracker.go index cf650e23d6..2d5739844c 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_tracker.go +++ b/apps/backend/internal/agentctl/server/process/workspace_tracker.go @@ -40,8 +40,9 @@ type WorkspaceTracker struct { fsChangeTrigger chan struct{} // Control - stopCh chan struct{} - wg sync.WaitGroup + stopCh chan struct{} + wg sync.WaitGroup + started bool } // NewWorkspaceTracker creates a new workspace tracker @@ -64,6 +65,15 @@ func NewWorkspaceTracker(workDir string, log *logger.Logger) *WorkspaceTracker { // Start begins monitoring the workspace func (wt *WorkspaceTracker) Start(ctx context.Context) { + wt.mu.Lock() + if wt.started { + wt.mu.Unlock() + wt.logger.Debug("workspace tracker already started, skipping") + return + } + wt.started = true + wt.mu.Unlock() + wt.wg.Add(1) go wt.monitorLoop(ctx) @@ -230,7 +240,7 @@ func (wt *WorkspaceTracker) getGitStatus(ctx context.Context) (types.GitStatusUp continue } - indexStatus := line[0] // Staged status (X) + indexStatus := line[0] // Staged status (X) workTreeStatus := line[1] // Unstaged status (Y) filePath := strings.TrimSpace(line[3:]) @@ -479,8 +489,6 @@ func (wt *WorkspaceTracker) getFileList(ctx context.Context) (types.FileListUpda return update, nil } - - // Unified workspace stream subscriber management methods // SubscribeWorkspaceStream creates a new unified workspace stream subscriber @@ -490,7 +498,9 @@ func (wt *WorkspaceTracker) SubscribeWorkspaceStream() types.WorkspaceStreamSubs wt.workspaceSubMu.Lock() wt.workspaceStreamSubscribers[sub] = struct{}{} + count := len(wt.workspaceStreamSubscribers) wt.workspaceSubMu.Unlock() + wt.logger.Info("workspace stream subscriber added", zap.Int("subscribers", count)) // Send current git status immediately wt.mu.RLock() @@ -524,8 +534,10 @@ func (wt *WorkspaceTracker) SubscribeWorkspaceStream() types.WorkspaceStreamSubs func (wt *WorkspaceTracker) UnsubscribeWorkspaceStream(sub types.WorkspaceStreamSubscriber) { wt.workspaceSubMu.Lock() delete(wt.workspaceStreamSubscribers, sub) + count := len(wt.workspaceStreamSubscribers) wt.workspaceSubMu.Unlock() close(sub) + wt.logger.Info("workspace stream subscriber removed", zap.Int("subscribers", count)) } // Notification methods for unified workspace stream @@ -575,6 +587,48 @@ func (wt *WorkspaceTracker) notifyWorkspaceStreamFileList(update types.FileListU } } +// notifyWorkspaceStreamProcessOutput sends process output to all workspace stream subscribers +func (wt *WorkspaceTracker) notifyWorkspaceStreamProcessOutput(output *types.ProcessOutput) { + wt.workspaceSubMu.RLock() + defer wt.workspaceSubMu.RUnlock() + + msg := types.NewWorkspaceProcessOutput(output) + wt.logger.Debug("broadcast process output", + zap.String("session_id", output.SessionID), + zap.String("process_id", output.ProcessID), + zap.String("kind", string(output.Kind)), + zap.Int("subscribers", len(wt.workspaceStreamSubscribers)), + ) + for sub := range wt.workspaceStreamSubscribers { + select { + case sub <- msg: + default: + // Subscriber is slow, skip + } + } +} + +// notifyWorkspaceStreamProcessStatus sends process status updates to all workspace stream subscribers +func (wt *WorkspaceTracker) notifyWorkspaceStreamProcessStatus(status *types.ProcessStatusUpdate) { + wt.workspaceSubMu.RLock() + defer wt.workspaceSubMu.RUnlock() + + msg := types.NewWorkspaceProcessStatus(status) + wt.logger.Debug("broadcast process status", + zap.String("session_id", status.SessionID), + zap.String("process_id", status.ProcessID), + zap.String("status", string(status.Status)), + zap.Int("subscribers", len(wt.workspaceStreamSubscribers)), + ) + for sub := range wt.workspaceStreamSubscribers { + select { + case sub <- msg: + default: + // Subscriber is slow, skip + } + } +} + // watchFilesystem watches for filesystem changes and triggers debounced updates func (wt *WorkspaceTracker) watchFilesystem(ctx context.Context) { defer wt.wg.Done() @@ -755,5 +809,3 @@ func (wt *WorkspaceTracker) GetFileContent(reqPath string) (string, int64, error return string(content), info.Size(), nil } - - diff --git a/apps/backend/internal/agentctl/types/streams/process.go b/apps/backend/internal/agentctl/types/streams/process.go new file mode 100644 index 0000000000..29b56219fe --- /dev/null +++ b/apps/backend/internal/agentctl/types/streams/process.go @@ -0,0 +1,47 @@ +package streams + +import "time" + +// ProcessKind identifies which repository script kind is running. +type ProcessKind string + +const ( + ProcessKindSetup ProcessKind = "setup" + ProcessKindCleanup ProcessKind = "cleanup" + ProcessKindDev ProcessKind = "dev" + ProcessKindCustom ProcessKind = "custom" +) + +// ProcessStatus represents the lifecycle status of a process. +type ProcessStatus string + +const ( + ProcessStatusStarting ProcessStatus = "starting" + ProcessStatusRunning ProcessStatus = "running" + ProcessStatusExited ProcessStatus = "exited" + ProcessStatusFailed ProcessStatus = "failed" + ProcessStatusStopped ProcessStatus = "stopped" +) + +// ProcessOutput carries stdout/stderr output from a process. +type ProcessOutput struct { + SessionID string `json:"session_id"` + ProcessID string `json:"process_id"` + Kind ProcessKind `json:"kind"` + Stream string `json:"stream"` // stdout|stderr + Data string `json:"data"` + Timestamp time.Time `json:"timestamp"` +} + +// ProcessStatusUpdate carries status updates for a process. +type ProcessStatusUpdate struct { + SessionID string `json:"session_id"` + ProcessID string `json:"process_id"` + Kind ProcessKind `json:"kind"` + ScriptName string `json:"script_name,omitempty"` + Command string `json:"command"` + WorkingDir string `json:"working_dir"` + Status ProcessStatus `json:"status"` + ExitCode *int `json:"exit_code,omitempty"` + Timestamp time.Time `json:"timestamp"` +} diff --git a/apps/backend/internal/agentctl/types/types.go b/apps/backend/internal/agentctl/types/types.go index add0d2b226..c0bcfbe3fa 100644 --- a/apps/backend/internal/agentctl/types/types.go +++ b/apps/backend/internal/agentctl/types/types.go @@ -42,6 +42,12 @@ type ( ShellMessage = streams.ShellMessage ShellStatusResponse = streams.ShellStatusResponse ShellBufferResponse = streams.ShellBufferResponse + + // Process stream types + ProcessKind = streams.ProcessKind + ProcessStatus = streams.ProcessStatus + ProcessOutput = streams.ProcessOutput + ProcessStatusUpdate = streams.ProcessStatusUpdate ) // Re-export stream constants for convenience. @@ -77,6 +83,13 @@ const ( ShellMsgTypePing = streams.ShellMsgTypePing ShellMsgTypePong = streams.ShellMsgTypePong ShellMsgTypeExit = streams.ShellMsgTypeExit + + // Process statuses + ProcessStatusStarting = streams.ProcessStatusStarting + ProcessStatusRunning = streams.ProcessStatusRunning + ProcessStatusExited = streams.ProcessStatusExited + ProcessStatusFailed = streams.ProcessStatusFailed + ProcessStatusStopped = streams.ProcessStatusStopped ) // PermissionRequest represents a permission request from the agent. @@ -127,17 +140,19 @@ type McpServer struct { const ( // Workspace stream message types - WorkspaceMessageTypeShellOutput WorkspaceMessageType = "shell_output" - WorkspaceMessageTypeShellInput WorkspaceMessageType = "shell_input" - WorkspaceMessageTypeShellExit WorkspaceMessageType = "shell_exit" - WorkspaceMessageTypePing WorkspaceMessageType = "ping" - WorkspaceMessageTypePong WorkspaceMessageType = "pong" - WorkspaceMessageTypeGitStatus WorkspaceMessageType = "git_status" - WorkspaceMessageTypeFileChange WorkspaceMessageType = "file_change" - WorkspaceMessageTypeFileList WorkspaceMessageType = "file_list" - WorkspaceMessageTypeError WorkspaceMessageType = "error" - WorkspaceMessageTypeConnected WorkspaceMessageType = "connected" - WorkspaceMessageTypeShellResize WorkspaceMessageType = "shell_resize" + WorkspaceMessageTypeShellOutput WorkspaceMessageType = "shell_output" + WorkspaceMessageTypeShellInput WorkspaceMessageType = "shell_input" + WorkspaceMessageTypeShellExit WorkspaceMessageType = "shell_exit" + WorkspaceMessageTypePing WorkspaceMessageType = "ping" + WorkspaceMessageTypePong WorkspaceMessageType = "pong" + WorkspaceMessageTypeGitStatus WorkspaceMessageType = "git_status" + WorkspaceMessageTypeFileChange WorkspaceMessageType = "file_change" + WorkspaceMessageTypeFileList WorkspaceMessageType = "file_list" + WorkspaceMessageTypeError WorkspaceMessageType = "error" + WorkspaceMessageTypeConnected WorkspaceMessageType = "connected" + WorkspaceMessageTypeShellResize WorkspaceMessageType = "shell_resize" + WorkspaceMessageTypeProcessOutput WorkspaceMessageType = "process_output" + WorkspaceMessageTypeProcessStatus WorkspaceMessageType = "process_status" ) // WorkspaceStreamMessage is the unified message format for the workspace stream. @@ -164,6 +179,10 @@ type WorkspaceStreamMessage struct { // File list fields (for file_list) FileList *FileListUpdate `json:"file_list,omitempty"` + // Process fields (for process_output, process_status) + ProcessOutput *ProcessOutput `json:"process_output,omitempty"` + ProcessStatus *ProcessStatusUpdate `json:"process_status,omitempty"` + // Error fields (for error) Error string `json:"error,omitempty"` } @@ -213,6 +232,24 @@ func NewWorkspaceFileList(update *FileListUpdate) WorkspaceStreamMessage { } } +// NewWorkspaceProcessOutput creates a process output message +func NewWorkspaceProcessOutput(output *ProcessOutput) WorkspaceStreamMessage { + return WorkspaceStreamMessage{ + Type: WorkspaceMessageTypeProcessOutput, + Timestamp: timeNowUnixMilli(), + ProcessOutput: output, + } +} + +// NewWorkspaceProcessStatus creates a process status message +func NewWorkspaceProcessStatus(status *ProcessStatusUpdate) WorkspaceStreamMessage { + return WorkspaceStreamMessage{ + Type: WorkspaceMessageTypeProcessStatus, + Timestamp: timeNowUnixMilli(), + ProcessStatus: status, + } +} + // NewWorkspacePong creates a pong message func NewWorkspacePong() WorkspaceStreamMessage { return WorkspaceStreamMessage{ diff --git a/apps/backend/internal/events/types.go b/apps/backend/internal/events/types.go index d82af5ed73..0a0498c2df 100644 --- a/apps/backend/internal/events/types.go +++ b/apps/backend/internal/events/types.go @@ -116,6 +116,12 @@ const ( ShellExit = "shell.exit" // Shell process exited ) +// Event types for dev server I/O +const ( + ProcessOutput = "process.output" // Process output data + ProcessStatus = "process.status" // Process status updates +) + // Event types for context window const ( ContextWindowUpdated = "context_window.updated" // Context window usage updated @@ -141,6 +147,26 @@ func BuildShellExitWildcardSubject() string { return ShellExit + ".*" } +// BuildProcessOutputSubject creates a process output subject for a specific session +func BuildProcessOutputSubject(sessionID string) string { + return ProcessOutput + "." + sessionID +} + +// BuildProcessOutputWildcardSubject creates a wildcard subject for all process output events +func BuildProcessOutputWildcardSubject() string { + return ProcessOutput + ".*" +} + +// BuildProcessStatusSubject creates a process status subject for a specific session +func BuildProcessStatusSubject(sessionID string) string { + return ProcessStatus + "." + sessionID +} + +// BuildProcessStatusWildcardSubject creates a wildcard subject for all process status events +func BuildProcessStatusWildcardSubject() string { + return ProcessStatus + ".*" +} + // BuildAgentStreamSubject creates an agent stream subject for a specific session func BuildAgentStreamSubject(sessionID string) string { return AgentStream + "." + sessionID diff --git a/apps/backend/internal/gateway/websocket/session_notifications.go b/apps/backend/internal/gateway/websocket/session_notifications.go new file mode 100644 index 0000000000..6bc99c9c8f --- /dev/null +++ b/apps/backend/internal/gateway/websocket/session_notifications.go @@ -0,0 +1,86 @@ +package websocket + +import ( + "context" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" + ws "github.com/kandev/kandev/pkg/websocket" + "go.uber.org/zap" +) + +type SessionStreamBroadcaster struct { + hub *Hub + subscriptions []bus.Subscription + logger *logger.Logger +} + +func RegisterSessionStreamNotifications(ctx context.Context, eventBus bus.EventBus, hub *Hub, log *logger.Logger) *SessionStreamBroadcaster { + b := &SessionStreamBroadcaster{ + hub: hub, + logger: log.WithFields(zap.String("component", "ws-session-stream-broadcaster")), + } + if eventBus == nil { + return b + } + + b.subscribe(eventBus, events.BuildGitStatusWildcardSubject(), ws.ActionSessionGitStatus) + b.subscribe(eventBus, events.BuildFileChangeWildcardSubject(), ws.ActionWorkspaceFileChanges) + b.subscribe(eventBus, events.BuildShellOutputWildcardSubject(), ws.ActionSessionShellOutput) + b.subscribe(eventBus, events.BuildShellExitWildcardSubject(), ws.ActionSessionShellOutput) + b.subscribe(eventBus, events.BuildProcessOutputWildcardSubject(), ws.ActionSessionProcessOutput) + b.subscribe(eventBus, events.BuildProcessStatusWildcardSubject(), ws.ActionSessionProcessStatus) + + go func() { + <-ctx.Done() + b.Close() + }() + + return b +} + +func (b *SessionStreamBroadcaster) Close() { + for _, sub := range b.subscriptions { + if sub != nil && sub.IsValid() { + _ = sub.Unsubscribe() + } + } + b.subscriptions = nil +} + +func (b *SessionStreamBroadcaster) subscribe(eventBus bus.EventBus, subject, action string) { + sub, err := eventBus.Subscribe(subject, func(ctx context.Context, event *bus.Event) error { + sessionID := extractSessionID(event.Data) + if sessionID == "" { + return nil + } + msg, err := ws.NewNotification(action, event.Data) + if err != nil { + b.logger.Error("failed to build websocket notification", zap.String("action", action), zap.Error(err)) + return nil + } + b.hub.BroadcastToSession(sessionID, msg) + return nil + }) + if err != nil { + b.logger.Error("failed to subscribe to events", zap.String("subject", subject), zap.Error(err)) + return + } + b.subscriptions = append(b.subscriptions, sub) +} + +func extractSessionID(data interface{}) string { + if data == nil { + return "" + } + if typed, ok := data.(interface{ GetSessionID() string }); ok { + return typed.GetSessionID() + } + if m, ok := data.(map[string]interface{}); ok { + if sessionID, ok := m["session_id"].(string); ok { + return sessionID + } + } + return "" +} diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index 21d0b1eb44..127ce6c5db 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -76,7 +76,7 @@ type Service struct { queue *queue.TaskQueue executor *executor.Executor scheduler *scheduler.Scheduler - watcher *watcher.Watcher + watcher *watcher.Watcher // Message creator for saving agent responses messageCreator MessageCreator @@ -131,15 +131,15 @@ func NewService( // Create the service (watcher will be created after we have handlers) s := &Service{ - config: cfg, - logger: svcLogger, - eventBus: eventBus, - taskRepo: taskRepo, - repo: repo, - agentManager: agentManager, - queue: taskQueue, - executor: exec, - scheduler: sched, + config: cfg, + logger: svcLogger, + eventBus: eventBus, + taskRepo: taskRepo, + repo: repo, + agentManager: agentManager, + queue: taskQueue, + executor: exec, + scheduler: sched, } // Create the watcher with event handlers that wire everything together @@ -355,26 +355,13 @@ func (s *Service) resumeExecutorsOnStartup(ctx context.Context) { continue } - // For sessions that were STARTING, RUNNING, or WAITING_FOR_INPUT: - // We need a resume token to recover them - if running.ResumeToken == "" { - s.logger.Warn("no resume token available; marking session as failed", + startAgent := running.ResumeToken != "" && running.Resumable + if !startAgent { + s.logger.Warn("resuming workspace without agent session", zap.String("session_id", sessionID), - zap.String("previous_state", string(previousState))) - _ = s.repo.UpdateTaskSessionState(ctx, sessionID, models.TaskSessionStateFailed, - "backend restarted and session cannot be resumed (no resume token)") - _ = s.repo.DeleteExecutorRunningBySessionID(ctx, sessionID) - continue - } - - // Check if executor supports resume - if !running.Resumable { - s.logger.Warn("executor not resumable; marking session as failed", - zap.String("session_id", sessionID)) - _ = s.repo.UpdateTaskSessionState(ctx, sessionID, models.TaskSessionStateFailed, - "executor does not support session resume") - _ = s.repo.DeleteExecutorRunningBySessionID(ctx, sessionID) - continue + zap.String("previous_state", string(previousState)), + zap.Bool("has_resume_token", running.ResumeToken != ""), + zap.Bool("resumable", running.Resumable)) } // Validate worktree exists before resuming @@ -391,7 +378,8 @@ func (s *Service) resumeExecutorsOnStartup(ctx context.Context) { zap.String("session_id", sessionID), zap.String("task_id", running.TaskID), zap.String("previous_state", string(previousState)), - zap.String("resume_token", running.ResumeToken)) + zap.String("resume_token", running.ResumeToken), + zap.Bool("start_agent", startAgent)) // Resume the session - this will: // 1. Create a new agentctl instance @@ -404,7 +392,7 @@ func (s *Service) resumeExecutorsOnStartup(ctx context.Context) { // - Session resume just loads conversation history, doesn't process a prompt // - Agents don't send a "complete" event after session load (only after prompt completion) // - The agent is ready for the next user prompt immediately after session loads - if _, err := s.executor.ResumeSession(ctx, session, true); err != nil { + if _, err := s.executor.ResumeSession(ctx, session, startAgent); err != nil { s.logger.Warn("failed to resume executor on startup", zap.String("session_id", sessionID), zap.Error(err)) @@ -413,16 +401,19 @@ func (s *Service) resumeExecutorsOnStartup(ctx context.Context) { continue } - // Set state to WAITING_FOR_INPUT - agent is ready for the next prompt - if err := s.repo.UpdateTaskSessionState(ctx, sessionID, models.TaskSessionStateWaitingForInput, ""); err != nil { - s.logger.Warn("failed to set session state to WAITING_FOR_INPUT after resume", - zap.String("session_id", sessionID), - zap.Error(err)) + if startAgent { + // Set state to WAITING_FOR_INPUT - agent is ready for the next prompt + if err := s.repo.UpdateTaskSessionState(ctx, sessionID, models.TaskSessionStateWaitingForInput, ""); err != nil { + s.logger.Warn("failed to set session state to WAITING_FOR_INPUT after resume", + zap.String("session_id", sessionID), + zap.Error(err)) + } } s.logger.Info("session resumed successfully", zap.String("session_id", sessionID), zap.String("task_id", running.TaskID), + zap.Bool("start_agent", startAgent), zap.String("state", string(models.TaskSessionStateWaitingForInput))) } } @@ -458,5 +449,3 @@ func (s *Service) GetStatus() *Status { LastHeartbeat: time.Now(), } } - - diff --git a/apps/backend/internal/task/controller/repository.go b/apps/backend/internal/task/controller/repository.go index c8346521d1..81bbbfe065 100644 --- a/apps/backend/internal/task/controller/repository.go +++ b/apps/backend/internal/task/controller/repository.go @@ -53,6 +53,7 @@ func (c *RepositoryController) CreateRepository(ctx context.Context, req dto.Cre WorktreeBranchPrefix: req.WorktreeBranchPrefix, SetupScript: req.SetupScript, CleanupScript: req.CleanupScript, + DevScript: req.DevScript, }) if err != nil { return dto.RepositoryDTO{}, err @@ -73,6 +74,7 @@ func (c *RepositoryController) UpdateRepository(ctx context.Context, req dto.Upd WorktreeBranchPrefix: req.WorktreeBranchPrefix, SetupScript: req.SetupScript, CleanupScript: req.CleanupScript, + DevScript: req.DevScript, }) if err != nil { return dto.RepositoryDTO{}, err diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index 80cbb2a9e8..5192ea1c62 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -54,6 +54,7 @@ type RepositoryDTO struct { WorktreeBranchPrefix string `json:"worktree_branch_prefix"` SetupScript string `json:"setup_script"` CleanupScript string `json:"cleanup_script"` + DevScript string `json:"dev_script"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } @@ -307,6 +308,7 @@ func FromRepository(repository *models.Repository) RepositoryDTO { WorktreeBranchPrefix: repository.WorktreeBranchPrefix, SetupScript: repository.SetupScript, CleanupScript: repository.CleanupScript, + DevScript: repository.DevScript, CreatedAt: repository.CreatedAt, UpdatedAt: repository.UpdatedAt, } diff --git a/apps/backend/internal/task/dto/requests.go b/apps/backend/internal/task/dto/requests.go index 1071b0d709..027d3f96d5 100644 --- a/apps/backend/internal/task/dto/requests.go +++ b/apps/backend/internal/task/dto/requests.go @@ -102,6 +102,7 @@ type CreateRepositoryRequest struct { WorktreeBranchPrefix string SetupScript string CleanupScript string + DevScript string } type UpdateRepositoryRequest struct { @@ -117,6 +118,7 @@ type UpdateRepositoryRequest struct { WorktreeBranchPrefix *string SetupScript *string CleanupScript *string + DevScript *string } type DeleteRepositoryRequest struct { diff --git a/apps/backend/internal/task/handlers/process_handlers.go b/apps/backend/internal/task/handlers/process_handlers.go new file mode 100644 index 0000000000..4991c8bde0 --- /dev/null +++ b/apps/backend/internal/task/handlers/process_handlers.go @@ -0,0 +1,456 @@ +package handlers + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/agent/lifecycle" + agentctlclient "github.com/kandev/kandev/internal/agentctl/client" + agentctltypes "github.com/kandev/kandev/internal/agentctl/types" + "github.com/kandev/kandev/internal/agentctl/types/streams" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/service" +) + +type ProcessHandlers struct { + service *service.Service + lifecycleMgr *lifecycle.Manager + logger *logger.Logger +} + +func RegisterProcessRoutes( + router *gin.Engine, + svc *service.Service, + lifecycleMgr *lifecycle.Manager, + log *logger.Logger, +) { + handlers := &ProcessHandlers{ + service: svc, + lifecycleMgr: lifecycleMgr, + logger: log.WithFields(zap.String("component", "task-process-handlers")), + } + api := router.Group("/api/v1") + processes := api.Group("/task-sessions/:id/processes") + processes.Use(func(c *gin.Context) { + start := time.Now() + c.Next() + handlers.logger.Debug("process route", + zap.String("method", c.Request.Method), + zap.String("path", c.Request.URL.Path), + zap.String("route", c.FullPath()), + zap.Int("status", c.Writer.Status()), + zap.Duration("duration", time.Since(start)), + ) + }) + processes.POST("/start", handlers.httpStartProcess) + processes.POST("/:processId/stop", handlers.httpStopProcessByID) + processes.GET("", handlers.httpListProcesses) + processes.GET("/:processId", handlers.httpGetProcess) +} + +type httpStartProcessRequest struct { + Kind string `json:"kind"` + ScriptName string `json:"script_name,omitempty"` + RepositoryID string `json:"repo_id,omitempty"` +} + +func (h *ProcessHandlers) httpStartProcess(c *gin.Context) { + sessionID := c.Param("id") + if sessionID == "" { + h.logger.Warn("start process missing session id") + c.JSON(http.StatusBadRequest, gin.H{"error": "session_id is required"}) + return + } + + var body httpStartProcessRequest + if err := c.ShouldBindJSON(&body); err != nil { + h.logger.Warn("start process invalid request body", zap.String("session_id", sessionID), zap.Error(err)) + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + h.logger.Debug("start process request", + zap.String("session_id", sessionID), + zap.String("kind", body.Kind), + zap.String("script_name", body.ScriptName), + zap.String("repo_id", body.RepositoryID), + ) + + session, err := h.service.GetTaskSession(c.Request.Context(), sessionID) + if err != nil { + h.logger.Warn("start process session not found", zap.String("session_id", sessionID), zap.Error(err)) + handleNotFound(c, h.logger, err, "task session not found") + return + } + + executorID := session.ExecutorID + // TODO: fix session, should always have executorID + if executorID == "" { + if task, err := h.service.GetTask(c.Request.Context(), session.TaskID); err == nil { + if workspace, err := h.service.GetWorkspace(c.Request.Context(), task.WorkspaceID); err == nil { + if workspace.DefaultExecutorID != nil { + executorID = *workspace.DefaultExecutorID + } + } + } + if executorID == "" { + executorID = models.ExecutorIDLocalPC + } + } + executor, err := h.service.GetExecutor(c.Request.Context(), executorID) + if err != nil { + h.logger.Warn("start process executor not found", + zap.String("session_id", sessionID), + zap.String("executor_id", executorID), + zap.Error(err), + ) + handleNotFound(c, h.logger, err, "executor not found") + return + } + + repoID := body.RepositoryID + if repoID == "" { + repoID = session.RepositoryID + } + if repoID == "" { + if task, err := h.service.GetTask(c.Request.Context(), session.TaskID); err == nil { + if len(task.Repositories) > 0 { + repoID = task.Repositories[0].RepositoryID + } + } + } + if repoID == "" { + h.logger.Warn("start process missing repository", + zap.String("session_id", sessionID), + zap.String("task_id", session.TaskID), + ) + c.JSON(http.StatusBadRequest, gin.H{"error": "session has no repository"}) + return + } + repo, err := h.service.GetRepository(c.Request.Context(), repoID) + if err != nil { + h.logger.Warn("start process repository not found", + zap.String("session_id", sessionID), + zap.String("repo_id", repoID), + zap.Error(err), + ) + handleNotFound(c, h.logger, err, "repository not found") + return + } + + command, kind, scriptName, err := resolveScriptCommand(c.Request.Context(), h.service, repo, body.Kind, body.ScriptName) + if err != nil { + h.logger.Warn("start process script resolution failed", + zap.String("session_id", sessionID), + zap.String("repo_id", repoID), + zap.String("kind", body.Kind), + zap.String("script_name", body.ScriptName), + zap.Error(err), + ) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + workingDir := repo.LocalPath + if len(session.Worktrees) > 0 && session.Worktrees[0].WorktreePath != "" { + workingDir = session.Worktrees[0].WorktreePath + } + h.logger.Info("start process resolved command", + zap.String("session_id", sessionID), + zap.String("repo_id", repoID), + zap.String("kind", kind), + zap.String("script_name", scriptName), + zap.String("working_dir", workingDir), + zap.String("command", command), + ) + + if _, err := h.lifecycleMgr.EnsureWorkspaceExecutionForSession( + c.Request.Context(), + session.TaskID, + session.ID, + ); err != nil { + h.logger.Error("failed to ensure workspace execution for process", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + readyCtx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) + defer cancel() + if err := h.lifecycleMgr.WaitForAgentctlReadyForSession(readyCtx, session.ID); err != nil { + h.logger.Warn("agentctl not ready for process start", + zap.String("session_id", sessionID), + zap.Error(err), + ) + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "agentctl not ready"}) + return + } + + switch executor.Type { + case models.ExecutorTypeLocalPC, models.ExecutorTypeLocalDocker: + if streams.ProcessKind(kind) == streams.ProcessKindDev { + if existing, err := h.lifecycleMgr.ListProcesses(c.Request.Context(), sessionID); err == nil { + for _, proc := range existing { + if proc.Kind != streams.ProcessKindDev { + continue + } + if proc.Status == agentctltypes.ProcessStatusRunning || proc.Status == agentctltypes.ProcessStatusStarting { + c.JSON(http.StatusOK, gin.H{"process": proc}) + return + } + } + } else { + h.logger.Warn("failed to list processes for dev check", + zap.String("session_id", sessionID), + zap.Error(err), + ) + } + } + proc, err := h.lifecycleMgr.StartProcess(c.Request.Context(), lifecycle.StartProcessRequest{ + SessionID: sessionID, + Kind: kind, + ScriptName: scriptName, + Command: command, + WorkingDir: workingDir, + }) + if err != nil { + h.logger.Error("failed to start process", + zap.String("session_id", sessionID), + zap.String("repo_id", repoID), + zap.String("kind", kind), + zap.String("script_name", scriptName), + zap.Error(err), + ) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + h.logger.Info("process started", + zap.String("session_id", sessionID), + zap.String("process_id", proc.ID), + zap.String("kind", kind), + zap.String("script_name", scriptName), + ) + c.JSON(http.StatusOK, gin.H{"process": proc}) + default: + h.logger.Warn("start process unsupported executor type", + zap.String("session_id", sessionID), + zap.String("executor_id", executorID), + zap.String("executor_type", string(executor.Type)), + ) + c.JSON(http.StatusBadRequest, gin.H{"error": "executor type not supported for process runner"}) + } +} + +func (h *ProcessHandlers) httpStopProcessByID(c *gin.Context) { + sessionID := c.Param("id") + processID := c.Param("processId") + if sessionID == "" || processID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "session_id and process_id are required"}) + return + } + h.logger.Debug("stop process request (path)", + zap.String("session_id", sessionID), + zap.String("process_id", processID), + ) + + session, err := h.service.GetTaskSession(c.Request.Context(), sessionID) + if err != nil { + handleNotFound(c, h.logger, err, "task session not found") + return + } + + executorID := session.ExecutorID + if executorID == "" { + if task, err := h.service.GetTask(c.Request.Context(), session.TaskID); err == nil { + if workspace, err := h.service.GetWorkspace(c.Request.Context(), task.WorkspaceID); err == nil { + if workspace.DefaultExecutorID != nil { + executorID = *workspace.DefaultExecutorID + } + } + } + if executorID == "" { + executorID = models.ExecutorIDLocalPC + } + } + executor, err := h.service.GetExecutor(c.Request.Context(), executorID) + if err != nil { + handleNotFound(c, h.logger, err, "executor not found") + return + } + + switch executor.Type { + case models.ExecutorTypeLocalPC, models.ExecutorTypeLocalDocker: + proc, err := h.lifecycleMgr.GetProcess(c.Request.Context(), processID, false) + if err != nil { + handleNotFound(c, h.logger, err, "process not found") + return + } + if proc.SessionID != sessionID { + handleNotFound(c, h.logger, fmt.Errorf("process not found"), "process not found") + return + } + if err := h.lifecycleMgr.StopProcessForSession(c.Request.Context(), sessionID, processID); err != nil { + h.logger.Warn("failed to stop process (path)", + zap.String("session_id", sessionID), + zap.String("process_id", processID), + zap.Error(err), + ) + } + c.Status(http.StatusNoContent) + default: + c.Status(http.StatusNoContent) + } +} + +func (h *ProcessHandlers) httpListProcesses(c *gin.Context) { + sessionID := c.Param("id") + if sessionID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "session_id is required"}) + return + } + session, err := h.service.GetTaskSession(c.Request.Context(), sessionID) + if err != nil { + handleNotFound(c, h.logger, err, "task session not found") + return + } + + executorID := session.ExecutorID + // TODO: fix session, should always have executorID + if executorID == "" { + if task, err := h.service.GetTask(c.Request.Context(), session.TaskID); err == nil { + if workspace, err := h.service.GetWorkspace(c.Request.Context(), task.WorkspaceID); err == nil { + if workspace.DefaultExecutorID != nil { + executorID = *workspace.DefaultExecutorID + } + } + } + if executorID == "" { + executorID = models.ExecutorIDLocalPC + } + } + executor, err := h.service.GetExecutor(c.Request.Context(), executorID) + if err != nil { + handleNotFound(c, h.logger, err, "executor not found") + return + } + switch executor.Type { + case models.ExecutorTypeLocalPC, models.ExecutorTypeLocalDocker: + listCtx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second) + defer cancel() + procs, err := h.lifecycleMgr.ListProcesses(listCtx, sessionID) + if err != nil { + var netErr net.Error + if errors.Is(err, context.DeadlineExceeded) || errors.As(err, &netErr) || strings.Contains(err.Error(), "connection refused") { + h.logger.Warn("process list unavailable", zap.String("session_id", sessionID), zap.Error(err)) + c.JSON(http.StatusOK, []agentctlclient.ProcessInfo{}) + return + } + h.logger.Error("failed to list processes", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, procs) + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "executor type not supported for process runner"}) + } +} + +func (h *ProcessHandlers) httpGetProcess(c *gin.Context) { + sessionID := c.Param("id") + processID := c.Param("processId") + if sessionID == "" || processID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "session_id and process_id are required"}) + return + } + session, err := h.service.GetTaskSession(c.Request.Context(), sessionID) + if err != nil { + handleNotFound(c, h.logger, err, "task session not found") + return + } + + executorID := session.ExecutorID + // TODO: fix session, should always have executorID + if executorID == "" { + if task, err := h.service.GetTask(c.Request.Context(), session.TaskID); err == nil { + if workspace, err := h.service.GetWorkspace(c.Request.Context(), task.WorkspaceID); err == nil { + if workspace.DefaultExecutorID != nil { + executorID = *workspace.DefaultExecutorID + } + } + } + if executorID == "" { + executorID = models.ExecutorIDLocalPC + } + } + executor, err := h.service.GetExecutor(c.Request.Context(), executorID) + if err != nil { + handleNotFound(c, h.logger, err, "executor not found") + return + } + includeOutput := c.Query("include_output") == "true" + switch executor.Type { + case models.ExecutorTypeLocalPC, models.ExecutorTypeLocalDocker: + proc, err := h.lifecycleMgr.GetProcess(c.Request.Context(), processID, includeOutput) + if err != nil { + handleNotFound(c, h.logger, err, "process not found") + return + } + if proc.SessionID != sessionID { + handleNotFound(c, h.logger, fmt.Errorf("process not found"), "process not found") + return + } + c.JSON(http.StatusOK, proc) + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "executor type not supported for process runner"}) + } +} + +func resolveScriptCommand( + ctx context.Context, + svc *service.Service, + repo *models.Repository, + kind string, + scriptName string, +) (string, string, string, error) { + switch strings.ToLower(kind) { + case "setup": + if strings.TrimSpace(repo.SetupScript) == "" { + return "", "", "", fmt.Errorf("setup script not configured") + } + return repo.SetupScript, "setup", "", nil + case "cleanup": + if strings.TrimSpace(repo.CleanupScript) == "" { + return "", "", "", fmt.Errorf("cleanup script not configured") + } + return repo.CleanupScript, "cleanup", "", nil + case "dev": + if strings.TrimSpace(repo.DevScript) == "" { + return "", "", "", fmt.Errorf("dev script not configured") + } + return repo.DevScript, "dev", "", nil + case "custom": + if strings.TrimSpace(scriptName) == "" { + return "", "", "", fmt.Errorf("script_name is required for custom scripts") + } + scripts, err := svc.ListRepositoryScripts(ctx, repo.ID) + if err != nil { + return "", "", "", err + } + for _, script := range scripts { + if script.Name == scriptName { + if strings.TrimSpace(script.Command) == "" { + return "", "", "", fmt.Errorf("script command is empty") + } + return script.Command, "custom", script.Name, nil + } + } + return "", "", "", fmt.Errorf("custom script not found") + default: + return "", "", "", fmt.Errorf("invalid script kind") + } +} diff --git a/apps/backend/internal/task/handlers/process_handlers_test.go b/apps/backend/internal/task/handlers/process_handlers_test.go new file mode 100644 index 0000000000..f08768e2a8 --- /dev/null +++ b/apps/backend/internal/task/handlers/process_handlers_test.go @@ -0,0 +1,558 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "strconv" + "strings" + "sync/atomic" + "testing" + "unsafe" + + "github.com/gin-gonic/gin" + "github.com/kandev/kandev/internal/agent/lifecycle" + "github.com/kandev/kandev/internal/agent/registry" + agentctlclient "github.com/kandev/kandev/internal/agentctl/client" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository" + "github.com/kandev/kandev/internal/task/service" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +type mockRepository struct { + scriptsByRepo map[string][]*models.RepositoryScript + sessions map[string]*models.TaskSession + executors map[string]*models.Executor +} + +func (m *mockRepository) CreateWorkspace(ctx context.Context, workspace *models.Workspace) error { + return nil +} +func (m *mockRepository) GetWorkspace(ctx context.Context, id string) (*models.Workspace, error) { + return nil, nil +} +func (m *mockRepository) UpdateWorkspace(ctx context.Context, workspace *models.Workspace) error { + return nil +} +func (m *mockRepository) DeleteWorkspace(ctx context.Context, id string) error { + return nil +} +func (m *mockRepository) ListWorkspaces(ctx context.Context) ([]*models.Workspace, error) { + return nil, nil +} +func (m *mockRepository) CreateTask(ctx context.Context, task *models.Task) error { + return nil +} +func (m *mockRepository) GetTask(ctx context.Context, id string) (*models.Task, error) { + return nil, nil +} +func (m *mockRepository) UpdateTask(ctx context.Context, task *models.Task) error { + return nil +} +func (m *mockRepository) DeleteTask(ctx context.Context, id string) error { + return nil +} +func (m *mockRepository) ListTasks(ctx context.Context, boardID string) ([]*models.Task, error) { + return nil, nil +} +func (m *mockRepository) ListTasksByColumn(ctx context.Context, columnID string) ([]*models.Task, error) { + return nil, nil +} +func (m *mockRepository) UpdateTaskState(ctx context.Context, id string, state v1.TaskState) error { + return nil +} +func (m *mockRepository) AddTaskToBoard(ctx context.Context, taskID, boardID, columnID string, position int) error { + return nil +} +func (m *mockRepository) RemoveTaskFromBoard(ctx context.Context, taskID, boardID string) error { + return nil +} +func (m *mockRepository) CreateTaskRepository(ctx context.Context, taskRepo *models.TaskRepository) error { + return nil +} +func (m *mockRepository) GetTaskRepository(ctx context.Context, id string) (*models.TaskRepository, error) { + return nil, nil +} +func (m *mockRepository) ListTaskRepositories(ctx context.Context, taskID string) ([]*models.TaskRepository, error) { + return nil, nil +} +func (m *mockRepository) UpdateTaskRepository(ctx context.Context, taskRepo *models.TaskRepository) error { + return nil +} +func (m *mockRepository) DeleteTaskRepository(ctx context.Context, id string) error { + return nil +} +func (m *mockRepository) DeleteTaskRepositoriesByTask(ctx context.Context, taskID string) error { + return nil +} +func (m *mockRepository) GetPrimaryTaskRepository(ctx context.Context, taskID string) (*models.TaskRepository, error) { + return nil, nil +} +func (m *mockRepository) CreateBoard(ctx context.Context, board *models.Board) error { + return nil +} +func (m *mockRepository) GetBoard(ctx context.Context, id string) (*models.Board, error) { + return nil, nil +} +func (m *mockRepository) UpdateBoard(ctx context.Context, board *models.Board) error { + return nil +} +func (m *mockRepository) DeleteBoard(ctx context.Context, id string) error { + return nil +} +func (m *mockRepository) ListBoards(ctx context.Context, workspaceID string) ([]*models.Board, error) { + return nil, nil +} +func (m *mockRepository) CreateColumn(ctx context.Context, column *models.Column) error { + return nil +} +func (m *mockRepository) GetColumn(ctx context.Context, id string) (*models.Column, error) { + return nil, nil +} +func (m *mockRepository) GetColumnByState(ctx context.Context, boardID string, state v1.TaskState) (*models.Column, error) { + return nil, nil +} +func (m *mockRepository) UpdateColumn(ctx context.Context, column *models.Column) error { + return nil +} +func (m *mockRepository) DeleteColumn(ctx context.Context, id string) error { + return nil +} +func (m *mockRepository) ListColumns(ctx context.Context, boardID string) ([]*models.Column, error) { + return nil, nil +} +func (m *mockRepository) CreateMessage(ctx context.Context, message *models.Message) error { + return nil +} +func (m *mockRepository) GetMessage(ctx context.Context, id string) (*models.Message, error) { + return nil, nil +} +func (m *mockRepository) GetMessageByToolCallID(ctx context.Context, sessionID, toolCallID string) (*models.Message, error) { + return nil, nil +} +func (m *mockRepository) GetMessageByPendingID(ctx context.Context, sessionID, pendingID string) (*models.Message, error) { + return nil, nil +} +func (m *mockRepository) UpdateMessage(ctx context.Context, message *models.Message) error { + return nil +} +func (m *mockRepository) ListMessages(ctx context.Context, sessionID string) ([]*models.Message, error) { + return nil, nil +} +func (m *mockRepository) ListMessagesPaginated(ctx context.Context, sessionID string, opts repository.ListMessagesOptions) ([]*models.Message, bool, error) { + return nil, false, nil +} +func (m *mockRepository) DeleteMessage(ctx context.Context, id string) error { + return nil +} +func (m *mockRepository) CreateTurn(ctx context.Context, turn *models.Turn) error { + return nil +} +func (m *mockRepository) GetTurn(ctx context.Context, id string) (*models.Turn, error) { + return nil, nil +} +func (m *mockRepository) GetActiveTurnBySessionID(ctx context.Context, sessionID string) (*models.Turn, error) { + return nil, nil +} +func (m *mockRepository) UpdateTurn(ctx context.Context, turn *models.Turn) error { + return nil +} +func (m *mockRepository) CompleteTurn(ctx context.Context, id string) error { + return nil +} +func (m *mockRepository) ListTurnsBySession(ctx context.Context, sessionID string) ([]*models.Turn, error) { + return nil, nil +} +func (m *mockRepository) CreateTaskSession(ctx context.Context, session *models.TaskSession) error { + return nil +} +func (m *mockRepository) GetTaskSession(ctx context.Context, id string) (*models.TaskSession, error) { + if m.sessions != nil { + if session, ok := m.sessions[id]; ok { + return session, nil + } + } + return nil, fmt.Errorf("task session not found: %s", id) +} +func (m *mockRepository) GetTaskSessionByTaskID(ctx context.Context, taskID string) (*models.TaskSession, error) { + return nil, nil +} +func (m *mockRepository) GetActiveTaskSessionByTaskID(ctx context.Context, taskID string) (*models.TaskSession, error) { + return nil, nil +} +func (m *mockRepository) UpdateTaskSession(ctx context.Context, session *models.TaskSession) error { + return nil +} +func (m *mockRepository) UpdateTaskSessionState(ctx context.Context, id string, state models.TaskSessionState, errorMessage string) error { + return nil +} +func (m *mockRepository) ListTaskSessions(ctx context.Context, taskID string) ([]*models.TaskSession, error) { + return nil, nil +} +func (m *mockRepository) ListActiveTaskSessions(ctx context.Context) ([]*models.TaskSession, error) { + return nil, nil +} +func (m *mockRepository) ListActiveTaskSessionsByTaskID(ctx context.Context, taskID string) ([]*models.TaskSession, error) { + return nil, nil +} +func (m *mockRepository) HasActiveTaskSessionsByAgentProfile(ctx context.Context, agentProfileID string) (bool, error) { + return false, nil +} +func (m *mockRepository) HasActiveTaskSessionsByExecutor(ctx context.Context, executorID string) (bool, error) { + return false, nil +} +func (m *mockRepository) HasActiveTaskSessionsByEnvironment(ctx context.Context, environmentID string) (bool, error) { + return false, nil +} +func (m *mockRepository) HasActiveTaskSessionsByRepository(ctx context.Context, repositoryID string) (bool, error) { + return false, nil +} +func (m *mockRepository) DeleteTaskSession(ctx context.Context, id string) error { + return nil +} +func (m *mockRepository) CreateTaskSessionWorktree(ctx context.Context, sessionWorktree *models.TaskSessionWorktree) error { + return nil +} +func (m *mockRepository) ListTaskSessionWorktrees(ctx context.Context, sessionID string) ([]*models.TaskSessionWorktree, error) { + return nil, nil +} +func (m *mockRepository) DeleteTaskSessionWorktree(ctx context.Context, id string) error { + return nil +} +func (m *mockRepository) DeleteTaskSessionWorktreesBySession(ctx context.Context, sessionID string) error { + return nil +} +func (m *mockRepository) CreateRepository(ctx context.Context, repository *models.Repository) error { + return nil +} +func (m *mockRepository) GetRepository(ctx context.Context, id string) (*models.Repository, error) { + return nil, nil +} +func (m *mockRepository) UpdateRepository(ctx context.Context, repository *models.Repository) error { + return nil +} +func (m *mockRepository) DeleteRepository(ctx context.Context, id string) error { + return nil +} +func (m *mockRepository) ListRepositories(ctx context.Context, workspaceID string) ([]*models.Repository, error) { + return nil, nil +} +func (m *mockRepository) CreateRepositoryScript(ctx context.Context, script *models.RepositoryScript) error { + return nil +} +func (m *mockRepository) GetRepositoryScript(ctx context.Context, id string) (*models.RepositoryScript, error) { + return nil, nil +} +func (m *mockRepository) UpdateRepositoryScript(ctx context.Context, script *models.RepositoryScript) error { + return nil +} +func (m *mockRepository) DeleteRepositoryScript(ctx context.Context, id string) error { + return nil +} +func (m *mockRepository) ListRepositoryScripts(ctx context.Context, repositoryID string) ([]*models.RepositoryScript, error) { + if m.scriptsByRepo == nil { + return nil, nil + } + return m.scriptsByRepo[repositoryID], nil +} +func (m *mockRepository) CreateExecutor(ctx context.Context, executor *models.Executor) error { + return nil +} +func (m *mockRepository) GetExecutor(ctx context.Context, id string) (*models.Executor, error) { + if m.executors != nil { + if executor, ok := m.executors[id]; ok { + return executor, nil + } + } + return nil, fmt.Errorf("executor not found: %s", id) +} +func (m *mockRepository) UpdateExecutor(ctx context.Context, executor *models.Executor) error { + return nil +} +func (m *mockRepository) DeleteExecutor(ctx context.Context, id string) error { + return nil +} +func (m *mockRepository) ListExecutors(ctx context.Context) ([]*models.Executor, error) { + return nil, nil +} +func (m *mockRepository) ListExecutorsRunning(ctx context.Context) ([]*models.ExecutorRunning, error) { + return nil, nil +} +func (m *mockRepository) UpsertExecutorRunning(ctx context.Context, running *models.ExecutorRunning) error { + return nil +} +func (m *mockRepository) GetExecutorRunningBySessionID(ctx context.Context, sessionID string) (*models.ExecutorRunning, error) { + return nil, nil +} +func (m *mockRepository) DeleteExecutorRunningBySessionID(ctx context.Context, sessionID string) error { + return nil +} +func (m *mockRepository) CreateEnvironment(ctx context.Context, environment *models.Environment) error { + return nil +} +func (m *mockRepository) GetEnvironment(ctx context.Context, id string) (*models.Environment, error) { + return nil, nil +} +func (m *mockRepository) UpdateEnvironment(ctx context.Context, environment *models.Environment) error { + return nil +} +func (m *mockRepository) DeleteEnvironment(ctx context.Context, id string) error { + return nil +} +func (m *mockRepository) ListEnvironments(ctx context.Context) ([]*models.Environment, error) { + return nil, nil +} +func (m *mockRepository) Close() error { + return nil +} + +func newTestService(t *testing.T, scripts map[string][]*models.RepositoryScript) *service.Service { + log, err := logger.NewLogger(logger.LoggingConfig{ + Level: "error", + Format: "json", + }) + if err != nil { + t.Fatalf("failed to create logger: %v", err) + } + repo := &mockRepository{scriptsByRepo: scripts} + return service.NewService(repo, nil, log, service.RepositoryDiscoveryConfig{}) +} + +func addExecution(t *testing.T, mgr *lifecycle.Manager, exec *lifecycle.AgentExecution) { + t.Helper() + store := getExecutionStore(t, mgr) + store.Add(exec) +} + +func getExecutionStore(t *testing.T, mgr *lifecycle.Manager) *lifecycle.ExecutionStore { + t.Helper() + value := reflect.ValueOf(mgr).Elem().FieldByName("executionStore") + if !value.IsValid() { + t.Fatal("executionStore field not found on lifecycle manager") + } + value = reflect.NewAt(value.Type(), unsafe.Pointer(value.UnsafeAddr())).Elem() + store, ok := value.Interface().(*lifecycle.ExecutionStore) + if !ok { + t.Fatal("executionStore is not *lifecycle.ExecutionStore") + } + return store +} + +func newLifecycleManager(t *testing.T, log *logger.Logger) *lifecycle.Manager { + t.Helper() + reg := registry.NewRegistry(log) + return lifecycle.NewManager(reg, nil, nil, nil, nil, nil, nil, log) +} + +func newAgentctlClient(t *testing.T, serverURL string, log *logger.Logger) *agentctlclient.Client { + t.Helper() + parsed, err := url.Parse(serverURL) + if err != nil { + t.Fatalf("failed to parse server url: %v", err) + } + port, err := strconv.Atoi(parsed.Port()) + if err != nil { + t.Fatalf("failed to parse server port: %v", err) + } + return agentctlclient.NewClient(parsed.Hostname(), port, log) +} + +func newProcessStopRouter( + t *testing.T, + svc *service.Service, + lifecycleMgr *lifecycle.Manager, + log *logger.Logger, +) *gin.Engine { + t.Helper() + gin.SetMode(gin.TestMode) + router := gin.New() + RegisterProcessRoutes(router, svc, lifecycleMgr, log) + return router +} + +func TestResolveScriptCommandBuiltins(t *testing.T) { + svc := newTestService(t, nil) + repo := &models.Repository{ID: "repo-1", SetupScript: "echo setup", CleanupScript: "echo cleanup", DevScript: "echo dev"} + + cmd, kind, scriptName, err := resolveScriptCommand(context.Background(), svc, repo, "setup", "") + if err != nil || cmd != "echo setup" || kind != "setup" || scriptName != "" { + t.Fatalf("unexpected setup result: cmd=%q kind=%q script=%q err=%v", cmd, kind, scriptName, err) + } + + cmd, kind, scriptName, err = resolveScriptCommand(context.Background(), svc, repo, "cleanup", "") + if err != nil || cmd != "echo cleanup" || kind != "cleanup" || scriptName != "" { + t.Fatalf("unexpected cleanup result: cmd=%q kind=%q script=%q err=%v", cmd, kind, scriptName, err) + } + + cmd, kind, scriptName, err = resolveScriptCommand(context.Background(), svc, repo, "dev", "") + if err != nil || cmd != "echo dev" || kind != "dev" || scriptName != "" { + t.Fatalf("unexpected dev result: cmd=%q kind=%q script=%q err=%v", cmd, kind, scriptName, err) + } +} + +func TestResolveScriptCommandCustom(t *testing.T) { + scripts := map[string][]*models.RepositoryScript{ + "repo-1": { + {ID: "s1", RepositoryID: "repo-1", Name: "build", Command: "make build"}, + }, + } + svc := newTestService(t, scripts) + repo := &models.Repository{ID: "repo-1"} + + cmd, kind, scriptName, err := resolveScriptCommand(context.Background(), svc, repo, "custom", "build") + if err != nil || cmd != "make build" || kind != "custom" || scriptName != "build" { + t.Fatalf("unexpected custom result: cmd=%q kind=%q script=%q err=%v", cmd, kind, scriptName, err) + } +} + +func TestResolveScriptCommandErrors(t *testing.T) { + svc := newTestService(t, nil) + repo := &models.Repository{ID: "repo-1"} + + if _, _, _, err := resolveScriptCommand(context.Background(), svc, repo, "setup", ""); err == nil { + t.Fatal("expected error for missing setup script") + } + if _, _, _, err := resolveScriptCommand(context.Background(), svc, repo, "cleanup", ""); err == nil { + t.Fatal("expected error for missing cleanup script") + } + if _, _, _, err := resolveScriptCommand(context.Background(), svc, repo, "dev", ""); err == nil { + t.Fatal("expected error for missing dev script") + } + if _, _, _, err := resolveScriptCommand(context.Background(), svc, repo, "custom", ""); err == nil { + t.Fatal("expected error for missing custom script name") + } + if _, _, _, err := resolveScriptCommand(context.Background(), svc, repo, "custom", "unknown"); err == nil { + t.Fatal("expected error for unknown custom script") + } +} + +func TestStopProcessRejectsDifferentSession(t *testing.T) { + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + if err != nil { + t.Fatalf("failed to create logger: %v", err) + } + stopCalled := atomic.Bool{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/processes/stop": + stopCalled.Store(true) + _, _ = w.Write([]byte(`{"success":true}`)) + return + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/processes/"): + resp := agentctlclient.ProcessInfo{ + ID: "proc-1", + SessionID: "session-other", + Kind: agentctlclient.ProcessKind("dev"), + Status: agentctlclient.ProcessStatus("running"), + } + data, _ := json.Marshal(resp) + _, _ = w.Write(data) + return + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + repo := &mockRepository{ + sessions: map[string]*models.TaskSession{ + "session-a": {ID: "session-a", TaskID: "task-1", ExecutorID: "exec-1"}, + }, + executors: map[string]*models.Executor{ + "exec-1": {ID: "exec-1", Type: models.ExecutorTypeLocalPC}, + }, + } + svc := service.NewService(repo, nil, log, service.RepositoryDiscoveryConfig{}) + lifecycleMgr := newLifecycleManager(t, log) + client := newAgentctlClient(t, server.URL, log) + exec := (&lifecycle.RuntimeInstance{ + InstanceID: "exec-1", + TaskID: "task-1", + SessionID: "session-a", + Client: client, + }).ToAgentExecution(&lifecycle.RuntimeCreateRequest{ + InstanceID: "exec-1", + TaskID: "task-1", + SessionID: "session-a", + AgentProfileID: "profile-1", + WorkspacePath: "/tmp", + }) + addExecution(t, lifecycleMgr, exec) + + router := newProcessStopRouter(t, svc, lifecycleMgr, log) + req := httptest.NewRequest(http.MethodPost, "/api/v1/task-sessions/session-a/processes/proc-1/stop", nil) + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusNotFound { + t.Fatalf("expected 404 for mismatched session, got %d", resp.Code) + } + if stopCalled.Load() { + t.Fatal("expected stop endpoint not to be called on session mismatch") + } +} + +func TestStopProcessAgentctlUnavailable(t *testing.T) { + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + if err != nil { + t.Fatalf("failed to create logger: %v", err) + } + stopCalled := atomic.Bool{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/processes/stop": + stopCalled.Store(true) + _, _ = w.Write([]byte(`{"success":true}`)) + return + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/processes/"): + w.WriteHeader(http.StatusInternalServerError) + return + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + repo := &mockRepository{ + sessions: map[string]*models.TaskSession{ + "session-a": {ID: "session-a", TaskID: "task-1", ExecutorID: "exec-1"}, + }, + executors: map[string]*models.Executor{ + "exec-1": {ID: "exec-1", Type: models.ExecutorTypeLocalPC}, + }, + } + svc := service.NewService(repo, nil, log, service.RepositoryDiscoveryConfig{}) + lifecycleMgr := newLifecycleManager(t, log) + client := newAgentctlClient(t, server.URL, log) + exec := (&lifecycle.RuntimeInstance{ + InstanceID: "exec-1", + TaskID: "task-1", + SessionID: "session-a", + Client: client, + }).ToAgentExecution(&lifecycle.RuntimeCreateRequest{ + InstanceID: "exec-1", + TaskID: "task-1", + SessionID: "session-a", + AgentProfileID: "profile-1", + WorkspacePath: "/tmp", + }) + addExecution(t, lifecycleMgr, exec) + + router := newProcessStopRouter(t, svc, lifecycleMgr, log) + req := httptest.NewRequest(http.MethodPost, "/api/v1/task-sessions/session-a/processes/proc-1/stop", nil) + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusNotFound { + t.Fatalf("expected 404 when agentctl is unavailable, got %d", resp.Code) + } + if stopCalled.Load() { + t.Fatal("expected stop endpoint not to be called when get process fails") + } +} diff --git a/apps/backend/internal/task/handlers/repository_handlers.go b/apps/backend/internal/task/handlers/repository_handlers.go index bb3d36ce04..b086a109de 100644 --- a/apps/backend/internal/task/handlers/repository_handlers.go +++ b/apps/backend/internal/task/handlers/repository_handlers.go @@ -146,6 +146,7 @@ type httpCreateRepositoryRequest struct { WorktreeBranchPrefix string `json:"worktree_branch_prefix"` SetupScript string `json:"setup_script"` CleanupScript string `json:"cleanup_script"` + DevScript string `json:"dev_script"` } func (h *RepositoryHandlers) httpCreateRepository(c *gin.Context) { @@ -171,6 +172,7 @@ func (h *RepositoryHandlers) httpCreateRepository(c *gin.Context) { WorktreeBranchPrefix: body.WorktreeBranchPrefix, SetupScript: body.SetupScript, CleanupScript: body.CleanupScript, + DevScript: body.DevScript, }) if err != nil { if errors.Is(err, service.ErrInvalidRepositorySettings) { @@ -219,6 +221,7 @@ type httpUpdateRepositoryRequest struct { WorktreeBranchPrefix *string `json:"worktree_branch_prefix"` SetupScript *string `json:"setup_script"` CleanupScript *string `json:"cleanup_script"` + DevScript *string `json:"dev_script"` } func (h *RepositoryHandlers) httpUpdateRepository(c *gin.Context) { @@ -240,6 +243,7 @@ func (h *RepositoryHandlers) httpUpdateRepository(c *gin.Context) { WorktreeBranchPrefix: body.WorktreeBranchPrefix, SetupScript: body.SetupScript, CleanupScript: body.CleanupScript, + DevScript: body.DevScript, }) if err != nil { if errors.Is(err, service.ErrInvalidRepositorySettings) { @@ -376,6 +380,7 @@ type wsCreateRepositoryRequest struct { WorktreeBranchPrefix string `json:"worktree_branch_prefix"` SetupScript string `json:"setup_script"` CleanupScript string `json:"cleanup_script"` + DevScript string `json:"dev_script"` } func (h *RepositoryHandlers) wsCreateRepository(ctx context.Context, msg *ws.Message) (*ws.Message, error) { @@ -399,6 +404,7 @@ func (h *RepositoryHandlers) wsCreateRepository(ctx context.Context, msg *ws.Mes WorktreeBranchPrefix: req.WorktreeBranchPrefix, SetupScript: req.SetupScript, CleanupScript: req.CleanupScript, + DevScript: req.DevScript, }) if err != nil { if errors.Is(err, service.ErrInvalidRepositorySettings) { @@ -442,6 +448,7 @@ type wsUpdateRepositoryRequest struct { WorktreeBranchPrefix *string `json:"worktree_branch_prefix,omitempty"` SetupScript *string `json:"setup_script,omitempty"` CleanupScript *string `json:"cleanup_script,omitempty"` + DevScript *string `json:"dev_script,omitempty"` } func (h *RepositoryHandlers) wsUpdateRepository(ctx context.Context, msg *ws.Message) (*ws.Message, error) { @@ -465,6 +472,7 @@ func (h *RepositoryHandlers) wsUpdateRepository(ctx context.Context, msg *ws.Mes WorktreeBranchPrefix: req.WorktreeBranchPrefix, SetupScript: req.SetupScript, CleanupScript: req.CleanupScript, + DevScript: req.DevScript, }) if err != nil { if errors.Is(err, service.ErrInvalidRepositorySettings) { diff --git a/apps/backend/internal/task/handlers/task_handlers.go b/apps/backend/internal/task/handlers/task_handlers.go index 11985961d4..f1308b99ec 100644 --- a/apps/backend/internal/task/handlers/task_handlers.go +++ b/apps/backend/internal/task/handlers/task_handlers.go @@ -3,6 +3,7 @@ package handlers import ( "context" "net/http" + "time" "github.com/gin-gonic/gin" @@ -163,7 +164,8 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) { }) } - resp, err := h.controller.CreateTask(c.Request.Context(), dto.CreateTaskRequest{ + reqCtx := c.Request.Context() + resp, err := h.controller.CreateTask(reqCtx, dto.CreateTaskRequest{ WorkspaceID: body.WorkspaceID, BoardID: body.BoardID, ColumnID: body.ColumnID, @@ -182,8 +184,10 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) { response := createTaskResponse{TaskDTO: resp} if body.StartAgent && body.AgentProfileID != "" && h.orchestrator != nil { + startCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() // Use task description as the initial prompt - execution, err := h.orchestrator.StartTask(c.Request.Context(), resp.ID, body.AgentProfileID, body.Priority, resp.Description) + execution, err := h.orchestrator.StartTask(startCtx, resp.ID, body.AgentProfileID, body.Priority, resp.Description) if err != nil { h.logger.Error("failed to start agent for task", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start agent for task"}) @@ -274,7 +278,9 @@ func (h *TaskHandlers) httpMoveTask(c *gin.Context) { } func (h *TaskHandlers) httpDeleteTask(c *gin.Context) { - resp, err := h.controller.DeleteTask(c.Request.Context(), dto.DeleteTaskRequest{ID: c.Param("id")}) + deleteCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + resp, err := h.controller.DeleteTask(deleteCtx, dto.DeleteTaskRequest{ID: c.Param("id")}) if err != nil { handleNotFound(c, h.logger, err, "task not deleted") return diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index 89f3dae6e5..4f43d5929b 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -277,6 +277,7 @@ type Repository struct { WorktreeBranchPrefix string `json:"worktree_branch_prefix"` SetupScript string `json:"setup_script"` CleanupScript string `json:"cleanup_script"` + DevScript string `json:"dev_script"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt *time.Time `json:"deleted_at,omitempty"` diff --git a/apps/backend/internal/task/repository/sqlite.go b/apps/backend/internal/task/repository/sqlite.go index 7226177837..3f03f34be3 100644 --- a/apps/backend/internal/task/repository/sqlite.go +++ b/apps/backend/internal/task/repository/sqlite.go @@ -171,6 +171,7 @@ func (r *sqliteRepository) initSchema() error { worktree_branch_prefix TEXT DEFAULT 'feature/', setup_script TEXT DEFAULT '', cleanup_script TEXT DEFAULT '', + dev_script TEXT DEFAULT '', created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, deleted_at DATETIME, @@ -315,6 +316,9 @@ func (r *sqliteRepository) initSchema() error { if err := r.ensureColumn("repositories", "worktree_branch_prefix", "TEXT DEFAULT 'feature/'"); err != nil { return err } + if err := r.ensureColumn("repositories", "dev_script", "TEXT DEFAULT ''"); err != nil { + return err + } if err := r.ensureColumn("executors", "deleted_at", "DATETIME"); err != nil { return err } @@ -1763,11 +1767,11 @@ func (r *sqliteRepository) CreateRepository(ctx context.Context, repository *mod _, err := r.db.ExecContext(ctx, ` INSERT INTO repositories ( id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_owner, - provider_name, default_branch, worktree_branch_prefix, setup_script, cleanup_script, created_at, updated_at, deleted_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + provider_name, default_branch, worktree_branch_prefix, setup_script, cleanup_script, dev_script, created_at, updated_at, deleted_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, repository.ID, repository.WorkspaceID, repository.Name, repository.SourceType, repository.LocalPath, repository.Provider, repository.ProviderRepoID, repository.ProviderOwner, repository.ProviderName, repository.DefaultBranch, repository.WorktreeBranchPrefix, - repository.SetupScript, repository.CleanupScript, repository.CreatedAt, repository.UpdatedAt, repository.DeletedAt) + repository.SetupScript, repository.CleanupScript, repository.DevScript, repository.CreatedAt, repository.UpdatedAt, repository.DeletedAt) return err } @@ -1778,12 +1782,12 @@ func (r *sqliteRepository) GetRepository(ctx context.Context, id string) (*model err := r.db.QueryRowContext(ctx, ` SELECT id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_owner, - provider_name, default_branch, worktree_branch_prefix, setup_script, cleanup_script, created_at, updated_at, deleted_at + provider_name, default_branch, worktree_branch_prefix, setup_script, cleanup_script, dev_script, created_at, updated_at, deleted_at FROM repositories WHERE id = ? AND deleted_at IS NULL `, id).Scan( &repository.ID, &repository.WorkspaceID, &repository.Name, &repository.SourceType, &repository.LocalPath, &repository.Provider, &repository.ProviderRepoID, &repository.ProviderOwner, &repository.ProviderName, - &repository.DefaultBranch, &repository.WorktreeBranchPrefix, &repository.SetupScript, &repository.CleanupScript, &repository.CreatedAt, &repository.UpdatedAt, &repository.DeletedAt, + &repository.DefaultBranch, &repository.WorktreeBranchPrefix, &repository.SetupScript, &repository.CleanupScript, &repository.DevScript, &repository.CreatedAt, &repository.UpdatedAt, &repository.DeletedAt, ) if err == sql.ErrNoRows { @@ -1799,11 +1803,11 @@ func (r *sqliteRepository) UpdateRepository(ctx context.Context, repository *mod result, err := r.db.ExecContext(ctx, ` UPDATE repositories SET name = ?, source_type = ?, local_path = ?, provider = ?, provider_repo_id = ?, provider_owner = ?, - provider_name = ?, default_branch = ?, worktree_branch_prefix = ?, setup_script = ?, cleanup_script = ?, updated_at = ? + provider_name = ?, default_branch = ?, worktree_branch_prefix = ?, setup_script = ?, cleanup_script = ?, dev_script = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL `, repository.Name, repository.SourceType, repository.LocalPath, repository.Provider, repository.ProviderRepoID, repository.ProviderOwner, repository.ProviderName, repository.DefaultBranch, repository.WorktreeBranchPrefix, repository.SetupScript, - repository.CleanupScript, repository.UpdatedAt, repository.ID) + repository.CleanupScript, repository.DevScript, repository.UpdatedAt, repository.ID) if err != nil { return err } @@ -1835,7 +1839,7 @@ func (r *sqliteRepository) DeleteRepository(ctx context.Context, id string) erro func (r *sqliteRepository) ListRepositories(ctx context.Context, workspaceID string) ([]*models.Repository, error) { rows, err := r.db.QueryContext(ctx, ` SELECT id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_owner, - provider_name, default_branch, worktree_branch_prefix, setup_script, cleanup_script, created_at, updated_at, deleted_at + provider_name, default_branch, worktree_branch_prefix, setup_script, cleanup_script, dev_script, created_at, updated_at, deleted_at FROM repositories WHERE workspace_id = ? AND deleted_at IS NULL ORDER BY created_at DESC `, workspaceID) if err != nil { @@ -1849,7 +1853,7 @@ func (r *sqliteRepository) ListRepositories(ctx context.Context, workspaceID str err := rows.Scan( &repository.ID, &repository.WorkspaceID, &repository.Name, &repository.SourceType, &repository.LocalPath, &repository.Provider, &repository.ProviderRepoID, &repository.ProviderOwner, &repository.ProviderName, - &repository.DefaultBranch, &repository.WorktreeBranchPrefix, &repository.SetupScript, &repository.CleanupScript, &repository.CreatedAt, &repository.UpdatedAt, &repository.DeletedAt, + &repository.DefaultBranch, &repository.WorktreeBranchPrefix, &repository.SetupScript, &repository.CleanupScript, &repository.DevScript, &repository.CreatedAt, &repository.UpdatedAt, &repository.DeletedAt, ) if err != nil { return nil, err diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index bc47c2d32c..4a61138b75 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -185,6 +185,7 @@ type CreateRepositoryRequest struct { WorktreeBranchPrefix string `json:"worktree_branch_prefix"` SetupScript string `json:"setup_script"` CleanupScript string `json:"cleanup_script"` + DevScript string `json:"dev_script"` } // UpdateRepositoryRequest contains the data for updating a repository @@ -200,6 +201,7 @@ type UpdateRepositoryRequest struct { WorktreeBranchPrefix *string `json:"worktree_branch_prefix,omitempty"` SetupScript *string `json:"setup_script,omitempty"` CleanupScript *string `json:"cleanup_script,omitempty"` + DevScript *string `json:"dev_script,omitempty"` } // CreateExecutorRequest contains the data for creating an executor @@ -470,55 +472,35 @@ func (s *Service) UpdateTask(ctx context.Context, id string, req *UpdateTaskRequ // DeleteTask deletes a task and publishes a task.deleted event func (s *Service) DeleteTask(ctx context.Context, id string) error { + start := time.Now() task, err := s.repo.GetTask(ctx, id) if err != nil { return err } - if s.executionStopper != nil { - if err := s.executionStopper.StopTask(ctx, id, "task deleted", true); err != nil { - s.logger.Warn("failed to stop task execution on delete", - zap.String("task_id", id), - zap.Error(err)) - } - } - - // Clean up worktree BEFORE deleting task (CASCADE would delete worktree DB records) - // This must happen first so we can find the worktree paths to remove from disk - if s.worktreeCleanup != nil { - if err := s.worktreeCleanup.OnTaskDeleted(ctx, id); err != nil { - s.logger.Warn("failed to cleanup worktree on task deletion", - zap.String("task_id", id), - zap.Error(err)) - } - } - sessions, err := s.repo.ListTaskSessions(ctx, id) if err != nil { s.logger.Warn("failed to list task sessions for delete", zap.String("task_id", id), zap.Error(err)) - } else { - for _, session := range sessions { - if session == nil || session.ID == "" { - continue - } - if err := s.repo.DeleteExecutorRunningBySessionID(ctx, session.ID); err != nil { - s.logger.Debug("failed to delete executor runtime for session", + } + + var worktrees []*worktree.Worktree + if s.worktreeCleanup != nil { + if provider, ok := s.worktreeCleanup.(interface { + GetAllByTaskID(context.Context, string) ([]*worktree.Worktree, error) + }); ok { + worktrees, err = provider.GetAllByTaskID(ctx, id) + if err != nil { + s.logger.Warn("failed to list worktrees for delete", zap.String("task_id", id), - zap.String("session_id", session.ID), zap.Error(err)) } - if err := s.repo.DeleteTaskSessionWorktreesBySession(ctx, session.ID); err != nil { - s.logger.Warn("failed to delete session worktrees", + } else { + // Fallback for legacy implementations: cleanup before delete. + if err := s.worktreeCleanup.OnTaskDeleted(ctx, id); err != nil { + s.logger.Warn("failed to cleanup worktree on task deletion", zap.String("task_id", id), - zap.String("session_id", session.ID), - zap.Error(err)) - } - if err := s.repo.DeleteTaskSession(ctx, session.ID); err != nil { - s.logger.Warn("failed to delete task session", - zap.String("task_id", id), - zap.String("session_id", session.ID), zap.Error(err)) } } @@ -530,7 +512,53 @@ func (s *Service) DeleteTask(ctx context.Context, id string) error { } s.publishTaskEvent(ctx, events.TaskDeleted, task, nil) - s.logger.Info("task deleted", zap.String("task_id", id)) + s.logger.Info("task deleted", + zap.String("task_id", id), + zap.Duration("duration", time.Since(start))) + + if s.executionStopper != nil || s.worktreeCleanup != nil || len(sessions) > 0 { + go func(taskID string, sessions []*models.TaskSession, worktrees []*worktree.Worktree) { + cleanupStart := time.Now() + cleanupCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + if s.executionStopper != nil { + if err := s.executionStopper.StopTask(cleanupCtx, taskID, "task deleted", true); err != nil { + s.logger.Warn("failed to stop task execution on delete", + zap.String("task_id", taskID), + zap.Error(err)) + } + } + + if len(worktrees) > 0 { + if cleaner, ok := s.worktreeCleanup.(interface { + CleanupWorktrees(context.Context, []*worktree.Worktree) error + }); ok { + if err := cleaner.CleanupWorktrees(cleanupCtx, worktrees); err != nil { + s.logger.Warn("failed to cleanup worktrees after delete", + zap.String("task_id", taskID), + zap.Error(err)) + } + } + } + + for _, session := range sessions { + if session == nil || session.ID == "" { + continue + } + if err := s.repo.DeleteExecutorRunningBySessionID(cleanupCtx, session.ID); err != nil { + s.logger.Debug("failed to delete executor runtime for session", + zap.String("task_id", taskID), + zap.String("session_id", session.ID), + zap.Error(err)) + } + } + + s.logger.Info("task delete cleanup finished", + zap.String("task_id", taskID), + zap.Duration("duration", time.Since(cleanupStart))) + }(id, sessions, worktrees) + } return nil } @@ -975,6 +1003,7 @@ func (s *Service) CreateRepository(ctx context.Context, req *CreateRepositoryReq WorktreeBranchPrefix: prefix, SetupScript: req.SetupScript, CleanupScript: req.CleanupScript, + DevScript: req.DevScript, } if err := s.repo.CreateRepository(ctx, repository); err != nil { @@ -1033,6 +1062,9 @@ func (s *Service) UpdateRepository(ctx context.Context, id string, req *UpdateRe if req.CleanupScript != nil { repository.CleanupScript = *req.CleanupScript } + if req.DevScript != nil { + repository.DevScript = *req.DevScript + } repository.UpdatedAt = time.Now().UTC() if err := s.repo.UpdateRepository(ctx, repository); err != nil { diff --git a/apps/backend/internal/worktree/manager.go b/apps/backend/internal/worktree/manager.go index 26b71dc991..e56ef6c80b 100644 --- a/apps/backend/internal/worktree/manager.go +++ b/apps/backend/internal/worktree/manager.go @@ -417,6 +417,38 @@ func (m *Manager) removeWorktree(ctx context.Context, wt *Worktree, removeBranch return nil } +// CleanupWorktrees removes provided worktrees without re-fetching from the store. +func (m *Manager) CleanupWorktrees(ctx context.Context, worktrees []*Worktree) error { + if len(worktrees) == 0 { + return nil + } + + var lastErr error + for _, wt := range worktrees { + if wt == nil { + continue + } + if err := m.removeWorktree(ctx, wt, true); err != nil { + m.logger.Warn("failed to remove worktree on task deletion", + zap.String("task_id", wt.TaskID), + zap.String("worktree_id", wt.ID), + zap.Error(err)) + lastErr = err + } + } + + m.mu.Lock() + for _, wt := range worktrees { + if wt == nil { + continue + } + delete(m.worktrees, wt.TaskID) + } + m.mu.Unlock() + + return lastErr +} + // OnTaskDeleted cleans up all worktrees for a task when it is deleted. func (m *Manager) OnTaskDeleted(ctx context.Context, taskID string) error { // Get all worktrees for this task @@ -425,28 +457,7 @@ func (m *Manager) OnTaskDeleted(ctx context.Context, taskID string) error { return err } - if len(worktrees) == 0 { - return nil - } - - // Remove each worktree and its branch - var lastErr error - for _, wt := range worktrees { - if err := m.removeWorktree(ctx, wt, true); err != nil { - m.logger.Warn("failed to remove worktree on task deletion", - zap.String("task_id", taskID), - zap.String("worktree_id", wt.ID), - zap.Error(err)) - lastErr = err - } - } - - // Clear from cache - m.mu.Lock() - delete(m.worktrees, taskID) - m.mu.Unlock() - - return lastErr + return m.CleanupWorktrees(ctx, worktrees) } // Reconcile syncs worktree state with active tasks on startup. diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index 452bd8c502..b53cc27c76 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -166,6 +166,13 @@ const ( ActionShellInput = "shell.input" // Send input to shell ActionSessionShellOutput = "session.shell.output" // Shell output notification (also used for exit with type: "exit") + // Session git status + ActionSessionGitStatus = "session.git.status" + + // Process runner actions + ActionSessionProcessOutput = "session.process.output" + ActionSessionProcessStatus = "session.process.status" + // Git worktree actions ActionWorktreePull = "worktree.pull" // Pull from remote ActionWorktreePush = "worktree.push" // Push to remote diff --git a/apps/packages/ui/package.json b/apps/packages/ui/package.json index 60f1d77298..75b8a613df 100644 --- a/apps/packages/ui/package.json +++ b/apps/packages/ui/package.json @@ -30,7 +30,7 @@ "next-themes": "^0.4.6", "radix-ui": "^1.4.3", "react-day-picker": "^9.13.0", - "react-resizable-panels": "^2.1.9", + "react-resizable-panels": "^4.4.1", "recharts": "2.15.4", "sonner": "^2.0.7", "tailwind-merge": "^2.6.0", diff --git a/apps/packages/ui/src/resizable.tsx b/apps/packages/ui/src/resizable.tsx index 7be8a0e349..2c7bcd8cb4 100644 --- a/apps/packages/ui/src/resizable.tsx +++ b/apps/packages/ui/src/resizable.tsx @@ -8,12 +8,12 @@ import { cn } from "./lib/utils" function ResizablePanelGroup({ className, ...props -}: React.ComponentProps) { +}: React.ComponentProps) { return ( - & { +}: React.ComponentProps & { withHandle?: boolean }) { return ( - div]:rotate-90", + "focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[orientation=vertical]:h-px data-[orientation=vertical]:w-full data-[orientation=vertical]:after:left-0 data-[orientation=vertical]:after:h-1 data-[orientation=vertical]:after:w-full data-[orientation=vertical]:after:translate-x-0 data-[orientation=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90 [&[data-orientation=vertical]>div]:rotate-90", className )} {...props} @@ -46,7 +46,7 @@ function ResizableHandle({ {withHandle && (
)} - + ) } diff --git a/apps/pnpm-lock.yaml b/apps/pnpm-lock.yaml index c6f5fb07f1..f1fe90b2aa 100644 --- a/apps/pnpm-lock.yaml +++ b/apps/pnpm-lock.yaml @@ -96,7 +96,7 @@ importers: version: 9.39.2(jiti@2.6.1) eslint-config-next: specifier: 16.1.1 - version: 16.1.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 16.1.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) tailwindcss: specifier: ^4.0.0 version: 4.1.18 @@ -197,8 +197,8 @@ importers: specifier: ^19.0.0 version: 19.2.3(react@19.2.3) react-resizable-panels: - specifier: ^2.1.9 - version: 2.1.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^4.4.1 + version: 4.4.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) recharts: specifier: 2.15.4 version: 2.15.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -357,8 +357,8 @@ importers: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.8)(react@19.2.3) react-resizable-panels: - specifier: ^2.1.9 - version: 2.1.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^4.4.1 + version: 4.4.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) recharts: specifier: 2.15.4 version: 2.15.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -404,7 +404,7 @@ importers: version: 9.39.2(jiti@2.6.1) eslint-config-next: specifier: 16.1.1 - version: 16.1.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 16.1.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) happy-dom: specifier: ^20.1.0 version: 20.1.0 @@ -4845,11 +4845,11 @@ packages: '@types/react': optional: true - react-resizable-panels@2.1.9: - resolution: {integrity: sha512-z77+X08YDIrgAes4jl8xhnUu1LNIRp4+E7cv4xHmLOxxUPO/ML7PSrE813b90vj7xvQ1lcf7g2uA9GeMZonjhQ==} + react-resizable-panels@4.4.1: + resolution: {integrity: sha512-dpM9oI6rGlAq7VYDeafSRA1JmkJv8aNuKySR+tZLQQLfaeqTnQLSM52EcoI/QdowzsjVUCk6jViKS0xHWITVRQ==} peerDependencies: - react: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc - react-dom: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 react-smooth@4.0.4: resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==} @@ -10748,7 +10748,7 @@ snapshots: optionalDependencies: '@types/react': 19.2.8 - react-resizable-panels@2.1.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + react-resizable-panels@4.4.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) diff --git a/apps/web/app/actions/workspaces.ts b/apps/web/app/actions/workspaces.ts index 05833d4ee2..36b102f789 100644 --- a/apps/web/app/actions/workspaces.ts +++ b/apps/web/app/actions/workspaces.ts @@ -186,6 +186,7 @@ export async function createRepositoryAction(payload: { worktree_branch_prefix: string; setup_script: string; cleanup_script: string; + dev_script: string; }) { return fetchJson(`${apiBaseUrl}/api/v1/workspaces/${payload.workspace_id}/repositories`, { method: 'POST', @@ -201,6 +202,7 @@ export async function createRepositoryAction(payload: { worktree_branch_prefix: payload.worktree_branch_prefix, setup_script: payload.setup_script, cleanup_script: payload.cleanup_script, + dev_script: payload.dev_script, }), }); } diff --git a/apps/web/app/s/[sessionId]/page.tsx b/apps/web/app/s/[sessionId]/page.tsx index 4f269ef3bb..934faca808 100644 --- a/apps/web/app/s/[sessionId]/page.tsx +++ b/apps/web/app/s/[sessionId]/page.tsx @@ -1,4 +1,5 @@ import { StateHydrator } from '@/components/state-hydrator'; +import { readLayoutDefaults } from '@/lib/layout/read-layout-defaults'; import { fetchBoardSnapshot, fetchTaskSession, @@ -21,6 +22,7 @@ export default async function SessionPage({ let initialState: ReturnType | null = null; let task: Task | null = null; let sessionId: string | null = null; + const defaultLayouts = await readLayoutDefaults(); try { const { sessionId: paramSessionId } = await params; @@ -155,6 +157,7 @@ export default async function SessionPage({ task={task} sessionId={sessionId} initialRepositories={initialState?.repositories?.itemsByWorkspaceId?.[task?.workspace_id ?? ''] ?? []} + defaultLayouts={defaultLayouts} /> ); diff --git a/apps/web/app/settings/workspace/workspace-repositories-client.tsx b/apps/web/app/settings/workspace/workspace-repositories-client.tsx index 5ee0854870..b0dc79349e 100644 --- a/apps/web/app/settings/workspace/workspace-repositories-client.tsx +++ b/apps/web/app/settings/workspace/workspace-repositories-client.tsx @@ -98,7 +98,8 @@ export function WorkspaceRepositoriesClient({ repo.default_branch !== saved.default_branch || repo.worktree_branch_prefix !== saved.worktree_branch_prefix || repo.setup_script !== saved.setup_script || - repo.cleanup_script !== saved.cleanup_script + repo.cleanup_script !== saved.cleanup_script || + repo.dev_script !== saved.dev_script ); }; @@ -200,6 +201,7 @@ export function WorkspaceRepositoriesClient({ worktree_branch_prefix: 'feature/', setup_script: '', cleanup_script: '', + dev_script: '', created_at: '', updated_at: '', scripts: [], @@ -272,6 +274,7 @@ export function WorkspaceRepositoriesClient({ worktree_branch_prefix: repo.worktree_branch_prefix, setup_script: repo.setup_script, cleanup_script: repo.cleanup_script, + dev_script: repo.dev_script, }); const scripts = await Promise.all( repo.scripts.map((script, index) => @@ -301,6 +304,7 @@ export function WorkspaceRepositoriesClient({ worktree_branch_prefix: repo.worktree_branch_prefix, setup_script: repo.setup_script, cleanup_script: repo.cleanup_script, + dev_script: repo.dev_script, }); const saved = savedRepositoriesById.get(repoId); diff --git a/apps/web/app/task/[id]/page-client.tsx b/apps/web/app/task/[id]/page-client.tsx index 3cd8b0c64a..7055625dba 100644 --- a/apps/web/app/task/[id]/page-client.tsx +++ b/apps/web/app/task/[id]/page-client.tsx @@ -15,17 +15,20 @@ import { useSessionAgentctl } from '@/hooks/use-session-agentctl'; import { useAppStore } from '@/components/state-provider'; import { fetchTask } from '@/lib/http'; import { useTasks } from '@/hooks/use-tasks'; +import type { Layout } from 'react-resizable-panels'; type TaskPageClientProps = { task: Task | null; sessionId?: string | null; initialRepositories?: Repository[]; + defaultLayouts?: Record; }; export default function TaskPage({ task: initialTask, sessionId = null, initialRepositories = [], + defaultLayouts = {}, }: TaskPageClientProps) { const [taskDetails, setTaskDetails] = useState(initialTask); const [isMounted, setIsMounted] = useState(false); @@ -72,6 +75,21 @@ export default function TaskPage({ effectiveSessionId ? state.taskSessions.items[effectiveSessionId]?.state ?? null : null ); const agentctlStatus = useSessionAgentctl(effectiveSessionId); + const previewOpen = useAppStore((state) => + effectiveSessionId ? state.previewPanel.openBySessionId[effectiveSessionId] ?? false : false + ); + const previewStage = useAppStore((state) => + effectiveSessionId ? state.previewPanel.stageBySessionId[effectiveSessionId] ?? 'closed' : 'closed' + ); + const previewUrl = useAppStore((state) => + effectiveSessionId ? state.previewPanel.urlBySessionId[effectiveSessionId] ?? '' : '' + ); + const devProcessId = useAppStore((state) => + effectiveSessionId ? state.processes.devProcessBySessionId[effectiveSessionId] : undefined + ); + const devProcessStatus = useAppStore((state) => + devProcessId ? state.processes.processesById[devProcessId]?.status ?? null : null + ); // Session resumption hook - handles auto-resume on page reload const { @@ -150,6 +168,11 @@ export default function TaskPage({ agentctl_ready: agentctlStatus.isReady, agentctl_error: agentctlStatus.errorMessage ?? null, agentctl_execution_id: agentctlStatus.agentExecutionId ?? null, + preview_open: previewOpen, + preview_stage: previewStage, + preview_url: previewUrl || null, + dev_process_id: devProcessId ?? null, + dev_process_status: devProcessStatus ?? null, }} /> )} @@ -167,12 +190,15 @@ export default function TaskPage({ worktreeBranch={worktreeBranch} repositoryPath={repository?.local_path ?? null} repositoryName={repository?.name ?? null} + hasDevScript={Boolean(repository?.dev_script?.trim())} />
diff --git a/apps/web/components/kanban-board.tsx b/apps/web/components/kanban-board.tsx index 1876e7a4f6..001c0ecc4e 100644 --- a/apps/web/components/kanban-board.tsx +++ b/apps/web/components/kanban-board.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useMemo, useState, useSyncExternalStore } from 'react'; +import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'; import { Column } from './kanban-column'; import { Task } from './kanban-card'; import { TaskCreateDialog } from './task-create-dialog'; @@ -44,14 +44,11 @@ export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {} useBoards(workspaceState.activeId, true); useBoardSnapshot(boardsState.activeId); - const { - settings: userSettings, - commitSettings, - selectedRepositoryIds, - } = useUserDisplaySettings({ - workspaceId: workspaceState.activeId, - boardId: boardsState.activeId, - onWorkspaceChange: (nextWorkspaceId) => { + const handleWorkspaceChange = useCallback( + (nextWorkspaceId: string | null) => { + if (nextWorkspaceId === workspaceState.activeId) { + return; + } setActiveWorkspace(nextWorkspaceId); if (nextWorkspaceId) { router.push(`/?workspaceId=${nextWorkspaceId}`); @@ -59,7 +56,14 @@ export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {} router.push('/'); } }, - onBoardChange: (nextBoardId) => { + [router, setActiveWorkspace, workspaceState.activeId] + ); + + const handleBoardChange = useCallback( + (nextBoardId: string | null) => { + if (nextBoardId === boardsState.activeId) { + return; + } setActiveBoard(nextBoardId); if (nextBoardId) { const workspaceId = boardsState.items.find((board) => board.id === nextBoardId)?.workspaceId; @@ -67,6 +71,21 @@ export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {} router.push(`/?boardId=${nextBoardId}${workspaceParam}`); } }, + [boardsState.activeId, boardsState.items, router, setActiveBoard] + ); + + const stableWorkspaceId = workspaceState.activeId; + const stableBoardId = boardsState.activeId; + + const { + settings: userSettings, + commitSettings, + selectedRepositoryIds, + } = useUserDisplaySettings({ + workspaceId: stableWorkspaceId, + boardId: stableBoardId, + onWorkspaceChange: handleWorkspaceChange, + onBoardChange: handleBoardChange, }); const isMounted = useSyncExternalStore( diff --git a/apps/web/components/settings/repository-card.tsx b/apps/web/components/settings/repository-card.tsx index 2014b7e2fd..e37e5f5c26 100644 --- a/apps/web/components/settings/repository-card.tsx +++ b/apps/web/components/settings/repository-card.tsx @@ -59,11 +59,13 @@ export function RepositoryCard({ const worktreeBranchPrefix = repository.worktree_branch_prefix ?? ''; const setupScript = repository.setup_script ?? ''; const cleanupScript = repository.cleanup_script ?? ''; + const devScript = repository.dev_script ?? ''; const isDirty = isRepositoryDirty || areScriptsDirty; const scriptsCount = repository.scripts.length; const hasSetupScript = Boolean(setupScript.trim()); const hasCleanupScript = Boolean(cleanupScript.trim()); - const showScriptsSummary = scriptsCount > 0 || hasSetupScript || hasCleanupScript; + const hasDevScript = Boolean(devScript.trim()); + const showScriptsSummary = scriptsCount > 0 || hasSetupScript || hasCleanupScript || hasDevScript; const scriptsLabel = scriptsCount === 0 ? 'No custom scripts' : `${scriptsCount} custom script${scriptsCount === 1 ? '' : 's'}`; @@ -196,6 +198,20 @@ export function RepositoryCard({ +
+ +